child_process: make piped stdio streams instances of net.Socket
What does this PR do?
Node.js wraps each piped child stdio fd in a net.Socket (via lib/internal/child_process.js createSocket), so child.stdin/stdout/stderr are Duplex streams with net.Socket in their prototype chain. Bun was returning a plain fs.WriteStream for stdin and a plain Readable for stdout/stderr:
import { spawn } from 'node:child_process';
import { Socket } from 'node:net';
const cp = spawn('node', [], { stdio: 'pipe' });
console.log(cp.stdout.constructor.name); // 'Readable' (Node: 'Socket')
console.log(cp.stdout instanceof Socket); // false (Node: true)
console.log(typeof cp.stdin.setEncoding); // 'undefined' (Node: 'function')
cp.kill();
Packages rely on this:
- Nx checks
instanceof net.Socketon worker stdio to decide whether tounref()the stream (nrwl/nx#34224). - python-shell / say call
setEncodingon all three stdio streams unconditionally and throw onstdinbecause it was a plainWritable.
Fix
Wrap stdin/stdout/stderr in thin net.Socket subclasses that call Duplex directly and route reads/writes to the existing FileSink / native readable, so the prototype chain matches Node while the data path is unchanged. constructNativeReadable now accepts an optional base class so child_process can supply the Socket subclass for stdout/stderr. The now-unused writableFromFileSink helper is removed.
This also fixes child.stdin.write(str, encoding), which previously always wrote the string as UTF-8 regardless of the requested encoding because FileSink.write does not take an encoding argument.
After:
stdin constructor: Socket instanceof Socket: true (readable=false writable=true)
stdout constructor: Socket instanceof Socket: true
stderr constructor: Socket instanceof Socket: true
typeof stdin.setEncoding === 'function'
typeof stdin.ref / stdin.unref === 'function'
Supersedes #32432 (which made stdin a Duplex but not a net.Socket); #32432 is closed and its remaining assertions (the pause/resume/write/end/destroySoon surface and the signal === null check) are folded into the tests here.
After merging main, streamFdOf() (added by #31829 so that one child's stdin can be passed as another child's stdio target) also reads the fd from the new stdin socket's sink, since the sink no longer lives on fs.WriteStream's fast-path slot. child_process.test.ts "accepts another subprocess's stdin as a stdio target" covers that path.
How did you verify your code works?
Added tests in test/js/node/child_process/child-process-stdio.test.js that assert all three streams are instanceof net.Socket / Duplex with the expected surface, that writes and reads still flow end to end, that stdin.write honours the encoding argument, and that stdin.destroySoon flushes then closes. The tests fail on the current release and pass with this change. Existing child_process tests continue to pass.
[review] gate passed · iteration 1 · 4 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
bun test v1.4.0 (5299c8ab9)
test/js/node/child_process/child-process-stdio.test.js:
(pass) process.stdout > should allow us to write to it [1130.74ms]
(pass) process.stdin > should allow us to read from stdin in readable mode [1369.83ms]
(pass) process.stdin > should allow us to read from stdin via flowing mode [1351.11ms]
(pass) process.stdin > should allow us to read > 65kb from stdin [1394.03ms]
(pass) process.stdin > should allow us to read from a file [1368.81ms]
(pass) child.stdin > write() after child 'close' returns false and calls back with ERR_STREAM_DESTROYED [168.69ms]
(pass) child.stdin > write() after child 'exit' (before 'close') returns false and calls back with ERR_STREAM_DESTROYED [1356.23ms]
185 | "instanceof Readable": child[name] instanceof Readable,
186 | "instanceof Writable": child[name] instanceof Writable,
187 | "typeof ref": typeof child[name].ref,
188 | "typeof unref": typeof child[name].unref,
189 | "typeof setEn
... (truncated)
release without fix: all passed
bun test v1.4.0-canary.1 (c6a39ad6f)
test/js/node/child_process/child-process-stdio.test.js:
(pass) process.stdout > should allow us to write to it [20.66ms]
(pass) process.stdin > should allow us to read from stdin in readable mode [25.03ms]
(pass) process.stdin > should allow us to read from stdin via flowing mode [23.73ms]
(pass) process.stdin > should allow us to read > 65kb from stdin [30.67ms]
(pass) process.stdin > should allow us to read from a file [24.22ms]
(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 [22.31ms]
(pass) ChildProcess stdio pipe streams are net.Socket > stdin/stdout/stderr pass instanceof and shape checks [2.53ms]
(pass) ChildProcess stdio pipe streams are net.Socket > stdin still delivers writes to the child and stdout still delivers reads [24.28ms]
(pass) ChildProcess stdio pipe streams are net.Socket > stdin.write respects the encoding argument [24.00ms]
(pass) ChildProcess stdio pipe streams are net.Socket > stdin.destroySoon flushes pending writes then
... (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
bun test v1.4.0 (5299c8ab9)
test/js/node/child_process/child-process-stdio.test.js:
(pass) process.stdout > should allow us to write to it [1134.94ms]
(pass) process.stdin > should allow us to read from stdin in readable mode [1379.72ms]
(pass) process.stdin > should allow us to read from stdin via flowing mode [1364.12ms]
(pass) process.stdin > should allow us to read > 65kb from stdin [1405.67ms]
(pass) process.stdin > should allow us to read from a file [1338.98ms]
(pass) child.stdin > write() after child 'close' returns false and calls back with ERR_STREAM_DESTROYED [139.95ms]
(pass) child.stdin > write() after child 'exit' (before 'close') returns false and calls back with ERR_STREAM_DESTROYED [1346.25ms]
(pass) ChildProcess stdio pipe streams are net.Socket > stdin/stdout/stderr pass instanceof and shape checks [152.99ms]
(pass) ChildProcess stdio pipe streams are net.Socket > stdin still delivers writes to the child and stdout still delivers reads [1384.83ms]
(pass) ChildPr
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 684ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (9015ms)
Bundle modules (94ms)
Postprocesss modules (232ms)
Bundle Functions (776ms)
Generate Code (35ms)
[10.17s] Bundled "src/js" for production
2573 kb
193 internal modules
13 native modules
90 internal functions across 19 files
[1/8] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)
^[[1m^[[92m Compiling^[[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
^[[1m^[[92m Compiling^[[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
^[[1m^[[92m Compiling^[[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
^[[1m^[[92m Compiling^[[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
^[[1m^[[92m Compiling^[[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
^[[1m^[[92m Compiling^[[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
^[[1m^[[92m Compiling^[[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
^[[1m^[[92m Compiling^[[0
... (truncated)
diff hotspot
src/js/internal/fs/streams.ts | 12 --
src/js/internal/streams/native-readable.ts | 12 +-
src/js/node/child_process.ts | 223 +++++++++++++++++----
.../node/child_process/child-process-stdio.test.js | 148 ++++++++++++++
4 files changed, 336 insertions(+), 59 deletions(-)
gate history · 5 passed · 0 rejected · iteration 1
evidence per changed file
file reads edits tests
src/js/internal/fs/streams.ts 2 1 0
src/js/internal/streams/native-readable.ts 3 4 0
src/js/node/child_process.ts 9 10 0
test/js/node/child_process/child-process-stdio.test.js 2 5 0