Skip to content

install: let the hoister bind optional peers when loading bun.lock - #37925

Open
robobun wants to merge 1 commit into
mainfrom
farm/24036d4d/bun-lock-optional-peers-hoister
Open

install: let the hoister bind optional peers when loading bun.lock#37925
robobun wants to merge 1 commit into
mainfrom
farm/24036d4d/bun-lock-optional-peers-hoister

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun install writes bun.lock, then bun install --frozen-lockfile on the unchanged project fails with error: lockfile had changes, but lockfile is frozen (warm or after rm -rf node_modules), when a dependency bundles a package whose own dependency has an optional peer that the root satisfies. Plain bun install reports no changes and re-saves the file byte-identically.
  • The frozen check is Lockfile::eql between the lockfile as parsed and the one clean_with_logger rebuilds from it (install_with_manager.rs:710). eql compares the hoisted trees.
  • Package::clone resets every optional peer edge and lets Tree::process_subtree bind it during the clean (lockfile/Package.rs:610); a fresh install does the same, since enqueue_dependency_with_main_and_success_fn returns before resolving optional peers (PackageManagerEnqueue.rs:635). The bun.lock parser was the one path that bound these edges itself, by walking the printed package paths (bun.lock.rs, the three binding loops in parse_into_binary_lockfile).
  • An edge bound up front hoists differently from one the hoister binds. Here: bop/mid (a dependency of the bundled bop/opd, hoisted to the bundle root, printed without a "bundled" marker) has an optional peer on tdep; the walk finds the root tdep and binds it, and the hoister then places tdep under bop because mid inherits the bundle's hoist root (Tree.rs:943). The clean leaves the edge unbound and places nothing, so the trees differ by one placement.
  • install: don't resolve a bundled dependency's optional peer past the bundle hoist root when loading bun.lock #37350 bounded the walk at entries marked "bundled": true, which covers the bundled package's own optional peers but not those of its dependencies (its docstring noted this gap). The same mismatch also shows up without bundles: a bound edge places its target's subtree earlier in the breadth-first fill, so a version conflict under the target can hoist differently (this is the shape described in install: keep optional peer bindings when cleaning the lockfile #37426; test/bun.lock in this repo fails --frozen-lockfile on main because of it).

Fix

Background

  • bun.lock stores the hoisted tree as keys: a/b means b is installed in a's node_modules. On load, a regular edge of package a/b is bound by looking up a/b/<name>, then a/<name>, then <name>, mirroring node resolution.
  • The hoister (Tree::process_subtree / hoist_dependency) places each package as high in the tree as it can, walking up from the dependent's node_modules. It runs breadth-first; a placed package's dependencies are queued and processed later, so the point at which a package gets placed decides how later conflicts under it are laid out.
  • A bundled dependency ships inside its dependent's tarball. The hoister places it in the dependent's node_modules and makes that folder a hoist root: nothing in the bundle's subtree (the bundled package or its dependencies) is hoisted or resolved above it. Only the directly bundled package is printed with "bundled": true.
  • Optional peers are never resolved by the resolver. The hoister binds one when it finds a package of that name while walking up to the hoist root (Resolve), or later when such a package gets placed next to the unbound edge (ResolveReplace); otherwise the edge stays unbound and is dropped from the tree.
  • --frozen-lockfile does not compare files: it parses the lockfile, runs the same clean a normal install runs, and compares the two in-memory trees with Lockfile::eql.
Lockfile written by the fixture (same before and after; only loading it changes)
"packages": {
  "bundled-transitive-optional-peer": ["bundled-transitive-optional-peer@1.0.0", "...", { "dependencies": { "depends-on-optional-peer-deps": "1.0.0" } }, "sha512-..."],
  "no-deps": ["no-deps@1.0.0", "...", {}, "sha512-..."],
  "bundled-transitive-optional-peer/depends-on-optional-peer-deps": ["depends-on-optional-peer-deps@1.0.0", "...", { "dependencies": { "optional-peer-deps": "1.0.0" }, "bundled": true }, "sha512-..."],
  "bundled-transitive-optional-peer/optional-peer-deps": ["optional-peer-deps@1.0.0", "...", { "peerDependencies": { "no-deps": "*" }, "optionalPeers": ["no-deps"] }, "sha512-..."],
}

