Skip to content

install: keep optional peer bindings when cleaning the lockfile - #37426

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/69bec4cc/keep-optional-peer-resolutions-on-clean
Aug 13, 2026
Merged

install: keep optional peer bindings when cleaning the lockfile#37426
Jarred-Sumner merged 4 commits into
mainfrom
farm/69bec4cc/keep-optional-peer-resolutions-on-clean

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Repro

On a pristine checkout of main, with the released canary (1.4.0-canary.1, 9008ae7):

cd test
bun install --frozen-lockfile
# error: lockfile had changes, but lockfile is frozen
bun install --lockfile-only && git diff --stat bun.lock   # re-hoists jsbn
bun install --frozen-lockfile                               # still fails on the re-saved file

The re-save flips the top-level jsbn from 1.1.0 to 0.1.1, drops ecc-jsbn/jsbn and sshpk/jsbn, and adds ip-address/jsbn. Re-saving does not help: --frozen-lockfile rejects the re-saved lockfile too, so a project with this shape cannot pass --frozen-lockfile at all. 1.3.14 accepts both layouts. Any project with an optional peer whose target is reached through a deeper dependency, plus a version conflict somewhere under that target, hits this (here mongodb has an optional peer on socks, socks -> ip-address -> jsbn@1.1.0, and sshpk wants jsbn@0.1.1). The new registry fixtures are a small version of the same graph, and the same three commands fail on them the same way.

Cause

Loading bun.lock binds every optional peer to the package next to it (the path walk in bun.lock.rs) and then hoists. Since #35681, Package::clone writes invalid_package_id into optional peer slots while cleaning, so the hoist in Cloner::flush runs with those edges unbound.

Hoisting is breadth-first, and the two runs queue the peer target's subtree at different times: with the edge bound, the target is placed when its dependent is (socks from mongodb, at depth 1) and its subtree is walked right after; unbound, the target is only placed when the first real edge reaches it (socks-proxy-agent, several levels down) via ResolveReplace. Whatever conflicts under that subtree (jsbn) is hoisted differently, so the tree built at load time and the tree built by the clean differ. --frozen-lockfile compares exactly those two trees (Lockfile::eql), which is why it fails on an unchanged project and keeps failing after a re-save: the file is written from the clean's tree, but loading it binds the peers again and builds the other one. The same thing makes an unrelated bun add rewrite these entries. #35681 described this as a one-time rewrite; it is not, because the load side still binds the slots.

Fix

Three pieces, all in the lockfile tree builder:

  1. Clean keeps the bindings. Package::clone defers optional peer slots to Cloner::optional_peers instead of clearing them; Cloner::flush binds them once the clone queue has drained, and only when the target was actually cloned, i.e. some non-peer edge still reaches it. A target held only by peer slots is still never cloned, so what install: drop packages held only by optional-peer resolution slots from bun.lock #35681 fixed (bun remove leaving the package behind) stays fixed and its tests still pass. For a surviving target this is what 1.3.x did (Package.clone in Zig copied every slot), so lockfiles written by 1.3.x are fixed points: test/bun.lock passes --frozen-lockfile and re-saves byte-identically with this branch, without touching the file.

  2. A kept binding has to be the one the tree expresses. The hoister places a bound optional peer like any dependency, except that when another version of the target already holds the slot next to the dependent and the peer range accepts it, the dependent dedupes onto that version. In that case the binding now moves to that version too (HoistDependencyResult::Rebind, in the saved tree only). That is what node resolution finds, what the path walk binds on the next load, and what the isolated linker keys the dependent's store entry by, so the install that writes bun.lock and the next install from it link the same entry. Without this, a carried-over binding could survive in memory for one run while the saved tree said otherwise (the isolated linker would then link against one version and a reinstall against the other). Required peers are untouched: they keep the resolver's version, which bun.lock.rs reproduces by version since install: fix stale global-store links on disable and peer edge drift across bun.lock loads #32182. So the rule for an optional peer is unchanged from main: it is bound to whatever ends up next to it. The carry-over only matters when the bound package is still the one placed there, and then it keeps its place instead of being re-derived.

  3. Fresh installs settle in the same pass structure a reload has. When a peer is bound late in a hoist pass (ResolveReplace), the pass is repeated, until a pass binds nothing late. The last pass built the tree from bindings it had up front, which is what a reload does, so a fresh install writes the same file a reload would write instead of one that converges on the next re-save. Binding one peer can move a dependent and put a target within reach of a second peer that the previous pass could not bind (shape 2 of the fixtures: the target is hidden behind a bundled copy of itself until the first binding hoists its dependent out), which is why this is a loop rather than one extra pass. Each pass that repeats filled at least one empty slot and no pass empties one, so it ends after at most one pass per optional peer; in practice lockfiles loaded from disk take one pass and fresh installs one or two. filter (the install-time tree) runs after this and stays single-pass.

