Skip to content

fs: fail WriteStream fast-path write() with ERR_STREAM_DESTROYED after destroy - #34267

Merged
Jarred-Sumner merged 3 commits into
mainfrom
farm/1f2e0d74/child-stdin-write-after-destroy
Jul 15, 2026
Merged

Jarred-Sumner merged 3 commits into
mainfrom
farm/1f2e0d74/child-stdin-write-after-destroy

Conversation

@robobun

@robobun robobun commented Jul 15, 2026 •

Copy link
Copy Markdown
Collaborator

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.

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.


[review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
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 (c1207a628)

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 (1db92f7fb)

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
passes on PR (with fix)
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 (c1207a628)

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)
diff hotspot
src/js/internal/fs/streams.ts                      |  6 +--
 .../node/child_process/child-process-stdio.test.js | 49 ++++++++++++++++++++++
 2 files changed, 52 insertions(+), 3 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
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

…r destroy

child.stdin (and other FileSink-backed WriteStreams) install writeFast as
an own .write that bypasses Writable.prototype.write. It already deferred
to the real Writable machinery when state.ending was set, but not when
state.destroyed was, so writing to an exited child's stdin returned true
and called the callback with null while stdin.destroyed === true.

Route through Writable.prototype.write when state.destroyed is set so the
existing ERR_STREAM_DESTROYED handling runs.
@robobun

robobun commented Jul 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced with USE_SYSTEM_BUN=1 bun test test/js/node/child_process/child-process-stdio.test.js -t 'child.stdin' (both new tests fail with ret: true, cbCode: undefined); bun bd test on the same file passes all 7 tests.

Gate passed (fail-before/pass-after verified on ASAN and release). The new child.stdin tests pass on every CI lane across builds #73381 and #73403. Remaining red lanes are unrelated flakes and pre-existing failures per ci:errors (install/registry, webview-chrome, dns-tcp-bidirectional-poll, net-mongodb-pattern-leak, test-net-connect-memleak), none of which touch src/js/internal/fs/streams.ts or child_process stdio. Ready for a maintainer to merge or to pick between this and the overlapping #34268 / #33508.

@robobun

robobun commented Jul 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 PM PT - Jul 15th, 2026

❌ @robobun, your commit c1207a6 has 1 failures in Build #73403 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34267

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

bun-34267 --bun

@coderabbitai

coderabbitai Bot commented Jul 15, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

writeFast() now treats destroyed writable streams like ending streams and falls back to Writable.prototype.write. Child-process stdin tests verify write behavior after process exit and close.

Changes

Destroyed stdin write handling

Layer / File(s) Summary
Fast-path destroyed-state guard
src/js/internal/fs/streams.ts
writeFast() checks both ending and destroyed writable states before reaching the fast-path sink.
Child stdin lifecycle validation
test/js/node/child_process/child-process-stdio.test.js
Tests await process lifecycle events and verify late stdin writes return false with ERR_STREAM_DESTROYED; the close case also checks destroyed and writable state.

Possibly related PRs

  • oven-sh/bun#33707: Updates when child.stdin is destroyed or retained after process exit, directly relating to post-exit stream writability.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly matches the main change: fixing fast-path writes to return ERR_STREAM_DESTROYED after destroy.
Description check ✅ Passed The description covers the bug, root cause, fix, and verification, even though it doesn't use the template headings exactly.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Overlaps with #34268, which includes this state.destroyed bail and additionally makes the sink-write rejection always errorOrDestroy() the stream (the other half of the Node onwriteError contract). With only this change, a write that fails with EPIPE while the child is still alive never destroys the stream when a callback was passed, so the next write still takes the fast path and reports success.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — one-condition addition to an existing guard, routing to already-correct Writable machinery.

