Skip to content

stdio: report write callbacks through Writable, never inside write() - #45552

Draft
robobun wants to merge 5 commits into
mainfrom
robobun/d3f2b684/write-callback-order
Draft

robobun wants to merge 5 commits into
mainfrom
robobun/d3f2b684/write-callback-order

Conversation

@robobun

@robobun robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

On hold, do not merge. Three open regressions (see Downsides). #45319 now fixes the reported order in the sink.

Problem

  • child.stdin.write(chunk, cb) and process.stdout.write(chunk, cb) run callbacks out of write order: the repro prints 2,1,3, Node 1,2,3.
  • Cause: writeFast calls cb(null) inside write() (src/js/internal/fs/streams.ts:703), ahead of writes the sink still holds.
  • It also cuts write("", () => process.exit(0)) to 65536 of 1048576 bytes. A write(x, next) loop throws RangeError: Maximum call stack size exceeded.

Fix

  • A write with a callback also sends a zero-length marker through Writable, which orders it with end(), _final and destroy().
  • A chunk that Writable holds goes out first. A failed write destroys the stream, and process.stdout recovers as in Node.
  • Verified: test/js/node/process/process-stdio-write-callback.test.ts and two more. 64 of 114 fail on main.
  • Self-reviewed: the third round found the regressions under Downsides.

Background

Downsides

Notes

Repro

Exit code 1 on main, 0 with this PR and on Node v26.3.0. There is no issue for this. The report came from differential fuzzing against Node.

// child.stdin.write(chunk, callback): the callbacks must fire in the order of the writes.
import { spawn } from "node:child_process";
if (process.argv[2] === "kid") { process.stdin.resume(); process.stdin.on("end", () => process.exit(0)); }
else {
  const rest = [import.meta.filename, "kid"];
  const kid = spawn(process.execPath, process.versions.bun ? ["--no-install", ...rest] : rest, { stdio: ["pipe", "ignore", "inherit"] });
  const order = [];
  kid.stdin.write(Buffer.alloc((1 << 20) + 1), () => order.push(1));
  kid.stdin.write("", () => order.push(2));
  kid.stdin.write("x", () => order.push(3));
  kid.stdin.end(() => { console.log("callbacks fired in the order", order.join(",")); process.exit(order.join() === "1,2,3" ? 0 : 1); });
}

Behaviour (release builds of main 5866b5f and of this branch, Linux x64, Node v26.3.0)

Probe main this PR Node
The repro 2,1,3 1,2,3 1,2,3
stdout.write(1 MiB); stdout.write("", () => process.exit(0)) into a pipe 65536 of 1048576 bytes, exit 0 1048576 1048576
function next() { if (++n < 200000) process.stdout.write("x", next) } RangeError: Maximum call stack size exceeded. completes completes
write("A", w1); moveCursor(0, 0, m0); write("B", w2); cursorTo(3, c); moveCursor(1, 0, m1) w1,w2,c,m1,m0 w1,m0,w2,c,m1 w1,m0,w2,c,m1
write(w); nextTick(t1); write(w); nextTick(t2); write(x); write(w) w,w,x,w,t1,t2 w,w,t1,t2,x,w w,w,t1,t2,x,w
A callback that throws, chunk taken at once the throw leaves write() uncaughtException uncaughtException
A callback that throws, chunk held by the sink unhandledRejection uncaughtException uncaughtException
The pipe breaks with write(1 MiB, w0); write("", w1); write("b", w2) pending w1 inside write(), w0:EPIPE, w2:EPIPE, error w0:EPIPE, w1:EPIPE, w2:EPIPE, error same as this PR
write(1 MiB, cb); Writable.prototype.write.call(stdout, "B"); write("C"): the bytes after the first chunk BC BC BC
Two writes of 1 MiB in one turn: 'drain' events 2 1 1
The pipe is already broken: cork(); write(1 MiB, w0); write("b", w1), and uncork() on the next turn w0:EPIPE, error, w1:EPIPE, all before uncork() after uncork(): w0:EPIPE, w1:EPIPE, error same as this PR
After a handled EPIPE on piped stdout: errored, and a later write(chunk, cb) EPIPE, the callback runs inside write() null, the callback runs null, the callback runs

