child_process: support piping subprocess stdio streams between spawns
Repro
const { spawn } = require('child_process');
const p2 = spawn('perl', ['-ne', 'print uc'], {
stdio: ['pipe', process.stdout, process.stderr],
});
const p1 = spawn('node', ['-p', '"hello world"'], {
stdio: ['ignore', p2.stdin, process.stderr],
});
p1.on('exit', () => { p2.stdin.end(); });
- Node: prints
HELLO WORLD. - Bun (before):
error: TODO: stream.Readable stdio @ 1.
Also broken in reverse: spawn(..., { stdio: [p1.stdout, 'pipe', 'inherit'] }).
Cause
nodeToBun() in node:child_process looks for .fd or ._handle.fd on stream stdio. Node's subprocess.stdin is a net.Socket that exposes its pipe fd on ._handle.fd; Bun's is a WriteStream wrapping a FileSink, and .stdout/.stderr is a native Readable wrapping a ReadableStreamSource — neither surfaced a fd where nodeToBun looks. Just forwarding the fd isn't enough either — two more problems follow (see test/regression/issue/30831.test.ts for the full reasoning).
Fix
Three changes, all required — removing any one breaks one of the three cases in the test file:
- Expose
.fdon the wrappers.writableFromFileSinknow readsfileSink._getFd();constructNativeReadablecalls a newgetFd()method onFileInternalReadableStreamSource(returnsreader.get_fd()).nodeToBun's existing.fd/._handle.fdlookup then finds it. - Quiesce the source stream. When
nodeToBunextracts an fd from a stream, it also pauses the stream (setFlowing(false) + updateRef(false) on the native ptr,.pause()on the JS Readable) and tags itkIsUsedAsStdio.#handleOnExitskipsstdout.resume?.()on tagged streams. Mirrors Node'sgetValidStdiopost-spawn hook (readStop()+pause()+kIsUsedAsStdioinlib/internal/child_process.js) — without it the parent's own PipeReader races the child for the pipe's bytes. - Clear
O_NONBLOCKbeforedup2. Bun sets pipe fds non-blocking in the parent for its own async reads;dup2shares the open file description so the child inherits non-blocking mode. A synchronous reader likecatseesEAGAINon its firstread(2)and exits 1 with 'Resource temporarily unavailable'.PosixStdio::Pipe(fd)now clearsO_NONBLOCKon caller-supplied fds beforedup2; safe because step (2) has paused the parent-side reader.
Verification
test/regression/issue/30831.test.ts — three tests covering forward piping (p2.stdin as p1's stdout slot), reverse (p1.stdout as p2's stdin slot), and a regression-guard for process.stdout/process.stderr passthrough.