install: diff and apply workspace members row by row, like the root package
Fixes #26070
Problem
- In a
bun.lockworkspace,bun cifails witherror: lockfile had changes, but lockfile is frozenwhen nothing changed. The trigger is a member with an install script and alatestdependency that published. A plainbun installmoves the pin. Diff::generate_inner(src/install/lockfile/Package.rs:1653) compares a member's scripts with the lockfile.bun.lockrecords none, so the member always reads as changed.- A changed member is read from disk again (
src/install/resolvers/folder_resolver.rs:364), and all of its rows resolve again.
Fix
- The diff compares scripts only when the lockfile recorded them (
bun.lockb). - A changed member gets a row mapping, as the root package does.
install_with_managerapplies it in place, so only changed rows resolve again. - Verified:
test/cli/install/bun-workspaces.test.ts(34 new cases, 29 fail on main). Alsobun-lock.test.tsandfrozen-lockfile-pruned.test.ts.
Background
Diff::generatecompares the loaded root package with a fresh parse ofpackage.json. Its mapping says which new row keeps which loaded resolution. A row with no mapping resolves again, and a dist-tag row then takes the newest version.- Considered the script guard alone: a real member edit still moves every
latestrow of that member.
Downsides
bun cinow passes when a member's specifier differs frombun.lockand the locked version satisfies it. The root package already does this.bun updatenow leaves only its targets unmapped. Without-rit no longer moveslatestrows of other workspaces.generate_innerruns 5 more instructions per root dependency (813 to 853 for 8), and 23 more perbun.lockbmember. Release text grows 7,424 bytes.
Notes
What the issue shows. Workspace package "packages/app" has added 0 dependencies, removed 0 dependencies, and updated 1 dependencies, then a manifest request for each dependency of that member, on an install where nothing changed. With this change that line is not printed and no manifest is requested.
Other parts of the change
- The resolver binds a
workspace:row to a member that the lockfile loaded. It does not read that member'spackage.jsonagain (get_or_put_resolved_package, Workspace arm). - A row that only moves to another dependency group keeps its resolution. This applies to the root package too.
bun.lockalso loads a member without its scripts when the member did not change. The install copies the scripts (and a newbin) into the loaded record, so the installers do not fall back topackage.jsonwith different rules.- Overrides are compared once, for the root package.
trustedDependenciesare compared once, after the members are parsed, because the lockfile holds one set for all of them. This is the same change as #41803. - A member whose only scripts are
preprepareorpostpreparefaileddebug_assert!(first_index != -1)in debug builds. The assertion is removed: those two scripts do not run for a workspace.
Four repro scripts, before and after (bun 1.4.3-canary.1+367d939d9 and this branch, loopback registry, latest moves from 1.0.0 to 1.1.0 between the two installs)
| case | before | after |
|---|---|---|
bun ci, member has postinstall or prepare, dependency is latest | exit 1, lockfile had changes, but lockfile is frozen | exit 0, 0 manifests |
bun install, same project | pin moves to 1.1.0 | pin stays 1.0.0, 0 manifests |
bun ci, same project, range ^1.0.0 | exit 0, 1 manifest | exit 0, 0 manifests |
another member's ^1.0.0 or >=1.0.0 on the same package | follows the moved latest to 1.1.0 | stays 1.0.0 |
bun install --filter ./packages/a, an excluded member has a hook and a private scope | 401 from the private registry | exit 0, private registry not asked |
root gains an overrides entry for another package | the latest pin and a catalog: pin move | both stay |
| root catalog gains a key that no package uses | catalog: pin moves | catalog: pin moves (not changed here) |
Not changed here
- A change to the root catalogs still resolves every
catalog:row again (install_with_manager, thecatalogs_changedloop). - A member that is renamed or moved is still read from disk as a new package.
- #42030 covers
file:directory dependencies in general. One side effect here: a member with an install script no longer re-reads itsfile:dependencies on every install. - #41866 proposes to read
trustedDependenciesfrom the root only. This change keeps the current behaviour for members. - #41931 proposes that
--frozen-lockfilefails for a retyped specifier that the locked version satisfies. This change makes members behave like the root package does today.
Tests that changed
bun-lock.test.ts, "--frozen-lockfile keeps a package that an older lockfile lists only as an optional peer": the test reachedkeep_optional_peer_targetsthrough a member edit. It now uses a retyped root range, which reaches the same line. It still fails when that line is removed.frozen-lockfile-pruned.test.ts: a hand-edited member specifier that the locked version satisfies now passes--frozen-lockfile, as the same edit in the root package does.
Measurements (release builds of the merge base and of this branch before its last rebase, x64 Linux, loopback registry. Instructions are the function's own instructions, counted with a gdb single-step script. Fixtures: a has no workspaces and 8 range dependencies. b has 40 members with 8 dependencies each and bun.lock. c is b with bun.lockb. h is b with a postinstall script in each member.)
| base | this change | |
|---|---|---|
generate_inner, fixture a | 813 | 853 |
generate_inner, fixture b | 52,926 | 51,795 |
generate_inner, fixture c | 52,926 | 53,835 |
generate_inner, fixture h | 53,806 | 52,570 |
install_with_manager, fixture a | 1,894 | 1,893 |
install_with_manager, fixture b | 19,630 | 19,629 |
| allocator calls, whole run, fixture a | 326 | 326 |
| allocator calls, whole run, fixture b | 5,763 | 5,763 |
| allocator calls, whole run, fixture c | 5,223 | 5,223 |
| allocator calls, whole run, fixture h | 26,654 | 5,768 |
| file syscalls, fixtures a, b, c | same | same |
| syscalls, whole run, fixture h | 3,582 to 3,590 | 1,970 to 1,974 |
| manifest cache files read, fixture h | 320 | 0 |
release .text bytes | 88,770,945 | 88,778,369 |
size_of::<DiffSummary>() | 224 | 248 |
Other designs weighed
- The script guard alone (this is what #26073 did). It fixes the unchanged project. A member that really changes still loses every pin, and
--filterstill asks the registry of a member it excludes when that member changes. - Restore the loaded rows inside the folder resolver after it re-reads the member. The re-read stays, the resolver has to know about the differ, and each other consumer of the fresh record needs the same repair.
- Keep the re-read and make dist-tag rows sticky in the resolver. That changes
bun updateandbun addfor every dist-tag row, also outside workspaces.
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/frozen-lockfile-pruned.test.ts, test/cli/install/bun-workspaces.test.ts, test/cli/install/bun-lock.test.ts