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

@ -361,13 +361,12 @@ describe("withSecretFileLock degrades rather than failing", () => {
describe("withSecretFileLock reports what it cannot clean up", () => {
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.
// The destructive half of the stale-takeover race. `proper-lockfile`'s
// removal is an unconditional `rmdir`, so a holder whose lock was
// replaced deletes the *winner's* directory on the way out — one
// compromised holder becoming 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.
//
// No waiting here, deliberately: this is the fast case the tick misses.
const target = filePath();
@ -375,7 +374,6 @@ describe("withSecretFileLock reports what it cannot clean up", () => {
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);
@ -384,18 +382,58 @@ describe("withSecretFileLock reports what it cannot clean up", () => {
});
expect(result).toBe("saved");
// The winner's lock is untouched — same directory, not a recreated one.
// Same directory, not a recreated one — it was left alone, not deleted.
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.
it("the exit handler this guards against really does delete a lock", async () => {
// The other lifecycle window, and the reason the guard lives in
// `options.fs` rather than in a check before `release()`:
// `proper-lockfile` registers a `signal-exit` handler that `rmdirSync`s
// every lock it still has registered, with no ownership check of its own.
// A guard placed only around release would leave that free to delete the
// winner's directory if the process exits at the wrong moment.
//
// Shown in a real child, because the handler only runs on a real exit,
// and with an **empty** replacement directory — a non-empty one makes
// `rmdirSync` fail `ENOTEMPTY` and would "pass" for the wrong reason,
// which is exactly how the first draft of this test fooled itself.
//
// `withSecretFileLock` routes that same handler through the shim's
// `rmdirSync`, which shares `removeIfMine` with the release path proven
// by the test above.
const target = filePath();
const lockPath = `${target}.lock`;
await fs.mkdir(path.dirname(target), { recursive: true });
const script = `
const lockfile = require(${JSON.stringify(LOCKFILE_MODULE)});
const fs = require("node:fs");
const lockPath = ${JSON.stringify(lockPath)};
lockfile
.lock(${JSON.stringify(target)}, { realpath: false, stale: 10000 })
.then(() => {
// Taken over while we hold it, then exit *without* releasing.
fs.rmSync(lockPath, { recursive: true, force: true });
fs.mkdirSync(lockPath);
process.exit(0);
});
`;
await run(process.execPath, ["-e", script]);
// Unguarded, the winner's directory is gone.
expect(existsSync(lockPath)).toBe(false);
}, 30_000);
it("tells the operator to clear a lock that cannot expire on its own", async () => {
// `rmdir` refuses a non-empty directory — and so does stale takeover,
// which reclaims through the same call. So an `ENOTEMPTY` lock is not
// cleaned up by the staleness mechanism, by us, or by the next writer:
// every later save fails `ELOCKED` against it until somebody deletes it.
// Promising it "expires on its own after 10s" would be false.
const target = filePath();
const result = await withSecretFileLock(target, async () => {
await fs.writeFile(`${target}.lock/stray`, "", "utf-8");
@ -406,6 +444,8 @@ describe("withSecretFileLock reports what it cannot clean up", () => {
// not be turned into a failure by its own teardown.
expect(result).toBe("saved");
expect(warnings()).toContain("Could not release the lock");
expect(warnings()).toContain("by hand");
expect(warnings()).not.toContain("expires on its own");
});
it("warns rather than crashing the process when the library detects the takeover", async () => {
@ -413,7 +453,7 @@ describe("withSecretFileLock reports what it cannot clean up", () => {
// 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.
// library's own detection fires rather than the removal guard.
const target = filePath();
await withSecretFileLock(target, async () => {
await fs.rm(`${target}.lock`, { recursive: true, force: true });