Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
39 changes: 22 additions & 17 deletions src/runtime/shell/builtin/exit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,32 @@ 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
// 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 +53,13 @@ impl Exit {
_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
25 changes: 23 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,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");
});
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