child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED - #34268
Conversation
…er writes with ERR_STREAM_DESTROYED The WriteStream FileSink fast path (writeFast / underscoreWriteFast in src/js/internal/fs/streams.ts) reported a failed sink write to the per-write callback but only destroyed the stream when no callback was supplied. Node's onwriteError invokes the callback AND errorOrDestroy() unconditionally: the callback is additive, not a replacement for the 'error' event. Because the stream was never destroyed, a subsequent write() on the dead pipe took the fast path again, the ended sink returned synchronously, and the caller's callback fired with success. Every byte written after the first EPIPE vanished while the producer was told it arrived. Fix by routing every sink write error through errorOrDestroy() regardless of callback presence, and bailing out of writeFast to Writable.prototype.write when the stream is already destroyed so later writes surface ERR_STREAM_DESTROYED like Node.
|
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 (2)
Comment |
|
Updated 3:33 PM PT - Jul 15th, 2026
❌ @robobun, your commit 465228d has 2 failures in
🧪 To try this PR locally: bunx bun-pr 34268That installs a local version of the PR into your bun-34268 --bun |
|
Status: reproduced with CI build 73385 finished. The new test passed on every lane. Remaining red is unrelated to this JS-only streams change:
Diff is ready for review. |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
Neither of the suggested issues is closed by this change:
|
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Re the duplicate detector:
This PR is the union of both on the |
There was a problem hiding this comment.
I didn't find any bugs, but this changes error semantics on the shared FileSink fast path used by process.stdout/process.stderr and tty.WriteStream (not just child_process stdin), and the PR explicitly asks for a coordination call vs #33485 — worth a human look.
Checked: errorOrDestroy usage matches the existing pattern at streams.ts:311 and Node's onwriteError (callback + destroy are additive). The state.destroyed bail correctly routes post-failure writes to Writable.prototype.write for ERR_STREAM_DESTROYED. The two-arg .then() switch prevents a throwing 'drain' listener from being misclassified as a write failure. Test awaits real conditions (stdout readiness handshake, error event) with failure paths wired to reject, and cleanup is in finally.
Extended reasoning...
Overview
The PR modifies src/js/internal/fs/streams.ts — specifically the writeFast and underscoreWriteFast functions on the FileSink fast path — so that a sink write error always routes through errorOrDestroy() (matching Node's onwriteError), rather than only when no callback was supplied. It also adds state.destroyed to the bail-to-Writable.prototype.write guard so subsequent writes surface ERR_STREAM_DESTROYED, and switches .then().catch() to two-arg .then() so a throw from the fulfillment handler (e.g. a user 'drain' listener) is not misinterpreted as a write failure. A new test in child_process.test.ts covers the EPIPE-on-closed-stdin case end to end.
Security risks
None. This is stream error-propagation plumbing; no auth, crypto, or untrusted-input parsing is involved.
Level of scrutiny
Moderate-to-high. While the diff is small (~30 lines net) and the mechanism is well-argued against Node's onwriteError, the FileSink fast path is shared by process.stdout/process.stderr (ProcessObjectInternals.ts:55), tty.WriteStream (tty.ts:109), and child_process stdin (child_process.ts:1207). Changing when these streams get destroyed on write error is user-observable well beyond the PR title's scope. The author did run the relevant suites (tty.test.ts, process-stdio.test.ts, test-console-log-stdio-broken-dest, etc.), which is reassuring, but a maintainer should confirm the process.stdout/tty behavioral shift is intended.
Other factors
The PR description explicitly flags overlap with #33485 ("either can be closed in favor of the other once merged") — that is a maintainer coordination decision, not something I should resolve by auto-approving. CI (#73385) was still building at review time. The new test is well-constructed: it awaits a stdout readiness handshake instead of sleeping, wires error/exit to reject, uses Promise.withResolvers for the callback/event assertions, and kills the child in finally.
|
On the shared-path concern: the three consumers diverge exactly where they should.
Also ran #34267's write-after-close/write-after-exit tests against this branch: both pass. So this change satisfies the test coverage from all three PRs. |
* upstream/main: (57 commits) node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488) expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266) lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253) Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289) test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297) worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278) buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273) fs.promises.watch: yield events with a null prototype (oven-sh#34279) child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268) Fix asString assertion when passing String objects as signals (oven-sh#34265) Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274) test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294) tty: track raw mode per handle instead of per process (oven-sh#33527) test: expect the bumped mimalloc SHA in process.versions Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181) Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131) test: update block-scoped enum lowering expectations to let (oven-sh#34287) Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259) js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246) js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245) ...
* upstream/main: (70 commits) node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488) expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266) lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253) Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289) test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297) worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278) buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273) fs.promises.watch: yield events with a null prototype (oven-sh#34279) child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268) Fix asString assertion when passing String objects as signals (oven-sh#34265) Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274) test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294) tty: track raw mode per handle instead of per process (oven-sh#33527) test: expect the bumped mimalloc SHA in process.versions Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181) Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131) test: update block-scoped enum lowering expectations to let (oven-sh#34287) Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259) js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246) js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245) ...
* upstream/main: (52 commits) node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488) expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266) lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253) Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289) test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297) worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278) buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273) fs.promises.watch: yield events with a null prototype (oven-sh#34279) child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268) Fix asString assertion when passing String objects as signals (oven-sh#34265) Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274) test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294) tty: track raw mode per handle instead of per process (oven-sh#33527) test: expect the bumped mimalloc SHA in process.versions Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181) Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131) test: update block-scoped enum lowering expectations to let (oven-sh#34287) Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259) js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246) js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245) ...
* upstream/main: (52 commits) node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488) expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266) lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253) Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289) test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297) worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278) buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273) fs.promises.watch: yield events with a null prototype (oven-sh#34279) child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268) Fix asString assertion when passing String objects as signals (oven-sh#34265) Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274) test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294) tty: track raw mode per handle instead of per process (oven-sh#33527) test: expect the bumped mimalloc SHA in process.versions Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181) Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131) test: update block-scoped enum lowering expectations to let (oven-sh#34287) Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259) js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246) js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245) ... # Conflicts: # test/js/bun/websocket/websocket-server.test.ts
Repro
cb1/errEvEPIPE/EPIPEEPIPE/ no eventEPIPE/EPIPEdestroyed/writabletrue/falsefalse/truetrue/falser2/cb2false/ERR_STREAM_DESTROYEDtrue/successfalse/ERR_STREAM_DESTROYEDA producer that doesn't attach a callback to every single write never learns the pipe broke; every write after the first failure vanishes into a dead pipe while reporting success.
Cause
child.stdinis aWriteStreamon theFileSinkfast path (writableFromFileSink). Its.write()overridewriteFastinsrc/js/internal/fs/streams.tsbypasses theWritablestate machine and, on sink rejection, calledcb(err)but onlythis.destroy(err)when no callback was provided. Node'sonwriteErrorcalls the callback anderrorOrDestroy(stream, er)unconditionally; the callback is additive, not a replacement for the'error'event.Because the stream was never destroyed, the next
writeFastcall went straight to the (now-ended) sink again, which returned synchronously, and the success callback fired.Fix
writeFast/underscoreWriteFast: route every sink write error througherrorOrDestroy()regardless of whether a callback was supplied.writeFast: bail toWritable.prototype.writewhen the state isdestroyed(in addition toending), so a write after the first failure surfacesERR_STREAM_DESTROYEDinstead of reaching the sink.then(onFulfilled, onRejected)so a throw from the fulfillment handler isn't caught as a write failure.Relation to #33485
#33485 fixes the same
!hasCallbackguard inwriteFastfor theprocess.stdout-on-hung-up-tty case. It does not add thestate.destroyedbail, so with that change alone the second write to a child's closed stdin still returnstruewith a success callback (verified against its branch). This PR is a superset on thestreams.tsside and addschild_process-specific coverage; either can be closed in favor of the other once merged.Verification
Fails (timeout waiting for
'error') on 1.4.0, passes with the fix. Related suites all green:fs.test.ts -t WriteStream,tty.test.ts,regression/issue/1632.test.ts,process-stdio.test.ts,child-process-stdio.test.js, and nodetest/paralleltest-file-write-stream*,test-fs-write-stream-*,test-child-process-std*,test-process-external-stdio-close*,test-console-log-stdio-broken-dest.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/node/child_process/child_process.test.ts