fix(auth): stop degrading on ELOCKED, and correct the claim about the lock (#2082)

Copilot review round 2, both comments upheld.

**ELOCKED must not degrade.** Exhausting the retries on a held lock means the
lock is *working* and something else demonstrably holds it — so running the
mutation anyway entered the exact interleaving the lock exists to prevent,
and entered it knowing a concurrent writer was there. `set` now fails
instead. Two supporting changes:

- The retry budget went from ~3.3s to ~15s so it outlasts the 10s stale
  window. Refusing is only defensible because a *crashed* holder resolves by
  takeover first; with the shorter budget, one Inspector killed mid-save
  would have failed every later save on the box until someone deleted the
  lock by hand. Pinned by a new test that stages a dead holder's lock
  directly (mkdir + backdated mtime) and asserts the save succeeds.
- Acquire is now probe-then-wait. `proper-lockfile` drives its whole acquire
  through `retry`, which re-attempts on *any* error — so with a 20-retry
  budget a read-only `$HOME` would have spent the full 15s re-issuing an
  identical failing `mkdir` on every save before degrading. One retry-less
  probe separates "held" (worth waiting out) from "unavailable" (not).

**The comment overclaimed what proper-lockfile provides, and the reviewer was
right about the mechanism.** Verified against lib/lockfile.js@4.1.2: on
EEXIST it stats, and if stale it rmdirs and re-mkdirs *without* checking the
directory it removed is the one it found stale — so a slow waiter can still
delete a fast waiter's fresh lock and both proceed. That is the same race the
hand-rolled version could not close, and it is not closable without
renameat2(RENAME_EXCHANGE).

What the library actually adds is that the loser is *detected*: its refresh
tick compares the lock's mtime against the value recorded at acquire, so a
compromised holder is told rather than proceeding silently. And what is
genuinely exclusive is the case that matters — mkdir is atomic and a live
holder refreshes its mtime, so its lock never goes stale and two running
Inspectors are serialized. The residual window opens only after a holder dies
without releasing, and the optimistic verify still covers it.

file-lock.ts, README.md, specification/v2_servers_file.md and AGENTS.md all
carried the overclaim; all four now state the narrower, true version.

Also fixes a test that was quietly asserting the opposite of its name: its
holder used stale: 60_000 while the waiter used 10_000, and staleness is
judged by the *waiter's* threshold — so the holder was declared stale after
10s and the save succeeded by takeover.

Not done: a two-process stale-takeover race test. It would be testing the
library rather than this code, and is inherently nondeterministic; the honest
response to that finding was to stop claiming the race is closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 10:08:12 -04:00
commit dcd70e2f11

View file

@ -71,11 +71,15 @@ v2/main/
│ │ │ # file it cannot decrypt rather than destroying it),
│ │ │ # file-lock.ts (withSecretFileLock: the cross-process
│ │ │ # mutual exclusion #2082 settled on — proper-lockfile,
│ │ │ # borrowed rather than hand-rolled, because stale-takeover
│ │ │ # is what three review rounds of a mkdir election failed to
│ │ │ # get right; DEGRADES rather than throws when no lock can
│ │ │ # be taken, since this store exists for boxes missing the
│ │ │ # usual mechanism and must not gain a new way to fail),
│ │ │ # borrowed rather than hand-rolled. Read its header before
│ │ │ # citing it: it makes two LIVE Inspectors exclusive, and
│ │ │ # does NOT make stale takeover single-winner — it makes the
│ │ │ # loser detectable (ECOMPROMISED), which is the honest claim.
│ │ │ # DEGRADES when no lock CAN be taken (read-only $HOME etc),
│ │ │ # since this store exists for boxes missing the usual
│ │ │ # mechanism; but THROWS on ELOCKED — a lock held by a live
│ │ │ # writer is evidence the lock works, not licence to bypass
│ │ │ # it — after waiting past the stale window),
│ │ │ # and
│ │ │ # secret-store-selection.ts (the POLICY: explicit
│ │ │ # MCP_INSPECTOR_SECRET_STORE wins, else probe the keychain,