Summary
ResourceRefCounter answers "is this still claimed?" by reading global-state.json from disk.
During a global sync, the state being mutated is that file, and Configurator.configure()'s
removal loop does not save between packs — the first write is the intermediate save that follows
it. Every pack in the loop therefore queries a snapshot that still lists its siblings.
Deselect two globally-configured packs that share a resource in one run: the first sees the second
still claiming it and reports .stillNeeded, the second sees the first and does the same. Both
release their claim, nothing is removed, and the resource is left installed with no owner. The same
reciprocal-keep happens when several retained global packs drop a shared claim in one update.
This affects brew packages and plugins, and now gitignore entries too — it lives in the shared
primitive rather than in any one resource kind. It fails in the safe direction (a resource is kept
rather than deleted), which is why it has gone unnoticed, but the leak never resolves on its own:
no later sync removes an artifact that no record claims.
Changes
Reference counting needs to see the run's uncommitted intent, not just committed state — either by
consulting the in-memory state the removal loop is mutating, or by passing it the set of claims
already released in this pass. Whichever shape is chosen must apply to all three resource kinds,
since they share the query path.
Worth deciding at the same time: whether an artifact left unclaimed should be reported by mcs doctor, so existing orphans become visible rather than silently persisting.
Test plan
- Install two packs globally that declare the same brew package (or the same gitignore entry).
mcs sync --global and deselect both in the same run.
- Expect the shared resource to be removed once. Today it is kept, and neither pack's record
claims it afterwards.
Found by review on #384, which added gitignore entries to the ref-counted set and inherited this.
Summary
ResourceRefCounteranswers "is this still claimed?" by readingglobal-state.jsonfrom disk.During a global sync, the state being mutated is that file, and
Configurator.configure()'sremoval loop does not save between packs — the first write is the intermediate save that follows
it. Every pack in the loop therefore queries a snapshot that still lists its siblings.
Deselect two globally-configured packs that share a resource in one run: the first sees the second
still claiming it and reports
.stillNeeded, the second sees the first and does the same. Bothrelease their claim, nothing is removed, and the resource is left installed with no owner. The same
reciprocal-keep happens when several retained global packs drop a shared claim in one update.
This affects brew packages and plugins, and now gitignore entries too — it lives in the shared
primitive rather than in any one resource kind. It fails in the safe direction (a resource is kept
rather than deleted), which is why it has gone unnoticed, but the leak never resolves on its own:
no later sync removes an artifact that no record claims.
Changes
Reference counting needs to see the run's uncommitted intent, not just committed state — either by
consulting the in-memory state the removal loop is mutating, or by passing it the set of claims
already released in this pass. Whichever shape is chosen must apply to all three resource kinds,
since they share the query path.
Worth deciding at the same time: whether an artifact left unclaimed should be reported by
mcs doctor, so existing orphans become visible rather than silently persisting.Test plan
swift testpasses locallyswiftformat --lint .andswiftlintpass without violationsmcs sync,mcs doctor)mcs sync --globaland deselect both in the same run.claims it afterwards.
Found by review on #384, which added gitignore entries to the ref-counted set and inherited this.