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;