What was reviewed:

  • Confirmed state.destroyed is a valid getter on WritableState (bitmap descriptor for kDestroyed), matching the existing state.ending pattern.
  • Traced the deferred path: Writable.prototype.write → _write checks kDestroyed → ERR_STREAM_DESTROYED + nextTick callback + errorOrDestroy, which no-ops on already-destroyed streams (so test 1 doesn't need an error listener).
  • Tests await real conditions (once(child, 'close'/'exit')), use bunExe/bunEnv, and robobun confirmed they fail on system bun and pass on the debug build.
Extended reasoning...

Overview

The PR adds || state.destroyed to the existing state.ending guard in writeFast() (src/js/internal/fs/streams.ts). When either flag is set, the fast path defers to Writable.prototype.write instead of writing directly to the FileSink. Two new tests in test/js/node/child_process/child-process-stdio.test.js cover write-after-close and write-after-exit on a child's stdin.

Security risks

None. This is a Node.js compatibility fix in stream state handling; no auth, crypto, path handling, or untrusted input parsing involved.

Level of scrutiny

Low-to-medium. The source change is a single boolean condition mirroring the adjacent state.ending check that already exists on the same line. I verified in src/js/internal/streams/writable.ts that WritableState.prototype.destroyed is defined via makeBitMapDescriptor(kDestroyed), so the property read is valid, and that _write() (the target of the deferred call) already produces ERR_STREAM_DESTROYED, calls the callback via process.nextTick, and returns the error so Writable.prototype.write returns false. errorOrDestroy early-returns when the stream is already destroyed, so the first test (no error listener) won't crash on an unhandled error event; the second test adds an error listener defensively since it writes before the stream is fully torn down.

Other factors

  • robobun confirmed both new tests fail with USE_SYSTEM_BUN=1 (ret: true, cbCode: undefined) and pass with bun bd test, satisfying the repo's test-validity requirement.
  • Tests follow harness conventions: bunExe()/bunEnv, await once(...) on real events, no setTimeout, Promise.withResolvers() for callback bridging, and combined-object assertions for readable failure messages.
  • The PR description notes #33508 will remove writeFast entirely; this is the minimal targeted fix in the meantime, which is the right call.
  • No prior reviewer comments to address; only bot comments in the timeline.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any issues with the change itself — routing to Writable.prototype.write when state.destroyed is set is correct and the tests are solid. Deferring to a human only because of the noted overlap with #34268 (and #33508), which is a landing-order decision a maintainer should make.

What was reviewed:

  • The writeFast guard — adding || state.destroyed only widens the existing bail-to-prototype path; no new behavior beyond what Writable.prototype.write already implements for destroyed streams.
  • Confirmed _writableState.destroyed is the right flag (matches the autoClose getter and Node's internal _write helper check).
  • Tests: both await real lifecycle events, wire spawn/write errors to reject, and assert exact {ret, code} — no timing sleeps.
Extended reasoning...

Overview

One-line logic change in src/js/internal/fs/streams.ts: the writeFast fast-path override now bails to Writable.prototype.write when state.destroyed is set, in addition to the existing state.ending check. Two new tests in child-process-stdio.test.js cover write-after-close and write-after-exit on a child's stdin, asserting ret === false and callback error code ERR_STREAM_DESTROYED.

Security risks

None. This is a Node-compat behavioral fix in stream lifecycle handling; no auth, crypto, path, or untrusted-input parsing involved.

Level of scrutiny

Medium. The code change is mechanically trivial — it extends an existing guard with one more condition and delegates to already-correct shared machinery. The subtlety is entirely in stream semantics, and the fix defers to the canonical implementation rather than reimplementing anything. The PR includes fail-on-main / pass-on-PR evidence for both debug+ASAN and release builds.

Other factors

The change is correct and self-contained, and I would otherwise approve. However, robobun flagged that #34268 is a superset (same state.destroyed bail plus an errorOrDestroy fix for the EPIPE-while-alive case), and the PR description itself notes #33508 removes writeFast entirely. Which of these to land, and in what order, is a maintainer coordination call rather than a code-correctness question — so deferring rather than approving.

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