diff --git a/src/runtime/shell/builtin/exit.rs b/src/runtime/shell/builtin/exit.rs index 767cbe4b2bd..84821fa01e7 100644 --- a/src/runtime/shell/builtin/exit.rs +++ b/src/runtime/shell/builtin/exit.rs @@ -18,19 +18,22 @@ enum State { impl Exit { 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, - 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"); - } + let argc = bltn.args_slice().len(); + let code: crate::shell::ExitCode = if argc == 0 { + // POSIX: "the exit status shall be that of the last command + // executed, or zero if no command was executed." + interp.as_cmd(cmd).base.shell().last_exit_code + } else { + // bash checks arg[0] before argc: `exit abc def` is "numeric + // argument required" (2), not "too many arguments" (1). + match parse_exit_code(bltn.arg_bytes(0)) { + None => { + return Self::fail(interp, cmd, b"exit: numeric argument required\n", 2); } - } - _ => { - return Self::fail(interp, cmd, b"exit: too many arguments\n"); + Some(_) if argc > 1 => { + return Self::fail(interp, cmd, b"exit: too many arguments\n", 1); + } + Some(c) => c, } }; // Intentional divergence from bash: this completes only the current @@ -38,9 +41,9 @@ impl Exit { 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( @@ -50,11 +53,13 @@ impl Exit { _err: Option, ) -> 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 { - // %256 is bash semantics — keep wrapper fn. - bun_core::fmt::parse_decimal::(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::(s).map(|n| crate::shell::ExitCode::from(n as u8)) } diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index e9bbdcab23f..28ca8ee30ce 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -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), @@ -1793,6 +1794,10 @@ pub struct ShellExecEnv { pub __cwd: Vec, pub cwd_fd: Fd, pub async_pids: SmolList, + /// 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 { @@ -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)) } diff --git a/src/runtime/shell/states/Binary.rs b/src/runtime/shell/states/Binary.rs index 3da8ab94e8e..1bf7b392fcb 100644 --- a/src/runtime/shell/states/Binary.rs +++ b/src/runtime/shell/states/Binary.rs @@ -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 { diff --git a/src/runtime/shell/states/Stmt.rs b/src/runtime/shell/states/Stmt.rs index aff6c0d6281..ffa1a9d0824 100644 --- a/src/runtime/shell/states/Stmt.rs +++ b/src/runtime/shell/states/Stmt.rs @@ -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; } diff --git a/test/js/bun/shell/commands/exit.test.ts b/test/js/bun/shell/commands/exit.test.ts index 5a3239d0f49..3509e0d7de6 100644 --- a/test/js/bun/shell/commands/exit.test.ts +++ b/test/js/bun/shell/commands/exit.test.ts @@ -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"); @@ -16,5 +17,25 @@ 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"); + + // prettier-ignore + TestBuilder.command`exit abc def`.exitCode(2).stderr("exit: numeric argument required\n").runAsTest("non-numeric first arg checked before argc"); + + 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"); + }); + + describe("negative argument wraps mod 256", async () => { + TestBuilder.command`exit -1`.exitCode(255).runAsTest("-1"); + TestBuilder.command`exit -300`.exitCode(212).runAsTest("-300"); + }); }); diff --git a/test/js/bun/shell/exec.test.ts b/test/js/bun/shell/exec.test.ts index b7c27219712..8b9b5d17dc1 100644 --- a/test/js/bun/shell/exec.test.ts +++ b/test/js/bun/shell/exec.test.ts @@ -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, "", ""],