fix(cli): survive broken stdio pipes - #41968
Conversation
There was a problem hiding this comment.
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
errorlisteners toprocess.stdoutandprocess.stderrto swallowEPIPEand 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.
| for (const stream of [process.stdout, process.stderr]) { | ||
| stream.on("error", (error) => { | ||
| if ("code" in error && error.code === "EPIPE") return | ||
| throw error | ||
| }) | ||
| } |
|
Both findings are addressed in 3774949. Finding 1 (process-wide EPIPE suppression): accepted, with scoping as the achievable granularity. The process-level 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 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 |
The compiled Bun CLI dies from unhandled EPIPE errors in two distinct ways, and both are fatal for the background service.
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.
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:
This made
bun dev:desktopunusable: 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:uncaughtExceptionandunhandledRejectionhandlers that swallow EPIPE only and preserve fatal semantics (print and exit 1) for everything elseA 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.