On main, loading this binds optional-peer-deps' peer to the root no-deps and the load-time tree gains a bundled-transitive-optional-peer/no-deps placement that the clean never produces:

$ bun install --frozen-lockfile
error: lockfile had changes, but lockfile is frozen

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

The bun.lock parser bound optional peer edges by walking the printed
package paths, while a fresh install and Lockfile::clean_with_logger
(Package::clone resets these edges) both leave them for
Tree::process_subtree to bind. An edge bound up front hoists
differently: it escapes the hoist root of a bundled dependency's
subtree, and it places its target's subtree earlier in the
breadth-first fill. The tree built at load time then differs from the
tree the clean rebuilds, and Lockfile::eql reports a lockfile bun itself
just wrote as changed, so --frozen-lockfile fails.

#37350 bounded the walk at entries marked "bundled", which covers a
bundled package's own optional peers but not the optional peers of its
dependencies (hoisted to the same bundle root, but printed without a
marker). Skip optional peers in all three binding loops instead and let
the resolve() call at the end of parsing bind them exactly like the
other two paths do; the bounded walk is no longer needed.

The next-pages lockfile dumps change in one field: the root jiti slot is
now owned by its prod dependency edge instead of eslint's optional peer
edge, as in a fresh install. Same package id.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3921edab-28f1-4f66-937d-02292f58c1a7

📥 Commits

Reviewing files that changed from the base of the PR and between 315136d and 078a59d.

⛔ Files ignored due to path filters (3)
  • test/integration/next-pages/test/__snapshots__/dev-server-ssr-100.test.ts.snap is excluded by !**/*.snap
  • test/integration/next-pages/test/__snapshots__/dev-server.test.ts.snap is excluded by !**/*.snap
  • test/integration/next-pages/test/__snapshots__/next-build.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (6)
  • src/install/lockfile/bun.lock.rs
  • test/cli/install/bun-lock.test.ts
  • test/cli/install/registry/packages/bundled-transitive-optional-peer/bundled-transitive-optional-peer-1.0.0.tgz
  • test/cli/install/registry/packages/bundled-transitive-optional-peer/package.json
  • test/cli/install/registry/packages/depends-on-optional-peer-deps/depends-on-optional-peer-deps-1.0.0.tgz
  • test/cli/install/registry/packages/depends-on-optional-peer-deps/package.json

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

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:05 PM PT - Aug 12th, 2026

@robobun, your commit 078a59d has 3 failures in Build #93533 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37925

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

bun-37925 --bun

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced, fixed, ready for review. CI is green everywhere it ran; the red jobs are infrastructure (details below).

