From cabd0616ea8c194988a1010ff4182a8b72cbe68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 3 Jun 2026 22:53:08 -0400 Subject: [PATCH] fix(cli): suppress EPIPE crashes in piped agent environments (#1184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): suppress EPIPE crashes in piped agent environments When the CLI runs inside a piped environment (Claude Code, Codex, Cursor), the reader may close the pipe before we finish writing. Node treats EPIPE on stdout/stderr as an uncaughtException, crashing the process with a non-zero exit code. Add stream-level EPIPE handlers on stdout/stderr at the top of the entry point (before any output) and make the uncaughtException handler EPIPE-aware so it exits cleanly (code 0) instead of crash-reporting. PostHog data: ~10,300 EPIPE errors over 10 days, contributing to the preview command's 43-59% failure rate in agent environments. * fix(cli): set commandFailed before EPIPE exit to prevent false success telemetry EPIPE is a pipe-reader-closed signal, not a successful run. The exit handler uses 'code === 0 && !commandFailed' to determine success — without setting commandFailed=true before process.exit(0), every EPIPE exit was recorded as success:true in telemetry. Moves the commandFailed declaration to the top of the file so the stream-error EPIPE handlers (which must run before any writes) can reference it. Also sets commandFailed=true in the uncaughtException EPIPE path for the same reason. --- packages/cli/src/cli.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 603d7a9df..3f8aace1c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,5 +1,26 @@ #!/usr/bin/env node +// ── EPIPE suppression (must run before ANY stdout/stderr write) ──────────── +// When the CLI runs inside a piped agent environment (Claude Code, Codex, +// Cursor, etc.), the reader may close the pipe before we finish writing. +// Node treats EPIPE on stdout/stderr as an uncaughtException, which crashes +// the process. This is a normal lifecycle event — suppress it. +// +// commandFailed must be declared here (before the handlers) so the EPIPE +// stream-error path can set it before process.exit(0). The telemetry exit +// handler reads this flag to determine success/failure — an EPIPE exit +// should NOT score as success:true in telemetry. +let commandFailed = false; + +for (const stream of [process.stdout, process.stderr]) { + stream.on("error", (err) => { + if ((err as NodeJS.ErrnoException).code === "EPIPE") { + commandFailed = true; + process.exit(0); + } + }); +} + // ── Worker entry path bootstrap (must run before any producer/engine load) ── // The hf#677 worker_threads pools (`pngDecodeBlitWorkerPool`, // `shaderTransitionWorkerPool`) live in the producer package and try to @@ -194,7 +215,6 @@ if (!isHelp && !hasJsonFlag && command !== "upgrade") { } const commandStart = Date.now(); -let commandFailed = false; // Async flush for normal exit. `beforeExit` re-fires every time the // event loop drains, and the async `_flush()` itself schedules new @@ -220,6 +240,10 @@ process.on("exit", (code) => { }); process.on("uncaughtException", (error) => { + if ((error as NodeJS.ErrnoException).code === "EPIPE") { + commandFailed = true; + process.exit(0); + } commandFailed = true; _trackCliError?.({ error_name: error.name,