From cdaba2b943928ba572b4d34a13b668d9625a2657 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:17:07 +0000 Subject: [PATCH 1/4] shell(exit): propagate last status on no-arg, fix negative/invalid arg codes POSIX specifies that exit with no argument returns the status of the last command executed. Bun returned 0 unconditionally, so the standard 'cmd; exit' tail idiom always reported success to ShellPromise.exitCode. Also: - exit -1 now wraps to 255 (was: 'numeric argument required', exit 1). The argument is now parsed as a signed integer and the low 8 bits are kept, matching bash. - exit abc now returns 2 (was: 1), matching bash's 'numeric argument required' status. - exit 1 2 (too many arguments) still returns 1. Tracking for the last command's status lives on ShellExecEnv so it is scoped per subshell/command-substitution (inherited on dupe, never written back), and is updated by Stmt::child_done and Binary::child_done. --- src/runtime/shell/builtin/exit.rs | 20 ++++++++++++-------- src/runtime/shell/interpreter.rs | 8 ++++++++ src/runtime/shell/states/Binary.rs | 1 + src/runtime/shell/states/Stmt.rs | 1 + test/js/bun/shell/commands/exit.test.ts | 18 +++++++++++++++++- test/js/bun/shell/exec.test.ts | 2 +- 6 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/runtime/shell/builtin/exit.rs b/src/runtime/shell/builtin/exit.rs index 767cbe4b2bd..6d56fb6a062 100644 --- a/src/runtime/shell/builtin/exit.rs +++ b/src/runtime/shell/builtin/exit.rs @@ -19,18 +19,20 @@ 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, + // 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); } }; // Intentional divergence from bash: this completes only the current @@ -38,9 +40,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 +52,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..b5930ce58a1 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"; const TestBuilder = createTestBuilder(import.meta.path); +const BUN = process.argv0; describe("exit", async () => { TestBuilder.command`exit`.exitCode(0).runAsTest("works"); @@ -16,5 +17,20 @@ 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`${{ raw: "(exit 42); exit" }}`.exitCode(42).runAsTest("subshell; exit"); + 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, "", ""], From 79a5e6fb7ba4f50bdd4517a1d2da3b695dc75855 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:44:12 +0000 Subject: [PATCH 2/4] ci: retrigger From 058ab3c9a88894a301d39fd89939cb359eed6e7b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:50:31 +0000 Subject: [PATCH 3/4] test(shell/exit): use bunExe(), cover pipeline and subshell-inherit paths --- test/js/bun/shell/commands/exit.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/js/bun/shell/commands/exit.test.ts b/test/js/bun/shell/commands/exit.test.ts index b5930ce58a1..87a435fe95a 100644 --- a/test/js/bun/shell/commands/exit.test.ts +++ b/test/js/bun/shell/commands/exit.test.ts @@ -1,7 +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 = process.argv0; +const BUN = bunExe(); describe("exit", async () => { TestBuilder.command`exit`.exitCode(0).runAsTest("works"); @@ -23,7 +23,9 @@ describe("exit", 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"); From d8a9367dcd4cce88f4d3fadfcb8a401891c7094a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:45:33 +0000 Subject: [PATCH 4/4] shell(exit): check arg[0] numeric-ness before argc bash validates the first argument before counting: `exit abc def` is 'numeric argument required' (2), not 'too many arguments' (1). The order was previously unobservable in the exit code because both paths returned 1; splitting them exposed it. --- src/runtime/shell/builtin/exit.rs | 25 +++++++++++++------------ test/js/bun/shell/commands/exit.test.ts | 3 +++ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/runtime/shell/builtin/exit.rs b/src/runtime/shell/builtin/exit.rs index 6d56fb6a062..84821fa01e7 100644 --- a/src/runtime/shell/builtin/exit.rs +++ b/src/runtime/shell/builtin/exit.rs @@ -18,21 +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() { + 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." - 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", 2); - } + 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", 1); + 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 diff --git a/test/js/bun/shell/commands/exit.test.ts b/test/js/bun/shell/commands/exit.test.ts index 87a435fe95a..3509e0d7de6 100644 --- a/test/js/bun/shell/commands/exit.test.ts +++ b/test/js/bun/shell/commands/exit.test.ts @@ -19,6 +19,9 @@ describe("exit", async () => { // prettier-ignore 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");