install: bind an unsatisfiable peer to the copy bun.lock nests under its dependent
Closed
RO
3mo ago+95+95+01234567890123456789-7-7-0123456789
Problem
- A
bun.lockwritten by bun 1.3.x can nest a copy of a peer under a dependent whose peer range no version in the file satisfies. activepieces/activepieces (HEAD, also at ec27c2f2) commitsreact-json-view/react,react-json-view/react-dom(react@18.3.1,react-dom@18.3.1;react-json-viewdeclaresreact: ^17 || ^16.3 || ^15.5.4, the root holdsreact@19.2.5),flux/reactandreact-json-view/react-dom/scheduler. 1.3.14 installs that file as written and re-saves it byte-identically. - On main, loading the file binds those peers to
react@19.2.5/react-dom@19.2.5:resolve_peer_dep_version_based(src/install/lockfile/bun.lock.rs, from #32182) binds a ranged peer by version and, when nothing satisfies the range, takes the highest candidate. The nested copies are then not installed (react-json-viewgets React 19),react-dom@18.3.1has no dependent left, and a plainbun installon the untouched checkout deletes the four entries (Clean lockfile: 4069 -> 4068). Until #38870 this also failed--frozen-lockfile; now the frozen install succeeds but lays out a different tree than 1.3.14 does from the same file. Part of the 1.3 to 1.4 lockfile ledger (the other four repos there are the optional-peer case: #38853 for frozen installs, #39002 for the rest).
Fix
- When no candidate satisfies the range, bind the entry printed directly inside the dependent's own
node_modules(PkgMap::get_nested, passed to the helper as a lazy closure) before falling back to the highest candidate. The hoister nests a peer under its dependent exactly when nothing enclosing satisfies it, so that entry is the binding the file records, and it is what node resolves from the dependent. Binding it makes the file a fixed point: the copy is placed nested again, installed, and written back. - Files written by this version bind the same as before: a fresh resolve puts the fallback candidate there or nothing. Satisfied ranges, optional peers,
*peers and overridden names are untouched. The root loop passes|| None(root-level entries are hoisted ones, not bindings); the pnpm migration passes|| None(no printed tree yet). One hash lookup, only on the nothing-satisfies path. - #38767 and #38768 add callers of this helper; whichever lands second passes the extra argument (
|| Nonefor #38768's placed-package scan; for #38767's pass over already bound edges the natural value is the edge's current target, so a recorded nothing-satisfies binding is kept rather than re-derived). - Verified:
test/cli/install/bun-lock.test.ts, "installs and keeps the copy an older lockfile nests under a dependent whose peer range nothing satisfies": a hand-written 1.3-shaped lockfile on the registry fixtures (strict-peer-depwantsno-deps@^2.0.0;one-dephoistsno-deps@1.0.1;strict-peer-dep/no-depsis1.0.0). Rebuilt with main'ssrc/installit fails (strict-peer-dep/node_modules/no-depsis not installed: receivednull, expected"1.0.0"); with this change it passes,--lockfile-onlykeeps the nested entry and--frozen-lockfileaccepts the re-saved file.- activepieces at HEAD with this build:
--frozen-lockfileexits 0 and a plain install no longer touches the four entries (the remaining diff there is the optional-peer case above). - Debug build: bun-lock (41), migration/migrate (125, snapshots unchanged), the pnpm-lock-v9 peer group, and isolated-install's
across installs from bun.locktests (#32182) pass. Full isolated-install / pnpm-lock-v9 runs on this box hitConnectionRefusedfrom the test registry under load (load average above 200) on varying tests; the same tests pass when re-run. isolated-install's "ranged peer dependency resolution is stable" is intermittent on its fresh-install assertion (line 1202, before any lockfile is loaded; the arrival-order dependence #38767 describes), with or without this change.
Background
bun.lockkeys each package by thenode_modulespath the hoister gave it and stores no target for peer edges. On load, ordinary edges are re-bound by walking up those paths; since #32182 required ranged peers are re-bound by version instead, because a peer whose range is satisfied by a version hoisted above it has no path of its own, and the walk rebound it to that version (re-keying isolated store entries on every warm install).- When a peer's bound target is not satisfied by anything enclosing,
Tree::hoist_dependencycannot dedupe it and places it under the dependent, which is thedependent/nameentry this change reads back. 1.3.x bound every peer by path, so it produced and preserved such entries whenever a project's peer ranges fell behind the versions it installed. clean_with_loggerrebuilds the lockfile from the packages the edges reach; a package no edge points at is dropped, which is how rebinding the peer deletes the nested copy on the next save.