Repository navigation
fix(release): run npm and the pack check's loads with --single-threaded - #609
Merged
nissa-seru merged 2 commits intoOct 20, 2026
Conversation
A pack-installs-alone test hung in a pre-merge test run. An npm install the script ran never exited, and the test timed out at 600 s. Run on a loaded host with Node 24.22.0 and npm 11.19.1, the test hung 6 times in 862 runs, each time in the script's npm install after npm had logged "info ok". Each hung npm's main thread was waiting to join one of its own V8 worker threads. That is nodejs/node#64274, where process.exit() joins V8's workers while one is parked waiting for a garbage collection only the main thread runs. npm 11 ends every command with process.exit(). The reproduction in the proposed fix, nodejs/node#66171, hung 11 of 50 times on that host, and 0 of 50 with --single-threaded, which disables V8's background tasks. scripts/single-threaded.mjs says how npm and Node are started: - npmCommand(cwd, path) takes the first executable npm in path's directories, each resolved against cwd, the directory the child runs in, which is where the child reads a relative or empty entry from. Where that npm's real path is a JavaScript file, as an npm installed with Node's is, it runs under this Node with --single-threaded, which NODE_OPTIONS refuses, by the path it was found at. Any other npm, such as a test's shim or a shell script, is started by name as before, and so is npm when PATH is unset. - The script asks once for npm pack, from its own directory, and once for npm install, from the install's. Its loads, the bin's load and a CommonJS bin's --check run under --single-threaded. The test starts the real npm the same way, in its own npm pack and in the shims that hand calls to it, and its stand-in for bun starts Node with the flag. Its own npm pack, which had no limit, now kills a hung npm at two minutes and fails, naming npm pack and its directory, rather than blocking the test's process past the runner's timeout. Tests: - npmCommand's walk: .js, .cjs and .mjs entries; a non-executable npm and a directory named npm passed over; a first npm that isn't JavaScript, a PATH with no npm, and an unset PATH, by name; relative and empty entries read from the child's directory. - npm starts under the flag where PATH's npm is a JavaScript file, and by name otherwise, in the script and in the tests' own npm pack. - npm pack, npm install and the tests' own npm pack each read a relative PATH entry from their own directory. - Through a NODE_OPTIONS preload: both loads, each bin's load, a CommonJS bin's --check and a JavaScript npm start under the flag, and each Node the script starts with its environment is one of them. - The tests' own npm pack fails at its limit, naming the command and its directory, and other failures stay npm's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Node 20 the flags test counted each bin's load twice (4, not 2) in CI's Node 20 jobs. Where Node lacks registerHooks, the script's load hook runs through module.register, on a thread of the process, and that thread runs the NODE_OPTIONS preload too. The preload now notes on the main thread alone, so each Node process is noted once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The packed-release check now starts npm with
--single-threadedwhere PATH's npm is a JavaScript file, as an npm installed with Node is, and starts each Node of its own the same way. On Node 24,process.exit()can wait forever on a V8 worker thread that is parked waiting for a garbage collection (nodejs/node#64274), and npm ends every command withprocess.exit().--single-threadedis V8's "disable the use of background tasks", so V8 has no background job to park. One of this check's tests hung that way. The test file's ownnpm packalso gets a time limit, which names the command when it's hit.Problem
test/pack-installs-alone.test.ts's test "a package imported only through another dependency's copy, or a dependency's peer, fails, since the install is linked" timed out at 600 s in a pre-merge test run on October 19. Annpm installthe script ran never exited.That test, run repeatedly on Linux with Node 24.22.0 and npm 11.19.1, on a 12-CPU host under load:
npm install --install-strategy=linked, after npm's debug log had writtenverbose exit 0andinfo ok.V8Workerthreads. That's howpthread_joinwaits. EachV8Workerwaited on a futex at an address of its own.That matches nodejs/node#64274, where
process.exit()joins V8's worker threads while one of them is parked waiting for a garbage collection that only the main thread runs. npm 11.19.1 ends withprocess.exit()(lib/cli/exit-handler.js:163).--single-threaded, the same reproduction hung 0 times in 50.CI runs the script on Node 24 in
ci.yml's andpublish.yml'spack-checkjobs. By our reading, an npm hung this way there fails the check when the script's 600 s limit kills it. The test's ownnpm packhad no limit, so by our reading a hang there would block the test file's process, and the runner's timeout couldn't end it.Changes
scripts/single-threaded.mjs(new):npmCommand(cwd, path)says how to start npm for a child started incwdwithpathas its PATH. It takes the first executablenpminpath's directories, each resolved againstcwd, which is where the child reads a relative or empty entry from.--single-threaded. That's an npm whose real path ends in.js,.cjsor.mjs, as with an npm installed with Node. The official Node 24.22.0 tarball linksbin/npmto../lib/node_modules/npm/bin/npm-cli.js. It runs asprocess.execPath --single-threaded <the path it was found at>, sinceNODE_OPTIONSrefuses the flag ("node: --single-threaded is not allowed in NODE_OPTIONS").scripts/pack-installs-alone.mjsstartsnpm packandnpm installthroughnpmCommand(), each from the directory it runs in. Its loads, the bin's load and a CommonJS bin's--checktake--single-threaded. bun is unchanged.test/pack-installs-alone.test.tsstarts the real npm the same way: in its ownnpm packand in the shims that hand calls to it. Its stand-in for bun starts Node with--single-threadedtoo.npm packhas a 120 s limit. An npm that hangs is killed there, and the test fails withnpm pack in <dir> didn't finish within 120 s. What npm started itself isn't killed with it, and, as with the script's ownnpm pack, the call waits on any of that still holding npm's output open.The flag costs time. In an interleaved run on the loaded host, the hung test's median went from 21.0 s to 24.6 s with npm under
--single-threaded(225 runs each).What each part is for
npmCommand: puts the flag on npm, the process that hangs.NODE_OPTIONScan't carry it.npmon PATH is the one a start by name runs.npm installthat's the install directory, not the script's. So the script asks once for each start.--check: each load ends withprocess.exit(0). The bin's load ends by throwing, and a comment on Hang at shutdown nodejs/node#64274 reports that an uncaught exception hangs the same way.--checktakes it so that every Node the script starts of its own runs the same way.SINGLE_THREADED: one name for the flag in the script and its module.realNpm(): the real npm for a shim to hand calls to. An npm started by name goes by its absolute path, so the shim, which is first on PATH, doesn't find itself, and a shim run in another directory still finds it.npmHandingOn(): an npm that runs a line of the test's, then hands the call to the real npm, for the tests that watch how npm starts.packInto(): the test's ownnpm pack, used by the test that hung.pathparameter, so a test can put its own npm first.process.exit(0)too, from the script's bun load, so it runs as the script's own loads run.process.execArgvto, which the new tests read. One test notes through aNODE_OPTIONS--requirepreload, which reaches every Node the script starts and runs under--checktoo. It notes on the main thread alone: where Node lacksregisterHooks, as Node 20 does, the script's load hook runs on a thread of the process, which runs the preload too. Its path is quoted inNODE_OPTIONS, so a directory with a space in its name still loads it, and the test puts it in one.NODE_OPTIONS, so the real npm's own processes aren't counted, whatever npm the host has.--single-threadedin assertions, so a change to the module's constant can't pass by changing the expectation with it.Tests
test/pack-installs-alone.test.ts:npmCommandwalks PATH as described, from a directory the test makes:npm-cli.js,.cjsand.mjsentries run under this Node, by the path they were found at;npmand a directory namednpmare passed over;npmthat isn't JavaScript, a PATH with nonpm, and an unset PATH go by name, the last even where the directory holds a JavaScriptnpm;npmin that directory, and taken where it does..cjsfile), the script'snpm packandnpm installand the test's ownnpm packeach start with--single-threaded. Where it's the same source under a name without an extension, each starts by name, with no flags.tools/directory andtoolsfirst on PATH, the script'snpm pack, which runs in the package, takes it under the flag, and itsnpm install, which runs in the install directory, takes the npm further on. The test's ownnpm pack, run in the package, takes it under the flag too.importand arequiretarget, an ES module bin and a CommonJS bin, checked with a noting preload inNODE_OPTIONSand a JavaScript npm first on PATH:--check, andnpm packandnpm installstart with--single-threaded, each the expected number of times;pack, the test's ownnpm packfails at its limit, naming the command and its directory, and the npm had reached its handler by then. Annpm packthat exits 3 fails with its own status instead.Runs at
0a38600b, on Linux:test/pack-installs-alone.test.ts.test/pack-installs-alone.test.tson Node 20.20.2, with npm 11.19.1: 26 tests, none failing. At26794a89, the PR's first head, its test 4 failed there as in CI's Node 20 jobs, counting each bin's load twice.scripts/single-threaded.mjsonmaind8cc6909's script, tests 2, 3 and 4 above fail, and the other 23 pass. Tests 1 and 5 pass there, since what they check is in the module and the test file.Not verified
--single-threadedpast these runs: 0 in 50 of the reproduction, and 0 in 225 of the hung test with npm under the flag, against 1 in 225 without it.no-changeloglabel).Written by Hilda-2681 (Claude Opus 5.5), contributing through nissa-seru. Ada-1017 (Claude Opus 5.5) made the design call. Our internal review read it, and Desmond-2579 (Claude Opus 5.5) read each round and approved it.
Description checked and posted by Lottie-2661 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.