Skip to content

cli: run an empty -e / --print / stdin script instead of printing help - #38587

Open
robobun wants to merge 1 commit into
mainfrom
farm/3792a764/empty-eval-script
Open

robobun wants to merge 1 commit into
mainfrom
farm/3792a764/empty-eval-script

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun -e "" and bun --print "" (also --eval=, --print=, -e=, -p=, -pe "") print the 37-line CLI help text to stdout and exit 0. node -e "" runs an empty program and exits 0 silently; node -p "" prints undefined.
  • The same root cause hits the other eval entry points: node -e "" (bun running as node) fails with error: Missing script to execute, bun repl -e "" starts the interactive REPL, and bun - / bun run - with empty stdin fail with error: Module not found '<cwd>/[stdin]'.
  • Cause: Eval::script (src/options_types/context.rs) is a plain Box<[u8]>, so an explicitly empty script is indistinguishable from no script, and every dispatch site tests is_empty(): cli/mod.rs (AutoCommand dispatch), run_command.rs (boot, add_conditional_globals, exec_as_if_node) and repl_command.rs. On top of that, Command::start in cli/mod.rs short-circuited the exact bun -e "" argv shapes straight to HelpCommand::exec().
  • Not a regression: the len > 0 gate dates back to when -e was added; the Rust port preserved it and added the help shortcut.

Fix

  • Eval::script becomes Option<Box<[u8]>>: None means no script was given, Some means run it, empty or not. Argument parsing, the stdin reader and the --interactive bootstrap store Some; the dispatch sites test is_some(); the exact-shape help shortcut in Command::start is deleted (the --version / bun <path> shortcuts stay).
  • ReplRunner takes Option<&[u8]> and runs the script whenever one was given, so bun repl -e "" evaluates and exits like bun repl -p "" already did. The script is take()n out of the process-global context and leaked instead of reborrowed through a raw pointer, which drops an unsafe block.
  • process._eval returns undefined when the eval source is empty (node_process.rs). Node only defines process._eval for a truthy value, and the --interactive branch right above already did this.
  • Why this is correct: it matches node for every spelling above (checked against node v26.3.0: -e "" silent, -p "" / -pe "" / -p "" x print undefined, empty stdin silent, process._eval undefined under -e "", -i -e "" enters the REPL with process._eval undefined). Absent vs. empty is the distinction node itself tracks (has_eval_string), and the Option puts that distinction in the type instead of in a convention about emptiness. An empty eval source goes through the same ParseResult::empty_with path as an empty file, and --print falls back to undefined when the module produced no completion value, so nothing downstream needed to learn about empty scripts.
  • Bare bun still prints help (positionals empty, no script); bun -e "" with no positionals now boots the runtime like bun -e ";" does.
  • Verified:
    • test/cli/run/run-eval.test.ts: 13 new tests (every spelling, preload + trailing argv + process._eval, empty stdin for bun - and bun run -); all 13 fail on the released binary, 50/50 pass with this build.
    • test/cli/run/as-node.test.ts: node -e "" / -p "" / -pe "" (3 fail before, 14/14 pass after).
    • test/js/bun/repl/repl.test.ts: bun repl -e "" exits without the REPL (fails before), --interactive -e "" keeps entering the REPL with process._eval undefined (unchanged behavior, pinned); 150/150 pass.
    • Test suites that spawn bun -e "" as a trivial child still pass: spawn, spawn-signal, spawn-stdin-pipe-fd-leak, spawn-stdin-readable-stream, child_process, child-process-stdio, terminal, terminal-platform-gaps, cli/bun, run_command, and the bounds memory tests in serve/fetch.
  • Side effect on tests: emptyProcessMaxRSS() in test/harness.ts spawns bun -e "" as the "empty bun process" baseline. It now measures a booted runtime (debug/ASAN: ~318 MiB instead of ~216 MiB for the help text; release: 27.4 vs 26.4 MiB), which is what the helper intends; every consumer asserts fixture - baseline < N, so their deltas only get smaller. Each bun -e "" child also costs a runtime boot now (~6 ms release, ~265 ms instead of ~100 ms debug/ASAN); the one test that spawns 20 of them sequentially with full GCs between (extra stdio pipes are not double-closed on GC, child_process.test.ts) takes ~11.5 s on a debug build here, so it gets an explicit 30 s timeout; prettier re-indents that test body because of the added argument.
  • Overlap: Node v26 CLI compatibility: make node:cli tests pass (+9 upstream tests) #32622 (node CLI compat, currently conflicted) fixes the bun -e "" case among many other things with a separate provided flag, but not the stdin or bun repl cases; this PR is the small standalone version and would let that PR drop its flag on rebase. node emulation: keep the first positional in process.argv for node -e / -p #38556 and cli: read the script from stdin for node - in node emulation #38566 touch neighboring lines of exec_as_if_node / exec_stdin; whichever lands second has a one-line conflict to resolve.

Background

  • Eval entry point: -e / -p (and bun - for stdin) do not run a file. RunCommand::boot stores the script bytes as vm.module_loader.eval_source, a Source keyed at the synthetic path <cwd>/[eval] (or [stdin]), and the module loader serves that source instead of reading the disk when the entry specifier ends in /[eval] or /[stdin]. When eval_source is not set, as happened for the empty-stdin case, loading the synthetic path fails with "Module not found".
  • --interactive: exec_node_repl swaps the user's -e bytes into Eval::interactive_script and puts the node:repl bootstrap in Eval::script, so process._eval reads the user's bytes from interactive_script (already undefined when empty); the non-interactive branch of process._eval now does the same.
  • Command::start fast path: a few exact argv shapes (bun --version, bun <path>) are dispatched before the subcommand classifier runs, purely to keep startup small; the empty-eval shapes were in that list only to reproduce the help-text behavior, so removing them changes nothing else.
Repro on the released binary vs node v26.3.0
$ bun -e "" | head -1
Bun is a fast JavaScript runtime, package manager, bundler, and test runner. (1.4.0-canary.1+b7a043103)
$ bun --print "" | head -1
Bun is a fast JavaScript runtime, package manager, bundler, and test runner. (1.4.0-canary.1+b7a043103)
$ echo -n | bun -
error: Module not found '/tmp/[stdin]'
$ bun --bun node -e "" </dev/null
error: Missing script to execute. Pass --interactive to start the Node.js-compatible REPL.
$ bun repl -e "" </dev/null
Welcome to Bun v1.4.0
...

$ node -e ""; echo $?
0
$ node -p ""
undefined
$ echo -n | node -; echo $?
0
$ node -r ./preload.cjs -e "" a b        # preload prints process._eval and argv
preload _eval: undefined argv: ["a","b"]

With this branch, every bun invocation above matches the node output (-p variants print undefined, the rest exit 0 silently), and bun -r ./preload.cjs -e "" a b prints preload _eval: undefined argv: ["a","b"].

