Skip to content

shell: finish a pipeline whose pipe setup fails instead of throwing - #40740

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/57643f5b/shell-pipeline-emfile-finish
Aug 28, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
farm/57643f5b/shell-pipeline-emfile-finish

Conversation

@robobun

@robobun robobun commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • When a shell pipeline cannot create its pipes, the $ promise never settles and the process never exits. Repro: ulimit -n 16; bun -e 'await Bun.$\echo a; echo hi | cat | cat | cat | cat | cat | cat | cat | cat`'printsa, reports EMFILE: Too many open files (socketpair)`, then hangs.
  • The cause is the two pipe setup error paths in Pipeline::next_starting (src/runtime/shell/states/Pipeline.rs, the socketpair() loop and the dupe_for_subshell call). They set the state to WaitingWriteErr, call interp.throw(..) and return Yield::failed(). No writer is enqueued for that state and no child_done reaches the parent, so the interpreter never calls finish(). The pending-activity count is never dropped either, so the process stays alive.

Fix

  • Both error paths now call a new Pipeline::write_failing_error, which writes bun: <message>\n to the pipeline's stderr and finishes with exit code 1. This is the shape Builtin::cmd_write_failing_error and CondExpr::write_failing_error already use.
  • A stderr fd enqueues the message on the IOWriter with a new WriterTag::Pipeline and parks in WaitingWriteErr. Pipeline::on_io_writer_chunk finishes the pipeline when the write completes. A captured stderr is appended to synchronously.
  • Both completions go through Pipeline::finish, which sets Done { exit_code } and returns Next(this). The trampoline then removes the pipeline from pipeline_stack before next() reports to the parent, which frees the node. The empty-pipeline path uses the same helper.
  • Verified: test/js/bun/shell/bunshell.test.ts, two new tests under pipeline that fails to create its pipes (both fail on the released bun with an uncaught EMFILE error). Also bunshell.test.ts, pipeline_stack.test.ts, yield.test.ts, shell-hang.test.ts, epipe.test.ts, assignments-in-pipeline.test.ts, throw.test.ts, exec.test.ts, shell-write-fault.test.ts, shell-pipe-read-fault.test.ts.

Background

  • The shell interpreter is a state machine over an arena of nodes (Script, Stmt, Pipeline, Cmd, ...). Each step returns a Yield. Yield::run is the trampoline that drives it. A node reports completion to its parent with interp.child_done(parent, this, exit_code), and the root completion calls Interpreter::finish, which settles the JS promise.
  • Yield::failed() only means "a JS exception was raised". It does not complete the node. Outside run_from_js, nothing catches that exception, so the parent waits forever.
  • The trampoline keeps a pipeline_stack of pipelines that are still starting children. A pipeline must not call child_done on its parent while it is on that stack: the parent frees the node and the stack then points at a freed slot. Returning Next(this) in the Done state lets the trampoline pop it first.
  • IOWriter is the shared async writer for an fd. A state enqueues bytes with a ChildPtr (node id plus WriterTag) and gets on_io_writer_chunk when the chunk is written. The tag selects which state's callback runs.
Notes
  • The Zig shell had this behavior (writeFailingError("bun: {f}\n", system_err.message) then childDone(this, 1) from onIOWriterChunk). The Rust port replaced it with throw + failed().
  • The message uses to_shell_system_error().message with no path, so it reads bun: Too many open files. The ShellErr Display impl would add a trailing : because socketpair and dup carry no path.
  • on_io_writer_chunk finishes with exit 1 even when the stderr write itself failed. Throwing there would hang the same way, and the parent always needs a completion.
  • In the dupe_for_subshell path at cmd_idx > 0, earlier children are already running. Finishing the pipeline makes the parent free it, and Pipeline::deinit tears down those children (Cmd::deinit kills a live subprocess, drops a builtin). ProcessHandle detaches the exit handler before the subprocess box is freed. The second test reaches this path with live builtin children at several cmd_idx values under the ASAN debug build.
  • bun exec (mini event loop) used to abort the whole script with error: Failed due to error: bunsh: Too many open files: and exit 1. It now prints bun: Too many open files and continues, like bash does after pipe error.
  • Test design: /bin/sh -c 'ulimit -n N && exec bun -e <script>' lowers both the soft and hard fd limits so bun cannot raise them at startup. The first test uses 24 cats under a limit of 32, so the pipes can never fit, and covers captured stderr, stderr on an fd, a script that continues after the failed pipeline, and the thrown ShellError. The second test grows the pipeline from 2 to 16 commands under a limit of 32 with the builtin cat, so the runs cross from success into the dup failure and then into the socketpair failure without any subprocess fds in the accounting.
  • Related open PR shell(pipeline): write pipe/env setup errors to stderr instead of throwing #32302 is an earlier attempt at the same bug. It conflicts with main and leaves the cmd_idx > 0 path throwing.
  • Out of scope, already tracked: with subprocess children under fd exhaustion, a successful posix_spawn followed by pidfd_open failing with EMFILE makes spawn_process.rs block in wait4 on a live child (the pipeline hangs at the first cat spawn for some ulimit values). That is a separate bug in src/spawn_sys/spawn_process.rs with its own PRs.
  • leak.test.ts was also run. Its memleak_* and #11816 > external cases time out in this debug build environment for tests that do not involve pipelines too (memleak_redirect_file, memleak_change_cwd), so that is the environment, not this change.

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/shell/bunshell.test.ts

When `socketpair()` or the per-command `dup()` of the cwd fd fails in
`Pipeline::next_starting` (for example with EMFILE), the pipeline set
its state to `WaitingWriteErr`, threw a JS error and returned
`Yield::failed()`. Nothing was enqueued for that state and no
`child_done` reached the parent, so the interpreter never called
`finish()` and the `$` promise never settled. The process also stayed
alive because the pending-activity count was never dropped.

Write `bun: <message>\n` to the pipeline's stderr the way `Cmd` and
`CondExpr` already do, and finish the pipeline with exit code 1 through
the `Done` state. A stderr fd goes through the IOWriter and a new
`WriterTag::Pipeline` completion. A captured stderr is appended to
synchronously. Both paths return `Next(this)` so the trampoline drops
the pipeline from `pipeline_stack` before the parent frees it.
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 23 days. After that, they cost $0.25 per reviewed file.

Or wait 8 minutes for your next included review.

View limit details

Limit details: You’ve used all 5 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 99f592a1-e39d-4462-91fe-5d008922045b

📥 Commits

Reviewing files that changed from the base of the PR and between f170b9c and 85c9385.

📒 Files selected for processing (3)
  • src/runtime/shell/IOWriter.rs
  • src/runtime/shell/states/Pipeline.rs
  • test/js/bun/shell/bunshell.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed.

Repro on the released bun (1.4.1):

ulimit -n 16
bun -e 'import { $ } from "bun"; await $`echo a; echo hi | cat | cat | cat | cat | cat | cat | cat | cat`; console.log("done")'

Prints a, then hangs. With try/catch around the await, the error is caught but the process still never exits. With .nothrow().quiet() the error escapes as an uncaught exception.

With this branch the pipeline prints bun: Too many open files on stderr, finishes with exit code 1, and the script continues. The two new tests in test/js/bun/shell/bunshell.test.ts fail on the released bun and pass with this change (debug ASAN build, 3 runs each).

@robobun

robobun commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:11 AM PT - Aug 28th, 2026

⏳ @robobun, your commit 85c9385 is still building in Build #107455, but has 1 failures so far (All Failures):

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this and didn't find any bugs. Because it changes control flow in the shell interpreter's trampoline-driven state machine — in particular the dupe_for_subshell failure at cmd_idx > 0, where finish completes the pipeline and Pipeline::deinit tears down already-started builtin children — a human look from someone familiar with the shell rewrite would still be worthwhile.

What was reviewed:

  • Traced finish → Yield::Next(this) through Yield::run for all four entry shapes (initial Next push, drain_pipelines re-entry, sync OnIoWriterChunk from enqueue, and async IOWriter callback) — the .position() removal handles both on-stack and already-popped cases, and WaitingWriteErr correctly makes drain_pipelines pop-without-next.
  • Compared write_failing_error line-by-line against CondExpr::write_failing_error and Builtin::cmd_write_failing_error — same shape, with finish in place of child_done(parent, ..) for the pipeline-stack invariant.
  • Checked the fd-close accounting in the dupe_for_subshell error arm at cmd_idx == 0 (IOWriter in child_io owns pipes[0][1], loop closes the rest).
  • Looked at whether firstFailure > 0 in the second test could go false under debug+ASAN's higher baseline fd count — a 2-command pipeline needs one socketpair plus one dup, which fits comfortably under 32.
Extended reasoning...

Overview

This PR fixes a hang in Bun.$ when pipeline setup fails (EMFILE from socketpair or the per-command env dup). The old code called interp.throw(..) + Yield::failed() from inside Pipeline::next_starting, which raised a JS exception no caller catches and never reported completion to the parent — the promise hung forever. The fix adds Pipeline::write_failing_error (mirroring CondExpr::write_failing_error and Builtin::cmd_write_failing_error), a WriterTag::Pipeline dispatch arm in IOWriter, Pipeline::on_io_writer_chunk as the async-write completion, and a finish helper that sets Done { exit_code } and returns Next(this) so the trampoline pops the node from pipeline_stack before next() frees it via child_done. Two POSIX-only tests exercise captured stderr, fd stderr, script continuation, .throw() mode, and a length sweep that crosses from success into the dup-failure and then the socketpair-failure regime.

Security risks

None. The change is entirely internal to the shell interpreter's error-path control flow — no parsing of untrusted input, no auth/crypto/permissions surface, no new syscalls. The only user-visible effect is that a previously-hanging promise now settles with exit code 1 and an error message on stderr.

Level of scrutiny

Medium-high. The code itself is small and closely follows two existing in-tree templates, but it lives in the shell interpreter's arena-backed state machine where node lifetime, pipeline_stack membership, and re-entrancy through the Yield::run trampoline are all load-bearing. I traced every path finish's Next(this) can take through Yield::run (initial push, drain_pipelines peek-and-return, sync OnIoWriterChunk, async callback into a fresh trampoline) and confirmed the .position()-based removal is a no-op when the node was already popped and correct when it wasn't. The WaitingWriteErr state makes is_starting_cmds false and is_done false, so drain_pipelines pops it without stepping — matching the pre-existing behavior for that state. The fd-close accounting in both error arms was already present and is unchanged in substance.

Other factors

The one path I'd want a human to confirm is dupe_for_subshell failing at cmd_idx > 0: finish completes the pipeline and the parent's child_done calls Pipeline::deinit, which walks cmds[] and deinit_nodes already-started children (builtin cats with live IOReader/IOWriter registrations). The PR notes state Cmd::deinit handles this and that the second test reaches it under ASAN across several cmd_idx values, and the old code on this path was strictly worse (same live children, plus a permanent hang) — but this is exactly the kind of teardown-while-registered path REVIEW.md flags for careful memory-safety review. The tests are well-designed (subprocess-spawned, await using, drain pipes concurrently, assert output before exit code, bunEnv spread correctly, describe.skipIf(isWindows)) and the PR description lists a broad set of related shell tests as passing. The firstFailure > 0 assertion in the sweep test was examined for debug+ASAN fragility and looks safe: a 2-command pipeline under ulimit -n 32 with builtin cat needs one socketpair and one dup on top of Bun's baseline, well within budget.

@Jarred-Sumner
Jarred-Sumner merged commit 173e280 into main Aug 28, 2026
10 of 12 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/57643f5b/shell-pipeline-emfile-finish branch August 28, 2026 07:38
Jarred-Sumner added a commit that referenced this pull request Aug 29, 2026
… captured stderr through the env's RefCell; Expansion::init callers pass assign_ctx
Jarred-Sumner pushed a commit that referenced this pull request Aug 30, 2026
### Problem
- A pipeline whose per-command `dup` fails at command `i > 0` crashes
later. Repro: `(echo hi) | cat | cat | ... ` under `ulimit -n 28`. The
pipeline prints `bun: Too many open files` and exits 1, then the process
panics with `internal error: entered unreachable code: child_done on
freed Node#3` (`interpreter.rs:794`) on the next event loop turn. With a
different slot layout it reads a freed node instead (ASAN SEGV in
`Stmt::next`).
- The cause is in `Pipeline::next_starting`
(`src/runtime/shell/states/Pipeline.rs`). It inits and starts one child
per call, so at command `i` the commands `0..i` already run.
`write_failing_error` finishes the pipeline, the parent frees it, and
`Pipeline::deinit` frees those children. It does not reach the subtrees
they own: a Subshell's Script, a Cmd's Expansion. The orphaned subtree
still writes into the pipe. When the pipe closes under it, it reports to
the freed node.

### Fix
- `Pipeline::setup_commands` creates the pipes, dupes the env and inits
every child before any child starts. A failed pipe or dup leaves only
children that never ran, so `Pipeline::deinit` frees them cleanly and
their IOReader/IOWriter drops close the pipe ends. This is the order the
Zig interpreter used (`setupCommands` before `start`).
- Correct because the children are independent until they start. Each
gets a dupe of the parent env, which no child can change, and no pipe
end is read or written before its owner starts. The `pipes` field goes
away: the fd numbers only matter inside setup.
- Pipeline is the only state with children that run at the same time, so
it was the only place a parent could be freed with a running child
underneath. Every other `deinit` runs from the parent's `child_done`,
after the child finished.
- Verified: `test/js/bun/shell/bunshell.test.ts`, new test `reports
EMFILE from the per-command env dup when the head is a subshell` (panics
on the released bun). Also `bunshell.test.ts`, `pipeline_stack.test.ts`,
`yield.test.ts`, `assignments-in-pipeline.test.ts`, `epipe.test.ts`,
`throw.test.ts`, `exec.test.ts`, `shell-hang.test.ts`,
`shell-write-fault.test.ts`, `shell-pipe-read-fault.test.ts` and the
rest of `test/js/bun/shell/`.

### Background
- The shell interpreter is a state machine over an arena of nodes
(`Script`, `Stmt`, `Pipeline`, `Cmd`, `Subshell`, ...). Each step
returns a `Yield`. `Yield::run` is the trampoline that drives it. A node
reports completion with `interp.child_done(parent, this, exit_code)`.
The parent then frees the child with `deinit_node`.
- A node's `deinit` frees what the node owns, not its running children.
`Subshell::deinit` frees the duped env only. `Cmd::deinit` tears down a
builtin or subprocess, but a Cmd still expanding its arguments has an
`Expansion` child node instead.
- `IOWriter` is the shared async writer for an fd. `enqueue` queues
bytes for a `ChildPtr` and later calls that child's
`on_io_writer_chunk`. The orphaned echo in the repro holds its own clone
of the pipe's IOWriter, which is why its chunk outlives the pipeline.

