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
This commit is contained in:
Peter Steinberger
2026-07-18 17:00:08 -07:00
committed by GitHub
parent 8853217ec7
commit 9a94beace7
3 changed files with 32 additions and 4 deletions
+11 -3
View File
@@ -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<void>((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 () => {
+19 -1
View File
@@ -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 = () => {
+2
View File
@@ -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);