Skip to content

fix: start node once per call in the Windows npm/npx shims - #10084

Open
azchohfi wants to merge 1 commit into
npm:latestfrom
azchohfi:fix/windows-shims-single-node-start
Open

azchohfi wants to merge 1 commit into
npm:latestfrom
azchohfi:fix/windows-shims-single-node-start

Conversation

@azchohfi

@azchohfi azchohfi commented Oct 5, 2026 •

Copy link
Copy Markdown

Follows up #3088 / #7271.

Summary

On Windows, every npm/npx call starts node.exe more than once:

shim node.exe starts per call, before → after
bin/npm, bin/npx (Git Bash, MSYS2, Cygwin) 3 → 1
npm.cmd, npx.cmd 2 → 1
npm.ps1, npx.ps1 (-File, -Command, pipeline) 2 → 1

The extra starts:

  • bin/npm-prefix.js, started only to print the global prefix;
  • in the bash shim, also node -p 'require("path").dirname(process.execPath)'.

On Linux and macOS, npm is a symlink to npm-cli.js, which is one start.

This PR makes the same decision in the node process that then runs npm:

  • bin/npm-shim.js (new): node npm-shim.js <npm|npx> [args...].

    • It finds the global prefix exactly as npm-prefix.js did.
    • It picks <globalPrefix>/node_modules/npm/bin/{npm,npx}-cli.js if that exists, else its own.
    • It sets process.argv to what that file sees when run directly ([node, cli, ...args]), then requires it.
  • lib/cli/global-prefix.js (new): the @npmcli/config loading that was in npm-prefix.js. npm-prefix.js and npm-shim.js both use it.

    • It hands the config a copy of process.env. load() exports npm_config_* variables, and the npm that runs next in the same process, possibly another version from the global prefix, must see the environment it would see on its own.
    • Without the copy, running a global npm 11 from a 12 install printed npm warn Unknown env config "global-ignore-file". A test covers this.
  • bin/npm-prefix.js: a thin wrapper over the helper, with unchanged output. The bash shims still use it outside Git Bash and Cygwin (WSL), and anything else that runs it keeps working.

  • npm.cmd, npx.cmd: one line, "%NODE_EXE%" "%~dp0\node_modules\npm\bin\npm-shim.js" npm %*.

  • npm.ps1, npx.ps1: call npm-shim.js in the pipeline, -File and -Command branches. The branches themselves are unchanged.

  • bin/npm, bin/npx: when $OSTYPE is msys* or cygwin*, a fast path that starts node once:

    • ${0%/*} instead of `dirname`;
    • no uname;
    • no -p execPath round trip, since msys converts the script path for native programs;
    • Cygwin keeps its cygpath -w.

    Everything else, including WSL running the Windows install, runs the existing script unchanged.

Numbers

  • Setup:
    • Node.js 26.7.0 official win-x64 zip, Windows 11, i9-14900K.
    • Two copies that differ only in npm: npm pack of latest (b317f16) against npm pack of this branch, so the "after" column is exactly this PR.
    • 20 interleaved passes; medians in ms; the paired difference's 95% CI.
    • Dev Drive.
  • Load: quiet machine (process-spawn canary 1.04–1.05× a clean machine). An earlier run on a shared machine (canary 1.36–1.48) gave the same savings within their intervals. The last column is an earlier quiet-machine run on npm 11.19.0, whose shims are identical.
before after saved node npm-cli.js directly saved, npm 11.19.0
Git Bash npm --version 481 232 240 (226–291) 114 222 (214–266)
Git Bash npx --no-install tsc --version 898 661 227 (206–254) 542 215 (203–231)
npm.cmd --version 246 140 107 (103–110) 114 91 (90–94)
npx.cmd --no-install tsc --version 662 567 99 (85–119) 542 —
npm.ps1 --version 505 419 89 (82–99) 114 78 (72–83)
npx.ps1 --no-install tsc --version 934 838 92 (74–110) 542 —
  • Each call saves one node start: about 100 ms on this machine, more under Git Bash, where the subshells go too.
  • What remains above node npm-cli.js is the shell itself: bash and cmd start-up, and about 300 ms for pwsh.

Behaviour

The aim is identical behaviour. Checked by running shipped and patched side by side and comparing exit code and normalised output:

  • 30 edge cases:

    • shells: Git Bash, cmd, pwsh -File, pwsh -Command, a pwsh pipeline, and bash in WSL running the Windows install;
    • arguments with spaces, quotes, $ and empty strings;
    • exit codes, and stdin;
    • npm_execpath, npm_command and npm_config_user_agent in scripts;
    • nested npm/npx from scripts, npm exec, npm ls, an unknown command, a cwd with spaces, invocation by relative path;
    • the global-prefix redirect for npm and npx (npm_config_prefix pointing at a different npm version).

    All 30 identical.

  • 32 real-world command lines (npm run build/lint/test, npx tsc, npx vitest run, npx next build, npx astro build, npx eslint, npx oxlint), run in order on fresh projects through Git Bash: all identical.

  • Node.js starts per call, counted with a NODE_OPTIONS=--require logger: as in the table at the top.

Two intentional differences:

  • npm.cmd/npx.cmd when the prefix cannot be determined (config fails to load) now exit 1 with "Could not determine Node.js install directory", as npm.ps1 and bin/npm already did. Before, the cmd shims silently ran the bundled npm.
  • That message now goes to stderr from node instead of PowerShell's Write-Host.

Tests

  • test/bin/npm-shim.js (new), with npm-cli.js/npx-cli.js in a temporary global prefix that records what it was run with. Covers:
    • prefix found, for npm and for npx;
    • prefix without npm;
    • a prefix that has only npx-cli.js (no npm);
    • config load failure (exit 1 and message);
    • the exact process.argv the CLI sees, including arguments with spaces and empty strings.
  • test/bin/npm-prefix.js (new): prints the prefix; exits 1 on failure.
  • test/lib/cli/global-prefix.js (new):
    • the config options;
    • rejection;
    • the real loader honouring npm_config_prefix;
    • process.env untouched (this test fails without the env copy).
  • test/bin/windows-shims.js: the fixture now ships the real npm-shim.js and fakes only lib/cli/global-prefix.js, which returns the fixture's global-prefix. The local npm-cli.js/npx-cli.js still throw if called, so the redirect is still asserted, now through the real shim code. The fake npm-prefix.js stays for the WSL path. The "shim contents" npm/npx diff check passes unchanged (only m→x).

On Windows 11 (Node 26.7.0, Git for Windows 2.55, PowerShell 7 and 5.1, WSL2 Ubuntu):

  • node . run test -- test/bin/windows-shims.js test/bin/npm-shim.js test/bin/npm-prefix.js test/lib/cli/global-prefix.js:
    • cmd, Windows PowerShell, Git Bash, Git usr/bin bash and WSL bash all pass (Cygwin not installed);
    • the new files are at 100% coverage.
  • node . run test (full suite): 124 of 127 files pass, coverage 100%.
    • The 3 failures, with identical subtests, also fail on unmodified latest on this machine, for environmental reasons:
      • windows-shims.js › pwsh › npm test: the local PowerShell profile prints a dotnet-suggest notice on stdout. It passes with -NoProfile, before and after this change.
      • publish.js (3 subtests) and stage/index.js (1): their lifecycle scripts call touch, which cmd.exe can't find here.
  • node . run lint passes.

Relation to #10058 (open, draft)

#10058 changes the same -Command branch of npm.ps1/npx.ps1: ProcessStartInfo instead of Invoke-Expression. It also changes the same test fixture, nesting it under a $(throw ...) directory. The two compose:

References

The Windows shims started node.exe once to print the global prefix
(npm-prefix.js) and again to run npm; the bash shim, used by Git Bash,
MSYS2 and Cygwin, started it a third time to turn its own directory into
a Windows path. On Linux and macOS, npm is a symlink to npm-cli.js: one
node start.

bin/npm-shim.js makes the same choice (the npm in the global prefix if
there is one, else this one) in the node process that then runs npm,
and gives the chosen cli the argv it would see if run directly.
npm.cmd, npx.cmd, npm.ps1 and npx.ps1 call it instead of npm-prefix.js;
the .ps1 pipeline, -File and -Command branches are kept. bin/npm and
bin/npx take a fast path under msys and Cygwin that calls it without
subshells; elsewhere (WSL running the Windows install) they are
unchanged.

The config loading moves from npm-prefix.js to lib/cli/global-prefix.js,
which npm-prefix.js and npm-shim.js share, so the shims' test fixture
can steer the prefix while the real npm-shim.js runs. It loads config
with a copy of the environment: load() exports npm_config_* variables,
and the npm that runs next in the same process, possibly another
version, must see the environment it would have seen on its own.
@azchohfi

Copy link
Copy Markdown
Author

Friendly ping on this one and the two smaller follow-ups, #10096 and #10097. None of them has had a review yet, and since these are my first PRs here the CI workflows are still waiting for a maintainer to approve them. They're independent of each other, so they can be looked at in any order. Happy to rebase, split anything up or answer questions if that makes review easier.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants