fix(auth): keep the keychain hand-off non-throwing, and stop overstating the wait (#2082)
Copilot review round 3. - **`absorbFileSecretsIntoKeyring` must never throw, and round 2 broke that.** Making `withSecretFileLock` reject on a held lock was right for `set` — a user is waiting on that value — and wrong here: this is awaited directly by both `resolveSecretStore` branches, so a stuck writer elsewhere on the box would have failed store resolution and with it the whole session. The lock failure is now caught, warned, and the file left for the next run. Covered by a test that holds the lock from another handle and asserts the hand-off resolves, the file survives, and the lock-specific warning is emitted (it asserts that message rather than the shared "left in place" tail, which the pre-existing claim-failure path also prints and would have let it pass without the lock ever being reached). - **The refusal message overstated how long it waited.** `retries * maxTimeout` reads 20s for a schedule that sums to 15.26s — the early attempts are the exponential ramp, not the cap. Replaced with `RETRY_BUDGET_MS`, computed from the schedule rather than written down, so it cannot drift from the thing it describes. `retry` applies no jitter by default, so it is exact rather than an estimate. - **The PR description still described the pre-round-2 design** — the single-winner claim and the ~3s backoff. Rewritten to match the code. Coverage: the round-3 changes cost `secret-store-selection.ts` two branches and it fell to 89.62%. Recovered honestly rather than by annotation — the `ENOTDIR` arm of `anythingToMigrate` now has a real test (a path whose parent is a file, the other shape of "nothing there"), and the new catch types its parameter `Error` instead of re-narrowing, since `withSecretFileLock` rejects only with the `SecretStoreUnavailableError` it constructs one call away. 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
dcd70e2f11
commit
833a6ba76b
2 changed files with 92 additions and 1 deletions
|
|
@ -96,6 +96,28 @@ const RETRY = {
|
|||
maxTimeout: 1_000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* What {@link RETRY} actually sums to, in milliseconds.
|
||||
*
|
||||
* Computed rather than written down. `retries * maxTimeout` overstates it by
|
||||
* a third — the early attempts are the exponential ramp, not the cap — and a
|
||||
* hand-maintained constant is one edit away from disagreeing with the
|
||||
* schedule it describes, in a message whose whole job is to tell a user how
|
||||
* long the Inspector waited. `retry` applies no jitter by default
|
||||
* (`randomize` is off), so this is exact rather than an estimate.
|
||||
*
|
||||
* It must stay **above {@link STALE_MS}**: see {@link RETRY}.
|
||||
*/
|
||||
const RETRY_BUDGET_MS = ((): number => {
|
||||
let total = 0;
|
||||
let delay = RETRY.minTimeout;
|
||||
for (let i = 0; i < RETRY.retries; i++) {
|
||||
total += Math.min(delay, RETRY.maxTimeout);
|
||||
delay *= RETRY.factor;
|
||||
}
|
||||
return total;
|
||||
})();
|
||||
|
||||
/** Emitted once per process, not once per call — see {@link warnOnce}. */
|
||||
const warned = new Set<string>();
|
||||
|
||||
|
|
@ -231,7 +253,7 @@ export async function withSecretFileLock<T>(
|
|||
// retries — whereas proceeding can lose a secret while reporting success.
|
||||
if (isHeldElsewhere(err)) {
|
||||
throw new SecretStoreUnavailableError(
|
||||
`Could not save to the secrets file at ${target}: another process has held the lock on it for over ${Math.round((RETRY.retries * RETRY.maxTimeout) / 1000)} seconds. Its secrets are intact; the value you just entered was not saved. If no other Inspector is running, remove ${target}.lock and try again.`,
|
||||
`Could not save to the secrets file at ${target}: another process has held the lock on it for the ${Math.round(RETRY_BUDGET_MS / 1000)} seconds this save waited. Its secrets are intact; the value you just entered was not saved. If no other Inspector is running, remove ${target}.lock and try again.`,
|
||||
);
|
||||
}
|
||||
// Everything else is the lock being *unavailable* rather than held — a
|
||||
|
|
|
|||
|
|
@ -553,6 +553,14 @@ export async function absorbFileSecretsIntoKeyring(
|
|||
// The lock is released before the hand-off: that part reads a snapshot
|
||||
// nobody else can reach, and holding it across a keychain round-trip per
|
||||
// secret would block every writer for the duration of a migration.
|
||||
//
|
||||
// Wrapped because `withSecretFileLock` **throws** when another process
|
||||
// holds the lock past its retry budget, and this function's contract is
|
||||
// that it never throws: it is awaited directly by both `resolveSecretStore`
|
||||
// branches, so a stuck writer would fail store resolution and with it the
|
||||
// whole session. Refusing is the right answer for a `set` — the user is
|
||||
// waiting on that value — and the wrong one here, where the file simply
|
||||
// stays put and the next run migrates it.
|
||||
const claimed = await withSecretFileLock(filePath, async () => {
|
||||
// A crash between the claim and the delete leaves only
|
||||
// `secrets.json.migrating-<pid>`. Checking the canonical path alone then
|
||||
|
|
@ -600,6 +608,15 @@ export async function absorbFileSecretsIntoKeyring(
|
|||
return null;
|
||||
}
|
||||
return staged;
|
||||
// `withSecretFileLock` rejects only with the `SecretStoreUnavailableError`
|
||||
// it constructs itself (a lock it could not create degrades instead of
|
||||
// throwing), so the cast is over a value produced one call away rather
|
||||
// than an assumption about arbitrary throwables.
|
||||
}).catch((err: Error) => {
|
||||
console.warn(
|
||||
`\n[mcp-inspector] Could not lock the secrets file at ${filePath} to migrate it into the OS keychain (${err.message}), so it has been left in place. Its secrets are not visible to this session; the next run will try again.`,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
if (claimed === null) return;
|
||||
const staged = claimed;
|
||||
|
|
|
|||
Loading…
Reference in a new issue