From 8342ac6b70bcb77709873d2cb091f6e2b1b536e7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:20:34 +0000 Subject: [PATCH] cli: run an empty -e / --print / stdin script instead of printing help `bun -e ""`, `bun --print ""` (and the `--eval=` / `--print=` / `-pe ""` spellings) printed the CLI help text, `node -e ""` printed "Missing script", `bun repl -e ""` started the interactive REPL, and `bun -` with empty stdin failed with "Module not found '/[stdin]'". node runs an empty program in all of these cases and exits 0 (`-p` prints undefined). The script was stored as a plain byte slice, so an explicitly empty script was indistinguishable from no script, and every dispatch site tested `is_empty()`. Command::start additionally short-circuited the exact `bun -e ""` argv shapes straight to the help text. Make `Eval::script` an `Option>`: `None` means no script was given, `Some` means run it even when empty. Dispatch sites test `is_some()`, the help shortcut is removed, and `process._eval` reports `undefined` for an empty script, as node does. The child_process double-close test spawns 20 `bun -e ""` children that now boot the runtime; give it an explicit timeout for debug builds. --- src/options_types/context.rs | 5 +- src/runtime/cli/Arguments.rs | 6 +- src/runtime/cli/mod.rs | 49 +++++---------- src/runtime/cli/repl_command.rs | 25 ++++---- src/runtime/cli/run_command.rs | 20 +++---- src/runtime/node/node_process.rs | 6 +- test/cli/run/as-node.test.ts | 21 +++++++ test/cli/run/run-eval.test.ts | 60 ++++++++++++++++++- test/js/bun/repl/repl.test.ts | 18 ++++++ .../node/child_process/child_process.test.ts | 42 +++++++------ 10 files changed, 169 insertions(+), 83 deletions(-) diff --git a/src/options_types/context.rs b/src/options_types/context.rs index 4190731b8ed8..eeb51d23f69f 100644 --- a/src/options_types/context.rs +++ b/src/options_types/context.rs @@ -564,7 +564,10 @@ pub struct RuntimeOptions { #[derive(Default)] pub struct Eval { - pub script: Box<[u8]>, + /// The `-e`/`-p` argument, or the bytes read from stdin for `bun -`. + /// `None` when neither was given; `Some` even when the script is empty, + /// since `bun -e ""` runs an empty program like `node -e ""` does. + pub script: Option>, pub eval_and_print: bool, /// Under `--interactive`, `script` holds the node:repl bootstrap; this /// holds the user's actual `-e` bytes so `process._eval` reports them diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 486f92a04d5b..f983e9860035 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -1125,7 +1125,7 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result` from working - ctx.runtime_options.eval.script = port_str.into(); + ctx.runtime_options.eval.script = Some(port_str.into()); ctx.runtime_options.eval.eval_and_print = true; } else { opts.port = match strings::parse_int::(port_str, 10) { @@ -1202,10 +1202,10 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result` / `bun .` run - // shape. Hoisted ABOVE `which()` and the per-tag `match` so these - // common invocations never decode the subcommand-name classifier - // (`which()` + its `RootCommandMatcher` name table / rodata) or walk - // the per-tag dispatch `match`. `bun --version` also skips - // `create_context_data` entirely (`arguments::parse` builds-and-drops - // a full `api::TransformOptions` and forces two `LazyLock`s for what - // is a no-op). Keeps `command::which`'s code/rodata and `arguments`'s - // clap tables out of the `--version` / `bun ` working set. + // Fast path: `bun -v` / `bun --version` / `bun --revision` and the + // dominant `bun ` / `bun .` run shape. Hoisted ABOVE `which()` + // and the per-tag `match` so these common invocations never decode + // the subcommand-name classifier (`which()` + its `RootCommandMatcher` + // name table / rodata) or walk the per-tag dispatch `match`. + // `bun --version` also skips `create_context_data` entirely + // (`arguments::parse` builds-and-drops a full `api::TransformOptions` + // and forces two `LazyLock`s for what is a no-op). Keeps + // `command::which`'s code/rodata and `arguments`'s clap tables out of + // the `--version` / `bun ` working set. // // Correctness guards: // * argv0 must be a plain `bun` invocation — a `node` / `bunx` shim @@ -1210,13 +1209,12 @@ pub mod command { // the *predicates* are read here; `which()` performs the matching // `PRETEND_TO_BE_NODE` / `IS_BUNX_EXE` side effects. // * the standalone-graph probe above already ran, so a compiled - // executable's `--version` / `-e ''` is still passed through to - // user code (it returned via `boot_standalone`). + // executable's `--version` is still passed through to user code + // (it returned via `boot_standalone`). // * the version check is exact-argv-shape (`len == 2`) so it cannot // intercept `bun --version`, where the flag belongs to // `` (the bug the old argv-scan shim had — see the - // NOTE below). The empty-eval check is likewise exact-shape, falling - // through to `HelpCommand.exec`. + // NOTE below). { let argv = bun::argv(); let argv0 = argv.get(0).map(bun_core::ZStr::as_bytes).unwrap_or(b""); @@ -1229,25 +1227,6 @@ pub mod command { } } - let empty_eval = match argv.len() { - 2 => matches!( - argv.get(1).map(bun_core::ZStr::as_bytes), - Some(b"-e=" | b"-p=" | b"--eval=" | b"--print=") - ), - 3 => { - argv.get(2).is_some_and(|a| a.as_bytes().is_empty()) - && matches!( - argv.get(1).map(bun_core::ZStr::as_bytes), - Some(b"-e" | b"-p" | b"--eval" | b"--print") - ) - } - _ => false, - }; - if empty_eval { - Output::flush(); - return HelpCommand::exec(); - } - // `bun ` / `bun .` — the dominant run shape. argv[1] is // path-shaped (`looks_like_run_entrypoint`), which no // subcommand keyword can be, so `which()` would unambiguously @@ -1451,7 +1430,7 @@ pub mod command { } } - if tag == Tag::AutoCommand && !ctx.runtime_options.eval.script.is_empty() { + if tag == Tag::AutoCommand && ctx.runtime_options.eval.script.is_some() { return run_command::RunCommand::exec_eval(ctx); } diff --git a/src/runtime/cli/repl_command.rs b/src/runtime/cli/repl_command.rs index 061ac4de2b1a..151c9db85f79 100644 --- a/src/runtime/cli/repl_command.rs +++ b/src/runtime/cli/repl_command.rs @@ -139,16 +139,14 @@ impl ReplCommand { repl, vm, arena, - // ctx is the process-global ContextData; extend the borrow past the - // local reborrow lifetime via raw ptr (the runner never outlives - // ctx — global_exit() is `!`). - eval_script: { - let ptr: *const [u8] = &raw const *ctx.runtime_options.eval.script; - // SAFETY: ctx.runtime_options.eval.script lives in the process-global - // ContextData; the raw-ptr reborrow is sound because the runner never - // outlives it — hold_api_lock returns into global_exit() (`!`). - unsafe { &*ptr } - }, + // The runner never returns (hold_api_lock ends in global_exit()), + // so the script can simply be leaked for the process lifetime. + eval_script: ctx + .runtime_options + .eval + .script + .take() + .map(|script| &*Box::leak(script)), eval_and_print: ctx.runtime_options.eval.eval_and_print, }; // vm.arena stores a *mut Arena pointing at runner.arena; @@ -201,7 +199,8 @@ struct ReplRunner<'a, 'r> { repl: &'a mut Repl<'r>, vm: *mut VirtualMachine, arena: Arena, - eval_script: &'a [u8], + /// `-e`/`-p` script; `None` runs the interactive loop instead. + eval_script: Option<&'a [u8]>, eval_and_print: bool, } @@ -221,10 +220,10 @@ impl<'a, 'r> ReplRunner<'a, 'r> { vm.global_exit(); } - if !this.eval_script.is_empty() || this.eval_and_print { + if let Some(eval_script) = this.eval_script { // Non-interactive: evaluate the -e/--eval or -p/--print script, // drain the event loop, and exit - let had_error = this.repl.eval_script(this.eval_script, this.eval_and_print); + let had_error = this.repl.eval_script(eval_script, this.eval_and_print); Output::flush(); if had_error { // Only overwrite on error so `process.exitCode = N` in the diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 87501ccffec9..86faacea7d25 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -979,16 +979,12 @@ Full documentation is available at https://bun.com/docs/cli/run let mut run_entry = entry; vm.set_main(entry); - if !ctx.runtime_options.eval.script.is_empty() { + if let Some(eval_script) = ctx.runtime_options.eval.script.as_deref() { // SAFETY: `ctx.runtime_options.eval.script` is process-lifetime // (CLI argv); erase the borrow lifetime so the `Source` (stored in // the VM for the process duration) can backref into it. - let script: &'static [u8] = unsafe { - ::core::slice::from_raw_parts( - ctx.runtime_options.eval.script.as_ptr(), - ctx.runtime_options.eval.script.len(), - ) - }; + let script: &'static [u8] = + unsafe { ::core::slice::from_raw_parts(eval_script.as_ptr(), eval_script.len()) }; vm.module_loader.eval_source = Some(Box::new(bun_ast::Source::init_path_string(entry, script))); vm.module_loader.interactive_eval_script = @@ -1259,7 +1255,7 @@ impl Run<'_> { fn JSC__JSGlobalObject__addGc(global: *const JSGlobalObject); } let ro = &ctx.runtime_options; - if !ro.eval.script.is_empty() { + if ro.eval.script.is_some() { // SAFETY: FFI; `vm.global` is live for the VM lifetime. unsafe { Bun__ExposeNodeModuleGlobals(vm.global) }; } @@ -2798,7 +2794,7 @@ impl RunCommand { if bun_sys::File::stdin().read_to_end_into(&mut list).is_err() { return Ok(false); } - ctx.runtime_options.eval.script = list.into_boxed_slice(); + ctx.runtime_options.eval.script = Some(list.into_boxed_slice()); #[cfg(windows)] const STDIN_TRIGGER: &[u8] = b"\\[stdin]"; @@ -2846,8 +2842,8 @@ impl RunCommand { // bootstrap via `[eval]`; it runs `process._eval` like Node's // internal/main/repl.js — no source splicing. ctx.runtime_options.eval.interactive_script = - Some(::core::mem::take(&mut ctx.runtime_options.eval.script)); - ctx.runtime_options.eval.script = bootstrap.to_vec().into_boxed_slice(); + Some(ctx.runtime_options.eval.script.take().unwrap_or_default()); + ctx.runtime_options.eval.script = Some(bootstrap.to_vec().into_boxed_slice()); Self::exec_eval(ctx) } @@ -2900,7 +2896,7 @@ impl RunCommand { return Self::exec_node_repl(ctx); } - if !ctx.runtime_options.eval.script.is_empty() { + if ctx.runtime_options.eval.script.is_some() { // synthetic `[eval]` path under cwd let mut entry_point_buf = [0u8; MAX_PATH_BYTES + EVAL_TRIGGER.len()]; let mut cwd_buf = PathBuffer::uninit(); diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index d021b241ea51..46cd12122c2e 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -471,7 +471,11 @@ mod _impl { } return ZigString::init(script).with_encoding().to_js(global_object); } - if let Some(source) = vm.module_loader.eval_source.as_deref() { + // Node only defines `process._eval` for a non-empty script, so + // `-e ""` reports `undefined` just like the `--interactive` case above. + if let Some(source) = vm.module_loader.eval_source.as_deref() + && !source.contents().is_empty() + { return ZigString::init(source.contents()) .with_encoding() .to_js(global_object); diff --git a/test/cli/run/as-node.test.ts b/test/cli/run/as-node.test.ts index 34ee77efd693..2db5957182dd 100644 --- a/test/cli/run/as-node.test.ts +++ b/test/cli/run/as-node.test.ts @@ -87,6 +87,27 @@ describe("fake node cli", () => { expect(fakeNodeRun(temp, ["-e", "console.log('pass')"]).stdout).toBe("pass"); }); + // Like node, an empty -e / -p script is still a script to run. It used to be + // treated as no script at all (the "Missing script" / REPL path below), so + // stdin is pinned to a pipe here as well. + test.each([ + [["-e", ""], ""], + [["-p", ""], "undefined\n"], + [["-pe", ""], "undefined\n"], + ])("node %j runs an empty program", async (args, expectedStdout) => { + using temp = tempDir("fake-node", {}); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--bun", "node", ...args], + cwd: String(temp), + env: { ...bunEnv, NODE_ENV: undefined }, + stdin: Buffer.alloc(0), + 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: expectedStdout, stderr: "", exitCode: 0 }); + }); + test("process args work", () => { using temp = tempDir("fake-node", { "index.js": "console.log(JSON.stringify(process.argv.slice(1)))", diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index 0f928a7ce6a7..b34f6018fc2a 100644 --- a/test/cli/run/run-eval.test.ts +++ b/test/cli/run/run-eval.test.ts @@ -1,7 +1,7 @@ import { SyncSubprocess } from "bun"; import { describe, expect, test } from "bun:test"; import { rmSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, isWindows, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir, tmpdirSync } from "harness"; import { tmpdir } from "os"; import { join, sep } from "path"; @@ -108,6 +108,64 @@ for (const flag of ["-e", "--print"]) { }); } +// `node -e ""` and `node -p ""` run an empty program. Bun used to treat an +// empty script the same as no script at all and printed the help text instead. +describe.concurrent("empty script", () => { + async function runBun(args: string[], options: { cwd?: string; stdin?: Buffer } = {}) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: bunEnv, + cwd: options.cwd, + stdin: options.stdin ?? "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test.each([ + [["-e", ""], ""], + [["--eval", ""], ""], + [["-e="], ""], + [["--eval="], ""], + [["-p", ""], "undefined\n"], + [["--print", ""], "undefined\n"], + [["-p="], "undefined\n"], + [["--print="], "undefined\n"], + [["-pe", ""], "undefined\n"], + ])("bun %j runs an empty program instead of printing help", async (args, expectedStdout) => { + expect(await runBun(args)).toEqual({ stdout: expectedStdout, stderr: "", exitCode: 0 }); + }); + + test.each(["-e", "-p"])( + "bun %s '' still runs preloads and keeps the remaining arguments as process.argv", + async flag => { + using dir = tempDir("empty-eval", { + "preload.js": `console.log(JSON.stringify({ _eval: typeof process._eval, argv: process.argv.slice(1) }));`, + }); + // Without the fix, "abc" is looked up as a script to run instead. + const { stdout, stderr, exitCode } = await runBun(["-r", "./preload.js", flag, "", "abc", "def"], { + cwd: String(dir), + }); + expect(stderr).toBe(""); + const [preloadLine, ...rest] = stdout.split("\n"); + // Like node, process._eval is only defined for a non-empty script. + expect(JSON.parse(preloadLine)).toEqual({ _eval: "undefined", argv: ["abc", "def"] }); + expect(rest.join("\n")).toBe(flag === "-p" ? "undefined\n" : ""); + expect(exitCode).toBe(0); + }, + ); + + test.each([ + ["bun -", ["-"]], + ["bun run -", ["run", "-"]], + ])("%s with empty stdin runs an empty program", async (_, args) => { + // Without the fix this fails with "Module not found '/[stdin]'". + expect(await runBun(args, { stdin: Buffer.alloc(0) })).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + }); +}); + describe("--print for cjs/esm", () => { test("eval result between esm imports", async () => { let cwd = tmpdirSync(); diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 403342009aa3..a9016f44cdb3 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -823,6 +823,11 @@ describe.concurrent("Bun REPL", () => { expect(exitCode).toBe(0); }); + test("-e with empty script exits instead of starting the interactive REPL", async () => { + const { stdout, stderr, exitCode } = await runReplWith(["-e", ""]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + }); + test("-e supports TypeScript", async () => { const { stdout, exitCode } = await runReplWith(["-p", "const x: number = 42; x * 2"]); expect(stdout).toBe("84\n"); @@ -1429,6 +1434,19 @@ describe.concurrent("--interactive", () => { interactiveTimeout, ); + // `node -i -e ""` still enters the REPL and leaves process._eval undefined. + test( + "-e with an empty script enters the REPL with process._eval undefined", + async () => { + const { stdout, stderr, exitCode } = await runInteractive(["-e", ""], 'console.log("EVAL=" + process._eval)\n'); + expect(stdout).toContain("Welcome to Bun"); + expect(stdout).toContain("EVAL=undefined"); + expect(stderr).not.toContain("error"); + expect(exitCode).toBe(0); + }, + interactiveTimeout, + ); + // The bootstrap runs -e via vm.runInThisContext (raw JS, matching // `node -i -e`); TypeScript syntax is a SyntaxError, not transpiled. test( diff --git a/test/js/node/child_process/child_process.test.ts b/test/js/node/child_process/child_process.test.ts index a7bf59ba32ac..ab494f293d06 100644 --- a/test/js/node/child_process/child_process.test.ts +++ b/test/js/node/child_process/child_process.test.ts @@ -921,15 +921,21 @@ it.if(isLinux)("spawn still works with more than 10240 fds open", async () => { // GC'd, finalize_streams does not close the fd a second time (EBADF, or // worse, closing a reused fd number). Before the fix, Fd::close()'s // debug_assert!(err.is_none()) panicked under debug_assertions builds. -it.skipIf(isWindows)("extra stdio pipes are not double-closed on GC", async () => { - // Run in a subprocess so GC/finalize timing is isolated from the test - // runner's own state, and so the assert abort surfaces as a non-zero - // exit rather than taking the whole test runner down. - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` +// +// Each of the 20 children boots the runtime for its empty `-e ""` program and +// every iteration runs two full GCs, which adds up to several seconds on a +// debug build, hence the explicit timeout. +it.skipIf(isWindows)( + "extra stdio pipes are not double-closed on GC", + async () => { + // Run in a subprocess so GC/finalize timing is isolated from the test + // runner's own state, and so the assert abort surfaces as a non-zero + // exit rather than taking the whole test runner down. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` const { spawn } = require("node:child_process"); async function once() { const child = spawn(process.execPath, ["-e", ""], { @@ -949,14 +955,16 @@ it.skipIf(isWindows)("extra stdio pipes are not double-closed on GC", async () = } console.log("OK"); `, - ], - env: { ...bunEnv, BUN_GARBAGE_COLLECTOR_LEVEL: "1" }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "OK", stderr: "", exitCode: 0 }); -}); + ], + env: { ...bunEnv, BUN_GARBAGE_COLLECTOR_LEVEL: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "OK", stderr: "", exitCode: 0 }); + }, + 30_000, +); // For fd 0-2, "ignore" opens /dev/null. For fd >= 3, Node leaves the fd // closed; Bun used to open /dev/null on the slot, so children probing