Skip to content

child_process: deliver each stdio event after the nextTicks and promise jobs of the one before - #43790

Draft
robobun wants to merge 8 commits into
mainfrom
robobun/b94aed8d/child-stdio-end-after-data-jobs
Draft

robobun wants to merge 8 commits into
mainfrom
robobun/b94aed8d/child-stdio-end-after-data-jobs

Conversation

@robobun

@robobun robobun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • child.stdout and child.stderr emit '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.ts pushes all of one native read in one tick and emits 'close' two ticks after destroy(), before the promise jobs.

Fix

  • New runAfterTickDrain(fn) (internal/process/after_tick_drain.ts): the tick loop runs fn once the nextTick and promise job queues are empty.
  • Child stdio delivers each chunk, the EOF, a read error and 'close' (with hadError) through it, and Readable.fromWeb its 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 Readable over a native source. One ptr.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 an uncaughtException, and finished() 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 domain: route fs callback throws to uncaughtException, unblocking 4 tests (domain 88%→96%) #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's src/ 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) and AsyncLocalStorage-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* and test-domain* files: 448 pass. test-child-process-emfile.js fails because the debug build loads its JS from files and the file descriptors run out. test-stdin-from-file-spawn.js fails on a debug-only $assert in constructNativeReadable that 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.js and streams.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, yes at 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 in FileReader::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) and fs.createReadStream run the promise jobs of a 'data' listener before its nextTicks, and process.stdin can emit 'end' before a late job. They do not use NativeReadable. process.stdin is the natural next user of runAfterTickDrain. process.stdin: deliver 'end' when pause() inside a 'data' handler races a buffered EOF #34613, process.stdin: do not reset the ended state when resume() follows EOF #43100 and process.stdin: end instead of throwing EISDIR when fd 0 is a directory #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 when pull() returns bytes at once, and emits available chunks back to back. Node's fromWeb pushes 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

…ise jobs of the one before

A child's stdout and stderr got the last bytes and the EOF from one
native read and pushed both from one nextTick drain, so 'end' and
'close' ran before the promise jobs that the listeners of the last chunk
queued. A chunk from a pending read was pushed inside a promise job, so
a listener's promise jobs ran before its nextTicks. Chunks that were
available at once were emitted back to back, and 'close' ran before the
promise jobs of the 'end' listeners.

Node reports each chunk, the EOF, a read error and the close of a libuv
handle from a callback of its own, and runs every nextTick and promise
job between two callbacks. internal/process/after_tick_drain gives a
callback such a point: the tick loop runs one of its callbacks each time
the nextTick queue and the promise job queue are both empty. A child's
stdio streams deliver their events from there while a 'data' or
'readable' listener can see the order. A throw from a 'data' listener is
an uncaught exception now, and the stream reads on.

Readable.fromWeb of a native stream pushes its EOF from there too, so a
promise job of the last 'data' listener runs first, and can destroy the
stream before the EOF.
@robobun

robobun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: blocked, and a draft until then. Do not merge this before a report that nothing handles ends the run. #34661 has that change.

Why: after a fatal error this branch still delivers stream events. A wrapper that exits with its child's code then exits 0, where main and Node exit 1. The review thread on native-readable.ts:384 and the Notes of the PR body have the two cases and the measurements. No guard in this PR can tell a fatal throw from one that an uncaughtException listener took, so the source does not change for it.

How I reproduced it

  • The repro in the PR body (bun c.cjs): main prints 1 to 4 of 40 on an idle machine and more under load. This branch prints 0 of 40, as Node v26.3.0 does.
  • late-close.cjs in the Notes of the PR body: on main the 'close' listener that a consumer adds after its await never runs, and await once(child, "close") never settles. On this branch both come, as in Node v26.3.0.
  • test/js/node/child_process/fixtures/child-process-stdio-event-order.js makes each order deterministic. The parent and the child wait for files. So the bytes and the EOF are in the pipe before the first read, or the read already waits when the child writes, or a grandchild holds the pipe until the parent has seen 'exit'. Node v26.3.0 prints the same lines as this branch.
  • With main's src/ and the debug build, 24 of the 26 new tests fail. With this branch all of them pass.

