Skip to content

fix(cli): end output quietly when the stdout reader closes - #7175

Open
avallete wants to merge 8 commits into
developfrom
avallete/cli-stdout-broken-pipe
Open

avallete wants to merge 8 commits into
developfrom
avallete/cli-stdout-broken-pipe

Conversation

@avallete

@avallete avallete commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

TL;DR

When the process reading the CLI's output exits early (supabase … | head, or an agent that stops reading), the CLI crashed with Unknown: Stdio.stdout and exited 1. With JSON output and a reader that closed before the first write, it hung forever. Now a closed stdout ends the command cleanly with exit 0, the way a Unix tool stops on a broken pipe.

Before

flowchart LR
  A["reader exits early<br/>(e.g. | head)"] --> B[stdout write fails: EPIPE]
  B --> C[defect]
  C -->|text output| D["Unknown: Stdio.stdout<br/>exit 1"]
  C -->|JSON output| E[error envelope written<br/>to closed stdout] --> F[can hang forever]
Loading

After

flowchart LR
  A["reader exits early<br/>(e.g. | head)"] --> B[stdout write fails: EPIPE]
  B --> C[StdoutClosedError ends the command<br/>through its finalizers]
  C --> D[later stdout writes skipped]
  D --> E[exit 0, no error output]
Loading

Why

Since #6212, all three output layers write through the Effect Stdio sink. The sink reports a write to a closed pipe as PlatformError: Unknown: Stdio.stdout (cause EPIPE), and the shared writer turned that into a defect. It shows up across many commands (db query, status, functions serve, migration list, …), mostly from agents.

In JSON output the run's error renderer then writes the error envelope to the same closed stdout. A closed stream never emits drain or error again, so that write can wait forever. 2.120.0 behaves the same way, so this is not a regression from the latest release.

What changed

  • A broken pipe on stdout ends the command with StdoutClosedError. The error carries exit code 0 through every finalizer, so follow-style commands such as stack logs --follow | head stop too, and no error is printed.
  • Every output layer on the same process streams shares the closed state, so later stdout writes, including the final error renderer's, are skipped instead of hanging.
  • A command that already failed keeps its exit code: the JSON error is dropped when stdout is closed, and no report, upgrade notice, or success trailer runs after the reader is gone.
  • A broken pipe on stderr only drops diagnostics; stdout keeps working.
  • Any other write failure still surfaces as a defect.
  • Telemetry records the new tag:StdoutClosedError fingerprint with the cancelled category.

Terminal captures

Recorded with vhs against a local stack, running develop (before) and this branch (after) from source. exit= shows the CLI's own exit code from $pipestatus.

Text output, | head -n 3

Before After
before-text after-text

JSON output, reader closed before the first write

Before After
before-json after-json

🤖 Generated with Claude Code

A reader that exits before the CLI finishes writing (for example `| head`)
made the stdout write fail with EPIPE. The CLI crashed with
"Unknown: Stdio.stdout", and in JSON output it then wrote the error envelope
to the same closed stdout and hung forever.

The shared stdio writer now treats EPIPE as the end of that stream: the
failed write succeeds, later writes to the stream are skipped, and the
command finishes with exit 0. Other write failures still surface as defects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@avallete
avallete requested a review from a team as a code owner October 15, 2026 09:49
@avallete

Copy link
Copy Markdown
Member Author

/ai-review

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Superseded by a newer AI review

🤖 AI Review

Confirmed Claude's single test-coverage nit. Codex reported no findings, and code inspection found no additional correctness issues. Tests were not run; dependencies are absent from the checkout.

Findings

Severity Location Category Sources Claim
⚪ NIT apps/cli/src/shared/output/output.layer.unit.test.ts:804 test-coverage claude The new failure tests exercise only jsonOutputLayer. They do not directly cover EPIPE handling through the text layer's spinner wrapper or verify that stderr remains writable after stdout closes.

Stats

Claude findings: 1 · Codex findings: 0 · Confirmed: 1 · Refuted: 0 · Uncertain: 0


Models: claude-opus-5-5 + gpt-6.1-sol · Trigger: manual · Workflow run

This review runs once per PR. A maintainer can request another with a /ai-review comment.

Comment thread apps/cli/src/shared/output/output.layer.unit.test.ts Outdated

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Superseded by a newer AI review

🤖 AI Review

Both independent reviews completed; Codex reported no findings. Code inspection confirms Claude's streaming-lifecycle regression and test-coverage nit. Runtime tests were not run because dependencies are absent.

Findings