How the marker works

  • writeFast still calls sink.write(chunk) inside write(). When the sink returns a promise, the writes that it parks on that promise form a run.
  • A write that has a callback then calls Writable.prototype.write(marker, cb) with a zero-length Buffer. Writable queues it like any chunk and calls _write for one marker at a time.
  • _write completes a marker at once when its chunk joined no run. Otherwise it completes it when the run settles: with null, or with the error of the run. 'drain' is emitted once per run, on a tick before the callbacks.
  • So the callbacks come out of Writable in call order, end() waits for them, and destroy() and a failed write report through Writable's own paths.

The contract table

  • process-stdio-write-callback-fixture.js runs one row: a stream (process.stdout, process.stderr, child.stdin), the state of its pipe (room, full, breaks after the calls, already broken) and a sequence of calls. It runs unchanged under Node: node process-stdio-write-callback-fixture.js stdout parked "write x3".
  • All 111 rows, Node against this PR: 99 reports are identical. The other 12 are rows with destroy() or with a write made from the 'error' listener. There the test checks only that each write callback runs once, in order, after the calls, because Node and Bun do not give those callbacks the same result.
  • Main differs from Node in 78 of the 111 rows.
  • A larger table from the review of this branch (141 rows: also tty.WriteStream, a Worker's process.stdout, net.Socket, fs.createWriteStream, PassThrough) has four clauses: no callback inside write(), write callbacks in write order, the end() callback and 'finish' after them, each callback once. This PR: 0 rows fail. Main: 60.

Cost (release builds of main and of this branch, Linux x64)

Per write main this PR
No callback, "hello world\n" to /dev/null, median of 10 interleaved rounds 130.1 ns 131.0 ns (spread 6 to 8%)
No callback: ticks, objects held, write syscalls 0, 0.31, 1 0, 0.31, 1
One shared callback, 200,000 writes in one turn 190.9 ns 427.3 ns
A new closure per write, 200,000 writes in one turn 198.2 ns 796.0 ns
Each callback makes the next write, 10,000 writes 392.8 ns 1584.5 ns
A new closure per write, 20,000 writes per round, best of 17 rounds 130.3 ns 224.0 ns
Ticks per 1000 writes: shared callback, new closures 0, 0 (the callbacks ran inside write()) 1, 1000
Bytes held per write until it reports: new closure, chunk taken at once 36 228
Objects held per write while the sink holds it: no callback, shared callback 5.02, 5.02 0.02, 1.02
Heap after 200,000 writes in one turn: shared callback, new closures 0.66 MiB, 0.66 MiB 0.80 MiB, 35.91 MiB
  • Node v26.3.0 on the same three loops: 496 ns, 1497 ns, 1740 ns. Its heap after the burst with new closures: 58.52 MiB.
  • The added cost is that of one write to a plain Writable whose _write completes at once: 166 ns with a shared callback, 477 ns with a new closure, 1290 ns in the loop where each callback makes the next write.
  • Size: internal/fs/streams.js 17505 to 19493 bytes. Release binary text (size): 106,331,459 to 106,333,447 bytes, which is the same 1,988 bytes.
  • Not measured: instructions per write. perf, valgrind, strace and bloaty are not installed here, and the container has no PMU.

Tests

  • The three test files: 114 pass with this PR (debug build and release build). With main: 50 pass, 64 fail.
  • Each clause of the new code was removed in turn, 16 changes. 15 make at least one test fail. The other one is the line that clears the run of a stream when the run settles. Without it, a later write on process.stdout reports the error of a run that failed before. No row covers that: on a pipe that stays broken, Node reports EPIPE for that write and the sink reports success (FileSink: report a buffered write that fails later, and keep the sink usable on a file #41568).
  • The rows with cork() are there for two of those clauses. A corked write reports when the stream is uncorked. In the state where the pipe is already broken, its write has failed by then.
  • test/js/node/{process,child_process,readline,stream}, tty.test.ts and test/regression/issue/25432.test.ts (42 files, 852 tests, debug build): 14 fail. 10 of them fail with main's stream code in the same run. The other 4 are 5 s timeouts of tests that need 3 to 5 s in this container and that also time out on main.
  • Vendored Node tests: 238 files (child-process, stdio, stdout, stderr, stdin, tty, readline, console, fs-write-stream, process-exit, stream-writable, stream-pipeline, stream-finished, unhandled rejections), release builds: 234 pass on main and with this PR, same exit code per file.
  • macOS and Windows: the tests that run there pass.
  • The process.stdout and process.stderr rows use a FIFO and do not run on Windows. On Windows the sink of process.stdout writes synchronously, and one uv_write takes a whole chunk for child.stdin, so the tests that need a held write or a write that fails after it was accepted are skipped there. The child.stdin rows on a pipe that the child closed before the calls do not finish on Windows, and are skipped there too. child_process.test.ts already skips its test of that case on Windows.

What changes for callers

  • A write callback runs one tick later than on main, never inside write().

  • A callback of write() runs one tick after the call. A throw from it is an uncaughtException in every case.

  • 'drain' fires once for all the writes that the sink held on one promise, on a tick. Main fires it once per held write.

  • After a failed write, process.stdout and process.stderr on a pipe are destroyed and reset, as in Node: errored is null again and 'close' is emitted. On main the stream stays errored. A tty.WriteStream that user code made is destroyed by a failed write.

  • cork() now holds the callbacks until uncork(). The bytes still go out at once.

  • User code that replaces _write on one of these streams now gets a zero-length chunk for each write() that has a callback. On main it gets no call at all. On Node it gets every chunk.

Open regressions (release build of this branch against main and Node v26.3.0)

  • process.stdout.once("error", h); process.stdout.write(Buffer.alloc((1 << 20) + 1)); process.stdout.end() piped into head -c 1: h runs once, then the process dies of an uncaught EPIPE: broken pipe, write. With .on, h runs twice. Main and Node: once. Two things report the one failure: the run of held writes, and the flush promise of _final. The stream is destroyed and reset between them, so the second report emits 'error' again.
  • Writable.prototype.write.call(process.stdout, "A"); process.stdout.write("B"); process.stdout.write("C\n"); process.exit(0) prints A. Main and Node print ABC. While Writable holds a chunk, write() goes through Writable, and process.exit() drops what Writable holds.
  • Stdout is a file: write("A", w0); Writable.prototype.write.call(out, "B", p1); write("C", w2); end(cb); write("E", w4). The end() callback never runs. On main and Node it gets ERR_STREAM_DESTROYED.
  • A replaced process.stdout._write sees a zero-length chunk for each write that has a callback. On main it sees nothing, on Node every chunk.

Needs #42031 first

Still as on main

Other open PRs

process.stdout, process.stderr, tty.WriteStream and child.stdin give each
chunk to their sink inside write() and skip the queue of Writable. Their
write() also called the callback right there when the sink took the chunk
at once. So a callback ran inside write(), and ahead of the callbacks of
earlier writes that the sink still held.

A write that has a callback now also sends a zero-length marker through
Writable. Writable reports the callback, in order with end(), _final and
destroy(). _write completes the marker when the writes that the sink
parked together with its chunk have settled, or at once when the sink
holds nothing. The chunk still reaches the sink inside write(), and a
write without a callback does what it did before.

Two things follow from Writable being in the path. A chunk that Writable
still holds goes out first: write() sends its own chunk through Writable
while that is so. And a failed write destroys the stream also without
autoDestroy, because Writable holds back every later write of a stream
that is errored and not destroyed. process.stdout and process.stderr come
back from that destroy ready for the next write, as in Node.
@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:23 PM PT - Oct 16th, 2026

❌ @robobun, your commit 87786a6 has 1 failures in Build #126794 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 45552

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

bun-45552 --bun

@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

PR: #45552

The fixture read its own FIFO with fs.createReadStream(). A read of an
empty FIFO holds a thread, and with BUN_DESTRUCT_VM_ON_EXIT=1 (set on the
ASAN lane) the exit of the fixture waits for that thread and never ends.
`cat` reads the pipe now.
Comment thread src/js/internal/fs/streams.ts Outdated
Comment thread src/js/internal/fs/streams.ts Outdated
Comment thread src/js/internal/fs/streams.ts Outdated
Comment thread src/js/internal/fs/streams.ts Outdated
A corked write whose pipe has failed reports when the stream is uncorked.
The rows run that in a state where the read end is closed before the calls,
so the write has failed by the time of uncork().
Both rows waited 90 s there and timed out. The test of the same case in
child_process.test.ts is skipped on Windows too.
@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

The prerequisite that the description names, #42031, is closed. #45597 makes the same change and is the prerequisite now.

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