fix(auth): guard every lock removal, not just release, and stop promising expiry (#2082)

Copilot review round 5. Both comments upheld; the first is a real hole in
round 4's own fix.

**The ownership guard was in the wrong place, and overclaimed.**

Round 4 checked ownership and then called `release()`. Two problems:

- Check-then-act. The `stat` and the `rmdir` were separate async steps, so a
  waiter could replace the directory in between and we deleted the winner's
  fresh lock anyway — the very thing the check was added to prevent.
- Skipping `release()` on a mismatch left proper-lockfile's record registered
  in its `locks` map, and its `signal-exit` handler `rmdirSync`s every
  registered lock with **no ownership check of its own**. An exit during that
  window deleted the winner too.

The guard now lives in `options.fs`, which is the single seam *both* removal
paths route through — `removeLock` on release, and the exit handler. It
refuses to remove a directory whose inode and birth time are not the ones
recorded at acquire, and reports the refusal as success so the library's
bookkeeping forgets the lock either way (leaving it registered is what hands
the exit handler a record pointing at the winner's directory).

The check is a `statSync` immediately followed by an `rmdirSync`, with no
`await` between them, so nothing in this process can interleave. **It still
does not close the race** — it is check-then-act across processes, which needs
the compare-and-swap Node does not expose — and saying it "closes the
destructive half outright" was wrong. file-lock.ts, README.md,
specification/v2_servers_file.md and AGENTS.md now all say it narrows the
window rather than closing it. AGENTS.md carries the negative instruction
alongside round 4's, since this is the third round spent on an overclaim.

**"It expires on its own after 10s" is false for the ENOTEMPTY case** the new
test exercises. Stale takeover reclaims through the same `rmdir`, which also
cannot remove a non-empty directory — so nothing clears it, and every later
save fails ELOCKED against it. `releaseAdvice` now branches: by-hand removal
for ENOTEMPTY, expiry for everything else.

Tests: a real child process demonstrates the exit handler removing a
replacement lock (with an **empty** directory — a non-empty one makes
`rmdirSync` fail ENOTEMPTY, which is how the first draft of this test passed
for the wrong reason). The release-failure test now asserts the message says
"by hand" and does not promise expiry. The shim's `rmdirSync` arm carries a
justified `v8 ignore`: it is reachable only from a real process exit, and its
logic is `removeIfMine`, covered through the async arm.

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 11:10:48 -04:00
commit b159c75b88

View file

@ -76,10 +76,14 @@ v2/main/
│ │ │ # does NOT make stale takeover single-winner. proper-lockfile
│ │ │ # detects a takeover only on its 5s refresh tick, which an
│ │ │ # ordinary sub-second mutation never reaches, and its release
│ │ │ # is an unconditional rmdir — so withSecretFileLock does its
│ │ │ # OWN ownership check (inode+birthtime) before releasing, to
│ │ │ # avoid deleting the winner's lock. Detection is BEST-EFFORT;
│ │ │ # do not write that a compromised holder is always told.
│ │ │ # is an unconditional rmdir (as is its signal-exit handler) —
│ │ │ # so withSecretFileLock passes a GUARDED options.fs, the one
│ │ │ # seam both removal paths share, refusing to delete a lock
│ │ │ # that is no longer ours (inode+birthtime). That NARROWS the
│ │ │ # window, it does not close it — still check-then-act across
│ │ │ # processes. BEST-EFFORT throughout: do not write that a
│ │ │ # compromised holder is always told, or that the winner's
│ │ │ # lock is always preserved.
│ │ │ # 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