stdio: reset writable state after end() on file-backed process.stdout/stderr - #33618
Conversation
…/stderr b280ca3 made the fast-path write() consult writable state so a piped process.stdout rejects writes after end(). File-backed stdio went through the same check: its writable state also latched ending/ended because the stream is created with autoClose:false, which WriteStream maps to autoDestroy:false, so the finish -> destroy -> _undestroy cycle that Node's SyncWriteStream relies on to reset state never ran. A later process.stdout.write() then failed with ERR_STREAM_WRITE_AFTER_END and the bytes were dropped from the output file, which is the pipeline(src, process.stdout) followed by more logging shape. Enable autoDestroy on file-backed stdio so end() runs through the existing stdio _destroy override (cb + _undestroy), matching Node's SyncWriteStream: writableEnded returns to false and later writes succeed. Pipe/socket stdio keeps autoDestroy:false so the write-after-end rejection from b280ca3 is preserved there.
|
Updated 5:05 AM PT - Jul 7th, 2026
❌ @robobun, your commit adeb9c1 has some failures in 🧪 To try this PR locally: bunx bun-pr 33618That installs a local version of the PR into your bun-33618 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not a duplicate of #33508; they are complementary. #33508 deletes the This PR changes where the stream is constructed, not how writes are routed: it enables |
|
Warning Review limit reached
Next review available in: 2 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughAdds a test fixture that writes to stdout or stderr, ends the stream, and attempts a post-end write while reporting the outcome as JSON to the opposite stream. A new parameterized test verifies write-after-end delivery succeeds when the target is a regular file. ChangesFile-backed stdio write-after-end test
Sequence Diagram(s)sequenceDiagram
participant Test
participant Fixture
participant Stream
participant OtherStream
Test->>Fixture: spawn with stream redirected to file
Fixture->>Stream: write("A"), end("B")
Stream-->>Fixture: "finish" event
Fixture->>Stream: write("C") with callback
Stream-->>Fixture: callback error (if any)
Fixture->>Stream: console log "D"
Fixture->>OtherStream: write JSON report
Test->>OtherStream: parse JSON report
Test->>Test: verify file contents "ABCD\n"
Estimated code review effort: 3/5 Related issues: None referenced. Related PRs: None referenced. Suggested labels: test, node.js Suggested reviewers: None identified. 🐰 A stream that ends but writes once more, 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
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-stdout-write-after-end.test.ts`:
- Around line 47-92: The subprocess/filesystem test in the process stdio
write-after-end suite should be made concurrent. Update the relevant `test.each`
cases, including the sibling file-backed variant in
`process-stdout-write-after-end.test.ts`, to `test.concurrent.each` so each
parameterized run can execute in parallel with its own `tempDir` and file
descriptor without shared state. Keep the existing `Bun.spawn`,
`fs.openSync`/`closeSync`, and `fs.readFileSync` flow unchanged, and ensure the
helper fixture references still point to the same
`process-stdout-write-after-end-file-fixture.mjs` logic.
🪄 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: 902df9ca-69ae-40c6-8417-8734570d1ad9
📒 Files selected for processing (3)
src/js/builtins/ProcessObjectInternals.tstest/js/node/process/process-stdout-write-after-end-file-fixture.mjstest/js/node/process/process-stdout-write-after-end.test.ts
There was a problem hiding this comment.
LGTM — targeted one-line regression fix with thorough tests.
Extended reasoning...
Overview
This PR fixes a regression from #33557 where process.stdout.end() on file-backed stdio (e.g. bun app.js > out.txt) permanently latches writableEnded, causing subsequent writes to fail with ERR_STREAM_WRITE_AFTER_END. The production change is a single else branch in getStdioWriteStream that sets _writableState.autoDestroy = true for file-backed stdio only, so the existing _destroy → _undestroy() override runs after 'finish' and resets writable state — mirroring Node's SyncWriteStream behavior. The rest of the diff is a new fixture and two new test.each cases covering stdout/stderr redirected to a file fd.
Security risks
None. This is a Node.js-compat behavioral fix to stdio stream state management. No parsing of untrusted input, no auth/crypto/permissions, no new external surface.
Level of scrutiny
Low-to-medium. The production change is 5 lines (one field assignment plus a comment) in a narrowly-scoped else branch that only fires when fdType === file, so pipe/socket stdio retains the write-after-end rejection from #33557 (verified by the existing piped test cases in the same file). The PR description traces the mechanism precisely (autoClose:false → autoDestroy:false → destroy never runs → _undestroy never resets ending) and cites the Node reference implementation. The fix reuses the already-present _destroy override rather than adding new machinery.
Other factors
Tests follow repo conventions well: tempDir/bunEnv/bunExe from harness, fd cleanup in finally, concurrent drain of report stream + proc.exited, exit-code asserted last, ASAN/debug noise on fd 2 handled via toContain while stdout uses exact toBe. The fixture awaits 'finish' + one setImmediate rather than a timeout. The PR states both new cases fail on the released binary and on current main, and the fixture produces identical output under Node. No prior reviewer comments to address.
The previous commit inadvertently dropped the src/js/builtins/ProcessObjectInternals.ts hunk while applying the test.concurrent.each change.
There was a problem hiding this comment.
Thanks for restoring the source fix — the diff now looks correct to me, but since this changes stream lifecycle behavior on the core process.stdout/stderr path (now emits 'close' and runs destroy → _undestroy after every end() on file-backed stdio), it's worth a human sign-off.
Extended reasoning...
Overview
The PR is a one-line semantic change in src/js/builtins/ProcessObjectInternals.ts (getStdioWriteStream): when stdout/stderr is backed by a regular file (not TTY/pipe/socket), set _writableState.autoDestroy = true after constructing the fs.WriteStream. This wires end() into the existing _destroy override (which calls _undestroy() and emits 'close'), so writable state resets and post-end() writes succeed — matching Node's SyncWriteStream. It also adds a fixture and two parameterized file-backed test cases alongside the existing piped cases.
My earlier review flagged that commit 7664448 had accidentally dropped the source change; commit 2104091 restored it and the net diff now matches the PR description. No bugs were found by the bug-hunting pass on the restored diff.
Security risks
None. No untrusted input parsing, auth, crypto, or filesystem-path handling is touched. The change only flips an internal Writable state flag on a stream Bun itself constructs for fd 1/2.
Level of scrutiny
Moderate-to-high. The diff is tiny, but process.stdout/process.stderr is exercised by essentially every Bun program, and the file-backed branch is hit whenever output is redirected (> out.txt, cron/CI, spawn with a file fd). The change alters observable stream lifecycle: end() on file-backed stdio will now run destroy() → the stdio _destroy override → _undestroy(), and will start emitting 'close' where it previously did not. The PR argues this matches Node, and the reasoning traces cleanly through Node's SyncWriteStream (autoDestroy: true + dummyDestroy → _undestroy), but the interaction between autoDestroy, the custom _destroy/_final overrides, the `` sink, and emitClose is subtle enough that a maintainer familiar with the stdio fast-path (#33557, #33508) should confirm there's no unintended fallout for programs that call `end()` on file-backed stdio without expecting `'close'`.
Other factors
- Tests are well-constructed: parameterized over stdout/stderr, assert exact JSON facts (
writableEnded,writable,ret,cbErr,ev) and file contents, handle ASAN/debug noise on fd 2, clean up the fd infinally, and were verified to fail on both the released binary and current main. - The piped-stdio behavior from #33557 is preserved (tests for that still pass and
autoDestroyremainsfalseon the pipe/socket branch). - There is a related open PR (#33508) touching adjacent write-routing logic; the author's analysis that they're complementary looks correct, but coordination is a human call.
|
The diff is ready. CI is red on unrelated flakes that differ between the two runs:
None touch |
What
Regression from b280ca3 (#33557). When
process.stdout/process.stderris a regular file (bun app.js > out.txt, cron/CI logs,spawn(..., { stdio: [.., fileFd, ..] })) and the program callsprocess.stdout.end(), either directly or viastream.pipeline(src, process.stdout)which always ends its destination, every laterprocess.stdout.write()is now rejected withERR_STREAM_WRITE_AFTER_END, an'error'event fires (a fatal uncaught exception when there is no'error'listener on stdout), and the bytes never reach the file. Node delivers them; so did Bun 1.4.0 and every earlier main.Repro
writableEndedafter cyclewrite("C")false"ABCD\n"true"ABCD\n"trueERR_STREAM_WRITE_AFTER_END"ABD\n"false"ABCD\n"Cause
#33557 correctly made the fast-path
write()delegate toWritable.prototype.writewhenstate.endingis set, so piped stdio honors the write-after-end contract. File-backed stdio goes through the sameWriteStreamfast path, and itsstate.endingalso latches: the stream is created withautoClose: false, whichfs.WriteStreammaps toautoDestroy: false, soend()reaches'finish'but never runsdestroy(). Node's file-backed stdio is aSyncWriteStreamwithautoDestroy: true;end()there runsfinish -> destroy -> dummyDestroy -> _undestroy(), which resetsending/ended/finishedso the stream is writable again.Fix
Set
_writableState.autoDestroy = trueon file-backed stdio (fdType === file) ingetStdioWriteStream. The existing stdio_destroyoverride (cb(err); this._undestroy()) then runs after'finish'and resets writable state, matching Node'sSyncWriteStream. Pipe/socket stdio keepsautoDestroy: false, so the write-after-end rejection from #33557 is preserved there (Node's piped stdout also rejects, via EPIPE afternet.Sockethalf-close).This also fixes the long-standing divergence where
process.stdout.writableEndedstayedtrueon file-backed stdio afterend()(Node reportsfalse), and makes'close'fire like it does in Node.Verification
test/js/node/process/process-stdout-write-after-end.test.tsgains file-backed cases for bothstdoutandstderrthat redirect the fixture's target stream to a temp-file fd, assert the post-end write succeeds with{writableEnded: false, ret: true, cbErr: null, ev: []}, and assert the file received"ABCD\n". Both new cases fail on the released binary and on current main without this change; all four cases (piped + file) pass with it. The fixture also produces identical output under Node.