refactor: modernize RunProcessAsync with WaitForExitAsync#1790
refactor: modernize RunProcessAsync with WaitForExitAsync#1790JamieMagee wants to merge 1 commit intomainfrom
Conversation
Replace the legacy TaskCompletionSource + cold-Task pattern in CommandLineInvocationService.RunProcessAsync with Process.WaitForExitAsync(CancellationToken) (.NET 5+) and StreamReader.ReadToEndAsync(CancellationToken) (.NET 7+). Cancellation now kills the entire process tree and propagates OperationCanceledException. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Refactors CommandLineInvocationService.RunProcessAsync in the Common library to use modern .NET Process async APIs for more reliable non-blocking execution and improved cancellation handling across all detectors that shell out to external tools.
Changes:
- Replaces
Process.Exited+TaskCompletionSourcecompletion withProcess.WaitForExitAsync(CancellationToken). - Replaces
new Task(...).Start()stream draining with concurrentStreamReader.ReadToEndAsync(...). - On cancellation, kills the entire process tree and rethrows
OperationCanceledException; removes redundant overload.
Show a summary per file
| File | Description |
|---|---|
| src/Microsoft.ComponentDetection.Common/CommandLineInvocationService.cs | Modernizes process execution, output/error capture, and cancellation behavior for all command-line invocations. |
Copilot's findings
Comments suppressed due to low confidence (1)
src/Microsoft.ComponentDetection.Common/CommandLineInvocationService.cs:182
- The updated cancellation behavior (waiting via
WaitForExitAsync, killing the entire process tree on cancel, and rethrowingOperationCanceledException) is not covered by the existingCommandLineInvocationServiceTests. Adding tests that (1) cancel a long-running process and assert the process is terminated and (2) ensure late cancellation after process exit doesn’t turn a successful run into anOperationCanceledExceptionwould help prevent regressions in this high-impact code path.
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
try
{
process.Kill(entireProcessTree: true);
}
catch (InvalidOperationException)
{
// Process already exited.
}
throw;
}
- Files reviewed: 1/1 changed files
- Comments generated: 1
| var stdOutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); | ||
| var stdErrTask = process.StandardError.ReadToEndAsync(cancellationToken); |
There was a problem hiding this comment.
ReadToEndAsync(cancellationToken) can throw OperationCanceledException if the token is canceled after WaitForExitAsync completes but before the stream-read tasks finish. That can cause this method to surface cancellation even though the process already exited successfully. Consider not passing the external cancellation token to the output/error reads (or switch to a separate token that is only canceled when you actually intend to abort the read), and rely on cancellation to kill the process instead.
This issue also appears on line 166 of the same file.
| var stdOutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); | |
| var stdErrTask = process.StandardError.ReadToEndAsync(cancellationToken); | |
| // Do not pass the external cancellation token here: cancellation is handled by | |
| // WaitForExitAsync below, which kills the process. Once the process exits, these | |
| // reads should be allowed to complete so we do not surface cancellation after a | |
| // successful process exit. | |
| var stdOutTask = process.StandardOutput.ReadToEndAsync(); | |
| var stdErrTask = process.StandardError.ReadToEndAsync(); |
What
Rewrites
CommandLineInvocationService.RunProcessAsyncto use the modern .NET process APIs:Process.WaitForExitAsync(CancellationToken)(.NET 5+) instead of aTaskCompletionSourcedriven by theProcess.Exitedevent.StreamReader.ReadToEndAsync(CancellationToken)(.NET 7+) instead ofnew Task(() => ReadToEnd()).Start()(an anti-pattern that scheduled cold tasks on the default scheduler).process.Kill(entireProcessTree: true)) and re-throwsOperationCanceledException.The two private overloads collapse into a single signature with an optional
CancellationToken. The.cmd/.bat→cmd /Cshim is preserved unchanged.Why
The previous implementation predates
WaitForExitAsyncand used several patterns that are no longer recommended:new Task(() => ...).Start()— cold tasks bypass async/await scheduling and are easy to get wrong.Task.WaitAll(t1, t2)inside an event handler — synchronously blocks a threadpool thread.TaskCompletionSourceto bridge the event-based completion — entirely replaced byWaitForExitAsync.The new code is roughly half the size, propagates cancellation correctly, and avoids blocking threadpool threads.
Risk
This is the highest-risk PR in the cleanup series — every detector that shells out (
gradle,maven,mvn-cli,pip,go,rust,vcpkg,pnpm, etc.) goes through this code path. The full test suite passes locally.Part 6 of a 6-PR cleanup series removing .NET-Framework-era anachronisms.