Skip to content

install: bind a multi-path package's edges from the bun.lock row that prints the binding - #39347

Open
robobun wants to merge 1 commit into
mainfrom
farm/ad38d9b3/bun-lock-multi-path-rows
Open

install: bind a multi-path package's edges from the bun.lock row that prints the binding#39347
robobun wants to merge 1 commit into
mainfrom
farm/ad38d9b3/bun-lock-multi-path-rows

Conversation

@robobun

@robobun robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A package printed at more than one path in bun.lock has one set of edges, but parse_into_binary_lockfile binds them once per printed row and every row overwrites the previous one (src/install/lockfile/bun.lock.rs, the for row in pkg_rows loop: pkg_map.get returns the same package for each row because append_package_dedupe dedupes equal resolutions, and map_dep_to_pkg assigns 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 install of 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, so bun install --frozen-lockfile on the file bun just wrote fails with error: 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); install: keep a peer nothing in bun.lock satisfies where the file records it #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_resolution also 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-edge RowEvidence and 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_dependency puts 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, install: keep optional peer bindings when cleaning the lockfile #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-lockfile fails. 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-lockfile must accept the file bun wrote and install 1.0.0 at the root and 1.1.0 next to mid); the recorded copy under either parent, hand-written (both orders must re-save to the same entries, pass --frozen-lockfile --dry-run and 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-parent case 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_install and rustfmt --check clean.
  • Interaction with open PRs: install: keep a peer nothing in bun.lock satisfies where the file records it #38892's "two paths" shape now loads the record in either order, so the order caveat in its test comment can go; install: keep bundled peer edges on their own bun.lock entry when loading #38837 (bundled peers take the walk) always finds them in the package's own path, which is the top rank; install: bind peer edges in the hoister, the way loading bun.lock binds them #38767 rebinds version-bound peers only and does not touch the walked edges this changes.

