Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions src/runtime/shell/builtin/exit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,30 @@
pub(crate) fn start(interp: &Interpreter, cmd: NodeId) -> Yield {
let bltn = Builtin::of(interp, cmd);
let code: crate::shell::ExitCode = match bltn.args_slice().len() {
0 => 0,
// POSIX: "the exit status shall be that of the last command
// executed, or zero if no command was executed."
0 => interp.as_cmd(cmd).base.shell().last_exit_code,
1 => {
let s = bltn.arg_bytes(0);
match parse_exit_code(s) {
Some(c) => c,
None => {
return Self::fail(interp, cmd, b"exit: numeric argument required\n");
return Self::fail(interp, cmd, b"exit: numeric argument required\n", 2);
}
}
}
_ => {
return Self::fail(interp, cmd, b"exit: too many arguments\n");
return Self::fail(interp, cmd, b"exit: too many arguments\n", 1);
}

Check warning on line 36 in src/runtime/shell/builtin/exit.rs

View check run for this annotation

Claude / Claude Code Review

exit with non-numeric first arg + extra args returns 1 instead of bash's 2

nit: `exit abc def` still returns 1 with "too many arguments" — bash checks arg[0]'s numeric-ness *before* argc, so it prints "numeric argument required" and exits 2. Before this PR both error paths returned 1 so the check order was unobservable in the exit code; splitting them into 2 vs 1 makes the divergence visible. Consider parsing `arg_bytes(0)` first whenever `argc >= 1` and only falling through to "too many arguments" if it parsed successfully.
Comment thread
robobun marked this conversation as resolved.
Outdated
};
// Intentional divergence from bash: this completes only the current
// Cmd rather than unwinding the whole script.
Builtin::done(interp, cmd, code)
}

fn fail(interp: &Interpreter, cmd: NodeId, msg: &[u8]) -> Yield {
fn fail(interp: &Interpreter, cmd: NodeId, msg: &[u8], code: crate::shell::ExitCode) -> Yield {
Self::state_mut(interp, cmd).state = State::WaitingIo;
Builtin::write_failing_error(interp, cmd, msg, 1)
Builtin::write_failing_error(interp, cmd, msg, code)
}

pub(crate) fn on_io_writer_chunk(
Expand All @@ -50,11 +52,13 @@
_err: Option<bun_sys::SystemError>,
) -> Yield {
Self::state_mut(interp, cmd).state = State::Done;
Builtin::done(interp, cmd, 1)
let code = Builtin::of(interp, cmd).exit_code.unwrap_or(1);
Builtin::done(interp, cmd, code)
}
}

fn parse_exit_code(s: &[u8]) -> Option<crate::shell::ExitCode> {
// %256 is bash semantics — keep wrapper fn.
bun_core::fmt::parse_decimal::<u64>(s).map(|n| (n % 256) as crate::shell::ExitCode)
// bash semantics: parse as a signed integer and keep the low 8 bits
// (`exit -1` → 255, `exit 300` → 44, `exit -300` → 212).
bun_core::fmt::parse_decimal::<i64>(s).map(|n| crate::shell::ExitCode::from(n as u8))
}
8 changes: 8 additions & 0 deletions src/runtime/shell/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ impl Interpreter {
__cwd: cwd_arr,
cwd_fd,
async_pids: SmolList::default(),
last_exit_code: 0,
}),
root_io: JsCell::new(IO {
stdin: crate::shell::io::InKind::Fd(stdin_reader),
Expand Down Expand Up @@ -1793,6 +1794,10 @@ pub struct ShellExecEnv {
pub __cwd: Vec<u8>,
pub cwd_fd: Fd,
pub async_pids: SmolList<PidT, 4>,
/// Exit status of the most recently completed command in this execution
/// context (POSIX `$?`). Read by the `exit` builtin when called with no
/// argument. Updated by `Stmt::child_done` and `Binary::child_done`.
pub last_exit_code: ExitCode,
}

pub enum Bufio {
Expand Down Expand Up @@ -1958,6 +1963,9 @@ impl ShellExecEnv {
__cwd: self.__cwd.clone(),
cwd_fd: dupedfd,
async_pids: SmolList::default(),
// Inherited so a subshell/command-substitution sees the parent's
// value; writes never propagate back (POSIX behavior).
last_exit_code: self.last_exit_code,
});
Ok(bun_core::heap::into_raw(duped))
}
Expand Down
1 change: 1 addition & 0 deletions src/runtime/shell/states/Binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ impl Binary {
{
let me = interp.as_binary_mut(this);
me.currently_executing = None;
me.base.shell_mut().last_exit_code = exit_code;
if me.left.is_none() {
me.left = Some(exit_code);
} else {
Expand Down
1 change: 1 addition & 0 deletions src/runtime/shell/states/Stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ impl Stmt {
{
let me = interp.as_stmt_mut(this);
me.last_exit_code = Some(exit_code);
me.base.shell_mut().last_exit_code = exit_code;
me.idx += 1;
me.currently_executing = None;
}
Expand Down
22 changes: 20 additions & 2 deletions test/js/bun/shell/commands/exit.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe } from "bun:test";
import { createTestBuilder } from "../test_builder";
import { bunExe, createTestBuilder } from "../test_builder";
const TestBuilder = createTestBuilder(import.meta.path);
const BUN = bunExe();

describe("exit", async () => {
TestBuilder.command`exit`.exitCode(0).runAsTest("works");
Expand All @@ -16,5 +17,22 @@ describe("exit", async () => {
TestBuilder.command`exit 62757836`.exitCode(204).runAsTest("exit code wraps u8");

// prettier-ignore
TestBuilder.command`exit abc`.exitCode(1).stderr("exit: numeric argument required\n").runAsTest("numeric argument required");
TestBuilder.command`exit abc`.exitCode(2).stderr("exit: numeric argument required\n").runAsTest("numeric argument required");

describe("no argument propagates the last command's status", async () => {
TestBuilder.command`false; exit`.exitCode(1).runAsTest("false; exit");
TestBuilder.command`true; exit`.exitCode(0).runAsTest("true; exit");
TestBuilder.command`false || exit`.exitCode(1).runAsTest("false || exit");
TestBuilder.command`true | false; exit`.exitCode(1).runAsTest("pipeline; exit");
TestBuilder.command`${{ raw: "(exit 42); exit" }}`.exitCode(42).runAsTest("subshell; exit");
TestBuilder.command`${{ raw: "false; (exit)" }}`.exitCode(1).runAsTest("inherited by subshell");
TestBuilder.command`${BUN} -e ${"process.exit(7)"}; exit`.exitCode(7).runAsTest("subprocess; exit");
TestBuilder.command`${{ raw: "false\nexit" }}`.exitCode(1).runAsTest("across statements");
TestBuilder.command`false; exit 3`.exitCode(3).runAsTest("explicit arg wins");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe("negative argument wraps mod 256", async () => {
TestBuilder.command`exit -1`.exitCode(255).runAsTest("-1");
TestBuilder.command`exit -300`.exitCode(212).runAsTest("-300");
});
});
2 changes: 1 addition & 1 deletion test/js/bun/shell/exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe("bun exec", () => {
["rm", 1, "rm: illegal option -- -\n", ""],
["mv", 1, "mv: illegal option -- -\n", ""],
["ls", 1, "ls: illegal option -- -\n", ""],
["exit", 1, "exit: numeric argument required\n", ""],
["exit", 2, "exit: numeric argument required\n", ""],
["true", 0, "", ""],
["false", 1, "", ""],
// ["yes", 1, "", ""],
Expand Down
Loading