install: keep the packages bun.lock holds only through optional peers when an install re-resolves
Closed
RO
1mo ago+93+93+01234567890123456789-16-16-01234567890123456789
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
encodingand@vitest/coverage-v8trees, activepieces'dockerodetree, ...). Since #35681clean_with_loggerdrops such packages; #38853 keeps them on a frozen install, so--frozen-lockfilepasses, 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 dependencieson main today, as they do on 1.3.14). On main, a plainbun installon a fresh clone of opencode printsClean lockfile: 2711 packages -> 2697 packages, rewrites the committedbun.lock(-36 lines) and installs without those packages; activepieces goes4069 -> 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 abun addafter 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 recordsheld_at_load: the loaded packages some non-optional-peer dependency resolves to (one pass over the dependency rows into a bitset). InPackage::clonean optional peer slot defers toCloner::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_targetsgoes away: frozen and plain installs apply the same rule.- The #35681 behavior is unchanged: in
bun remove xor the same edit by hand,xwas 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 dedupekeeps its model because a surviving version keeps every edge it had (comment indedupe.rsupdated). 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'ssrc/installthe plain install drops theno-depsentry; with this change it keeps it, installs it, and--frozen-lockfileaccepts 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
ConnectionRefusedfrom 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_loggerrebuilds 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