Background

  • bun.lock lists packages by their path in the hoisted node_modules layout ("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 (install: fix stale global-store links on disable and peer edge drift across bun.lock loads #32182).
  • A peer edge is satisfied by whatever copy is next to or above the dependent. When Tree::hoist_dependency builds 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, install: keep optional peer bindings when cleaning the lockfile #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 leaves package.json.
  • --frozen-lockfile builds 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-only prints the second tree. --frozen-lockfile --dry-run performs that comparison without fetching anything, which is what the hand-written tests use.

…he binding

A package printed at several paths in bun.lock has one set of edges, which
the loader bound once per printed row, each row overwriting the previous
one. Rows can find different copies of a peer, so the binding depended on
which row the writer happened to print last: a `*` peer whose own target is
printed at the root from the root-level row was rebound to whatever version
a deeper row walked into, and a copy recorded next to one of the rows was
only read back when that row came last.

find_resolution now reports where it found the entry, and the package rows
loop ranks each row's find: an entry in the package's own path, then the
root entry (for edges the hoister keeps, which is the only other place it
puts an edge's own target), then anything walked into. A row only overwrites
a binding with at least as strong a find, so equal finds keep the last row
as before, and a package printed once is unaffected. Optional peers rank
every walked find the same, since the hoister rebinds them to whatever each
placement dedupes onto.
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:00 AM PT - Aug 16th, 2026

@robobun, your commit ad2ad71 is building: #99399

@coderabbitai

coderabbitai Bot commented Aug 16, 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: 48 seconds

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.
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: 4a27a2dd-1766-4c31-adee-59fa80820356

📥 Commits

Reviewing files that changed from the base of the PR and between 1726b14 and ad2ad71.

📒 Files selected for processing (2)
  • src/install/lockfile/bun.lock.rs
  • test/cli/install/bun-lock.test.ts

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

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced with the released build (1.4.0-canary.1) on the two shapes now in test/cli/install/bun-lock.test.ts under "loading bun.lock binds the edges of a package printed at several paths": the * peer shape fails its --frozen-lockfile step with error: lockfile had changes, but lockfile is frozen on the file the previous install wrote, and the recorded-copy shape loses the record when it is printed under the parent that sorts first. Both pass with this branch; the other two cases in the block pin behavior that must not change (the recorded copy under the last parent, and an optional peer with nothing recorded).

The CodeRabbit comment above is a rate-limit notice, nothing to address in it.

@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. The change is small and well-argued, but it adjusts how the bun.lock loader picks which row binds a deduped package's peer edges — a subtle semantics change whose correctness rests on the stated invariants of Tree::hoist_dependency and the optional-peer Rebind path. A maintainer familiar with the hoister should confirm the ranking (own-path > root > enclosing, with optional peers demoting root to walked) matches those invariants.

What was reviewed:

  • find_resolution_impl's new FoundAt tracking — own_path is set only on the first probe and offset == 0 correctly identifies the top-level entry; the two other find_resolution callers only inspect Err, so the widened return type is harmless.
  • bound_by sizing and indexing — sized to dependencies.len(), indexed by dep_id which is always in range; Unbound as the lowest rank means single-row packages behave exactly as before.
  • The derived Ord on RowEvidence — variant order gives Unbound < Walked < WalkedToRoot < OwnEntry, and equal ranks keep last-row-wins as described.
  • Tests: the end-to-end *-peer case, the both-orders own-entry case, and the optional-peer case that pins why FoundAt::Root must rank as Walked for optional peers.
Extended reasoning...

Overview

The PR changes src/install/lockfile/bun.lock.rs (~60 net lines) and test/cli/install/bun-lock.test.ts (~190 net new test lines plus a refactor hoisting serveRegistry/installWithOwnCache to module scope). PkgMap::find_resolution now returns where the hit was found (FoundAt::OwnPath/EnclosingPath/Root), and the for row in pkg_rows loop in parse_into_binary_lockfile keeps a per-edge RowEvidence so that when the same package's edges are bound from several printed rows, a later row only overwrites the binding if its find is at least as strong. Optional peers demote Root to Walked because the hoister rebinds them per placement.

Security risks

None. This is pure lockfile-loading semantics over data bun itself wrote; no new parsing of untrusted input, no allocation on untrusted sizes, no filesystem or network changes.

Level of scrutiny

High. The lockfile loader runs on every bun install with an existing bun.lock, and a wrong ranking would silently rebind peer edges to a different package version — showing up as spurious --frozen-lockfile rejections or, with the isolated linker, packages linked against the wrong peer. The PR description's correctness argument leans entirely on how Tree::hoist_dependency places the package an edge is bound to (own path or root, never an enclosing path in between) and on HoistDependencyResult::Rebind for optional peers. That reasoning reads correctly to me and the third test pins the optional-peer carve-out, but someone who owns the hoister should confirm it.

Other factors

  • Mechanically the change is clean: the two other find_resolution call sites (lines ~1134, ~1376) only pattern-match Err, so the tuple return type doesn't touch them; bound_by is sized to the full dependencies slice and indexed by the same dep_id used elsewhere; the #[derive(Ord)] variant ordering matches the documented ranking.
  • The test refactor is a pure hoist of existing helpers; the moved serveRegistry and installWithOwnCache are byte-equivalent to the previous nested versions modulo the manifests parameter.
  • The new tests cover both parent orderings for the own-entry case (the failing order and the previously-passing one), the end-to-end *-peer shape against a real registry, and the negative case that would regress if optional peers used WalkedToRoot. The PR description also lists a broad set of install suites re-run under the debug build.
  • The acknowledged not-covered case (a * peer of a package printed both inside and outside a bundled dependency) keeps the old last-row-wins behavior, which is no worse than main.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

For whoever confirms the two invariants the review above points at, the code they come from (line numbers as of aec33f5, which this branch does not touch):

  • Where a bound edge's package ends up: Tree::hoist_dependency (src/install/lockfile/Tree.rs:946-1069) walks up from the dependent's tree and returns on the first tree holding the name. A conflicting copy returns DependencyLoop (line 1046), which the AS_DEFINED frame turns into a Placement in the dependent's own tree (lines 1059-1068); no copy anywhere returns a Placement in the top frame of the walk (lines 1050 and 1065), the lockfile root unless the subtree is bundled. Every other outcome (Hoisted, Rebind, the version-satisfied and root-dependency dedupes at lines 1012-1044) places nothing, so an entry at an intermediate path can only have been placed from that path's own package. That is what OwnEntry > WalkedToRoot > Walked encodes.
  • Optional peers: Rebind is produced only for is_optional_peer() (line 1017) and applied at line 885, in the Resolvable hoist that Lockfile::resolve runs both at the end of the load (bun.lock.rs, last statement of the package rows block) and in the clean before a save (src/install/lockfile.rs:1251). The third new test is the shape where ranking the root find above the walked one for an optional peer makes those two hoists disagree; I confirmed it fails --frozen-lockfile with that variant built, and passes with this branch and with main.

The bundled-subtree case is the one mentioned in the PR body as not covered: for the edges this change ranks, the walk is unbounded, so a find at a bundle root ranks as Walked and keeps the last-row behavior.

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.

1 participant