fix(auth): floor the shared revocation budget instead of racing zero

The shared teardown deadline was decided on `remainingMs > 0` against
`Date.now()`. Both halves of that are noise-sensitive: the deadline is
enforced by a `setTimeout`, and a wall clock at millisecond resolution
can still read a hair short of it when the loop comes round, so the next
grant inherits a fractional budget and spends it on a request that cannot
possibly complete. The same run then issues one request or two depending
on scheduling — which is what makes the "shares one deadline across
grants" test intermittent on CI (#2252).

Measure the deadline with `performance.now()` (monotonic, so an NTP step
or a suspend cannot expire or extend the budget, and sub-millisecond, so
none is lost to rounding), and skip any grant reaching the loop with less
than MIN_REVOCATION_REQUEST_BUDGET_MS left. The skipped grant is already
reported as `failed`, so nothing is silently dropped — the needless call
to the authorization server is.

The CLI's outer per-plan budget in `sendPlans` shares both the shape and
the defect, so it gets the same treatment.

One consequence worth naming: a monotonic clock hands `revokeToken` a
fractional budget, and Node's `AbortSignal.timeout` throws
`ERR_OUT_OF_RANGE` on a non-integer delay — before the fetch, so the
request would never be sent and the revocation would report that as its
failure. `revokeToken` now rounds its own budget to whole milliseconds,
which also covers any other caller passing a fractional one.

Tests: the epsilon skip is pinned by a grant that lands inside the floor
but above zero (removing the floor fails it, verified); the fractional
budget by a `revokeToken` call at 19.996ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wEXtbs8UEHUMxDEAxbs99
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-05 01:25:25 -04:00
commit 84a65f28d8

View file

@ -4,6 +4,7 @@ import {
} from "@inspector/core/auth/node/storage-node.js";
import {
DEFAULT_REVOCATION_TIMEOUT_MS,
MIN_REVOCATION_REQUEST_BUDGET_MS,
clearAndPlanRevocation,
executeOAuthRevocation,
type OAuthRevocationPlan,
@ -113,17 +114,19 @@ async function sendPlans(
budgetMs: number,
): Promise<TokenRevocationOutcome | undefined> {
const fetchFn = createProxyFetch() ?? fetch;
const deadlineAt = Date.now() + budgetMs;
// Monotonic and epsilon-floored for the same reasons as the shared deadline
// inside `executeOAuthRevocation` — see MIN_REVOCATION_REQUEST_BUDGET_MS.
const deadlineAt = performance.now() + budgetMs;
let reported: TokenRevocationOutcome | undefined;
let lastSkip: TokenRevocationOutcome | undefined;
for (const plan of plans) {
const remainingMs = deadlineAt - Date.now();
const remainingMs = deadlineAt - performance.now();
// A plan that already knows its answer needs no network, so the budget is
// irrelevant to it. Synthesising exhaustion here would warn that a grant
// may still be live when the key held no grant at all — a false alarm, and
// one that outranks the real outcome under the failure-first rule below.
const needsNetwork = plan.outcome === undefined;
if (needsNetwork && remainingMs <= 0) {
if (needsNetwork && remainingMs <= MIN_REVOCATION_REQUEST_BUDGET_MS) {
// Overrides an earlier success rather than deferring to it: this key's
// grant may still be live at the authorization server, and that is the
// thing the user needs to hear about. Same failure-first rule as below.