subprocess: surface stdio read and write errors instead of dropping them
Problem
- A
recv()orsend()on a subprocess stdio socket that fails with an errno other thanEAGAIN/EINTR(ENOBUFS,ENOMEM,EIO,ECONNRESET) is lost in four places.Bun.spawn({stdin: ReadableStream})truncates the child's input and never calls the stream'scancel().child.stdoutfromnode:child_processemits'close'with no'error'.Bun.spawnrejects the read withEIObut keeps the stdout fd open, so a still-writing child blocks andproc.exitednever settles.Bun.spawnSyncreturns{stdout: undefined, success: true}. - The common cause is
FileReader::on_reader_error(src/runtime/webcore/FileReader.rs): it only rejects a parked read. With none parked it drops the error, leaves the fd and the poll in place, and the next pull reads on. #41420 (merged) fixes that function forBun.file().stream(). The bufferedSubprocessPipeReaderturns an error intoReadable::Ignore(subprocess.rs:on_close_io),native-readable.ts_destroycalls its callback without the error, andFileSink::on_closehands the piped streamundefinedas its cancel reason.
Fix
- On top of #41420's
read_errorslot, now on main:on_pullreturns a stored error before it consults the reader state, so a reader that never started can end with one. Readable::Errored(bytes, err)keeps a buffered pipe's partial output and error, and the reader releases its fd at once, the way EOF does, so the child getsEPIPE..stdoutbecomes a stream that delivers the bytes and then errors (ReadableStream::from_bytes_then_error).Bun.spawnSyncthrows the error, sochild_process.spawnSyncreportsresult.errorandexecFileSyncthrows.native-readable.tspasses the error to the_destroycallback, so the nodeReadableemits'error'before'close'.FileSink::on_closepasses the recorded write error to the source, and the pump's abrupt path (rsisAbrupt) cancels the stream with it even though its own orphaned reader holds the lock.- Verified:
test/js/bun/spawn/spawn-stdio-syscall-error.test.ts(12 tests, all fail on 1.4.1; anLD_PRELOADshim fails the Nthrecv/sendon the stdio socketpair). Alsotest/js/bun/spawn/,test/js/node/child_process/,test/js/node/stream/,test/js/web/streams/,test/js/bun/io/bun-write.test.js, the shell fault tests.
Background
Bun.spawnreads stdout into aSubprocessPipeReaderuntil JS touches.stdout. Then the fd and its buffer move into aFileReader, the native source behind the JSReadableStream. A JS pull first tries a synchronous read; with no data it registers a poll and parks aPendingpromise.- The
BufferedReaderreportson_read_chunk,on_reader_done(EOF: it closes itself first) oron_reader_error(it did not close). node:child_processwraps the native stream withnative-readable.ts, aReadablewhose_readcalls the nativepull. A sync throw frompullreacheserrorOrDestroy, which calls_destroy(err, cb). Node's contract iscb(err).- A
ReadableStreamgiven asstdinis pumped into aFileSinkbyreadStreamIntoSink(BunStreamSource.cpp). A sink write that fails synchronously throws into the pump (rsisAbrupt). A write that fails later closes the writer, which calls the pump'sonClose(reason).
Notes
The four faces come from a fault-injection fuzz round (internal ledger entries 20002 to 20005, not GitHub issues): strace injected errnos on the parent's stdio socketpairs. All four pre-date 1.4.
Open question for review: Bun.spawnSync has no error channel other than a throw, so the partial output, the exit code and the pid are lost when a stdio read fails. Node's spawnSync returns {error, status, pid, stdout: <partial>}. A result field on Bun.spawnSync (next to exitedDueToMaxBuffer) would let child_process.spawnSync match Node. That is new API surface, so this PR keeps the throw.
Follow-up candidates, kept out of this PR: the fd release on a read error belongs once in PosixBufferedReader::on_error (src/io/PipeReader.rs) for every parent, not per parent. The native-readable.ts _destroy fix also makes a user's child.stdout.destroy(err) emit 'error' before 'close'.
The strace-based repros from the ledger reproduce with the test's shim (strace is not available in the build container). On 1.4.1:
- stdin stream,
send#3 fails withENOBUFS:cancelReason "not-called", the child reads 131072 of 8388608 bytes, exit code 0, nothing thrown. - child_process,
recv#3 fails withEIO: events["stdout.close", "exit:1:null"], 360448 of 8000000 bytes. - Bun.spawn
for await,recv#3 fails:rerr "EIO", thenexiteddoes not settle, the child sits indo_wait/sock_alloc_send_pskb. Withrecv#4 failing instead the error is swallowed and reading continues. - Bun.spawnSync:
{stdout: undefined, exitCode: 0, success: true};cp.spawnSync{stdout: null, status: 0, error: undefined};execFileSyncreturnsnull.
rsisAbrupt cleared op->m_reader and then tested isReadableStreamLocked(stream). The orphaned reader still locks the stream, so that branch never ran and the source's cancel() was never called. The old JS pump had the same gap (stream.cancel(e) on a locked stream rejects).
The shim interposes syscall() for SYS_close as well as close(): bun closes fds through the raw syscall, so a per-fd counter that only reset in close() carried over to a recycled fd number.
Pre-existing on this machine with or without the change (debug ASAN build): test/js/bun/spawn/spawn-pipe-leak.test.ts exceeds its 30 s budget (450 ms per iteration), test/js/web/streams/streams-leak.test.ts "Absolute memory usage" sits at the 700 MB ASAN bound (692 to 713 MB across runs on both builds), and child_process.test.ts "spawn in the default shell" fails on the released bun too.
no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/spawn/spawn-stdio-syscall-error.test.ts