Repository navigation
Conversation
When a write fails (EPIPE because the reader of stdout went away, for example), IOWriter::on_error and the EndOfFile arm of on_write_pollable delivered one error completion per distinct child and dropped the rest of that child's queued chunks. rm and the OutputTask builtins (ls, mkdir, touch, cp) queue one chunk per argument under the same ChildPtr and finish once every chunk has completed, so with two or more chunks queued they waited forever and the awaited `$` never settled. fail_pending_writers now hands every still-queued chunk its own error completion, walking the queue in place so that a child which stops at its first error can cancel_chunks() the rest from inside the callback. The two children that do stop early, cat and the subprocess CapturedWriter, now do that; the CapturedWriter in particular can be freed by its first error completion, so a second one would be a use-after-free. The EndOfFile path shares the same function instead of its own per-child copy, and on_sync_error withholds the enqueuing child's own chunk as before.
|
Warning Review limit reached
Next review available in: 6 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
Status Reproduced on bun 1.4.0 (release) and on a current debug build: with the reader of the process's stdout gone, Fix is in this PR. The new tests in |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it reworks the shell IOWriter error path with re-entrant callback dispatch and Arc<PipeReader> lifetime management (the PR itself notes an ASAN-caught UAF that the cancel_chunks ordering guards against), a human look would still be worthwhile.
What was reviewed:
fail_pending_writerswalk-in-place: liveness re-checked per iteration,err/broken_pipeset before first callback so re-entrantenqueuehitshandle_dead_writer; keepalive moved into the shared function.on_sync_errorwithhold semantics: only the first chunk of the enqueuing child is withheld; later chunks of the same child dispatch inline — verified those callers (Builtin/Cmd/CondExpr) count completions and don't finish on the first one.CapturedWriter/catcancellation: both callcancel_chunksbefore their first error completion can free them, andfail_pending_writersre-derivesstate()per iteration so the cancellation is observed.
Extended reasoning...
Overview
This PR fixes a hang in Bun Shell where builtins that queue multiple output chunks under one ChildPtr (ls, mkdir, rm, touch, cp) never finish when the process's stdout reader goes away. The old fail_pending_writers/broken_pipe_for_writers delivered one error per distinct child and dropped the rest; the fix delivers one error completion per queued chunk. Two children (cat, CapturedWriter) that finish on their first error now cancel_chunks() from that callback so the walk skips their remaining chunks. Four files: IOWriter.rs (core change), cat.rs and subproc.rs (cancellation calls), and epipe.test.ts (four new spawned-fixture tests).
Security risks
None identified. This is internal shell I/O error-path plumbing with no untrusted-input parsing, auth, or crypto involvement.
Level of scrutiny
High. This is native memory-safety-critical code in the shell subsystem — the most-blocked category in Bun reviews. The change involves:
- Re-entrant callback dispatch while iterating a queue whose entries the callbacks may mark dead (
cancel_chunks). Arc<PipeReader>lifetime: the first error completion for aCapturedWritercan synchronously free thePipeReader(and the embeddedCapturedWriter) viatry_signal_done_to_cmd→close_io→detach; the PR's ASAN trace shows this was a real UAF without the cancellation.UnsafeCell<State>aliasing:state()must be re-derived across every re-entrant call, and the walk correctly does so per iteration.- The
on_sync_errorwithhold contract (avoiding trampoline re-entry) is preserved but subtly changed: only the first of the enqueuing child's chunks is withheld, later ones dispatch inline. The finder/verifier examined this and concluded it is correct because the affected callers count completions rather than finishing on the first one.
Other factors
The PR description is unusually thorough — it names the mechanism, the ASAN trace, the withhold contract, and why the queue is walked in place rather than cleared first. Test coverage is solid (four spawned fixtures covering OutputTask builtins, the rm counting path, and the CapturedWriter relay), verified to fail on the release build without the fix. The cat change is acknowledged as review-only (behind an experimental flag on POSIX). Given the re-entrancy and lifetime reasoning here is exactly the shape that has historically caught UAFs in this subsystem, a human reviewer familiar with the shell's Yield trampoline and Cmd node lifecycle should confirm the ordering invariants.
|
Heads-up: #40033 makes the builtins in your tests (ls, mkdir -v, rm -v) and the relayed subprocess exit 1 on EPIPE instead of 0 or the errno. Your tests only check that the command settles, so they are unaffected. It also adds a |
…a JS error (#42288) Fixes #42282. Supersedes #42251 (its commits are in this branch). ### Problem - A pipeline member that throws a JS error (`cmd | cat > ${new Response("r")}`) rejects the promise, but the process never exits. The members that already run keep running, the pipe ends of the failed member and of the members after it stay open, and a producer that writes more than the pipe buffer blocks forever. - A state that throws returned `Yield::Failed` with no node. The failed member never reported to its parent, so `Pipeline::child_done` (`src/runtime/shell/states/Pipeline.rs`) never ran for it, nothing released its `IO`, and the members after it were never started or freed. On main the trampoline also left the exception pending (`Yield.rs:134`), so after the first suspension the promise never settled. ### Fix - `Yield::Failed` carries the `NodeId`. The trampoline calls `Interpreter::fail(id)`: it takes the exception, kills every subprocess with SIGKILL, reports `id` to its parent as finished with exit code 1, and then rejects the promise. The rejection runs user JS, so it comes last. - The tree then winds down through the normal `child_done` path. The sequencing states stop on the `failed` flag (`interrupted()`), a pipeline frees the members it did not start (`release_unstarted`), a `Cmd` whose expansion completes later does not spawn, and an expansion stops after a failed command substitution. Each freed member drops its pipe ends, so the members that still run see EOF or EPIPE. - `finish` runs once every member has exited. It settles nothing when the script failed, but it still disables the keep-alive and drops the pending-activity count. No node is freed while a callback can still reach it, which is what #33996 got wrong. - Verified: `test/js/bun/shell/yield.test.ts` (20 new cases, 17 hang or abort on 1.4.3) and `shell-worker-terminate-leak.test.ts` (1 new ASAN case). Also `bunshell`, `pipeline_stack`, `epipe`, `throw`, `lazy`, `shell-hang`, `exec`, `file-io`, `shell-worker-terminate-leak`, `commands/` and the rest of `test/js/bun/shell`. ### Background - The shell interpreter is a tree of state nodes in an arena (`Interpreter::nodes`). A node reports to its parent with `child_done`, and the parent frees it. `Yield::run` is the trampoline that steps nodes until one suspends. - A pipeline creates one socketpair per `|` and gives each member an `IOReader` (read end) and an `IOWriter` (write end). The fds close when the last `Arc` drops, that is, when the member node is freed. - `hasPendingActivity` keeps the `ShellInterpreter` wrapper alive until `finish`. A subprocess exit, a pipe read, or a thread-pool task can call back into the interpreter until then. <details><summary>Notes</summary> Relation to #42251: that PR rejects the promise but leaves the failed pipeline's interpreter pinned and its members running, and names #42282 as the follow-up. Landing it alone ships an interim design, landing this one alone strands it, so this PR carries its commits and #42251 is closed. Here `reject_with_pending_exception` only takes the exception and rejects. The keep-alive and the pending-activity count are released by `finish`, as on the success path. Why kill instead of wait: `sleep 100 | bad > ${blob}` would keep the process open for 100 s. A member that does write the pipe gets EPIPE once the failed member's read end closes, so the kill only matters for members that do not touch the pipe. SIGKILL is what `Cmd::deinit` already uses. In-process tests check the mechanism, not only that the process exits: the member that does not touch the pipe writes its pid, the failing member waits for that file in a command substitution before it throws, and after the rejection the pid is gone (killed and reaped). Two members that fail reject with the first error, and a non-quiet pipeline rejects and leaves the shell usable. A worker that is terminated while a failed pipeline winds down runs clean under ASAN and LSan. Shapes covered by the tests: producer bigger than the pipe buffer, a member that never touches the pipe, members after the failed one, the failed member first, a glob-expanding member whose glob completes after the failure (`${bun} -e ... * | bad` spawned the command before the guard in `Cmd::next`), a subshell member, a command substitution in another member, a builtin producer on the thread pool, `(cmd; touch a) | bad; touch b` leaves no file, and a failed pipeline is collected by the GC. The other producers of `Yield::Failed` take the same path: a failed `dup()` for a subshell or command substitution, a failed write of the `command not found` message, an OOM pin of an `ArrayBuffer` redirect. #40033 changes the `command not found` write failure to exit 1 instead of a throw; this PR keeps the throw. Self-review: 10 concerns raised, 3 addressed (the `Cmd::next` guard, `Fixes` line, fold #42251). Rejected: a parse-time check of the redirect target instead of a runtime wind-down, because the other producers above reach the same path with no misuse of the API, and waiting instead of killing, because a member that does not touch the pipe would pin the process for its lifetime. Found while testing, not changed here: `ls -R dir | <consumer that exits early>` hangs on 1.4.3 because `IOWriter` reports a failed write once per child while `ls` queues one chunk per directory. #37719 fixes that. The test here uses `ls dir` (one chunk). Probes on the ASAN debug build: `BUN_JSC_validateExceptionChecks=1` and `BUN_GARBAGE_COLLECTOR_LEVEL=2` over `yield.test.ts`, all green. `leak.test.ts` memleak cases and `shell-blocking-pipe.test.ts` time out the same way on a main build in this container. `commands/ls.test.ts` permission cases fail as root and the `node_modules` case needs the registry. `cargo check -p bun_runtime` passes for `x86_64-pc-windows-msvc` and `aarch64-apple-darwin`. </details> <!-- robobun:evidence:begin --> --- **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/shell/yield.test.ts, test/js/bun/shell/shell-worker-terminate-leak.test.ts <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
…a JS error (oven-sh#42288) Fixes oven-sh#42282. Supersedes oven-sh#42251 (its commits are in this branch). ### Problem - A pipeline member that throws a JS error (`cmd | cat > ${new Response("r")}`) rejects the promise, but the process never exits. The members that already run keep running, the pipe ends of the failed member and of the members after it stay open, and a producer that writes more than the pipe buffer blocks forever. - A state that throws returned `Yield::Failed` with no node. The failed member never reported to its parent, so `Pipeline::child_done` (`src/runtime/shell/states/Pipeline.rs`) never ran for it, nothing released its `IO`, and the members after it were never started or freed. On main the trampoline also left the exception pending (`Yield.rs:134`), so after the first suspension the promise never settled. ### Fix - `Yield::Failed` carries the `NodeId`. The trampoline calls `Interpreter::fail(id)`: it takes the exception, kills every subprocess with SIGKILL, reports `id` to its parent as finished with exit code 1, and then rejects the promise. The rejection runs user JS, so it comes last. - The tree then winds down through the normal `child_done` path. The sequencing states stop on the `failed` flag (`interrupted()`), a pipeline frees the members it did not start (`release_unstarted`), a `Cmd` whose expansion completes later does not spawn, and an expansion stops after a failed command substitution. Each freed member drops its pipe ends, so the members that still run see EOF or EPIPE. - `finish` runs once every member has exited. It settles nothing when the script failed, but it still disables the keep-alive and drops the pending-activity count. No node is freed while a callback can still reach it, which is what oven-sh#33996 got wrong. - Verified: `test/js/bun/shell/yield.test.ts` (20 new cases, 17 hang or abort on 1.4.3) and `shell-worker-terminate-leak.test.ts` (1 new ASAN case). Also `bunshell`, `pipeline_stack`, `epipe`, `throw`, `lazy`, `shell-hang`, `exec`, `file-io`, `shell-worker-terminate-leak`, `commands/` and the rest of `test/js/bun/shell`. ### Background - The shell interpreter is a tree of state nodes in an arena (`Interpreter::nodes`). A node reports to its parent with `child_done`, and the parent frees it. `Yield::run` is the trampoline that steps nodes until one suspends. - A pipeline creates one socketpair per `|` and gives each member an `IOReader` (read end) and an `IOWriter` (write end). The fds close when the last `Arc` drops, that is, when the member node is freed. - `hasPendingActivity` keeps the `ShellInterpreter` wrapper alive until `finish`. A subprocess exit, a pipe read, or a thread-pool task can call back into the interpreter until then. <details><summary>Notes</summary> Relation to oven-sh#42251: that PR rejects the promise but leaves the failed pipeline's interpreter pinned and its members running, and names oven-sh#42282 as the follow-up. Landing it alone ships an interim design, landing this one alone strands it, so this PR carries its commits and oven-sh#42251 is closed. Here `reject_with_pending_exception` only takes the exception and rejects. The keep-alive and the pending-activity count are released by `finish`, as on the success path. Why kill instead of wait: `sleep 100 | bad > ${blob}` would keep the process open for 100 s. A member that does write the pipe gets EPIPE once the failed member's read end closes, so the kill only matters for members that do not touch the pipe. SIGKILL is what `Cmd::deinit` already uses. In-process tests check the mechanism, not only that the process exits: the member that does not touch the pipe writes its pid, the failing member waits for that file in a command substitution before it throws, and after the rejection the pid is gone (killed and reaped). Two members that fail reject with the first error, and a non-quiet pipeline rejects and leaves the shell usable. A worker that is terminated while a failed pipeline winds down runs clean under ASAN and LSan. Shapes covered by the tests: producer bigger than the pipe buffer, a member that never touches the pipe, members after the failed one, the failed member first, a glob-expanding member whose glob completes after the failure (`${bun} -e ... * | bad` spawned the command before the guard in `Cmd::next`), a subshell member, a command substitution in another member, a builtin producer on the thread pool, `(cmd; touch a) | bad; touch b` leaves no file, and a failed pipeline is collected by the GC. The other producers of `Yield::Failed` take the same path: a failed `dup()` for a subshell or command substitution, a failed write of the `command not found` message, an OOM pin of an `ArrayBuffer` redirect. oven-sh#40033 changes the `command not found` write failure to exit 1 instead of a throw; this PR keeps the throw. Self-review: 10 concerns raised, 3 addressed (the `Cmd::next` guard, `Fixes` line, fold oven-sh#42251). Rejected: a parse-time check of the redirect target instead of a runtime wind-down, because the other producers above reach the same path with no misuse of the API, and waiting instead of killing, because a member that does not touch the pipe would pin the process for its lifetime. Found while testing, not changed here: `ls -R dir | <consumer that exits early>` hangs on 1.4.3 because `IOWriter` reports a failed write once per child while `ls` queues one chunk per directory. oven-sh#37719 fixes that. The test here uses `ls dir` (one chunk). Probes on the ASAN debug build: `BUN_JSC_validateExceptionChecks=1` and `BUN_GARBAGE_COLLECTOR_LEVEL=2` over `yield.test.ts`, all green. `leak.test.ts` memleak cases and `shell-blocking-pipe.test.ts` time out the same way on a main build in this container. `commands/ls.test.ts` permission cases fail as root and the `node_modules` case needs the registry. `cargo check -p bun_runtime` passes for `x86_64-pc-windows-msvc` and `aarch64-apple-darwin`. </details> <!-- robobun:evidence:begin --> --- **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/shell/yield.test.ts, test/js/bun/shell/shell-worker-terminate-leak.test.ts <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
|
This change also fixes a hang that needs no closed process stdout. A pipeline inside the shell is enough: import { $ } from "bun";
// dir has a few hundred subdirectories
await $`ls -R ${dir} | true`; // never settles
await $`ls -R ${dir} | head -n 1`; // never settles
await $`ls -R ${dir} | sleep 1`; // never settles
Measured on bun 1.4.3-canary.1+09bb54630 (Linux x64): This branch conflicts with main now. The conflict is in
With the rebased change the three pipelines above settle in every run (13 runs with 600 subdirectories, 0 hangs). All of One difference from bash remains. After the EPIPE, |
|
Superseded by #43183. It carries this change rebased onto main (this branch conflicts in |
Problem
ls -d a b c,rm -v,mkdir -v) never finishes once the process's stdout has lost its reader, e.g.bun script.ts | head -2. The awaited$promise never settles, so the script hangs (bun 1.4.0 too); one item, orecho, finishes.rmand theOutputTaskbuiltins queue one chunk per argument under one child and finish only after counting a completion per chunk, so after the first EPIPE they waited for the other N-1 forever, and those tasks leaked.Fix
catand the subprocess output relay, now cancel their remaining chunks inside that first callback. For the relay this is required: the first completion can free the relay, so a second one is a use-after-free (ASAN report in the original). Thecatline is covered by review only.ls -dwith 16 entries,mkdir -v,rm -vand a relayed subprocess. Without the fix the three builtins time out in 6/6 runs; with it all pass on a debug ASAN build. Exit codes on EPIPE are deliberately not asserted (shell: exit 1 on builtin failure instead of raw errno; fix cd ENOENT message #32278 is changing them).Background
$) runs commands such asls,rm,mkdirandcatin-process as builtins; their output reaches the process's stdout through the shell, not directly.IOWriteris the shell's per-file-descriptor write queue. A child enqueues chunks tagged with itsChildPtrand normally gets one completion callback per chunk;cancel_chunksmarks a child's queued chunks dead, so they are skipped with no callback.rmand theOutputTaskbuiltins produce output on a thread pool, one chunk per argument, all under the builtin's singleChildPtr, and finish when completions counted equals chunks queued.CapturedWriterrelays a spawned subprocess's piped output into the same queue, one chunk per read. It is embedded in the subprocess's pipe reader, so finishing the command from its callback can free it.Original description
What does this PR do?
A Bun Shell builtin whose output consists of several chunks never finishes when the process's stdout has lost its reader, so the awaited
$promise never settles and the script hangs:With a single entry (
ls -d dst_1), or withecho, the command finishes..quiet()avoids it because nothing is written to stdout at all.Cause
IOWriteris the per-fd write queue the shell uses to echo command output. When a write fails,on_error(and the EndOfFile arm ofon_write_pollable, viabroken_pipe_for_writers) delivered one error completion per distinct child and dropped the rest of that child's queued chunks.rmand theOutputTaskbuiltins (ls,mkdir,touch,cp) queue one chunk per argument, all under the sameChildPtr((cmd, WriterTag::Builtin)), and finish once they have counted a completion for every chunk (output_done >= output_waiting,output_queueFIFO). After the first EPIPE they received exactly one completion and waited for the other N-1 forever. The N-1OutputTaskboxes leaked as well.In the Zig implementation each
OutputTaskwas its own writer child, so "one error per child" was one error per chunk for these builtins; the Rust port collapsed them onto the builtin'sChildPtr(theoutput_queueFIFO is the stopgap for that) without changing the error path.Fix
fail_pending_writersnow gives every chunk that is still queued its own error completion, oldest first, and all three error paths (on_error,on_sync_error, EndOfFile) go through it;broken_pipe_for_writersis gone.Two children stop at their first error while more of their chunks may still be queued:
catand the subprocess output relay (CapturedWriter). Both nowcancel_chunks()their remaining chunks from inside that first error callback, and the queue is walked in place (liveness re-checked before each callback, cleared only at the end) so that the cancellation takes effect. This is the samecancel_chunksboth already use on their other teardown paths. Nothing can be appended to the queue during the walk:err/broken_pipeare set before the first callback, so a re-entrantenqueueis answered byhandle_dead_writeras before (#32946).The
CapturedWritercancellation is required, not just tidy: its first error completion can gotry_signal_done_to_cmd->buffered_output_close->close_io->Readable::finalize->detach, which drops the lastArc<PipeReader>and frees theCapturedWriteritself, so a second completion for it would be a use-after-free. ASAN confirmed that with the per-chunk delivery but without the cancellation (happens when the subprocess's output was fully read before the writer failed):ASAN report (per-chunk delivery without the CapturedWriter cancellation)
cat's case is the same shape (Builtin::donefrom the first completion retires the Cmd node, so a second completion would dispatch into a freed or reused node); it is theExecStdin/ExecFilepathArgswrite-error arm, one line away from the cancellation that its read-error arm already did. The builtin is currently behindBUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINSon POSIX and I did not find a way to get more than one of its chunks queued at once there, so that line is covered by review rather than by a test.The alternative of keeping "one error per child" and having the builtins drain their own queues does not work: their
output_queueFIFO does not know which writer (stdout or stderr) a given chunk went to, so a single notification cannot tell them how many of their chunks just failed. Giving everyOutputTaskits ownChildPtragain (what theoutput_queuestopgap stands in for) would fix theOutputTaskbuiltins but notrm, which counts chunks under the builtin's pointer too, so the contract is fixed at the queue instead and the two early-exit children opt out explicitly.Other details:
on_sync_errorkeeps its contract: the enqueuing child's own chunk is withheld and returned so the trampoline that is already on the stack delivers it (shell: don't re-enter the Yield trampoline on synchronous write errors #33008); other queued chunks, if any, are dispatched inline. It now comes back asYield::done()only if one of those inline completions made the child cancel its chunks, i.e. the child has already finished.SystemErrorwithto_system_error(); it now usesto_shell_system_error()like theon_errorpath (which is the one a real EPIPE takes, since the pipe writer reports it as an error). Thehandle_dead_writerrejections are unchanged.on_error/on_sync_errorheld moved into the shared function, which is where the post-callback state access happens; the EndOfFile path gets it too.ls/mkdir/rmreport 0, asls -d one_entryalready did; a relayed subprocess reports the errno, as before). What the exit code should be on a write error is a separate question (shell: exit 1 on builtin failure instead of raw errno; fix cd ENOENT message #32278 is moving these around), so the tests only assert that the command settles.How did you verify your code works?
New tests in
test/js/bun/shell/epipe.test.ts. A fixture waits until writes to its own stdout fail with EPIPE (the test closes the read end right after spawning), starts the command, blocks the main thread briefly so every argument's output is queued in one batch, and awaits it.ls -dwith 16 entries (OutputTask path),mkdir -v(OutputTask path, and the directories must exist afterwards),rm -v(the counting path inrm, and the files must be gone), and a relayedhead -c 1048576 /dev/zerosubprocess (CapturedWriter; 4 to 6 of its chunks were queued when the writer failed in every local run).Against the release build without the fix,
ls,mkdirandrmtime out in 6/6 runs and the subprocess test passes (it guards the cancellation described above, which ASAN catches when the ordering hits); with the fix, all pass on a debug ASAN build (5/5 runs). The rest oftest/js/bun/shell(bunshell, commands/, yield, shell-write-fault, shell-pipe-read-fault, shelloutput, shell-blocking-pipe, pipeline_stack, throw, exec, shell-hang, file-io, leak) shows no new failures on the debug build; the only failures here are pre-existing in this environment (thelspermission-denied tests, which fail as root on the release build too, and leak tests that exceed their 100s budget under ASAN).