Conversation
|
Warning Review limit reached
Next review available in: 23 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 (2)
Comment |
|
Updated 11:05 PM PT - Aug 14th, 2026
❌ @robobun, your commit c9bdca1 has 1 failures in 🧪 To try this PR locally: bunx bun-pr 36285That installs a local version of the PR into your bun-36285 --bun |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
Neither of those is covered by this change:
This PR only changes |
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
test/cli/install/bun-workspaces.test.ts:2155-2157— Nit:setup()acquires a semaphore slot viaawait setupTest()and then runsexpect(...)assertions before returningctx— if any of those throw, the caller'susing ctx = await setup()never binds and the slot is never released. Wrap the post-setupTest()body in a try/catch that callsctx[Symbol.dispose]()before rethrowing (mirroring howsetupTest()itself releases on error). Impact today is nil (3 serial tests, last in the file, 12-slot pool), but it violates the file's own convention and REVIEW.md's "release every resource … BEFORE the assertions" rule.Extended reasoning...
What the bug is
The new
setup()helper in the"bun.lock workspace version tracks package.json version"describe block does:async function setup() { const ctx = await setupTest(); // <-- acquires a semaphore slot, returns disposable const { packageDir, packageJson, env } = ctx; await Promise.all([ write(...), ... ]); let { stderr, exited } = spawn({ cmd: [bunExe(), "install"], ... }); const err = await stderr.text(); expect(err).not.toContain("error:"); // <-- can throw expect(await exited).toBe(0); // <-- can throw const lockfile = await file(...).text(); expect(workspaceEntry(lockfile, "packages/pkg-a")).toBe(...); // <-- can throw return ctx; }
Callers use
using ctx = await setup();. Theusingdisposal only arms after the awaited expression resolves — ifsetup()rejects,ctxis never bound andctx[Symbol.dispose]()(which callsreleaseSlot()) never runs. The slot acquired bysetupTest()is leaked for the rest of the file.Why existing code doesn't prevent it
setupTest()itself is careful: afterawait acquireSlot()it wraps the remainder intry { ... } catch (e) { releaseSlot(); throw e; }so a failure insidesetupTest()never leaks. But once it returns, that guard is gone — the responsibility passes to whoever holdsctx. Every other test in this file armsusing ctx = await setupTest();before running any assertions, so disposal is always registered when the firstexpectruns.setup()breaks that contract by inserting assertions between acquire and return.This is exactly the pattern REVIEW.md's test rules call out:
Release every resource via
using/await usingor try/finally registered BEFORE the assertions (cleanup after expectations leaks on the first failure and poisons later tests on persistent CI runners)Step-by-step proof
- Test
"bun install --lockfile-only" after bumping...starts, callsawait setup(). setup()callsawait setupTest()→acquireSlot()incrementsactiveSlotsfrom 0 → 1 and returns actxwhose[Symbol.dispose]callsreleaseSlot().- Suppose the initial
bun installon this CI runner exits nonzero (disk full, ASAN issue, etc.).expect(await exited).toBe(0)throws. setup()has no try/catch around this — the exception propagates and the returned promise rejects.- In the test body,
using ctx = await setup()never completes the binding, so no dispose is registered. Test fails. activeSlotsstays at 1.releaseSlot()is never called for this test.
Repeat for the other two
setup()callers → up to 3 leaked slots.Impact
Effectively none today, which is why this is a nit:
- Only 3 tests call
setup(), all plaintest(serial), so at most 3 ofMAX_CONCURRENT = 12slots can leak — not enough to starve anything. - The describe block is the last thing in the file; nothing runs after it.
activeSlotsis a module-locallet, not persisted across processes, so "poisons later tests on persistent CI runners" doesn't apply here.
The only concern is fragility: if someone later adds
test.concurrentcases after this block, or reorders, a flaky initial install would silently reduce available concurrency for the rest of the file.Fix
Mirror
setupTest()'s own pattern:async function setup() { const ctx = await setupTest(); try { const { packageDir, packageJson, env } = ctx; // ... writes, spawn, expects ... return ctx; } catch (e) { ctx[Symbol.dispose](); throw e; } }
(Alternatively, move the initial-install assertions into each test body after the
usingbinding, but the try/catch is the smaller diff.) - Test
There was a problem hiding this comment.
LGTM — the earlier build-metadata gap is fixed and covered by a test; the remaining describe.concurrent note is just a CI-speed nit.
Also checked this pass: DiffSummary derives Default so the new flag stays false on non-root/per-member diffs (Package.rs:1483 unaffected); the had_any_diffs branch in install_with_manager.rs:412-421 already re-copies workspace_versions, so flipping has_diffs() is sufficient; ExternalString.hash is content-derived, so comparing pre/build hashes across the two lockfiles' independent string buffers is valid.
Extended reasoning...
Overview
Adds a workspace_versions_changed flag to DiffSummary in src/install/lockfile/Package.rs, computed on the root diff pass by comparing from_lockfile.workspace_versions against to_lockfile.workspace_versions (count, key presence, and per-entry Version::eql + tag.build.hash). The flag feeds into has_diffs(), which gates both the workspace_versions re-copy in install_with_manager.rs:412-421 and the lockfile save at line 850. New regression test file with 5 cases: bump via --lockfile-only, bump via plain install, build-metadata-only bump, adding a version where none existed, and a no-op guard.
Security risks
None. This is a lockfile-freshness comparison over already-parsed semver data; no new parsing of untrusted input, no filesystem writes beyond the existing lockfile save path.
Level of scrutiny
Medium. The change touches bun install's diff decision, but it is purely additive — a new boolean OR'd into has_diffs() — and mirrors the patched_dependencies_changed block immediately above it line-for-line. Failure modes are bounded: a false positive would rewrite an unchanged lockfile (guarded against by the no-op test), and a false negative is the pre-existing bug. I confirmed the second has_diffs() call site at Package.rs:1483 (per-workspace-member diffs) is unaffected because the flag is only set under if is_root and DiffSummary derives Default.
Other factors
My previous review flagged that Version::eql ignores build metadata; the author added the tag.build.hash comparison and a dedicated 1.0.0+build.1 → 1.0.0+build.2 test in 1abc9e3. The comment-cop feedback was addressed in 46339b0. The version comparison uses content hashes (ExternalString.hash), not buffer offsets, so comparing across two lockfiles with separate string buffers is sound. One expected consequence not explicitly tested: --frozen-lockfile will now (correctly) fail when a workspace's package.json version disagrees with bun.lock, since the copy path now runs and Lockfile::eql sees the difference — that's the semantically correct behavior for a frozen check. The only new finding this run is the describe.concurrent nit, which is a test-speed suggestion, not a correctness issue.
There was a problem hiding this comment.
LGTM — small additive change following the existing patched_dependencies_changed pattern, with all prior review feedback addressed.
What was reviewed:
- Verified the version comparison is hash-based (
major/minor/patchints +pre.hash+build.hash), so it's safe across two lockfiles with different string buffers. - Confirmed the
is_rootgate keeps the check out of the recursive per-workspaceDiff::generatecall (which passesid_mapping = None), soupdate_mappingat Package.rs:1483 is unaffected. - Traced
has_diffs()to thehad_any_diffselse-branch ininstall_with_manager.rs— that path already runs for every other diff type, and the no-op test guards against false positives.
Extended reasoning...
Overview
This PR fixes #18906: bumping a workspace package's "version" in its package.json and running bun install (or --lockfile-only) left the workspace's version in bun.lock stale. The fix adds a workspace_versions_changed: bool to DiffSummary, computes it in Diff::generate on the root pass by comparing the workspace_versions maps between the loaded and fresh lockfiles, and includes it in has_diffs(). Two files touched: 25 lines in src/install/lockfile/Package.rs and a new 157-line regression test.
Security risks
None. No untrusted input handling, no auth/crypto/permission surface. The change reads two in-memory hash maps and compares u64 hashes and integer version components.
Level of scrutiny
Moderate. This is bun install core diff logic, but the change is purely additive: a new bool field (defaults to false via #[derive(Default)]), only set in the root-pass if is_root block. It follows the exact same shape as patched_dependencies_changed and catalogs_changed directly above it. I checked:
DiffSummaryis only ever built via::default(), so no explicit-field constructors break.- The recursive
Diff::generateat Package.rs:1457 passesid_mapping = None, sois_root = falsethere and the new check doesn't run — the!diff.has_diffs()at line 1483 that gatesupdate_mappingis unaffected. Version::eqlcomparesmajor/minor/patchas integers andtagviapre.hash; the addedtag.build.hashcomparison closes the build-metadata gap I flagged earlier. All hash/int-based, so comparing versions from two lockfiles with different string buffers is safe (VersionHashMapis keyed byPackageNameHash, also buffer-independent).- The count check + per-key-in-
toloop is sufficient: if counts match and everytokey exists infromwith an equal value, the maps are equal; a rename (remove A, add B, same count) hits theNonearm. - The
had_any_diffselse-branch ininstall_with_manager.rs:278that this now triggers for version-only bumps is the standard "something changed, re-copy state" path already exercised by every other diff type. Worst-case false positive is an unnecessary lockfile rewrite; the no-op test guards this.
Other factors
Two prior rounds of my review feedback are resolved: the tag.build.hash comparison was added with a dedicated test, and the test file was switched to describe.concurrent. The comment-cop flag was addressed. Tests cover --lockfile-only, plain install, build-metadata-only bumps, adding a version where none existed, and the no-change no-op guard. The PR body includes evidence that tests fail on both ASAN-debug and release without the fix and pass with it. Tests use tempDir/bunEnv/bunExe, drain pipes concurrently, and assert stderr before exit code per harness conventions.
There was a problem hiding this comment.
LGTM — the earlier feedback (build-metadata comparison, describe.concurrent) is addressed and the no-op guard test covers the false-positive case.
What was reviewed:
Diff::generateaddition mirrors the existingpatched_dependencies_changedpattern;is_rootgate matches thecatalogs_changedblock and the recursive workspace call passesid_mapping: None, so it stays untouched on non-root passes.Version::eql+tag.build.hashare buffer-independent (integer/hash comparisons), so comparing versions across the two lockfiles' string buffers is sound.- Confirmed
had_any_diffsgates the existingworkspace_versionscopy ininstall_with_manager.rs:411-421, so the new flag reaches the save path with no other wiring needed.
Extended reasoning...
Overview
The PR adds a workspace_versions_changed flag to DiffSummary and computes it in Diff::generate on the root pass by comparing from_lockfile.workspace_versions against to_lockfile.workspace_versions (count, key set, and per-entry Version::eql + tag.build.hash). The flag is OR'd into has_diffs(), which install_with_manager already uses to decide whether to copy the fresh lockfile's workspace_versions into the loaded lockfile and mark it for saving. Five regression tests in test/regression/issue/18906.test.ts cover plain bun install, --lockfile-only, build-metadata-only bumps, adding a version where none existed, and the no-change no-op guard.
Security risks
None. This is a lockfile-freshness check on locally-parsed package.json versions; no untrusted input, no allocation driven by external data, no auth/crypto.
Level of scrutiny
Moderate — bun install is production-critical, but the change is small (~25 lines), purely additive (only widens the set of cases where has_diffs() is true), and follows the exact shape of the neighboring patched_dependencies_changed and catalogs_changed blocks. The recursive Diff::generate call for workspace packages passes id_mapping: None, so is_root is false there and the new block is skipped — the inner diff.has_diffs() at Package.rs:1483 is unaffected. Version::eql and the .hash fields are integer comparisons, so the cross-lockfile-buffer comparison is sound without slicing into either buffer.
Other factors
Two prior rounds of my feedback are resolved: (1) the Version::eql precedence-vs-identity gap for +build metadata is closed by the added tag.build.hash comparison plus a dedicated test, and (2) the test file now uses describe.concurrent. The PR description includes fails-on-main / passes-on-PR evidence for both debug-ASAN and release. The no-op test ("with no version change is still a no-op") guards against the new flag causing spurious lockfile rewrites. The comment-cop note about a long explanatory comment was also addressed (comment removed). No outstanding reviewer comments remain.
|
CI is blocked on build infrastructure, not on this diff. build 84639: 34 jobs passed, then build 84714 (retrigger): the Rust build step finished successfully on every lane (
|
alii
left a comment
There was a problem hiding this comment.
The fix is the right shape but the comparison needs to be on tag contents, and the tests need a second install after a bump to show the flag settles.
- hash compare flaps forever on versions with an empty identifier (
1.0.0-,1.0.0+), see inline - no test reinstalls after a bump, so nothing pins that the rewrite happens once; removal direction and hoisted linker are also untested
- the Verification section in the body still describes the earlier revision (bun-workspaces.test.ts, 4 tests). The "release without fix" block was built from 079df30, which is this branch's own commit, so it is not evidence of anything; the ASAN leg is the one that shows the tests fail without the fix.
- needs a rebase, DiffSummary fields are pub(crate) on main now.
18c3661 to
d050ae7
Compare
|
Reworked in d050ae7, rebased onto main (
|
alii
left a comment
There was a problem hiding this comment.
Looks ready to merge from this side. Traced the byte comparison for 1.0.0-, 1.0.0+, 1.0.0rc.1 and a normal prerelease with identifiers longer than the inline limit: both maps slice their own string buffer, empty identifiers come out as the zeroed String on both sides, and everything the writer emits reparses to the same pre and build bytes, so a second install produces no diff. The removal direction, the from-side walk, the hoisted leg (main prints "1 package installed" on every install after the bump, reproduced) and the whole-file snapshots all check out against main's writer output; the pass/fail counts in the body were not rerun here.
|
Final CI state for the rebased c9bdca1 (build 97270, now finished): 176 of 179 jobs passed, including every ASAN lane;
Nothing in the build is attributable to this change; ready to merge from my side. |
|
@robobun this conflicts with main now, please rebase and get a fresh CI run so it can be merged. |
Bumping a workspace's "version" in its package.json and running
`bun install` (or `bun install --lockfile-only`) left the old version in
bun.lock. Diff::generate only compared dependency edges, overrides,
catalogs, trusted and patched dependencies, so a version-only change
produced no diff, the fresh workspace_versions map was never copied into
the loaded lockfile, and the save gate stayed closed. With the hoisted
linker the stale map also made every later install report
"1 package installed" for the bumped workspace.
Compare the two workspace_versions maps on the root pass (numeric parts
plus the pre/build identifier bytes from each lockfile's own string
buffer) and record the result in DiffSummary so has_diffs() opens the
existing copy-and-save path. Comparing bytes rather than tag hashes means
spellings that serialize identically ("1.0.0-" is written as "1.0.0")
settle after one write.
Fixes #18906
d050ae7 to
c9bdca1
Compare
|
Rebased onto 39fb3c1 and force-pushed as c9bdca1; fresh CI is running. Two things changed in the rebase, both noted in the body:
Re-verified after the rebase with main's |
…on (#41302) ### What does this PR do? Fixes #40393, fixes #40366, fixes #18906. Supersedes #40396 and #36285; after a `bun install`, #20477's `bun pm pack` reads the current workspace version too. #40393 at its source: Whether an npm range links to the same-named workspace was decided in three places: the resolver and the `--filter` graph link a range that satisfies the workspace's version *or* is `*`; the package.json parser only had the first half. bun.lock records edges by what the resolver did, so `"pkg": "*"` on a versionless or prerelease workspace reloaded as a workspace edge while reparsing package.json gave an npm edge. Every `bun install` saw that workspace as "updated 1 dependencies" (visible with `--verbose`) and re-resolved it; `bun prune` / `bun dedupe` refused with "bun.lock does not match package.json". Now there is one rule, `linked_workspace_path`, shared by the parser, resolver and `--filter` graph, and one `tag_workspace_links` pass that every lockfile loader (bun.lock, bun.lockb, npm/yarn/pnpm migration) runs before building its tree. Loaders rebuild an edge from its literal; the pass gives a `workspace:` edge its path and turns an npm range into a workspace edge only when the rule, asked with the workspaces the lockfile records and the current `linkWorkspacePackages`, links it — the decision the parser makes for the same text. Ranges bound to a workspace another way (a peer that took a sibling `workspace:*`'s version, an override, a link only npm could have made) stay ranges as they do in a reparse, so they are sticky rather than phantom updates. `catalog:`, dist-tag and folder edges keep their parsed shape. This also ends the phantom updates for a name listed in two dependency groups and for every linked range after a bun.lockb load. With `linkWorkspacePackages = false`, edges an earlier lockfile linked stay linked until the lockfile is regenerated. Because more edges now carry a workspace path, two places that dropped it are fixed: cloning a dependency between buffers re-derived the path from the literal (`bun update` with a root `npm:` alias of a workspace failed with "Workspace dependency not found"; the yarn.lock printer now lists one key per requested spec as written), and a `$name` override copied the root dependency's tag with its literal (an override referencing a linked range read back from bun.lock as changed on every install; `$name` also no longer matches the root's `workspaces` entries). A root `*` on its own prerelease workspace now links it, as it already did from any other workspace. A changed workspace `version` (compared as bun.lock prints it, so `1.0.0-` settles as `1.0.0`) now rewrites the lockfile, including under `--lockfile-only` (#18906); nothing diffed it before, and with the phantom diffs gone nothing refreshed it by accident either. `bun pm pack` substitutes `workspace:` ranges from that recorded version. #40366 is the dev + peer same-name case: the old loader only reshaped the first of the two edges. Migrated lockfiles get the same pass, so a workspace depending on a sibling by `^x` or `*` keeps its registry pins across the migrating install, and npm links bun's rule would not make survive later installs instead of being re-resolved on the second one; the npm migrator's renamed-link bookkeeping (first install only) is removed, and the npm and pnpm migrators now record workspace versions from package.json into the lockfile buffer. Known and unchanged: an override that redirects a workspace-named package away from the workspace still reloads differently from the reparse; switching a linked spelling (`*` ↔ `workspace:*`) does not rewrite the literal in bun.lock. ### How did you verify your code works? New cases in `bun-prune` / `bun-dedupe` (`*` on versionless/prerelease workspaces, `npm:` and `workspace:` aliases, `catalog:`, dev+peer same name), `bun-workspaces` (`*` follows a versionless workspace being added and removed; root `*` on a prerelease workspace; root alias survives `bun update`; `$ref` of a linked range round-trips `--frozen-lockfile`; a workspace version bump rewrites bun.lock), and `migrate.test.ts` (migrated pins kept, no manifest fetch across two installs). Each fails on 1.4.0 and passes on a debug build. Existing suites run locally: `bun-lock`, `lockfile-version-2`, `bun-add-filter`, `nested-overrides` `$ref`, `test-dev-peer-dependency-priority`, `migrate-bun-lockb-v2`, the pnpm migration suites, regression 3192, and the workspace subsets of `bun-workspaces`, `bun-install` and `migrate`. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
|
Superseded by #41302. |
|
Confirmed on main at f942cb0. The 8 cases in this branch's |
Fixes #18906
Problem
"version"in itspackage.jsonand runbun installorbun install --lockfile-only:bun.lockkeeps the old version. With the hoisted linker every later install also reports1 package installedfor the bumped workspace, because the installer checks it against the stale version map.install_with_managerparses the rootpackage.jsoninto a fresh lockfile and callsDiff::generateagainst the loaded one.DiffSummary::has_diffs()(src/install/lockfile/Package.rs) only covers dependency add/remove/update, overrides, catalogs, trusted and patched dependencies. Workspace versions are never compared, so a version-only change yields no diff, theworkspace_versionscopy ininstall_with_manager.rsis skipped, and the save gate stays closed.Fix
Diff::generatecomparesworkspace_versionsbetween the loaded and fresh lockfiles on the root pass (count, then per entry major/minor/patch plus the pre and build identifier bytes read from each lockfile's own string buffer, then the reverse key walk), mirroring thepatched_dependencies_changedblock above it. The result is a newDiffSummary::workspace_versions_changedincluded inhas_diffs(), which opens the existing copy-and-save path. It is deliberately not part ofchanges_resolutions(): a version bump does not change what any dependency resolves to, so it should not affect the optional-peer and direct-dependency snapshots that method gates.bun.lockomits empty identifiers, so"1.0.0-"is written back as"1.0.0"; comparing the bytes makes such a project settle after one write instead of depending on how empty identifiers hash.test/regression/issue/18906.test.ts(8 cases). Each case that changes a version checks the next install printsSaved lockfile, inline-snapshots the wholebun.lock, and then checks one more install printsno changeswithout saving. Cases: bump under--linker=isolatedand--linker=hoisted, bump with--lockfile-only, build metadata only, prerelease (1.0.0to1.0.0-beta.1+build.7to1.0.0-beta.2+build.7), adding a version, removing a version, and the1.0.0-settle guard.src/install/lockfile/Package.rschecked out and rebuilt,bun bd test test/regression/issue/18906.test.tsgives 7 fail / 1 pass (the1.0.0-guard passes either way; it guards the comparison, not the bug); with the change restored it gives 8 pass on repeated runs. The file sets a 30s default timeout because each case runs three or four installs concurrently with its siblings, which tipped over the 5s default once on a cold debug build.test/cli/install/lockfile-only.test.ts(pass).--frozen-lockfileafter a bump behaves as on main (no error, file untouched).Background
bun.lockhas aworkspacessection listing each workspace's name, version and dependencies. In memory that version lives inLockfile.workspace_versions, a map from package name hash toSemver::Version.bun install, bun loads the existing lockfile, parses the currentpackage.jsonfiles into a second, throwawayLockfile, and runsDiff::generatebetween them.DiffSummaryis the result;has_diffs()decides whether the loaded lockfile gets the fresh data copied in and is rewritten, or is reused as is.Semver::Versionstores the numeric parts inline and the prerelease/build identifiers as offsets into the owning lockfile's string buffer, so comparing two versions from different lockfiles needs both buffers.Version::eqlis SemVer precedence equality (ignores build metadata), which is why it is not used here.Earlier revision
The first revision compared with
Version::eqlplustag.build.hash, had its tests inbun-workspaces.test.ts, only ran under the isolated linker and never reinstalled after a bump. Reworked after review: byte comparison, reverse key walk, standalone test file covering both linkers, settle-after-change, prerelease, add and remove.