Skip to content

src: run node --run scripts without a subshell#63978

Open
anonrig wants to merge 1 commit into
nodejs:mainfrom
anonrig:run-exec-no-subshell
Open

src: run node --run scripts without a subshell#63978
anonrig wants to merge 1 commit into
nodejs:mainfrom
anonrig:run-exec-no-subshell

Conversation

@anonrig

@anonrig anonrig commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

node --run <script> is a thin launcher: it never initializes V8/JS, it
just runs the package.json script command. Today it always spawns the
command through /bin/sh, keeps the (large) node process resident to
wait for the child, and then goes through the normal shutdown. That means
paying for a fork of the node process, an event-loop round-trip, and the
teardown of V8/ICU/OpenSSL static state β€” none of which the task runner
needs.

This PR makes the POSIX path hand the process off directly:

  • Simple commands (a plain program args invocation with no shell
    metacharacters) are run directly via execvp(), skipping /bin/sh
    entirely. The augmented node_modules/.bin PATH and the NODE_RUN_*
    variables are applied first so resolution and the child environment are
    unchanged. Positional arguments after -- are passed through verbatim.
  • Commands needing shell syntax (&&, |, $, globs, quotes, …)
    execve() /bin/sh -c <command>, still replacing the node process
    instead of forking a child and waiting.
  • If the exec fails (e.g. the command is a shell builtin, or not found)
    it falls back to the original uv_spawn() path, so behavior is
    preserved for every case.

ICU initialization is also skipped for --run, since the Intl APIs are
never reached.

Windows keeps the existing spawn-based behavior.

Benchmark

macOS, node --run of a node_modules/.bin binary, interleaved A/B,
600 iters Γ— 8 rounds:

before after
node --run <bin> 20.56 ms 14.77 ms (1.39Γ—, βˆ’28%)

The remaining time is essentially node's own load floor (node -v is
~12–13 ms of pre-main() dyld + V8/ICU static init), so this removes
most of the avoidable overhead between that floor and the script.

Behavior change worth a look

Because node now execs the target/shell directly, the script's exact
exit status is propagated
(e.g. exit 2 β†’ 2). The previous code
collapsed any non-zero status to 1. This matches npm run/shell
semantics; the existing test/parallel/test-node-run.js assertions
(codes 0 and 1) still pass, but flagging it explicitly.

Test plan

  • test/parallel/test-node-run.js and
    test/message/node_run_non_existent.js pass (cover
    node_modules/.bin resolution, NODE_RUN_* env vars, parent-dir
    package.json search, and verbatim positional args β€” all now via the
    fast path)
  • cpplint clean
  • CI on Windows/Linux/macOS

Made with Cursor

node --run previously spawned the script's command through /bin/sh and
kept the (large) node process resident to wait for the child, then went
through the normal process shutdown. For a launcher that never
initializes V8/JS this is mostly wasted work: forking the node process,
an event-loop round-trip and tearing down V8/ICU/OpenSSL static state.

On POSIX, replace this with a direct hand-off:

- Simple commands (a plain `program args` invocation with no shell
  metacharacters) are executed directly with execvp(), bypassing the
  shell entirely. Positional arguments are passed through verbatim.
- Anything that needs shell syntax execve()s /bin/sh, still replacing
  the node process instead of forking a child and waiting for it.
- If the exec fails (e.g. the command is a shell builtin or is not
  found) it falls back to the original uv_spawn() path.

ICU initialization is skipped for --run as well, since the Intl APIs are
never reached. Because node now execs the target directly, the script's
exact exit status is propagated instead of being collapsed to 1.

Benchmarks on macOS for `node --run` of a node_modules/.bin binary show
roughly a 1.4x reduction in wall-clock time versus the previous shell
spawn. Windows keeps the existing spawn-based behavior.

Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/startup

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run. labels Jun 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants