From 3b37fe1de46cb605f652a104f924190b04145199 Mon Sep 17 00:00:00 2001 From: cxbAsDev Date: Sat, 4 Jul 2026 13:49:12 +0800 Subject: [PATCH] fix(supervisor): suppress unhandled stream errors on child stdout/stderr (#99802) * fix(supervisor): suppress unhandled stream errors on child stdout/stderr Child process stdout and stderr streams can emit 'error' events (EPIPE, resource limits, I/O errors) that crash the process when no listener is attached. Add noop error handlers to prevent crashes while preserving normal data/end/close event flow. Co-Authored-By: Claude * test(supervisor): add regression test for stream error suppression Co-Authored-By: Claude * fix(supervisor): guard child stream errors during setup --------- Co-authored-by: Claude Co-authored-by: Vincent Koc --- src/process/supervisor/adapters/child.test.ts | 38 +++++++++++++++++++ src/process/supervisor/adapters/child.ts | 5 +++ 2 files changed, 43 insertions(+) diff --git a/src/process/supervisor/adapters/child.test.ts b/src/process/supervisor/adapters/child.test.ts index 578a4db21723..d161d9c42d03 100644 --- a/src/process/supervisor/adapters/child.test.ts +++ b/src/process/supervisor/adapters/child.test.ts @@ -511,4 +511,42 @@ describe("createChildAdapter", () => { expect(first).toHaveBeenCalledWith("first"); expect(second).toHaveBeenCalledWith("second"); }); + + it("guards stream errors before output listeners are registered", async () => { + vi.useFakeTimers(); + setPlatform("win32"); + const { child, emitExit } = createStubChild(6666); + spawnWithFallbackMock.mockResolvedValue({ + child, + usedFallback: false, + }); + const adapter = await createChildAdapter({ + argv: ["node", "-e", "setTimeout(() => {}, 1000)"], + stdinMode: "pipe-open", + }); + + const stdoutErr = new Error("simulated stdout pipe error"); + const stderrErr = new Error("simulated stderr pipe error"); + const settled = vi.fn(); + void adapter.wait().then(settled); + + emitExit(0, null); + expect(() => child.stdout?.emit("error", stdoutErr)).not.toThrow(); + expect(() => child.stderr?.emit("error", stderrErr)).not.toThrow(); + await vi.advanceTimersByTimeAsync(300); + expect(settled).not.toHaveBeenCalled(); + + adapter.onStdout(() => {}); + adapter.onStdout(() => {}); + adapter.onStderr(() => {}); + adapter.onStderr(() => {}); + + expect(child.stdout?.listenerCount("error")).toBe(1); + expect(child.stderr?.listenerCount("error")).toBe(1); + + child.stdout?.emit("close"); + child.stderr?.emit("close"); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toHaveBeenCalledWith({ code: 0, signal: null }); + }); }); diff --git a/src/process/supervisor/adapters/child.ts b/src/process/supervisor/adapters/child.ts index 1596d449255d..79419ba9daf3 100644 --- a/src/process/supervisor/adapters/child.ts +++ b/src/process/supervisor/adapters/child.ts @@ -103,6 +103,11 @@ export async function createChildAdapter(params: { }); const child = spawned.child as ChildProcessWithoutNullStreams; + // Pipe errors can arrive before output subscribers attach. Close remains + // responsible for decoder flush and Windows drain completion. + const ignoreOutputStreamError = () => {}; + child.stdout.on("error", ignoreOutputStreamError); + child.stderr.on("error", ignoreOutputStreamError); const childStdin = spawned.child.stdin; let stdinDestroyed = childStdin?.destroyed ?? false; let stdinEnded = childStdin?.writableEnded === true || childStdin?.writableFinished === true;