Relationships
#1638 Catalog write-lock contention during extension load leaves a partial registry; auto-resolve fabricates 'local edits' and suggests a destructive --force re-pull
Opened by psftw · 8/13/2026· Shipped 8/14/2026
Summary
If another process holds the extension catalog's SQLite write lock for longer than the busy timeout (~5 s) while a swamp invocation needs to (re)index a pulled extension, that extension silently fails to load: the failure is logged once at WARN and execution continues on a partially populated registry. The first lookup of the missing type then triggers the auto-resolve fallback, which fabricates a different cause and prescribes destructive advice:
2026-08-13T12:32:02.520Z [WRN] swamp·cli: Failed to load user model "ssh.ts": "Error: database is locked"
Resolved nestpoke (@sbx/nested)
Executing poke
Resolving @swamp/ssh not found locally, searching registry...
Error @swamp/ssh already installed at <repo>/.swamp/pulled-extensions/@swamp/ssh but failed to load
Local edits may be preventing it from registering — inspect the source and fix errors.
To reset: swamp extension pull "@swamp/ssh" --force
Error: nested resolve failed: Model type "@swamp/ssh" not found for definition "testfleet".Note the contradiction: the WARN names the real cause (database is locked), then the ERROR three lines later invents another ("local edits")
and recommends swamp extension pull --force — the one command documented
to discard user work — for a pristine, current, checksum-verified install
that merely lost a lock race. The method run fails with the unrelated
"Model type not found"; nothing lands in the per-run log except the
step-start INFO line.
Two real swamp invocations racing to re-index (e.g. the first runs after a
manifest change) can hold the write lock long enough to do this to each
other — that is how it was hit in the wild (two parallel
model method run <role> check invocations; the pulled @swamp/ssh type
vanished while the repo's local types, loaded earlier in the phase, worked).
Reproduction
Verified against release 20260812.161650.0-sha.7abaf01a (Linux). Repo:
pulled @swamp/ssh 2026.07.28.1, one @swamp/ssh definition (testfleet,
any host list — resolve never connects), and a minimal local extension
model whose method calls ctx.runModel({definition: "testfleet", method: "resolve", ...}) (mirroring how @psftw/pets calls its fleet).
Dirty the index so the load phase must write:
sqlite3 .swamp/_extension_catalog.db \ "DELETE FROM bundle_types WHERE type_normalized='@swamp/ssh'"(Any state requiring a row write works — a source mtime bump does too. With a warm catalog the load phase is read-only and the race window closes, which is why the bug strikes "first run after a change" and then seems to fix itself: any later uncontended run heals the row.)
Hold the write lock from a second process, as a slow peer would:
python3 -c "import sqlite3,time; db=sqlite3.connect('.swamp/_extension_catalog.db'); db.execute('BEGIN IMMEDIATE'); time.sleep(30); db.rollback()" &swamp model method run nestpoke poke→ the output above, every time.
Threshold: a ≤2 s hold is absorbed (busy timeout); ≥6 s reproduces the cascade 100%. Deleting the row without the lock self-heals silently, and a corrupt bundle surfaces its real SyntaxError — the misleading path is specifically transient faults during the index build.
FD exhaustion behaves correctly in this path, for contrast: at
ulimit -n 24 the run fails with an honest "too many open files". The
lock-loss path is the one that lies.
Mechanism
loadUserModels(src/cli/mod.ts): per-file failures fromloader.buildIndexare warn-and-continue, and the surroundingcatchwarn-and-continues wholesale. Load order is local > sources > pulled, so a mid-phase fault yields exactly the observed asymmetry: local types registered, pulled types absent.ExtensionAutoResolver.installAndLoad(src/domain/extensions/extension_auto_resolver.ts): on a registry miss with an intact on-disk install it emitsrenderAutoResolveAlreadyInstalled, whose text guesses "local edits". The renderer receives only(extension, path)— no error object exists at that point, so the printed cause is fabricated by construction.- Same defect family as #1620 (I/O faults degrading into "model not found" in definition walks — shipped), #741 (datastore load path; its "secondary defects" section describes this exact message firing on a pristine install), #585, #524.
Fix (implemented and validated)
Patch to loadUserModels (applies to current main; the region is
unchanged since the failing release), attached as
extension-loader-fix.patch:
- Classify transient index-build failures
(
database is locked/EMFILE/EACCES/EIO/ …). - Retry
buildIndexup to 2× with backoff — a peer's write transaction is normally sub-second, so the race is absorbed invisibly. - If transient failures persist, throw with the real error instead of
continuing on a partial registry:
Failed to load 1 extension model(s): ssh.ts: Error: database is locked. If another swamp process is writing the extension index, retry once it finishes.
Validation (same repro recipe):
| Scenario | Release 20260812 | Patched |
|---|---|---|
| No contention | ok | ok |
| 8 s writer | fabricated "local edits" cascade | 1 retry → succeeds |
| 45 s writer | fabricated cascade | honest failure naming the lock |
A production version should use the established UserError type for clean
rendering (the demo throws a plain marker class, so a stack trace prints),
apply the same treatment to loadUserVaults / loadUserDatastores /
loadUserReports, and could reuse/extend
src/infrastructure/persistence/io_errors.ts from #2125 for the
classification. Independently worthwhile: when auto-resolve's
"already installed but failed to load" branch fires, surface the load
failure recorded earlier in the process instead of the "local edits"
guess — the information exists in buildIndex's failure list.
Environment
- Reproduced on release
20260812.161650.0-sha.7abaf01aand from source atc8953e9e(20260811) — behavior identical; not a regression, a longstanding path. - Linux 6.12.101+deb13-amd64;
@swamp/ssh 2026.07.28.1(pulled, pristine).
Shipped
Click a lifecycle step above to view its details.