Reproduced with the new registry fixtures in test/cli/install/bun-lock.test.ts (root depends on bundled-transitive-optional-peer and no-deps; the bundled package's dependency optional-peer-deps has an optional peer on no-deps): bun install succeeds, bun install --frozen-lockfile then fails with error: lockfile had changes, but lockfile is frozen on main including #37350, and passes with this branch. cd test && bun install --frozen-lockfile on the repo's own test/bun.lock goes from failing to passing as well. No GitHub issue for this shape; the direct-bundled variant is #37346.

CI (build 93533, finished): 170 jobs passed, including the install suites and the new test on every lane that produced a binary. The three jobs that did not pass are all infrastructure from the run: the windows aarch64 build-bun lane failed twice fetching c-ares/libuv/mimalloc/WebKit during the github.com download failures that hit every build between roughly 19:45 and 21:30 UTC (its 8 test jobs never ran as a result), complex-workspace.test.ts failed on debian 13 aarch64 and alpine 3.23 aarch64 because sharp's postinstall could not download its prebuilts from GitHub in the same window (bun-install.test.ts retried once on a GitHub tarball fetch for the same reason and passed), and one darwin aarch64 shard expired unassigned in the fleet-wide darwin test queue (around 300 jobs queued against 10 agents at the time). Nothing in it touches this diff. The downloads have recovered; retrying those three jobs is all the build needs. I am not pushing a retrigger, since it would re-run 170 green jobs and put 4 more darwin jobs at the back of that queue.

@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 this PR and didn't find any bugs. Because it changes how the bun.lock parser binds optional peer edges — package-manager core semantics that interact with the hoister and with the related open work in #35571 and #37426 — a human look would still be worthwhile.

What was reviewed:

  • The three binding loops in parse_into_binary_lockfile now skip optional peers before is_deferred_peer/find_resolution, and lockfile.resolve() at the end still runs the hoister that binds them — same code path as clean_with_logger.
  • find_resolution_bounded_at_bundle and its bundled_pkgs argument are fully dead after the skip; bundled_pkgs itself remains used by the CHECK_FOR_BUNDLED behavior tagging at ~L2520/L3352.
  • The next-pages snapshot change (jiti slot owner 434→937, same package_id 316) matches the description: the optional-peer edge is no longer the one that claims the root slot during parse.
  • New test mirrors the existing #37346 test one level deeper and asserts warm + cold --frozen-lockfile plus byte-identical re-save.
Extended reasoning...

Overview

The PR changes src/install/lockfile/bun.lock.rs so that the three dependency-binding loops in parse_into_binary_lockfile (root, workspaces, packages) skip optional peer edges entirely, leaving them for the trailing lockfile.resolve() call — the same hoister that clean_with_logger uses after Package::clone resets those edges. The find_resolution_bounded_at_bundle helper introduced in #37350 (a partial fix bounded at "bundled": true entries only) is removed and find_resolution is restored to its pre-#37350 form. A new test in bun-lock.test.ts reproduces the transitive-bundled case with two new Verdaccio fixtures, and three next-pages lockfile-dump snapshots are updated for the one field whose owning dep_id shifts.

Security risks

None. This is purely lockfile-parsing/hoist-tree semantics; no untrusted-input parsing surface, network, filesystem write, or auth path is touched.

Level of scrutiny

High. parse_into_binary_lockfile is the load path for every bun install that has a bun.lock, and its output feeds both --frozen-lockfile's Lockfile::eql comparison and the did_meta_hash_change re-save trigger. The change is small in surface (three continues + a helper removal) but shifts which code binds optional peers at load time, and the PR description explicitly notes overlap with two other open PRs (#35571 carries the same three-loop skip; #37426 changes the clean side). A maintainer who owns the hoister invariants and is tracking those threads should confirm the sequencing and that the snapshot change (root jiti slot owned by dep 937 instead of eslint's optional peer 434, same package_id) is the expected shape.

Other factors

The correctness argument is well-stated and I could follow it against the code: Package::clone at Package.rs:610 resets optional peers, enqueue_dependency_with_main_and_success_fn returns before resolving them, so the parser was the one path that bound them from printed paths — and a path-walk binding places the target earlier and ignores a bundle-subtree hoist root that only the hoister knows about. Deferring to lockfile.resolve() makes the parsed tree and the cleaned tree come from identical inputs and identical code. bundled_pkgs is still live for Behavior::BUNDLED tagging, so removing the bounded-walk helper leaves nothing dead. The new test is placed alongside the existing #37346 test, uses local registry fixtures, asserts both warm and cold --frozen-lockfile, and checks that the lockfile is byte-identical after. The PR reports the full install-related suite passing on a debug build. No bugs found by the multi-agent hunt or by my read.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

On the sequencing question for whoever picks this up:

  • If install: hoist workspace members in path order, not package-name order #35571 lands first, its three continues are the same as the ones here, and what remains of this PR is removing the bounded walk from install: don't resolve a bundled dependency's optional peer past the bundle hoist root when loading bun.lock #37350 plus the fixtures and test. Rebasing is mechanical either way.
  • install: keep optional peer bindings when cleaning the lockfile #37426 takes the other route for the same load/clean mismatch: it keeps the parser's bindings and teaches the clean to carry them over, so that lockfiles written by 1.3.x keep their existing layout. This PR instead makes the parser stop producing those bindings, so with it a 1.3.x layout is re-derived by the hoister (--frozen-lockfile still passes, because both sides now derive the same tree, and the file is only rewritten on the next save that happens for another reason). The two do not conflict mechanically, but they disagree on which side should be the source of truth for optional peer placement, so that is the call to make when reviewing either one. Concretely for the repo's own test/bun.lock, this branch passes --frozen-lockfile and re-saves it byte-identically.
  • The bundled case in this PR is not covered by install: keep optional peer bindings when cleaning the lockfile #37426 on its own: with bindings kept, the edge from inside the bundle to the root package is carried into the clean, so the frozen check passes, but a fresh install and an install from the lockfile still disagree about the edge (the fresh one leaves it unbound, since the target is outside the bundle's hoist root). Stopping the parser from binding is what removes that difference.

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.

2 participants