Skip to content

ExecuteAsync doesn't return after the command exits while a background child it started still holds stdout/stderr: sh -c "echo x; sleep 1000 &" blocks for the sleep's lifetime #100

Description

@matt-edmondson

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions