Repository navigation
Conversation
…sole $(...) Cmd::child_done parked the exit status of a sole command-substitution atom in Cmd::exit_code so that a command expanding to no words could complete with that status. When the substitution expanded to an actual command line, the value stayed behind: ShellSubprocess::on_process_exit skips Cmd::on_exit once exit_code is set, and has_finished() treats the command as finished as soon as its piped stdio closes. The shell then reported the substitution's status instead of the command's, and with nothing piped (e.g. `$(echo cmd) &> file`) never finished at all. Keep that status in its own field and only read it in the empty-argv branch of transition_to_exec, so exit_code stays unset until the builtin or subprocess actually reports.
|
Warning Review limit reached
Next review available in: 8 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 (2)
Comment |
|
Status: reproduced on the 1.4.0 release and on a debug build of main (Linux) with the new cases in CI: the first build (d0063ea) passed every lane that ran, including all Windows lanes and macOS 14; the only non-passing jobs were the macOS 26 arm64 test lane expiring without an agent. Later pushes only add a test case (eb21596) and shorten comments (efa8531). |
There was a problem hiding this comment.
LGTM — clean root-cause fix that separates the substitution's stashed status from the exec's, restoring the exit_code.is_none() invariant that has_finished and on_process_exit depend on.
What was reviewed:
- Verified
on_process_exitgates oncmd.exit_code.is_none()(subproc.rs:987), so leavingexit_codeunset until the exec reports is the correct fix for both the wrong-status and the hang cases. - Confirmed the new slice pattern
[Simple(CmdSubst(_))]onname_and_args: &[Atom]is equivalent to the oldlen() == 1 && matches!(...)guard; the droppednew_idx == 1check was redundant since a one-element argv only expands once. - Checked the empty-argv path still returns the substitution's status via
cmd_subst_exit_code(default 0), and that redirects don't affect the match sinceredirect_fileis separate fromname_and_args. - Tests cover piped / partially redirected / fully redirected (the hang case), the inverse (failing subst + succeeding command), and pin the builtin and empty-argv paths.
Extended reasoning...
Overview
The PR fixes a bug in Bun's shell where a command whose entire argv comes from a single $(...) substitution reported the substitution's exit code instead of the spawned command's, and could hang when both stdout/stderr were redirected to files. The change touches src/runtime/shell/states/Cmd.rs (adds a cmd_subst_exit_code: ExitCode field, moves the stash from exit_code to that field, and reads it only in the empty-argv branch of transition_to_exec) and adds six TestBuilder cases in test/js/bun/shell/bunshell.test.ts.
Security risks
None. This is internal shell state-machine bookkeeping — no untrusted input parsing, no new unsafe blocks, no FFI or memory-ownership changes, no filesystem/network surface.
Level of scrutiny
Moderate. The shell interpreter state machine is intricate, but the change is narrowly scoped: one new plain u8 field on Cmd, one assignment retargeted, one read site changed from exit_code.unwrap_or(0) to cmd_subst_exit_code. The refactor from an index-and-length check to a slice pattern is a straight simplification. I traced the invariant the PR description claims (ShellSubprocess::on_process_exit skips on_exit when exit_code is already Some, and has_finished keys off exit_code.is_some()) directly in the source and it holds, so the mechanism is proven rather than correlated.
Other factors
The tests are well-constructed per the repo's review standards: they cover the variant matrix (captured / one stream redirected / both redirected — the hang case), the inverse case where the substitution fails but the expanded command succeeds, and pin the two paths that already worked (builtin via false, empty expansion via $(echo; exit 3)) so a future regression there is caught. They use the existing TestBuilder / BUN / .file() harness conventions from the surrounding file. The PR description explicitly documents which cases fail on the current release, satisfying the fails-for-the-right-reason requirement.
|
Updated 3:06 PM PT - Aug 12th, 2026
❌ @robobun, your commit efa8531 has 4 failures in
🧪 To try this PR locally: bunx bun-pr 37808That installs a local version of the PR into your bun-37808 --bun |
There was a problem hiding this comment.
LGTM — focused fix that restores the exit_code.is_none() invariant has_finished and on_process_exit depend on.
What was reviewed:
- Confirmed
ShellSubprocess::on_process_exitgatesCmd::on_exitonexit_code.is_none()(subproc.rs:987), so the old stash did suppress the real status. - Verified
name_and_argsis&[Atom], so theif let [CmdSubst]slice pattern is equivalent to the oldlen() == 1 && matches!(...)check (the droppednew_idx == 1was redundant given a single element). - Checked
cmd_subst_exit_codeis only read on the empty-argv path and defaults to 0, so non-substitution empty commands andFOO=bar-only commands are unchanged. - Tests cover piped / stdout-redirected / both-redirected (the hang), failing-subst-successful-cmd,
||chaining, builtin, and the POSIX empty-expansion case.
Extended reasoning...
Overview
The PR fixes exit-code reporting for shell commands whose entire argv comes from a single $(...) substitution. Previously, Cmd::child_done stashed the substitution's own exit code into Cmd::exit_code so the empty-argv branch of transition_to_exec could return it (POSIX 2.9.1). But exit_code.is_some() is also the signal has_finished() and ShellSubprocess::on_process_exit() use to mean "the exec has already reported", so when the substitution did expand to an external command, the real process exit code was never recorded (or, with both stdout/stderr redirected to a file, nothing ever completed the command and the promise hung). The fix moves the stash into a new dedicated cmd_subst_exit_code field, read only in the empty-argv branch, leaving exit_code None until the builtin or subprocess actually reports.
Two files change: src/runtime/shell/states/Cmd.rs (+8/−23, mostly deleting a verbose comment block and simplifying to a slice pattern) and test/js/bun/shell/bunshell.test.ts (+44 lines, seven new TestBuilder cases in a new describe block).
Security risks
None. This is exit-code bookkeeping in the shell interpreter's Cmd state machine — no parsing of untrusted input, no new syscalls, no memory-ownership changes. The added struct field is a plain ExitCode (u16) with private visibility.
Level of scrutiny
Medium. The shell subprocess path involves careful state-machine sequencing and raw-pointer callbacks, but this change doesn't touch any of that machinery — it only moves where a stashed value lives and where it's read. I verified the invariant the PR description claims (on_process_exit at subproc.rs:987 checks cmd.exit_code.is_none() before calling on_exit), confirmed name_and_args is a &'arena [Atom] slice so the new if let [ast::Atom::Simple(ast::SimpleAtom::CmdSubst(_))] pattern is semantically identical to the old length+matches check, and checked that the dropped new_idx == 1 guard was redundant (with exactly one argv atom, ExpandingArgs completes exactly once). The new field defaults to 0 and is only read on the empty-argv path, so ordinary commands, assign-only commands, and non-substitution empty argv all still return 0 as before.
Other factors
The tests cover the full variant matrix the review guide asks for: the three stdio configurations (both piped, stdout-only redirected, both redirected — the hang case), the inverse case (failing substitution expanding to a successful command → 0), || chaining, a builtin (false), and the POSIX empty-expansion case ($(echo; exit 3) → 3) that pins the behaviour the fix must preserve. The author confirmed all seven fail on release/main without the fix and pass with it, and that the rest of bunshell.test.ts still passes. The comment-cop bot's feedback about long comments was addressed in efa8531 and the threads are resolved. The CI failures on the latest build (bake deinitialization segfault on Windows 2019, musl verify-baseline exit 56, agent-creation failure) are unrelated infrastructure/pre-existing issues — none touch the shell.
Problem
$(echo ./exit7.sh)reports exit code 0 when the script exits 7, and$(echo /bin/true; exit 5)reports 5 instead of 0. bash reports 7 and 0.$(echo ./exit7.sh) &> out.txtnever resolves.$(...)word expanding to an external command is affected. Builtins overwrite the exit code and are fine.Fix
$(exit 3)still reports 3.&>case times out, and the failing-substitution case reports 5. The builtin and empty-expansion cases already passed.Background
$(cmd)runscmdand splices its stdout into the command line as words, so$(echo ./x.sh)becomes the command./x.sh.Cmdstate: it expands its words one at a time, then runs a builtin or spawns a subprocess.Original description
Repro
bash reports 7, 7, 0 for the first three (
$(echo ./exit7.sh); echo $?prints 7): once a substitution expands to a command name, the status of the simple command is the status of the command that ran. The substitution's own status only counts when there is no command name at all (POSIX 2.9.1), which is$(exit 3)reporting 3 today.Only the combination "the whole command is one
$(...)word" and "it expands to an external command" is affected. Builtins are fine becauseBuiltin::doneoverwrites the exit code unconditionally.Cause
Cmd::child_done(src/runtime/shell/states/Cmd.rs) stashed the substitution's status inCmd::exit_codewhen the sole argv atom is a$(...), so that the empty-argv branch oftransition_to_execcould return it. When argv turned out to be non-empty the stash was left in place, andexit_code.is_some()is what the subprocess path uses to mean "the exec has already reported":ShellSubprocess::on_process_exitskipsCmd::on_exitwhenexit_codeis already set (so that a stdout/stderr read error wins over the process status), so the real status was never recorded and the stale value was reported.Cmd::has_finishedonly checksexit_code.is_some()plus the piped stdio being closed, so with stdout/stderr captured the command "finished" as soon as the pipes closed, and with nothing piped (&> file, or a file redirect on both streams) nothing ever finished it, so the promise hung.Fix
Keep the substitution's status in its own
Cmdfield and read it only in the empty-argv branch.exit_codenow staysNoneuntil the builtin or subprocess actually reports, which is the invarianthas_finishedandon_process_exitrely on. The empty-argv behavior ($(exit 3)reports 3) is unchanged.Tests
New cases in the
cmd subst expanding to the whole commandblock oftest/js/bun/shell/bunshell.test.ts. Against the current release the first two report 0 instead of 7, the third times out, and the fourth reports 5 instead of 0; the last two already pass and pin the builtin and empty-argv paths.$(echo bun exit7.js)with stdout/stderr captured: 7> out.txt(stderr still piped): 7&> out.txt(nothing piped): 7, and the command completes$(echo bun ok.js; exit 5): 0$(echo false): 1$(echo; exit 3): 3