child_process: make child.stdin a Duplex so setEncoding works

Closed
RO
3mo ago
+214-42

What does this PR do?

Fixes #11011.

In Node.js, child_process.spawn() stdio pipe streams are net.Socket instances which extend stream.Duplex. Bun was creating child.stdin as an fs.WriteStream (a plain Writable), so Readable prototype methods like setEncoding, pause and resume were missing. Packages such as python-shell and say unconditionally call setEncoding on all three stdio streams and threw:

TypeError: self[name].setEncoding is not a function

Reproduction

const { spawn } = require("node:child_process");
const child = spawn("cat", [], { stdio: "pipe" });
["stdout", "stdin", "stderr"].forEach(name => {
  child[name].setEncoding("utf8"); // throws on stdin in Bun, works in Node
});
child.stdin.end();

Fix

Replace the fs.WriteStream with a Duplex subclass constructed with readable: false, matching how Node creates the stdin socket (new net.Socket({ handle, readable: false })). Writes go to the underlying FileSink in _write/_writev and the sink is ended in _destroy, same as before. The now-unused writableFromFileSink helper in internal/fs/streams is removed.

After:

stdin.constructor.name  === "Socket"    (was "WriteStream")
stdin instanceof Duplex === true        (was false)
typeof stdin.setEncoding === "function" (was "undefined")
stdin.readable === false, stdin.writable === true (matches Node)

How did you verify your code works?

Added tests in test/js/node/child_process/child-process-stdio.test.js that assert child.stdin is a Duplex with the expected surface, and that calling setEncoding on stdin/stdout/stderr succeeds while writes still reach the child. The tests fail on the current release and pass with this change.


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

fails on main (without fix)
ASAN without fix: 3 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 (042e10fe1)

test/js/node/child_process/child-process-stdio.test.js:
(pass) process.stdout > should allow us to write to it [1298.02ms]
(pass) process.stdin > should allow us to read from stdin in readable mode [1527.31ms]
(pass) process.stdin > should allow us to read from stdin via flowing mode [1511.15ms]
(pass) process.stdin > should allow us to read > 65kb from stdin [4932.65ms]
(pass) process.stdin > should allow us to read from a file [1545.61ms]
(pass) child.stdin > write() after child 'close' returns false and calls back with ERR_STREAM_DESTROYED [147.00ms]
(pass) child.stdin > write() after child 'exit' (before 'close') returns false and calls back with ERR_STREAM_DESTROYED [1497.45ms]
187 |         "st
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (746a35610)

test/js/node/child_process/child-process-stdio.test.js:
(pass) process.stdout > should allow us to write to it [24.97ms]
(pass) process.stdin > should allow us to read from stdin in readable mode [27.63ms]
(pass) process.stdin > should allow us to read from stdin via flowing mode [29.98ms]
(pass) process.stdin > should allow us to read > 65kb from stdin [35.52ms]
(pass) process.stdin > should allow us to read from a file [29.23ms]
(pass) child.stdin > write() after child 'close' returns false and calls back with ERR_STREAM_DESTROYED [2.40ms]
(pass) child.stdin > write() after child 'exit' (before 'close') returns false and calls back with ERR_STREAM_DESTROYED [26.29ms]
(pass) ChildProcess stdio streams > child.stdin is a Duplex and supports setEncoding [28.29ms]
(pass) ChildProcess stdio streams > child.stdin.destroySoon flushes pending writes then closes [27.59ms]
(pass) ChildProcess stdio streams > setEncoding can be called on stdin, stdout and stderr and writes still reach the child [27.43ms]
(pass) ChildProcess stdio streams > child.stdin.write preserves non-UTF-8 encodings [26.02ms]

 11 pass
 0 fail
 20 expect() calls
Ran 
... (truncated)
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 (042e10fe1)

test/js/node/child_process/child-process-stdio.test.js:
(pass) process.stdout > should allow us to write to it [1284.37ms]
(pass) process.stdin > should allow us to read from stdin in readable mode [1516.59ms]
(pass) process.stdin > should allow us to read from stdin via flowing mode [1515.78ms]
(pass) process.stdin > should allow us to read > 65kb from stdin [4942.01ms]
(pass) process.stdin > should allow us to read from a file [1517.31ms]
(pass) child.stdin > write() after child 'close' returns false and calls back with ERR_STREAM_DESTROYED [142.44ms]
(pass) child.stdin > write() after child 'exit' (before 'close') returns false and calls back with ERR_STREAM_DESTROYED [1494.60ms]
(pass) ChildProce
... (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 724ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/20] gen JS modules (bundle-modules)
Preprocess modules (8531ms)
Bundle modules (50ms)
Postprocesss modules (94ms)
Bundle Functions (817ms)
Generate Code (114ms)

[9.62s] Bundled "src/js" for production
  1912 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

  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 2026-05-05)

info: checking for self-update (current version
... (truncated)
diff hotspot
src/js/internal/fs/streams.ts                      |  12 --
 src/js/node/child_process.ts                       | 129 ++++++++++++++++-----
 .../node/child_process/child-process-stdio.test.js | 115 ++++++++++++++++++
 3 files changed, 214 insertions(+), 42 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

evidence per changed file
file                                                    reads  edits  tests
src/js/internal/fs/streams.ts                               4      1     10
src/js/node/child_process.ts                                9     14     10
test/js/node/child_process/child-process-stdio.test.js      6      7     10