Skip to content

install: re-resolve a peer row instead of binding it to a stale bun.lock leftover - #38901

Open
robobun wants to merge 7 commits into
mainfrom
farm/a1038672/peer-row-reresolve
Open

install: re-resolve a peer row instead of binding it to a stale bun.lock leftover#38901
robobun wants to merge 7 commits into
mainfrom
farm/a1038672/peer-row-reresolve

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun update -i offers a root peerDependencies row (peer-q peer 1.1.0 1.1.0 2.0.0, the Latest column is what selecting it promises). Enter then prints warn: incorrect peer dependency "peer-q@1.1.0", summarises (no changes), leaves bun.lock and node_modules on 1.1.0, and rewrites package.json from ^1.0.0 to ^1.1.0. Same for a workspace row under -i -r, and for the in-range case (locked 1.0.0, Target 1.1.0: nothing moves, the warning prints, the range is written back as it was).
  • Not specific to -i: bun update peer-q@2.0.0 on the same tree warns and writes ^1.1.0, bun add peer-q@2.0.0 warns and keeps 1.1.0 (fix(install): Resolve the requested version when bun add targets a peer dependency #30855's report), and editing the range to ^2.0.0 by hand and running bun install keeps 1.1.0 and warns. --latest works only because it writes a dist-tag literal, which the fallback below never matches.
  • Cause: get_or_put_resolved_package (src/install/PackageManager/PackageManagerEnqueue.rs, the install_peer branch). In the deferred peer pass a peer row binds to a package of its name already in the lockfile; when none satisfies the range it binds to the highest one anyway and warns. With an existing bun.lock that candidate is the package this same entry installed last time: nothing else depends on it, it is only still there because clean has not run yet. -i writes the range into package.json and runs bun update <name>, the differ re-enqueues the row, the pass binds it back to the leftover, and the named path's write-back then rewrites the range to the version it ended up on.

Fix

  • existing_peer_target replaces the Id/Ids arms (same satisfies scan, same highest-candidate fallback, same kind check; the warning and binding move into bind_existing_peer, shared with the site below). The fallback is skipped when would_revive_leftover: the row is a root or workspace entry (Lockfile::is_workspace_dependency, already used by this function for file: rows), the candidate was loaded from bun.lock (id < loaded_package_count), and the only rows still resolving to it are peer rows of workspace members or of dependencies. The row then resolves from the manifest like a peer nothing provides, the new package takes the entry's place in its node_modules, and clean drops the leftover.
  • What still blocks it, and why: a non-peer row resolving to the candidate means it is provided (a devDependencies entry next to the peer entry, a dependency's own copy), so the entry binds to it and warns as before. Any row of the root package.json, peer included, also blocks it: Tree::hoist_dependency dedupes every other peer row onto the root's copy whatever their ranges say, so a member entry that resolved its own copy would only have it dropped when the tree is written, and the warning with it (that is what the first revision did; "a member's rewritten entry keeps binding to the copy the root's own entry holds" pins it). Packages appended during the current resolve never qualify, so a fresh install binds exactly as before. Root rows are drained before member rows, so when root and member entries are rewritten together the root's new copy is there for the member to bind to.
  • A dependency's own peer rows are out of scope on purpose: they take whatever copy the tree offers them, and changing that is a separate decision from making a declared entry mean what it says. They behave exactly as on main (pinned with the two-copy setup below).
  • The fallback stays "the highest candidate or nothing" rather than moving down to a lower candidate, because the bun.lock loader binds an unsatisfied peer row to the highest copy too (resolve_peer_dep_version_based); a row that resolved afresh is saved next to a package that satisfies it. (Raised in review; the first revision picked the first non-leftover candidate.) The remaining way resolver and loader can disagree, an entry that nothing satisfies whose copy is outranked by another copy in the file, exists on main independently of this change (it is reached today by a regular dependency pulling in a higher copy) and is what install: keep a peer nothing in bun.lock satisfies where the file records it #38892 fixes in the loader.
  • When the entry passed over its leftover but the manifest has nothing for the new range, because no published version matches it or (raised in review) because the only matching releases are inside --minimum-release-age, the FindVersionResult::Err arm in the peer pass binds the leftover after all, so both shapes keep behaving exactly as on main: warn: incorrect peer dependency, old copy kept. The first of them is what the Windows lanes of test/cli/install/bunx.test.ts exercise: bun init installs the root peer typescript@^6, then the test runs bun add typescript@5.0.0, a version that was never published (5.0 shipped as 5.0.2); without the binding the first revision spun forever on a warm manifest cache (the loop install: stop looping on a peer dependency no published version satisfies #38851 has since fixed on main for the case with no copy at all; rebased onto it), and the age-gated shape turned the warning into an error. Every row that reaches that arm with this change also reached it on main.
  • resolution_satisfies_dependency had no other caller and is deleted.
  • Unchanged on purpose: bun update <name> / bare bun update on an entry whose locked version still satisfies it (the satisfies scan binds it first; the "never re-resolved" tests in bun-update-transitive.test.ts still pass), and -i on a peer row whose sibling devDependencies row is left unselected.
  • fix(install): Resolve the requested version when bun add targets a peer dependency #30855 fixes the bun add instance by skipping the fallback for every row whose name is an update request, tree-wide: after bun add react@18, each package with an older react peer range would resolve its own react instead of binding to the root's and warning. The checks here leave those bound.
  • Tests, all in test/cli/install/bun-update-transitive.test.ts. Fail on main, pass here: -i Target row, -i Latest row, -i -r member row, update <name>@<version>, install after an edit, the two-copy case (entry rewritten past both its own 1.1.0 and a dependency's 1.0.0 resolves 2.0.0 instead of binding 1.0.0), root and member entries rewritten together, only the root's entry rewritten (its copy is installed and the unchanged member is deduped onto it, as for a dependency bump; the tree's missing warning there is a pre-existing TODO, filed separately, so that test does not assert on stderr), and a member entry rewritten next to a sibling's when the root declares nothing (each member gets its copy). Pass on both and pin what stays: an entry another group provides, a member entry under a root entry (binds the root's copy and warns), a dependency's own peer row with the two copies (binds the highest and warns), bun add <name>@<unpublished version> (keeps the copy and warns; times out without the rebinding), the same entry under --minimum-release-age (keeps the copy and warns; errors without it), two unprovided peers on a fresh install. Also run locally: bun-update-transitive, bun-lock (with install: stop looping on a peer dependency no published version satisfies #38851's cases), bun-update-lockfile-sync, bun-update/bun-add, bun-install-registry -t peer, bun-install, isolated-install/bun-workspaces/frozen-lockfile-pruned, bun-audit/nested-overrides/hoist/bun-dedupe/bun-prune/isolated-relink/test-dev-peer-dependency-priority, catalogs/bun-add-filter/update_interactive_*, migration/; the only failures need public network access or were 5s timeouts on a heavily loaded machine that pass alone. On Windows against registry.npmjs.org: bun init + bun add typescript@5.0.0 warns and keeps 6.0.3 in under a second, bun add typescript@5.0.4 installs 5.0.4, and the bunx test passes.

Background

  • A package.json dependency is a row (buffers.dependencies[i]) owned by a package; buffers.resolutions[i] is the package it resolves to; the root package.json is package 0. package_index maps a name to every package of that name in the lockfile, highest version first. On an install with an existing bun.lock all of the old packages stay in the lockfile while rows are re-resolved; Lockfile::clean rebuilds it from the root at the end and drops whatever nothing reaches. loaded_package_count is how many packages came from bun.lock; packages resolved during the run are appended after them.
  • Peers resolve in two passes: while regular rows resolve, peer rows are parked in peer_dependencies; wait_for_resolution then drains them with install_peer = true, which is the branch changed here. A peer nothing in the tree provides is installed from the registry at that point; root and workspace peer entries are installed this way too, which is how the leftover got there.
  • Writing bun.lock and node_modules goes through the hoisted tree: the root's rows are placed at the top, a workspace's rows are hoisted there or placed in the workspace's own node_modules, and a peer row whose package conflicts with what is already at a level is deduped onto it when that copy satisfies the row, or unconditionally when the root's own row put it there.
  • bun update -i applies a selection by writing the chosen ranges into package.json and then running bun update <selected names>; that path re-resolves the named rows and writes each one's resolved version back into its range, which is where the ^1.1.0 came from.
Probes on main (loopback registry with peer-q 1.0.0 / 1.1.0 / 2.0.0)
# package.json: {"dependencies":{"b":"^1.0.0"},"peerDependencies":{"peer-q":"^1.0.0"}}, bun install -> peer-q@1.1.0

$ printf ' \r' | bun update -i
  peerDependencies 1 Current  Target  Latest
  > [x] peer-q peer  1.1.0    1.1.0   2.0.0
warn: incorrect peer dependency "peer-q@1.1.0"
Checked 2 installs across 3 packages (no changes)
# package.json now "peer-q": "^1.1.0", bun.lock still peer-q@1.1.0

$ bun update peer-q@2.0.0
warn: incorrect peer dependency "peer-q@1.1.0"
# package.json now "peer-q": "^1.1.0", bun.lock still peer-q@1.1.0

$ sed -i 's/\^1.0.0"}}/^2.0.0"}}/' package.json && bun install
warn: incorrect peer dependency "peer-q@1.1.0"
# bun.lock still peer-q@1.1.0

$ bun update peer-q --latest
^ peer-q 1.1.0 -> 2.0.0

With the fix the first three print ^ peer-q 1.1.0 -> 2.0.0 (install: + peer-q@2.0.0), write ^2.0.0, lock 2.0.0, and bun install --frozen-lockfile afterwards reports no changes. With a dev row on the same name, selecting both rows in -i still moves both to ^2.0.0, selecting the dev row alone still leaves the peer entry untouched, and selecting the peer row alone still warns and keeps 1.1.0, all as on main.

@coderabbitai

coderabbitai Bot commented Aug 15, 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: 9 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: 4ce8238f-1d7b-4b92-b3cb-2dad452078ae

📥 Commits

Reviewing files that changed from the base of the PR and between 9fb606f and e2c732a.

📒 Files selected for processing (2)
  • src/install/PackageManager/PackageManagerEnqueue.rs
  • test/cli/install/bun-update-transitive.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review (rebased onto main after #38851 landed; latest revision e2c732a).

Reproduced on main (0def731) with a loopback registry serving peer-q 1.0.0 / 1.1.0 / 2.0.0 and a root peerDependencies: { "peer-q": "^1.0.0" }: selecting the row in bun update -i printed warn: incorrect peer dependency "peer-q@1.1.0", left bun.lock on 1.1.0 and rewrote the range to ^1.1.0; bun update peer-q@2.0.0, bun add peer-q@2.0.0 and a hand edit plus bun install did the same. Nine cases in test/cli/install/bun-update-transitive.test.ts fail on main and pass with this branch; six more pin the shapes that stay as they are (a member entry under a root entry, a provided entry, a dependency's own peer row, an unpublished version, a release blocked by --minimum-release-age, a fresh install). The first CI run's only new failure (bunx.test.ts on Windows, bun add typescript@5.0.0 on a bun init project, a version that does not exist) is covered by the <unpublished version> case and was re-verified on Windows against the real registry.

CI: build 97953 (e2c732a) has every lane green except jobs that failed once and passed on retry (hook timeouts in two install files, a shell pipe test, napi node-gyp builds on Windows aarch64, cluster tests), none touching peer resolution; the Windows bunx hang from the first run is gone. Nothing further planned from my side; ready for a maintainer.

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. bun outdated doesn't update peer deps #13509 - Root peerDependencies: { "typescript": "^5.5.2" } with nothing else in the tree depending on typescript, so bun outdated flags it but bun update re-binds the peer row to the stale bun.lock entry — exactly the case is_stale_peer_target now excludes from the fallback.
  2. Bun broke optional peerDependencies constraits when i run bun i -d #19304 - Same shape (root-only peerDependencies entry, existing bun.lock) where a subsequent bun i -d breaks the previously-satisfied peer constraint; the report is video-only, so worth confirming against the recording before claiming it.

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #13509
Fixes #19304

🤖 Generated with Claude Code

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Neither of those is closed by this PR, so I am not adding them to the description.

  • bun outdated doesn't update peer deps #13509 is the other case: the locked typescript still satisfies ^5.5.2, so the satisfies scan binds it before the fallback changed here is reached, and bare bun update / bun update <name> leave such an entry alone both before and after this change (bun-update-transitive.test.ts pins that, "a pattern matches a peerDependencies entry" and the --prod case). What this PR does change for that shape is bun update -i: selecting the row now installs the version it shows instead of warning and writing the range back unchanged. Whether the non-interactive commands should move a still-satisfied root peer entry is a separate decision.
  • Bun broke optional peerDependencies constraits when i run bun i -d #19304 is about peerDependenciesMeta optional peers, which never enter the deferred peer pass this PR touches (enqueue_dependency_with_main_and_success_fn returns for them first), and the report has no text reproduction to check against.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:56 AM PT - Aug 15th, 2026

@robobun, your commit 09c526f is building: #97661

Comment thread src/install/PackageManager/PackageManagerEnqueue.rs Outdated
Comment thread src/install/PackageManager/PackageManagerEnqueue.rs Outdated
Comment thread src/install/PackageManager/PackageManagerEnqueue.rs Outdated
Comment thread src/install/PackageManager/PackageManagerEnqueue.rs Outdated
Comment thread src/install/PackageManager/PackageManagerEnqueue.rs Outdated
Comment thread src/install/lockfile/bun.lock.rs Outdated
Comment thread src/install/PackageManager/PackageManagerEnqueue.rs Outdated
Comment thread src/install/PackageManager/PackageManagerEnqueue.rs Outdated
Comment thread src/install/lockfile/bun.lock.rs Outdated
…ile leftover

In the deferred peer pass, a peer row whose range no package of its name
satisfies is bound to the highest same-kind package anyway, with the
"incorrect peer dependency" warning. When that package was loaded from
bun.lock and no non-peer row resolves to it any more, it is only there
because a peer row previously installed it; binding the row back to it
keeps the stale version and then, on the named update path, narrows the
declared range down to it. Leave such packages out of the fallback so the
row resolves afresh. Packages appended during the current resolve and
packages some non-peer row still resolves to are bound as before.

This is what `bun update -i`, `bun update <name>@<version>`, and a hand
edit of a peerDependencies range followed by `bun install` run into, as
well as a package whose replacement declares a different peer range.
…keep the highest-candidate fallback

Only a root or workspace peerDependencies entry passes over the leftover it
installed: a package's own peer rows take whatever copy the tree has, since a
copy resolved for them alone is never placed, so for them the warning stays.
The fallback stays the highest candidate or nothing, matching how loading
bun.lock binds, instead of moving down to a lower candidate. When the entry
passed over its leftover but nothing published matches its new range, bind
the leftover after all rather than returning the "manifest not loaded"
answer, which spins on a warm manifest cache and otherwise leaves the row
unresolved.
@robobun
robobun force-pushed the farm/a1038672/peer-row-reresolve branch from fd4260f to 8510010 Compare August 15, 2026 08:35
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed a reworked version (description updated to match):

  • The first CI run's one new failure was real: bunx.test.ts on Windows runs bun add typescript@5.0.0 on a bun init project (root peer typescript@^6), and 5.0.0 was never published. With the leftover passed over and nothing to resolve, the row hit the Ok(None) retry loop and spun. The NotFound site in the peer pass now binds the leftover after all, so that input behaves as on main (warning, old copy kept); reproduced the hang and verified the fix on Windows against registry.npmjs.org, and the <unpublished version> test times out without it. Rebased onto install: stop looping on a peer dependency no published version satisfies #38851, which fixed the same loop for the no-candidate case, and the two compose (one conflict at that site).
  • Per the review thread, the fallback stays the highest candidate or nothing, matching the bun.lock loader; and the rule is limited to root and workspace entries, since a copy resolved for a dependency's own peer row is never placed in the tree (it was being downloaded and then dropped on save, with the warning lost). Two tests pin the two-copy setup from both sides.
  • Comments in src/ are single lines; bun.lock.rs is no longer touched.

@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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/install/PackageManager/PackageManagerEnqueue.rs:2482-2493 — The nothing-published fallback binds to highest_peer_candidate at the moment this row is drained, but a later workspace peer row on the same name can still hit would_revive_leftover → resolve afresh → append a higher version, so the bound id is no longer candidates.first() in the saved lockfile and resolve_peer_dep_version_based flips the row on reload (one-time bun.lock rewrite / --frozen-lockfile failure). This is the residual case of the resolver/loader-agreement class 09c526f fixed for existing_peer_target; consider deferring the row until the other workspace peer rows on name_hash have drained, or re-visiting rows bound via this fallback once the peer pass is done.

    Extended reasoning...

    What the bug is

    existing_peer_target's doc comment (line 2815-2818) says "the fallback has to stay the highest candidate or nothing" so that resolve_peer_dep_version_based — which falls back to candidates.first() unconditionally at bun.lock.rs:3402 — reproduces the resolver's choice on load. Commit 09c526f enforced that for existing_peer_target itself (the fallback is now the highest candidate or None, never a lower one). But the second fallback added at line 2482-2493 ("Nothing published to resolve afresh: the entry that passed over its leftover keeps it") binds to highest_peer_candidate at the moment that row is processed, and a later-drained workspace peer row on the same name can still resolve afresh and append a higher version — so what was highest at line 2483 is no longer candidates.first() in the saved lockfile.

    Why the later row still resolves afresh

    would_revive_leftover (line 2917) filters out peer rows via !dep.behavior.is_peer(). So after the unpublished-range row binds the leftover via bind_existing_peer(false) at line 2486, a subsequent workspace peer row on the same name still sees the leftover as reached only by peer rows → would_revive_leftover returns true → existing_peer_target returns None → the row falls through to the manifest and appends a fresh, higher version.

    Step-by-step proof

    Setup: root has peerDependencies: { react: "15.0.0" } (a yanked/typo'd version — nothing published matches). Workspace member W has peerDependencies: { react: "^18" }. bun.lock carries a peer-only leftover react@17.0.2 (installed for one of these rows in a previous run). Root's row is enqueued into peer_dependencies before W's.

    1. Root's row drained. existing_peer_target: 17.0.2 doesn't satisfy 15.0.0; fallback highest is 17.0.2; would_revive_leftover(17.0.2) → true (workspace row ✓, id < loaded_package_count ✓, only peer rows resolve to it) → returns None. Manifest: find_best_version_with_filter("15.0.0")NotFound. Line 2483: highest_peer_candidate → 17.0.2 (only entry in package_index[react] at this moment). bind_existing_peer(false) → warn, resolutions[root_row] = 17.0.2.
    2. W's row drained. existing_peer_target: 17.0.2 doesn't satisfy ^18; would_revive_leftover(17.0.2) is still true — root's row is a peer row, filtered out by !dep.behavior.is_peer() at line 2917 → returns None. Manifest → react@18.x appended. resolutions[W_row] = 18.x.
    3. clean and save. Root → 17.0.2 (peer), root → W → 18.x (peer). Both kept. Saved bun.lock: package_index[react] = [18.x, 17.0.2], highest first.
    4. Reload. resolve_peer_dep_version_based for root's 15.0.0: neither 18.x nor 17.0.2 satisfies → fallback to candidates.first() = 18.x. Root's row flips from 17.0.2 (resolver) to 18.x (loader). On the next bun install, clean finds 17.0.2 unreachable and drops it; bun.lock is rewritten on a no-op install and --frozen-lockfile fails once.

    Why not already prevented

    The updated doc comment at bun.lock.rs:3320-3325 argues the divergence case "makes the edge resolve afresh to a package its range accepts, which is what gets saved and what the satisfies scan above finds." That covers the case where the row did resolve to a satisfying package. Line 2486 is precisely the case where it did not (nothing published matches), and the leftover it was bound back to is no longer candidates.first() after step 2. Before this PR the same tree bound both rows to 17.0.2 (neither could skip the leftover), so only one react was saved and the loader trivially agreed — the divergence is new.

    Impact and severity

    This is the residual case of the resolver/loader-agreement class the earlier review comment flagged and 09c526f fixed for existing_peer_target, so per REVIEW.md's "fix the whole class in the same PR" it belongs here. That said, the trigger is narrow — a root/workspace peer entry whose range matches nothing published (typo/yanked), plus another root/workspace peer on the same name resolving higher than the leftover, plus a peer-only leftover in bun.lock, plus the unpublished row FIFO'd first — and the symptom is a one-time lockfile rewrite that then converges (arguably to a better state than pre-PR, where both rows were stuck on 17.0.2). Marking as a nit: worth addressing alongside the class, not worth blocking the fix for the much more common bug.

    Possible fix

    At line 2486, only take the fallback when the highest cannot later be superseded — e.g. defer the row (re-write_item to peer_dependencies) until every other workspace peer row on name_hash has drained, or after the peer pass, re-visit rows bound via this fallback and rebind them to the then-current highest_peer_candidate.

…esolving afresh

The tree dedupes every other peer row onto the copy a root row holds, whatever
their ranges say, so a workspace entry that resolved its own copy would have it
dropped when the tree is written and lose the warning with it. Count the root's
rows, peer or not, as holding the copy; the entry then binds to it and warns as
before. Covers the root-plus-member, root-and-member-rewritten, and
members-only shapes.
Comment thread src/install/PackageManager/PackageManagerEnqueue.rs Outdated
…nly NotFound

A version blocked by --minimum-release-age took the TooRecent arm before the
NotFound site rebound the copy, turning the warn-and-keep of main into an
error that dropped the package. Bind it once where the manifest lookup fails,
ahead of both arms; the NotFound site goes back to what main has.
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

On the additional finding (unpublished root range plus a member entry on the same name): I ran that shape and a few neighbours. The real problem it uncovered was a step earlier than the reload: the member's copy was being resolved and then dropped when the tree was written, because Tree::hoist_dependency dedupes every other peer onto the copy a root row holds regardless of range, so the member entry ended up silently unmet where main warns. b0aeaa6 makes any root row holding the copy, peer rows included, block the member entry from resolving afresh, so in the scenario above the root's entry rebinds its copy (warning), the member's entry then binds the same copy (warning), one copy is saved, and the loader agrees, exactly as on main. Three new tests cover root plus member, both rewritten together, and members only.

What is left of the resolver/loader disagreement is an entry that nothing satisfies whose copy is outranked by another copy in the file. That is independent of this change: on main today, peerDependencies: { "no-deps": "1.5.0" } (unpublished) next to one-fixed-dep@1.0.0 binds the entry to the 1.0.0 that dependency brings in; bumping one-fixed-dep to 2.0.0 appends no-deps@2.0.0 under it, and the next install (frozen or not) rebinds the root entry to 2.0.0 and installs it at the top while bun.lock keeps saying 1.0.0 and is never rewritten, so the symptom is a stale root entry rather than a one-off rewrite. #38892 fixes that in the loader by binding root and workspace rows to the entry printed at their own path before the version scan, which also covers the variant reachable through this PR (two members, one with an unpublished range). Nothing here depends on the order the two land in.

Comment thread src/install/PackageManager/PackageManagerEnqueue.rs

@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 latest revision (e2c732a) and found no new issues — the added test pins the root-edited/member-unchanged case from my earlier note, and the FindVersionResult::Err rebinding now covers both NotFound and TooRecent. Because this reworks the deferred-peer binding path and its agreement with resolve_peer_dep_version_based in the loader, a human look at the would_revive_leftover predicate and its interaction with Tree::hoist_dependency would still be worthwhile.

What was reviewed:

  • existing_peer_target / highest_peer_candidate / bind_existing_peer against the removed Id/Ids arms — same satisfies scan, same tag check, same warning text; the only behavioural delta is the would_revive_leftover gate.
  • The FindVersionResult::Err rebinding runs before the TooRecent return and before the NotFound fall-through, so both shapes keep main's warn-and-keep.
  • would_revive_leftover's owner == 0 || !is_peer() predicate against Tree::hoist_dependency's root-dedupe and the loader's highest-first fallback — the remaining resolver/loader disagreement is the pre-existing one #38892 addresses.
  • resolution_satisfies_dependency had no other callers.
Extended reasoning...

Overview

The PR changes get_or_put_resolved_package's install_peer branch in src/install/PackageManager/PackageManagerEnqueue.rs: the duplicated Id/Ids match arms (~100 lines) are replaced by four helpers — existing_peer_target (satisfies scan then highest-or-nothing fallback), highest_peer_candidate (same-kind tag check on candidates[0]), bind_existing_peer (the warning + success_fn call, shared with the manifest-miss site), and would_revive_leftover (the new predicate: root/workspace row, id < loaded_package_count, no non-peer or root-owned row resolves to it). The FindVersionResult::Err arm now rebinds the passed-over leftover before returning either error. resolution_satisfies_dependency is deleted (no other callers). ~260 lines of tests in bun-update-transitive.test.ts cover the fixed paths and pin the shapes that stay as on main.

Security risks

None identified. This is dependency-resolution bookkeeping over data already parsed into the lockfile; no new parsing of untrusted input, no filesystem path handling, no network surface beyond what bun install already reaches.

Level of scrutiny

High. Peer resolution in bun install is a correctness-critical path where a wrong binding silently installs the wrong version or diverges from what --frozen-lockfile reproduces. Two earlier revisions of this PR each had a 🔴 finding (fallback-vs-loader disagreement; TooRecent bypassing the rebind), both fixed. The would_revive_leftover predicate is a heuristic over the whole buffers.resolutions table whose correctness depends on drain order (root before members), on Tree::hoist_dependency's root-dedupe rule, and on the loader binding the same highest candidate — invariants that live in three other files. That is exactly the shape a maintainer familiar with the resolver should sign off on.

Other factors

  • The author addressed all three prior findings with code + pinning tests, and the PR description traces each design choice (highest-or-nothing, root-row block, dependency-owned peers left alone, #38892 handling the remaining loader gap) to a concrete failure mode.
  • The one open note (🟡, root edited / member unchanged loses the warning for one install) is diagnostic-only, the on-disk result is better than main's, and the fix belongs at the pre-existing Tree.rs TODO; the new test pins current behaviour without asserting stderr.
  • Test coverage is thorough (14 new cases, each ending in --frozen-lockfile to check loader agreement), but the resolver/loader/tree interaction is subtle enough that I'm not confident a human reviewer wouldn't want to weigh in on the predicate's exact shape.

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