child_process: deliver each stdio event after the nextTicks and promise jobs of the one before
Problem
child.stdoutandchild.stderremit'end'and'close'before the promise jobs of the last'data'listeners when the EOF arrives with the last bytes.Readable.fromWeb(blob.stream())always does.- A consumer that awaits
'end', then listens for'close', never gets it:await once(child, "close")can hang. - Cause:
src/js/internal/streams/native-readable.tspushes all of one native read in one tick and emits'close'two ticks afterdestroy(), before the promise jobs.
Fix
- New
runAfterTickDrain(fn)(internal/process/after_tick_drain.ts): the tick loop runsfnonce the nextTick and promise job queues are empty. - Child stdio delivers each chunk, the EOF, a read error and
'close'(withhadError) through it, andReadable.fromWebits EOF. - Verified:
child-process-stdio.test.js,node-stream.test.js,spawn-stdio-syscall-error.test.ts(24 of 26 new tests fail without the fix). Other suites: Notes. - Self-reviewed: 32 concerns raised, 28 addressed (the rest: Notes).
Background
- A NativeReadable is a Node
Readableover a native source. Oneptr.pull()can return bytes and the EOF. - Node gets each chunk, the EOF and the close in separate libuv callbacks, and empties both queues after each (
processTicksAndRejections). - Considered a promise job that queues a nextTick, and
setImmediate. Fake timers replace both (Notes).
Downsides
- As in Node, a throw in a child stdio
'data'listener is now anuncaughtException, andfinished()calls back before'close'. - One queue entry and one tick loop pass per child stdio chunk. 4 GiB through
child.stdout: 1388 to 1671 MiB/s before, 1424 to 1683 after. - Do not merge before #34661's exit at the report: after a fatal error the stream goes on, and a wrapper that exits with its child's code exits 0. Main and Node exit 1 (Notes).
Notes
Design question for a maintainer. This adds a small primitive to the process.nextTick loop. The Notes below say why it is there and what it costs. If you prefer another layer, say so and I change it.
Repro (bun c.cjs and node c.cjs). The async listener does its work after an await, so it is not done when 'end' runs:
const cp = require("node:child_process");
let bad = 0, n = 0;
const N = 40;
function one() {
const c = cp.spawn("/bin/sh", ["-c", "printf AAAA; printf BB"], { stdio: ["ignore", "pipe", "ignore"] });
let got = 0, sent = 0;
c.stdout.on("data", async d => { sent += d.length; await null; got += d.length; });
c.stdout.on("end", () => {
if (got !== sent) bad++;
if (++n < N) one();
else console.log(`'end' ran before the last data listener's continuation: ${bad} of ${N}`);
});
}
one();
Node v26.3.0 prints 0 of 40. A release build of main prints 1 to 4 of 40 on an idle machine and more under load. This branch prints 0 of 40.
Event order of that repro on main, 100 runs under CPU load. The listener logs data(n), a nextTick logs tick(n), the code after await null logs job(n).
48x data(4) job(4) tick(4) data(2) job(2) tick(2) end close exit
30x data(6) job(6) tick(6) end close exit
19x data(6) tick(6) end close job(6) exit
3x data(6) tick(6) job(6) end close exit
node v26.3.0: always data, tick, job, then end. The position of exit varies.
The test fixture (fixtures/child-process-stdio-event-order.js) makes each case deterministic with files that the parent and the child wait for. Each listener queues a nextTick, a promise job, and two more jobs that each wait for a nextTick. Some rows, with C for tick(x) job(x) job2(x) job3(x):
first-read data the bytes and the EOF are in the pipe before the first read
node, this branch data(AAAABB) C end C close(false)
main data(AAAABB) tick end tick close job job(end) job2 job2(end) job3 job3(end)
waiting-read data the read already waits when the child writes
node, this branch data(AAAABB) C end C close(false)
main data(AAAABB) job tick end tick(end) close job2 job(end) job3 job2(end) job3(end)
two-chunks data the second chunk is in the pipe while the first 'data' listener runs
node, this branch data(AAAA) C data(BBBB) C end C close(false)
main data(AAAA) job tick data(BBBB) tick end tick(end) close job2(AAAA) job(BBBB) ...
first-read destroy a promise job of the listener destroys the stream
node, this branch data(AAAABB) destroy job(destroy) tick(destroy) job2(destroy) job3(destroy) close(false)
main data(AAAABB) end tick(end) close destroy ...
waiting-read throw the listener throws, an uncaughtException handler goes on
node, this branch data(AAAABB) uncaughtException(uncaughtException) C end C close(false)
main data(AAAABB) unhandledRejection, then no 'end' and no 'close'
both first-read data stdout and stderr both have their bytes before the first read
node, this branch data(OUT) C data(ERR) C
main data(OUT) data(ERR) tick(OUT) tick(ERR) job(OUT) job(ERR) ...
first-read for-await a `for await` loop, then a 'close' listener and `await once(child, "close")`
node, this branch data(AAAABB) consumed close(false) late-close(false) child-close-settled
main data(AAAABB) close(undefined) consumed child-close-settled
held-open for-await a grandchild holds the pipe, and writes when the parent has seen 'exit'
node, this branch exit data(AAAABB) consumed close(false) late-close(false) child-close-settled
main exit data(AAAABB) close(undefined) consumed
On Windows main only fails the data, throw and both rows, because libuv reports the bytes and the EOF separately there. Two rows run on POSIX only. first-read throw: Node on Windows runs the promise jobs of a listener that threw after 'end' when the EOF is already in the pipe. first-read read-on-exit: Bun reads a pipe asynchronously on Windows, so a read() call in 'exit' finds no bytes yet (the same on main). The for-await, await-end and held-open rows came later and ran on Linux only. The held-open rows are POSIX-only.
A consumer that listens for 'close' after its await. On main 'close' comes two ticks after destroy(), and the tick loop runs every tick before the promise jobs. So the pipe and the ChildProcess emit 'close' before the await of the consumer continues. This script shows it (<runtime> late-close.cjs <for-await|after-end|once-child>):
const { spawn } = require("node:child_process");
const { once } = require("node:events");
const mode = process.argv[2];
// once-child: a grandchild holds the pipe for 3 s after the child's 'exit'.
const cmd = mode === "once-child" ? "(sleep 3; printf bbbb) & printf aaaa; exit 0" : "printf aaaa";
const child = spawn("sh", ["-c", cmd], { stdio: ["ignore", "pipe", "ignore"] });
const out = [];
child.on("exit", () => out.push("exit"));
child.on("close", () => out.push("child:close"));
(async () => {
if (mode === "after-end") {
child.stdout.on("data", () => {});
await once(child.stdout, "end");
} else {
for await (const chunk of child.stdout) out.push("chunk");
}
out.push("consumed");
if (mode === "once-child") {
await once(child, "close");
out.push("once(child, 'close') settled");
} else {
child.stdout.on("close", hadError => out.push("late listener: close(" + hadError + ")"));
}
})();
setTimeout(() => { console.log(mode.padEnd(11) + out.join(" | ")); process.exit(0); }, 6000);
for-await node chunk | exit | consumed | child:close | late listener: close(false)
main chunk | consumed | exit | child:close
branch chunk | consumed | late listener: close(false) | exit | child:close
after-end node exit | consumed | child:close | late listener: close(false)
main consumed | exit | child:close
branch consumed | late listener: close(false) | exit | child:close
once-child node chunk | exit | chunk | consumed | child:close | once(child, 'close') settled
main chunk | exit | chunk | child:close | consumed
branch chunk | exit | chunk | consumed | child:close | once(child, 'close') settled
The main and branch lines come from debug builds of main's src/ (e4c638fab6) and of this branch. Bun 1.4.2 prints the same lines as main. In the first two modes the child's 'exit' and the EOF of the pipe race, so the position of exit is not part of the comparison. The fixture rows for-await, await-end and held-open make the three modes deterministic.
The ordinary cases keep their order. Node v26.3.0, main (Bun 1.4.2 prints the same), and this branch:
a 'close' listener added before the data
node stdout:data(aaaa) | exit | stdout:end | child:close | stdout:close(false)
main stdout:data(aaaa) | stdout:end | stdout:close(undefined) | exit | child:close
branch stdout:data(aaaa) | stdout:end | stdout:close(false) | exit | child:close
child.on("close") only all three exit | child:close(0,null)
exec() callback all three exit | callback(null,aaaa,eeee) | child:close
execFile() callback all three exit | callback(null,aaaa,) | child:close
an 'end' listener queues a tick, a promise job, an immediate and a 0 ms timer
node end | tick | job | immediate | close | timer
main end | tick | close | job | immediate | timer
branch end | tick | job | close | immediate | timer
Why a hook in the tick loop. A first version of this fix pushed the EOF from a promise job that queued a nextTick. A review found its limits. It needs the global queueMicrotask, which fake timers replace, so 'end' never came. A listener that awaits a promise which a nextTick settles still saw 'end' first. A chunk pushed from a nextTick let the next chunk run before the jobs of the one before. setImmediate would wait long enough, but fake timers replace it too, and it costs a turn of the event loop for each chunk. The tick loop is the one place that knows when both queues are empty. A native queue after the drain exists (DeferredTaskQueue in event_loop.rs), but it is unordered, and nothing drains the ticks and jobs that one of its tasks queues before the next task runs. The JS hook is the smaller change.
What the tick loop pays. The queue and its code are in internal/process/after_tick_drain.ts, which only native-readable.ts loads. The tick loop and that module meet through the object that internal/async_hooks_tick.ts exports, which the nextTick initializer already loads. The tick loop gets one optional call for each drain, in the loop condition, where Node's loop calls processPromiseRejections(). ensureTickLoop() creates the tick loop and marks it as having work without a call of process.nextTick, so the callbacks also run when fake timers replaced process.nextTick (they read it first, and that read creates the tick queue). When process.nextTick was overwritten before its first read, no tick queue exists, and runAfterTickDrain falls back to process.nextTick.
Nobody listens: push now. Without a 'data' or 'readable' listener nobody sees an order, and a read() call must get the bytes at once. Node reads a child's pipe from the start, so there the bytes are in the buffer already. child.on("exit", () => child.stdout.read()) depends on that.
'close'. A child's stdio stream is a net.Socket in Node: _destroy calls back at once, so 'error' comes in the next tick, and 'close' comes from the close callback of the handle with the argument hadError. The "libuv-handle" kind does the same: emitClose: false, the callback at once, and 'close' (with hadError) through runAfterTickDrain. So 'close' comes after the promise jobs of the 'end' listeners, and finished() calls back at 'end'. Node's close callback runs in a later phase of the loop, after the immediates. 'close' does not wait for an immediate here: fake timers replace setImmediate, and a pending immediate still runs after a fatal error, which is one more turn of the loop (#34661).
After a fatal error: do not merge before the exit at the report. Two cases end with exit status 0 on this branch, where main and Node v26.3.0 exit 1. In both, nothing handles the error (no 'error' listener, no uncaughtException listener), and a wrapper exits with its child's code on 'close'. The review thread on native-readable.ts:384 found them.
child.stdout.destroy(new Error("boom")) at the child's 'exit'
node 1 | main 1 | Bun 1.4.2 0 | branch 0 'close' is queued before the 'error' tick
a 'data' listener throws, the child exited before, the EOF is behind the data
node 1 | main 1 | Bun 1.4.2 1 | branch 0 the read is armed again after the throw
With an uncaughtException listener the branch does what Node does in both cases, and main does not. The state of the stream is the same after a throw that a listener took and after a fatal one, and the result of the report does not reach JS. So a guard in native-readable.ts cannot tell the two apart. It would take away Node's behaviour with a listener.
The stop belongs to the report. On main VirtualMachine::uncaught_exception counts the error and returns. The drain that runs goes on, and only the next turn of the loop is refused. After-drain callbacks stand for that next turn, but they run inside the drain.
#34661 makes a report that nothing handles end the main run, as Node does. With a stand-in for that (a preload that exits in uncaughtExceptionMonitor when nothing handles the error) both cases exit 1 with no further event, and the case with a listener does not change. When the exit at the report is on main, this PR gets rows for the fatal cases. #44849 has the first case and marks it failing.
A destroyed stream gets no more events. A promise job of the last 'data' listener can destroy the stream before the EOF gets its turn. Node reports nothing more for a closed handle, so deliverReadEvent drops what waits, and pushEof (from #43739) drops the EOF of a fromWeb stream. #43739 also drops the chunk that a flowing stream holds when destroy() runs (dropReadAhead). Child stdio no longer reads ahead while someone listens, so that call is now for fromWeb only. For child stdio this removes the difference that #43739 accepted: chunks buffered while paused are emitted after destroy(), as in Node.
A read error. A read error is an event too, so the chunks that the same native read gave come first, and 'close' has hadError true. test/js/bun/spawn/spawn-stdio-syscall-error.test.ts has a new case for that (Linux, LD_PRELOAD).
A destroy() callback that throws. _destroy queues 'close' before it calls back, as net.Socket registers the close callback of its handle before cb(exception). So a throw from the callback that destroy(err, cb) got does not lose 'close' (row destroy-callback-throws: Node and the branch print data(AAAABB) destroy-callback close(false), on main the stream never closes).
A listener that throws. The throw leaves push() before maybeReadMore(), so nothing read on, and 'end' never came. deliverReadEvent now reads on after a throw. This covers the child stdio part of #37171.
Cost. Release builds of main and of this branch at the same commit (bf80d21c69, before the merge of main into this branch), with the same configuration, interleaved runs on a busy 16-core machine. No difference shows:
4 GiB from `head -c` through child.stdout, sync 'data' listener, 6 runs each
main 1388 to 1671 MiB/s, parent user CPU 958 to 1316 ms
branch 1424 to 1683 MiB/s, parent user CPU 966 to 1310 ms
300000 writes of 64 bytes from a child, 5 runs each
main 0.34 to 0.45 s
branch 0.31 to 0.46 s
300 short children, one after the other, 5 runs each
main 0.67 to 0.74 ms per child
branch 0.64 to 0.78 ms per child
first process.nextTick() call (it creates the tick loop), 10th percentile of 60 to 200 runs, 5 rounds
main 1.00 to 1.05 ms
branch 1.02 to 1.12 ms
An earlier layout of this change had the queue inside the nextTick initializer. That made the first process.nextTick() call about 90 us slower, so the queue moved to the lazy module.
Suites run with the debug build, after the merge of main (5b4575267c):
- All pass:
child-process-stdio.test.js(28 tests),spawn-stdio-syscall-error.test.ts(18),node-stream.test.js(118),child-process-exec.test.ts,child_process-node.test.js,child_process_ipc.test.js,child_process_send_cb.test.js,process-nexttick.test.js,streams.test.js(688),readline.node.test.ts,process-stdin.test.ts. child_process.test.ts: 82 pass. 2 fail, and they fail with main'ssrc/too on this machine: the debug build loads its JS from files and runs out of file descriptors, and one GC test times out.process.test.js(188 tests) andAsyncLocalStorage-tracking.test.ts(74): they pass, apart from 5 s timeouts that hit different tests in each run on this machine (0 to 2 and 0 to 3 in three runs each).- 450 of Node's
test-child-process-*,test-stream*,test-readable*,test-readline*,test-next-tick*,test-microtask*,test-process-next-tick*,test-stdio-*,test-stdin*,test-stdout*,test-pipe*,test-async-hooks*,test-promise*andtest-domain*files: 448 pass.test-child-process-emfile.jsfails because the debug build loads its JS from files and the file descriptors run out.test-stdin-from-file-spawn.jsfails on a debug-only$assertinconstructNativeReadablethat main has too. - Before the merge: all of
test/js/node/child_process/,test/js/node/stream/,test/js/web/fetch/body.test.ts,test/js/web/fetch/fetch-backpressure.test.ts,test/js/node/async_hooks/, and on Windows x64 both test files,child_process.test.ts,child_process-node.test.jsandstreams.test.js(841 tests).
Left out after the self-review (the first three items below), plus one naming point: the bridge stays in internal/async_hooks_tick.ts under that name, to keep the diff small.
Differences from Node that this PR does not change:
- A child that writes faster than the parent reads keeps the tick loop busy while reads return bytes at once, so timers wait. This is on main too: there
flow()loops inside one nextTick. Release builds at the same commit,yesat full speed for 1 s, a 10 ms interval: main fires it 23 to 82 times of 99 (worst lateness 23 to 157 ms), the branch 44 to 64 times (36 to 44 ms), Node 99 times (3 ms). A bound needs a way to yield to the event loop that fake timers do not replace, or a cap on consecutive synchronous reads inFileReader::on_pull. - Only nextTicks and promise jobs run between two events. Node reads a pipe once for each turn of the event loop, so there a
setImmediate()from a listener also runs before the next event. Bun reports unhandled rejections after the whole drain, Node between two events. process.stdin(a pipe) andfs.createReadStreamrun the promise jobs of a'data'listener before its nextTicks, andprocess.stdincan emit'end'before a late job. They do not use NativeReadable.process.stdinis the natural next user ofrunAfterTickDrain. #34613, #43100 and #41484 are open on that code, so it is not part of this PR.Readable.fromWeb()of a native stream runs nextTicks before promise jobs whenpull()returns bytes at once, and emits available chunks back to back. Node'sfromWebpushes every chunk from a promise job.
#44849 is open for a regression on main: after an unhandled 'error' on child.stdout, the pipe and the ChildProcess never emit 'close'. It gives the child's pipes the same nodeSource argument, emitClose: false, and a _destroy that queues 'close' two ticks later. This branch does not lose that 'close' either. I ran the two test files of #44849 against this branch: every row passes except comes at the tick where the stream layer emits 'close', which pins the position that this PR moves. For child.stdout that row reads tick 1, tick 2, close, tick 3, promise job, immediate in #44849, tick 1, tick 2, tick 3, promise job, close, immediate here, and tick 1, tick 2, tick 3, promise job, immediate, close in Node. The PR that lands second needs a small rebase in _destroy.
Related open PRs that touch native-readable.ts: #37171 (a throw from a 'data' listener: this PR covers its child stdio case, and its pushAndCheck change conflicts). #36316 and #42281 add a third parameter to constructNativeReadable. #42584 routes the push(null) sites through a new endOfSource(). #41608 touches the constructor. None of them changes the order described here. Each needs a small rebase if this lands first.
no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/stream/node-stream.test.js, test/js/node/child_process/child_process.test.ts, test/js/node/child_process/child-process-stdio.test.js, test/js/bun/spawn/spawn-stdio-syscall-error.test.ts