Why keep the binding rather than also stop binding at load time (the other way to make the two sides agree, which #35571 carries as a side change)? Re-deriving everywhere rewrites every existing lockfile with this shape once, failing --frozen-lockfile in CI on the bun upgrade (that approach had to regenerate the next-pages lockfile snapshots; this branch regenerates nothing), and it rebinds a peer to a different version of its target on unrelated bun adds. The binding recorded in the lockfile is a resolution like any other; keeping it while the target exists and the tree can express it is what the lockfile is for, and it is what 1.3.x did. Lockfiles written by builds with #35681 (the other placement) are accepted as well and converge on their next re-save; the parameterized test covers both placements. #37350 (bundled dependency's optional peer bound across the bundle root at load) is a different shape of the load/clean mismatch; its test passes on this branch too, and it is still worth landing on top because it stops that edge from being bound at all.

Verification

Registry fixtures optional-peer-hoist-* (generated by create-optional-peer-hoist-packages.ts, which documents both shapes; every tarball is a single package.json, plus the bundled copy in target@3.0.0). Tests added to test/cli/install/bun-lock.test.ts, each with what fails without the fix:

  • fresh install, then --frozen-lockfile passes and --lockfile-only re-saves byte-identically (before: error: lockfile had changes, but lockfile is frozen on the file the install just wrote)
  • shape 2: a fresh install writes the settled layout (tail@2.0.0 at the root) and re-saves identically (before, and with a fixed two passes: tail@1.0.0 at the root and the first re-save rewrites it)
  • --frozen-lockfile accepts a hand-written bun.lock in either placement (both rejected before)
  • adding provider keeps consumer bound to target@1.0.0, target@2.0.0 nests under provider, --frozen-lockfile passes, re-save identical (before: rebound to 2.0.0 and the existing entries re-hoisted)
  • the same with provider aliased to sort first, under the isolated linker: target@2.0.0 takes the root, and the store entry consumer is linked to by the writing install is the one a --frozen-lockfile reinstall from scratch links to as well (fails with piece 1 alone: the writing install still links the target@1.0.0 variant)

The four tests from #35681 still pass. Also run with the debug build: bun-lockb, migrate-bun-lockb-v2, hoist, lockfile-only, lockfile-version-2, isolated-install (including the #32182 peer stability tests), bun-remove, bun-add, bun-update, bun-workspaces, overrides, catalogs, public-hoist-pattern, config-version, bun-pm, bun-pm-why, bun-install-registry, bun-install and the migration/ suites (lockfile snapshots unchanged). The only failures are tests needing bitbucket/gitlab/external network access, which fail identically with the released build in this environment. cd test && bun bd install --frozen-lockfile passes on the committed test/bun.lock, and --lockfile-only leaves it unchanged.

Loading bun.lock binds every optional peer to the package next to it
before hoisting, but since #35681 Package::clone dropped those bindings
again, so the tree built while cleaning could differ from the tree built
while loading. For a graph where the peer target's subtree competes with
another package for a hoisted slot, --frozen-lockfile then failed on an
unchanged project, including on the lockfile bun had just written, and a
re-save moved packages around (test/bun.lock: jsbn).

Bind the slot after the clone queue drains instead, and only when a
non-peer edge cloned the target, so a target held only by peer slots is
still dropped. Hoist a second time when a peer was bound by a package
placed after its dependent, so a fresh install writes the same layout a
reload builds.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 173a4bdb-81c3-4110-a0f0-54b759b79718

📥 Commits

Reviewing files that changed from the base of the PR and between 792ebab and 5755a5d.

📒 Files selected for processing (31)
  • src/install/lockfile.rs
  • src/install/lockfile/Package.rs
  • src/install/lockfile/Tree.rs
  • test/cli/install/bun-lock.test.ts
  • test/cli/install/registry/packages/create-optional-peer-hoist-packages.ts
  • test/cli/install/registry/packages/optional-peer-hoist-consumer/optional-peer-hoist-consumer-1.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-consumer/package.json
  • test/cli/install/registry/packages/optional-peer-hoist-consumer2/optional-peer-hoist-consumer2-1.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-consumer2/package.json
  • test/cli/install/registry/packages/optional-peer-hoist-deep-child/optional-peer-hoist-deep-child-1.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-deep-child/optional-peer-hoist-deep-child-2.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-deep-child/package.json
  • test/cli/install/registry/packages/optional-peer-hoist-deep/optional-peer-hoist-deep-1.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-deep/optional-peer-hoist-deep-2.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-deep/package.json
  • test/cli/install/registry/packages/optional-peer-hoist-leaf/optional-peer-hoist-leaf-1.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-leaf/optional-peer-hoist-leaf-2.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-leaf/optional-peer-hoist-leaf-3.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-leaf/package.json
  • test/cli/install/registry/packages/optional-peer-hoist-provider/optional-peer-hoist-provider-1.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-provider/package.json
  • test/cli/install/registry/packages/optional-peer-hoist-tail/optional-peer-hoist-tail-1.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-tail/optional-peer-hoist-tail-2.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-tail/package.json
  • test/cli/install/registry/packages/optional-peer-hoist-target/optional-peer-hoist-target-1.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-target/optional-peer-hoist-target-2.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-target/optional-peer-hoist-target-3.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-target/package.json
  • test/cli/install/registry/packages/optional-peer-hoist-target2/optional-peer-hoist-target2-0.0.1.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-target2/optional-peer-hoist-target2-1.0.0.tgz
  • test/cli/install/registry/packages/optional-peer-hoist-target2/package.json

Walkthrough

Changes

Optional peer hoisting

Layer / File(s) Summary
Optional peer rebind handling
src/install/lockfile/Tree.rs
Tree deduplication can return Rebind(PackageID) for resolvable optional peers and update their resolutions.
Deferred optional peer cloning
src/install/lockfile.rs, src/install/lockfile/Package.rs
Cloning queues optional peer mappings and applies them during Cloner::flush.
Repeated late-bound hoisting
src/install/lockfile.rs
Lockfile::hoist reports late-bound peers, and Lockfile::resolve repeats hoisting until completion.
Install and registry validation
test/cli/install/bun-lock.test.ts, test/cli/install/registry/packages/*
Tests and registry fixtures cover hoisting, rebinding, lockfile stability, and isolated-linker targets.

Possibly related PRs

  • oven-sh/bun#35681: Modifies optional-peer resolution and related lockfile install tests.
  • oven-sh/bun#36631: Modifies dependency-resolution logic in Package.rs for peer deduplication.
  • oven-sh/bun#37350: Modifies optional-peer hoisting and bundled-dependency install behavior.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: preserving optional peer bindings during lockfile cleaning.
Description check ✅ Passed The description explains the problem, implementation, rationale, and verification results in substantial detail, despite using different section headings than the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:36 AM PT - Aug 11th, 2026

@robobun, your commit 5755a5d2215128a8473e1c6c1d80041c64511503 passed in Build #92234! 🎉


🧪   To try this PR locally:

bunx bun-pr 37426

That installs a local version of the PR into your bun-37426 executable, so you can run:

bun-37426 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. install: don't resolve a bundled dependency's optional peer past the bundle hoist root when loading bun.lock #37350 - Diagnoses the same Package::clone optional-peer clearing regression from install: drop packages held only by optional-peer resolution slots from bun.lock #35681 and the same lockfile had changes, but lockfile is frozen failure, but fixes it on the load side by bounding the bun.lock parser's find_resolution walk instead of preserving the binding during clean — a mutually exclusive design for the same bug.
  2. install: hoist workspace members in path order, not package-name order #35571 - Beyond its workspace hoist-ordering fix, it carries a bun.lock.rs hunk that skips is_optional_peer() in all three parse-side resolution-binding loops so "load matches fresh resolve" — the same load-vs-clean optional-peer divergence, resolved in the opposite direction from this PR.

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline nit, I also checked that the optional-peer branch in Package::clone leaving *resolution unset is safe — the slot is pre-filled with invalid_package_id by extend_from_fn at Package.rs:531, and Cloner::flush only overwrites it when the target was cloned. Also confirmed hoist() has no callers outside resolve()/filter(), both updated for the new Result<bool, _> return.

Extended reasoning...

This PR reworks how optional peer bindings survive the lockfile clean and adds a conditional second hoist pass in Lockfile::resolve. It touches core package-manager hoisting logic and embeds a design choice (keep the recorded binding vs. re-derive everywhere) that affects lockfile stability across Bun versions, so a human should look at it. The one inline finding is cosmetic and effectively unreachable; I verified the two non-obvious safety points above so a later pass need not re-derive them.

Comment thread src/install/lockfile.rs
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

On the two PRs flagged above:

#37350 fixes one shape of the same load-vs-clean mismatch (a bundled dependency's optional peer) on the load side, by stopping the parser's path walk at the bundle root. This PR fixes the mismatch itself, so #37350's test also passes on this branch without its bun.lock.rs change (checked locally with its fixture; it fails on canary). #37350 is still worth landing on top: on this branch alone, a forced re-save of that shape (--lockfile-only, bun add) writes a bundled-pkg/peer-target entry once, which is what 1.3.x did too, and with #37350 the edge is never bound across the bundle root in the first place, so that entry never appears. The source hunks do not overlap; both add tests at the end of bun-lock.test.ts.

#35571 is about workspace hoist order, but it ran into this same mismatch on its repro and carries a side hunk that makes the parser skip binding optional peers when loading bun.lock, so that load matches the cleared slots. That is the other possible direction, and it is the one this PR argues against in the description: it rewrites every existing lockfile with this shape once (that PR had to regenerate the next-pages lockfile snapshots for exactly this reason; this PR regenerates nothing and test/bun.lock is byte-stable), and it rebinds a peer to a different version of its target on an unrelated bun add, which the third test here pins down. With this PR in, that hunk in #35571 should no longer be needed, since both sides of the comparison see the same bindings regardless of the hoist order. If #35571 lands first with the hunk, the two need to be reconciled one way or the other, so flagging it for whoever reviews these.

…oist until settled

A carried-over binding is only kept if the bound package is still the
one placed next to the dependent. When another version of it already
holds that slot and the peer range accepts it, the dependent dedupes
onto it, so repoint the binding there as well: that is what a reload
binds from the saved tree, and the isolated linker keys the dependent's
store entry by it, so the install that wrote bun.lock and the next
install from it now link the same entry.

Binding one peer can move a dependent and put a target within reach of
a second peer the previous pass could not bind, so resolve() hoists
until a pass binds nothing late instead of exactly twice. Each extra
pass fills at least one more slot and none is ever emptied, so it ends.
Comment thread src/install/lockfile.rs Outdated
Comment thread src/install/lockfile.rs Outdated
Comment thread src/install/lockfile.rs Outdated
Comment thread src/install/lockfile.rs Outdated
Comment thread src/install/lockfile.rs Outdated
Comment thread src/install/lockfile/Package.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile.rs Outdated
Comment thread src/install/lockfile.rs Outdated
Comment thread src/install/lockfile.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed a second round after reviewing the first version more carefully (description updated to match):

  • Carried-over bindings now follow the tree. If the bound version of the target has lost the slot next to the dependent to another version the peer range accepts, the dependent dedupes onto that version and the binding is moved there too (Rebind, saved tree only). Before this, the binding could survive in memory for the run that wrote bun.lock while the saved tree implied the other version, so under the isolated linker that run linked the dependent against one target version and the next install from the same file against the other. New test with provider aliased to sort first, under linker = "isolated": the store entry the writing install links consumer to must be the one a --frozen-lockfile reinstall from scratch links to as well. Fails with the carry-over alone, passes now; the install: fix stale global-store links on disable and peer edge drift across bun.lock loads #32182 isolated-linker peer tests still pass.
  • resolve repeats until a pass binds nothing late instead of exactly once more. Binding one peer can move a dependent and thereby put a target within reach of a second peer that the previous pass could not bind (shape 2 of the fixtures: the target is hidden behind a bundled copy of itself until the first binding hoists its dependent out), so two passes were not always enough; the old comment claiming they were was wrong. New test: the fresh install of that shape must already write the layout a reload builds. It fails with a fixed two passes (first re-save rewrites it) and passes with the loop. Termination: a pass only repeats after filling an empty slot and no pass empties one.
  • Comments in the source trimmed to one-line pointers; the reasoning is in the description and the commit messages.

#37350's test still passes on this branch (checked again after these changes), and test/bun.lock is still byte-stable.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the changes since my earlier note (the Rebind variant, the loop-until-settled in resolve(), and the deferred optional_peers binding in Cloner::flush) and didn't find any bugs. Given this reworks hoist convergence for optional peers and is one of three mutually-exclusive approaches in flight (#35571, #37350), a maintainer should sign off on the direction.

What was reviewed:

  • Termination of the while self.hoist(...) {} loop — late_bound_optional_peer is only set on ResolveReplace, which fills a previously-invalid slot, and no path clears one, so it's bounded by the optional-peer count.
  • Rebind is gated on METHOD == Resolvable so filter() (install-time tree) still returns Hoisted and doesn't mutate resolutions.
  • Cloner::flush binds deferred peers only when the target survived the clone (mapping < max_package_id), so #35681's remove behavior is preserved.
  • Package::clone now constructs PendingResolution before the optional-peer check but the *old_resolution >= max_package_id guard runs first, so old_resolution is always a valid index into mapping.
Extended reasoning...

Overview

This PR touches the lockfile clean/hoist pipeline in src/install/lockfile.rs, lockfile/Package.rs, and lockfile/Tree.rs, plus ~200 lines of new tests in bun-lock.test.ts and a set of Verdaccio registry fixtures. The core change: instead of clearing optional-peer resolution slots during Package::clone (as #35681 introduced), defer them to a new Cloner::optional_peers list and bind them in Cloner::flush after the clone queue drains — but only if the target package was independently kept alive. Lockfile::resolve now loops hoist() until a pass binds no optional peer late, and a new HoistDependencyResult::Rebind variant updates the in-memory binding when a bound optional peer dedupes onto a different version already holding the slot.

Security risks

None. This is package-manager resolution bookkeeping over data already parsed and validated by bun.lock.rs. No new untrusted-input parsing, no filesystem paths derived from new fields, no network. The registry fixtures are local test data.

Level of scrutiny

High. The hoist algorithm is what determines the on-disk node_modules layout and bun.lock contents for every install; a bug here silently corrupts lockfiles or breaks --frozen-lockfile in CI for real projects. The change also introduces a fixed-point loop whose termination depends on a monotonicity invariant ("no pass empties a slot") that is argued in prose rather than enforced by the type system. The reasoning is careful and the tests are strong (each pinned to a specific failure mode without the fix, including the isolated-linker store-entry consistency check), but this is exactly the kind of subtle graph-algorithm change where a maintainer who owns src/install/ should confirm the approach.

Other factors

  • Two other open PRs address overlapping symptoms with conflicting designs: #35571 clears the load-side binding (opposite direction, forces a one-time lockfile rewrite), and #37350 bounds one specific load-side walk. The author has already laid out the reconciliation in the thread, but choosing between "keep the binding" and "stop binding on load" is a design call.
  • My earlier inline note (duplicated Invalid dependency name diagnostic across hoist passes) was acknowledged as cosmetic-only and intentionally left; that's reasonable and now moot to re-raise.
  • The comment-cop bot flags were addressed in 5755a5d (comments trimmed to one-liners).
  • Test coverage is thorough: fresh install → frozen → lockfile-only round-trips, both historical placements accepted under --frozen-lockfile, the multi-pass shape-2 fixture, and the isolated-linker rebind check. The #35681 regression tests remain in the file and are stated to still pass.

@Jarred-Sumner
Jarred-Sumner merged commit 2f75bab into main Aug 13, 2026
51 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants