Skip to content

fix(cli): survive broken stdio pipes - #41968

Closed
Hona wants to merge 4 commits into
anomalyco:v2from
Hona:bun-epipe-guard
Closed

Hona wants to merge 4 commits into
anomalyco:v2from
Hona:bun-epipe-guard

Conversation

@Hona

@Hona Hona commented Aug 12, 2026 •

Copy link
Copy Markdown
Member

The compiled Bun CLI dies from unhandled EPIPE errors in two distinct ways, and both are fatal for the background service.

  1. Stdio pipes: when the consumer of the service's stdout/stderr goes away while the server keeps logging, the next write raises EPIPE. The Node wrapper guarded stdout only; the Bun-compiled binary that the desktop stages had no guard, and the desktop supervisor pipes stderr.

  2. Native async pipe writes: the service also crashed reproducibly during the boot-time project copy refresh with a stack-less EPIPE raised from native async I/O completion (a git child stdin or socket write racing a peer disconnect). These errors carry no JS frames, so they bypass every Effect error channel and surface as an uncaught exception:

UNCAUGHT uncaughtException EPIPE: broken pipe, write

This made bun dev:desktop unusable: the service registered, answered the readiness probe, then died seconds later during its boot refresh, and the renderer looped on "event stream disconnected" forever.

The fix is one idempotent guardStdio() in the shared CLI entry, called by both entrypoints:

  • stream error listeners on stdout and stderr that swallow EPIPE and rethrow everything else
  • process-level uncaughtException and unhandledRejection handlers that swallow EPIPE only and preserve fatal semantics (print and exit 1) for everything else

A broken pipe is always a peer disconnect and must never terminate the server process.

Validated on Windows: with the guard the service survives its boot project copy refresh (previously a reliable crash under concurrent-service git contention), keeps listening, and survives its spawner tree being killed while it logs.

Copilot AI lite review requested due to automatic review settings August 12, 2026 08:08

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

Pull request overview

Updates the shared CLI entrypoint to prevent the Bun-compiled CLI/server process from crashing when its stdout/stderr pipes are closed (EPIPE), matching the resilience already present in the Node wrapper and addressing desktop supervisor scenarios that pipe stderr.

Changes:

  • Add error listeners to process.stdout and process.stderr to swallow EPIPE and avoid server termination when log pipes break.

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

Comment thread packages/cli/src/index.ts Outdated
Comment on lines +16 to +21
for (const stream of [process.stdout, process.stderr]) {
stream.on("error", (error) => {
if ("code" in error && error.code === "EPIPE") return
throw error
})
}
@Hona

Hona commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Both findings are addressed in 3774949.

Finding 1 (process-wide EPIPE suppression): accepted, with scoping as the achievable granularity. The process-level uncaughtException/unhandledRejection EPIPE handlers moved out of the shared entry into guardServiceProcess(), installed only by serve --service during service startup (server-process.ts). Foreground commands, serve without --service, and --stdio mode get no process-wide suppression. Source-level attribution is not possible for this failure: the crashing EPIPE arrives from native async write completion with an empty JS stack (UNCAUGHT uncaughtException EPIPE: broken pipe, write, no frames), so no owner in this repo can observe it. The true owner fix is upstream in the effect spawner, which detaches its child-stdin error listener once the write sink completes; a late EPIPE then has no listener and becomes an unhandled exception. Until that lands, the service-mode catch-all is the only mitigation, and it preserves fatal semantics (print and exit 1) for every non-EPIPE error. On the incomplete-operation concern: in the observed git case the owning Effect completes through the child exit-code path regardless, so the stray write completion carries no result anyone waits on.

Finding 2 (foreground commands continuing after stdout closes): accepted, and note the swallow-and-continue behavior for stdout predates this PR (the Node wrapper guard from 42e2dde did the same). The stream guard now applies mode-dependent policy: in foreground, an EPIPE on stdout terminates the process cleanly, so opencode2 run "large task" | head -n 1 stops instead of continuing the model request; stderr EPIPE stays non-fatal as diagnostics. In service mode both streams are non-fatal, since a detached service's logging consumer may legitimately outlive or predecease it.

Validation: the desktop background service (the invocation that reproducibly crashed during its boot project-copy refresh under concurrent git activity) survives with the scoped guard, confirmed by a detached run completing project copy refresh done and by a live desktop session with no event-stream disconnect spam. Typecheck clean in packages/cli.

@opencode-agent

Copy link
Copy Markdown
Contributor

⚠️ Blocking Beta Release

This PR cannot be merged into the beta branch due to: Merge failed

Please resolve this issue to include this PR in the next beta release.

2 similar comments
@opencode-agent

Copy link
Copy Markdown
Contributor

⚠️ Blocking Beta Release

This PR cannot be merged into the beta branch due to: Merge failed

Please resolve this issue to include this PR in the next beta release.

@opencode-agent

Copy link
Copy Markdown
Contributor

⚠️ Blocking Beta Release

This PR cannot be merged into the beta branch due to: Merge failed

Please resolve this issue to include this PR in the next beta release.

@Enough1122

Copy link
Copy Markdown

