From 2ef4ac08cfd25e69768b988f79ec615b3cfefa84 Mon Sep 17 00:00:00 2001 From: Keshav Rao Date: Thu, 12 Feb 2026 05:45:36 -0800 Subject: [PATCH] fix(gateway): handle async EPIPE on stdout/stderr during shutdown (#13414) * fix(gateway): handle async EPIPE on stdout/stderr during shutdown The console capture forward() wrapper catches synchronous EPIPE errors, but when the receiving pipe closes during shutdown Node emits the error asynchronously on the stream. Without a listener this becomes an uncaught exception that crashes the gateway, causing macOS launchd to permanently unload the service. Add error listeners on process.stdout and process.stderr inside enableConsoleCapture() that silently swallow EPIPE/EIO (matching the existing isEpipeError helper) and re-throw anything else. Closes #13367 * guard stream error listeners against repeated enableConsoleCapture() calls Use a separate streamErrorHandlersInstalled flag in loggingState so that test resets of consolePatched don't cause listener accumulation on process.stdout/stderr. --- src/logging/console-capture.test.ts | 24 ++++++++++++++++++++++++ src/logging/console.ts | 18 ++++++++++++++++++ src/logging/state.ts | 1 + 3 files changed, 43 insertions(+) diff --git a/src/logging/console-capture.test.ts b/src/logging/console-capture.test.ts index 638332ddf..39acaf108 100644 --- a/src/logging/console-capture.test.ts +++ b/src/logging/console-capture.test.ts @@ -121,6 +121,30 @@ describe("enableConsoleCapture", () => { console.log(payload); expect(log).toHaveBeenCalledWith(payload); }); + + it("swallows async EPIPE on stdout", () => { + setLoggerOverride({ level: "info", file: tempLogPath() }); + enableConsoleCapture(); + const epipe = new Error("write EPIPE") as NodeJS.ErrnoException; + epipe.code = "EPIPE"; + expect(() => process.stdout.emit("error", epipe)).not.toThrow(); + }); + + it("swallows async EPIPE on stderr", () => { + setLoggerOverride({ level: "info", file: tempLogPath() }); + enableConsoleCapture(); + const epipe = new Error("write EPIPE") as NodeJS.ErrnoException; + epipe.code = "EPIPE"; + expect(() => process.stderr.emit("error", epipe)).not.toThrow(); + }); + + it("rethrows non-EPIPE errors on stdout", () => { + setLoggerOverride({ level: "info", file: tempLogPath() }); + enableConsoleCapture(); + const other = new Error("EACCES") as NodeJS.ErrnoException; + other.code = "EACCES"; + expect(() => process.stdout.emit("error", other)).toThrow("EACCES"); + }); }); function tempLogPath() { diff --git a/src/logging/console.ts b/src/logging/console.ts index 986bf89ac..dbff864ba 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -170,6 +170,24 @@ export function enableConsoleCapture(): void { } loggingState.consolePatched = true; + // Handle async EPIPE errors on stdout/stderr. The synchronous try/catch in + // the forward() wrapper below only covers errors thrown during write dispatch. + // When the receiving pipe closes (e.g. during shutdown), Node emits the error + // asynchronously on the stream. Without a listener this becomes an uncaught + // exception that crashes the gateway. + // Guard separately from consolePatched so test resets don't stack listeners. + if (!loggingState.streamErrorHandlersInstalled) { + loggingState.streamErrorHandlersInstalled = true; + for (const stream of [process.stdout, process.stderr]) { + stream.on("error", (err) => { + if (isEpipeError(err)) { + return; + } + throw err; + }); + } + } + let logger: ReturnType | null = null; const getLoggerLazy = () => { if (!logger) { diff --git a/src/logging/state.ts b/src/logging/state.ts index 4c0c96615..f45de04d2 100644 --- a/src/logging/state.ts +++ b/src/logging/state.ts @@ -8,6 +8,7 @@ export const loggingState = { consoleTimestampPrefix: false, consoleSubsystemFilter: null as string[] | null, resolvingConsoleSettings: false, + streamErrorHandlersInstalled: false, rawConsole: null as { log: typeof console.log; info: typeof console.info;