fix(auth): hold the snapshot lock across the main lock's release (#2082)
Copilot review round 8 — two findings, both upheld. The first is a gap in round 7's own fix. **The snapshot was unprotected between two awaits.** Returning from `withSecretFileLock(filePath, ...)` releases the main lock *before* `handOffStagedSecrets` acquired the snapshot's, so a second startup could take the main lock in that window, see the staged file unlocked, and adopt and re-claim it — leaving this process reading a path that no longer exists. Narrower than the race round 7 closed, but the same one. `openSecretFileLock` is now split out of `withSecretFileLock`: it takes the lock and returns its release, so a caller can hold one *across* another's release. The claim acquires the snapshot's lock before returning — inside the main lock — and the hand-off then runs outside the main lock, still holding it. `withSecretFileLock` is a thin wrapper over the same function, so which failures refuse and which degrade is decided in one place and the two entry points cannot drift. Locking the snapshot degrades rather than refusing (`null` → hand off anyway): the exposure is one uniquely-named snapshot, which is a better trade than refusing to migrate at all on a box that cannot lock. **Both migration scans matched the lock directories themselves.** `secrets.json.migrating-<pid>-<uuid>.lock` passes the plain prefix test, and treating it as a snapshot is self-sustaining damage: the liveness probe asks about a nonexistent `<name>.lock.lock` and so answers "not held", recovery tries to hard-link a *directory* onto the secrets path, fails, and prints the orphan warning. Because a liveness *check* never clears a stale lock directory — only a would-be acquirer does — that false migration repeats on every startup forever, including long after the real snapshot was recovered. `isSnapshotName` now excludes `.lock` for both `anythingToMigrate` and `recoverOrphanedSnapshots`. New test verified by mutation. 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:
parent
b35db18fe8
commit
edddc6c9cc
1 changed files with 112 additions and 49 deletions
|
|
@ -828,6 +828,28 @@ describe("absorbFileSecretsIntoKeyring", () => {
|
|||
expect(await keyring.get("srv", "oauthClientSecret")).toBe(null);
|
||||
});
|
||||
|
||||
it("ignores a snapshot's own lock directory (#2082)", async () => {
|
||||
// `secrets.json.migrating-<pid>-<uuid>.lock` matches the plain prefix
|
||||
// test, and treating it as a snapshot is self-sustaining damage: the
|
||||
// liveness probe asks about a nonexistent `<name>.lock.lock` and answers
|
||||
// "not held", recovery tries to hard-link a *directory* onto the secrets
|
||||
// path, fails, and prints the orphan warning — every startup, forever,
|
||||
// since a liveness *check* never clears a stale lock directory.
|
||||
const strayLock = path.join(tmpDir, "secrets.json.migrating-999-abc.lock");
|
||||
await fs.mkdir(strayLock);
|
||||
process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "secrets.json");
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const mod = await loadWithProbe(true);
|
||||
|
||||
await mod.absorbFileSecretsIntoKeyring(new InMemorySecretStore());
|
||||
|
||||
// Not mistaken for a snapshot: nothing said, nothing linked, and the
|
||||
// directory left exactly where it was.
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
expect(existsSync(strayLock)).toBe(true);
|
||||
expect(existsSync(path.join(tmpDir, "secrets.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("still adopts an orphan when the live file is absent (#2082)", async () => {
|
||||
// The fast path above must not be a `stat` of `secrets.json`: an
|
||||
// interrupted migration leaves *only* the snapshot, which is precisely
|
||||
|
|
|
|||
Loading…
Reference in a new issue