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
15 changes: 15 additions & 0 deletions docs/runtime/shell.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,21 @@ Use the `$(...)` syntax instead.

---

## Exit code and process ID (`$?` and `$$`)

`$?` is the exit code of the last statement. After `a && b` or `a || b`, it is the exit code of the operand that ran last. `$$` is the ID of the Bun process that runs the shell.

```js
import { $ } from "bun";

await $`false; echo $?`; // 1\n
await $`echo $$`; // the value of process.pid
```

Both expand inside double quotes. Single quotes or a backslash keep the characters: `'$?'`, `\$?`.

---

## Environment variables

Set environment variables like in bash:
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/shell/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,7 @@ impl Interpreter {
shell_env: EnvMap::init(),
cmd_local_env: EnvMap::init(),
export_env,
last_exit_code: 0,
__prev_cwd: cwd_arr.clone(),
__cwd: cwd_arr,
cwd_fd,
Expand Down Expand Up @@ -1801,6 +1802,8 @@ pub(crate) struct ShellExecEnv {
pub(crate) shell_env: EnvMap,
pub(crate) cmd_local_env: EnvMap,
pub(crate) export_env: EnvMap,
/// What `$?` expands to. `dupe_for_subshell` copies it: a `$(...)` or a `(...)` does not write it back.
pub(crate) last_exit_code: ExitCode,
pub(crate) __prev_cwd: Vec<u8>,
pub(crate) __cwd: Vec<u8>,
pub(crate) cwd_fd: Fd,
Expand Down Expand Up @@ -1962,6 +1965,7 @@ impl ShellExecEnv {
shell_env: self.shell_env.clone(),
cmd_local_env: EnvMap::init(),
export_env: self.export_env.clone(),
last_exit_code: self.last_exit_code,
__prev_cwd: self.__prev_cwd.clone(),
__cwd: self.__cwd.clone(),
cwd_fd: dupedfd,
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/shell/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ pub(crate) use bun_shell_parser::{escape_8bit, needs_escape_utf8_ascii_latin1};
pub(crate) mod ast {
pub(crate) use bun_shell_parser::parse::SmolList;
use bun_shell_parser::parse::ast as p;
pub(crate) use p::{BinaryOp, CondExprOp, IoKind, RedirectFlags};
pub(crate) use p::{BinaryOp, CondExprOp, IoKind, RedirectFlags, SpecialParam};

pub(crate) type Script = p::Script<'static>;
pub(crate) type Stmt = p::Stmt<'static>;
Expand Down
5 changes: 5 additions & 0 deletions src/runtime/shell/shell_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ pub(crate) mod test {
CloseParen,
Var(&'a [u8]),
VarArgv(u8),
SpecialParam(ast::SpecialParam),
Text(&'a [u8]),
SingleQuotedText(&'a [u8]),
DoubleQuotedText(&'a [u8]),
Expand All @@ -145,6 +146,7 @@ pub(crate) mod test {
match the_token {
Token::Var(txt) => TestToken::Var(&buf[txt.start as usize..txt.end as usize]),
Token::VarArgv(int) => TestToken::VarArgv(int),
Token::SpecialParam(param) => TestToken::SpecialParam(param),
Token::Text(txt) => TestToken::Text(&buf[txt.start as usize..txt.end as usize]),
Token::SingleQuotedText(txt) => {
TestToken::SingleQuotedText(&buf[txt.start as usize..txt.end as usize])
Expand Down Expand Up @@ -217,6 +219,9 @@ pub(crate) mod test {
w.write_char('}')
}
T::VarArgv(n) => write!(w, "{{\"VarArgv\":{}}}", n),
T::SpecialParam(param) => {
write!(w, "{{\"SpecialParam\":\"{}\"}}", param.as_str())
}
T::Text(s) => {
w.write_str("{\"Text\":")?;
encode_json_string(w, s)?;
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 @@ -81,6 +81,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() && !stop {
me.left = Some(exit_code);
} else {
Expand Down
10 changes: 10 additions & 0 deletions src/runtime/shell/states/Expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,16 @@ impl Expansion {
// SAFETY: `command_ctx` is the live VM ctx; `vm_args_utf8` borrows it.
Interpreter::append_var_argv(out, *int, event_loop, command_ctx, vm_args_utf8);
}
ast::SimpleAtom::SpecialParam(param) => {
use std::io::Write as _;
let value = match param {
ast::SpecialParam::ExitCode => u32::from(shell.last_exit_code),
// A `(...)` and a `$(...)` run in the same process.
ast::SpecialParam::Pid => std::process::id(),
};
// A write to a `Vec` does not fail.
let _ = write!(out, "{value}");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: pre-existing, nit: a script that ends with a bare exit still exits 0 after a failed command, while exit $? now works, so the two spellings disagree. POSIX exit with no operand uses the last exit status, which this PR now stores in ShellExecEnv.last_exit_code (Expansion.rs:508) but exit.rs:22 still maps zero arguments to 0. Fix: make the no-argument exit builtin read base.shell().last_exit_code so false; exit, exit and exit $? agree, and add a false; exit; echo $? style test next to the new $? cases.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A Bun shell script such as some-check; exit run through Bun.$, bun exec, bun run --shell=bun or any package.json script on Windows reaches the exit builtin with zero arguments. src/runtime/shell/builtin/exit.rs:22 maps 0 => 0, so the Cmd completes with exit code 0 regardless of the previous command's status. On the base branch the same script also exits 0 (the state did not exist), so this is pre-existing; but the diff now records the last status in ShellExecEnv.last_exit_code (src/runtime/shell/interpreter.rs:1806, written at src/runtime/shell/states/Stmt.rs:81 and src/runtime/shell/states/Binary.rs:84) and expands it for $? at src/runtime/shell/states/Expansion.rs:508, so false; exit $? yields 1 while false; exit still yields 0. sh yields 1 for both. No guard prevents it: exit never consults the env. The PR text lists X=$(false) and command-not-found as the only $? divergences and does not mention bare exit.

Verification: pre-existing (nit). Trigger: any Bun shell script that runs a bare exit after a failing command, e.g. false; exit. src/runtime/shell/builtin/exit.rs:21-22 still maps match bltn.args_slice().len() { 0 => 0, ... }, so a bare exit always completes the Cmd with code 0. The base branch behaves identically for bare exit, so merging makes nothing worse.

}
ast::SimpleAtom::Asterisk => {
meta_offsets.push(out.len() as u32);
out.push(b'*');
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
3 changes: 3 additions & 0 deletions src/shell_parser/json_fmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ fn write_simple_atom(w: &mut impl Write, s: &SimpleAtom<'_>) -> fmt::Result {
SimpleAtom::VarArgv(n) => {
write!(w, "{{\"VarArgv\":{}", n)?;
}
SimpleAtom::SpecialParam(param) => {
write!(w, "{{\"special_param\":\"{}\"", param.as_str())?;
}
SimpleAtom::Text(t) => {
w.write_str("{\"Text\":")?;
encode_json_string(w, t)?;
Expand Down
59 changes: 55 additions & 4 deletions src/shell_parser/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -667,10 +667,29 @@ pub mod ast {
}
}

/// A parameter that the shell sets: `$?` or `$$`.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SpecialParam {
/// `$?`: the exit code of the statement, or the `&&` or `||` operand, that ran last.
ExitCode,
/// `$$`: the process ID of the shell.
Pid,
}

impl SpecialParam {
pub const fn as_str(self) -> &'static str {
match self {
SpecialParam::ExitCode => "$?",
SpecialParam::Pid => "$$",
}
}
}

#[derive(Clone)]
pub enum SimpleAtom<'arena> {
Var(&'arena [u8]),
VarArgv(u8),
SpecialParam(SpecialParam),
Text(&'arena [u8]),
/// An empty string from a quoted context (e.g. "", '', or ${''}). Preserved as an
/// explicit empty argument during expansion, unlike unquoted empty text which is dropped.
Expand Down Expand Up @@ -1630,6 +1649,16 @@ impl<'bump> Parser<'bump> {
}
}
}
Token::SpecialParam(param) => {
let _ = self.expect(TokenTag::SpecialParam);
atoms.push(ast::SimpleAtom::SpecialParam(param));
if next_delimits {
let _ = self.r#match(TokenTag::Delimit);
if should_break {
break;
}
}
}
Token::OpenParen | Token::CloseParen => {
self.add_error(format_args!(
"Unexpected token: `{}`",
Expand Down Expand Up @@ -1978,6 +2007,7 @@ pub enum TokenTag {
CloseParen,
Var,
VarArgv,
SpecialParam,
Text,
SingleQuotedText,
DoubleQuotedText,
Expand Down Expand Up @@ -2029,6 +2059,7 @@ pub enum Token {

Var(TextRange),
VarArgv(u8),
SpecialParam(ast::SpecialParam),
Text(TextRange),
/// Quotation information is lost from the lexer -> parser stage and it is
/// helpful to disambiguate from regular text and quoted text
Expand Down Expand Up @@ -2087,6 +2118,7 @@ impl Token {
Token::CloseParen => b"`)",
Token::Var(r) => &strpool[r.start as usize..r.end as usize],
Token::VarArgv(n) => VARARGV_STRINGS[n as usize],
Token::SpecialParam(param) => param.as_str().as_bytes(),
Token::Text(r) => &strpool[r.start as usize..r.end as usize],
Token::SingleQuotedText(r) => &strpool[r.start as usize..r.end as usize],
Token::DoubleQuotedText(r) => &strpool[r.start as usize..r.end as usize],
Expand Down Expand Up @@ -2654,10 +2686,7 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
let var_tok = self.eat_var()?;

match var_tok.len() {
0 => {
self.append_char_to_str_pool(u32::from(b'$'))?;
self.break_word(AddDelimiter::No)?;
}
0 => self.eat_special_param()?,
1 => 'blk: {
let c = self.strpool[var_tok.start as usize];
if c >= b'0' && c <= b'9' {
Expand Down Expand Up @@ -2967,6 +2996,7 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
&& match self.tokens[self.tokens.len() - 1].tag() {
TokenTag::Var
| TokenTag::VarArgv
| TokenTag::SpecialParam
| TokenTag::Text
| TokenTag::SingleQuotedText
| TokenTag::DoubleQuotedText
Expand Down Expand Up @@ -3391,6 +3421,27 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
true
}

/// After a `$` with no name: lexes `$?` and `$$`. Before any other character the `$` is text.
#[cold]
fn eat_special_param(&mut self) -> Result<(), LexerError> {
let mut chars = self.chars;
let next = chars.eat().filter(|c| !c.escaped).map(|c| c.char);
let param = if next == Some(u32::from(b'?')) {
ast::SpecialParam::ExitCode
} else if next == Some(u32::from(b'$'))
// `$$(cmd)` stays a `$` followed by `$(cmd)`.
&& chars.peek().is_none_or(|c| c.escaped || c.char != u32::from(b'('))
{
ast::SpecialParam::Pid
} else {
self.append_char_to_str_pool(u32::from(b'$'))?;
return self.break_word(AddDelimiter::No);
};
self.chars = chars;
self.tokens.push(Token::SpecialParam(param));
Ok(())
}

fn eat_var(&mut self) -> Result<TextRange, LexerError> {
let start = self.j;
let mut i: usize = 0;
Expand Down
30 changes: 30 additions & 0 deletions test/cli/run/run-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,36 @@ describe.concurrent("run-shell", () => {
expect(stdout).toEqual("wah\n");
});

test("a shell script expands $? and $$", async () => {
using dir = tempDir("run-shell-special-params", {
"status.sh": 'false\necho $? "$?"\necho $$\n',
});
await using proc = Bun.spawn({
cmd: [bunExe(), join(String(dir), "status.sh")],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: `1 1\n${proc.pid}\n`, stderr: "", exitCode: 0 });
});

test("bun run --shell=bun expands $? in a package.json script", async () => {
using dir = tempDir("run-shell-special-params", {
"package.json": JSON.stringify({ name: "special-params", scripts: { status: "false; echo $?" } }),
});
await using proc = Bun.spawn({
cmd: [bunExe(), "run", "--shell=bun", "--silent", "status"],
cwd: String(dir),
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "1\n", stderr: "", exitCode: 0 });
});

test("invalid syntax reports the error correctly", async () => {
const dir = tmpdirSync("bun-shell-test-error");
mkdirSync(dir, { recursive: true });
Expand Down
54 changes: 54 additions & 0 deletions test/js/bun/shell/bunshell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,60 @@ booga"
expect(str).toEqual("bar baz\n");
});

describe("$?", () => {
TestBuilder.command`echo $?`.stdout("0\n").runAsTest("is 0 at the start");
TestBuilder.command`false; echo $?`.stdout("1\n").runAsTest("after a builtin");
TestBuilder.command`false; echo "$?" a$?b`.stdout("1 a1b\n").runAsTest("in double quotes and in a word");
TestBuilder.command`${BUN} -e "process.exit(42)"; echo $?`.stdout("42\n").runAsTest("after a command");
TestBuilder.command`false; true; echo $?`.stdout("0\n").runAsTest("is the status of the last statement");
TestBuilder.command`false || echo $?`.stdout("1\n").runAsTest("right of ||");
TestBuilder.command`true && false; echo $?`.stdout("1\n").runAsTest("after &&");
TestBuilder.command`true | false; echo $?; false | true; echo $?`.stdout("1\n0\n").runAsTest("after a pipeline");
TestBuilder.command`if false; then echo no; else echo $?; fi`.stdout("1\n").runAsTest("in an else branch");
TestBuilder.command`false; if false; then echo no; fi; echo $?`
.stdout("0\n")
.runAsTest("after an if that takes no branch");
TestBuilder.command`(false); echo $?; false; (echo $?)`.stdout("1\n1\n").runAsTest("out of and into a subshell");
TestBuilder.command`false; echo $(echo $?)`.stdout("1\n").runAsTest("into a command substitution");
// dash prints 1 here and bash prints 0.
TestBuilder.command`false; echo $(true)$?`
.stdout("1\n")
.runAsTest("a $(...) in the same command does not set it");
TestBuilder.command`false; STATUS=$?; true; echo $STATUS`.stdout("1\n").runAsTest("as an assignment value");
TestBuilder.command`false; [[ $? == 1 ]] && echo yes`.stdout("yes\n").runAsTest("in [[ ]]");
TestBuilder.command`false; echo hi > out_$?.txt`
.ensureTempDir()
.fileEquals("out_1.txt", "hi\n")
.runAsTest("in a redirect target");
TestBuilder.command`${{ raw: "false; echo é $?" }}`
.stdout("é 1\n")
.runAsTest("in a script with a non-ASCII character");
TestBuilder.command`false; echo '$?' \$? $\?`.stdout("$? $? $?\n").runAsTest("quoted or escaped stays text");
TestBuilder.command`false; echo $${"?"} ${"$?"}`.stdout("$? $?\n").runAsTest("an interpolated value stays text");

// `$?` does not see the status of an assignment from `$(...)` today. #45074 and #37839 change this row to 1.
TestBuilder.command`X=$(false); echo $?`.stdout("0\n").runAsTest("after an assignment from a $(...) that fails");
Comment on lines +1267 to +1268

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: Users who check a command substitution's status with X=$(cmd); [[ $? -ne 0 ]] && exit 1 now get 0 and the script silently continues, where sh stops. The 0 comes from Assigns.rs:80, which reports exit 0 for every standalone assignment even though the Expansion already carries the failed $(...) status in out_exit_code. Fix: make a standalone assignment's exit status the out_exit_code of its last $(...) expansion (POSIX: a command with no name completes with the status of the last command substitution), so $? reads 1 here; the test row at bunshell.test.ts:1268 then asserts 1, not 0. The PR text calls this accepted pending #45074, but the wrong value is new user-visible behaviour: on the base branch $? was the literal text, which no script could mistake for success.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Trigger: any Bun shell script (Bun.$, bun exec, bun run --shell=bun, and every package.json script on Windows) that runs X=$(cmd) where cmd exits nonzero and then reads $?. Assigns.rs:80 (interp.child_done(parent, this, 0)) always reports 0 for a standalone assignment; it drops out.out_exit_code, which Expansion.rs:657 set from the failed $(...) script. Stmt.rs:81 then stores that 0 in last_exit_code, and Expansion.rs:508 expands $? to 0. On the base branch $? was not expanded at all, so [[ $? -ne 0 ]] never evaluated to 'success'; after merging, the idiom X=$(cmd); [[ $? -ne 0 ]] && exit 1 silently proceeds past a failed cmd. sh and bash both print 1 for this script. No safeguard covers it: dupe_for_subshell copies last_exit_code into the $(...) env and never writes it back, and nothing else reads out_exit_code on the Assigns path.

Verification: src/runtime/shell/states/Expansion.rs:656-657 sets me.out_exit_code = exit_code when the sole atom is a $(...) that failed; src/runtime/shell/states/Assigns.rs:100 takes that out but never reads out_exit_code; Assigns.rs:78-81 unconditionally reports 0 for a standalone assignment. The new line src/runtime/shell/states/Stmt.rs:81 then stores that 0, and Expansion.rs:505-513 expands $? to 0.

TestBuilder.command`shell_test_no_such_command; echo $?`
.stdout("1\n")
.stderr("bun: command not found: shell_test_no_such_command\n")
.runAsTest("after a command that is not found");
Comment on lines +1267 to +1272

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): Two new tests lock in values the PR itself calls wrong, so the follow-up PRs must flip assertions instead of un-todoing them. bunshell.test.ts:1268 asserts X=$(false); echo $? prints "0\n" and bunshell.test.ts:1269 asserts a missing command gives $? of 1, where sh gives 1 and 127. Fix: assert the sh values ("1\n" and "127\n") and mark both rows .todo("#45074") / .todo("command not found should be 127") via TestBuilder's todo() at test_builder.ts:279, so they start passing when the behaviour is corrected and no test certifies the divergence.

Why this was flagged

The describe block at bunshell.test.ts:1236 adds TestBuilder.commandX=$(false); echo $?.stdout("0\n") (line 1268) with a comment saying #45074 and #37839 change this row to 1, and shell_test_no_such_command; echo $? expecting "1\n" (lines 1269-1272) where POSIX sh yields 127. The PR description lists both as known divergences. The repository review rules reject assertions captured from known-wrong behaviour and ask that todo tests be un-skipped by the fixing change. TestBuilder already supports .todo(reason) (test_builder.ts:279-291), which runs the case under test.todo so it is reported when it starts passing. On the base branch neither test exists; after merging, a maintainer landing #45074 or #37839 gets a failing assertion to rewrite rather than a todo to remove.

Verification: nit; acknowledged in diff: /home/claude/bun/test/js/bun/shell/bunshell.test.ts:1268 pins the divergent value: X=$(false); echo $?.stdout("0\n"). Triggering condition: whichever of #45074/#37839 lands must now flip a green assertion instead of un-todoing a row. One listed site does NOT carry the defect: the row at lines 1269-1272 asserts $? = 1, which is Bun's established, already-pinned behaviour.

});

describe("$$", () => {
TestBuilder.command`echo $$ "$$" $$x`
.stdout(`${process.pid} ${process.pid} ${process.pid}x\n`)
.runAsTest("is the process ID of bun");
TestBuilder.command`echo $(echo $$); (echo $$)`
.stdout(`${process.pid}\n${process.pid}\n`)
.runAsTest("is the same in $(...) and (...)");
TestBuilder.command`${{ raw: "echo é $$" }}`
.stdout(`é ${process.pid}\n`)
.runAsTest("in a script with a non-ASCII character");
TestBuilder.command`echo $$(echo hi)`.stdout("$hi\n").runAsTest("$$( is still a $ and a $(...)");
TestBuilder.command`echo '$$' \$\$ $\$`.stdout("$$ $$ $$\n").runAsTest("quoted or escaped stays text");
TestBuilder.command`echo $${"$"} ${"$$"}`.stdout("$$ $$\n").runAsTest("an interpolated value stays text");
});

test("shell var", async () => {
const { stdout } = await $`FOO=bar BAR=baz && BAZ=1 ${BUN} -e "console.log(JSON.stringify(process.env))"`;
const str = stdout.toString();
Expand Down
16 changes: 16 additions & 0 deletions test/js/bun/shell/exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ describe("bun exec", () => {
)
.runAsTest("write a lot of data");

TestBuilder.command`${BUN} exec ${'false; echo $? "$?"'}`
.env(bunEnv)
.stdout("1 1\n")
.runAsTest("$? is the last exit code");

test("$$ is the process ID of bun exec", async () => {
await using proc = Bun.spawn({
cmd: [BUN, "exec", 'echo $$ "$$"'],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: `${proc.pid} ${proc.pid}\n`, stderr: "", exitCode: 0 });
});

describe("--help works", () => {
// prettier-ignore
const programs = [
Expand Down
Loading
Loading