install: diff and apply workspace members row by row, like the root package

Draft
RO
1mo ago
+1092-218

Fixes #26070

Problem

  • In a bun.lock workspace, bun ci fails with error: lockfile had changes, but lockfile is frozen when nothing changed. The trigger is a member with an install script and a latest dependency that published. A plain bun install moves the pin.
  • Diff::generate_inner (src/install/lockfile/Package.rs:1653) compares a member's scripts with the lockfile. bun.lock records 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_manager applies it in place, so only changed rows resolve again.
  • Verified: test/cli/install/bun-workspaces.test.ts (34 new cases, 29 fail on main). Also bun-lock.test.ts and frozen-lockfile-pruned.test.ts.

Background

  • Diff::generate compares the loaded root package with a fresh parse of package.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 latest row of that member.

Downsides

  • bun ci now passes when a member's specifier differs from bun.lock and the locked version satisfies it. The root package already does this.
  • bun update now leaves only its targets unmapped. Without -r it no longer moves latest rows of other workspaces.
  • generate_inner runs 5 more instructions per root dependency (813 to 853 for 8), and 23 more per bun.lockb member. 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's package.json again (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.lock also loads a member without its scripts when the member did not change. The install copies the scripts (and a new bin) into the loaded record, so the installers do not fall back to package.json with different rules.
  • Overrides are compared once, for the root package. trustedDependencies are 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 preprepare or postprepare failed debug_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)

casebeforeafter
bun ci, member has postinstall or prepare, dependency is latestexit 1, lockfile had changes, but lockfile is frozenexit 0, 0 manifests
bun install, same projectpin moves to 1.1.0pin stays 1.0.0, 0 manifests
bun ci, same project, range ^1.0.0exit 0, 1 manifestexit 0, 0 manifests
another member's ^1.0.0 or >=1.0.0 on the same packagefollows the moved latest to 1.1.0stays 1.0.0
bun install --filter ./packages/a, an excluded member has a hook and a private scope401 from the private registryexit 0, private registry not asked
root gains an overrides entry for another packagethe latest pin and a catalog: pin moveboth stay
root catalog gains a key that no package usescatalog: pin movescatalog: pin moves (not changed here)

Not changed here

  • A change to the root catalogs still resolves every catalog: row again (install_with_manager, the catalogs_changed loop).
  • 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 its file: dependencies on every install.
  • #41866 proposes to read trustedDependencies from the root only. This change keeps the current behaviour for members.
  • #41931 proposes that --frozen-lockfile fails 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 reached keep_optional_peer_targets through 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.)

basethis change
generate_inner, fixture a813853
generate_inner, fixture b52,92651,795
generate_inner, fixture c52,92653,835
generate_inner, fixture h53,80652,570
install_with_manager, fixture a1,8941,893
install_with_manager, fixture b19,63019,629
allocator calls, whole run, fixture a326326
allocator calls, whole run, fixture b5,7635,763
allocator calls, whole run, fixture c5,2235,223
allocator calls, whole run, fixture h26,6545,768
file syscalls, fixtures a, b, csamesame
syscalls, whole run, fixture h3,582 to 3,5901,970 to 1,974
manifest cache files read, fixture h3200
release .text bytes88,770,94588,778,369
size_of::<DiffSummary>()224248

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 --filter still 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 update and bun add for 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