Hive
SwifterPM: duplicate and colliding pins race on the same checkout directory during restore
GitHub issue · Open
Found while investigating the Could not find Package.swift in this directory or any of its parent directories reports (fixed diagnostically in #12224). This is a separate defect that came out of that work and is worth fixing on its own merits.
The defect
Two pins can resolve to the same checkout directory, and the restore fans out over pins concurrently with no lock on that directory.
1. Deduplication is skipped on the paths CI actually takes. dedupePinsByIdentity runs only after a fresh solve (swifterpm/Sources/swifterpm/Resolve.swift:71). All three paths that read a committed Package.resolved — readOnly, skipUpdate, and preferResolvedFile when the file is current — return pins verbatim (Resolve.swift:210-242). normalizedForResolvedFile sorts but does not dedupe (Models.swift:41-54). With a committed lockfile, tuist install short-circuits the solve, so duplicate identities survive into the restore. That the dedupe helper exists at all implies duplicates do occur.
2. Collisions do not even need duplicate identities. PinKind.checkoutDirectoryName is the last path component of the location (Models.swift:253-261), so orgA/Logger and orgB/Logger both map to checkouts/Logger. Cache.sourcePath collides similarly when two pins share identity, version and revision.
3. The protection is asymmetric. The shared source cache is guarded by flock(LOCK_EX) (Support.swift:440-455), which is exclusive between two opens even inside one process. But replaceWithCachedDirectory — remove the destination, then copy or symlink into it — holds no lock at all (FileSystemSupport.swift:160-177). Two tasks in restoreSourcePins’ ConcurrentTasks.map that target the same checkout name will interleave a delete with a copy.
Why it is worth fixing
The end state of that interleaving is a checkout directory that is partial or momentarily absent. Because SwiftPM’s dump-package walks up from its working directory, a manifest-less checkout inside the package tree does not error — it silently dumps the root package’s manifest for that dependency. A wrong dependency graph is a worse outcome than a failure, and nothing currently detects it.
What is not established
This was found while chasing the Could not find Package.swift reports, and it is tempting to call it the cause. It is not, on the evidence so far:
- Those failures happen on CI, where SwifterPM copies rather than symlinks (
Environment.swift:11), so checkouts are real directories under the package root — where a mangled checkout produces the silent wrong-manifest outcome above, not that error. - Reproducing that error requires a dumped directory outside any package tree, which on the affected layouts means a local
.package(path:)dependency or the root.
So treat these as two candidate bugs until proven otherwise. #12224 makes the next occurrence name the directory, which should settle it.
Suggested fix
- Dedupe pins on the three read-from-
Package.resolvedpaths, not just after a fresh solve. - Make the checkout destination collision-safe — either disambiguate the directory name the way SwiftPM does, or serialise per destination path.
- Consider failing loudly when a dependency’s dumped manifest resolves to an ancestor rather than the directory asked for, so the silent wrong-graph case cannot happen quietly.
Verification ideas
- A test with two pins whose locations share a basename, asserting both checkouts materialise with their own contents.
- A test that a
Package.resolvedcontaining duplicate identities restores deterministically.
Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.
Start a Flight and preserve its objective, outcome, and session here.
No GitHub comments yet.