Skip to content

install: don't resolve a bundled dependency's optional peer past the bundle hoist root when loading bun.lock - #37350

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/f0091ae1/frozen-lockfile-bundled-optional-peer
Aug 12, 2026
Merged

install: don't resolve a bundled dependency's optional peer past the bundle hoist root when loading bun.lock#37350
Jarred-Sumner merged 4 commits into
mainfrom
farm/f0091ae1/frozen-lockfile-bundled-optional-peer

Conversation

@robobun

@robobun robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #37346

Repro

{
  "dependencies": {
    "cdk8s": "2.70.88",
    "constructs": "10.8.0",
    "debug": "3.2.7"
  }
}
bun install                     # writes bun.lock
bun install                     # (no changes)
bun install --frozen-lockfile
error: lockfile had changes, but lockfile is frozen

cdk8s bundles follow-redirects, which declares debug as an optional peer. Adding a top-level debug makes that peer satisfiable from the root and flips the frozen install from pass to fail, even though the lockfile is byte-identical across installs.

Cause

The frozen-lockfile check compares the loaded lockfile against the cleaned one with Lockfile::eql, which compares the hoisted trees. Package::clone (run by clean_with_logger) resets optional peer edges and lets Tree::hoist_dependency re-derive them, and that search is bounded at the bundle hoist root, so the bundled dependency's optional peer on debug stays unresolved, exactly as in a fresh install (which is why bun.lock has no cdk8s/debug entry).

The bun.lock parser's find_resolution path walk had no such bound: it walked cdk8s/follow-redirects/debug -> cdk8s/debug -> debug and bound the edge to the root debug. The parse-side hoist then placed debug (and its dependency ms) under the bundle hoist root, producing tree placements a fresh install never generates:

eql: hoisted len mismatch l=7 r=9
  ...
  R path="cdk8s" pkg="debug" res=3.2.7
  R path="cdk8s" pkg="ms" res=2.1.3

so eql returned false and the frozen install failed.

Fix

Bound the parser's path walk at the bundle hoist root for optional peer edges: once a directory on the upward walk is a bundled package, only one more level (the tree node containing the bundled package) is searched, matching Tree::hoist_dependency. The walk for other dependency kinds is unchanged, as is lockfile serialization.

Verification

  • New fixture package bundled-optional-peer (bundles optional-peer-deps, which has an optional peer on no-deps) and a test in test/cli/install/bun-lock.test.ts that installs it together with no-deps and runs --frozen-lockfile twice (with and without node_modules). The test fails with error: lockfile had changes, but lockfile is frozen without the src change and passes with it.
  • The cdk8s repro above passes with the fix and still fails the frozen install when package.json actually changes.
  • test/cli/install/bun-lock.test.ts, lockfile-version-2.test.ts, and the bundled/peer suites in bun-install-registry.test.ts pass.

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-lock.test.ts

… bundle hoist root when loading bun.lock

The hoister never searches past a bundled dependency's hoist root when it
re-derives optional peer edges, but the bun.lock parser's path walk went
all the way to the root. Loading a lockfile where a bundled dependency
declares an optional peer that is satisfiable from the root then produced
a tree a fresh install would never generate, and --frozen-lockfile
rejected a lockfile bun itself had just written.

Bound the parser's path walk at the bundle hoist root for optional peer
edges, matching Tree::hoist_dependency.

Fixes #37346
@coderabbitai

coderabbitai Bot commented Aug 10, 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: 724c4b87-ef00-45bb-8491-9c54ad758a5f

📥 Commits

Reviewing files that changed from the base of the PR and between 1c370f0 and ebc4512.

📒 Files selected for processing (1)
  • src/install/lockfile/bun.lock.rs

Walkthrough

Changes

Bundled optional-peer resolution

Layer / File(s) Summary
Bounded package resolution
src/install/lockfile/bun.lock.rs
Optional peer lookups stop at the bundled package hoist root. Other dependency lookups retain upward resolution.
Frozen-install regression coverage
test/cli/install/bun-lock.test.ts, test/cli/install/registry/packages/bundled-optional-peer/package.json
Registry metadata and tests cover initial, existing-node_modules, and cold-start frozen installs while checking lockfile stability.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#35681: Both changes update optional-peer handling, but use different resolution logic.
  • oven-sh/bun#37142: Both changes update lockfile peer resolution, but address different lookup cases.
  • oven-sh/bun#37289: Both changes modify package dependency and peer resolution in bun.lock.rs, but address different lookup paths.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary fix for bundled dependency optional-peer resolution during bun.lock loading.
Description check ✅ Passed The description explains the problem, cause, fix, reproduction, and verification, covering the template requirements despite different headings.
Linked Issues check ✅ Passed The implementation and regression test address issue #37346 by preventing frozen-lockfile failures for unchanged lockfiles.
Out of Scope Changes check ✅ Passed The source change, regression test, and registry fixture directly support the linked issue and stated objectives.

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

Comment thread src/install/lockfile/bun.lock.rs
Comment thread src/install/lockfile/bun.lock.rs Outdated
Comment thread src/install/lockfile/bun.lock.rs Outdated
@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:25 PM PT - Aug 10th, 2026

@alii, your commit b0abf3816972d6ecc7c385ae68f75d1a88aeb2b2 passed in Build #91762! 🎉


🧪   To try this PR locally:

bunx bun-pr 37350

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

bun-37350 --bun

Comment thread src/install/lockfile/bun.lock.rs
Comment thread src/install/lockfile/bun.lock.rs
@Jarred-Sumner
Jarred-Sumner merged commit a8c807f into main Aug 12, 2026
51 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/f0091ae1/frozen-lockfile-bundled-optional-peer branch August 12, 2026 06:15
Jarred-Sumner pushed a commit that referenced this pull request Aug 13, 2026
### Repro

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

```sh
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 #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 #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 add`s. 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.
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.

--frozen-lockfile rejects bun's own lockfile output (1.4.0-canary)

4 participants