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:
parent
833a6ba76b
commit
91e1392c1f
5 changed files with 160 additions and 33 deletions
|
|
@ -73,8 +73,13 @@ v2/main/
|
|||
│ │ │ # mutual exclusion #2082 settled on — proper-lockfile,
|
||||
│ │ │ # 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.
|
||||
│ │ │ # 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.
|
||||
│ │ │ # 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
|
||||
|
|
|
|||
|
|
@ -504,7 +504,9 @@ The Inspector writes the file `0600` and re-tightens it at startup if something
|
|||
|
||||
**Two Inspectors, one file.** Within a process, mutations are serialized per file path, so a web session's own concurrent saves cannot lose each other. Across processes — a CLI run beside a web session — each mutation takes an exclusive lock on `secrets.json.lock` for the whole read-modify-write, using [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) (the same library npm itself locks with). The lock expires 10 seconds after its holder stops refreshing it, so an Inspector that is killed mid-save does not leave the file unwritable.
|
||||
|
||||
Two running Inspectors are therefore genuinely serialized. What a lock file cannot make single-winner is the *takeover of a lock whose holder died* — that needs a compare-and-swap on a directory entry (`renameat2`) which Node does not expose, and it is what an earlier hand-rolled attempt failed three review rounds on. `proper-lockfile` does not close that race either; what it adds is that the loser is **told** (it compares the lock's mtime against the value it recorded, so a holder whose lock was replaced is marked compromised and warns). The window opens only after a holder dies without releasing.
|
||||
Two running Inspectors are therefore genuinely serialized. What a lock file cannot make single-winner is the *takeover of a lock whose holder died* — that needs a compare-and-swap on a directory entry (`renameat2`) which Node does not expose, and it is what an earlier hand-rolled attempt failed three review rounds on. `proper-lockfile` does not close that race either. The window opens only after a holder dies without releasing.
|
||||
|
||||
The Inspector adds one thing on top: before releasing, it checks the lock directory is still the one it created (by inode and birth time, which survive the library's own refresh but not a delete-and-recreate). That matters mostly because the library's release is an unconditional `rmdir` — so a holder whose lock had been replaced would otherwise delete the *winner's* lock on the way out, turning one compromised writer into two unprotected ones. It also surfaces the takeover as a warning. Treat that warning as **best-effort**, not a guarantee: it rests on filesystem metadata that not every filesystem reports.
|
||||
|
||||
Which is why, underneath the lock, each mutation still reads the file, applies its change, writes, then reads back and compares the whole map; if something wrote in between it re-applies onto what was left and retries, failing loudly after five lost rounds rather than returning as though the value were saved. That check is what still catches a clobber inside that window — and it covers what no lock can, since a lock only orders the writers that *take* it: an editor, a restored backup, or an Inspector older than this release.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,12 +29,25 @@
|
|||
* the hand-rolled version could not close, and it cannot be closed with what
|
||||
* Node exposes (`renameat2(RENAME_EXCHANGE)`).
|
||||
*
|
||||
* What the library adds over the hand-rolled one is that the loser finds
|
||||
* out. Its refresh tick compares the lock's mtime against the value it
|
||||
* recorded at acquire, so a holder whose directory was replaced is marked
|
||||
* `ECOMPROMISED` and told — see `onCompromised` below. The window is also
|
||||
* narrow and conditional: it opens only after a holder *dies without
|
||||
* releasing*, since nothing else lets a lock go stale.
|
||||
* The library detects it only on its refresh tick — `updateLock` compares
|
||||
* the lock's mtime against the value recorded at acquire and fires
|
||||
* `onCompromised`. That tick runs at `stale / 2`, i.e. every 5s here, while
|
||||
* an ordinary mutation is a read, an scrypt derivation and an atomic write:
|
||||
* comfortably under a second. **So in the common case the tick never runs
|
||||
* and the library tells nobody.** Worse, its `release` path calls `rmdir`
|
||||
* unconditionally, with no ownership check — so a holder whose lock was
|
||||
* replaced goes on to delete the *winner's* lock on the way out, silently
|
||||
* ending the winner's exclusion too.
|
||||
*
|
||||
* {@link withSecretFileLock} therefore does its own ownership check before
|
||||
* releasing (see there). That closes the destructive half — we never remove
|
||||
* a lock directory that is not the one we created — and surfaces the
|
||||
* compromise in exactly the fast-mutation case the tick misses. Detection is
|
||||
* still **best-effort**, not a guarantee: it rests on inode and birth-time
|
||||
* identity, which some filesystems do not report.
|
||||
*
|
||||
* The window is at least narrow and conditional: it opens only after a
|
||||
* holder *dies without releasing*, since nothing else lets a lock go stale.
|
||||
*
|
||||
* **Which is why the optimistic verify in {@link FileSecretStore.mutate}
|
||||
* stays, and is not belt-and-braces.** It is what still catches a clobber
|
||||
|
|
@ -166,6 +179,53 @@ const isHeldElsewhere = (err: unknown): boolean =>
|
|||
const describeError = (err: unknown): string =>
|
||||
err instanceof Error ? err.message : String(err);
|
||||
|
||||
/** Where `proper-lockfile` puts the lock for `target` — its documented default. */
|
||||
const lockPathOf = (target: string): string => `${target}.lock`;
|
||||
|
||||
/**
|
||||
* A lock directory's identity, or `null` if it could not be read.
|
||||
*
|
||||
* `ino` and `birthtimeMs` together: a directory removed and recreated gets a
|
||||
* new inode and a new birth time, while `utimes` — which the library's own
|
||||
* refresh tick performs on our behalf every few seconds — changes neither.
|
||||
*
|
||||
* Not every filesystem reports both (Windows shares, some network mounts, and
|
||||
* older kernels report `0`). There the two reads simply agree and the check
|
||||
* below concludes the lock is ours, which is the behaviour without this check
|
||||
* at all — best-effort, and never a false alarm on a healthy lock.
|
||||
*/
|
||||
async function identify(
|
||||
lockPath: string,
|
||||
): Promise<{ ino: number; birthtimeMs: number } | null> {
|
||||
try {
|
||||
const stat = await fs.stat(lockPath);
|
||||
return { ino: stat.ino, birthtimeMs: stat.birthtimeMs };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the lock directory still the one we created?
|
||||
*
|
||||
* A missing directory counts as **not ours** — it was removed by a takeover
|
||||
* (or by hand), and there is nothing of ours left to release.
|
||||
*
|
||||
* An unreadable *baseline* (`claimedAs === null`) is the one case that
|
||||
* answers "ours": we could not identify the directory at acquire, so we have
|
||||
* nothing to compare against and must not accuse a healthy lock. Releasing is
|
||||
* then exactly what the library would have done unaided.
|
||||
*/
|
||||
async function stillOurs(
|
||||
lockPath: string,
|
||||
claimedAs: { ino: number; birthtimeMs: number } | null,
|
||||
): Promise<boolean> {
|
||||
if (claimedAs === null) return true;
|
||||
const now = await identify(lockPath);
|
||||
if (now === null) return false;
|
||||
return now.ino === claimedAs.ino && now.birthtimeMs === claimedAs.birthtimeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* One `proper-lockfile` acquire against `target`, with the given retry policy.
|
||||
*
|
||||
|
|
@ -266,20 +326,40 @@ export async function withSecretFileLock<T>(
|
|||
);
|
||||
return fn();
|
||||
}
|
||||
// Identity of the directory we just created, for the ownership check below.
|
||||
// **Not the mtime**, which is what the library compares: a lock held longer
|
||||
// than one refresh tick has its mtime rewritten by `utimes` legitimately,
|
||||
// so an mtime comparison would call our own healthy lock compromised. The
|
||||
// inode and birth time both survive `utimes` and both change when a
|
||||
// directory is removed and recreated, which is precisely the event to
|
||||
// detect.
|
||||
const claimedAs = await identify(lockPathOf(target));
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
try {
|
||||
await release();
|
||||
} catch (err) {
|
||||
// The body already ran and its result is being returned; a release
|
||||
// that failed means the lock was taken from us (declared stale while
|
||||
// we held it) or the directory went away. Neither is worth turning a
|
||||
// successful save into a failure, but a silent catch would leave a
|
||||
// lock nobody can explain, so say it.
|
||||
if (!(await stillOurs(lockPathOf(target), claimedAs))) {
|
||||
// Someone declared our lock stale and replaced it. **Do not release**:
|
||||
// `proper-lockfile`'s release is an unconditional `rmdir`, so calling
|
||||
// it here would delete the lock directory that now belongs to whoever
|
||||
// took over — ending their exclusion as well as ours, and turning one
|
||||
// compromised holder into two unprotected writers. Leaving their lock
|
||||
// alone costs us nothing; ours is already gone.
|
||||
warnOnce(
|
||||
`Could not release the lock on the secrets file at ${target} (${describeError(err)}). It expires on its own after ${STALE_MS / 1000}s.`,
|
||||
`The lock on the secrets file at ${target} was taken over by another process while a write was in progress. If a secret you just saved is missing, save it again.`,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
await release();
|
||||
} catch (err) {
|
||||
// The body already ran and its result is being returned; a release
|
||||
// that failed means the lock was taken from us (declared stale while
|
||||
// we held it) or the directory went away. Neither is worth turning a
|
||||
// successful save into a failure, but a silent catch would leave a
|
||||
// lock nobody can explain, so say it.
|
||||
warnOnce(
|
||||
`Could not release the lock on the secrets file at ${target} (${describeError(err)}). It expires on its own after ${STALE_MS / 1000}s.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ Each server entry may carry these Inspector-extension fields at the top level:
|
|||
- **Secret store selection (#1950)**: #1356 assumed a keychain. On a host without one — the published container (no D-Bus session, #1848), Android/Termux (no prebuilt binary, #1905), a minimal Linux install — `set` hard-failed, so those users could not persist an OAuth client secret or a stdio `env:` value **at all**. #1950 adds the two missing implementations plus the policy that picks one, and the surfaces that say which was picked.
|
||||
- **Selection**: `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright. Otherwise the keychain is _probed_ — an `AsyncEntry` construction plus a real read, because the container that motivated #1848 imports the package fine and only fails when it reaches for a Secret Service that isn't there. A read and not a write, deliberately: probing by writing would deposit a value in the user's login keyring at every startup for a store they may never use. If the probe fails the fallback is **`memory`** in a container whose secrets directory is not on a mount, and **`file`** otherwise — so mounting the volume the README already recommends for the catalog flips the same run to durable storage with no configuration, and an unmounted container gets the honest answer rather than a file `docker run --rm` will discard. Resolution is cached per process so the startup banner, `GET /api/config`, and the store doing the writing cannot disagree.
|
||||
- **`FileSecretStore` at rest**: one JSON document at `~/.mcp-inspector/secrets.json` (or `MCP_INSPECTOR_SECRET_FILE`, else under `MCP_STORAGE_DIR`), written `0600` and re-tightened at startup. With `MCP_INSPECTOR_SECRET_KEY` set it is AES-256-GCM with a scrypt-derived key and a per-write random salt; without it the values are in the clear, and _that_ is what the loud banner and the warning-toned modal footer are about. The **whole map is encrypted as a unit**, not value-by-value, so the account names (`serverId:field`) are hidden too — a per-value scheme would leave a readable index of which servers you hold a client secret for. Upgrading is lazy: setting the passphrase later re-encrypts on the next write rather than rewriting the file during a run that may never touch a secret, and the descriptor reports `pendingEncryption` until it does. A file that can no longer be decrypted reads as empty but **refuses to be written**, because replacing a file of still-valid secrets to satisfy an additive request destroys data.
|
||||
- **Concurrency**: within a process, mutations are serialized per **resolved file path** (not per store instance — two `FileSecretStore`s on one file are ordinary, since the resolved store holds one and the keychain hand-off builds another). Across processes, each mutation holds an exclusive `proper-lockfile` lock on `secrets.json.lock` for the whole read-modify-write (#2082). The in-process queue is what keeps that usable: `proper-lockfile` is not reentrant, so a second `lock()` from the same process fails `ELOCKED` and would be indistinguishable from a genuine remote holder. An earlier iteration hand-rolled the lock — a `mkdir` election with an owner stamp, heartbeat and stale-takeover — and three review rounds each found a real race, the last not closable with what Node exposes (claiming a stale lock needs compare-and-swap on a directory entry, `renameat2(RENAME_EXCHANGE)`). #2082 settled that as "borrow, don't hand-roll" — but the borrowed lock is not claimed to close that race, because it does not. `proper-lockfile@4.1.2` `rmdir`s a stale lock and re-`mkdir`s 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; what it adds over the hand-rolled version is *detection* — its refresh tick compares the lock's mtime against the value recorded at acquire, so a compromised holder is told (`ECOMPROMISED`, surfaced as a warning) instead of proceeding silently. What **is** 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 genuinely serialized. The residual window opens only after a holder dies without releasing. A waiter also waits past the stale window before giving up (so a crashed holder resolves by takeover rather than failing everyone else's saves), and a lock still held after that makes `set` **fail** rather than write alongside a visible concurrent writer — `ELOCKED` is evidence the lock is working, not a reason to bypass it. **The optimistic verify stays underneath it**, and is not redundant: read `M0`, apply, write `M1`, read back `M2`, re-apply onto whatever a concurrent writer left if they differ, bounded, with `set` throwing on non-convergence rather than returning as though the value were saved. The comparison is over the **whole map** — checking only your own entry passes in exactly the case that loses data, because yours is present and the other writer's is gone. A lock is advisory between the processes that take it, so the verify is what covers a writer outside this codebase (an editor, a restored backup, an Inspector predating #2082) and what covers the lock being *unavailable*: `withSecretFileLock` runs the body anyway, warning once, on a directory that cannot hold a lock file — this store exists for boxes where the usual mechanism is missing (#1848, #1905) and must not acquire a new way to be unavailable. Reads take no lock; `writeStoreFile` is atomic, so a reader sees the old file or the new one, never a torn one.
|
||||
- **Concurrency**: within a process, mutations are serialized per **resolved file path** (not per store instance — two `FileSecretStore`s on one file are ordinary, since the resolved store holds one and the keychain hand-off builds another). Across processes, each mutation holds an exclusive `proper-lockfile` lock on `secrets.json.lock` for the whole read-modify-write (#2082). The in-process queue is what keeps that usable: `proper-lockfile` is not reentrant, so a second `lock()` from the same process fails `ELOCKED` and would be indistinguishable from a genuine remote holder. An earlier iteration hand-rolled the lock — a `mkdir` election with an owner stamp, heartbeat and stale-takeover — and three review rounds each found a real race, the last not closable with what Node exposes (claiming a stale lock needs compare-and-swap on a directory entry, `renameat2(RENAME_EXCHANGE)`). #2082 settled that as "borrow, don't hand-roll" — but the borrowed lock is not claimed to close that race, because it does not. `proper-lockfile@4.1.2` `rmdir`s a stale lock and re-`mkdir`s 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 its own detection is weaker than it looks: `updateLock` compares mtime only on the refresh tick (`stale / 2`, 5s here), while an ordinary mutation finishes in well under a second — so in the common case the tick never runs and nobody is told. Its `release` is also an unconditional `rmdir` with no ownership check, so a holder whose lock was replaced deletes the *winner's* lock on the way out, turning one compromised writer into two unprotected ones. `withSecretFileLock` therefore performs its own ownership check before releasing (inode + birth time, which survive the library's `utimes` refresh but not a delete-and-recreate): on a mismatch it declines to release and warns. That closes the destructive half outright and surfaces the takeover in the fast case the tick misses — but detection remains **best-effort**, since not every filesystem reports those fields, and where they are unavailable it degrades to the library's unaided behaviour rather than to a false alarm. What **is** 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 genuinely serialized. The residual window opens only after a holder dies without releasing. A waiter also waits past the stale window before giving up (so a crashed holder resolves by takeover rather than failing everyone else's saves), and a lock still held after that makes `set` **fail** rather than write alongside a visible concurrent writer — `ELOCKED` is evidence the lock is working, not a reason to bypass it. **The optimistic verify stays underneath it**, and is not redundant: read `M0`, apply, write `M1`, read back `M2`, re-apply onto whatever a concurrent writer left if they differ, bounded, with `set` throwing on non-convergence rather than returning as though the value were saved. The comparison is over the **whole map** — checking only your own entry passes in exactly the case that loses data, because yours is present and the other writer's is gone. A lock is advisory between the processes that take it, so the verify is what covers a writer outside this codebase (an editor, a restored backup, an Inspector predating #2082) and what covers the lock being *unavailable*: `withSecretFileLock` runs the body anyway, warning once, on a directory that cannot hold a lock file — this store exists for boxes where the usual mechanism is missing (#1848, #1905) and must not acquire a new way to be unavailable. Reads take no lock; `writeStoreFile` is atomic, so a reader sees the old file or the new one, never a torn one.
|
||||
- **Strict reads (`getStrict`)**: `get` is tolerant by contract, and that tolerance is wrong for exactly one caller — a migration that treats `null` as proof of absence and then _writes_. A transient read failure would look like "nothing there", and the write would replace a newer stored value with an older on-disk copy, inverting the keychain-wins rule the migration is built on. `getStrict` throws instead, and both plaintext migrations plus the keychain hand-off use it. Two traps this hit on the way in, both worth remembering: the seam must be forwarded by `DeferredSecretStore` (the store production actually uses, so without forwarding the strictness existed only in tests that injected a concrete store), and it must be implemented by _every_ store rather than falling back to `get` for the one it was introduced for.
|
||||
- **Bulk reads (`getMany`)**: rehydration asked field by field, and `FileSecretStore.get` reads and decrypts the entire file per call. scrypt at `N=16384` measures ~23ms, so an encrypted catalog spent ~450ms of pure key derivation on every `GET /api/servers` — a visible stall rather than a micro-optimization. The seam takes a **list of `{ serverId, fields }` across servers**, and both rehydration callers pass the whole catalog in one request, so `FileSecretStore` decrypts once per rehydration; stores for which per-field reads are already cheap (the keychain) fall back to parallel `get`. The cross-server shape is the point: a per-server version shipped first and left a 20-server catalog paying 20 serialized derivations, because both callers iterate servers — the same stall reached one server at a time instead of one field at a time.
|
||||
- **Durability gate on migration**: the plaintext-stripping migrations only delete the disk copy once the value is somewhere that outlives the process (`isDurable`). Against a session-scoped store, stripping would trade a secret that survives restarts for one that dies with the process — and it runs on an ordinary `GET`, so merely opening the app would destroy it.
|
||||
|
|
|
|||
Loading…
Reference in a new issue