Zoltan Kochan 1310ab53c4 perf: close repeat-install and warm-resolve gaps (lazy lockfile, pre-runtime fast path, 304 freshness renewal) (#12364)
* perf(cli): keep the repeat-install fast path off the lockfile parse and thread spawns

The "Already up to date" short-circuit decides from manifest mtimes
alone (mirroring upstream checkDepsStatus, which never reads the wanted
lockfile), yet pacquet parsed pnpm-lock.yaml eagerly in State::init
before the check ran — a multi-millisecond YAML parse on every no-op
install, scaling with lockfile size (babylon's 720 KB lockfile dominated
its repeat-install wall time).

pnpm-lock.yaml now loads through a LazyLockfile (OnceLock-backed) that
install forces only after the fast path has passed on the run; add /
update / remove / outdated / the pnpr path force it up front, keeping
their behavior unchanged. The repeat-install regenerate branch probes
for the file's existence instead of its parsed contents, so the
fast path stays mtime-cheap.

The rayon global pool is likewise no longer built eagerly at startup:
the worker count is published via RAYON_NUM_THREADS (set in fn main
while the process is still single-threaded) so the pool spawns lazily
on first parallel use — commands that never reach a parallel phase no
longer pay 2×CPUs thread spawns.

A corrupt lockfile now surfaces its parse error when the install
actually reads the file; an up-to-date project with an unreadable
lockfile reports "Already up to date" exactly as pnpm does.

* perf(cli): finish up-to-date installs before building the async runtime

A plain `pacquet install` that is already up to date now completes on
the main thread, before the tokio runtime, the rayon pool, the HTTP
client, or any install state exists. The new
`install_already_up_to_date` twin of the repeat-install short-circuit
reuses the exact same workspace discovery and
`check_optimistic_repeat_install` inputs as `Install::run`, and the
CLI invokes it from the (now synchronous) `main` after clap parsing.

Gates mirror everything that would make the full path behave
differently: `--frozen-lockfile` / `--lockfile-only`, a configured
pnpr server (that path never runs the optimistic check), `--recursive`
/ `--filter`, config dependencies, and pnpmfile updateConfig hooks
(both can mutate the config the check compares against). Any gate or
error falls through to the full install path, which re-runs the check
and reproduces failures with their established error shape; the
"Already up to date" + summary emissions are byte-identical.

Repeat-install instruction count on the vue fixture drops from ~203M
to ~41M retired instructions — within a rounding error of the
`pacquet --version` floor (~39M).

* perf(resolving): renew metadata-mirror freshness on 304 Not Modified

The minimumReleaseAge freshness shortcut treats a metadata mirror
younger than the cutoff as authoritative and resolves without touching
the network. But a 304 revalidation never rewrote the mirror file, so
its mtime froze at the last 200 response: once a cached packument grew
older than minimumReleaseAge (24h by default), every subsequent install
re-validated every package against the registry, forever.

A 304 proves the cached packument equals the registry's current
document, so the validation clock legitimately restarts at the
response: bump the mirror's mtime to now (fire-and-forget — a
read-only cache dir only costs the next install another conditional
request). Applied to both stacks: pnpm's pickPackage notModified
branch and pacquet's fetch_full_metadata_cached 304 path.

On a vue-fixture install with a stale cache, the second warm resolve
drops from ~2s (520 conditional requests) to ~250ms (zero requests).

* style(resolving): use clippy's preferred Duration units in the 304 mtime test

CI clippy denies duration_suboptimal_units; from_hours / from_mins
replace the hand-multiplied from_secs values.

* style(package-manager): use clippy's preferred Duration unit in the sync fast-path test

Same duration_suboptimal_units deny as the previous commit, one site
CI's clippy surfaced after it stopped at the first failing crate.

* fix(lockfile): address review — dir-addressed LazyLockfile, read-open 304 touch

LazyLockfile resolved pnpm-lock.yaml against the process cwd while the
CLI honours a canonicalized --dir without chdir, so the deferred load
and the existence probe could consult a different lockfile than the
rest of the install (which derives lockfile_path from the manifest's
directory). The lazy handle now carries the manifest's directory and
loads via load_wanted_from_dir; lockfile-disabled config gets an
explicit disabled() constructor. The pre-runtime fast path builds its
handle from the same directory, keeping verdict parity with
Install::run.

renew_mirror_freshness opened the mirror with append just to bump the
mtime; set_modified only needs a file handle plus ownership (futimens
semantics), so a plain read-open also covers mirrors whose mode
dropped write permission.

* test(integrated-benchmark): compare best-of-N samples in the slow-start proxy test

The test raced two single wall-clock samples, and a loaded CI runner
can inflate the ramped-vs-flat comparison in either direction (macOS
runner measured flat 318ms vs ramped 304ms against a ~66ms model).
Scheduler stalls only ever inflate a sample, while slow start's ramp
overhead is structural and survives in every sample — so the minimum
of several runs per side is the noise-resistant estimator.

* chore(deps): bump esbuild to 0.28.1 to clear GHSA-gv7w-rqvm-qjhr

The new advisory (install-module RCE via NPM_CONFIG_REGISTRY,
patched in 0.28.1) fails the audit gate. 0.28.1 was published within
the minimumReleaseAge window, so the patched version is excluded from
the age gate — the same mechanism pnpm audit --fix uses — including
its '@esbuild/*' platform packages, whose versions move in lockstep
with the root package.

* fix(lockfile): make the unloaded presence probe match the loader's absence rules

The loader treats an empty file and an env-only combined document as
an absent wanted lockfile (Ok(None)), but is_loaded_or_on_disk probed
bare file existence, so the repeat-install path could skip restoring a
semantically-missing pnpm-lock.yaml. The probe now reads the file and
checks the main document is non-empty (Lockfile::wanted_exists_in_dir)
— the loader's exact absence rules, still without the YAML parse.

