install: bind an unsatisfiable peer to the copy bun.lock nests under its dependent

Closed
RO
3mo ago
+95-7

Problem

  • A bun.lock written 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) commits react-json-view/react, react-json-view/react-dom (react@18.3.1, react-dom@18.3.1; react-json-view declares react: ^17 || ^16.3 || ^15.5.4, the root holds react@19.2.5), flux/react and react-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-view gets React 19), react-dom@18.3.1 has no dependent left, and a plain bun install on 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 (|| None for #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-dep wants no-deps@^2.0.0; one-dep hoists no-deps@1.0.1; strict-peer-dep/no-deps is 1.0.0). Rebuilt with main's src/install it fails (strict-peer-dep/node_modules/no-deps is not installed: received null, expected "1.0.0"); with this change it passes, --lockfile-only keeps the nested entry and --frozen-lockfile accepts the re-saved file.
    • activepieces at HEAD with this build: --frozen-lockfile exits 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.lock tests (#32182) pass. Full isolated-install / pnpm-lock-v9 runs on this box hit ConnectionRefused from 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.lock keys each package by the node_modules path 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_dependency cannot dedupe it and places it under the dependent, which is the dependent/name entry 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_logger rebuilds 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.