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;