* fix(cli): build the rayon pool after the fast-path gate instead of injecting env

Publishing the worker count through RAYON_NUM_THREADS leaked the
variable into every child process the install spawns — lifecycle
scripts, node probes, git — and pnpm exposes no such variable to
scripts. Build the global pool with ThreadPoolBuilder again, but only
once the repeat-install fast path has declined: real installs pay
exactly the cost they always did, the no-op path still spawns no
workers, and the process environment stays untouched (which also
drops the unsafe set_var and its single-threaded contract).

* fix(lockfile): treat only NotFound as absence in the presence probe

A permission or I/O failure reading pnpm-lock.yaml reported the file
as absent, which would send the repeat-install path into the
regenerate-on-missing branch — overwriting an existing lockfile it
merely could not read. Only NotFound counts as absent now; any other
read failure reports presence, and the real load surfaces the
underlying error when the contents are actually needed.

* fix(resolving): open the mirror write-capable for the 304 touch, read-only as fallback

Windows' set_modified requires write-attributes access on the handle,
so the read-only open silently failed there (caught by the Windows CI
run of a_304_renews_the_mirror_mtime). Append-mode open carries that
access; the read-only fallback still covers Unix mirrors whose mode
dropped write permission, where timestamp syscalls need ownership
rather than write access.

* fix(package-manager): never short-circuit partial installs as already up to date

add and remove mutate the manifest in memory and persist it only after
Install::run returns, so the on-disk mtimes the optimistic
repeat-install check reads still describe the pre-mutation project.
With a fresh workspace state, `pacquet add X` right after a clean
install reported "Already up to date", skipped the entire install,
and then saved a package.json declaring a dependency that was never
resolved, lockfiled, or materialized (self-healing on the next run,
which sees the newer manifest mtime).

Gate the short-circuit on is_full_install, mirroring upstream
installDeps calling checkDepsStatus only for the plain-install
mutation, never for installSome / uninstallSome. The new
partial_install_disables_optimistic_short_circuit test fails without
the gate.

The bug predates this PR (the KeepAll gate has carried add since the
optimistic path landed) — surfaced by CodeRabbit review on
pnpm/pnpm#12364.
2026-06-13 00:10:06 +02:00
2026-04-10 18:30:33 +02:00
2026-06-12 00:53:15 +02:00
2026-06-12 08:27:37 +02:00
2026-06-12 00:53:15 +02:00
2026-06-10 12:40:29 +02:00
2026-06-12 00:53:15 +02:00
2026-06-12 00:53:15 +02:00
2026-06-12 00:53:15 +02:00
2026-06-12 00:53:15 +02:00
2026-06-12 00:53:15 +02:00
2026-06-12 00:53:15 +02:00
2026-06-12 00:53:15 +02:00
2026-05-24 02:23:07 +02:00
2026-06-12 00:53:15 +02:00
2026-04-30 23:19:31 +02:00
2026-06-12 00:53:15 +02:00
2026-04-30 23:03:46 +02:00
2026-06-12 00:53:15 +02:00
2026-01-16 16:31:31 +01:00
2024-03-21 01:09:22 +01:00

简体中文 | 日本語 | 한국어 | Italiano | Português Brasileiro

pnpm

Fast, disk space efficient package manager:

  • Fast. Up to 2x faster than the alternatives (see benchmark).
  • Efficient. Files inside node_modules are linked from a single content-addressable storage.
  • Great for monorepos.
  • Strict. A package can access only dependencies that are specified in its package.json.
  • Deterministic. Has a lockfile called pnpm-lock.yaml.
  • Works as a Node.js version manager. See pnpm runtime.
  • Works everywhere. Supports Windows, Linux, and macOS.
  • Battle-tested. Used in production by teams of all sizes since 2016.
  • See the full feature comparison with npm and Yarn.

To quote the Rush team:

Microsoft uses pnpm in Rush repos with hundreds of projects and hundreds of PRs per day, and weve found it to be very fast and reliable.

npm version OpenCollective OpenCollective X Follow Stand With Ukraine

Platinum Sponsors

Bit OpenAI

Gold Sponsors

Sanity Discord Vite
SerpApi CodeRabbit Stackblitz
Workleap Nx

Silver Sponsors

Replit Cybozu BairesDev
devowl.io u|screen Leniolabs_
Depot Cerbos ⏱️ Time.now

Support this project by becoming a sponsor.

Background

pnpm uses a content-addressable filesystem to store all files from all module directories on a disk. When using npm, if you have 100 projects using lodash, you will have 100 copies of lodash on disk. With pnpm, lodash will be stored in a content-addressable storage, so:

  1. If you depend on different versions of lodash, only the files that differ are added to the store. If lodash has 100 files, and a new version has a change only in one of those files, pnpm update will only add 1 new file to the storage.
  2. All the files are saved in a single place on the disk. When packages are installed, their files are linked from that single place consuming no additional disk space. Linking is performed using either hard-links or reflinks (copy-on-write).

As a result, you save gigabytes of space on your disk and you have a lot faster installations! If you'd like more details about the unique node_modules structure that pnpm creates and why it works fine with the Node.js ecosystem, read this small article: Flat node_modules is not the only way.

💖 Like this project? Let people know with a tweet

Getting Started

Benchmark

pnpm is up to 2x faster than npm and Yarn classic. See all benchmarks here.

Benchmarks on an app with lots of dependencies:

License

MIT, except the pnpr/ directory, which is source-available under the PolyForm Shield License 1.0.0.

Description
No description provided
Readme MIT 345 MiB
Languages
Rust 55.9%
TypeScript 43.5%
JavaScript 0.5%