[PR #3781] [MERGED] fix(cli): stop quietly when the stdout reader closes early #5894

Closed
opened 2026-10-31 01:10:14 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/morluto/rea/pull/3781
Author: @kaoru0822-kitauji
Created: 10/15/2026
Status: ✅ Merged
Merged: 10/15/2026
Merged by: @kaoru0822-kitauji

Base: main ← Head: fix/cli-closed-stdout


📝 Commits (2)

  • 0d441ee fix(cli): stop quietly when the stdout reader closes early
  • 1f5d1b4 fix(cli): guard mcp doctor output and cover midstream reader closure

📊 Changes

3 files changed (+125 additions, -5 deletions)

View changed files

📝 docs/cli.md (+5 -0)
📝 scripts/rea.mjs (+36 -5)
➕ tests/boundary/cli/closedStdout.test.ts (+84 -0)

📄 Description

Summary

rea <command> | head crashed as soon as the reader closed the pipe. Agents and scripts pipe output into head all the time, and each such run produced a failure that looked like an analysis error:

  • TOON/YAML (default): an unhandled 'error' event with a Node stack trace on stderr, exit 1.
  • --format json: {"code":"UNKNOWN","message":"write EPIPE"} on stderr, exit 1.

Now the CLI stops writing, prints nothing to stderr and exits 141. That is the status git log | head and yes | head report when their reader closes the pipe.

Problem and expected behavior

When the reader closes early it declines the rest of the output; the command itself has not failed. process.stdout had no 'error' listener, so for Incur-rendered formats the EPIPE became an uncaught exception. On the JSON streaming path, writeOutputText rejected with EPIPE. Incur then reported that rejection as a command failure, and the entry point sent the report to stderr.

Change and scope

  • The entry point that owns process.stdout is scripts/rea.mjs. A shared guardClosedStdout(), called by both the ordinary CLI branch and mcp doctor, listens for EPIPE on stdout and records that the stream is closed. After that it skips further stdout writes, drops the failure report that the write rejection would otherwise send to stderr, and sets exit status 141.
  • Incur exits through process.exit(code) on its failure path. serve now receives an exit override that keeps 141 when the pipe was closed.
  • Other stdout errors keep their previous behavior. They stay unhandled, and so crash as before, unless a pending JSON write is listening and reports them.
  • docs/cli.md "Output and exit status" documents the new status.
  • Not included: MCP stdio mode, where a closed stdout means the client disconnected; that path has its own lifecycle.
  • Also not included: incur's own --token-offset past the end, which prints a reversed range (showing tokens 100000–1251 of 1251). It is an upstream rendering issue.

Contract and boundary impact

  • Semantic owner and earliest changed stage: CLI entry point stdout ownership (scripts/rea.mjs).
  • Contribution scope fit and linked maintainer decision: correctness fix within existing CLI output; not applicable.
  • CLI and MCP/tool-catalog contract: a new documented exit status 141 for a reader that closes early; schemas unchanged. MCP is unaffected.
  • Provider, bridge, target-format, or platform compatibility: all CLI commands. The regression test skips Windows, where a closed pipe reader is reported through a different error path; Windows behavior there is unverified.
  • Evidence, artifact, provenance, or reconstruction contract: unchanged; the operation still runs to completion.
  • Target selection, authority, upstream behavior, and cleanup details: unchanged.
  • Generated metadata, package, or installation impact: none.

Expected effects:

  • Documentation or usability only; no target interaction.

Evidence and regression coverage

  • Tests added: tests/boundary/cli/closedStdout.test.ts spawns the real scripts/rea.mjs. It covers capabilities (TOON and JSON) and mcp doctor --json with the reader closed before the first write. It also covers a 4 MiB streamed inspect-sqlite-database --format json result whose reader closes after the first chunk. Each case asserts exit 141 and empty stderr.
  • Base reproduction: against the unfixed scripts/rea.mjs, all 4 cases fail with code: 1 and a stderr containing node:events ... code: 'EPIPE'.
  • User-visible output before:
    $ rea trace-dylib-resolution /Applications/Ghostty.app | head -c 10
    node:events:487
          throw er; // Unhandled 'error' event
    Error: write EPIPE ...            # exit 1
    $ rea trace-dylib-resolution /Applications/Ghostty.app --format json | head -c 10
    { "code": "UNKNOWN", "message": "write EPIPE" }   # exit 1
    
    After: no stderr output, rea exits 141.
  • Remaining proof gaps: Windows closed-pipe behavior.

Validation performed

  • npm run test:focused -- tests/boundary/cli/jsonOutput.test.ts tests/boundary/cli/cliOutput.test.ts tests/boundary/cli/closedStdout.test.ts tests/boundary/cli/nativePlistCli.test.ts: 71 passed
  • npm run check:fast: passed; npm run docs:check: passed
  • Real app on macOS (Darwin 25.6, arm64). trace-dylib-resolution /Applications/Ghostty.app (143 KB of JSON) was piped into a reader that closes after 10 bytes, for TOON, JSON and YAML. Each run gave exit 141 and empty stderr; before the fix they gave exit 1 with a stack trace or an UNKNOWN error. A closed-before-first-write reader was also tested with capabilities (TOON/JSON), inspect-plist /nonexistent --format json and --llms-full: exit 141 and empty stderr in each case. Unchanged: a normal failure (inspect-plist /nonexistent --format json to a terminal) still prints its structured error and exits 1; a normal success exits 0.
  • Cost: no added work; the remaining output is simply not written once the reader is gone.

Compatibility, safety, and release

  • Breaking changes or migration steps: none. A script that treated exit 1 after a closed pipe as an analysis failure now sees 141, which matches standard Unix tools.
  • Real Hopper/Ghidra, browser, or OS coverage: macOS only.
  • Package or release metadata impact: none.
  • Security, privacy, process, or containment review: none; no new reads, writes or processes.

Author readiness

Human owner:

  • I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work.
  • I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps.
  • I declare this PR ready for initial maintainer review.

Before merge

  • This PR addresses a concrete problem or an agreed enhancement.
  • Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff.
  • The PR has one focused outcome and the title follows type(scope): outcome.
  • Related issue is linked, or the reason for not linking one is stated above. (Found while piping real CLI output; no existing issue.)
  • Tests cover changed observable behavior and meaningful failure paths.
  • Owning docs, contracts, and generated metadata are updated where needed.
  • User-visible CLI/MCP changes include representative output.
  • I checked the final diff for secrets, unrelated cleanup, and unsupported claims.

🤖 Generated with Claude Code


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/morluto/rea/pull/3781 **Author:** [@kaoru0822-kitauji](https://github.com/kaoru0822-kitauji) **Created:** 10/15/2026 **Status:** ✅ Merged **Merged:** 10/15/2026 **Merged by:** [@kaoru0822-kitauji](https://github.com/kaoru0822-kitauji) **Base:** `main` ← **Head:** `fix/cli-closed-stdout` --- ### 📝 Commits (2) - [`0d441ee`](https://github.com/morluto/rea/commit/0d441ee92702107dc901c0ec02f6cb724aef03c5) fix(cli): stop quietly when the stdout reader closes early - [`1f5d1b4`](https://github.com/morluto/rea/commit/1f5d1b49df81e0812e62dcc182d844327642ba35) fix(cli): guard mcp doctor output and cover midstream reader closure ### 📊 Changes **3 files changed** (+125 additions, -5 deletions) <details> <summary>View changed files</summary> 📝 `docs/cli.md` (+5 -0) 📝 `scripts/rea.mjs` (+36 -5) ➕ `tests/boundary/cli/closedStdout.test.ts` (+84 -0) </details> ### 📄 Description ## Summary `rea <command> | head` crashed as soon as the reader closed the pipe. Agents and scripts pipe output into `head` all the time, and each such run produced a failure that looked like an analysis error: - TOON/YAML (default): an unhandled `'error'` event with a Node stack trace on stderr, exit `1`. - `--format json`: `{"code":"UNKNOWN","message":"write EPIPE"}` on stderr, exit `1`. Now the CLI stops writing, prints nothing to stderr and exits `141`. That is the status `git log | head` and `yes | head` report when their reader closes the pipe. ## Problem and expected behavior When the reader closes early it declines the rest of the output; the command itself has not failed. `process.stdout` had no `'error'` listener, so for Incur-rendered formats the EPIPE became an uncaught exception. On the JSON streaming path, `writeOutputText` rejected with EPIPE. Incur then reported that rejection as a command failure, and the entry point sent the report to stderr. ## Change and scope - The entry point that owns `process.stdout` is `scripts/rea.mjs`. A shared `guardClosedStdout()`, called by both the ordinary CLI branch and `mcp doctor`, listens for EPIPE on stdout and records that the stream is closed. After that it skips further stdout writes, drops the failure report that the write rejection would otherwise send to stderr, and sets exit status `141`. - Incur exits through `process.exit(code)` on its failure path. `serve` now receives an `exit` override that keeps `141` when the pipe was closed. - Other stdout errors keep their previous behavior. They stay unhandled, and so crash as before, unless a pending JSON write is listening and reports them. - `docs/cli.md` "Output and exit status" documents the new status. - Not included: MCP stdio mode, where a closed stdout means the client disconnected; that path has its own lifecycle. - Also not included: `incur`'s own `--token-offset` past the end, which prints a reversed range (`showing tokens 100000–1251 of 1251`). It is an upstream rendering issue. ## Contract and boundary impact - Semantic owner and earliest changed stage: CLI entry point stdout ownership (`scripts/rea.mjs`). - Contribution scope fit and linked maintainer decision: correctness fix within existing CLI output; not applicable. - CLI and MCP/tool-catalog contract: a new documented exit status `141` for a reader that closes early; schemas unchanged. MCP is unaffected. - Provider, bridge, target-format, or platform compatibility: all CLI commands. The regression test skips Windows, where a closed pipe reader is reported through a different error path; Windows behavior there is unverified. - Evidence, artifact, provenance, or reconstruction contract: unchanged; the operation still runs to completion. - Target selection, authority, upstream behavior, and cleanup details: unchanged. - Generated metadata, package, or installation impact: none. Expected effects: - [x] Documentation or usability only; no target interaction. ## Evidence and regression coverage - Tests added: `tests/boundary/cli/closedStdout.test.ts` spawns the real `scripts/rea.mjs`. It covers `capabilities` (TOON and JSON) and `mcp doctor --json` with the reader closed before the first write. It also covers a 4 MiB streamed `inspect-sqlite-database --format json` result whose reader closes after the first chunk. Each case asserts exit `141` and empty stderr. - Base reproduction: against the unfixed `scripts/rea.mjs`, all 4 cases fail with `code: 1` and a stderr containing `node:events ... code: 'EPIPE'`. - User-visible output before: ``` $ rea trace-dylib-resolution /Applications/Ghostty.app | head -c 10 node:events:487 throw er; // Unhandled 'error' event Error: write EPIPE ... # exit 1 $ rea trace-dylib-resolution /Applications/Ghostty.app --format json | head -c 10 { "code": "UNKNOWN", "message": "write EPIPE" } # exit 1 ``` After: no stderr output, `rea` exits `141`. - Remaining proof gaps: Windows closed-pipe behavior. ## Validation performed - `npm run test:focused -- tests/boundary/cli/jsonOutput.test.ts tests/boundary/cli/cliOutput.test.ts tests/boundary/cli/closedStdout.test.ts tests/boundary/cli/nativePlistCli.test.ts`: 71 passed - `npm run check:fast`: passed; `npm run docs:check`: passed - Real app on macOS (Darwin 25.6, arm64). `trace-dylib-resolution /Applications/Ghostty.app` (143 KB of JSON) was piped into a reader that closes after 10 bytes, for TOON, JSON and YAML. Each run gave exit `141` and empty stderr; before the fix they gave exit `1` with a stack trace or an `UNKNOWN` error. A closed-before-first-write reader was also tested with `capabilities` (TOON/JSON), `inspect-plist /nonexistent --format json` and `--llms-full`: exit `141` and empty stderr in each case. Unchanged: a normal failure (`inspect-plist /nonexistent --format json` to a terminal) still prints its structured error and exits `1`; a normal success exits `0`. - Cost: no added work; the remaining output is simply not written once the reader is gone. ## Compatibility, safety, and release - Breaking changes or migration steps: none. A script that treated exit `1` after a closed pipe as an analysis failure now sees `141`, which matches standard Unix tools. - Real Hopper/Ghidra, browser, or OS coverage: macOS only. - Package or release metadata impact: none. - Security, privacy, process, or containment review: none; no new reads, writes or processes. ## Author readiness Human owner: <!-- @GitHub-handle --> - [ ] I reviewed the final diff and relevant existing code, understand the change and its edge cases, and take responsibility for the complete PR, including AI-assisted work. - [ ] I checked the evidence above, confirmed it supports the current change, and recorded meaningful validation gaps. - [ ] I declare this PR ready for initial maintainer review. ## Before merge - [x] This PR addresses a concrete problem or an agreed enhancement. - [x] Material scope and authority questions are resolved, and any required maintainer scope decision covers the final diff. - [x] The PR has one focused outcome and the title follows `type(scope): outcome`. - [x] Related issue is linked, or the reason for not linking one is stated above. (Found while piping real CLI output; no existing issue.) - [x] Tests cover changed observable behavior and meaningful failure paths. - [x] Owning docs, contracts, and generated metadata are updated where needed. - [x] User-visible CLI/MCP changes include representative output. - [x] I checked the final diff for secrets, unrelated cleanup, and unsupported claims. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
bsakaguchi 2026-10-31 01:10:14 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#5894
No description provided.