Skip to content

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

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

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

Conversation

@chethanuk

@chethanuk chethanuk commented Jul 6, 2026 •

Copy link
Copy Markdown
Owner

Issue for this PR

Closes anomalyco#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

@codeant-ai

codeant-ai Bot commented Jul 6, 2026

Copy link
Copy Markdown

CodeAnt AI is reviewing your PR.

@codeant-ai

codeant-ai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Thanks for using CodeAnt! 🎉

We're free for open-source projects. if you're enjoying it, help us grow by sharing.

Share on X ·
Reddit ·
LinkedIn

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

@chethanuk, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8f170095-5258-462e-8816-bae13e0c3859

📥 Commits

Reviewing files that changed from the base of the PR and between 3a149ba and 13c9c38.

📒 Files selected for processing (2)
  • packages/opencode/src/tool/shell.ts
  • packages/opencode/test/tool/shell.test.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-35511-shell-no-output-race

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codeant-ai codeant-ai Bot added the size:S This PR changes 10-29 lines, ignoring generated files label Jul 6, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses issue anomalyco#35511 by capturing the reader fiber and joining it on a clean exit to ensure that the merged stdout/stderr stream drains to EOF before the scope closes, preventing lost buffered output. A regression test is also added to verify this behavior. The reviewer pointed out a potential issue where the tool could hang indefinitely if a background process keeps the stdout/stderr streams open, since Fiber.join(reader) is called outside of the race block. They suggested racing the join with abort and timeout effects to prevent this hang.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread packages/opencode/src/tool/shell.ts Resolved
@greptile-apps

greptile-apps Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR fixes the (no output) regression on exit-0 shell commands by capturing the forkScoped reader fiber and joining it before the Effect.scoped block closes, ensuring buffered stdout/stderr chunks are fully drained. A three-way race (join vs abort vs fresh timeout) guards the drain phase against backgrounded children holding the pipe open indefinitely.

  • shell.ts: Captures the reader fiber, adds a post-exit drain step with a Fiber.join raced against abort and a fresh timeout; sets aborted/expired flags if the drain is cut short.
  • shell.test.ts: Adds a 10-iteration regression loop asserting both result.output and result.metadata.output contain the echoed text, catching the original race reliably.

Confidence Score: 4/5

The core drain fix is sound, but setting expired = true when only the post-exit drain times out causes a false "shell tool terminated command" message for a process that exited cleanly — worth addressing before merging.

The drain-timeout path sets the same expired flag that the kill-on-timeout path uses, so the tool emits a misleading "terminated command after exceeding timeout" message when a backgrounded child holds the pipe open past the drain window. The process exited cleanly and was never killed, but the metadata tells the caller otherwise.

packages/opencode/src/tool/shell.ts — specifically the if (drained === "timeout") expired = true line inside the drain block.

Important Files Changed

Filename Overview
packages/opencode/src/tool/shell.ts Captures the forkScoped reader fiber and joins it on clean exit to drain buffered output; introduces a three-way race (join vs abort vs fresh timeout) for the drain phase, but reuses the expired flag when the drain times out, which causes a misleading "terminated command" metadata message for a process that exited cleanly.
packages/opencode/test/tool/shell.test.ts Adds a 10-iteration regression test for anomalyco#35511 that asserts both result.output and result.metadata.output contain the expected text and are not the sentinel "(no output)" string; addresses the field-consistency concern raised in the previous review thread.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Scope as Effect.scoped
    participant Spawner as spawner.spawn
    participant Reader as forkScoped reader fiber
    participant Race1 as raceAll (exit / abort / timeout)
    participant Race2 as drain race (join / abort / timeout)

    Scope->>Spawner: spawn process
    Scope->>Reader: fork Stream.runForEach (stdout+stderr)
    Scope->>Race1: race exitCode vs abort vs timeout

    alt "exit.kind === exit"
        Race1-->>Scope: process exited cleanly
        Scope->>Race2: Fiber.join(reader) race abort race fresh-timeout
        alt reader drains to EOF
            Race2-->>Scope: "drained = exit"
        else abort fires during drain
            Race2-->>Scope: "drained = abort → aborted = true"
        else fresh timeout fires during drain
            Race2-->>Scope: "drained = timeout → expired = true (misleading)"
        end
    else "exit.kind === abort"
        Race1-->>Scope: abort signal
        Scope->>Spawner: kill forceKillAfter 3s
    else "exit.kind === timeout"
        Race1-->>Scope: timeout elapsed
        Scope->>Spawner: kill forceKillAfter 3s
    end

    Scope->>Scope: assemble output from list
    Note over Scope: if expired append terminated message
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Scope as Effect.scoped
    participant Spawner as spawner.spawn
    participant Reader as forkScoped reader fiber
    participant Race1 as raceAll (exit / abort / timeout)
    participant Race2 as drain race (join / abort / timeout)

    Scope->>Spawner: spawn process
    Scope->>Reader: fork Stream.runForEach (stdout+stderr)
    Scope->>Race1: race exitCode vs abort vs timeout

    alt "exit.kind === exit"
        Race1-->>Scope: process exited cleanly
        Scope->>Race2: Fiber.join(reader) race abort race fresh-timeout
        alt reader drains to EOF
            Race2-->>Scope: "drained = exit"
        else abort fires during drain
            Race2-->>Scope: "drained = abort → aborted = true"
        else fresh timeout fires during drain
            Race2-->>Scope: "drained = timeout → expired = true (misleading)"
        end
    else "exit.kind === abort"
        Race1-->>Scope: abort signal
        Scope->>Spawner: kill forceKillAfter 3s
    else "exit.kind === timeout"
        Race1-->>Scope: timeout elapsed
        Scope->>Spawner: kill forceKillAfter 3s
    end

    Scope->>Scope: assemble output from list
    Note over Scope: if expired append terminated message
Loading

Reviews (2): Last reviewed commit: "fix(shell): drain stdout before reading ..." | Re-trigger Greptile

Comment thread packages/opencode/src/tool/shell.ts Resolved
Comment thread packages/opencode/src/tool/shell.ts Resolved
@codeant-ai

codeant-ai Bot commented Jul 6, 2026

Copy link
Copy Markdown

CodeAnt AI finished reviewing your PR.

@chethanuk
chethanuk force-pushed the fix/issue-35511-shell-no-output-race branch from d779212 to 895e46d Compare July 6, 2026 07:46
… 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.
@chethanuk
chethanuk force-pushed the fix/issue-35511-shell-no-output-race branch from 895e46d to 13c9c38 Compare July 6, 2026 11:13

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chethanuk has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

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

Labels

size:S This PR changes 10-29 lines, ignoring generated files

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