Repository navigation
Conversation
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.
Collaborator
Author
|
Updated 1:23 PM PT - Oct 16th, 2026
❌ @robobun, your commit 87786a6 has 1 failures in 🧪 To try this PR locally: bunx bun-pr 45552That installs a local version of the PR into your bun-45552 --bun |
Collaborator
Author
|
Status
PR: #45552 |
This was referenced Oct 16, 2026
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.
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.
Collaborator
Author
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)andprocess.stdout.write(chunk, cb)run callbacks out of write order: the repro prints2,1,3, Node1,2,3.writeFastcallscb(null)insidewrite()(src/js/internal/fs/streams.ts:703), ahead of writes the sink still holds.write("", () => process.exit(0))to 65536 of 1048576 bytes. Awrite(x, next)loop throwsRangeError: Maximum call stack size exceeded.Fix
Writable, which orders it withend(),_finalanddestroy().Writableholds goes out first. A failed write destroys the stream, andprocess.stdoutrecovers as in Node.test/js/node/process/process-stdio-write-callback.test.tsand two more. 64 of 114 fail on main.Background
fs.WriteStreams whose ownwrite()(writeFast) gives each chunk to aFileSink, which can hold it, and skipsWritable.writeFast(end(chunk)anddestroy()bypass it), and nowriteFast(stdio: route process.stdout/stderr writes through Writable's buffer accounting #33508: 22 to 24% slower).Downsides
'unhandledRejection'handler that exits from a write callback exits 0.write(big); end()into| head -c 1emits'error'twice.process.exit()after aWritable.prototype.write()loses output. A write afterend()can drop theend()callback.write()calls back after the queued chunks #45319 fixes the order on a full pipe in the sink. This PR conflicts with it instreams.ts.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.
Behaviour (release builds of main 5866b5f and of this branch, Linux x64, Node v26.3.0)
2,1,31,2,31,2,3stdout.write(1 MiB); stdout.write("", () => process.exit(0))into a pipefunction next() { if (++n < 200000) process.stdout.write("x", next) }RangeError: Maximum call stack size exceeded.write("A", w1); moveCursor(0, 0, m0); write("B", w2); cursorTo(3, c); moveCursor(1, 0, m1)w1,w2,c,m1,m0w1,m0,w2,c,m1w1,m0,w2,c,m1write(w); nextTick(t1); write(w); nextTick(t2); write(x); write(w)w,w,x,w,t1,t2w,w,t1,t2,x,ww,w,t1,t2,x,wwrite()uncaughtExceptionuncaughtExceptionunhandledRejectionuncaughtExceptionuncaughtExceptionwrite(1 MiB, w0); write("", w1); write("b", w2)pendingw1insidewrite(),w0:EPIPE,w2:EPIPE,errorw0:EPIPE,w1:EPIPE,w2:EPIPE,errorwrite(1 MiB, cb); Writable.prototype.write.call(stdout, "B"); write("C"): the bytes after the first chunkBCBCBC'drain'eventscork(); write(1 MiB, w0); write("b", w1), anduncork()on the next turnw0:EPIPE,error,w1:EPIPE, all beforeuncork()uncork():w0:EPIPE,w1:EPIPE,errorEPIPEon piped stdout:errored, and a laterwrite(chunk, cb)EPIPE, the callback runs insidewrite()null, the callback runsnull, the callback runsHow the marker works
writeFaststill callssink.write(chunk)insidewrite(). When the sink returns a promise, the writes that it parks on that promise form a run.Writable.prototype.write(marker, cb)with a zero-lengthBuffer.Writablequeues it like any chunk and calls_writefor one marker at a time._writecompletes a marker at once when its chunk joined no run. Otherwise it completes it when the run settles: withnull, or with the error of the run.'drain'is emitted once per run, on a tick before the callbacks.Writablein call order,end()waits for them, anddestroy()and a failed write report throughWritable's own paths.The contract table
process-stdio-write-callback-fixture.jsruns 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".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.tty.WriteStream, a Worker'sprocess.stdout,net.Socket,fs.createWriteStream,PassThrough) has four clauses: no callback insidewrite(), write callbacks in write order, theend()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)
"hello world\n"to /dev/null, median of 10 interleaved roundswrite())Writablewhose_writecompletes 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.internal/fs/streams.js17505 to 19493 bytes. Release binary text (size): 106,331,459 to 106,333,447 bytes, which is the same 1,988 bytes.perf,valgrind,straceandbloatyare not installed here, and the container has no PMU.Tests
process.stdoutreports the error of a run that failed before. No row covers that: on a pipe that stays broken, Node reportsEPIPEfor that write and the sink reports success (FileSink: report a buffered write that fails later, and keep the sink usable on a file #41568).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.tsandtest/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.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.process.stdoutandprocess.stderrrows use a FIFO and do not run on Windows. On Windows the sink ofprocess.stdoutwrites synchronously, and oneuv_writetakes a whole chunk forchild.stdin, so the tests that need a held write or a write that fails after it was accepted are skipped there. Thechild.stdinrows on a pipe that the child closed before the calls do not finish on Windows, and are skipped there too.child_process.test.tsalready 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 anuncaughtExceptionin 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.stdoutandprocess.stderron a pipe are destroyed and reset, as in Node:erroredisnullagain and'close'is emitted. On main the stream stays errored. Atty.WriteStreamthat user code made is destroyed by a failed write.cork()now holds the callbacks untiluncork(). The bytes still go out at once.User code that replaces
_writeon one of these streams now gets a zero-length chunk for eachwrite()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 intohead -c 1:hruns once, then the process dies of an uncaughtEPIPE: broken pipe, write. With.on,hruns 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)printsA. Main and Node printABC. WhileWritableholds a chunk,write()goes throughWritable, andprocess.exit()drops whatWritableholds.write("A", w0); Writable.prototype.write.call(out, "B", p1); write("C", w2); end(cb); write("E", w4). Theend()callback never runs. On main and Node it getsERR_STREAM_DESTROYED.process.stdout._writesees a zero-length chunk for each write that has a callback. On main it sees nothing, on Node every chunk.Needs #42031 first
'unhandledRejection'handler queues never runs when nothing else keeps the process alive:process.on("unhandledRejection", () => process.nextTick(() => process.exit(7)))exits 0, Node exits 7.write()on main, soprocess.on("unhandledRejection", e => process.stderr.write(msg, () => process.exit(1)))exits 1 today. With this PR alone it exits 0.Still as on main
write()returnstruefor a chunk that the sink takes behind one that it holds. Node returnsfalse.writableLengthandwritableNeedDrainstay 0 andfalse. The encoding argument is ignored (fs: decode and validate write(chunk, encoding) on the WriteStream fast path (process.stdout, stderr, tty, child.stdin) #33474).write(data); process.exit()with no callback truncates. console.log truncates output at 64 KiB after importing @babel/core #36419 and bun test: stderr report loses its head (the(fail)lines) when stderr is a pipe and a test prints a ~100 KB expect diff; sizes cap under 64 KiB multiples; a file redirect never loses (1.3.14, macOS) #41782 are other truncation reports. This PR does not change them.destroy()the write that the sink holds reports what the sink says, which can be success. Node reportsECANCELED.'error'can come before that callback.end(chunk)andWritable.prototype.write()chunks complete inside the sink's promise reaction.child.stdinis constructed one tick after it is made. Until then its write callbacks wait, and adestroy()in that tick givesERR_STREAM_DESTROYEDto the callbacks of writes that the sink already took. child_process: make child.stdin born constructed, as in Node.js #45195 makes the stream constructed from the start.Other open PRs
process.stdout: an empty
write()calls back after the queued chunks #45319 (an emptywrite()behind a full pipe returns the promise of the sink, one'drain'per promise): on a build of main with process.stdout: an emptywrite()calls back after the queued chunks #45319, the repro above prints1,2,3and the exit idiom delivers all 1048576 bytes. What it leaves is the callback insidewrite()when the sink takes the chunk at once. Content conflict with this PR insrc/js/internal/fs/streams.ts.console: output goes behind what process.stdout/stderr have buffered, as in Node #37128 (console output goes behind what the stream has buffered) no longer touches
streams.tsand keepswriteFast. No conflict with this branch.child_process: end the stdin pipe in child.stdin's _final #45002 (
child.stdin's_finalends the pipe) conflicts in two type lines ofstreams.tsand inchild-process-stdio.test.js. Merged by hand, one of its tests changes: "end(cb) after a write() that failed calls back without autoDestroy" expectswrite callback, error, end callback. With this PR the order is Node's:write callback, end callback, error. The PR that merges second updates that line.process.stdout/stderr with no FileSink: write synchronously, reset after a failed write #44954 conflicts textually in
streams.ts, where both add functions at one place. child_process: make child.stdin born constructed, as in Node.js #45195 and FileSink: end the writer before the parked promise settles #45318 conflict in test files only. child_process: make piped stdio streams instances of net.Socket #36316 makeschild.stdinanet.Socket, which takes it off this path. process.stdout/stderr: emit 'close' on its own tick over a pipe, a socket or a TTY, as in Node #44980 and Run a microtask checkpoint after each batch of unhandledRejection listeners, and follow Node's captureRejections #42031 do not conflict.