install: bind unbound required peers while hoisting, like optional peers - #39343
install: bind unbound required peers while hoisting, like optional peers#39343robobun wants to merge 1 commit into
Conversation
The hoister already walks the tree for an unresolved optional peer and binds it to the copy placed next to or above its dependent. Required peers that arrive unbound (a pnpm-lock.yaml or yarn.lock migration has nothing recorded for a peer the other tool left unmet) were skipped instead, so the install that migrated keyed the dependent's isolated store entry without the peer, while every later install, which loads bun.lock and binds the edge by the same tree walk, keyed it with the peer and relinked. Walk for every unbound peer, so the migrating install ends up with the bindings a reload produces.
|
Warning Review limit reached
Next review available in: 5 minutes Limit details: You’ve used all 5 included reviews currently available under your plan. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
Updated 2:44 AM PT - Aug 16th, 2026
✅ @robobun, your commit 9605fb676d23dd149554ae7761bf7d13e3cac5f8 passed in 🧪 To try this PR locally: bunx bun-pr 39343That installs a local version of the PR into your bun-39343 --bun |
|
Status: reproduced and fixed; waiting on CI and review. Reproduced with a debug build of main (aec33f5) using the registry fixtures: The new tests in |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The one-line guard widening in the hoister is well-argued and thoroughly tested, but it changes peer-binding semantics in Tree::process_subtree — the path every lockfile producer runs — so a human look at the resolve-loop and version-based-binding interactions is still worthwhile.
Checked the widened is_peer() guard against the Resolve/ResolveLater/ResolveReplace arms and the relaxed debug_assert!s in hoist_dependency — all reachable states still hold under is_peer().
Checked Lockfile::resolve termination: late_bound_peer is set only in ResolveReplace, which fills a previously-invalid slot, so the loop still converges.
Checked the METHOD == Filter path: the early pkg_id == invalid_package_id → continue there fires before this branch, so the Filter tree is unchanged.
Checked the pending_optional_peers → pending_peers rename — no other references in the tree.
Extended reasoning...
Overview
The PR changes one guard in src/install/lockfile/Tree.rs (process_subtree): the tree walk for an unresolved dependency now fires on is_peer() instead of is_optional_peer(), so an unbound required peer takes the same Resolve/ResolveLater/ResolveReplace path optional peers already took. Everything else in Tree.rs and lockfile.rs is mechanical: relaxing four debug_assert!(…is_optional_peer()) to is_peer(), renaming pending_optional_peers/late_bound_optional_peer to pending_peers/late_bound_peer, and updating comments. Two test files add fixed-point tests for pnpm-lock and yarn.lock migrations under the isolated linker (migrate → store keyed with +<hash>, reinstall is no-op with identical store, fresh install produces the same store), plus a workspace/root-peer link test.
Security risks
None. This is dependency-tree hoisting logic; no untrusted-input parsing, path handling, network, or auth surface is touched. The tests use the local Verdaccio registry.
Level of scrutiny
High. The code diff is small but the hoister runs for every bun install and every lockfile producer (pnpm/yarn/package-lock migrations, bun.lock load, fresh resolve). The PR description is unusually thorough — it explains why the binding matches the loader's path walk, why Lockfile::resolve still terminates, and how it composes with #32182/#37426/#38767/#38892 — but that same breadth of interaction is exactly why a maintainer who owns this subsystem should confirm the argument. In particular: (a) that required peers reaching ResolveReplace and triggering a re-hoist can never oscillate, and (b) that no fresh-resolve or bun.lock-load caller relied on unresolved required peers not being placed.
Other factors
- The
METHOD == Filterbranch skipsinvalid_package_idbefore this guard is reached, so the install-time filtered tree is unchanged; only theResolvable(save-to-disk) tree sees new behavior. That matches the claim thatbun.lockoutput is byte-identical (102 snapshots pass). - Tests are strong: they cover both the
Resolvepath (peer-deps-too independencies, no-deps already at root) and theResolveReplacepath (peer-deps-too indevDependencies, hoists first), assert store contents exactly, and cross-check against a fresh install of the same manifest. - No CODEOWNERS entry covers
src/install/. - No prior human reviews or outstanding comments; CI was still building at review time.
|
On the two points above, for whoever picks this up:
|
Problem
bun installthat migrates apnpm-lock.yamlin which pnpm left a package's peer unmet (no peer suffix on its snapshot key, e.g.autoInstallPeers: false) is not a fixed point. Withone-dep(depends onno-deps) andpeer-deps-too(peerno-deps: *), the migrating install createsnode_modules/.bun/peer-deps-too@1.0.0; the nextbun install, with nothing changed, prints+ peer-deps-too@1.0.0, createspeer-deps-too@1.0.0+f8a822eca018d0a1next to it and relinks to it.bun.lockis byte-identical between the two runs, so there is nothing to diff.yarn.lockmigration (yarn.lock has no entry for theno-deps@*spec), and for the root's or a workspace member's own unmet peers the migrating install leaves the peer out of that importer'snode_modulesand the next install, while reporting(no changes), links it.src/install/pnpm.rs, thecontinuearms for a peer with nothing recorded;src/install/yarn.rsbinds a peer only when an entry with that exact spec exists), andTree::process_subtree(src/install/lockfile/Tree.rs) walked the tree for an unresolved edge only when it was an optional peer; an unresolved required peer was skipped. So the edge stayed unbound through the migrating install, and the isolated linker drops an unbound required peer (is_filtered_dependency_or_workspace) and keys the entry without it. Loading the savedbun.lockbinds the same edge by the path walk (PkgMap::find_resolution;*peers and everything elseresolve_peer_dep_version_baseddeclines take that walk), so every later install keys the entry with the peer.Fix
process_subtreewalks for any unbound peer, not only an optional one. Required peers now go through the existingResolve/ResolveLater/ResolveReplaceresults, so the asserts inhoist_dependencyand the namespending_optional_peers/late_bound_optional_peer(nowpending_peers/late_bound_peer) follow; nothing else changes. Every lockfile producer hoists after building its packages (pnpm.rs,yarn.rs,migration.rsfor package-lock.json, and the bun.lock loader itself), which is why the fix is here rather than in each migration.Behavior::cmp), an enclosing node is complete before a package inside it is processed, and a copy that reaches the root later binds throughResolveReplace, after whichLockfile::resolverepeats the pass with the binding known up front, exactly as a reload runs (the loop install: keep optional peer bindings when cleaning the lockfile #37426 added for optional peers; it still ends because every repeated pass fills a slot and none empties one). A fresh resolve also binds a*peer to a copy already in the lockfile, which is why the tests can compare the migrated store against a fresh install's.bun.lockoutput is unchanged (all 102 migration snapshots pass unmodified); the binding shows in the isolated store key, the importer'snode_moduleslinks,pm whyand the yarn printer.test/cli/install/migration/pnpm-lock-v9.test.ts,describe("a peer pnpm left unmet is bound by the install that migrates"):peer-deps-tooindependencies(the copy is above it when its peer is walked) and indevDependencies(it hoists first, so the peer is bound byResolveReplacewhenno-depsreaches the root and the pass repeats); each requires the migrating install to create a+<hash>entry, the reinstall to print(no changes)with the same store and the samebun.lock, and a fresh install of the samepackage.jsonto produce the same store. A third test does the same for the root's and a workspace member's own unmet peers, checking theno-depslinks in bothnode_modules. With main'ssrc/all three fail at the first store / link assertion (barepeer-deps-too@1.0.0;no-depsmissing from both directories).test/cli/install/migration/yarn-lock-migration.test.ts,describe("installing from a migrated yarn.lock"): the same shape from a yarn.lock against the test registry; with main'ssrc/the store has the bare entry.test/cli/install/migration/, andbun-lock,isolated-install,hoist,bun-lockb,migrate-bun-lockb-v2,lockfile-only,lockfile-version-2,isolated-relink,config-version,bun-pm-why,frozen-lockfile-pruned,frozen-lockfile-missing-workspace,public-hoist-pattern,test-dev-peer-dependency-priority,bun-install-registry,bun-workspaces,catalogs,overrides,nested-overrides,bun-add,bun-add-catalog,bun-add-filter,bun-remove,bun-update,bun-update-transitive,bun-update-lockfile-sync,bun-prune,bun-dedupe,bun-patch,bun-audit.complex-workspacefails the same way without this change (gitlab/bitbucket are unreachable here); the only other failures were 5 second per-test timeouts inbun-add-catalogandpnpm-migration-completewhile the host was overloaded; the same files time out more with main'ssrc/under the same load, and the timed-out tests pass when rerun on their own.cargo clippy -p bun_installis clean.Background
bun.lock, which keys packages by their hoisted path and records no target for edges, binds a peer on load by probing<dependent>/<name>, each enclosing path, then the root. Required peers with a version range are the exception since install: fix stale global-store links on disable and peer edge drift across bun.lock loads #32182: they are bound to the highest version in the file that satisfies the range, because the path walk would move them.name@version, orname@version+<hash>where the hash covers the packages the entry's peers are bound to, and links the bound peers into the entry (and, for the root and workspaces, into their ownnode_modules). A dependent whose peer binding differs between two installs therefore gets two entries and is relinked; an unbound required peer contributes nothing.Resolvewhen a copy is there,ResolveLaterwhen another unresolved peer of the name got there first, otherwise an empty slot at the root that a later copy of the name fills throughResolveReplace, which marks the pass as having bound something late soLockfile::resolverepeats it. This change sends required peers down the same path.