AI code review -- automated review for reference; please use your judgment.

  • packages/cli/src/stdio.ts:11 -- stdout EPIPE triggers process.exit(0) -- a consumer piping output (e.g. opencode run | head) gets truncated results but a green exit code, so scripts cannot distinguish full success from a broken pipe mid-write -- consider a distinguishable exit code (e.g. 141 following the SIGPIPE convention, or a dedicated code), or at least emit a short notice to stderr before exiting.
  • packages/cli/src/stdio.ts:20-28 -- service-mode uncaughtException/unhandledRejection handlers swallow every EPIPE regardless of its source -- EPIPE also arises from sockets, IPC and child pipes, so a dead client connection is silently ignored and the service keeps running with zero diagnostic trail -- keep suppressing the crash but log swallowed EPIPEs (with whatever stream/target detail is available) before returning.
  • packages/cli/src/server-process.ts:47 -- resilient handlers are installed only when the service effect body executes, not at process boot -- between the entrypoint guardStdio() and this call, a service-mode process has no uncaughtException/unhandledRejection net, so an early EPIPE still kills the daemon -- install the resilient set at the service entry/bootstrap point as soon as mode is known instead of inside processEffect.
  • packages/cli/src/stdio.ts:1-2 -- installed/resilient are module-scope singletons -- if the module loads twice (dual entry points, bundled or symlinked duplicate builds), the stdio handlers close over one copy's resilient while guardServiceProcess() may flip another copy's, leaving exit-on-EPIPE active inside a service meant to be resilient -- key the state on a globalThis symbol so every instance shares one flag set.
  • packages/cli/src/node/index.ts:5 -- behavior drift versus the removed inline handler: previously EPIPE on both stdout and stderr was swallowed and the process always continued; now stdout EPIPE exits 0 while stderr EPIPE is silently swallowed and execution continues -- the asymmetry looks deliberate but is undocumented, and a dead stderr means every subsequent log disappears unnoticed -- document the intent in stdio.ts and consider a one-time warning when stderr fails.
  • Tests -- the new branching matrix (EPIPE/non-EPIPE x resilient/default x stdout/stderr x uncaught/rejection) ships without any tests in this diff -- these paths decide CLI exit codes and daemon liveness and are easy to regress silently -- add integration tests that spawn the CLI against a pre-closed stdout/stderr (e.g. | head -0 or a child spawn with a destroyed pipe) and assert exit codes plus continued service operation.

@Enough1122

Copy link
Copy Markdown

AI code review -- automated review for reference; please use your judgment.

  • packages/cli/src/server-process.ts:45-48 -- resilient guards are installed only for mode === "service", leaving mode === "stdio" exposed -- in stdio mode stdout is the JSON protocol channel, and on platforms where pipe writes fail synchronously (Windows pipes, and Bun in several paths) EPIPE surfaces as a thrown/uncaught error rather than a stream 'error' event, so a client vanishing mid-handshake can still crash the stdio server despite that being the exact failure class this PR targets -- fold "stdio" into the resilient path alongside "service", or install the EPIPE-tolerant uncaughtException guard there too.
  • packages/cli/src/server-process.ts:47 -- the service guard lands mid-effect, well after process boot -- module imports, config reads, and layer initialization all execute before processEffect reaches this line, so a pipe break right after spawn (parent died immediately) still kills the daemon with an unhandled error -- install the resilient set at the service bootstrap point as soon as mode is known instead of inside the effect body.
  • packages/cli/src/stdio.ts:11 -- stdout EPIPE triggers process.exit(0) -- queued writes are dropped, cleanup/exit hooks never run (any spawned server/session teardown is skipped), and scripts piping output (e.g. opencode ... | head) see a green exit code for work that never completed -- route through the app's normal shutdown path or use a distinguishable code (SIGPIPE-style 141), reserving 0 for genuinely finished runs.
  • packages/cli/src/stdio.ts:21,26 -- service-mode uncaughtException/unhandledRejection handlers swallow every EPIPE regardless of origin -- EPIPE also arises from sockets, IPC channels and child process pipes, so a dead client connection is silenced with zero diagnostic trail while the service keeps running -- keep suppressing the crash but log each swallowed EPIPE with whatever target/stream detail is available.
  • packages/cli/src/stdio.ts:10-11 -- asymmetric handling: stderr EPIPE is silently swallowed in default CLI mode while stdout EPIPE exits(0) -- deliberate or not, diagnostics written to a closed stderr vanish unnoticed and the discrepancy is undocumented (note the removed inline handler in node/index.ts only ever covered stdout) -- add a short comment stating the intent and consider a one-time best-effort warning when stderr fails.
  • packages/cli/src/stdio.ts:32 -- isEpipe inspects only the top-level code -- errors wrapped in a cause chain (common across Bun/Node internals) won't match and fall into the fatal paths, whereas addressInUse in server-process.ts already walks .cause recursively -- mirror that cause-walk for consistency and cross-runtime robustness.
  • packages/cli/src/stdio.ts:1-2 -- installed/resilient are module-scope singletons -- this module sits behind two entry points (index.ts, node/index.ts), so any duplicate copy (separate bundle targets, symlinked installs) would leave handlers closing over one copy's flags while guardServiceProcess() flips another's, re-enabling exit-on-EPIPE inside a service meant to be resilient -- key the state on a globalThis symbol so all instances share one flag set.
  • packages/cli/src/stdio.ts:7-14 -- the branchy guard matrix ships untested and is hard-bound to process.stdout/process.stderr -- these paths decide CLI exit codes and daemon liveness and have already regressed once (hence the deleted inline handler), yet direct stream references make them impossible to unit test -- accept injectable streams defaulting to the process ones and add tests spawning the CLI against a pre-closed pipe, asserting exit codes and continued service operation.

@github-actions

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants