fix(auth): stop deleting the winner's lock after a stale takeover (#2082)

Copilot review round 4 — four comments, all the same finding against the four
files carrying the claim, and it was more than a wording problem.

The comment said a holder whose lock was replaced is told. Checked against
proper-lockfile@4.1.2, that is false in the case that actually happens:

- `updateLock` compares mtime only on its refresh tick — `stale / 2`, so 5s
  here — while an ordinary mutation is a read, an scrypt derivation and an
  atomic write, comfortably under a second. The tick never runs, so nothing
  fires.
- `release` → `unlock` → `removeLock` is an unconditional `rmdir` with **no
  ownership check**. So the compromised holder goes on to delete the
  *winner's* lock on the way out, ending the winner's exclusion too: one
  compromised writer silently becomes two unprotected ones.

The second half is a correctness bug, not a documentation defect, and it is
fixable here even though the takeover race itself is not.

`withSecretFileLock` now records the lock directory's inode and birth time at
acquire and re-checks them before releasing. On a mismatch it **declines to
release** — leaving the new holder's lock alone costs nothing, ours is
already gone — and warns.

Inode and birth time rather than mtime, deliberately: both survive the
library's own `utimes` refresh, so a lock held past one tick is not accused of
being compromised, and both change on a delete-and-recreate, which is exactly
the event to detect. Where a filesystem reports neither, the two reads agree
and the check concludes "ours", degrading to the library's unaided behaviour
rather than to a false alarm. An unreadable baseline at acquire likewise
answers "ours" — with nothing to compare against, accusing a healthy lock
would be worse than releasing it.

Detection is therefore **best-effort**, and file-lock.ts, README.md,
specification/v2_servers_file.md and AGENTS.md now all say so rather than
promising the loser is told. AGENTS.md carries the negative instruction too,
since this is the second round spent on the same overclaim.

Tests: "does not delete the winner's lock after being taken over" replaces the
lock inside the body and asserts the replacement survives with the *same*
inode and birth time — it was left alone, not deleted and recreated. It waits
for nothing, being the fast case the library's tick misses. Verified by
mutation: forcing `stillOurs` to always answer true fails it. A second test
covers a release that fails while the lock genuinely *is* ours (a stray file
makes `rmdir` fail ENOTEMPTY), so the ownership check cannot swallow a real
release failure.

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:51:40 -04:00
commit 91e1392c1f

View file

@ -360,27 +360,67 @@ describe("withSecretFileLock degrades rather than failing", () => {
});
describe("withSecretFileLock reports what it cannot clean up", () => {
it("warns rather than crashing the process when the lock is taken over", async () => {
// `proper-lockfile`'s default `onCompromised` *throws*, from a timer
// with no caller on the stack — an uncaught exception that takes an
// Inspector session down. Driven for real: the lock directory is removed
// while held, which is what an operator "clearing a stuck lock" does,
// and the library's own refresh tick (`stale / 2`) notices.
it("does not delete the winner's lock after being taken over", async () => {
// The destructive half of the stale-takeover race, and the reason this
// check exists at all. `proper-lockfile`'s release is an unconditional
// `rmdir`: a holder whose lock was replaced deletes the *winner's*
// directory on the way out, so one compromised holder becomes two
// unprotected writers. Its own detection cannot prevent that — it runs on
// the refresh tick (5s here) while an ordinary mutation finishes in well
// under a second, so the tick never runs and nobody is told.
//
// No waiting here, deliberately: this is the fast case the tick misses.
const target = filePath();
const lockPath = `${target}.lock`;
let winner: { ino: number; birthtimeMs: number } | undefined;
const result = await withSecretFileLock(target, async () => {
// Someone declares our lock stale, removes it, and takes over.
await fs.rm(lockPath, { recursive: true, force: true });
await fs.mkdir(lockPath);
const stat = await fs.stat(lockPath);
winner = { ino: stat.ino, birthtimeMs: stat.birthtimeMs };
return "saved";
});
expect(result).toBe("saved");
// The winner's lock is untouched — same directory, not a recreated one.
const after = await fs.stat(lockPath);
expect(after.ino).toBe(winner?.ino);
expect(after.birthtimeMs).toBe(winner?.birthtimeMs);
expect(warnings()).toContain("was taken over by another process");
});
it("warns instead of throwing when releasing a lock that is ours fails", async () => {
// The ownership check above must not swallow a genuine release failure.
// `rmdir` refuses a non-empty directory, so a stray file inside the lock
// leaves it identifiably *ours* — same inode, same birth time — and still
// unremovable.
const target = filePath();
const result = await withSecretFileLock(target, async () => {
await fs.writeFile(`${target}.lock/stray`, "", "utf-8");
return "saved";
});
// The body's result is returned regardless: a save that completed must
// not be turned into a failure by its own teardown.
expect(result).toBe("saved");
expect(warnings()).toContain("Could not release the lock");
});
it("warns rather than crashing the process when the library detects the takeover", async () => {
// `proper-lockfile`'s default `onCompromised` *throws*, from a timer with
// no caller on the stack — an uncaught exception that takes an Inspector
// session down. Replaced with a warning. This is the slow path: the lock
// is removed and the body stays alive past the refresh tick, so the
// library's own detection fires rather than the release-time check above.
const target = filePath();
await withSecretFileLock(target, async () => {
await fs.rm(`${target}.lock`, { recursive: true, force: true });
await vi.waitFor(
() => expect(warnings()).toContain("was taken over by another process"),
{ timeout: 20_000, interval: 250 },
);
return "saved";
});
// The body's result is returned regardless. A compromised lock means the
// guarantee was lost, not that the work did not happen, and the release
// that then fails (`ELOCKNOTHELD`, since the library has already given
// the lock up) must not turn a completed save into a thrown error.
expect(result).toBe("saved");
expect(warnings()).toContain("Could not release the lock");
}, 30_000);
});