Skip to content

fix(cli): end the long-lived stream cleanly on EPIPE - #2479

Merged
cliffhall merged 2 commits into
v2/mainfrom
v2/fix/2412-cli-stream-epipe
Sep 24, 2026
Merged

cliffhall merged 2 commits into
v2/mainfrom
v2/fix/2412-cli-stream-epipe

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #2412

Problem

consumeMethodOutcome's long-lived stream path (resources/subscribe and the other stream methods) registered only SIGINT/SIGTERM handlers. When the reader exits early (| head, | grep -m1, quitting less), the next stdout write fails with EPIPE. With no 'error' listener on stdout, that became an unhandled 'error' event that crashed the process. The code carried its own TODO(#1432) for this.

Reproduced on v2/main by piping a stream outcome that writes every 5ms into head -n 2:

{"i":0}
{"i":1}
node:events:505
    throw er; // Unhandled 'error' event
Error: write EPIPE

With this branch, the same run prints the two lines and exits 0.

Change

clients/cli/src/handlers/consume-outcome.ts: the stream path now listens for stdout 'error' for as long as the stream is open, and attaches that listener before start() runs.

  • EPIPE: unsubscribes and resolves, so the CLI exits cleanly, as it already does on a signal.
  • Any other stdout error: unsubscribes and rejects, which sends it through the CLI's existing error path.
  • Every exit (signal, stdout error, or start() throwing): removes all three listeners.

The TODO is gone, and the doc comment now describes this behavior.

Tests

New clients/cli/__tests__/consume-outcome-stream.test.ts covers:

  • EPIPE resolving: unsubscribes exactly once and detaches the stdout, SIGINT and SIGTERM listeners
  • a non-EPIPE error rejecting with the original error
  • a non-Error value on stdout being treated as a failure, not as a broken pipe
  • SIGTERM also detaching the stdout listener
  • start() throwing and leaving no listeners behind

Like the existing test in run-method.test.ts, these call the registered listeners directly instead of emitting process-wide.

npm run local:gate is green. An earlier run failed on a web SkillsScreen test under load. This diff touches no web code, and that test passes 3/3 when run alone.

🤖 Generated with Claude Code

A reader that exits early (| head, | grep -m1, quitting less) made the
next stdout write fail with EPIPE, and with no 'error' listener on
stdout that was an uncaught event that crashed the CLI. The stream path
now listens on stdout for its lifetime: EPIPE unsubscribes and resolves,
any other stdout error unsubscribes and rejects into the CLI error path,
and every listener is detached on each exit, including start() throwing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Sep 24, 2026
@cliffhall cliffhall linked an issue Sep 24, 2026 that may be closed by this pull request
2 tasks done
@cliffhall
cliffhall requested a balanced review from Copilot September 24, 2026 04:55

Copilot AI 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.

Copilot review overview

🟢 Approval recommended

The implementation addresses the reported failure with focused cleanup and comprehensive tests.

Review effort: Balanced
Findings: None

What changed in this PR

Fixes CLI stream crashes when downstream readers close stdout early.

Changes:

  • Handles EPIPE as a clean stream termination.
  • Propagates other stdout errors and consistently removes listeners.
  • Adds coverage for errors, signals, cleanup, and startup failures.
File Description
clients/​cli/​src/​handlers/​consume-outcome.ts Adds stream-scoped stdout error handling and cleanup.
clients/​cli/​__tests__/​consume-outcome-stream.test.ts Tests stream termination and listener cleanup paths.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

@cliffhall

Copy link
Copy Markdown
Member Author

Copilot review loop closed: round 1 was clean (no inline comments, no findings in the body, no suppressed comments), so no further round was requested.

@cliffhall
cliffhall merged commit eec0349 into v2/main Sep 24, 2026
4 checks passed
@cliffhall
cliffhall deleted the v2/fix/2412-cli-stream-epipe branch September 24, 2026 05:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CLI's long-lived stream path can crash with an uncaught EPIPE

2 participants