Two things need a maintainer's decision:

@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 80f8ebf2-a639-41b6-9a62-d0ff04d6edc9

📥 Commits

Reviewing files that changed from the base of the PR and between 66871ac and 7b9dfc1.

📒 Files selected for processing (4)
  • src/js/builtins/ProcessObjectInternals.ts
  • src/js/internal/async_hooks_tick.ts
  • src/js/internal/process/after_tick_drain.ts
  • src/js/internal/streams/native-readable.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.

Walkthrough

The change adds post-tick-drain callbacks and integrates them with native readable streams. Libuv-backed streams now queue chunk, error, EOF, and close events for ordered delivery. Child-process and webstream tests cover event ordering and destruction behavior.

Changes

Native stream event ordering

Layer / File(s) Summary
Tick drain coordination
src/js/builtins/ProcessObjectInternals.ts, src/js/internal/async_hooks_tick.ts, src/js/internal/process/after_tick_drain.ts
The tick loop now supports post-drain callbacks. The dispatcher preserves callback order, async context, queue state, and exception handling.
Native readable event delivery
src/js/internal/streams/native-readable.ts, src/js/internal/webstreams_adapters.ts, src/js/node/child_process.ts
Libuv-handle streams queue and deliver chunk, error, EOF, and close events after the drain cycle. Webstream behavior remains source-specific. Adapters pass the source type.
Event-order regression coverage
test/js/bun/spawn/spawn-stdio-syscall-error.test.ts, test/js/node/child_process/*, test/js/node/stream/node-stream.test.js
Tests cover failing reads, data and error ordering, promise jobs, destruction, replaced process.nextTick, and native-backed Readable.fromWeb streams.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 7b9df

The change aligns native stream event delivery with Node-compatible queue ordering and adds regression coverage; no actionable merge-blocking risk is currently established.

Pre-merge checks: Passed 4
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly and concisely describes the main change: ordering each child-process stdio event after prior nextTick and promise-job processing.
Description check Passed The description explains the problem, fix, design rationale, limitations, performance impact, and verification results. It does not use the template headings exactly, but it provides the required cont…
Full details: Description check

Explanation

The description explains the problem, fix, design rationale, limitations, performance impact, and verification results. It does not use the template headings exactly, but it provides the required content in equivalent sections.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

…back

The callback of _destroy runs the one that destroy() got, and that one
can throw. net.Socket registers the close callback of its handle before
it calls back, so 'close' still comes.
Comment thread src/js/internal/async_hooks_tick.ts Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked the two call sites of the new constructNativeReadable signature (child_process.ts:1300 and webstreams_adapters.ts:41 are the only callers) and the "webstream" path: with [kReadEvents] undefined, chunks still go through pushAndCheck, pull errors still reach errorOrDestroy, and only the EOF moves to runAfterTickDrain, so Readable.fromWeb keeps its base-branch behavior apart from the intended EOF ordering. The destroy reorder in 7b9dfc1 (queue emitClose before cb(error)) covers the destroy-callback-throw case I raised earlier.

Extended reasoning...

The change adds an after-tick-drain hook to the process.nextTick loop and reworks native-readable event delivery for child_process stdio and Readable.fromWeb; it touches no security-sensitive surface. Findings are posted inline, so this note only records what else was examined: the signature change has exactly two callers, and the non-handle path preserves prior behavior. The tick-loop primitive is a design decision the author explicitly asks a maintainer to weigh in on.

The array with a head index only shrank when it became empty. With two
streams that each queue their next event before the other one is taken,
it never became empty, and it grew by one slot for each event.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed the latest push and found no bugs; the two earlier inline points ('close' queued before the destroy callback, the after-drain queue growth) are addressed in the current code. Because this adds a new hook into the process.nextTick loop and changes the delivery model for every child_process stdio stream, a maintainer decision on the tick-loop primitive (which the description itself asks for) is still worthwhile.

What was reviewed:

  • runAfterTickDrainCallback in processTicksAndRejections: only evaluated once the tick queue is empty, one entry per drain, and a throw leaves the async-context reset to JSNextTickQueue::drain the same way a throwing tick does.
  • The kReadEvents lifecycle in native-readable.ts: bounded to one _read's results, cleared on destroy, _read suppressed while events are pending or after EOF, and both constructNativeReadable callers pass the new nodeSource argument.
  • emitClose: false plus a direct emit("close") leaves closeEmitted unset, matching Node's net.Socket; the process.nextTick-overwritten-before-first-read fallback and the _exiting case were checked and do not strand the stream.
Extended reasoning...

The change touches src/js/builtins/ProcessObjectInternals.ts (the nextTick loop), a new src/js/internal/process/after_tick_drain.ts, src/js/internal/async_hooks_tick.ts, src/js/internal/streams/native-readable.ts, and the two callers in child_process.ts and webstreams_adapters.ts, plus new event-order tests. It touches no security-sensitive surface (no auth, crypto, injection, or path handling). The bug hunt ran dry with no findings, and the code addresses the prior inline comments, but the PR introduces a cross-cutting primitive in the tick loop and alters event timing for all child_process stdio streams and Readable.fromWeb EOF, which is a design decision a maintainer should weigh rather than something to approve automatically. I could not run the test suite here since no debug build exists in this checkout.

Jarred-Sumner pushed a commit that referenced this pull request Sep 29, 2026
### Problem
- `test/js/bun/spawn/spawn-stdio-syscall-error.test.ts` is red on
alpine: `"lost": -82000`, not `0` (build 120303). #43739 added the case,
not the bug.
- `read_loop` (`src/io/PipeReader.rs:766`) delivers the bytes read
before a failed read, then the error. The consumer asks for more inside
that delivery, and the reader reads the fd past the error. The error
comes late, or never.

### Fix
- `read_once` sets `PosixFlags::READ_FAILED` on a fatal error, and
`begin_read` does not read while it is set. The request parks, then
`on_reader_error` rejects it. Nothing clears the flag.
- Correct: libuv `uv__read` clears `UV_HANDLE_READABLE` before it
reports a read error.
- Verified: `test/js/bun/spawn/spawn-stdio-syscall-error.test.ts`, 17
pass. The four new cases fail without the fix. Suites: Notes.
- Self-reviewed: 15 concerns raised, 14 addressed. Not taken: a flag
reset in `start()`, which nothing on main needs.

### Background
- `PosixBufferedReader` reads the fd behind subprocess stdio, file
streams and the shell. Its parent gets `on_read_chunk`, then
`on_reader_done` or `on_reader_error`.
- `FileReader` is the parent behind a `ReadableStream`. `on_read_chunk`
resolves the parked `pull()`, and the reaction runs before it returns.
- Considered: a repeating shim failure hides the lost error. An error
stored in `FileReader` first needs a callback in every parent.
- #43920 is a newer PR with the same change. Its test trigger is here.

### Downsides
- After a read error, a stream that ended with `'end'` now ends with
`'error'`, as in Node. With no listener the process stops.
- After a read error, `bun run --filter` no longer drains that pipe at
exit.
- Reads that do not fail pay nothing: `begin_read` tests one more bit.

<details><summary>Notes</summary>

**Trace without the fix** (bun 1.4.3-canary.1+367d939d9, shim logs each
`recv()`, writer `dd bs=1025`). The debug build of main at 8d36bff
does the same: 3 of 8 runs, two with no `'error'` and `lost` -7714150:

```
recv #5 len=262144 -> 95325
recv #6 len=166819 -> EIO (injected)      same fill_scratch call as #5
JS data 95325
recv #7 len=65536 -> 65536                 pull from inside the delivery, read_into
recv #8 .. #116                            to EOF
{"received":452025,"got":8000125,"lost":-7548100,"events":["stdout.close","close"]}
```

No `'error'` event: the reads reached EOF before `on_reader_error` ran,
and a stored error is only returned by a later pull. When the reads park
first, `on_reader_error` rejects that pull and `'error'` comes late.
That is the CI signature: the events match and `lost` is a negative
multiple of 1025.

**Why alpine.** In CI the failure is injected: the shim fails only the
Nth `recv()`, so a later `recv()` succeeds and the extra bytes show. The
failing `recv()` must follow bytes in the same wakeup. BusyBox `head`
writes 1025 bytes at a time, so a wakeup often holds a short `recv()`
and then the failing one. coreutils `head` fills the buffer in one
`recv()`. The same run fails on debian when the writer is slow. With a
writer that copies BusyBox `head` (stdio, 1024-byte buffer), the
`RECV_AT=6` case fails 7 of 40 runs on bun 1.4.3-canary (release), and
36 of 40 when the writer also spins between chunks. This branch (debug):
0 of 120, and 0 of 60 with `dd bs=1025`.

**With no shim.** A child with one AF_UNIX socket as fd 0 and fd 1
writes a line to stdout and reads stdin. The peer leaves that line
unread, sends 8192 bytes and closes. The kernel gives the child 8192
bytes, then `ECONNRESET`, then EOF.

| Runtime | stdin events |
|---|---|
| Node v26.3.0 | `'error'` `ECONNRESET` after 8192 bytes |
| bun 1.4.3-canary.1+367d939d9 | `'end'` after 8192 bytes, no error |
| this branch | `'error'` `ECONNRESET` after 8192 bytes |

**The new cases.** Each one reaches the reader from inside the delivery
in a different way. Whole-file runs of the describe block:

| Case | Entry | Release, no fix | Debug, no fix | Debug, guard in
`read_into` only | Debug, this branch |
|---|---|---|---|---|---|
| `child_process`, `'data'` listener | pull, `read_into` | fails 6 of 6
| fails 5 of 5 | passes | passes |
| `child_process`, `'readable'` and `read()` | `set_flowing(true)`,
`read` | fails 6 of 6 | fails 5 of 5 | fails 3 of 3 | passes |
| `Bun.spawn`, `lazy` | pull, `read_into` | fails 6 of 6 | fails 5 of 5
| passes | passes |
| `Bun.spawn`, reader started at spawn | pull, `read_into` | fails 6 of
6 | fails 5 of 5 | passes | passes |

- The first three use counts: `SPAWN_FAULT_RECV_CAP=4096` makes every
`recv()` short, so `fill_scratch` calls `recv()` again in the same
wakeup. `SPAWN_FAULT_RECV_EAGAIN_AT=2` ends the first read loop, so the
consumer's read parks and the next read is poll-driven.
`SPAWN_FAULT_RECV_AT` then fails after bytes in that wakeup. For the
`'readable'` case it is 19: #3 to #18 return 64 KiB, the highWaterMark,
so the reader is stopped and `read()` starts it again.
- The fourth uses state, and comes from #43920: the writer waits for a
line on stdin, so the first read is parked when the bytes arrive.
`SPAWN_FAULT_RECV_MID_FILL=1` fails the `recv()` that follows one that
returned bytes, and `SPAWN_FAULT_READS_AFTER` counts the `recv()` calls
after it. Without the fix it is 1.
- With a count-based trigger and the reader started at spawn, the case
passed without the fix on a debug build: the buffered reader that runs
before JS reads `.stdout` took the bytes and the error. That is why this
case uses state.
- With the BusyBox-like writer at four speeds, the whole file passes 20
of 20 on this branch.

**With the fix**, `CAP=4096 EAGAIN_AT=2 RECV_AT=5`:

```
recv #3 -> 4096, recv #4 -> 4096, recv #5 -> EIO
JS data 8192
close(fd)
JS error EIO
```

**Node.** libuv `uv__read` (`src/unix/stream.c`): on a read error other
than `EAGAIN` it clears `UV_HANDLE_READABLE | UV_HANDLE_WRITABLE`, calls
`read_cb` with the error, then stops the watcher. It calls `read_cb`
once for each `read()`, so it never holds bytes and an error from one
batch.

**Placement.** EOF and the `maxBuffer` stop have the same guard at this
site: `close_if_final` closes the reader before the final chunk is
delivered. An error cannot use it, because a closed reader with no
stored error reads as a clean end. For the same reason `READ_FAILED` is
not part of `is_done()`.

**Parents** (14 `BufferedReaderParent` implementations, what each does
in `on_reader_error`):

- 3 release the fd: `FileReader`, `SubprocessPipeReader`, `Terminal`.
- 2 drop the reader: `FileResponseStream`, shell `subproc.rs`.
- 8 only do accounting: `filter_run.rs`, `multi_run.rs`,
`lifecycle_script_runner.rs`, `security_scanner.rs`, `git_runner.rs`,
both cron jobs, test `Worker.rs`. `lifecycle_script_runner.rs` and
`cron.rs` build a new reader with `init()` for each spawn.
- 1 is shared and can restart: the shell `IOReader`.

Only two read the same reader after an error. `filter_run.rs`
`drain_and_close_pipes` reads once more at exit. That read is now a
no-op, and `deinit()` follows. Before, it could reach a second terminal
callback and decrement `remaining_fds` twice. The shell
`IOReader::start()` does not restart a reader after a failed read on
main, because the fired one-shot poll still counts as registered. The
Windows reader gets one libuv callback for each read, so it has no
bytes-then-error batch.

**Left open.**

- The flag is permanent. #39638 and #37901 change `IOReader::start()` to
restart the shell's stdin reader. After this PR they must clear
`READ_FAILED` there, or a `cat` that follows a stdin read error gets no
data, no EOF and no error.
- Not in this PR: a batch that stops because the buffer is full is
labelled `ReadState::Eof` when the poll event carries the hangup
(`read_state`, `None if received_hup`). `Bun.write(file, proc.stdout)`
then writes 262144 of 400000 pending bytes and resolves. It is on main
and in 1.4.3-canary, and this PR does not change it. It needs its own
change.
- Not in this PR: release the fd on a read error once, in
`PosixBufferedReader::on_error`. #41456 names it as a follow-up. #41420,
#41456 and #42150 did it for one parent each.

**Suites run on the debug build:** `test/js/web/streams/streams.test.js`
(624 pass), `test/js/node/stream/node-stream.test.js` (112 pass),
`test/js/bun/spawn/spawn-streaming-stdout.test.ts` (pass),
`test/js/node/child_process/child_process.test.ts` (81 pass, 2 fail in
my container for reasons outside this change: "spawn in the default
shell" reads an empty `$SHELL`, and "extra stdio pipes are not
double-closed on GC" needs 5.0 s in a debug build against the 5 s
timeout, its script prints `OK`). With this branch's build, the test
file of #43920 passes 17 of 17 in 3 runs.

#43790 is open and edits the comment above the failing case. It changes
the event order in `native-readable.ts`, not the reader.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/bun/spawn/spawn-stdio-syscall-error.test.ts

<!-- robobun:evidence:end -->
@robobun

robobun commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

#44849 carries a part of this change on its own, as the fix for a regression on main (after an unhandled 'error' on child.stdout, the pipe and the ChildProcess never emit 'close'). It has the same required nodeSource argument of constructNativeReadable, emitClose off for the child's stdout and stderr, and a _destroy for them (destroyHandle) that queues 'close' before it calls its callback.

If #44849 merges first, the change to _destroy here reduces to the scheduler inside destroyHandle. Two things to keep in that rebase:

  • emit("close", hadError). This PR emits 'close' with no argument today. Node passes hadError, and the rows in test/js/node/child_process/child-process-stdio.test.js assert it.
  • The test comes at the tick where the stream layer emits 'close' in that file. It pins the position that this PR moves on purpose, so its expected order has to change with the scheduler.

…er that listens after its await

A consumer that awaits the end of child.stdout (for await, or
once(stdout, "end")) and then listens for 'close' gets it, and
once(child, "close") after that settles. The fixture has the rows:
for-await, await-end, and held-open, where a grandchild holds the pipe
until the parent has seen the child's 'exit'.

The 'close' of a child's stdout and stderr now has net.Socket's
hadError argument.
@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:22 AM PT - Oct 17th, 2026

❌ @robobun, your commit 1bbaee0 has 4 failures in Build #127319 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 43790

That installs a local version of the PR into your bun-43790 executable, so you can run:

bun-43790 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

…RE_FLAG_NO_ORPHANS

The test runner sets BUN_FEATURE_FLAG_NO_ORPHANS for ASAN builds. With it
a Bun child ends its own children when it exits. So the grandchild that
holds the pipe died before it wrote, and the stream ended before 'exit'.
The fixture removes the variable from the child's environment in that
mode.
}
if (this[kReadEvents] !== undefined) {
// As net.Socket: 'close' comes from the handle's close callback. It is queued before cb(), which can throw.
runAfterTickDrain(error ? emitCloseAfterError : emitClose, this);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 A wrapper that exits with its child's code can now exit 0 after a fatal, unhandled read error on child.stdout, where main and Node exit 1. At native-readable.ts:384 the 'close' is queued as its own after-drain entry, independent of the tick that emits 'error'; when that emit throws because nobody listens, the entry still runs, 'close' fires, and child_process.ts:1657 #maybeClose emits the ChildProcess 'close' before the loop stops. Fix: no 'close' after an 'error' that went unhandled, as on main and in Node; e.g. arm the close from the tick that emits 'error' only once emit has returned. The PR's Downsides note calls this accepted; it reinstates the #34661 behavior. [also at: src/js/internal/streams/native-readable.ts:335 - A wrapper whose child.stdout 'data' listener throws with no uncaughtException handler can now exit 0 instead of 1 when it exits with its child's code.]

Why this was flagged

Trigger: a read of a piped child stdout or stderr fails (ptr.pull throws or rejects, e.g. EIO), or user/library code calls child.stdout.destroy(err), while the stream has no 'error' listener (exec/execFile attach only 'data' listeners). The handle-mode destroy at native-readable.ts:384 queues emitCloseAfterError via runAfterTickDrain and then cb(error); destroy.ts:104 queues emitErrorCloseNT on nextTick. In the tick loop emitErrorNT at destroy.ts:152 throws the error (no listener); JSNextTickQueue.cpp:93 calls processTicksAndRejections again, whose loop condition at ProcessObjectInternals.ts:378 runs runAfterTickDrainCallback, so stream.emit('close', true) at native-readable.ts:401 runs in the same drain. child_process.ts:1302 then calls #maybeClose and emits the ChildProcess 'close' at child_process.ts:1661; a handler doing process.exit(child.exitCode) exits 0. On the base branch emitErrorNT throwing skips emitCloseNT, so the ChildProcess never emits 'close', and the run ends with code 1. The existing safeguard, the uncaught-exception exit code, is defeated because the explicit process.exit in the close handler runs first.

Verification: Acknowledged in the PR's "Downsides" bullet. destroy() at native-readable.ts:382-386 queues the 'close' entry independently of the error emit; emitErrorNT at destroy.ts:152 throws with no listener, VirtualMachine.rs:2150-2153 only sets exit_code = 1 and keeps running, and the re-entered loop at ProcessObjectInternals.ts:378 emits 'close', so child_process.ts:1657-1662 emits ChildProcess 'close'. Base never emitted 'close' here and exited 1.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both cases are real. I measured them on Node v26.3.0, Bun 1.4.2, a debug build of main's src/ and this branch. The number is the exit status of a wrapper that exits with its child's code on 'close'.

  • child.stdout.destroy(err) that nothing handles: Node 1, main 1, Bun 1.4.2 0, this branch 0.
  • A 'data' listener throws after the child's 'exit', with the EOF behind the data: Node 1, main 1, Bun 1.4.2 1, this branch 0.

I do not add a guard at :384 or :335. At both lines the state of the stream is the same after a throw that an uncaughtException listener took and after a fatal one. errorEmitted is set before the emit, and the result of the report does not reach JS (Bun__reportUnhandledError drops the bool of uncaught_exception). With a listener Node emits 'close' after the error and reads on after the throw, and the throw rows pin the second. A guard on the state of the stream takes that away.

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. The after-drain callbacks stand for that next turn, but they run inside the drain. Node exits at the report. #34661 makes a report that nothing handles end the main run.

I ran this branch with a stand-in for that: a preload that exits in uncaughtExceptionMonitor when no uncaughtException listener and no capture callback exists. Both cases exit 1 with no further event, and the case with a listener does not change.

So this PR must not merge before the report ends the run. The Downsides section and the Notes say so now. I leave this thread open until then.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants