Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
c79bbc5281 fix(shell): exit builtin now halts script execution
The `exit` builtin in Bun's shell was being treated like any other
command - it set an exit code but execution continued to subsequent
statements. This adds an `exit_requested` flag to `ShellExecEnv` that
is checked by `Stmt` and `Script` state handlers to halt execution
when `exit` is invoked, matching bash behavior.

The fix correctly scopes to the current shell context: `exit` inside
a subshell only exits the subshell (since subshells get a duplicated
`ShellExecEnv`), while `exit` at the top level exits the entire script.

Closes #20368

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 04:51:51 +00:00
6 changed files with 58 additions and 5 deletions

View File

@@ -640,6 +640,12 @@ pub fn done(this: *Builtin, exit_code: anytype) Yield {
log("builtin done ({s}: exit={d}) cmd to free: ({x})", .{ @tagName(this.kind), code, @intFromPtr(cmd) });
cmd.exit_code = this.exit_code.?;
// When the `exit` builtin runs, signal the shell to stop executing
// remaining statements (like bash does).
if (this.kind == .exit) {
cmd.base.shell.exit_requested = true;
}
// Aggregate output data if shell state is piped and this cmd is piped
if (cmd.io.stdout == .pipe and cmd.io.stdout == .pipe and this.stdout == .buf) {
bun.handleOom(cmd.base.shell.buffered_stdout().appendSlice(

View File

@@ -367,6 +367,10 @@ pub const Interpreter = struct {
async_pids: SmolList(pid_t, 4) = SmolList(pid_t, 4).zeroes,
/// Set to true when the `exit` builtin is invoked.
/// Checked by Stmt and Script to halt execution of remaining statements.
exit_requested: bool = false,
__alloc_scope: if (bun.Environment.enableAllocScopes) *bun.AllocationScope else void,
const pid_t = if (bun.Environment.isPosix) std.posix.pid_t else uv.uv_pid_t;

View File

@@ -85,7 +85,7 @@ fn finish(this: *Script, exit_code: ExitCode) Yield {
pub fn childDone(this: *Script, child: ChildPtr, exit_code: ExitCode) Yield {
child.deinit();
if (this.state.normal.idx >= this.node.stmts.len) {
if (this.base.shell.exit_requested or this.state.normal.idx >= this.node.stmts.len) {
return this.finish(exit_code);
}
return this.next();

View File

@@ -121,6 +121,13 @@ pub fn childDone(this: *Stmt, child: ChildPtr, exit_code: ExitCode) Yield {
log("{d} {d}", .{ data, data2 });
child.deinit();
this.currently_executing = null;
// If exit was requested (via the `exit` builtin), stop executing
// remaining statements and propagate the exit code.
if (this.base.shell.exit_requested) {
return this.parent.childDone(this, exit_code);
}
return this.next();
}

View File

@@ -2348,12 +2348,10 @@ describe("subshell", () => {
// test_oE 'effect of subshell'
TestBuilder.command /* sh */ `
a=1
# (a=2; echo $a; exit; echo not reached)
# NOTE: We actually implemented exit wrong so changing this for now until we fix it
(a=2; echo $a; exit; echo reached)
(a=2; echo $a; exit; echo not reached)
echo $a
`
.stdout("2\nreached\n1\n")
.stdout("2\n1\n")
.runAsTest("effect of subshell");
// test_x -e 23 'exit status of subshell'

View File

@@ -0,0 +1,38 @@
import { $ } from "bun";
import { expect, test } from "bun:test";
// https://github.com/oven-sh/bun/issues/20368
// The `exit` builtin in Bun's shell should halt script execution.
test("exit stops execution of subsequent commands", async () => {
const result = await $`echo "before"; exit; echo "after"`.nothrow().text();
expect(result).toBe("before\n");
});
test("exit 0 stops execution of subsequent commands", async () => {
const result = await $`echo "before"; exit 0; echo "after"`.nothrow().text();
expect(result).toBe("before\n");
});
test("exit 1 stops execution and sets exit code", async () => {
const result = await $`echo "before"; exit 1; echo "after"`.nothrow().quiet();
expect(await result.text()).toBe("before\n");
expect(result.exitCode).toBe(1);
});
test("exit stops execution across newline-separated statements", async () => {
const result = await $`
echo "Good Bun!"
exit
echo "Bad Bun!"
`
.nothrow()
.text();
expect(result).toBe("Good Bun!\n");
});
test("exit with code propagates the exit code", async () => {
const result = await $`exit 42; echo "unreachable"`.nothrow().quiet();
expect(await result.text()).toBe("");
expect(result.exitCode).toBe(42);
});