What's wrong
ExecuteAsync without a cancellation token awaits the output reads before it awaits the exit and returns the exit code (RunCommand/RunCommand.cs ~line 490 at 288554a). The reads in AsyncProcessStreamReader.ReadToEnd (~lines 142–155) loop until the pipe reports end of stream. DrainOrAbandon (~lines 84–111) waits for both reads to finish, and only a cancelled token can stop that wait.
A background descendant inherits the stdout/stderr handles, so the pipe stays open after the command itself exits. With no cancellation token, the call then waits exactly as long as that descendant lives.
Reproduction (observed, Linux, net10.0)
List<string> lines = [];
var sw = Stopwatch.StartNew();
Task<int> t = RunCommand.ExecuteAsync("sh", ["-c", "echo started; sleep 8 & exit 3"], new LineOutputHandler(lines.Add));
await Task.WhenAny(t, Task.Delay(TimeSpan.FromSeconds(20)));
// actual: finished=True elapsed=8074ms exit=3 lines=started
// expected: returns ~immediately after sh exits, exit=3, lines=started
With sleep 1000000 &, or a real daemon, the call never returns.
Why it matters
Plenty of ordinary commands leave long-lived children that inherit the output handles:
dotnet build MSBuild node reuse and VBCSCompiler
- Gradle daemons
ssh-agent
- git fsmonitor
- any
cmd & in a script
For these, a caller that doesn't pass a token hangs with no timeout and no error, even though the exit code has been available since the command exited. CLAUDE.md documents that a descendant keeping the pipe open "cannot hang a cancelled call" (#78). The uncancelled path is still unbounded.
Suggested fix / acceptance criteria
- Once
process.WaitForExitAsync() completes, give the reads a short grace period to drain what the command left in the pipe, then abandon them through the existing Abandon path that cancellation uses. This mirrors .NET's own Process.WaitForExit handling of redirected streams. For example, add process.WaitForExitAsync().ContinueWith(_ => Task.Delay(grace)).Unwrap() as a third "stop" task next to cancelled in DrainOrAbandon.
- Output the command wrote before exiting is still delivered in full.
- Add a regression test:
sh -c "echo x; sleep 30 &" returns in well under 30 s with output x and exit code 0. On Windows, use an equivalent start /b case.
What's wrong
ExecuteAsyncwithout a cancellation token awaits the output reads before it awaits the exit and returns the exit code (RunCommand/RunCommand.cs~line 490 at 288554a). The reads inAsyncProcessStreamReader.ReadToEnd(~lines 142–155) loop until the pipe reports end of stream.DrainOrAbandon(~lines 84–111) waits for both reads to finish, and only a cancelled token can stop that wait.A background descendant inherits the stdout/stderr handles, so the pipe stays open after the command itself exits. With no cancellation token, the call then waits exactly as long as that descendant lives.
Reproduction (observed, Linux, net10.0)
With
sleep 1000000 &, or a real daemon, the call never returns.Why it matters
Plenty of ordinary commands leave long-lived children that inherit the output handles:
dotnet buildMSBuild node reuse and VBCSCompilerssh-agentcmd &in a scriptFor these, a caller that doesn't pass a token hangs with no timeout and no error, even though the exit code has been available since the command exited. CLAUDE.md documents that a descendant keeping the pipe open "cannot hang a cancelled call" (#78). The uncancelled path is still unbounded.
Suggested fix / acceptance criteria
process.WaitForExitAsync()completes, give the reads a short grace period to drain what the command left in the pipe, then abandon them through the existingAbandonpath that cancellation uses. This mirrors .NET's ownProcess.WaitForExithandling of redirected streams. For example, addprocess.WaitForExitAsync().ContinueWith(_ => Task.Delay(grace)).Unwrap()as a third "stop" task next tocancelledinDrainOrAbandon.sh -c "echo x; sleep 30 &"returns in well under 30 s with outputxand exit code 0. On Windows, use an equivalentstart /bcase.