shell: reject the promise and stop the pipeline when a member throws a JS error
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::Failedwith no node. The failed member never reported to its parent, soPipeline::child_done(src/runtime/shell/states/Pipeline.rs) never ran for it, nothing released itsIO, 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::Failedcarries theNodeId. The trampoline callsInterpreter::fail(id): it takes the exception, kills every subprocess with SIGKILL, reportsidto 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_donepath. The sequencing states stop on thefailedflag (interrupted()), a pipeline frees the members it did not start (release_unstarted), aCmdwhose 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. finishruns 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) andshell-worker-terminate-leak.test.ts(1 new ASAN case). Alsobunshell,pipeline_stack,epipe,throw,lazy,shell-hang,exec,file-io,shell-worker-terminate-leak,commands/and the rest oftest/js/bun/shell.
Background
- The shell interpreter is a tree of state nodes in an arena (
Interpreter::nodes). A node reports to its parent withchild_done, and the parent frees it.Yield::runis the trampoline that steps nodes until one suspends. - A pipeline creates one socketpair per
|and gives each member anIOReader(read end) and anIOWriter(write end). The fds close when the lastArcdrops, that is, when the member node is freed. hasPendingActivitykeeps theShellInterpreterwrapper alive untilfinish. A subprocess exit, a pipe read, or a thread-pool task can call back into the interpreter until then.
Notes
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.
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