<details><summary>Notes</summary>

- Found by code review on #32302 (closed) and handed over after #40740
merged. #40740 made the pipeline finish with exit 1 instead of throwing.
Its second test uses `echo hi | cat | ...` with the builtin `cat`, where
every child is a plain Cmd. Those have no subtree, and their IOWriter
dies with the node, so the pending chunk never fires. A Subshell head
has a Script subtree that holds its own clone of the IO, so the chunk
outlives the pipeline.
- Trace of the panic on main (`BUN_DEBUG_SHELL=1`, `ulimit -n 28`,
`(echo a) | cat x6`): the subshell (Node#3) starts, its echo enqueues
`a\n` on the socketpair writer, children 1..5 start, the dup for child 6
fails, `Pipeline Node#2 deinit`, `Subshell Node#3 deinit`. On the next
tick the writer gets EPIPE (the read end closed with child 1), `Cmd
Node#6 execDone exit=65504`, `Stmt Node#5 childDone`, `Script::finish`
calls `child_done(Node#3)`: panic.
- The test runs `n = 2..16` under `ulimit -n 32` and awaits
`Bun.sleep(0)` after each pipeline. That is one event loop turn, not a
timed wait: the orphaned echo's EPIPE completion is delivered then,
while the old interpreter's slot is still free, so the unfixed build
panics deterministically instead of dispatching into a reused slot. The
window where the dup fails at `i > 0` spans about four values of `n` for
any fd budget, so the sweep hits it regardless of the base fd count of
the machine.
- With the fix, the failing sweep no longer leaks fds: on main each
orphan kept its socketpair end open until the interpreter was finalized.
Checked with 20 identical failing pipelines followed by one that must
still fit.
- `write_failing_error` now clones the stderr `Arc<IOWriter>` out of the
node before `enqueue` instead of holding a borrow of the node across the
call. Behavior is unchanged.
- Not changed here: on Windows, `WindowsBufferedWriter::write` can call
`IOWriter::on_error` from under `enqueue` when uv rejects the write
synchronously (`start_with_current_pipe` returns `Ok` unconditionally).
That runs the completion on a nested trampoline while the caller's
trampoline still has the pipeline on its `pipeline_stack`. On POSIX the
same failure is returned as a `Yield` through `on_sync_error`. An
earlier revision of this PR carried a Pipeline-local guard for it (an
`EnqueuingWriteErr` state). The self-review found that every `enqueue`
caller has the same exposure and that the contract `IOWriter::write`
documents (failures are returned, never dispatched) is what Windows
breaks, so the guard was dropped in favor of a fix at the IOWriter
layer. The open shell refactor #40228 defers to the same io-layer
follow-up. Windows behavior is unchanged from main.
- Out of scope, already noted in #40740: with subprocess children under
fd exhaustion, a spawn whose `pidfd_open` fails with EMFILE blocks in
`wait4`. A sweep with `(sleep 0.3; echo a) | cat | ...` (the real `cat`)
hangs at some budgets on main and on this branch alike.
- `ls` and `rm` tests under `test/js/bun/shell/commands/` fail in this
container for environment reasons (root user for the permission denied
cases, no registry for the `node_modules` cases). They do not involve
this change.
- `cargo check -p bun_runtime --target x86_64-pc-windows-msvc` and
`cargo clippy -p bun_runtime --no-deps` are clean.
</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/bunshell.test.ts

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants