shell: fail every queued chunk when an IOWriter dies, not one per child
Problem
- A Bun Shell builtin that queues one output chunk per item never finishes when its reader goes away.
await $`ls -R dir | true`never settles. So dols -d a b,mkdir -v a bandrm -v a b. Linux and Windows. - On EPIPE,
fail_pending_writersandbroken_pipe_for_writers(src/runtime/shell/IOWriter.rs:831,:797) send one error completion per child and drop its other queued chunks. These builtins wait for one completion per chunk. - The builtin
catcan finish with chunks still queued (cat.rs:305). The next command reuses its node and gets their completions: lost output, orpanic: expected Node::Cmd at Node#3, got Free.
Fix
fail_pending_writersgives every queued chunk its own error completion. The three failure paths share it.catand the subprocess output relay (CapturedWriter) stop at their first error, so they callcancel_chunksthere. Without it the relay's second completion is a heap-use-after-free.catfinishes only whenchunks_done >= chunks_queued, as its stdin form does. Required: per-chunk completions make its leftover chunks panic on EPIPE.- Verified:
test/js/bun/shell/epipe.test.ts. 11 of its 15 Linux tests fail on main. All pass on Linux (debug ASAN) and Windows (debug).
Background
IOWriteris the shell's write queue for one file descriptor (the process's stdout, or a pipe between pipeline stages). A child queues a chunk and gets one completion callback.cancel_chunksmarks a child's queued chunks dead. A dead chunk gets no callback.- A finished command's node id is reused by the next command.
Supersedes #37719. #40228 and #42819 also fix the hang: see Notes.
Notes
The pipeline case. The pipe between two stages is a socketpair with an IOWriter on the write end (src/runtime/shell/states/Pipeline.rs). ls -R starts one thread pool task per directory. Each finished task queues its listing as one chunk under the builtin's ChildPtr. The next stage (true, pwd, a second ls, an external command that exits early) closes the read end without reading. The first write fails with EPIPE while several chunks are queued. A debug log of the unfixed build shows four OutputTask starts and one onIOWriterChunk. The hang is racy because it needs two or more chunks in the queue when the write fails. A chunk that is queued after the failure is rejected by handle_dead_writer with its own completion, which is why a single operand never hangs.
Measured, never settled of 10 runs each (ls -R d | ls -R d, ls -R d | pwd, ls -R d | true; the tree has 4 directories and 80 files):
| build | ls -R | pwd | true |
|---|---|---|---|
| 1.4.3-canary.1+c6b7fcb5b (Linux x64, release) | 7 | 10 | 10 |
| main b52d513481, debug ASAN (Linux x64) | 7 | 9 | 10 |
| this branch, debug ASAN (Linux x64) | 0 | 0 | 0 |
| 1.4.3-canary.1+b52d51348 (Windows x64, release) | 3 | 0 | 0 |
| this branch, debug (Windows x64) | 0 | 0 | 0 |
The controls (ls -R d | cat, ls d | ls d, seq 400 | ls -R d) settle in every run on every build.
Why the fix is in the queue and not in the builtins. The OutputTask builtins keep one FIFO for the chunks they queued on stdout and on stderr, and both can be different writers. One notification per child cannot tell them how many of their chunks failed. The Zig shell registered every OutputTask as its own writer child, so one error per child was one error per chunk there. The Rust port put all chunks of a builtin under the builtin's ChildPtr and kept the per-child error path. rm counted chunks under one child in Zig too.
Children of IOWriter, checked one by one. Only cat, rm, ls, mkdir, touch, cp and CapturedWriter can have two or more chunks queued. rm and the OutputTask builtins count completions and ignore the error value. cat and CapturedWriter finish at their first error, so they cancel. Every other child (echo, pwd, which, seq, yes, basename, dirname, cd, exit, export, mv, Cmd, CondExpr, Pipeline) waits for its one chunk before it queues the next.
on_sync_error. The chunk of the child whose enqueue is on the stack is still returned, not dispatched, so the trampoline delivers it after enqueue unwinds. Other queued chunks are dispatched inline, as before. CapturedWriter::do_write only runs while its reader is pending, so an inline completion there cannot free the relay.
The cat change. The file form of the builtin cat set out_done when its output queue drained and never cleared it. When more input arrived later, cat still finished at the end of its input, with the new chunks queued. Builtin::done frees the node, the next command reuses the id, and the completions of the leftover chunks go to that command (or to a freed node). The builtin is the default cat on Windows and is behind BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS elsewhere. Measured with cat fifo; echo tail, where the test reads first\n back before it writes 1 MB more to the FIFO:
| build | the test reads all of stdout | the test closes stdout |
|---|---|---|
| main (release canary, Linux) | output cut to 82 to 180 KB of 1 MB, exit 0, 10 of 10 | settles, 10 of 10 (the one completion per child reaches echo) |
this branch without the cat change (debug ASAN) | output cut, 10 of 10 | panic: expected Node::Cmd at Node#2, got Free, 10 of 10 |
| this branch | complete output, 10 of 10 | settles, 10 of 10 |
On Windows, cat big.txt | cat > out.txt (1 MB) five times in one process panics with expected Node::Cmd at Node#3, got Free in 6 of 6 processes on the canary, and in none on this branch. The read-error path of cat (cancel, then suspend) is not changed here. #37743 is open for it.
Tests.
pipeline stage whose reader exits without reading(new, in-process, runs on Windows too except the two rows with external commands):ls -d,mkdir -vandrm -vwith 64 operands intotrue,ls -Rof 300 directories intotrue, intotrue | cat, intosh -c 'exit 0', and intohead -n 1.command output after the stdout reader went away(from #37719, reworked):epipe-fixture.tswaits until its own stdout fails with EPIPE, then runsls -d,mkdir -v,rm -vor a relayedhead -c 1048576 /dev/zeroten times. It has no timed wait. A run takes the path under test only when several chunks are queued before the first write fails. Ten runs replace the 100 ms pause that #37719 used for that.- On main the 7 pipeline tests and the 3 fixture builtins time out: 3 of 3 runs of the file on a debug ASAN build, 4 of 4 on a release build.
- The relay test guards the
CapturedWritercancel. With that call removed, it fails in 7 of 8 runs on a debug ASAN build (heap-use-after-free inCapturedWriter::on_iowriter_chunkfromfail_pending_writers, or a timeout). cat whose input ends while its output is still queued: the two FIFO shapes from the table above (Linux, with the experimental builtins flag in a child process), and on Windowscat big.txt | cat > out.txtfive times in one child process. The FIFO tests do not run on macOS: both timed out there in CI because the handshake never completed, and I could not find out why without a macOS machine. Thecatlogic they cover is the same on every platform. The read-everything test fails on main in 3 of 3 runs, the Windows test in 4 of 4.cat with several chunks queued(Windows only, wherecatis a builtin by default) runscat big.txt | truewith a 1 MB file in a child process. It guards thecatcancel. With that call removed, the child panics withexpected Node::Cmd at Node#3, got Freein every run on a Windows debug build. It passes on the canary, which has no per-chunk completions.
Related open PRs. #40228 fixes the same hang in another way: it keeps the one-completion-per-child path (IOWriter.rs:792 and :830 on its head) and gives every OutputTask chunk its own identity (ChildPtr::builtin_task(node, seq)), with closed-pipe tests for ls, mkdir and rm. #42819 rewrites the failure path around a per-chunk failed queue, which is the contract of this PR, and it also calls cancel_chunks in CapturedWriter::on_iowriter_chunk. Both are large refactors (68 and 419 files). This PR is the small version of the fix against main. If it lands first, #40228 no longer needs the dedup or the per-chunk seq for this hang, but the two cancel_chunks calls and the cat count have to carry over: its cat.rs still has out_done. The open design question is which contract the queue keeps: one completion per queued chunk (this PR, #42819) or one per child identity (#40228).
Not changed. After the EPIPE, ls -R still walks the rest of the tree before it finishes. It settles, but a large tree takes as long as a full listing. Exit codes on EPIPE are not changed and not asserted for the failing stage (#40033 changes them).
no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/shell/epipe.test.ts