Cancelling a run could hang forever. AsyncProcessStreamReader waited for both
pipes to reach end of stream, and end of stream means every handle on the write
end has closed. Killing the command closes only the handles the command itself
held, so a descendant that inherited one and outlived its parent kept the pipe
open and the wait never ended -- contradicting the documented contract that
cancellation kills the process and lets the await proceed.
Start now takes the cancellation token and races each wait against it, giving up
on a pending read rather than trying to cancel it. Cancelling a read is not an
option worth relying on here: StreamReader has no cancellable overload on every
target, and a pipe read is not reliably interruptible even where one exists.
Abandoned reads get a continuation that observes their eventual fault, so a
cancelled run cannot trip TaskScheduler.UnobservedTaskException later.
The netstandard2.0/2.1 targets were hit harder, since only a single-process kill
is available there and even a non-detached child triggered the same wait.
Fixes #78.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wi82CWVJxLjZR27vTkenFm
Fixes #78.
The bug
AsyncProcessStreamReader.Startpumped both pipes until each reached end of stream. End of stream means every handle on the write end has closed — and killing the command closes only the handles the command itself held. A descendant that inherited stdout/stderr and outlived its parent keeps the pipe open, so the wait never ends.RunAsyncawaitsTask.WhenAll(outputReader.Start(), process.WaitForExitAsync(cancellationToken)). The token reached the wait but never reached the reads:ReadAndCallbackcallsstreamReader.ReadAsync(buffer, 0, buffer.Length), which returns on new data or EOF and nothing else. So on cancellation the process died,WaitForExitAsynccame back, andWhenAllsat on a read nobody was going to finish — contradicting the library's documented contract that cancellation kills the process and lets the await proceed.Reproduced on
main, before any change here:The inner shell backgrounds a
sleepand exits, so thatsleepis reparented to init andKill(entireProcessTree: true)cannot walk to it — but it still holds the stdout and stderr handles it inherited. The outersleepkeeps the direct child alive so cancellation is what ends it.Worth recording, since it cost a detour:
setsidis not sufficient to reproduce this. It gives the child its own session but leaves its parent alone (ppidunchanged), so the tree kill still reaches it and the pipe still closes. Measured in this container —setsid sleep 25 &from a shell at pid 922 producedpid 924, ppid 922, sess 924. A test written that way passes onmainand proves nothing. The double-fork above givesppid 1, which is the condition that matters.The fix
The issue offers two routes: pass the token into
ReadAsync, or race the read against a cancellation-linked task. This takes the second, and the choice is deliberate rather than convenient —StreamReaderhas no cancellable overload on every target here, and a pipe read is not reliably interruptible even where one exists. Racing works uniformly across all five TFMs, including thenetstandard2.xones the issue notes are hit hardest.Starttakes theCancellationTokenand races every wait against aTaskCompletionSourcethe token completes: theWhenAnyin the read loop, the drain after the loop, and the post-exit final read.RunAsync's existing trailingThrowIfCancellationRequested()to raiseOperationCanceledException— so the cancellation path stays in one place rather than gaining a second thrower.TaskScheduler.UnobservedTaskExceptionon finalization — which would have turned this fix into an intermittent failure somewhere unrelated.awaits the reads on the non-cancelled path, so a decode failure is propagated exactly as before. That is load-bearing:ADecodeFailureIsNotDiscardedWhenTheProcessKeepsRunningand the byte-order-mark tests depend on it.No public API change —
AsyncProcessStreamReaderisinternal.Tests
ExecuteAsyncShouldReturnWhenCancelledWhileADetachedDescendantHoldsTheOutputPipeinRunCommandTests, the regression the issue asks for. Verified both ways:It is bounded with
Task.WhenAny(execution, Task.Delay(10s))and asserts on which task completed, rather than a bareawait: without the fix a bare await hangs, which takes the whole run down instead of reporting a failure. It then assertsOperationCanceledException, so a call that returns an exit code does not pass either.Skipped on Windows with
Assert.Inconclusive, matching the existing precedent inADecodeFailureIsNotDiscardedWhenTheProcessKeepsRunning— it needs a shell that can orphan a child out of its own process tree. The code being fixed is platform independent, so the other legs cover it.Full suite: 40 total, 39 passed, 0 failed, 1 skipped (the pre-existing Windows-only elevation skip). Release build of the solution across all five target frameworks (net10.0, net9.0, net8.0, netstandard2.0, netstandard2.1): 0 warnings, 0 errors.
Scope
One method reshaped, two small private helpers added, one call site updated, one test. Deliberately not in scope: a read that never ends at all still costs its buffer until the process handle is released. Reclaiming that would mean owning the pipe lifetime rather than borrowing
Process's, which is a larger change than the hang warrants — the contract this fixes is that a cancelled call returns, and it now does.Worth knowing downstream: this is the
DrainTimeoutconcern raised as the third caveat on ktsu-dev/GitBranchStateCache#27, whereGitRunner's hand-rolled bounded drain exists precisely because a grandchild can hold the pipe open. That caveat is addressed by this change.🤖 Generated with Claude Code
https://claude.ai/code/session_01Wi82CWVJxLjZR27vTkenFm
Generated by Claude Code