install: resolve a range onto an existing version only when every install has it - #38832
install: resolve a range onto an existing version only when every install has it#38832robobun wants to merge 5 commits into
Conversation
…tall has it A fresh `bun install` resolved a transitive range onto whatever lower version of the package happened to be in the lockfile already, if that version was an exact pin or of the same major. Whether such a version exists when the range is resolved depends on which parent's manifest the registry returned first, so the same package.json produced different lockfiles from one install to the next. `get_package_id` now settles a range onto an existing lower version only when that version is present on every install regardless of arrival order: loaded from the lockfile, appended for a root or workspace row (those are always resolved before transitive rows of the same name), or appended before resolution last drained completely (the regular rows are all resolved before peers are installed). Such a version is also preferred over a version some other transitive row appended even when the latter is the range's own best match, since its existence is equally order dependent. Everything else resolves to its best match from the manifest, which is what the range resolved to whenever it was processed first. The exact-pin / same-major policy for the versions that do qualify is unchanged, so every outcome that was already order independent stays the same.
|
Warning Review limit reached
Next review available in: 14 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
Updated 11:05 PM PT - Aug 14th, 2026
❌ @robobun, your commit 16ad612 has some failures in 🧪 To try this PR locally: bunx bun-pr 38832That installs a local version of the PR into your bun-38832 --bun |
|
Status: reproduced with a mock registry that holds one packument back until the other parent's tarball is requested ( Current state (16ad612): both review findings are in (0d28e11, 16ad612), every review thread is answered, and the latest review pass reported nothing further. CI for 16ad612 (build 97367): every lane that ran passed (177 jobs, no test failures); the build is marked failed only because its two |
… reusable Whether a version appended for a transitive row was recorded as pinned depended on which of the rows resolving to it happened to append it. Rows resolved after the version settles read that bit, so an exact row and a range row landing on the same version still gave two different answers to a later range from another major depending on arrival order. An exact regular row that resolves to an already-present version now records the pin as well, as long as the version is not reusable yet; once it is, rows are reading the bit and it stays as it is. Peer rows do not count, since whether they bind on sight or in the peer pass depends on arrival order.
The reuse path already skipped peer rows; the append path recorded them, so the pin on a version appended in the peer pass depended on which peer row got to it first.
There was a problem hiding this comment.
16ad612 addresses the peer-append asymmetry from my earlier note — pins now gates !behavior.is_peer() on both the reuse and append paths, so a peer row never stamps .pinned. No further issues found on this pass. Because this rewrites get_package_id's reuse rule and adds new resolver-phase state (settled_package_count, AppendedFor), a human look at the resolver semantics would still be worthwhile.
Checked: the is_reusable invariant against is_workspace_dependency and the FIFO ordering claim; that mark_settled_packages in wait_for_resolution runs between the regular drain and the peer pass (and again on the second wait_for_resolution in the --latest path); that the .eql() fallback still covers non-npm resolutions and should_update/suppress_peer_satisfies callers passing None; and that mark_pinned_by_reuse's freeze-once-reusable rules out the plain-OR regression the eighth test shape pins.
Extended reasoning...
Overview
This PR changes how Lockfile::get_package_id decides whether an npm range may resolve onto a version already in the lockfile, replacing the prior exact_pinned bitset with per-package AppendedFor { direct, pinned } state plus a settled_package_count watermark bumped between the regular and peer resolution passes. PackageManagerEnqueue.rs records pins on both the append and reuse paths (now gated on !behavior.is_peer() after 16ad612), install_with_manager.rs calls mark_settled_packages() in wait_for_resolution, and ~250 lines of new tests in bun-lock.test.ts force manifest arrival orders via a gated in-process registry across ten graph shapes.
Security risks
None identified. This is version-selection logic inside the resolver; no new inputs are parsed, no filesystem paths are derived from untrusted data, and the test registry is local (port: 0).
Level of scrutiny
High. get_package_id is on the hot path of every bun install and its output determines what ends up in bun.lock and node_modules. The change is a semantic rewrite of the reuse predicate with a subtle order-independence argument, and two earlier review passes each surfaced a real ordering hole (the .exact reuse gap fixed in 0d28e11, and the peer-append asymmetry fixed in 16ad612). The PR description also notes it will need a one-hunk rebase against #34336/#38770 and interacts with #37713 and the #36476 resolver rewrite — a maintainer familiar with those should confirm the intended reuse policy (each transitive range gets its own best match, bun dedupe collapses afterward) is the direction they want.
Other factors
The two prior findings from this reviewer were both addressed with follow-up commits and matching test shapes (shape 4 for the 🔴, shape 8 for the freeze rule ruling out a plain OR). The comment-cop bot still flags two doc comments on the latest push; the author has responded that these carry the invariant rather than justify a workaround, which seems reasonable but is a style call for a human. CI for 16ad612 is building (#97367). The test harness looks sound: it awaits a tarball request that can only follow the other parent's resolution, so the forced ordering is a real happens-before rather than a sleep.
Problem
bun install(nobun.lock) of{"a":"1.0.0","b":"1.0.0"}whereadepends onz@1.0.0andbonz@^1.0.0(registry hasz@1.0.0andz@1.1.0) writes one of two lockfiles:z@1.0.0alone, orz@1.0.0plusb/z@1.1.0. Roughly a 1:2 split over 30 clean installs; same on 1.3.14 and with--linker isolated. Found by the resolver property fuzzer (fresh-install determinism), which hit it in about a third of generated graphs.Lockfile::get_package_id(src/install/lockfile.rs) resolves a range onto a lower version of the package that already exists in the lockfile when that version was appended for an exact pin, or is of the same major as the range's best match. Whethera'sz@1.0.0exists yet whenb's row is resolved depends on whose manifest the registry answered first. Holdinga's manifest back forces the two-copy lockfile every time, holdingb's forces the one-copy lockfile every time.~1.0.0next to^1.0.0), and in the other direction: a range that the root's pin satisfies was instead resolved onto its best match whenever some sibling had appended that version first.exact_pinned) already stopped the cross-major variant of this; the exact-pin and same-major exemptions it kept are the remaining order-dependent cases.exact_pinnedwas set by whichever row happened to append the version. Witha -> z@1.1.0next tob -> z@^1.0.0(both 1.1.0), a range from another major resolved later (for examplez@*in the subtree of a peer, which is installed after the regular rows) was absorbed onto 1.1.0 or not depending on whethera's orb's manifest came first.Fix
get_package_idsettles a range onto an existing lower version only when that version is present on every install, whatever order manifests arrive in:AppendedFor::direct, recorded per appended package bymark_appended_for), orsettled_package_count, bumped bymark_settled_packagesinwait_for_resolutiononce every regular row is resolved, before peers are installed).AppendedFor::pinned) is no longer just the appending row's: an exact regular row that resolves to a version which is not reusable yet records the pin too (mark_pinned_by_reuse), and the record is frozen once the version becomes reusable, because from then on rows are reading it. So by the time anything reads it, it says whether any regular row pinned that version, which is a property of the inputs. Peer rows do not count, whether they reuse or append, since whether a peer binds on sight or in the peer pass, and which of several peers on one version gets to append it, depend on arrival order. The one already-deterministic outcome this moves: a version appended in the peer pass for a root or workspace exactpeerDependenciesrow (which is reusable right away, being direct) still absorbs same-major ranges from later peer-pass subtrees, but no longer cross-major ones.bun dedupestill collapses such copies afterwards when the user wants that.mark_settled_packagesruns nothing is in flight, so the set present at that point is itself a function of the inputs only.test/cli/install/bun-lock.test.ts: ten graph shapes, each installed under arrival orders forced by holding one manifest until a request that can only follow the other parent's resolution (the tarball of what it resolved to). The four shapes above fail on main (two different lockfiles); the six preserved-behavior shapes pass on main and still pass, one of them pinning that an exact row landing on the root's range-resolved version does not change what later rows get (which is what freezing the record is for); all ten pass with the fix.bun-install-registry.test.ts242/242 (the peer-subtree snapshotduplicate dependency in optionalDependencies maintains sort orderis what the settled watermark keeps unchanged),bun-lock,bun-update-transitive,bun-dedupe,hoist,lockfile-only,isolated-install,bun-workspaces,catalogs,overrides,nested-overrides,test-dev-peer-dependency-priority,bun-update-lockfile-sync,bun-add,bun-update,minimum-release-age,bun-remove,bun-prune,bun-pm-why, the autoinstall tests and the migration suites pass with the debug build.bun-install.test.tsandmigration/complex-workspaceonly fail their bitbucket/gitlab/off-box tests, which need network here.pinnedclause inget_package_id, so they compose, and whichever lands second rebases one hunk. install: resolve deferred*peers after their siblings, not on arrival order #37713 changes when*peers bind early; under this change that early binding only sees qualifying versions, so the two are independent. Deterministic dependency resolution: an ordered walk over a live tree #36476 (draft) replaces the resolver wholesale; this is the small fix for the property in the meantime.Background
get_package_id(name, range, best_match)is the step that decides whether a row reuses a package already in the lockfile or appendsbest_match(the highest version in the manifest that the range accepts). Reusing a version the range merely satisfies is what keeps one copy of a package in the tree; this PR only changes which existing versions are eligible for that.loaded_package_countis the existing watermark separating packages loaded frombun.lockfrom ones appended in this run;settled_package_countis the same idea taken again once resolution has drained.is_workspace_dependencyis the existing "is this row declared by the root or a workspace" predicate.is_reusablecombines the two watermarks with the direct flag; "reusable" throughout means "a range that merely satisfies this version may resolve to it".Probe: 10 shapes x forced arrival orders, main vs this branch
no test proof · iteration 1 · 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