child_process: support piping subprocess stdio streams between spawns

RO
3mo ago
+547-23

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:

  1. Expose .fd on the wrappers. writableFromFileSink now reads fileSink._getFd(); constructNativeReadable calls a new getFd() method on FileInternalReadableStreamSource (returns reader.get_fd()). nodeToBun's existing .fd / ._handle.fd lookup then finds it.
  2. Quiesce the source stream. When nodeToBun extracts 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 it kIsUsedAsStdio. #handleOnExit skips stdout.resume?.() on tagged streams. Mirrors Node's getValidStdio post-spawn hook (readStop() + pause() + kIsUsedAsStdio in lib/internal/child_process.js) — without it the parent's own PipeReader races the child for the pipe's bytes.
  3. Clear O_NONBLOCK before dup2. Bun sets pipe fds non-blocking in the parent for its own async reads; dup2 shares the open file description so the child inherits non-blocking mode. A synchronous reader like cat sees EAGAIN on its first read(2) and exits 1 with 'Resource temporarily unavailable'. PosixStdio::Pipe(fd) now clears O_NONBLOCK on caller-supplied fds before dup2; 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.

Fixes #30831
Fixes #25498