[#239] Atomic durable writes + ownership-safe stale-lock reclaim#244
Conversation
Storage durability/lock fixes from the #238 audit (Batch 39): - secure-fs: split writeSecureFile into atomic REPLACE (temp-file + fsync + rename; readers never observe a truncated/partial file, a crash before rename leaves the prior valid file intact) and a separate no-clobber createSecureFile (exclusive "wx" open). The four create-only callers (room brief/messages/state/participants, forum new-post) move to createSecureFile, preserving flag:"wx" semantics. - lock: make stale-lock reclaim identity-safe. Instead of an unconditional rm after a read, move the file aside atomically and discard it only if its bytes are still the record we judged stale; if a live successor replaced it, restore without clobbering. Reclaim can no longer delete a newly acquired lock. - state/joined-rooms: serialize the token and joined-room read-modify-write paths under withWriterLock so concurrent writers cannot both read the old store and lose an entry. Lock records hold only pid/createdAt — never a token. Tests: atomic replacement (no partial reads) + replacement-vs-create distinction + existing-target/concurrent-create wx contention; stale-lock contention (mutual exclusion, no overlap); concurrent token/joined-room mutations keep every entry. Full suite 428/428. No token/URL/log exposure, no new runtime dependencies, no public API/message-semantics change. Does not touch #240-#243 surfaces. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: BLOCK
Epic Alignment: PASS
The live diff stays within #239: secure replacement/create paths, stale-lock reclaim, and token/joined-room RMW locking.
Checked (evidence)
- Structural gate: live REST PR body contains filled EPIC Alignment, Self-Verification, and Deviations sections.
- Contract/diff: src/storage/secure-fs.ts:22-55 separates atomic replace from exclusive create; src/storage/lock.ts:61-135 moves stale locks aside and restores without overwrite; src/cli/state.ts:42-52 and src/storage/joined-rooms-store.ts:48-125 lock complete RMW operations.
- Tests: test/storage-secure-fs.test.ts, test/storage-lock.test.ts, and test/storage-concurrent-rmw.test.ts cover the reviewed contract paths.
- Riskiest part: stale-lock reclaim; the move-aside plus link-only restore preserves an intervening lock rather than clobbering it.
- Kill-list: scanned all items — clean.
- CI: gh pr checks 244 → Release gates pending (live; checked twice).
Findings
- [blocking] Required CI has not completed.
- File: N/A
- Why it fails: mandatory live checks evidence is unavailable while Release gates is pending.
- Do instead: wait for the required check to complete successfully, then request re-review; no code change is identified.
Decision
The reviewed diff aligns with #239, but approval is blocked until live CI completes successfully.
|
RE2 review — REQUEST CHANGES (authoritative verdict delivered to @dev in project chat). Atomic-write/create split ( [REQUIRED] blocker — stale-lock reclaim mutual-exclusion TOCTOU. Reachable with 2 contending processes + a pre-existing dead-pid stale lock:
This is the exact defect #239's acceptance forbids ("stale reclaim cannot delete a newly acquired lock"); the Fix: serialize reclamation under a dedicated meta-lock ( Test gap: |
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Epic Alignment: FAIL
The stale-lock implementation does not yet meet #239's contract that reclaim cannot delete or break a newly acquired lock.
Checked (evidence)
- Structural gate: live REST PR body contains filled EPIC Alignment, Self-Verification, and Deviations sections.
- Atomic replace/create: src/storage/secure-fs.ts:22-55 and migrated create callers preserve replacement versus wx no-clobber semantics.
- RMW: src/cli/state.ts:42-52 and src/storage/joined-rooms-store.ts:48-125 lock the complete token/joined-room read-modify-write paths.
- Riskiest part: stale-lock reclaim. It is not acceptable as written; see finding.
- Kill-list: scanned all items — hit listed below.
- CI: gh pr checks 244 → Release gates pending (live), not verified.
Findings
- [required] Delayed stale reclaim can remove a live successor lock before checking identity.
- File: src/storage/lock.ts:98-134
- Why it fails: reclaimIfUnchanged performs rename(lockPath, moved) at :101 before comparing movedRaw to expectedRaw at :116. If P1 reclaims the stale record and acquires a fresh lock, then delayed P2 moves P1's live lock aside, leaving lockPath empty during P1's critical section; a third writer can enter concurrently. The later link-based restore can also resurrect a released lock and wedge future writers.
- Do instead: serialize stale reclamation (for example, an exclusive reclaim meta-lock), re-read/revalidate immediately before removal, and ensure a reclaimer never moves a record it has not just verified as the stale record. Add a regression with two independently scheduled reclaimers/distinct lock records proving no double hold and no resurrected/wedged lock.
Decision
Request changes. The write/create split and RMW locking are sound, but the stale-lock race violates a mandatory #239 acceptance criterion.
…reclaim lock RE1 finding (mandatory): the move-aside reclaim renamed the lock before revalidating identity, so a delayed second reclaimer could move a live successor's lock aside (empty lockPath during its critical section → a third writer could enter; the link-based restore could resurrect a released lock and wedge future writers). Fix per RE1's guidance — serialize reclamation and revalidate before removal: acquire an exclusive `<lock>.reclaim` lock (open "wx"), then re-read the primary under it and `rm` it ONLY if still byte-for-byte the record judged stale. This is TOCTOU-free: while the primary still holds that record it cannot be acquired (acquire needs the file absent, and only the serialized reclaimer removes it), so a live successor lock is never moved aside or removed. An abandoned reclaim lock (crash mid- reclaim) is cleared only once older than the stale window. Drops the rename/link move-aside-and-restore path entirely. Regression: `racing reclaimers never double-hold, move a fresh lock aside, or wedge the lock` runs 60 rounds of 4 contending reclaimers, asserting single-holder, an undisturbed on-disk lock throughout each hold, clean release, and no wedged reclaim lock. Verified this fails on the prior move-aside code (double-holds + disturbed/wedged locks) and passes on the serialized reclaim. Full suite 429/429. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Epic Alignment: FAIL
The new primary-lock serialization addresses the prior race, but its reclaim meta-lock is not ownership-safe and can re-open the same class of race.
Checked (evidence)
- Re-review range: ce2a230..596dea3; only src/storage/lock.ts and test/storage-lock.test.ts changed.
- Prior finding: resolved at src/storage/lock.ts:100-126 for a normally held reclaim lock; primary record is re-read before removal.
- Riskiest part: reclaim meta-lock recovery. It is not acceptable as written; see finding.
- Kill-list: scanned new ranges — hit listed below.
- CI: gh pr checks 244 → Release gates pass (live).
Findings
- [required] The active reclaim meta-lock is deleted solely by age.
- File: src/storage/lock.ts:100-133
- Why it fails: the meta-lock contains no owner identity, and a competing reclaimer executes rm(reclaimPath, { force: true }) whenever mtime exceeds staleAfterMs (:130-133). An active reclaimer paused or delayed past that interval loses its exclusion lock; another reclaimer can enter and both can independently revalidate/remove the primary, recreating the concurrent-reclaimer race. This also contradicts the comment that age alone proves a crash.
- Do instead: store an owner record in the reclaim lock and reclaim it only after identity/liveness-safe verification, or otherwise make the reclaim lock recovery itself ownership-safe. Add a deterministic test with an active reclaim operation older than staleAfterMs showing a second reclaimer cannot remove its meta-lock or overlap primary reclamation.
Decision
Request changes. CI is green and the original primary-lock move-aside bug is fixed, but the required serialized-reclaim guard must itself be safe before #239 can be approved.
|
RE2 — APPROVE (authoritative verdict delivered to @dev in project chat; re-review of my earlier REQUEST CHANGES). My [required] stale-lock reclaim TOCTOU (msg 830) is resolved by
Non-blocking follow-up (not required for this PR): |
…me-only
RE1 re-review finding (mandatory): the `<lock>.reclaim` meta-lock was
created empty and cleared purely by mtime, so an active-but-slow
reclaimer (its lock older than staleAfterMs, owner still alive) could be
cleared by a peer, letting two reclaimers re-enter primary reclamation.
Fix: stamp the reclaim lock with the owner's identity ({pid, createdAt})
and recover it by liveness — a live owner keeps exclusivity regardless of
age; only a dead owner (crash mid-reclaim), or an unparseable/legacy lock
older than the stale window, is cleared. A merely-delayed reclaimer never
loses the reclaim lock.
Regression (`an active-but-old reclaim lock is not cleared, preserving
reclaim exclusion`): a reclaim lock owned by a live process but backdated
10min must NOT be cleared — the reclaimer backs off and times out rather
than re-entering reclamation, and both the reclaim lock and the primary
stale lock are left untouched. Verified this fails on the prior mtime-only
recovery and passes on the liveness-safe recovery. Full suite 430/430.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Epic Alignment: FAIL
The live-owner meta-lock case is fixed, but dead-owner meta-lock cleanup is still not identity-safe under concurrent cleaners.
Checked (evidence)
- Re-review range: 596dea3..6977288; only src/storage/lock.ts and test/storage-lock.test.ts changed.
- Prior active-but-old finding: resolved by owner record plus live-pid retention at src/storage/lock.ts:100-108,136-157.
- Riskiest part: concurrent dead-owner cleanup. It is not acceptable as written; see finding.
- Kill-list: scanned new ranges — hit listed below.
- CI: gh pr checks 244 → Release gates pass (live).
Findings
- [required] Two reclaim-lock cleaners can delete a fresh successor reclaim lock.
- File: src/storage/lock.ts:136-157
- Why it fails: P1 and P2 can both read the same dead reclaim record at :139-151. P1 removes it at :153, a new reclaimer creates a fresh reclaim lock, then P2's unconditional rm(..., force) at :153 deletes that successor. The same race exists for two cleaners of the old malformed path at :155-157. This breaks the serialized-reclaim exclusion the primary fix relies on.
- Do instead: atomically take ownership of the stale reclaim record (for example rename it to a unique scratch path, with exactly one cleaner winning) and remove only that moved inode; a loser must not remove whatever is now at reclaimPath. Add a two-cleaner regression that races dead reclaim recovery with a fresh reclaimer and asserts the fresh reclaim lock survives.
Decision
Request changes. The liveness fix is correct, but #239 cannot approve until dead reclaim-lock takeover is also atomic/ownership-safe.
|
RE2 — APPROVE (re-affirmed at Both @re2 review items are now resolved: my [required] reclaim TOCTOU (fixed in
Non-blocking fast-follow (unchanged): the dead-owner |
RE1 finding (mandatory): clearAbandonedReclaimLock force-removed the reclaim lock by path, so two cleaners could both read one dead reclaim lock; after the first removes it and a fresh reclaimer creates a successor, the second cleaner's force-rm deletes that successor's exclusion lock. Fix (per RE1 — atomic stale takeover, delete only the moved inode): recovery now renames the abandoned reclaim lock to a unique name (exactly one cleaner wins the rename), then deletes ONLY that moved inode and only if it is still byte-for-byte the record judged abandoned. If a fresh successor was moved instead (created in the gap), it is restored without clobbering rather than destroyed. A successor is therefore never deleted. (Note: even absent this, the primary's exclusive open + identity-checked removal already prevent a primary double-hold; this closes the reclaim- lock exclusion race directly.) Regression (`takeover deletes only the record it judged; a fresh successor survives`): models the second cleaner — takeOverAbandonedFile with an expectedRaw that no longer matches the on-disk file must preserve the successor (a plain force-rm would delete it), remove a still-matching record, no-op when already gone, and leave no scratch files. Full suite 431/431. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Epic Alignment: PASS
The full PR remains within #239: durable replacement/create separation, ownership-safe stale-lock recovery, and serialized token/joined-room RMW; #240-#243 remain untouched.
Checked (evidence)
- Re-review range: 6977288..5895e45; only src/storage/lock.ts and test/storage-lock.test.ts changed.
- Prior finding resolved: src/storage/lock.ts:153-203 atomically renames an abandoned reclaim record to a unique takeover path and deletes only that moved inode when bytes still match; mismatch restores without overwriting a fresh successor.
- Regression: test/storage-lock.test.ts adds matching-record removal, successor preservation, already-gone no-op, and no leftover takeover scratch coverage.
- Riskiest part: concurrent reclaim-lock cleanup; atomic takeover makes one cleaner win and preserves a reappearing successor, so it cannot delete the current reclaim lock by stale path.
- Kill-list: scanned new ranges — clean.
- CI: gh pr checks 244 → Release gates pass (live, run 29380793335).
Findings
- None.
Decision
APPROVE. All of my stale-reclaim findings are resolved on 5895e45, with green live CI and focused regression coverage.
|
RE2 — APPROVE (re-affirmed at @re1's takeover finding is fixed via the atomic-takeover primitive: Assessment: normal-operation primary mutual exclusion is sound. The remaining reclaim-lock crash-recovery edge is a deep, single-process-unobservable cross-process window — non-blocking, with diminishing returns on further file-lock hardening; if provably race-free crash recovery is ever wanted, that's a stronger-primitive ( All @re2 review items resolved. Both reviewers approve on the current head. |
Fixes #239
Implements #239's reviewed storage contract (Batch 39, code batch) — atomic durable writes + ownership-safe stale-lock reclaim + serialized token/joined-room RMW. Baseline fresh
origin/main@6ee4951; head5895e45.EPIC Alignment
cli/state.tsonly).flag:"wx"no-clobber amendment is honored by splitting replacement and create into separate operations.What changed
src/storage/secure-fs.ts—writeSecureFileis now atomic replacement: write payload to a sibling.tmp-<pid>-<n>,fsync, thenrenameover the target. Readers observe the whole old or whole new file, never a partial one; a crash/ENOSPC before the rename leaves the prior file intact. Added a separatecreateSecureFilefor atomic no-clobber create (exclusive"wx"open) — the create path is never a rename-over-existing. Removed theflagoption.createSecureFile(identical no-clobber semantics):room-storebrief/messages/state/participants (writeNewJson),forum-storenew-post (writeJsonNew).src/storage/lock.ts— ownership-safe stale-lock reclaim, hardened across the three reviewer findings:<lock>.reclaimlock (open "wx"); under it the primary is re-read and removed only if still byte-for-byte the record judged stale. TOCTOU-free — while the primary still holds that record it cannot be acquired (acquire needs the file absent, and only the serialized reclaimer removes it), so a live successor is never removed and check-then-remove cannot race a fresh acquire. (Replaces the earlier move-aside +link-restore approach entirely.){pid, createdAt}) and recovered by liveness — a live owner keeps exclusivity regardless of age; only a dead owner (crash mid-reclaim), or an unparseable/legacy lock older than the stale window, is a removal candidate. A merely-slow reclaimer never loses the reclaim lock.takeOverAbandonedFile, exported for direct regression coverage.)src/cli/state.ts(writeToken) andsrc/storage/joined-rooms-store.ts(recordJoinedRoom/setJoinedRoomArchived/deleteJoinedRoom) — the whole read-modify-write now runs underwithWriterLock(newtokens.lock/joined-rooms.lock). Lock records hold onlypid/createdAt— never a token.Self-Verification
writeSecureFile replacement is atomic — concurrent reads never see a partial file(would fail on the old in-placewriteFile).concurrent writers reclaim one stale lock without ever overlapping,racing reclaimers never double-hold, move a fresh lock aside, or wedge the lock(60 rounds × 4 reclaimers; verified it fails on the prior move-aside code and passes on the serialized reclaim),concurrent writeToken … keeps every token,concurrent recordJoinedRoom … keeps every entry.an active-but-old reclaim lock is not cleared, preserving reclaim exclusion(liveness, not mtime — deterministic, fails on the prior mtime-only recovery) andtakeover deletes only the record it judged; a fresh successor survives(deterministic two-cleaner model).tokens.jsonas before.flag:"wx"no-clobber retained, incl. concurrent-create contention; replacement vs create distinguished → testscreateSecureFile refuses to clobber an existing target …,writeSecureFile replaces where createSecureFile refuses,concurrent createSecureFile — exactly one wins, every loser gets the already-exists failure.open/rename/link/rmonly;package.jsonunchanged); no token-format/public-API/message-semantics change; 0600/0700 preserved (tests assertmode & 0o777 === 0o600); directory enumerations safe (forum-storefilters to dirs,registryfilters.endsWith(".json")so transient.tmp-are excluded); no#240–#243files touched.pnpm typecheck✓ ·pnpm lint✓ ·pnpm kit-guard✓ (4 panes clean) ·pnpm no-stub✓ ·pnpm build✓ ·node --test dist/test/**/*.js→ 431 pass / 0 fail. CIRelease gatespass on5895e45(run 29380793335).Deviations
<lock>.reclaimlock with revalidate-before-remove, liveness-based recovery, and atomic-takeover cleanup — rather than the initial move-aside +link-restore. This closes the reviewer-identified races (moving a fresh successor aside; mtime-only clearing an active-but-slow reclaimer; a second cleaner force-rming a successor). Mutual exclusion ultimately rests on the primary's exclusiveopen("wx")+ identity-checked removal.takeOverAbandonedFileis exported solely so the two-cleaner / fresh-reclaimer race has a deterministic, non-vacuous regression (a mismatchedexpectedRawmodels a second cleaner acting after a successor replaced the judged record; a plain force-rmwould delete it).fsyncon the temp handle beforerename(standard atomic-write durability) — not explicitly required by the contract, but it is what makes "a crash leaves the prior/complete file" hold across a real power loss. No runtime-dependency or API impact.flock/lock-server) would be a separate follow-up, out of [P1][Storage] Make durable state writes atomic and stale-lock reclamation ownership-safe #239's scope.