Skip to content

Install from the lockfile nvx checked, and take the sweep and the hang-up watch off the start and end of a command - #190

Merged
fstubner merged 4 commits into
mainfrom
perf/install-from-resolved-lockfile
Oct 8, 2026
Merged

fstubner merged 4 commits into
mainfrom
perf/install-from-resolved-lockfile

Conversation

@fstubner

@fstubner fstubner commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Three changes to what a shimmed command does at the start, in the middle and at the end. Each is its own commit, and the changelog entries are a fourth.

What changes

An npm install installs the lockfile nvx checked. An install that brings in new packages runs npm twice. The first run resolves the tree and nvx checks what it wrote. The second run resolved the tree again from the registry, so a version published between the runs was installed without a check, and the second run repeated the first run's work. It now starts from the lockfile the first run wrote, and npm asks the registry for nothing but the tarballs. A named install first makes each name exact from that lockfile (foo and foo@latest become foo@1.2.3), because npm asks the registry again about any package it is told to install.

The project's package.json and package-lock.json come out as npm leaves them. The lockfile is written into the project before the install and taken out again if npm did not write it itself (a run that stops early, package-lock=false in .npmrc, --no-save). npm 7 to 10 add the project itself to the lockfile of a bare install run from the project with another folder as its prefix, so that is removed before the lockfile is handed over.

The housekeeping sweep no longer holds up the end of a command. The age-based sweeps (package profiles, rescued logs, staged command copies) run when the last full look is an hour old, and sooner while one is working through a backlog. The sweep runs in the background and the command waits for it for 100 ms at most. One sweep runs at a time, under an operating system lock that is released if the sweep dies. Profile deletions stay on the command's own goroutine so exit never stops one halfway.

The hang-up watch no longer lists every process on Windows. The parent's pid comes from NtQueryInformationProcess, with the old snapshot as the fallback, and is no longer looked up a second time for the log line.

Which commands get the lockfile

Command Second run
npm install, npm i (bare) starts from the lockfile
npm install foo, foo@latest, -D foo, foo@1.2.3 name made exact, then starts from the lockfile
a version range, alias, URL or git source, a flag whose value is a separate token resolves again, as before (--verbose says why)
npm update, npm dedupe resolve again, as before. They ask for newer versions by design, and npm install would also run the project's own install scripts, which npm update does not
workspaces, local file: dependencies, -g no resolving run, unchanged
npm ci, or a lockfile that already matches package.json no resolving run, unchanged
pnpm, yarn, bun no resolving run, unchanged

Measurements

Medians of interleaved runs (arm order alternates each round), through the shim, contained. The machine and the Docker VM were busy with other work throughout (Windows CPU load 79 to 100 percent, container load average 8 to 15 on 4 CPUs), so absolute times are well above a quiet machine's.

Case Before After
Windows, 321-package project, no lockfile (5 runs) 88.2 s 64.8 s
Windows, 1 package (5) 8.7 s 8.8 s
Windows, node --version, stdin NUL (21) 0.221 s 0.101 s
Windows, node --version, stdin a pipe (21) 0.300 s 0.103 s
Windows, node -e 0 with a 12,000-file leftover home (5) 3.50 s 0.25 s
Linux, 321-package project (5) 89.8 s 61.4 s
Linux, 1 package (5) 2.47 s 2.39 s
Linux, node --version (21) 0.018 s 0.018 s
Linux, node -e 0 with the same leftover home (5) 0.46 s 0.18 s

Node alone takes 0.052 s on Windows. Spread of the five 321-package runs: Windows 68.6 to 98.3 s before and 45.4 to 100.5 s after, Linux 70.2 to 159.4 s before and 43.5 to 89.3 s after. Parent lookup alone: 19 to 27 ms for the snapshot against about 0.4 microseconds (benchmarks, 3 runs each).

Tests

  • npm_resolved_install_npm_test.go runs the real npm against a fake registry that publishes new versions after the resolving run and before the install. It compares package.json, package-lock.json and the installed versions with a plain npm run, and counts what npm asked the registry. A bare install and install -D app-dep@latest extra-dep pass with npm 7.24.2, 8.19.4, 9.9.4, 10.9.3, 10.9.9 and 11.11.0 on Windows, and 10.9.8 on Linux. With the handoff switched off (the second test) the same install gets the new versions, and the first test fails. Without the removal of the project link, the bare install fails with npm 10.
  • Unit tests for pinning, what is handed over and what is not, the stage-and-restore of the lockfile, and the project link removal.
  • Sweep tests: the age-based sweeps run once an hour and not while a backlog remains, one sweep at a time, and the command does not wait long for a slow sweep. Each fails on the old behaviour.
  • Hang-up watch tests: finding the parent does not take the snapshot, the kernel and snapshot agree, and the snapshot answers if the kernel does not.

gofmt, go vet for windows, linux and darwin, and go test ./internal/nvx pass on Windows. The same package passes with -race on Linux (Node 22).

