shell: stop reading a subprocess's output once relaying it to stdout/stderr fails
Problem
await $`yes`never settles once nothing reads the process's stdout (bun script.ts | head -c1). Bun reads the child's output into the capture buffer without end and the child keeps running.- Cause:
CapturedWriter::on_iowriter_chunk(src/runtime/shell/subproc.rs) records the relay error, buttry_signal_done_to_cmddoes nothing while the child still holds the pipe. Nothing stops the reader.
Fix
- New
PipeReader::on_capture_failed: while the reader is stillPending, close the shell's end of the pipe and set the EOF state, so the error reaches theCmdnow as aCaptureError::Relay. The stream closes, but the status waits inSubprocExec::relay_statusuntil the child exits. A read error (CaptureError::Read) still ends the command at once. - Correct because this is what a shell does: the child's next write fails, and the command ends with the child, not with a kill. The status is the one a child that exits by itself gets today.
- The rejection can also arrive inside the reader's own
on_read_chunk(dead writer), where theCmddrops itsArc<PipeReader>under the read loop. The reader gives the loop aref_/derefkeepalive likeIOReader(ASAN use-after-free without it, Notes).on_read_chunkandon_iowriter_chunktake*mut Selflike the other callbacks that can free their owner. - Verified:
test/js/bun/shell/epipe.test.ts, 6 new cases, 5 time out on the released bun, on Linux and Windows. Also the shell suites in Notes.
Background
- The shell reads a command's stdout and stderr through one
PipeReadereach. A chunk goes to the$result and, throughCapturedWriter, to theIOWriterof the process's own stdout or stderr.IOWriterreports a failed write once, then rejects new chunks synchronously. - A
Cmdfinishes when it has a status and all piped streams are closed.Cmd::deinitkills a child still running then.2>&1or2>/dev/nullleaves stdout as the only piped stream. ref_/derefon aBufferedReaderParentkeep the owner alive for one read-loop entry. On Windows a2>&1stdout is read withuv_fs_read;WindowsBufferedReader::deinitreleasing its buffer under that read was fixed on main by #31940, which this branch merges.
Notes
Repro (bun 1.4.0, Linux), relay.mjs:
import { $ } from "bun";
import fs from "node:fs";
fs.writeSync(1, "x");
await Bun.sleep(300);
const o = await $`/usr/bin/yes`.nothrow();
console.error("settled", o.exitCode);
timeout 20 bun relay.mjs | head -c1: before, "settled" is never printed, the timeout kills bun, yes is in state R and bun's RSS is 2 GB after 3 s. After: settled 32 after 277 ms, yes reports that its stdout is gone and exits, no process is left. A command that exits by itself (/bin/echo hi) already settled with 32 before this change.
Paths and test cases. The fixture is epipe-relay-fixture.ts. The producer is bun itself: it exits 0 after its first failed write and records that write in a file, so every case can check that the child saw the closed pipe and exited by itself, and that the failed relay is what fails the command.
relay: the error arrives from the IOWriter's poll. stderr is still relayed, so the command finishes when the child exits after its write fails. The test also checks the relayed stderr line.relay-only-stdout(2> /dev/null) andrelay-stderr-to-stdout(2>&1): stdout is the only piped stream. The command still waits for the child's exit.dead-writer(echo hello; ...):echoalready broke the writer, so the child's first chunk is rejected from insideon_read_chunk. This is the synchronous path that needs the keepalive.dead-writer-only-stdout: the same, with stdout as the only piped stream.exits-by-itself: the path that already worked, kept as a guard.
The producer can see EPIPE or ECONNRESET (Linux reports ECONNRESET when the closed socket still held unread data), so the test only checks that a write failed. The exit status is not pinned: other open work changes what a write error maps to.
ASAN output with the keepalive removed, both dead-writer cases:
heap-use-after-free in PosixBufferedReader::is_done, src/io/PipeReader.rs:843 (read_loop, after on_read_chunk returned)
freed by: PipeReader::detach <- Readable::finalize <- ShellSubprocess::close_io <- Cmd::buffered_output_close_stdout
<- PipeReader::try_signal_done_to_cmd <- PipeReader::on_capture_failed <- CapturedWriter::on_iowriter_chunk
<- Yield::run <- CapturedWriter::do_write <- PipeReader::on_read_chunk
on_read_chunk and do_write no longer borrow the PipeReader across the trampoline. The error Yield is still returned to the trampoline that delivered the chunk, not run nested: Interpreter::finish runs JS continuations synchronously (the ASAN run below printed the fixture's result line before it crashed), so a nested run would use one more level of the trampoline's depth budget.
In the asynchronous case the PipeReader is freed inside on_iowriter_chunk. That is the same shape as the existing path for an error that arrives after EOF (see the ASAN note in #37719). Nothing touches the writer after the call.
The state is set to Done(empty) and not to Err: take_capture_error keeps an existing read error over the relay error. Done is what on_reader_done sets too, and reader.deinit() has just emptied the buffer it would take.
Why the status waits for the exit (self-review finding): an earlier revision set the command's status from the relay error at once. With stdout as the only piped stream (2>&1, 2>/dev/null) that finished the command while the child was still running and Cmd::deinit sent it SIGKILL, while the default shape let the same child see EPIPE and exit by itself. bash reports the child's own fate in both shapes. Now the relay error only closes the stream; relay_status is applied by on_exit, so the command ends when the child does, in every shape, and the child is reaped by the normal exit path (no zombie, no kill). Read errors keep the old behavior: shell-pipe-read-fault.test.ts pins that a read error on a sleep 5 child ends the command at once with the errno.
The on_iowriter_chunk receiver (self-review finding): the async relay error frees the PipeReader, and with it the CapturedWriter, inside this call. It now takes *mut Self and the IOWriter dispatch forms no &mut, the same shape as on_read_chunk, on_reader_done and on_reader_error.
WindowsBufferedReader::deinit (self-review finding): 2>&1 on Windows hands the shell a BufferFd, read through uv_fs_read, so a read is in flight between callbacks. deinit released _buffer before close_impl could move it to the File (orphaned_read_buf). #31940 fixed this on main while this PR was open (the first revision here carried the same change); the branch merges main, so the PR's own diff no longer touches src/io. This PR is the first caller of deinit on a reading Windows PipeReader; the other callers are POSIX only.
Before the error is delivered, one event-loop turn of output is still read and captured. With yes on a debug build that was 39 MB. That is the existing greedy socket read loop, not changed here.
Related open work: #37719 (one error per queued chunk; it also edits on_iowriter_chunk and this test file), #38380 (reaping a child that the shell kills or that exits after the command finished; this PR adds one more path that reaches it, as the existing error paths do).
Suites run on the ASAN debug build: epipe (10 runs), bunshell (424 pass), shell/commands, shell-pipe-read-fault, shell-write-fault, shell-hang, exec, shelloutput, shell-blocking-pipe, pipeline_stack, yield, shell-worker-terminate-leak, throw, bunshell-instance, bunshell-default, shell-cmdsub-crash, bunshell-file, file-io, lazy, shell-load. The failures seen are environmental and also fail with the released bun here: the ls/rm node_modules cases (no registry access), the ls permission cases (root), shell-load (512 pid limit), shell-blocking-pipe (the heap snapshot takes over 5 s on this build, passes alone). cargo check -p bun_runtime --target x86_64-pc-windows-msvc passes.
Windows (Server 2019 x64, debug build of this branch at 1a640be): all six fixture modes settle, the producer reports EPIPE in every mode that has one, including 2>&1, and the command's status is 4047 (libuv's EPIPE). The unfixed canary hangs in relay mode there too, so the new cases run on Windows as well. reader.deinit() hands the stdout pipe to uv_close from inside its own read callback, which libuv allows; the read loop's keepalive still covers the Arc.
[review] gate passed · iteration 0 · 5 files touched
fails on main (without fix)
ASAN without fix: 5 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/shell/epipe.test.ts
bun test v1.4.1 (4448a2e21)
test/js/bun/shell/epipe.test.ts:
y
y
y
y
y
y
y
y
y
y
(pass) IOWriter epipe > builtin pipe to command [61.02ms]
(pass) IOWriter epipe > concurrent [844.06ms]
(fail) relayed output after the stdout reader went away > the child keeps writing, its stderr is still relayed [5005.02ms]
^ this test timed out after 5000ms.
(fail) relayed output after the stdout reader went away > the child keeps writing, stdout is the only relayed stream [5000.13ms]
^ this test timed out after 5000ms.
(fail) relayed output after the stdout reader went away > the child keeps writing, its stderr shares the stdout pipe [5000.10ms]
^ this test timed out after 5000ms.
(fail) relayed output after the stdout reader went away > the shell's writer is already dead when the child writes [5000.10ms]
^ this test timed out after 5000ms.
(fail) relayed output after the stdout reader went away > the shell's writer is already dead, stdout is the only relayed stream [5000.09ms]
^ this test timed out after 500
... (truncated)
release without fix: 5 FAILED
bun test v1.4.0-canary.1 (4448a2e21)
test/js/bun/shell/epipe.test.ts:
y
y
y
y
y
y
y
y
y
y
(pass) IOWriter epipe > builtin pipe to command [2.44ms]
(pass) IOWriter epipe > concurrent [66.85ms]
(pass) relayed output after the stdout reader went away > the child exits by itself [30.39ms]
(fail) relayed output after the stdout reader went away > the child keeps writing, its stderr is still relayed [5000.07ms]
^ this test timed out after 5000ms.
(fail) relayed output after the stdout reader went away > the child keeps writing, stdout is the only relayed stream [5000.06ms]
^ this test timed out after 5000ms.
(fail) relayed output after the stdout reader went away > the child keeps writing, its stderr shares the stdout pipe [5000.06ms]
^ this test timed out after 5000ms.
(fail) relayed output after the stdout reader went away > the shell's writer is already dead when the child writes [5000.03ms]
^ this test timed out after 5000ms.
(fail) relayed output after the stdout reader went away > the shell's writer is already dead, stdout is the only relayed stream [5000.06ms]
^ this test timed out after 5000ms.
3 pass
5 fail
106 expect() calls
Ran 8 tests across 1 f
... (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/bun/shell/epipe.test.ts
bun test v1.4.1 (4448a2e21)
test/js/bun/shell/epipe.test.ts:
y
y
y
y
y
y
y
y
y
y
(pass) IOWriter epipe > builtin pipe to command [61.07ms]
(pass) IOWriter epipe > concurrent [916.99ms]
(pass) relayed output after the stdout reader went away > the child keeps writing, its stderr is still relayed [1135.93ms]
(pass) relayed output after the stdout reader went away > the shell's writer is already dead when the child writes [1083.59ms]
(pass) relayed output after the stdout reader went away > the shell's writer is already dead, stdout is the only relayed stream [1123.11ms]
(pass) relayed output after the stdout reader went away > the child keeps writing, its stderr shares the stdout pipe [1386.11ms]
(pass) relayed output after the stdout reader went away > the child keeps writing, stdout is the only relayed stream [1417.48ms]
(pass) relayed output after the stdout reader went away > the child exits by itself [1702.78ms]
8 pass
0 fail
123 expect() calls
Ran 8 tests across 1 file. [6.11s]
__F:0:S:0
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 931ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 241 extern-C blocks audited
[1/5] cargo bun_runtime → libbun_runtime.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_runtime v0.0.0 (/workspace/bun/src/runtime)
[1m[92m Finished[0m `release` profile [optimized + debuginfo] target(s) in 5m 45s
[2/5] link bun-profile
[4/5] strip bun
[4/5] bun-profile --revision
1.4.1-canary.1+a6d4b2cbd
[build] done
bun test v1.4.1-canary.1 (a6d4b2cbd)
test/js/bun/shell/epipe.test.ts:
y
y
y
y
y
y
y
y
y
y
(pass) IOWriter epipe > builtin pipe to command [2.67ms]
(pass) IOWriter epipe > concurrent [120.00ms]
(pass) relayed output after the stdout reader went away > the shell's writer is already dead when the child writes [23.92ms]
(pass) relayed output after the stdout reader went away > the shell's writer is already dead, stdout is the
... (truncated)
diff hotspot
src/runtime/shell/IOWriter.rs | 20 +--
src/runtime/shell/states/Cmd.rs | 54 +++++---
src/runtime/shell/subproc.rs | 210 ++++++++++++++++++++-----------
test/js/bun/shell/epipe-relay-fixture.ts | 88 +++++++++++++
test/js/bun/shell/epipe.test.ts | 70 ++++++++++-
5 files changed, 344 insertions(+), 98 deletions(-)
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file
file reads edits tests
src/runtime/shell/IOWriter.rs 4 2 0
src/runtime/shell/states/Cmd.rs 7 10 0
src/runtime/shell/subproc.rs 18 24 0
test/js/bun/shell/epipe-relay-fixture.ts 2 5 0
test/js/bun/shell/epipe.test.ts 5 10 0