Skip to content

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

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

fix(cli): survive broken stdio pipes#41968
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.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants