Skip to content

fix(shell): drain stdout before reading output to avoid "(no output)" on exit 0#35543

Open
chethanuk wants to merge 1 commit into
anomalyco:devfrom
chethanuk:fix/issue-35511-shell-no-output-race
Open

fix(shell): drain stdout before reading output to avoid "(no output)" on exit 0#35543
chethanuk wants to merge 1 commit into
anomalyco:devfrom
chethanuk:fix/issue-35511-shell-no-output-race

Conversation

@chethanuk

Copy link
Copy Markdown

Issue for this PR

Closes #35511 (staged on fork against dev).

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

The shell tool can return (no output) with exit 0 on the first call, even when the command clearly printed to stdout.

Root cause. The merged stdout+stderr reader runs in a forkScoped fiber, but the main flow only awaits handle.exitCode. When the process exits, the enclosing Effect.scoped block closes and interrupts the reader fiber before it has drained the buffered chunks to EOF, so the assembled list is empty and the tool reports (no output) despite a clean exit.

Fix. Capture the reader fiber and, on the clean-exit branch, Fiber.join it so the merged stream drains to EOF before the scope closes. A backgrounded child can keep the stdout/stderr write end open past the parent's exit, so the join is bounded by the same abort/timeout races to avoid hanging forever. Because the process already exited cleanly on this branch, a cut-short drain is not relabelled as aborted/terminated — that would contradict the clean exit code and print a misleading "terminated after exceeding timeout" message.

Ordering that the fix guarantees:

sequenceDiagram
    participant Tool as ShellTool
    participant Proc as Process
    participant Reader as reader fiber (forkScoped)
    participant Scope as Effect.scoped

    Tool->>Proc: spawn(command)
    Tool->>Reader: forkScoped(Stream.runForEach handle.all)
    Tool->>Tool: raceAll [exitCode, abort, timeout]
    Proc-->>Tool: exitCode resolves (exit.kind = exit)
    Note over Tool,Reader: Fiber.join(reader), bounded by abort/timeout
    Reader-->>Tool: buffered stdout/stderr drained to EOF
    Tool->>Scope: scope closes (reader already finished)
    Note over Tool: output assembled from full list
    Note over Tool,Scope: Before: scope closed on exit, reader interrupted mid-buffer -> "(no output)"
Loading

How did you verify your code works?

  • bun run typecheck (tsgo, packages/opencode) — passes.
  • bun test test/tool/shell.test.ts — 24 pass / 0 fail. Includes a new regression that runs echo hi 10x and asserts exit 0 with output (and metadata.output) never equal to (no output); this reliably reproduced the race before the fix.

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

… on exit 0

The output reader was forked with forkScoped but only handle.exitCode was awaited, so the scope could close and interrupt the reader before buffered stdout/stderr drained to EOF, yielding "(no output)" on a clean exit 0 (anomalyco#35511).

Capture the reader fiber and, on a clean exit, join it so the merged stream drains before the scope closes. Bound the best-effort drain with the abort/timeout races so a backgrounded child holding the pipe open cannot hang the tool; a clean exit is never relabelled aborted/terminated if the drain is cut short.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shell tool can return (no output) with exit 0 on first call when using workdir

1 participant