shell: make exit end the script instead of just the current command - #33282
shell: make exit end the script instead of just the current command#33282robobun wants to merge 5 commits into
Conversation
The exit builtin completed only its own Cmd, so the statements after it
kept running and the reported status came from whatever ran last:
await $`echo start; exit 5; echo never` // printed "never", exited 0
Record the requested status on the ShellExecEnv that the exit builtin
shares with its script. Stmt refuses to run a command once it is set,
which unwinds the script body and any enclosing if branch, and Binary
checks it before its short-circuit so `exit 0 && echo hi` stops too.
A subshell, command substitution, and pipeline element each dupe the env,
so exit stays inside the context that ran it, as in bash.
Restores the upstream form of the "effect of subshell" test, which had
been weakened with a note saying exit was implemented wrong.
WalkthroughAdds an ChangesScoped exit builtin behavior
Related issues: Related PRs: None identified. Suggested labels: shell, bug, rust Suggested reviewers: Maintainers of 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 5:06 PM PT - Jul 2nd, 2026
✅ @robobun, your commit 7fd550aff871e59aed0855695ce754b9e4def1de passed in 🧪 To try this PR locally: bunx bun-pr 33282That installs a local version of the PR into your bun-33282 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/runtime/shell/interpreter.rs (1)
1786-1801: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftGive async jobs their own shell env
Async::initstores the parentShellExecEnvdirectly, soexitinsidecmd &writes the sharedexit_requestedflag and can terminate the foreground script. Async nodes need an owned/isolated env, or an equivalent scoped exit flag.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/runtime/shell/interpreter.rs` around lines 1786 - 1801, The async execution path is reusing the parent ShellExecEnv directly, so Async::init needs to stop sharing state that should be scoped per async job. Update the Async::init flow and related ShellExecEnv handling so cmd & gets its own owned ShellExecEnv instance (or an equivalent isolated exit flag), and ensure exit_requested in ShellExecEnv is not shared back to the foreground script. Use the ShellExecEnv and Async::init symbols to locate the fix.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/shell/states/Stmt.rs`:
- Around line 61-66: Gate the If state transition on exit_requested just like
Stmt does, so an early exit from the condition unwinds instead of advancing into
Then/Else. Update the dispatch/advance logic in If and the related
state-handling path in If.rs so it checks
interp.as_stmt(this).base.exit_requested() before spawning either branch, and
returns interp.child_done(parent, this, code) immediately when set.
---
Outside diff comments:
In `@src/runtime/shell/interpreter.rs`:
- Around line 1786-1801: The async execution path is reusing the parent
ShellExecEnv directly, so Async::init needs to stop sharing state that should be
scoped per async job. Update the Async::init flow and related ShellExecEnv
handling so cmd & gets its own owned ShellExecEnv instance (or an equivalent
isolated exit flag), and ensure exit_requested in ShellExecEnv is not shared
back to the foreground script. Use the ShellExecEnv and Async::init symbols to
locate the fix.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 18b87fbb-7eb7-433a-a7ef-7471fd1aa489
📒 Files selected for processing (7)
src/runtime/shell/builtin/exit.rssrc/runtime/shell/interpreter.rssrc/runtime/shell/states/Base.rssrc/runtime/shell/states/Binary.rssrc/runtime/shell/states/Stmt.rstest/js/bun/shell/bunshell.test.tstest/js/bun/shell/commands/exit.test.ts
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/bun/shell/commands/exit.test.ts`:
- Around line 29-39: The bare exit test in exit.test.ts only covers the
successful path, so add a separate case using the same TestBuilder.command shell
script pattern that runs a failing command like false before a bare exit and
asserts the resulting nonzero exit code. Keep the existing a bare exit on its
own line test, and add the new scenario near it so the behavior of exit
inheriting the previous command’s status is explicitly pinned.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 2fc9cf41-7a85-4aef-89c3-bd4799a9e947
📒 Files selected for processing (1)
test/js/bun/shell/commands/exit.test.ts
Adds multi-statement if arms and an elif condition to prove no command in any arm runs after exit, and marks the bare-exit-reuses-$? case todo since the shell does not track the last command's status yet.
A pipeline spawns an if-clause straight into its own duped env, with no
Stmt in between to report the status. When the condition fails and there
is no else arm, If::next returns a hardcoded Done(0), so the status from
`exit` was dropped:
echo hi | if exit 5; then echo t; fi // exited 0, bash exits 5
Check exit_requested in If::next as well. Stmt's guard already stopped
any command in the arms from running, so only the status was wrong.
|
Thanks, the
|
| guard removed | tests broken |
|---|---|
Stmt::next |
12 |
Binary::next |
2 (exit 0 && ..., exit 5 || ...) |
If::next |
2 (the two pipeline shapes above) |
Adding a fourth guard in Script::child_done still breaks nothing, so I've left that one out.
exit inside cmd & (not reachable)
Expr::Async is never constructed: the parser rejects & before any Async node exists, so Async::init is unreachable and exit has no path to it.
src/shell_parser/parse.rs:1075
if self.r#match(TokenTag::Ampersand) {
self.add_error(format_args!("Background commands \"&\" are not supported yet."))?;
return Err(ParseError::Unsupported.into());
}
Every form, including the exact repro:
exit 5 & sleep 0.01; echo b rejected: Background commands "&" are not supported yet.
exit 5 & rejected: Background commands "&" are not supported yet.
if true; then exit 5; fi & rejected: Background commands "&" are not supported yet.
echo a& echo b rejected: Background commands "&" are not supported yet.
The only mentions of Expr::Async in the tree are pattern matches (memory_cost, json_fmt, spawn_expr, and a panic! arm in Async::next). You're right that the invariant as I worded it is too strong, though, so I've reworded it to say Subshell/Pipeline/command substitution rather than implying the set is closed. Worth flagging for whoever implements &: bash forks for background jobs, so Async will need to dupe the env like the others, which would scope exit_requested correctly for free. I'd rather not change unreachable code here, since no test can reach it.
Nonzero status for bare exit
Real gap, and the one thing from the original report I could not close. Bare exit should report the last command's status, but the shell tracks no last-exit-status at all ($? expands literally today), so false; exit exits 0 instead of 1.
Pinned as a .todo with the bash-correct expectation in 9661670:
TestBuilder.command`false; exit`
.exitCode(1)
.todo("the shell does not track the last command's status yet")
.runAsTest("a bare exit reports the last command's status");#33274 adds last_exit_code to ShellExecEnv for $?. Once that lands, bare exit can read it and this can be un-skipped.
Fixes #20368
What does this PR do?
Repro
exitdid not stop the script, and the reported status came from whatever command happened to run last.exitmid-script is the standard early return in shell (cmd || exit 1guards), so code ported intoBun.$kept running past its guards, and the wrong status defeated.nothrow()exit-code checks.Cause
The
exitbuiltin completed only its ownCmdand returned the status to its parent, like any other builtin. Nothing told the enclosingStmt/Scriptto stop.src/runtime/shell/builtin/exit.rscarried aTODO(port)about this ("bashexitshould unwind the whole script... the Zig version sets a flag on the interpreter"), which #31783 reworded into "intentional divergence from bash" without implementing it.Fix
Record the requested status on the
ShellExecEnvthat theexitbuiltin shares with its script.ShellExecEnvis duped for each subshell, command substitution, and pipeline element, which are the contexts bash forks, so the scoping falls out for free:exitends the context that ran it and no more.Three state nodes consume the flag:
Stmt::nextrefuses to run a command once it is set. Every command in a script body or anifarm is spawned as aStmt, so this one check stops both.Binary::nextchecks before its short-circuit, soexit 0 && echo histops even though the left side succeeded.If::nextchecks because a pipeline spawns an if-clause straight into its own env, with noStmtin between to carry the status out. A failed condition with noelsearm reports a hardcodedDone(0), which would otherwise swallow it (echo hi | if exit 5; then echo t; fi).Scriptneeds no check of its own: it only ever spawnsStmts, which refuse. Confirmed by ablation, where removing any of the three above breaks tests and adding a fourth inScript::child_donebreaks none.Matching bash, a bad argument (
exit abc,exit 1 2) also ends the script, with the status it already used.How did you verify your code works?
Differential run against bash, 19
exitscenarios: 8/19 matched before, 17/19 after.The two that still differ are separate missing features, not this bug:
false; exit(bareexitshould reuse$?) andecho $?need$?, which does not exist in Bun's shell yet. shell: implement$?,$$,$#, and${NAME}; reject unsupported expansions #33274 implements it; once both land, bareexitcan readlast_exit_codeand that case closes too.{ ...; }brace grouping is unimplemented (command not found: {) and is already marked.todoin the suite.Before / after vs bash
22 tests added to
test/js/bun/shell/commands/exit.test.ts: the statements afterexitare skipped,&&/||/if-body/if-condition all unwind, argument errors still end the script, and the negative contract that a subshell, command substitution, and pipeline element each contain their ownexit. 19 of them fail onmain.test/js/bun/shell/bunshell.test.tshad the upstreameffect of subshellcase weakened to assert the buggy output:Restored to its original form, asserting
2\n1\n.Whole
test/js/bun/shell/suite: no new failures (the pre-existing fd-leak timeouts and root-onlylspermission cases are unchanged).