Relationships
#2709 Concurrent extension installs and removals can interleave in one checkout: add a pulled-extensions lock and LockfileRepository.refresh()
Opened by hammz · 9/29/2026· Shipped 9/29/2026
Summary
Nothing stops two installs or removals from changing the same checkout's pulled extensions at once. swamp serve's extension.install handler, a CLI extension pull/update/rm, and an auto-resolve in another command can all interleave. The lockfile's advisory .lock covers only the single writeEntry / removeEntry call. The copy into the pulled root, orphan pruning, and the catalog save are unprotected. Interleaving can leave a mix of two versions' files, prune files the other install just wrote, or record an entry whose files are not the ones on disk.
A second problem makes this worse: LockfileRepository serves reads from a construction-time snapshot (src/infrastructure/persistence/lockfile_repository.ts:48-56). Re-reading the lockfile after taking a lock therefore returns the stale snapshot.
This is unit U3 of the swamp-club#2612 split (see the planning ripples there). It affects every repo and ships on its own. Depends on swamp-club#2708 (the installExtension prepare/apply split).
Scope
- Per-checkout pulled-extensions lock: an in-process keyed mutex plus a cross-process
FileLock(src/infrastructure/persistence/file_lock.ts) on.swamp/pulled-extensions.lock. Provideacquire()andtryAcquire()(the latter is for #2612's convergence, which reports busy instead of waiting). - Reentrancy: an
AsyncLocalStoragelease, cleared infinally. An install nested inside a locked section (e.g. an auto-resolve triggered while one is held) runs inline. A promise that resolves after the section has exited cannot bypass the lock. - Where the lock is held: around
applyInstalland removal. It is never held acrossprepareInstall's network I/O, and never across the CLI's conflict prompt (the prompt happens afterConflictErrorpropagates out of the service, so the lock is already released). Entry points:InstallExtensionService.execute, held through the catalogsaveAlland theDuplicateTypeErrorrollback.UpgradeExtensionServiceand every factory built on it inherit this.RemoveExtensionService.execute(src/libswamp/extensions/rm.ts:211).- The auto-resolver's direct
installExtensioncall (src/cli/auto_resolver_adapters.ts:309), via a wrapper. - Dependency recursion runs under the parent's lease.
LockfileRepository.refresh(): re-read the file into the cache. It is called first thing under the lock in apply and inRemoveExtensionService.execute(fixes ADV-36 from the #2612 plan review).- Lock order: datastore global lock (when held) → pulled-extensions lock → lockfile advisory
.lock(innermost). The auto-resolver runs inside commands that already hold the datastore global lock (model validate/evaluate, workflow evaluate). swamp-club#2495 must therefore take the global lock before this one, never inside apply. Document this in the design doc so #2495 follows it. - Stale lock:
FileLock's TTL and heartbeat cover crashed holders. Choose a max wait that fits the longest apply, and make the timeout error name the lock file and the holder.
Tests
- Unit: two concurrent
InstallExtensionService.executecalls on the same checkout run one after the other (assert on ordering via events or call counts, not timing);tryAcquire()fails while the lock is held and succeeds after release. - Unit: reentrancy — a nested install inside a held lease does not deadlock; a promise created inside the lease but awaited after it exits waits for the lock.
- Unit:
refresh()observes an entry written by anotherLockfileRepositoryinstance. - Unit: the lock is not held during
prepareInstall(fake the registry client and assert the lock is free while it is called) or during the conflict prompt. - Integration: an install and a remove racing on the same checkout end with a consistent tree and lockfile.
Docs
design/primitives/extensions.md: the lock, where it is held, and the lock order.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.