From 9a94beace77be7c0ee8a19d7b3dd4cbdb5a1e5e4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 17:00:08 -0700 Subject: [PATCH] fix(process): preserve descendant output under event-loop stalls (#111040) * fix(process): drain buffered descendant output after exit * test(process): cover deferred output release phase --- src/process/child-process.test.ts | 14 +++++++++++--- src/process/child-process.ts | 20 +++++++++++++++++++- src/process/exec.windows.test.ts | 2 ++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/process/child-process.test.ts b/src/process/child-process.test.ts index 4c5c7d50c695..ced476fccbef 100644 --- a/src/process/child-process.test.ts +++ b/src/process/child-process.test.ts @@ -19,8 +19,7 @@ describe.skipIf(process.platform === "win32")("releaseChildProcessOutputAfterExi }); it("drains active descendant output after the parent exits", async () => { - const command = - 'printf "HEAD\\n"; ( for i in 1 2 3 4 5 6; do sleep 0.05; printf "TICK$i\\n"; done ) &'; + const command = 'printf "HEAD\\n"; ( sleep 0.05; printf "TAIL\\n" ) &'; child = execa("/bin/sh", ["-c", command], { buffer: false, detached: true, @@ -33,9 +32,18 @@ describe.skipIf(process.platform === "win32")("releaseChildProcessOutputAfterExi output += chunk.toString(); }); + // Simulate a contended worker after the direct child exits. The descendant + // writes while JS is parked, so its pipe data and the idle timer are both + // ready when the event loop resumes. + await new Promise((resolve) => { + child?.once("exit", () => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250); + resolve(); + }); + }); await child.finally(releaseOutput); expect(output).toContain("HEAD"); - expect(output).toContain("TICK6"); + expect(output).toContain("TAIL"); }); it("releases a quiet inherited pipe after the idle grace", async () => { diff --git a/src/process/child-process.ts b/src/process/child-process.ts index 76bc1f5e25db..2b49a00ff7dc 100644 --- a/src/process/child-process.ts +++ b/src/process/child-process.ts @@ -14,6 +14,7 @@ const EXIT_STDIO_MAX_DRAIN_MS = 1_000; export function releaseChildProcessOutputAfterExit(child: ChildProcess): () => void { let exited = false; let idleTimer: NodeJS.Timeout | undefined; + let idleReleaseImmediate: NodeJS.Immediate | undefined; let deadlineTimer: NodeJS.Timeout | undefined; const clearTimers = () => { @@ -21,6 +22,10 @@ export function releaseChildProcessOutputAfterExit(child: ChildProcess): () => v clearTimeout(idleTimer); idleTimer = undefined; } + if (idleReleaseImmediate) { + clearImmediate(idleReleaseImmediate); + idleReleaseImmediate = undefined; + } if (deadlineTimer) { clearTimeout(deadlineTimer); deadlineTimer = undefined; @@ -41,7 +46,20 @@ export function releaseChildProcessOutputAfterExit(child: ChildProcess): () => v if (idleTimer) { clearTimeout(idleTimer); } - idleTimer = setTimeout(release, EXIT_STDIO_GRACE_MS); + if (idleReleaseImmediate) { + clearImmediate(idleReleaseImmediate); + idleReleaseImmediate = undefined; + } + idleTimer = setTimeout(() => { + idleTimer = undefined; + // A loaded event loop can observe the idle timer before already-buffered + // pipe data. Give the poll phase one turn so that data can rearm the grace. + idleReleaseImmediate = setImmediate(() => { + idleReleaseImmediate = undefined; + release(); + }); + idleReleaseImmediate.unref(); + }, EXIT_STDIO_GRACE_MS); idleTimer.unref(); }; const onData = () => { diff --git a/src/process/exec.windows.test.ts b/src/process/exec.windows.test.ts index dfa4bf80b2aa..18fe86ec8b6d 100644 --- a/src/process/exec.windows.test.ts +++ b/src/process/exec.windows.test.ts @@ -389,6 +389,8 @@ describe("Windows command execution", () => { expect(execaMock).toHaveBeenCalledTimes(1); expect(command.stdout.destroyed).toBe(false); await vi.advanceTimersByTimeAsync(19); + expect(command.stdout.destroyed).toBe(false); + await vi.advanceTimersToNextTimerAsync(); expect(command.stdout.destroyed).toBe(true); expect(command.stderr.destroyed).toBe(true);