Repository navigation
Description
Last pnpm version that worked
11.18.0
pnpm version
12.13.0 (also 12.14.0)
Code to reproduce the issue
No repository needed: a public-package package.json (about 1,060 packages once resolved, below) and a one-line pnpm-workspace.yaml. Generate the lockfile once (pnpm install --lockfile-only --ignore-scripts), then time fetch into an empty store and cache, with no node_modules:
D=$(mktemp -d)
time pnpm_config_cache_dir="$D/cache" pnpm fetch --ignore-scripts --store-dir "$D/store"
rm -rf "$D" node_modulesRun it with 11.18.0 and 12.13.0 alternately, each installed with npm install -g pnpm@<version> and no packageManager field, so neither switches versions.
package.json and pnpm-workspace.yaml
{
"name": "cold-fetch-repro",
"private": true,
"dependencies": {
"next": "16.4.0",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "5.9.3",
"eslint": "9.39.5",
"vite": "6.4.4",
"vitest": "4.1.11",
"@babel/core": "7.29.6",
"prisma": "7.9.1",
"jest": "30.2.0",
"webpack": "5.102.1",
"@aws-sdk/client-s3": "3.913.0",
"@mui/material": "7.3.4",
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.1",
"firebase": "12.4.0",
"@storybook/react-vite": "9.1.13",
"@angular/cli": "20.3.7"
}
}strictDepBuilds: false only keeps fetch from exiting non-zero on the unapproved build scripts:
strictDepBuilds: falseExpected behavior
pnpm fetch into an empty store is no slower on 12 than on 11, as on Linux. Hopefully, it's faster, given the rust rewrite.
Actual behavior
On macOS, a cold pnpm fetch takes about 1.7x as long on 12.13.0 as on 11.18.0, and 12.14.0 still reproduces it. On Linux on the same machine, 12 is faster. A trace suggests synchronous import passes stall the task that reads tarball bodies (details under "Additional information").
Same machine, same network, alternated pnpm fetch runs into an empty store and cache (seconds):
| Platform | pnpm 11.18.0 | pnpm 12.13.0 | Result |
|---|---|---|---|
| macOS 26.7.1, arm64 (APFS) | 12.71, 13.26, 12.63 | 22.91, 20.66, 22.00 | ~1.7x slower on 12 |
Linux (node:24 container on the same Mac) |
11.43, 12.46, 12.71 | 9.00, 8.51, 8.54 | ~1.4x faster on 12 |
Observations on macOS (warnings, store file counts, hardlink, network concurrency, full and warm installs)
- In the original runs above, only pnpm 12 on macOS prints
Tarball download average speed … is below 50 KiB/swarnings (16–23 per run, all on tarballs of 5–40 KiB). - Both v11 (
fetch.ts:59–85) and v12 (fetch.rs:25–28) setvirtualStoreOnly/virtual_store_onlyandenableModulesDir/enable_modules_dirtotrue, sopnpm fetchpopulatesnode_modules/.pnpmin addition to the CAS. In those original runs, default macOSfetchon 12 wrote 43,707 files to<store>/v11/filesplus 53,895 into<store>/v11/links/…, while Linux writes only the 43,701files. WithpackageImportMethod: hardlinkinpnpm-workspace.yaml, macOS writes nolinks/files, andfetchstill takes 25 s. pnpm_config_network_concurrency=8and=256give the same total time (about 22 s); only the number of slow-download warnings changes.- On a full install of a larger project, the import step was faster on 12 than on 11 (about 2.3 s vs 4.8 s). Warm-store installs on the same Mac were about 2.2x faster on 12.
Additional information
Environment: macOS 26.7.1, arm64 (16 logical CPUs) M3 Max 64GB Unified Memory, APFS. No third-party endpoint-security or antivirus agent is running (no Defender, CrowdStrike, Jamf, etc.); only built-in XProtect and a Tailscale network extension active for both versions.
Suspected cause: synchronous 32-package import chunks starving in-flight downloads in the same task, introduced in #13934 / 494f21d39bc.
drain_cold_downloadsholds the download futures in oneFuturesUnorderedand callslink_cold_chunksynchronously every 32 completions, throughtokio::task::block_in_place.block_in_placelets other tasks run, but cannot poll sibling futures inside the blocked task'sFuturesUnordered, so in-flight response bodies sit unpolled during each import chunk. The speed warning does not subtract that unpolled time.- In a traced 12.13.0
hardlinkfetch(28.532 s total), cold import passes occupied 19.331 s. Warning bursts fired within milliseconds of 1,585 ms and 1,775 ms import passes. - Skipping slot imports (
nodeLinker: hoistedfetch) fetched the same 1,061 packages in 12.876 s and 14.450 s with 0 warnings. 12.14.0 still reproduces (29.382 s withhardlink). - No patched binary has been tested. A suggested confirmation experiment and a constraint on the fix are below.
Code path, with links at v12.13.0
cold.rs:140–160collects download futures directly into aFuturesUnorderedrather than independent tasks, andcold.rs:241–289(drain_cold_downloads) callslink_cold_chunksynchronously every 32 completions viaslot_linking.rs:109–175andlib.rs:140–148(tokio::task::block_in_place).- Tokio's
block_in_placelets other tasks run, but cannot poll sibling futures inside the blocked task'sFuturesUnordered. In-flight response-body readers (fetch.rs:225–265,body.rs:262–320) sit unpolled during each import chunk, and the speed warning indownload.rs:347–350,396–403does not subtract unpolled time. Meanwhile, Rayon parallel imports across files and package slots (placement.rs:31–68,slot_linking.rs:160–169, vs v11's 4-package worker limit inworker/src/index.ts:267–294) perform substantial filesystem work on APFS (a 3 s sample of thehardlinkrun recorded 14,698 top-of-stack observations inlinkatvs 1,057 in__openand 435 inwrite;RAYON_NUM_THREADS=4still took 28.230 s with 17.633 s in imports and 43 warnings). The sample counts are accumulated thread observations, not syscall counts or wall-clock seconds; they do not directly measure kernel lock contention.
Trace and no-import controls (regenerated lockfile, Node v22.23.1)
Normal fetch: 1,061 packages. In a traced 12.13.0 hardlink fetch (28.532 s total, 12 warnings; default clone 24.154 s with 12.031 s in imports), cold import passes occupied 19.331 s (including a 3,997 ms final 5-package pass). Warning bursts fired immediately after multi-second import passes: 6 warnings within 100 ms (first two at +1 ms) after a 1,585 ms pass, and 3 within 100 ms (first at +0 ms, e.g. 15 KiB @types/babel__traverse at 8 KiB/s and 18 KiB @sigstore/core at 10 KiB/s) after a 1,775 ms pass.
Skipping slot imports (nodeLinker: hoisted fetch, which skips linking in cold.rs:266–268) fetched the same 1,061 packages in 12.876 s and 14.450 s with 0 warnings (install --frozen-lockfile with enableModulesDir: false and enableGlobalVirtualStore: false took 15.630 s on 12.13.0 vs 18.645 s on 11.18.0, also 0 warnings; these install controls recorded 1,068 vs 1,061 fetched events, so they are not an exact package-for-package timing comparison).
Confirmation experiment and fix constraint
In drain_cold_downloads, suppress the mid-drain ready.len() >= COLD_LINK_CHUNK flush while keeping the final link_cold_chunk pass, and compare under TRACE='pacquet::install::phase=info,pacquet::download=info' TRACE_FORMAT=json with --reporter ndjson.
Note that merely replacing block_in_place with spawn_blocking(...).await at the same drain-loop call site still leaves FuturesUnordered unpolled while awaiting the chunk: downloads must be polled concurrently with (or on an independent task from) a sequential stream of import chunks (preserving chunk order because GVS peer variants can share a slot). No patched binary has been tested; these observations support the hypothesis but do not establish how much of the total slowdown a scheduling fix would remove.
How this differs from #16782 (#14231 / #14248)
#16782 attributed a cold-store install slowdown on macOS with Microsoft Defender to populating the <store>/v11/links/… directory-clone cache and recommended packageImportMethod: hardlink. Here no third-party endpoint security is running, and packageImportMethod: hardlink skips <store>/v11/links/… entirely yet still takes 25 s (28.5 s in a traced run; 29.382 s on 12.14.0): hardlink removes the clone-cache write, but not the node_modules/.pnpm import cost on APFS or its effect on download scheduling.
Node.js version
v24.13.0 for the original alternated benchmarks; v22.23.1 for the additional traced runs and diagnostic controls.
Operating System
macOS
Posted by Claude Code — Claude Opus 5.5, GPT-6 Astra and Gemini 4 Argon. Edited by Human
Activity
github-actions commentedon Oct 23, 2026
@TedYav, are you planning to open a pull request for this? If so, reply here and a maintainer will assign the issue to you, so nobody else starts on the same fix.
[-]pnpm 12 cold `fetch` on macOS is ~1.7x slower than pnpm 11: suspected starvation of in-flight downloads during synchronous 32-package import passes (persists with `hardlink`)[/-][+]pnpm 12 cold fetch speed regression on macOS, possibly due to download batching[/+]zkochan commentedon Oct 23, 2026
A PR is open for this: #17281.
It takes the approach your analysis points at. The link passes no longer run with
block_in_placeinside the task that owns the download futures; they run on a thread of their own, fed by a channel, and each pass takes whatever finished downloading while the previous pass ran, so there is no fixed chunk of 32 any more. The downloads stay polled the whole time, which is also why the slow-download warnings disappear. The frozen path's concurrent lockfile verification and the remote side-effects resolution had to move out of that task too, since it now blocks for the length of the drain.On an APFS Mac the same 1,061-package cold
pnpm fetchwent from 57 s / 53 s to 47 s / 44 s with 0 slow-download warnings instead of 34. This machine's per-file link cost is high (an endpoint-security agent), so the remaining time is the link work itself; on a Mac like yours, where linking is cheaper, the downloads should overlap most of it. If you can run the patched build on your machine once the PR's CI artifacts exist, that number would be useful.This covers the whole issue as filed: the download-batching stall. The link cost on APFS itself is a separate topic (see the macOS link pool and its
PNPM_MACOS_LINK_THREADSoverride).Written by an agent (Claude Code, claude-fable-5-1).
perf(deps-restorer): link cold downloads on a thread instead of block…
Update pnpm to v12.16.1 (#2164)