Noticed and left alone

  • Existing tests such as TestAutomaticReclamationIsBounded call the real package sweep against the machine's own package folder on Windows. The new tests point it at an empty folder.
  • TestContainedRegistryConfigIgnoresTheUserNpmrcRegistries fails when npm_config_userconfig is set in the environment of the test run.
  • Sharing an npm cache between the two runs is not part of this change, and neither is a persistent cache for npx.

Arming the watch at the start of a shimmed command with a piped stdin took a
snapshot of every process on the machine, twice, to find the parent's pid.
Measured on Windows on 2026-10-08, one snapshot took 19 to 27 ms (three runs of
40 calls). The kernel keeps the parent's pid in the process's own record, and
NtQueryInformationProcess returns it in about 0.4 us a call (three runs of
2000). The snapshot stays as the fallback, and the pid found for the handle is
now reused for the log line instead of being looked up again.

Measured through the shim on 2026-10-08 on a busy machine, node --version took a
median of 0.300 s with stdin a pipe and 0.221 s with stdin from NUL (21
interleaved runs each). With this change it takes 0.103 s and 0.101 s.
reclaimStaleSandboxes ran after each shimmed command and the command did not
exit until it finished. Measured on Windows on 2026-10-08 with 638 package
profiles on the machine, the package-profile sweep alone took 83 to 91 ms a call
(three runs of 20). With one guest home of 12,000 files left by a killed
install, node -e 0 through the shim took 3.50 s (median of 5 interleaved runs)
because the command waited for the home to be deleted.

The sweeps that go by age (package profiles, rescued logs, staged command
copies) find something only once it is a week or two old. They now run when the
last full look is an hour old, and sooner while one of them is still working
through a backlog. The sweep runs in a goroutine and the command waits for it
for at most 100 ms, leaving what is unfinished to the next command. Package
profile deletions stay on the command's own goroutine, so the process is never
cut off in the middle of one.

One sweep runs at a time. The lock is the operating system's (flock on Unix,
LockFileEx on Windows), so a sweep that dies does not leave it held.

After this change node --version through the shim took a median of 0.101 s
against 0.221 s before (21 interleaved runs each, stdin from NUL, node alone
0.052 s), and node -e 0 with the leftover home took 0.25 s against 3.50 s.
nvx runs npm twice for an install that brings in new packages. The first run
resolves the tree and nvx checks what it wrote. The second run resolved the tree
again from the registry and installed that, so a version published between the
runs was installed without being checked, and the second run repeated the work
of the first.

The second run now starts from the lockfile the first one wrote. For a bare
install that is the lockfile as it stands. For an install that names packages,
each name is first made exact from the lockfile (foo and foo@latest become
foo@1.2.3), because npm asks the registry again about any package it is told to
install. A range, an alias, a URL or a git source is not pinned, nor is a flag
nvx does not know takes no value, and those installs run as they did. npm update
and npm dedupe run as they did, since they ask for newer versions by design.
Only a lockfile of hashed tarballs is handed over, the entries the checks hold
to the registry's record. With --verbose the run says why it resolved again.

The pass runs from the project with a folder inside it as its prefix. For an
install that names nothing, npm 7 to 10 then install the project into that folder
as a file: dependency, and the lockfile gains the project's own files, a link to
them and a dependency on them. Measured on 2026-10-08, npm 7.24.2, 8.19.4,
9.9.4, 10.9.3 and 10.9.9 do this and 11.11.0 does not. That is removed before
the lockfile is handed over. With 10.9.9 and the 321-package project the rest of
the lockfile was the same as one resolved on its own.

The lockfile is written into the project before the install and taken out
again when npm did not write it itself. That is what a run that stops before npm
saves leaves, and so do a project that sets package-lock=false and an install
with --no-save.

The new tests run the real npm against a fake registry that publishes new
versions between the two runs, and compare package.json, package-lock.json and
the installed versions with a plain npm run. A bare install and an install of a
name, a tag and -D pass with npm 7.24.2, 8.19.4, 9.9.4, 10.9.3, 10.9.9 and
11.11.0 on Windows, and with 10.9.8 on Linux under the race detector. With the
handoff switched off the same test installs the new versions and fails. Through
the contained shim on Windows with Node 22.23.3 (npm 10.9.9), the 321-package
project ended with the same package-lock.json as plain npm.

Measured on Windows on 2026-10-08 on a busy machine, median of 5 interleaved
runs through the contained shim. The 321-package project with no lockfile took
88.2 s and now takes 64.8 s. An install of one package took 8.7 s and takes
8.8 s. In a Linux container the 321-package project took 89.8 s and now takes
61.4 s, and one package took 2.47 s and takes 2.39 s.
Each entry carries the numbers measured for it on Windows and in a Linux
container on 2026-10-08.
@fstubner
fstubner force-pushed the perf/install-from-resolved-lockfile branch from e4b017b to f8e105f Compare October 8, 2026 01:08
@fstubner
fstubner merged commit 303f59b into main Oct 8, 2026
5 checks passed
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.

1 participant