install: bind a multi-path package's edges from the bun.lock row that prints the binding
Draft
RO
1mo ago+329+329+012345678901234567890123456789-75-75-01234567890123456789
Problem
- A package printed at more than one path in
bun.lockhas one set of edges, butparse_into_binary_lockfilebinds them once per printed row and every row overwrites the previous one (src/install/lockfile/bun.lock.rs, thefor row in pkg_rowsloop:pkg_map.getreturns the same package for each row becauseappend_package_dedupededupes equal resolutions, andmap_dep_to_pkgassigns unconditionally). Rows walk up from different paths, so for a peer they can find different copies, and the edge ended up on whichever row the writer printed last (rows are printed by depth, then path). *peer:bun installof a project where the peer's dependent is hoisted to the root and also nested under a parent writes a file with the edge's own target at the root (peer-target= 1.0.0, held by nothing else) and the parent's other version next to the nested printing. Loading binds the edge from the root row to 1.0.0, then from the deeper row to 1.1.0. Nothing holds 1.0.0 any more, sobun install --frozen-lockfileon the file bun just wrote fails witherror: lockfile had changes, but lockfile is frozen, and a plain install rewrites it (root entry flipped to 1.1.0, nested entry dropped). With the isolated linker a reinstall relinks the root-level dependent to the store entry keyed by the deeper copy (star-peer@1.0.0+<hash>changes) instead of the one the writing install linked.- Copy recorded next to the dependent (
<parent>/dup/no-deps, what the hoister prints when it cannot dedupe the edge onto anything above): read back only when that parent sorts after the other one; otherwise the other row's walk to the root's copy wins and the re-save drops the record. On main this shape needs an optional peer (ranged required peers are bound by version); #38892 makes it the normal shape for a required peer nothing in the file satisfies, and its "two paths" test pins the one order that works.
Fix
PkgMap::find_resolutionalso returns where the hit was (FoundAt: the package's own path, an enclosing path, or the top-level entry). The package rows loop keeps a per-edgeRowEvidenceand a row only overwrites a binding when its find ranks at least as high: own entry > top-level entry (for edges other than optional peers) > anything else. Equal ranks keep the last row, so a package printed once binds exactly as before; edges bound by version (resolve_peer_dep_version_based) are unchanged, every row writes the same id.- Why these ranks:
Tree::hoist_dependencyputs the package an edge is bound to in exactly two places, nested in the dependent's own path (a copy above blocks it) or at the root (nothing above holds the name). A copy at an enclosing path in between was placed for some other package's edge, and a row only walks into it because the edge deduped onto it (a peer the copy satisfies), which says nothing about the binding. So when rows disagree, the own entry is the binding and the root entry is the only walked copy that can be; a file whose rows agree is unaffected whatever the ranks. A file that was stable in every order before is still stable: the pick is always one of the rows' finds. - Why optional peers keep last-row-wins between walked finds: the hoister rebinds an optional peer to whatever copy each placement dedupes onto (
HoistDependencyResult::Rebind, #37426), both in the hoist at the end of the load and again in the save. Preferring the root copy for them makes the load's tree differ from the save's in a file a fresh install writes (root holds an out-of-range copy, the last-sorted parent holds an in-range one): the load dedupes the first placement onto the root copy, the second placement rebinds the edge, and the save then nests the rebound copy under the first placement, so--frozen-lockfilefails. Checked by building that variant; the third test below pins it. Own entries still win for optional peers: an entry in the dependent's own path is never a copy another placement walks into. - Not covered: a
*peer of a package printed both inside a bundled dependency and outside it. The hoist root of a bundled subtree is the bundle root, which the unbounded walk those edges use does not tell apart from an enclosing path, so such a find keeps ranking as a walk, i.e. last row wins as before. - Verified:
test/cli/install/bun-lock.test.ts,describe("loading bun.lock binds the edges of a package printed at several paths"): the*shape end to end against an in-process registry (fresh install, remove the root dependency on the peer,--frozen-lockfilemust accept the file bun wrote and install 1.0.0 at the root and 1.1.0 next tomid); the recorded copy under either parent, hand-written (both orders must re-save to the same entries, pass--frozen-lockfile --dry-runand be a fixed point); and an optional peer with nothing recorded (must keep passing--frozen-lockfile --dry-run). With the released build the first fails on the frozen install and the record-under-a-parentcase fails (record dropped); the other two pass by construction. The unmet-peer tests' registry helper moved to module scope for the first test, otherwise unchanged.- Debug build: bun-lock (44), isolated-install (76), bun-install-registry (243), bun-workspaces (73), bun-add (70), bun-dedupe (76), bun-remove (14), frozen-lockfile-pruned (101), migration/migrate (125), migration/pnpm-lock-v9 (81; the peer block re-run on its own after cascading 5s timeouts), hoist, lockfile-only, lockfile-version-2, bun-lockb, migrate-bun-lockb-v2, bun-pm-why, public-hoist-pattern, config-version, test-dev-peer-dependency-priority, frozen-lockfile-missing-workspace. bun-prune and bun-update only had 5-second per-test timeouts on a heavily loaded host (every failing test was at or over 5 s; samples pass run on their own); migration/complex-workspace needs git network access here.
cargo clippy -p bun_installandrustfmt --checkclean.
- Interaction with open PRs: #38892's "two paths" shape now loads the record in either order, so the order caveat in its test comment can go; #38837 (bundled peers take the walk) always finds them in the package's own path, which is the top rank; #38767 rebinds version-bound peers only and does not touch the walked edges this changes.
Background
bun.locklists packages by their path in the hoistednode_moduleslayout ("a","a/b"). A package is printed at several paths when another version of it holds the slot above some of its dependents: each such dependent gets the package printed again next to it, but the loader dedupes the rows onto one package, which has one dependency list. The file has no field for what an edge resolved to; loading a non-workspace package's edge probes<row path>/<name>, then each enclosing path, then<name>at the top level (find_resolution), except for required peers with a range, which are bound by version (#32182).- A peer edge is satisfied by whatever copy is next to or above the dependent. When
Tree::hoist_dependencybuilds the tree, a peer dedupes onto a copy found above it when the copy satisfies the range (or is a root dependency); only when it cannot is the package the edge is bound to printed next to the dependent, and when no copy exists above at all it is printed at the root. For an optional peer the dedupe also moves the binding to that copy (Rebind, #37426); required peers keep their binding through hoists and through the clean that precedes a save (Package::clone), and a required peer edge keeps its target in the file even when nothing else depends on it, which is how a version ends up held only by such an edge after the dependency that brought it in leavespackage.json. --frozen-lockfilebuilds the tree from the loaded bindings, builds it again from a cleaned copy of the lockfile, and rejects the file when the two trees differ (Lockfile::eql);--lockfile-onlyprints the second tree.--frozen-lockfile --dry-runperforms that comparison without fetching anything, which is what the hand-written tests use.