`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 '<cwd>/[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<Box<[u8]>>`: `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.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c0e1ec62-ae1d-47b0-b1be-ba63f3f8394d

📥 Commits

Reviewing files that changed from the base of the PR and between eabb96d and 8342ac6.

📒 Files selected for processing (10)
  • src/options_types/context.rs
  • src/runtime/cli/Arguments.rs
  • src/runtime/cli/mod.rs
  • src/runtime/cli/repl_command.rs
  • src/runtime/cli/run_command.rs
  • src/runtime/node/node_process.rs
  • test/cli/run/as-node.test.ts
  • test/cli/run/run-eval.test.ts
  • test/js/bun/repl/repl.test.ts
  • test/js/node/child_process/child_process.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on the released binary (1.4.0-canary.1+b7a043103, linux-x64): bun -e "" and bun --print "" print the help text, echo -n | bun - fails with Module not found '<cwd>/[stdin]', bun-as-node -e "" fails with Missing script to execute, and bun repl -e "" starts the interactive REPL; node v26.3.0 runs an empty program in all of these cases.

The new tests in test/cli/run/run-eval.test.ts (13), test/cli/run/as-node.test.ts (3) and test/js/bun/repl/repl.test.ts fail on the released binary and pass with this branch; the surrounding files and the suites that spawn bun -e "" as a trivial child were re-run against this build (list in the PR description).

The rate-limit notice above needs no action; the review can be re-requested once the limit resets.

@claude claude Bot left a comment

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.

I reviewed this PR and didn't find any bugs. It's a clean, well-tested Node-compat fix, but since it touches CLI dispatch / startup and the author notes overlap with #32622 / #38556 / #38566, a maintainer should confirm the landing order.

What was reviewed:

  • Grepped every eval.script / eval_and_print consumer — all sites updated; eval_and_print = true is only ever set alongside script = Some(...), so dropping the || eval_and_print disjunct in ReplRunner is safe.
  • Checked from_raw_parts on the empty Box<[u8]> in boot — dangling-but-aligned pointer with len 0 is valid.
  • The process._eval empty-check preserves the existing non-empty stdin _eval test in run-eval.test.ts.
  • The removed fast-path in Command::start existed only to reproduce the help-text behavior; --version / bun <path> shortcuts remain.
Extended reasoning...

Overview

This PR changes Eval::script from Box<[u8]> to Option<Box<[u8]>> so that an explicitly empty -e/-p/stdin script is distinguishable from no script at all. It updates the three writers (Arguments.rs --print/--eval/--port, exec_stdin, exec_node_repl) to store Some, and the six readers (cli/mod.rs AutoCommand dispatch, run_command.rs boot/add_conditional_globals/exec_as_if_node, repl_command.rs ReplRunner) to test is_some(). The empty-eval fast-path in Command::start is deleted, and process._eval returns undefined for an empty source. Tests: 13 new cases in run-eval.test.ts, 3 in as-node.test.ts, 2 in repl.test.ts, plus a 30s timeout on one child_process test whose 20 children now boot the runtime.

Security risks

None. The change only affects how CLI argv is classified before dispatch; no untrusted-input parsing, no auth/crypto/permissions.

Level of scrutiny

Medium-high. The type change itself is mechanical and compiler-enforced, but it touches Command::start (the startup-critical dispatch root) and changes the semantics of bun -e "", which is used pervasively across the test suite as a trivial-child idiom. The PR author audited the affected suites (spawn, child_process, terminal, serve/fetch memory tests, emptyProcessMaxRSS()), but a maintainer should confirm the CI-wide impact and the emptyProcessMaxRSS() baseline shift doesn't destabilize leak tests on other platforms.

Other factors

  • I verified via grep that every eval.script site is updated and that eval_and_print is never set without script, so the removed || eval_and_print disjunct in ReplRunner::start cannot change behavior.
  • The repl_command.rs refactor replaces a raw-pointer reborrow with take() + Box::leak, which is strictly safer (drops an unsafe block) and correct because hold_api_lock never returns.
  • The PR explicitly notes overlap with #32622 (which fixes the same bun -e "" case via a separate flag) and one-line conflicts with #38556 / #38566. A maintainer should decide landing order rather than an automated approval.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

On landing order, for whoever merges: this PR does not depend on #38556, #38566 or #32622 and they do not depend on it.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

#39687 rewrites the extra stdio pipes are not double-closed on GC test in child_process.test.ts so that it no longer spawns bun. The test already times out on a debug build on main (about 5.1 s against the 5 s default). After #39687 lands, the 30 s timeout hunk in this PR is not needed, and the test body will conflict on rebase. Dropping this PR's hunk for that test resolves it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant