Compare commits

...

3 Commits

Author SHA1 Message Date
Claude Bot
76c3cd812c fix(shell): check exit_requested in Binary and If state handlers
The original exit_requested check only covered Stmt and Script, but
`exit` inside `&&`/`||` chains (Binary) or `if` blocks (If) would
continue executing subsequent commands. Binary.childDone now checks
the flag before starting the right-side operand, and If.childDone
checks before continuing to the next statement in the block.

In If.childDone, the child must be deinited explicitly before
propagating (not via defer), because the propagation chain deinits
the If and its alloc scope before the defer would run, causing an
allocation scope leak.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-01 09:21:04 +00:00
Claude Bot
1d75370e87 Merge branch 'main' into claude/fix-shell-exit-builtin 2026-03-01 08:54:53 +00:00
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
8 changed files with 95 additions and 7 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

@@ -122,6 +122,12 @@ pub fn childDone(this: *Binary, child: ChildPtr, exit_code: ExitCode) Yield {
child.deinit();
this.currently_executing = null;
// If exit was requested (via the `exit` builtin), stop executing
// the right side and propagate the exit code immediately.
if (this.base.shell.exit_requested) {
return this.parent.childDone(this, exit_code);
}
if (this.left == null) {
this.left = exit_code;
if ((this.node.op == .And and exit_code != 0) or (this.node.op == .Or and exit_code == 0)) {

View File

@@ -156,8 +156,6 @@ pub fn deinit(this: *If) void {
}
pub fn childDone(this: *If, child: ChildPtr, exit_code: ExitCode) Yield {
defer child.deinit();
if (this.state != .exec) {
@panic("Expected `exec` state in If, this indicates a bug in Bun. Please file a GitHub issue.");
}
@@ -165,6 +163,16 @@ pub fn childDone(this: *If, child: ChildPtr, exit_code: ExitCode) Yield {
var exec = &this.state.exec;
exec.last_exit_code = exit_code;
// If exit was requested (via the `exit` builtin), stop executing
// remaining statements in this if block and propagate immediately.
// Must deinit child before propagating, since the parent will deinit
// this If (and its alloc scope) before the defer would run.
if (this.base.shell.exit_requested) {
child.deinit();
return this.parent.childDone(this, exit_code);
}
child.deinit();
switch (exec.state) {
.cond => return .{ .@"if" = this },
.then => return .{ .@"if" = this },

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,59 @@
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);
});
test("exit stops execution in && chain", async () => {
const result = await $`exit 0 && echo "should not run"`.nothrow().text();
expect(result).toBe("");
});
test("exit stops execution in || chain", async () => {
const result = await $`exit 1 || echo "should not run"`.nothrow().quiet();
expect(await result.text()).toBe("");
expect(result.exitCode).toBe(1);
});
test("exit inside if-then body stops execution", async () => {
const result = await $`if true; then exit 0; echo "should not run"; fi; echo "also should not run"`.nothrow().text();
expect(result).toBe("");
});
test("exit inside if condition stops execution", async () => {
const result = await $`if exit 0; then echo "should not run"; fi`.nothrow().text();
expect(result).toBe("");
});