Skip to content

child_process: make child.stdin born constructed, as in Node.js - #45195

Open
robobun wants to merge 3 commits into
mainfrom
robobun/8c8e6d35/child-stdin-born-constructed
Open

robobun wants to merge 3 commits into
mainfrom
robobun/8c8e6d35/child-stdin-born-constructed

Conversation

@robobun

@robobun robobun commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • child.stdin.end(chunk) loses the chunk when process.exit(), destroy() or a stream error follows in the same tick as spawn(). After end("hello"); process.exit(0) the child reads 5 bytes in Node.js, 0 since Bun 1.2.0 (node fs compat pr #2 #16422). No user report.
  • writableFromFileSink (src/js/internal/fs/streams.ts:830) stored the sink after the constructor returned. The constructor skips _construct only for an fd (:480), so Writable held the chunk for one tick.

Fix

  • The constructor receives the sink and treats it like an open fd: born constructed, no 'open' or 'ready', pending false until destroy(), as in Node.js.
  • _destroy drops a flush error before 'finish', as Node.js does. Without this, end(chunk); destroy() to an exited child throws EPIPE.
  • Verified: test/js/node/child_process/child_process.test.ts (9 new tests, 7 fail without it), tty.test.ts, 161 vendored Node.js tests.
  • Self-reviewed: 11 concerns raised, 9 addressed. Not taken: more exit-test variants (same mechanism).

Background

Downsides

  • Call orders in that tick match the next tick, not Node.js: end(cb); destroy() gives cb(null) and 'finish' until child_process: end the stdin pipe in child.stdin's _final #45002.
  • end(1 MiB); destroy() to a child that never reads: the parent waits for the child, as after write().
  • One more own property: 4 more property slots at the first listener. Release binary: +248 bytes.
Notes

Versions, and what #16422 changed

  • Bun 1.1.45 delivers the chunk in end("hello"); process.exit(0) and in end("hello"); destroy(). Bun 1.2.0 delivers 0 bytes in both. Before node fs compat pr #2 #16422 child.stdin was a NativeWritable. It assigned _construct after Writable had run, so the stream was constructed from birth.
  • node fs compat pr #2 #16422 also removed child.stdin.ref() and unref() (functions in Node.js and in Bun 1.1.45, undefined since). This PR does not restore them.
  • One more loss on main with no destroy, error or exit: end(buffer); buffer.fill(0) gives the child zeroes. The stream held the chunk by reference until the construct step.

pending

  • On main child.stdin.pending is true for the whole life of the stream, and 'ready' fires once, one tick after creation. With no 'ready' left, if (stdin.pending) stdin.once("ready", go); else go(); would never call go.
  • pending is now false for a stream over an open sink until the stream is destroyed. Node.js reports the same values for child.stdin (false, then true after destroy). Path streams, fd streams and new tty.WriteStream() with no fd keep their values.

_destroy

  • With the chunk in the sink, destroy() can see the flush fail. spawn("true"); stdin.end("hello"); stdin.destroy() with no 'error' listener: non-zero exit in 0 of 10 runs on Node.js and on main, 10 of 10 with the constructor change alone (uncaught EPIPE: broken pipe, write), 0 of 10 with the guard.
  • The guard: when sink.end() fails on a stream that was destroyed before 'finish', _destroy reports the error it was given, not the flush error. After 'finish' it still reports the flush error, which is the only report of a failed graceful end().
  • The guard also removes the 'error' EPIPE that main emits after write(chunk); destroy() to a child that closed its stdin. Node.js emits none there.

Inherited options

  • The constructor reads its options from a copy whose prototype is Object.prototype, so an fd, fs or autoClose set there reaches every WriteStream. With the sink in the constructor, an inherited fd would replace the sink: the input of the child would go to that fd.
  • A stream over a handed-in sink takes no options. Its options object now has a null prototype, so nothing inherited reaches it. On main an inherited fd did not redirect the bytes, but an inherited fs made spawn() throw and autoClose: false left child.stdin open after 'finish'.

What still differs from Node.js after this change

  • A chunk under 4096 bytes (16384 on macOS arm64) waits in the FileSink until the deferred flush (src/io/PipeWriter.rs:944). end("hello"); spawnSync("sleep", ["0.3"]) leaves the child with 0 bytes while the parent blocks (Node.js: 5). write("hello") has the same delay on main. end(8192 bytes) followed by the same block is fixed (main 0 bytes, now 8192).
  • _final of this stream completes synchronously when the sink has no backlog. That is the cause of the call orders below. child_process: end the stdin pipe in child.stdin's _final #45002 changes _final.
  • write() on child.stdin ignores cork() and calls its callback synchronously.
  • child.stdin.ref() and unref() are undefined.
  • end(1 MiB); destroy(); child.unref() with a child that never reads: the sink keeps the backlog and the parent waits for the child (2.2 s with sleep 2, Node.js 19 ms). Main does the same for write(1 MiB); destroy() in that tick and for end(1 MiB); destroy() one macrotask later.
  • new tty.WriteStream() with no fd (Node.js: ERR_INVALID_FD) still creates a fast-path stream with nothing to write to. It keeps the construct step. Its 10 measured call shapes are identical to main.
  • Windows and macOS: run by CI only.

Call orders in the creating tick

test/js/node/child_process/fixtures/stdin-call-orders-fixture.js logs, for 18 call orders, the bytes the child read and the events and callbacks of the stream. node runs it unchanged. The table test pins the output and lists the rows that differ from Node.js v26.3.0.

rows equal to Node.js bytes equal
main 0 of 18 (5 when 'open' and 'ready' are ignored) 6 of 18
this PR 13 of 18 17 of 18
this PR with #45002 applied on top 17 of 18 17 of 18

The rows that differ on this PR:

calls this PR Node.js v26.3.0
end(cb); destroy() 'prefinish', cb(null), 'finish', 'close' cb(ERR_STREAM_DESTROYED), 'close'
end("hello", cb); nextTick(destroy) 'prefinish', cb(null), 'finish', 'close' cb(ERR_STREAM_DESTROYED), 'close'
end("hello", cb); queueMicrotask(destroy) 'prefinish', cb(null), 'finish', 'close' cb(ERR_STREAM_DESTROYED), 'close'
write("hello", cb); end(); destroy() cb(null), 'prefinish', 'finish', 'close' cb(null), 'close'
cork(); write("hello", cb); destroy() 5 bytes, cb(null), 'close' 0 bytes, cb(ERR_STREAM_DESTROYED), 'close'

On main, with 'open' and 'ready' ignored, the first and fourth rows equal Node.js, and the second has Node's log with 0 bytes delivered. The third and fifth rows differ on main too. Each "this PR" cell is what main already does when the same calls run one macrotask after spawn().

Measurements (linux-x64, Node.js v26.3.0, taken on b92e2f4 and this change on top of it)

  • spawn-tick shapes equal to node, on a wider table of 39 call orders that is not committed: bytes 38 of 39 (main 20), callback order 30 of 39 (main 33).
  • child.stdin own names at birth / after first listener: 17 / 18 (main 17 / 17, node 16 / 16). Out-of-line property storage after first listener: 8 slots (main 4).
  • per piped child.stdin: TickObjects from spawn() to the first setImmediate 1 (main 3, node 0). GC cells at creation 18 (main 40) on a debug build. A release build of the first revision gave 13 (main 31).
  • fast-path write functions (writeFast, underscoreWriteFast, finalFast) in the release bundle: 0 changed lines. Instructions per process.stdout.write("x"): 1,020 (main 1,020, run-to-run range 0.2%). Per child.stdin.write(50 B): 767 (main 767). Per child.stdin creation with the turns after it: about 15,000 (main about 33,000). Counted with a ptrace single-step counter over 200 calls, interleaved runs. perf is not installed on this machine.
  • parent syscalls per spawn() + stdin.end("hello"), 200 children: sendto 1, socketpair 1, fcntl 3 (main: the same). Counted with a ptrace counter. strace is not installed on this machine.
  • internal/fs/streams.js release bundle: 17,753 bytes (main 17,505), +248. The binary embeds the bundle: on the first revision the release text grew by the 252 bytes that the bundle grew by (105,076,379 against 105,076,127).
  • end(1 MiB); destroy() in the spawn tick: bytes at a reading child 1,048,576 (main 0, node 146,176). stdin 'error': none (main none, node none). Exit code with no 'error' listener: 0 (main 0, node 0).
  • stream fingerprint matrix (process.stdout, process.stderr, tty.WriteStream, fs.WriteStream, child stdio, with stdout as a pipe, a file and a pty): 3 of 37 cases differ from main, all child.stdin. new tty.WriteStream() with no fd: 0 of 10 call shapes changed.

Tests

  • 7 of the 9 new tests in child_process.test.ts fail on main. The other 2 (a child that has closed its stdin) pass on main and fail with the constructor change alone: they pin the _destroy guard. "when Object.prototype has stream options" also fails without the null-prototype options. The new test in tty.test.ts pins pending of the stream with no fd.
  • Suites run with the debug build, each also with main's built-in JS: child_process.test.ts, child-process-stdio.test.js, child_process-node.test.js, child-process-exec.test.ts, child_process_ipc.test.js, tty.test.ts, node-stream.test.js, process-stdout-write-after-end.test.ts, process-stdio.test.ts, process-stdin.test.ts, fs.test.ts.
  • 161 vendored Node.js tests (test-child-process-*, test-stream-construct, test-fs-write-stream*, test-stdout*, test-stdin*, test-tty*, test-process-std*, test-stdio*, test-pipe*): 161 pass on both.
  • The tests that fail on this machine's debug build fail the same way with main's JS (5 s timeouts and one fd-limit failure).
  • Not taken from the self-review: a Worker variant and two more variants of the exit test (the same mechanism, each a two-level spawn), and table rows for child.stdio[3], a stream this PR does not change. The call-order table has 18 rows, not 39, to keep the test under 2 s on a debug build.

Other designs

  • child_process: make piped stdio streams instances of net.Socket #36316 makes child.stdin a net.Socket subclass over the same FileSink. It is open with changes requested: its review asks for fd-backed sockets.
  • An fd-backed net.Socket for stdin needs native work first: Bun.spawn rejects "socket-fd" below index 3 (src/runtime/api/bun/spawn/stdio.rs:419). Any replacement must keep pending false and pass these tests. They assert bytes and state, not the mechanism.
  • A follow-up can reject new tty.WriteStream() with no fd as Node.js does. Then the true placeholder, its readers and the fast-path branch of streamConstruct can go.

Related open PRs


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/tty.test.ts, test/js/node/child_process/child_process.test.ts

child.stdin of node:child_process is a fast-path WriteStream over a
FileSink that is already open. writableFromFileSink built the stream
with no fd and stored the sink after the constructor returned, so the
stream kept the async _construct step that only an fd skipped. Until
that tick Writable buffered the chunk of end(chunk), and a destroy(),
a stream error or a process.exit() in the same tick dropped it.

The constructor now receives the sink as $fastPath and treats it like
an open fd: the stream is born constructed. An fd that the for-in
copy of the options took from Object.prototype does not replace the
sink. child.stdin no longer emits 'open' and 'ready', and its
`pending` is false until it is destroyed, as in Node.js.

The chunk now reaches the sink before destroy(), so _destroy can see
the flush fail. A failure of sink.end() on a stream destroyed before
'finish' is a write error on a destroyed stream, which Node.js drops:
_destroy reports only its own error there.
@robobun

robobun commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:55 AM PT - Oct 16th, 2026

✅ @robobun, your commit 3572b216c9a4faefcb29b61f1845d7fcd8b1a2b6 passed in Build #126766! 🎉


🧪   To try this PR locally:

bunx bun-pr 45195

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

bun-45195 --bun

@robobun

robobun commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduction on linux-x64, with Node.js v26.3.0 as the reference:

const { spawn } = require("node:child_process");
const child = spawn("cat", [], { stdio: ["pipe", "inherit", "inherit"] });
child.stdin.end("hello");
process.exit(0);
  • Node.js and Bun 1.1.45 print hello. Bun 1.2.0 and main print nothing.
  • With spawn("wc", ["-c"], ...) and child.stdin.destroy() in place of process.exit(0), the child counts 5 bytes in Node.js and 0 in Bun.
  • The new tests in test/js/node/child_process/child_process.test.ts fail on main and pass with this change.

Pull request: #45195

@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: c97deee8-42cc-4edc-8c5b-3b70516be236
📥 Commits

Reviewing files that changed from the base of the PR and between c69a636 and e07e46c.

📒 Files selected for processing: 4
  • src/js/internal/fs/streams.ts
  • test/js/node/child_process/child_process.test.ts
  • test/js/node/child_process/fixtures/stdin-call-orders-fixture.js
  • test/js/node/tty.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.

Walkthrough

WriteStream now adopts an existing FileSink as its fast path and updates fast-path destruction and pending-state handling. New child-process tests exercise stdin call sequences, buffered input, stream state, and results compared with Node.js v26.3.0.

Changes

Writable stream fast paths

Layer / File(s) Summary
WriteStream fast-path setup and lifecycle
src/js/internal/fs/streams.ts, test/js/node/tty.test.ts
WriteStream adopts an existing FileSink directly, adjusts construction and fast-path destruction handling, and updates pending state. A TTY test checks the pending state when no fd is supplied.
Child-process stdin scenarios
test/js/node/child_process/child_process.test.ts, test/js/node/child_process/fixtures/stdin-call-orders-fixture.js
The fixture records output and stream events, errors, and callback results for stdin call sequences. Tests cover buffered input, stream state, closed-stdin cases, and comparisons with Node.js v26.3.0.

Suggested reviewers: cirospaciari

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to e07e4

The stdin lifecycle change has no identified issue that needs resolution before merge; normal validation can proceed.

Pre-merge checks: Passed 4
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 and concisely identifies the primary change: making child.stdin constructed immediately to match Node.js behavior.
Description check Passed The description explains the problem, implementation, verification, known differences, and trade-offs. It does not use the exact template headings, but it provides the required content and is sufficie…
Full details: Description check

Explanation

The description explains the problem, implementation, verification, known differences, and trade-offs. It does not use the exact template headings, but it provides the required content and is sufficiently complete.

✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/node/child_process/child_process.test.ts Outdated
Comment thread test/js/node/child_process/child_process.test.ts Outdated
Comment thread src/js/internal/fs/streams.ts
Comment thread src/js/internal/fs/streams.ts
Comment thread src/js/internal/fs/streams.ts Outdated
The fast-path WriteStream that wraps an open FileSink (child.stdin)
takes no option. Its options object now has a null prototype, so an
fd, fs or autoClose on Object.prototype does not reach it. This
replaces the rule that dropped only an inherited fd.

The test for the polluted prototype checks fd and autoClose on the
stream. The tests with a child that has closed its stdin reject when
the child fails or exits before it is ready.
Comment thread src/js/internal/fs/streams.ts Outdated
Comment thread src/js/internal/fs/streams.ts Outdated
Comment thread src/js/internal/fs/streams.ts Outdated
Comment thread src/js/internal/fs/streams.ts Outdated
Comment thread src/js/internal/fs/streams.ts Outdated
Each comment that this branch adds in streams.ts is now one line. The note about path.resolve("") goes: no caller passes a path to resolve.

@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 re-reviewed the two follow-up commits and found no new bugs; the earlier inline points about the readiness wait, the inherited-fd test assertion and the wider inherited-option class are addressed by cc0815c/3572b216. A maintainer look is still worthwhile because this changes user-observable child.stdin semantics in the spawn tick and the new table test pins five deliberate divergences from Node (notably end(cb); destroy() delivering cb(null) and 'finish' on a destroyed stream, deferred to #45002) — whether to lock those in is a judgment call.

What was reviewed:

  • Tri-state $fastPath against every kWriteStreamFastPath consumer (write/_write/_writev/_final/_destroy/close/pending, ProcessObjectInternals stdout, child_process kill path): tty/process.stdout keep the true-then-open-from-fd flow, and true with no fd still keeps _construct and reports pending === true.
  • _destroy: cb runs exactly once on the sync-throw, rejected, resolved and non-promise paths; the pre-'finish' guard matches Node's errorOrDestroy dropping errors on an already-destroyed stream.
  • { __proto__: null } options for the sink case now cover fd, autoClose, fs, start and flush together, and the pollution test observes fd/autoClose directly rather than only the echoed bytes.
Extended reasoning...

The diff touches src/js/internal/fs/streams.ts (fs.WriteStream constructor, _destroy, pending getter, writableFromFileSink) plus new child_process and tty tests and a call-order fixture; it is the node:child_process stdin stream used by every spawn() with a piped stdin. No security-sensitive surface beyond the prototype-pollution hardening of the option copy, which now uses a null-prototype object for the sink case. Approve was ruled out because the change alters observable Node-compat behavior (callback timing, 'finish' after destroy(), pending, error reporting on destroy) and the test suite hard-codes five documented divergences from Node v26.3.0 that a maintainer should accept or reject; it is neither mechanical nor small. No CODEOWNERS entry covers the changed paths and the hunt ran dry with no findings, so a short defer rather than silence is warranted given new commits since the prior review.

This branch has not been deployed

No deployments
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.

1 participant