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:
parent
eb44e6d238
commit
84a65f28d8
1 changed files with 110 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue