Conversation
|
Updated 4:21 PM PT - Jul 16th, 2026
❌ @robobun, your commit 842a0e3 has 3 failures in
🧪 To try this PR locally: bunx bun-pr 33508That installs a local version of the PR into your bun-33508 --bun |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
I didn't find any bugs, but this trades a documented ~22-24% process.stdout.write throughput regression for correctness on a very hot path and supersedes four other open PRs — that's a maintainer-level call worth a human sign-off.
Extended reasoning...
Overview
This PR removes the own-property write() fast path (writeFast) from FileSink-backed WriteStreams (process.stdout/process.stderr, tty.WriteStream, child stdin) and routes writes through the standard Writable.prototype.write machinery instead. _write remains as the bridge to the native FileSink, now with decodeStrings: false and a manual non-UTF-8 decode branch. The kWriteMonkeyPatchDefense hack and hand-rolled emit('drain') are deleted. Net -78/+24 in src/js/internal/fs/streams.ts, plus five new subprocess-based tests in test/js/node/process/process-stdio.test.ts.
Security risks
None identified. No auth, crypto, permissions, or untrusted-input parsing is involved; the change is confined to Node stream compatibility semantics.
Level of scrutiny
High. process.stdout.write() is on the hot path of essentially every Bun program, and the fast path being removed was added deliberately for performance. The author transparently documents a 22-24% throughput drop on the /dev/null microbenchmark (4-12% against a real pipe, still ahead of Node). Whether that correctness-for-speed trade is acceptable — and whether the decodeStrings: false + in-_write encoding branch is the right shape versus reconstructing the accounting by hand — is a design/perf judgment a maintainer should make, not a bot.
Other factors
- The PR claims to supersede #33474, #31538, #33485, and #33500; someone needs to coordinate closing those and confirm nothing from them is lost (the author flags that #33500's fixture stalls under the new semantics and on Node).
- The change interacts with
_writev(still set toundefinedon the fast path) and withcork()/uncork()batching behavior — worth a second pair of eyes on whether corked writes now go through_writeone-by-one where they previously batched. - Test coverage is thorough and follows repo conventions (subprocess spawns,
await using, marker-based readiness instead of sleeps, exit code asserted last), and the author reports zero drift across 913 Node parallel tests. The implementation itself reads correct to me; my hesitation is purely about the perf trade-off and cross-PR coordination, not about bugs.
WalkthroughThe FileSink-backed write fast path is rewritten to use lazy writer setup and uniform callback handling. Stdio tests add coverage for child stdin lifecycle errors and process stdout buffering, encoding, inheritance, and callback ordering. Changesstdio write path and coverage
Sequence Diagram(s)sequenceDiagram
participant WriteStream
participant FileSink
WriteStream->>FileSink: writer() lazily initialized
WriteStream->>FileSink: write(chunk or Buffer.concat(chunks))
FileSink-->>WriteStream: promise or thrown error
WriteStream-->>WriteStream: cb(null) or cb(err)
Related issues: Related PRs: None identified Suggested labels: node.js, tests Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
I arrived at the same I pushed the regression coverage for it onto this branch in
All four fail on 1.4.0 (today Closing #33511. |
There was a problem hiding this comment.
I didn't find any bugs, but this deliberately removes the stdio writeFast bypass and takes a measured 4–24% hit on process.stdout.write() in exchange for correct Writable accounting — that's the right architectural call IMO, but it's a perf-vs-correctness tradeoff on one of the hottest paths in the runtime and it supersedes 4+ competing PRs, so a maintainer should sign off on the direction.
Extended reasoning...
Overview
The PR deletes the own-property write() override (writeFast) that process.stdout/stderr, tty.WriteStream, and child stdin install on the $fastPath, and instead lets Writable.prototype.write do the buffer accounting while _write (underscoreWriteFast) bridges into the native FileSink. It also flips decodeStrings to false on this path and decodes non-UTF-8 encodings inside _write. Net -78/+24 in src/js/internal/fs/streams.ts, plus ~250 lines of new test coverage for backpressure accounting, encoding, prototype inheritance, callback ordering, and write-after-end/destroy.
Security risks
None. This is Node-compat stream plumbing with no auth, crypto, or untrusted-input parsing surface changes.
Level of scrutiny
High. process.stdout.write() is one of the most-called functions in any Bun program, and the writeFast bypass was presumably added intentionally for throughput. The PR itself measures a 22–24% regression to /dev/null and 4–12% to a drained pipe (still faster than Node). The author argues convincingly that no fast path can stay correct here — whether the sink buffers is only knowable after the write — but whether the perf cost is acceptable, and whether this is the right consolidation vs. the four PRs it supersedes (#33474, #31538, #33485, #33500) plus #33511, is a maintainer-level architectural call.
Other factors
- The change is well-reasoned, thoroughly tested (9 new tests, all verified failing on main), and the description shows zero drift across 913 Node parallel tests.
- The bug-hunting pass found nothing; the simplified
_writelooks correct (synccb(null)when the sink accepts outright is fine — Writable'sonwritehandles the sync-completion case and defers via nextTick). bytesWrittenis still not maintained on this path (issue #23061) — the fast-path_writedoesn't increment it — but that's pre-existing and orthogonal to this PR's goal.- No prior human review comments to address; CI build #69103 was still in progress at the time of this review.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/node/process/process-stdio.test.ts`:
- Around line 336-337: Remove the uncorked lower-bound assertion from the
process stdio test and keep the rest of the buffering checks intact. The control
case in the test around the uncorked write loop can legitimately coalesce writes
because process.stdout uses Bun.file(fd).writer(), so do not require uncorked to
be one syscall per write; leave the corked threshold check and the byte-count
assertion in place, and update the test logic near the uncorked/corked
comparisons accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 4387cb64-7159-4a8a-895f-1e693864dad6
📒 Files selected for processing (2)
test/js/node/child_process/child-process-stdio.test.jstest/js/node/process/process-stdio.test.ts
f3086e6 to
f9d0d9c
Compare
|
Thanks — one of these found a real gap. Going through all four. @coderabbitai: the uncorked control could coalesce — correct, and it matteredYou were right, and the failure mode was worse than "the control is weak": on a pipe, a slow reader makes the sink coalesce both numbers, so the Rather than drop the control, I pointed stdout at a regular file. A file never applies backpressure, so every sink write is exactly one
So the control is now @claude: "do corked writes now go through
|
|
Nice catch also on the (◕‿◕)ノ nice work tracking that one down ✏️ Learnings added
✅ Action performedComments resolved. Approval is disabled; enable |
93c4436 to
7e71cd0
Compare
|
CI surfaced two things that change what this PR claims, so flagging them here rather than leaving them in a silent description edit. Branch is rebased onto 1.
|
fs.createWriteStream(...).write(ab) |
process.stdout.write(ab) |
|
|---|---|---|
| node | throws ERR_INVALID_ARG_TYPE |
throws ERR_INVALID_ARG_TYPE |
| bun, today | throws ERR_INVALID_ARG_TYPE |
accepted |
| this PR | throws ERR_INVALID_ARG_TYPE |
throws ERR_INVALID_ARG_TYPE |
A view over the buffer (new Uint8Array(ab)) is the supported spelling and is unaffected. Two in-tree helpers were leaning on the old leniency — they wrote bun:jsc serialize()'s SharedArrayBuffer to stdout — and are updated; the behavior is now pinned by process.stdout - write() rejects an ArrayBuffer like node, which fails on main with "accepted,accepted".
This one genuinely wants a maintainer's opinion. It cannot be avoided on this path: the validation lives in Writable.prototype.write, so keeping ArrayBuffer working would mean reintroducing the write() override this PR exists to delete. If Bun wants to keep accepting it on stdio, that has to be a deliberate extension somewhere else.
2. Windows still does not report backpressure, and this PR does not fix it
writableLength / writableNeedDrain remain 0 / false on Windows. Its writer hands the chunk to uv_write and reports completion the moment libuv accepts it, so the sink never tells the stream it had to buffer, and writableLength cannot see libuv's queue. On main, a 4 MB write into a blocked pipe returns true there too, so this predates the change and is native-side — closing it means teaching WindowsBufferedWriter to report Pending while a uv_write is in flight, which I can't build or test from here.
cork() accounting does work on Windows (the burst buffers correctly). The two backpressure tests now carry skipIf(isWindows) with that reason recorded inline, so the gap is visible rather than silently green. POSIX (Linux + macOS) is unaffected and matches node byte-for-byte.
Everything else CI flagged was the cookie-map Expires format, unrelated and fixed by the rebase. Locally, the four files CI named are green, the new ArrayBuffer test fails on main and passes here, and the suites I touched show no new failures against a main baseline.
Status: diff is green, CI is red on known main breaksRebased onto current main. All touched files pass locally, 913 node Build 74124 (current,
Build 73936 ( The two items that need a human decision are in the description: the deliberate |
Happy to rebase, just say the word. |
|
Appreciated. To set expectations though: I'm not a maintainer, so the call on whether to fold #31627 in here or land it separately on top isn't mine to give. That's for whoever merges. What I can do is make the rebase target precise so it's a small move whenever it happens. After this PR, the whole stdio write path funnels through one function in function underscoreWriteFast(this: FSStream, chunk, encoding, cb) {
...
maybePromise = fileSink.write(chunk); // <- the single point bytes reach the sink
...
}
One thing to keep in mind for the counting: on this path a UTF-8 string reaches
|
3e060b7 to
ab685c4
Compare
…r destroy (#34267) ## Repro Writing to a spawned child's `stdin` after the child has exited returns `true` and calls the write callback with `null`, even though `stdin.destroyed === true` and `stdin.writable === false`. Node returns `false` and calls back with `ERR_STREAM_DESTROYED`. ```js import { spawn } from "node:child_process"; import { once } from "node:events"; const child = spawn("true", { stdio: ["pipe", "ignore", "ignore"] }); await once(child, "close"); const ret = child.stdin.write("dropped", err => console.log({ ret, cb: err ? err.code : "success", destroyed: child.stdin.destroyed }), ); ``` ``` node: { ret: false, cb: 'ERR_STREAM_DESTROYED', destroyed: true } bun : { ret: true, cb: 'success', destroyed: true } ``` Any code feeding a child pipeline (gzip, ffmpeg, a formatter) that flushes after the child dies believes the bytes were delivered: silent data loss with a success callback. ## Cause `child.stdin` is a `fs.WriteStream` on the FileSink fast path. That path installs `writeFast` as an own `.write` which bypasses `Writable.prototype.write` entirely. It already deferred to the real Writable machinery when `state.ending` was set (so `end()` then `write()` correctly raised `ERR_STREAM_WRITE_AFTER_END`), but it never checked `state.destroyed`, so writes after `destroy()` reached the closed sink, which returned synchronously and mapped to `cb(null); return true`. ## Fix Also defer to `Writable.prototype.write` when `state.destroyed` is set. The existing Writable `_write` helper already handles this case (`ERR_STREAM_DESTROYED`, callback via `process.nextTick`, `errorOrDestroy`), so routing through it gets the full Node contract for free. ## Verification Added two tests in `test/js/node/child_process/child-process-stdio.test.js` covering write-after-`close` and write-after-`exit`. Both fail on stock bun with `{ ret: true, cbCode: undefined }` and pass with the fix. Note: #33508 removes `writeFast` entirely in favor of going through `Writable.prototype.write` for buffer accounting, which would also fix this. This PR is the minimal targeted change in case that one takes longer to land. <!-- robobun:evidence:begin --> --- **[review]** gate passed · iteration 1 · 2 files touched <details><summary>fails on main (without fix)</summary> ```console ASAN without fix: 2 FAILED $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/child_process/child-process-stdio.test.js info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05) info: component rust-src is up to date info: checking for self-update (current version: 1.29.0) bun test v1.4.0 (c1207a6) test/js/node/child_process/child-process-stdio.test.js: (pass) process.stdout > should allow us to write to it [1320.63ms] (pass) process.stdin > should allow us to read from stdin in readable mode [1550.61ms] (pass) process.stdin > should allow us to read from stdin via flowing mode [1548.14ms] killed 1 dangling process (pass) process.stdin > should allow us to read > 65kb from stdin [5110.24ms] (pass) process.stdin > should allow us to read from a file [1575.38ms] 134 | expect({ 135 | ret, 136 | cbCode: cbErr?.code, 137 | destroyed: child.stdin.destroyed, 138 | writable: child.stdin.writable, 139 | }).toEqual({ ^ error: expect(received).toEqual(expected) ... (truncated) release without fix: all passed bun test v1.4.0-canary.1 (1db92f7) test/js/node/child_process/child-process-stdio.test.js: (pass) process.stdout > should allow us to write to it [25.67ms] (pass) process.stdin > should allow us to read from stdin in readable mode [29.97ms] (pass) process.stdin > should allow us to read from stdin via flowing mode [31.07ms] (pass) process.stdin > should allow us to read > 65kb from stdin [39.69ms] (pass) process.stdin > should allow us to read from a file [29.65ms] (pass) child.stdin > write() after child 'close' returns false and calls back with ERR_STREAM_DESTROYED [2.82ms] (pass) child.stdin > write() after child 'exit' (before 'close') returns false and calls back with ERR_STREAM_DESTROYED [29.01ms] 7 pass 0 fail 8 expect() calls Ran 7 tests across 1 file. [339.00ms] __F:0:S:0 ``` </details> <details><summary>passes on PR (with fix)</summary> ```console ASAN with fix: all passed $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/child_process/child-process-stdio.test.js info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05) info: component rust-src is up to date info: checking for self-update (current version: 1.29.0) bun test v1.4.0 (c1207a6) test/js/node/child_process/child-process-stdio.test.js: (pass) process.stdout > should allow us to write to it [1323.53ms] (pass) process.stdin > should allow us to read from stdin in readable mode [1560.41ms] (pass) process.stdin > should allow us to read from stdin via flowing mode [1564.60ms] killed 1 dangling process (pass) process.stdin > should allow us to read > 65kb from stdin [5083.47ms] (pass) process.stdin > should allow us to read from a file [1680.26ms] (pass) child.stdin > write() after child 'close' returns false and calls back with ERR_STREAM_DESTROYED [159.26ms] (pass) child.stdin > write() after child 'exit' (before 'close') returns false and calls back with ERR_STREAM_DESTROYED [15 ... (truncated) release with fix: all passed $ bun scripts/build.ts --profile=release info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05) info: component rust-src is up to date info: checking for self-update (current version: 1.29.0) [configured] bun-profile → bun (stripped) in 733ms (unchanged) ninja: Entering directory `/workspace/bun/build/release' [1/20] gen JS modules (bundle-modules) Preprocess modules (7921ms) Bundle modules (33ms) Postprocesss modules (139ms) Bundle Functions (814ms) Generate Code (88ms) [9.01s] Bundled "src/js" for production 1911 kb 162 internal modules 12 native modules 90 internal functions across 19 files [1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu) info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05) info: component rust-src is up to date info: component rust-std is up to date info: checking for self-update (current version: 1.29.0) nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 202 ... (truncated) ``` </details> <details><summary>diff hotspot</summary> ``` src/js/internal/fs/streams.ts | 6 +-- .../node/child_process/child-process-stdio.test.js | 49 ++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) ``` </details> **gate history** · 2 passed · 0 rejected · iteration 1 <details><summary>evidence per changed file</summary> ``` file reads edits tests src/js/internal/fs/streams.ts 1 1 0 test/js/node/child_process/child-process-stdio.test.js 1 4 0 ``` </details> <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
91839ff to
b301076
Compare
b301076 to
3fac1dc
Compare
…ccounting process.stdout, process.stderr, tty.WriteStream and a child's stdin installed an own write() that wrote straight into the native FileSink, skipping Writable.prototype.write. _writableState was therefore never updated: writableLength stayed 0, writableNeedDrain stayed false, cork() buffered nothing, the encoding argument was dropped, write callbacks could complete out of order, and 'error' never fired when a write callback was supplied. Delete the override and let writeOrBuffer() do the accounting. _write() bridges into the FileSink, completing synchronously when the sink took the whole chunk and on the sink's promise when it had to buffer. decodeStrings is off for this path so UTF-8 strings still reach the sink without a Buffer.from() round-trip, matching node, whose process.stdout over a pipe is a net.Socket.
Deleting the writeFast() override restores Writable.prototype.write's ERR_STREAM_WRITE_AFTER_END / ERR_STREAM_DESTROYED checks on process.stdout, process.stderr and a child's stdin. Pin that: before this, a write issued after end() invoked the callback with no error, returned true, and for process.stdout the bytes still reached the fd.
With cork() finally buffering, clearBuffer() was flushing the backlog one chunk at a time: a corked burst of 1000 writes cost 1000 write(2) calls where node's stdio coalesces the same burst into a single writev(2). Give the FileSink-backed streams a _writev so the whole backlog reaches the sink as one write. Measured with /proc/self/io's syscw counter, stdout on a regular file: 1000 syscalls -> 1.
Three CI failures on build #69135, two of them real: - structuredClone tests wrote bun:jsc serialize()'s SharedArrayBuffer straight to process.stdout. The old bypass fed anything the sink accepted through untyped; Writable validates the chunk, so it now throws ERR_INVALID_ARG_TYPE like node, and like bun's own fs.WriteStream already did. Pass a Uint8Array view instead, and pin the new behavior with a test. - The two backpressure tests fail on Windows: its writer hands the chunk to uv_write and reports completion as soon as libuv accepts it, so the sink never reports buffering and writableLength can't see libuv's queue. Pre-existing and native-side (write() returns true on main there too). Skipped there, with the reason recorded; cork() accounting does work on Windows. The cookie-map failures were unrelated, already fixed on main; rebased.
…process helper The structured-clone cross-process helper was rewritten on main into a persistent child (#33622), which writes serialize()'s SharedArrayBuffer to process.stdout in a second spot. Writable rejects bare (Shared)ArrayBuffer (as node does, and as bun's own fs.WriteStream already did), so view it through a Uint8Array like the cold helper already does.
3fac1dc to
842a0e3
Compare
|
Closing: #37128 reworks the stdio sinks and lists this PR as superseded (it removes the own Tests from this PR that #37128 does not have (corked burst reaching the fd as one write, If something here turns out not to be covered once #37128 lands, this can be reopened. |
Repro
Every documented backpressure signal on
process.stdoutexcept thewrite()return value reports "nothing buffered".Producers that throttle on
writableLengthorwritableNeedDrain(loggers, NDJSON/CSV writers, hand-rolled pump loops) see an empty buffer forever and keep writing at full speed into an unread pipe.Cause
process.stdout,process.stderr,tty.WriteStreamand a child'sstdinall take thefs.WriteStream$fastPath, which installs an ownwrite()(writeFast) that hands the chunk straight to the nativeFileSinkand never callsWritable.prototype.write._writableStateis therefore never touched, sowriteOrBuffer()/onwrite()/afterWrite()never run. Five separate contracts break off that one bypass:writableLength/writableNeedDrainstuck at0/falsestate.lengthand theneedDrainflag are only maintained bywriteOrBuffer()/onwrite()cork()buffers nothingstate.bufferedwrite(chunk, encoding)ignoresencodingwriteFastpassed the raw string to the sinkwriteFastran the callback synchronously when the sink accepted outright, jumping the queue of callbacks already parked on the sink's promise'error'event when a write callback is suppliedwriteFastonly called the callback;onwriteError()does bothend()/destroy()silently succeededWritable.prototype.write'sERR_STREAM_WRITE_AFTER_END/ERR_STREAM_DESTROYEDchecks were never reachedFix
Delete the
write()override and let the standardWritablemachinery do the accounting._write()stays as the bridge into theFileSink: it completes the write synchronously when the sink took the whole chunk (so back-to-back writes don't pile up in the Writable buffer), and defers to the sink's promise when the sink had to buffer, which is exactly what makeswritableLengthandwritableNeedDrainhonest. The hand-rolledemit("drain")and thekWriteMonkeyPatchDefensehack go away with it;afterWrite()emits'drain'andwrite()is now inherited from the prototype, like node's.decodeStringsis set tofalseon this path so UTF-8 strings still reach the sink without aBuffer.from()round-trip; other encodings are decoded first. node'sprocess.stdoutover a pipe is anet.Socket, which also runs withdecodeStrings: false.decodeStringsaside, the one thing the Writable machinery needs that it didn't have is a_writev, so a corked burst reaches the sink as a single write instead of one per chunk (see below).Supersedes #33474, #31538, #33484, #33485, #33500, #33557, #34267, #34268 (and #33511, already closed into this branch)
Each of those PRs patches one of the symptoms above from inside
writeFast. Removing the bypass fixes them at the root; every one is verified againstmainand node below, not assumed.write("48490a","hex") + write("QUJD","base64") + setDefaultEncoding("hex") + write("21")48490aQUJD21HI\nABC!HI\nABC!process.stdout.write === process.stdout.constructor.prototype.write(sonic-boom/pino tamper check)falsetruetrue["cb:EIO", ...]["cb:EIO","error:EIO"]["cb:EIO","error:EIO"]write("A",cb)/moveCursor(0,0,cb)/write("B",cb)/cursorTo(3,cb)on a ptyw1,w2,c,m0w1,m0,w2,cw1,m0,w2,cwrite()afterend()onprocess.stdoutERR_STREAM_WRITE_AFTER_ENDERR_STREAM_WRITE_AFTER_ENDwrite()afterend()on piped stdout/stderr:write("A"); end("B"); write("C")"ABC""AB",ERR_STREAM_WRITE_AFTER_END"AB",ERR_STREAM_WRITE_AFTER_ENDchild.stdin.write()after child'close'{ret: true, cb: undefined}{ret: false, cb: 'ERR_STREAM_DESTROYED'}{ret: false, cb: 'ERR_STREAM_DESTROYED'}child.stdinwrite failure (EPIPE) with a callback: is'error'emitted and the stream destroyed?'error'never fired when a callback was supplied'error'fires, stream destroyed'error'fires, stream destroyedConflict resolution for #33557, #34267 and #34268 (all merged): each of those PRs added a guard or error-path tweak inside
writeFast/the oldunderscoreWriteFast, the functions this PR deletes. The resolution is to keep the deletion:Writable.prototype.writeimplements theERR_STREAM_WRITE_AFTER_END/ERR_STREAM_DESTROYEDchecks; #34268'serrorOrDestroycall is reached structurally because_write'scb(err)isstate.onwrite, andonwrite(err)callsonwriteError→errorOrDestroy(stream, err). Verified by running each PR's own test (process-stdout-write-after-end.test.ts,child-process-stdio.test.js,child_process.test.ts -t "stdin write failure") against the resolved tree: all pass. Thechild-process-stdio.test.jsconflict was a both-sides-add-tests; both sets were kept.One caveat for whoever closes #33500: its fixture (
process-stdout-write-order-fixture.js) only terminates against the old semantics. It sizes the in-flight data againstwrite()returningfalseat the first buffered byte; oncewrite()returnsfalseat the high-water mark instead (node's rule), more bytes are in flight than the FIFO can hold and the fixture stalls. node stalls on it too. A drain loop in the fixture makes it terminate, and then it shows the reordering onmainand in-order completion here and on node. This PR ports the deterministic half of that coverage (the readline-cursor interleave) instead.What this PR does not fix
Two bot suggestions to add
Fixes #...lines, both checked and both wrong:bytesWrittenalways 0) is untouched. Nothing on this path incrementsbytesWritten, before or after, andWritabledoesn't track it. Still0here. (For whoever picks it up: node only exposes it when stdout is a pipe/TTY, where it's anet.Socket; over a file it'sSyncWriteStreamandbytesWrittenisundefined, not0. node:process: track bytesWritten for stdio streams #31627 is the open PR for this, and it patches the function this PR deletes, so it needs a small rebase ontounderscoreWriteFast.)tty.WriteStreamfails withEINVAL: invalid argument, kqueueon macOS when opening/dev/tty#24158 (EINVAL: invalid argument, kqueuefromnew tty.WriteStream(fs.openSync("/dev/tty","w"))on macOS) is untouched. The failure is insideBun.file(fd).writer(), and this PR leaves that line exactly where it was; only thewrite()override is removed. The bot readthis.write = writeFastout of the minified stack-trace line and assumed the whole fast path was gone.Also related and not superseded: #29232 fixes the same encoding bug as #33474, but by routing every string through
Buffer.from(). That costs ~3x on string writes, which is why this PR keepsdecodeStrings: falseand only decodes the non-UTF-8 encodings.One behavior change, on purpose
The old fast path handed the chunk straight to the native sink without type validation, so an
ArrayBufferwritten to stdio "worked".Writable.prototype.writevalidates it, so it now throwsERR_INVALID_ARG_TYPE. This is not a new restriction so much as the end of an inconsistency — bun's ownfs.WriteStreamalready rejected it, only stdio didn't:fs.createWriteStream(...).write(ab)process.stdout.write(ab)A view over the same buffer (
new Uint8Array(ab)) is the supported spelling and is unaffected. Two in-tree test helpers were leaning on the old leniency (they wrotebun:jscserialize()'sSharedArrayBufferto stdout) and are updated; the new behavior is pinned byprocess.stdout - write() rejects an ArrayBuffer like node. Flagging it here rather than burying it: if Bun would rather keep acceptingArrayBufferon stdio, that has to be a deliberate extension, and it cannot live on this path without reintroducing thewrite()override.Known gap: Windows does not report backpressure
writableLength/writableNeedDrainstill read as "nothing buffered" on Windows, and that is not fixed here. Its writer hands the chunk touv_writeand reports completion the moment libuv accepts it, so the sink never tells the stream it had to buffer andwritableLengthcannot see libuv's queue. This predates the PR —process.stdout.write()returnstruefor a 4 MB write into a blocked pipe onmaintoo — and closing it means teachingWindowsBufferedWriterto reportPendingwhile auv_writeis in flight, which is a native change I can't test here.cork()accounting does work on Windows. The two backpressure tests carryskipIf(isWindows)with that reason recorded inline.Verification
writableLength/writableNeedDrain/writableCorkednow match node byte-for-byte on both destinations stdio can have:New tests, every one verified failing on
mainand passing here:test/js/node/process/process-stdio.test.tswritableLength, writableNeedDrain and cork() track the buffered bytes'drain' resets writableLength and writableNeedDrainuncork() flushes a corked burst in a single write syscall(Linux-only)process.stdout - write() decodes the encoding argumentprocess.stdout - write() is inherited, not an own propertyprocess.stdout - write callbacks run in call order with readline cursor callbacksprocess.stdout - write() rejects an ArrayBuffer like nodeprocess.stdout - write after end()test/js/node/child_process/child-process-stdio.test.jschild.stdinwrite afterend()/destroy(), with and without a callback913 of node's
test/parallelstream / fs / child_process / process / net / tty / console tests run identically before and after (894 pass, 19 pre-existing failures, zero drift), as dotest/js/node/stream,test/js/node/child_process,test/js/node/fs,test/js/node/ttyandtest/regression/issue/1632.test.ts(stdout EPIPE)._writev: cork() has to batch, not just bufferReview raised this and it was a real gap. With
cork()finally buffering,clearBuffer()was flushing the backlog one chunk at a time, so a corked burst of 1000 writes cost 1000write(2)calls where node coalesces the same burst into a singlewritev(2). Giving the FileSink-backed streams a_writevcloses it. Counted with/proc/self/io'ssyscw, 1000 × 64-byte writes:writableLengthwhile corked_writevTo be precise about the "regression" framing: cork never batched before this PR either, because
writeFastignoredstate.corkedand pushed every chunk straight at the sink. Nothing regressed; the batching simply never existed, and now it does._writevalso flushes the post-backpressure backlog in one sink write, which is where it earns back some of the cost in the table above.The regression test for it is Linux-only (
syscwis the only way to observe a syscall count) and points stdout at a regular file rather than a pipe: a pipe that fills up makes the sink coalesce on its own, which would sink both numbers and leave the test asserting nothing. Verified it fails in both directions it needs to:Performance
process.stdout.write()now pays theWritablebookkeeping node pays, so it gets slower. Release builds, same commit, 300k × 64-byte writes:/dev/null/dev/nullAgainst a real pipe, where the syscall dominates, the cost is 4-12% and Bun stays ahead of node. Keeping
decodeStrings: falseis what holds the string column up; routing strings throughBuffer.from()like a plainfs.WriteStreamwould have cost ~3x instead. I did not find a way to keep a fast path here and stay correct: whether the sink buffers is only knowable after the write, and by then the bytes are already in the sink and the accounting has to be reconstructed by hand.no test proof · iteration 15 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/process/process-stdio.test.ts