child_process: apply kernel backpressure to stdout/stderr pipes
Problem
node:child_process stdout/stderr pipes had no backpressure: a child writing 100 MB into a pipe nobody reads was never blocked (completes in <4 s; Node blocks at the ~64 KiB kernel pipe buffer). The parent buffered the whole output in RAM with no ceiling, and if 'exit' fired before a 'data' consumer attached the entire buffered output was discarded, so a late reader got 0 of 104,857,600 bytes.
import { spawn } from 'node:child_process';
const c = spawn('sh', ['-c', 'dd if=/dev/zero bs=1M count=100 2>/dev/null'], { stdio: ['ignore', 'pipe', 'ignore'] });
await new Promise(r => setTimeout(r, 4000));
console.log('exitedBeforeAttach', c.exitCode !== null); // node: false bun: true
let got = 0; for await (const d of c.stdout) got += d.length;
console.log('bytes', got, 'of', 100 * 1048576); // node: 104857600 bun: 0
Cause
PipeReader::start() registered the fd with epoll/IOCP immediately, so the event loop drained the child's output into PosixBufferedReader._buffer (which has no HWM for non-streaming parents) before any JS consumer attached. When the child exited, #handleOnExit called resume() on a stream with no 'data' listener, dropping every buffered chunk.
Fix
When Bun.spawn is called with lazy: true (the documented "don't start reading stdout until accessed" option, which node:child_process uses internally), start the pipe reader paused: set IS_PAUSED before PosixBufferedReader::start() so it skips register_poll(), and on Windows wire the source without calling start_reading(). The kernel pipe buffer then provides backpressure and the child blocks until JS first reads, matching Node. FileReader::on_start() clears IS_PAUSED before its first read() so the transferred reader wakes up.
Also:
#handleOnExitnow matches Node'sflushStdio: onlyresume()stdout/stderr whenreadableFlowing === null(the stream was never touched), so an explicitly paused stream is not drained behind the user's back.- Fixed a debug-build
$debugtemplate literal innative-readable.tsthat interpolated the resolvedUint8Arraydirectly.Uint8Array.prototype.toString()comma-joins every byte, so a 220 KB chunk spent ~600 ms stringifying under ASAN. This was pre-existing but the chunkier delivery made it visible. - Dropped
lazy: truefrom thehuge()fixture inspawn.test.ts; itsafter exitsubtest relied on the eager buffering this removes (with correctlazysemantics a 250 KB write cannot finish before stdout is read).
Verification
$ bun-debug repro.mjs
exitedBeforeAttach false
bytes 104857600 of 104857600
The new stdout pipe backpressure > blocks the child until a reader attaches and delivers every byte test in test/js/node/child_process/child_process.test.ts fails on main (child exits with exitCode: 0 within 10 ms) and passes with the fix.
no test proof · iteration 3 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/spawn/spawn.test.ts test/js/node/child_process/child_process.test.ts