Severity Location Category Sources Claim
🟠 MAJOR apps/cli/src/shared/output/output.layer.ts:76 error-handling claude Swallowing stdout EPIPE without signalling cancellation leaves streaming commands such as `supabase stack logs --follow
⚪ NIT apps/cli/src/shared/output/output.layer.unit.test.ts:804 test-coverage claude The new EPIPE and EIO regression tests exercise only jsonOutputLayer, leaving text and stream-json without explicit coverage of the broken-pipe contract.

Stats

Claude findings: 2 · Codex findings: 0 · Confirmed: 2 · Refuted: 0 · Uncertain: 0


Models: claude-opus-5-5 + gpt-6.1-sol · Trigger: auto · Workflow run

This review runs once per PR. A maintainer can request another with a /ai-review comment.

Comment thread apps/cli/src/shared/output/output.layer.ts Outdated
Comment thread apps/cli/src/shared/output/output.layer.unit.test.ts Outdated
Skipping writes after a broken pipe left follow-style commands such as
`stack logs --follow | head` running with their output discarded. A closed
stdout now ends the command with StdoutClosedError, which carries exit code
0 through every finalizer, so the run stops cleanly. The closed state is
shared by every output layer on the same process streams, so the final error
renderer can no longer hang on a dead stdout. A closed stderr only drops
diagnostics.

Broken-pipe behavior is covered across the text, json and stream-json
layers, and an e2e test closes the real stdout pipe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@avallete

Copy link
Copy Markdown
Member Author

/ai-review

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Superseded by a newer AI review

🤖 AI Review

Verified all four Claude findings against the PR code. Confirmed both error-handling gaps and the post-success work after stdout closes. Refuted the WeakMap-comment finding because the root command provides its own Output layer. Codex's independent review completed with no findings. Runtime tests were unavailable because dependencies are absent.

Findings

Severity Location Category Sources Claim
🟠 MAJOR apps/cli/src/shared/output/output.layer.ts:87 error-handling claude A failed command can exit 0 when its JSON error envelope is the first write to closed stdout. The StdoutClosedError defect aborts withJsonErrorHandling before it records the original error's exit code.
🟡 MINOR apps/cli/src/shared/output/output.layer.ts:87 error-handling claude A first stdout EPIPE during the runner's final JSON error rendering escapes as an uncaught StdoutClosedError instead of terminating quietly through processControl.exit.
🟡 MINOR apps/cli/src/shared/output/errors.ts:38 behavior claude Aborting command execution on closed stdout still triggers post-success hooks and queued success trailers, potentially performing a release check and writing notices after the output abort.
Refuted findings (kept for transparency, not posted as review comments)
  • apps/cli/src/shared/output/output.layer.ts:66 (documentation): The WeakMap comment overstates its purpose because command execution and final-error rendering use the same Output instance.
    Refuted: The finding overlooks the root command's Command.provide layer. root.ts:254-264 supplies command output independently of the runner's layer, including a distinct quiet-progress wrapper for machine output flags. The claimed absence of command-level Output provisioning is contradicted by this code.

Stats

Claude findings: 4 · Codex findings: 0 · Confirmed: 3 · Refuted: 1 · Uncertain: 0


Models: claude-opus-5-5 + gpt-6.1-sol · Trigger: manual · Workflow run

This review runs once per PR. A maintainer can request another with a /ai-review comment.

Comment thread apps/cli/src/shared/output/output.layer.ts
Comment thread apps/cli/src/shared/output/output.layer.ts
Comment thread apps/cli/src/shared/output/errors.ts
A failing command whose JSON error envelope was the first write to a closed
stdout could exit 0, because the envelope's StdoutClosedError replaced the
failure before its exit code was recorded. The exit code is now set before
the envelope is written, and a closed stdout while rendering the final error
is ignored instead of escaping as an uncaught defect.

A run that ends because stdout closed now skips the failure report, the
post-success hook and the success trailers, since nobody is left to read
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@avallete

Copy link
Copy Markdown
Member Author

/ai-review

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Superseded by a newer AI review

🤖 AI Review

Verified all three findings against the checked-out code. The duplicate exit-code reports merge into one confirmed major bug; the E2E assertion gap is also confirmed as minor.

Findings

Severity Location Category Sources Claim
🟠 MAJOR apps/cli/src/shared/cli/run.ts:752 error-handling claude+codex A broken pipe can suppress an independent command or cleanup failure and produce exit 0 when no explicit exit code was recorded. In text mode, it also suppresses the failure diagnostic despite stderr remaining open.
🟡 MINOR apps/cli/src/shared/cli/run.e2e.test.ts:143 test-coverage codex The successful broken-pipe E2E cases would allow a leaked StdoutClosedError diagnostic.

Stats

Claude findings: 1 · Codex findings: 2 · Confirmed: 2 · Refuted: 0 · Uncertain: 0


Models: claude-opus-5-5 + gpt-6.1-sol · Trigger: manual · Workflow run

This review runs once per PR. A maintainer can request another with a /ai-review comment.

Comment thread apps/cli/src/shared/cli/run.ts Outdated
Comment thread apps/cli/src/shared/cli/run.e2e.test.ts
A closed stdout skipped every report, so a typed failure or a cleanup
defect in the same run could exit 0 without a diagnostic. The runner now
sets aside only the closed stdout and the interruptions it caused, and
classifies and reports whatever failure remains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@avallete

Copy link
Copy Markdown
Member Author

/ai-review

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Superseded by a newer AI review

🤖 AI Review

Reconciled both review outputs against the checked-out code and trusted conventions. Claude's single minor finding remains uncertain: the EPIPE-only check is verified, but the claimed alternative errors on supported runtimes are unproven. Codex reported no findings, and this pass found no additional concrete issues. Tests were not run because dependencies are absent.

Findings

Severity Location Category Sources Claim
🟡 MINOR apps/cli/src/shared/output/output.layer.ts:61 error-handling claude Closed-reader errors reported as ERR_STREAM_DESTROYED or EOF would bypass the EPIPE-only check and become generic Stdio defects. The initial closed-stream check would not protect writes already in flight.

Stats

Claude findings: 1 · Codex findings: 0 · Confirmed: 0 · Refuted: 0 · Uncertain: 1


Models: claude-opus-5-5 + gpt-6.1-sol · Trigger: manual · Workflow run

This review runs once per PR. A maintainer can request another with a /ai-review comment.

Comment thread apps/cli/src/shared/output/output.layer.ts
libuv reports a write to a pipe whose reader exited as EOF on Windows, so
that case now ends output the same way as EPIPE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@jgoux jgoux left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two findings inline: the failure-path e2e cases cannot detect a hang regression (P2), and a co-occurring closed stdout masks a real failure's telemetry classification (P3).

Comment thread apps/cli/src/shared/cli/run.e2e.test.ts
Comment thread apps/cli/src/shared/output/errors.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@avallete

Copy link
Copy Markdown
Member Author

/ai-review

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Superseded by a newer AI review

🤖 AI Review

Confirmed both Claude findings as minor issues: clack-based text output bypasses the broken-pipe handler, and closed-stdout termination is recorded as a telemetry failure despite exiting successfully. Codex reported no findings. Verification was by code inspection; tests were not run.

Findings

Severity Location Category Sources Claim
🟡 MINOR apps/cli/src/shared/output/output.layer.ts:86 correctness claude The new broken-pipe handling does not cover clack-based text output. The text layer's info, warn, error, success, event, and non-TTY task messages bypass stdioWriter, so these writes neither update the shared closed-stream state nor terminate through StdoutClosedError.
🟡 MINOR apps/cli/src/shared/output/errors.ts:37 telemetry claude A command terminated solely by closed stdout records telemetry exit_code 1 even though runCli exits 0, inflating the recorded command failure rate.

Stats

Claude findings: 2 · Codex findings: 0 · Confirmed: 2 · Refuted: 0 · Uncertain: 0


Models: claude-opus-5-5 + gpt-6.1-sol · Trigger: manual · Workflow run

This review runs once per PR. A maintainer can request another with a /ai-review comment.

Comment thread apps/cli/src/shared/output/output.layer.ts
Comment thread apps/cli/src/shared/output/errors.ts
avallete and others added 2 commits October 16, 2026 19:48
Clack log lines wrote to process.stdout directly, so a closed reader crashed the
process with an uncaught EPIPE. Command telemetry now records exit 0 when a
closed stdout alone ended the command, matching the process exit code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@avallete

Copy link
Copy Markdown
Member Author

/ai-review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 AI Review

Reviewed both finding sets against the PR code and trusted conventions. Confirmed that text task progress and intro/outro bypass the shared broken-pipe handling. The terminal-width regression remains uncertain: the buffer lacks terminal metadata, but clack's source is unavailable and network access failed. No duplicates or additional findings were found. Tests were not run because dependencies are absent.

Findings

Severity Location Category Sources Claim
🟡 MINOR apps/cli/src/shared/output/output.layer.ts:314 behavior-change claude The buffer supplied to clack lacks terminal width and TTY metadata, potentially changing wrapping of text log messages on interactive terminals.
🟡 MINOR apps/cli/src/shared/output/output.layer.ts:353 error-handling claude Text task progress and intro/outro still bypass the shared writer, leaving broken-pipe handling incomplete for commands using these output paths.

Findings outside the diff

  • 🟡 MINOR apps/cli/src/shared/output/output.layer.ts:353 — Text task progress and intro/outro still bypass the shared writer, leaving broken-pipe handling incomplete for commands using these output paths.

Stats

Claude findings: 2 · Codex findings: 0 · Confirmed: 1 · Refuted: 0 · Uncertain: 1


Models: claude-opus-5-5 + gpt-6.1-sol · Trigger: manual · Workflow run

This review runs once per PR. A maintainer can request another with a /ai-review comment.

Comment thread apps/cli/src/shared/output/output.layer.ts

This branch has not been deployed

No deployments
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.

2 participants