install: keep the packages bun.lock holds only through optional peers when an install re-resolves

Closed
RO
1mo ago
+93-16

Problem

  • Lockfiles written by bun 1.3.x hold packages that only an optional peer slot points at (1.3.x never dropped a package once an optional peer was bound to it: opencode's encoding and @vitest/coverage-v8 trees, activepieces' dockerode tree, ...). Since #35681 clean_with_logger drops such packages; #38853 keeps them on a frozen install, so --frozen-lockfile passes, but every other install that re-resolves still prunes them (keep_optional_peer_targets = frozen_lockfile() || !summary.changes_resolutions(), src/install/lockfile.rs:973).
  • An install re-resolves on any package.json edit, and on untouched checkouts whose workspaces report a diff on every run (sst/opencode@4643e65 and activepieces/activepieces at HEAD still print Workspace package "..." has ... updated 1 dependencies on main today, as they do on 1.3.14). On main, a plain bun install on a fresh clone of opencode prints Clean lockfile: 2711 packages -> 2697 packages, rewrites the committed bun.lock (-36 lines) and installs without those packages; activepieces goes 4069 -> 4063 (-22 lines, measured with #38992 applied, which fixes its other four lines). 1.3.14 re-saves both files byte-identically. So what gets installed from one lockfile depends on whether the install was frozen, and a bun add after upgrading carries an unrelated removal of every such package into the diff.

Fix

  • Lockfile::mark_loaded_packages, already called once right after load and before the differ, dedupe or an update re-point anything, also records held_at_load: the loaded packages some non-optional-peer dependency resolves to (one pass over the dependency rows into a bitset). In Package::clone an optional peer slot defers to Cloner::flush, and so can release its target, only for a target in that set or one appended during this resolve; a target the loaded file held through optional peers alone is cloned like a dependency. keep_optional_peer_targets goes away: frozen and plain installs apply the same rule.
  • The #35681 behavior is unchanged: in bun remove x or the same edit by hand, x was held by a real dependency when the file was loaded, that dependency is gone after the diff, and the slot releases it. A fresh resolve never binds an optional peer to a package nothing else holds, so files written by this version are unaffected; bun dedupe keeps its model because a surviving version keeps every edge it had (comment in dedupe.rs updated). No snapshot changes (#38853 already updated the arborist fixture this affects; edit-package-json--removed, where a real dependent leaves, still exits 1).
  • Verified:
    • test/cli/install/bun-lock.test.ts, "a re-resolving install keeps the packages an older lockfile holds through optional peers alone": a 1.3-shaped lockfile plus a package.json that gains a dependency the file already has. Rebuilt with main's src/install the plain install drops the no-deps entry; with this change it keeps it, installs it, and --frozen-lockfile accepts the saved file. #38853's frozen test and the three #35681 tests pass unchanged.
    • Debug build: every optional-peer, frozen and #38870 test in bun-lock (14) and migration/migrate (125, snapshots untouched) pass. Full bun-lock and bun-dedupe runs on this box currently fail on ConnectionRefused from the test registry (load average above 200) on unrelated tests as well; the same logic on yesterday's base passed bun-lock, bun-dedupe, bun-remove, frozen-lockfile-pruned, bun-update, bun-update-transitive, bun-audit, bun-prune, catalogs, bun-workspaces, isolated-install, bun-install-registry and the migration suites in full.
  • Related: #38992 fixes the other shape in the same repos (required peers with a nested copy); independent of this change. #37925 would stop binding optional peers at load, which is what makes these entries reachable at all, so it conflicts with keeping them.

Background

  • An optional peer (peerDependenciesMeta) is never installed on its own; when a package of that name is in the tree for another reason the hoister binds the edge to it, and the binding is a resolution slot in the lockfile like any other edge. 1.3.x copied every slot when rebuilding the lockfile, so once bound, such a package stayed in the file after its real dependent left.
  • clean_with_logger rebuilds the lockfile after resolution by cloning what the edges reach from the root (Package::clone, Cloner::flush); packages nothing reaches are dropped, and since #37426 optional peer slots are bound afterwards, only to targets something else cloned.
  • DiffSummary::changes_resolutions() says whether the root or a workspace changed a dependency row against the lockfile; it is what decides whether an install re-resolves, and it is true on every run for some untouched projects (#38853 lists the shapes, #33632 more), which is why tying the pruning to it made the result depend on how the install was invoked.

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