fix: lock the lockless load-modify-write paths#138
Merged
Conversation
The capture ledger's orphan sweep (cleanOrphanPlaceholders, run from mutationPreamble) executed locklessly while commitCapture filled the reserved placeholder OUTSIDE the ledger flock. A capture stalled more than sixty seconds between reservation and commit could have its just-committed issue file deleted: the sweep classified the placeholder as an aged orphan, the stalled commit's atomic rename then landed, and the sweep's os.Remove deleted the fully-committed file after Capture had returned success (iss-102). Run both the sweep and the commit's checksum-reread+write under withLedgerLock, so a commit that races the sweep blocks on the lock; the sweep unlinks the still-empty placeholder and the commit then fails loudly on the missing placeholder instead of the sweep destroying a committed file. The existing orphanStillRemovable pre-unlink re-check stays as defence in depth. Promote the flock core to a single shared primitive, fsutil.WithFileLock, so capture's withLedgerLock (and, next, ahoy's history registry) route through one inter-process load-modify-write lock rather than divergent flock loops. Assisted-by: Claude:claude-opus-4-8
registerRepo did an unlocked load-modify-write of ~/.abcd/history/index.json (loadHistoryIndex -> mutate -> writeHistoryIndex) and bootstrapHistory was check-then-act. Atomic rename keeps the file intact but not lost-update-free: two `abcd ahoy install` runs from different worktrees share one index, so the last writer clobbered the other's update, silently dropping a repo registration or a re-founding lineage link (iss-101). Serialize the registerRepo load-modify-write behind an inter-process lock in ~/.abcd/history (the shared fsutil.WithFileLock primitive) and re-load the index INSIDE the lock, so concurrent registrations compose instead of overwriting. Bootstrap now publishes index.json atomically via an exclusive hard link (write temp -> os.Link), so exactly one racing run seeds it and no reader ever observes a 0-byte index that would parse-fail and drop its own registration. The re-founding lineage confirmation is asked BEFORE the lock is acquired -- never across the interactive prompt -- and the state it validated (the candidate still present and still linkable) is re-checked under the lock after the reload: a candidate changed by a concurrent install surfaces as a conflict rather than writing a lineage link the user approved against a stale index. Assisted-by: Claude:claude-opus-4-8
Both records move open/ -> resolved/ with impact: fix, closing the lockless load-modify-write class fixed on this branch. Assisted-by: Claude:claude-opus-4-8
The iss-101/102 class sweep confirmed the remaining load-modify-write sites are repo-local only (no home-global cross-worktree exposure). Recorded rather than fixed: out of the queued item's scope. Assisted-by: Claude:claude-fable-5
Review NOTES recorded, not fixed: registerRepo's best-effort edges lose signal silently (iss-128), and four pre-existing bespoke flock loops should consolidate onto fsutil.WithFileLock (iss-129). Assisted-by: Claude:claude-fable-5
The history-index seed comments still described an "O_EXCL create"; the final implementation publishes via an atomic hard link (write temp, sync, os.Link; EEXIST means a concurrent run already seeded it). Reword the bootstrapHistory doc and the bootstrap-race test comment to match. Comments only. Assisted-by: Claude:claude-opus-4-8
This was referenced Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Locks the lockless load-modify-write class (ledger issues iss-101 + iss-102, both minor bugs from the multi-agent hunt): the history index could drop a repo registration under two concurrent installs, and a >60s-stalled capture could have its just-committed issue file deleted by the orphan sweep after
Capturereturned success.Chained PR: base is
fix/iss-115-120-id-allocator-class(#137) — this branch touches the same capture files #137 changed and ledger-resolves with the--impactverb from #136. Merge order #136 → #137 → this; GitHub retargets automatically.What changed
fsutil.WithFileLock(O_NOFOLLOW + regular-file guard, polled non-blocking flock with timeout, fd released on every path). capture'swithLedgerLockand the new ahoywithHistoryLockboth route through it — no third flock. Pre-existing bespoke lock loops elsewhere are untouched (flagged, not expanded).registerRepo's load-mutate-write of the history index runs inside the lock with a re-load; the interactive re-founding prompt stays strictly outside the lock (per the issue's hard constraint), and the prompt-validated state is re-checked in-lock — a changed candidate surfaces as a conflict, never a stale write.bootstrapHistoryseeds atomically (write-then-link), so no O_EXCL zero-byte publish window.Verification
make preflightexit 0,go test -race ./internal/...exit 0,record-lintexit 0.impact: fix.