Repository navigation
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
Conversation
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
force-pushed
the
perf/install-from-resolved-lockfile
branch
from
October 8, 2026 01:08
e4b017b to
f8e105f
Compare
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.
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 (
fooandfoo@latestbecomefoo@1.2.3), because npm asks the registry again about any package it is told to install.The project's
package.jsonandpackage-lock.jsoncome 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=falsein.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
npm install,npm i(bare)npm install foo,foo@latest,-D foo,foo@1.2.3--verbosesays why)npm update,npm dedupenpm installwould also run the project's own install scripts, whichnpm updatedoes notfile:dependencies,-gnpm ci, or a lockfile that already matchespackage.jsonMeasurements
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.
node --version, stdin NUL (21)node --version, stdin a pipe (21)node -e 0with a 12,000-file leftover home (5)node --version(21)node -e 0with the same leftover home (5)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.goruns the real npm against a fake registry that publishes new versions after the resolving run and before the install. It comparespackage.json,package-lock.jsonand the installed versions with a plain npm run, and counts what npm asked the registry. A bare install andinstall -D app-dep@latest extra-deppass 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.gofmt,go vetfor windows, linux and darwin, andgo test ./internal/nvxpass on Windows. The same package passes with-raceon Linux (Node 22).Noticed and left alone
TestAutomaticReclamationIsBoundedcall the real package sweep against the machine's own package folder on Windows. The new tests point it at an empty folder.TestContainedRegistryConfigIgnoresTheUserNpmrcRegistriesfails whennpm_config_userconfigis set in the environment of the test run.npx.