Skip to content

fix(release): run npm and the pack check's loads with --single-threaded - #609

Merged
nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/pack-installs-alone-single-threaded
Oct 20, 2026
Merged

nissa-seru merged 2 commits into
anima-research:mainfrom
nissa-seru:fix/pack-installs-alone-single-threaded

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The packed-release check now starts npm with --single-threaded where 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 with process.exit(). --single-threaded is 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 own npm pack also 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. An npm install the 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:

  • It hung 6 times in 862 runs. Each hang was in the script's npm install --install-strategy=linked, after npm's debug log had written verbose exit 0 and info ok.
  • In the 3 hangs whose threads were captured, npm's main thread waited in a futex, with no timeout, on the thread ID of one of its own V8Worker threads. That's how pthread_join waits. Each V8Worker waited 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 with process.exit() (lib/cli/exit-handler.js:163).

CI runs the script on Node 24 in ci.yml's and publish.yml's pack-check jobs. By our reading, an npm hung this way there fails the check when the script's 600 s limit kills it. The test's own npm pack had 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 in cwd with path as its PATH. It takes the first executable npm in path's directories, each resolved against cwd, which is where the child reads a relative or empty entry from.
    • A JavaScript npm runs under this Node with --single-threaded. That's an npm whose real path ends in .js, .cjs or .mjs, as with an npm installed with Node. The official Node 24.22.0 tarball links bin/npm to ../lib/node_modules/npm/bin/npm-cli.js. It runs as process.execPath --single-threaded <the path it was found at>, since NODE_OPTIONS refuses the flag ("node: --single-threaded is not allowed in NODE_OPTIONS").
    • Any other npm starts by name, as before. That covers a shell script or an extensionless shim, and npm when PATH is unset.
  • scripts/pack-installs-alone.mjs starts npm pack and npm install through npmCommand(), each from the directory it runs in. Its loads, the bin's load and a CommonJS bin's --check take --single-threaded. bun is unchanged.
  • test/pack-installs-alone.test.ts starts the real npm the same way: in its own npm pack and in the shims that hand calls to it. Its stand-in for bun starts Node with --single-threaded too.
    • Its own npm pack has a 120 s limit. An npm that hangs is killed there, and the test fails with npm pack in <dir> didn't finish within 120 s. What npm started itself isn't killed with it, and, as with the script's own npm 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_OPTIONS can't carry it.
    • The first executable npm on PATH is the one a start by name runs.
    • Resolving each entry against the child's directory: a child reads a relative or empty PATH entry from its own working directory, and for npm install that's the install directory, not the script's. So the script asks once for each start.
    • Running npm by the path it was found at, not its real path, is how a start by name runs it.
    • Starting by name otherwise keeps the tests' fake npms, one of them a shell script, and any npm that isn't a JavaScript file working as they did. With PATH unset, a child looks on its default path, so npm starts by name there too.
  • The flag on the loads, the bin's load and a CommonJS bin's --check: each load ends with process.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. --check takes 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.
  • In the test:
    • 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 own npm pack, used by the test that hung.
      • The limit, with the command and its directory in the message, so a hang says where it was. Any other failure is rethrown as npm's own.
      • SIGKILL at the limit, so an npm that ignores SIGTERM still stops.
      • The path parameter, so a test can put its own npm first.
    • The bun stand-in's flag: its Node ends with process.exit(0) too, from the script's bun load, so it runs as the script's own loads run.
    • The flag notes: a file that each npm or Node a test starts adds its process.execArgv to, which the new tests read. One test notes through a NODE_OPTIONS --require preload, which reaches every Node the script starts and runs under --check too. It notes on the main thread alone: where Node lacks registerHooks, 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 in NODE_OPTIONS, so a directory with a space in its name still loads it, and the test puts it in one.
    • Taking that test's first note as the script's own, since the preload writes it before the script starts anything, and checking its main file, so every later note is a Node the script started.
    • That test's count of the Nodes the script starts for its package, each of exactly one kind, so a start the script gains on those paths without the flag fails it, as long as the start keeps the script's environment, where the preload is set. Its npm hands calls on without NODE_OPTIONS, so the real npm's own processes aren't counted, whatever npm the host has.
    • The literal --single-threaded in assertions, so a change to the module's constant can't pass by changing the expectation with it.
    • The ready marker in the limit test, which shows the npm was already ignoring SIGTERM when the limit came, so only the limit's SIGKILL could stop it in time.

Tests

test/pack-installs-alone.test.ts:

  1. npmCommand walks PATH as described, from a directory the test makes:
    • npm-cli.js, .cjs and .mjs entries run under this Node, by the path they were found at;
    • a non-executable npm and a directory named npm are passed over;
    • a first npm that isn't JavaScript, a PATH with no npm, and an unset PATH go by name, the last even where the directory holds a JavaScript npm;
    • a relative or empty entry is passed over where it holds no npm in that directory, and taken where it does.
  2. Where PATH's npm is a JavaScript file (a link to a .cjs file), the script's npm pack and npm install and the test's own npm pack each start with --single-threaded. Where it's the same source under a name without an extension, each starts by name, with no flags.
  3. With a JavaScript npm in the package's tools/ directory and tools first on PATH, the script's npm pack, which runs in the package, takes it under the flag, and its npm install, which runs in the install directory, takes the npm further on. The test's own npm pack, run in the package, takes it under the flag too.
  4. A package with an import and a require target, an ES module bin and a CommonJS bin, checked with a noting preload in NODE_OPTIONS and a JavaScript npm first on PATH:
    • both loads, each bin's load, the CommonJS bin's --check, and npm pack and npm install start with --single-threaded, each the expected number of times;
    • each Node the script starts for the package is of exactly one of those kinds, so they are all of them.
  5. With an npm on PATH that ignores SIGTERM and hangs on pack, the test's own npm pack fails at its limit, naming the command and its directory, and the npm had reached its handler by then. An npm pack that exits 3 fails with its own status instead.

Runs at 0a38600b, on Linux:

  • The whole suite at this head, from a clean checkout, on Node 24.22.0 with npm 11.19.1: typecheck passes, and 256 files run 5,616 tests, 5,574 passing, 42 skipped and none failing. That includes the 26 tests in test/pack-installs-alone.test.ts.
  • test/pack-installs-alone.test.ts on Node 20.20.2, with npm 11.19.1: 26 tests, none failing. At 26794a89, the PR's first head, its test 4 failed there as in CI's Node 20 jobs, counting each bin's load twice.
  • Red first: with this head's test file and scripts/single-threaded.mjs on main d8cc6909'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

  • macOS, where CI runs the tests.
  • Windows, where nothing runs the script.
  • That no hang is left under --single-threaded past 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.

  • This change is internal-only: it changes the release check and its test, and nothing the package ships (the no-changelog label).

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-seru account.

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>
@nissa-seru nissa-seru added the no-changelog Exempts this PR from the changelog check (internal-only change) label Oct 20, 2026
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>
@nissa-seru
nissa-seru merged commit 073f8be into anima-research:main Oct 20, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-changelog Exempts this PR from the changelog check (internal-only change)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant