fix(auth): floor the shared revocation budget instead of racing zero - #2256
Conversation
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>
There was a problem hiding this comment.
🟡 Changes recommended
The CLI-specific minimum-budget branch lacks a distinguishing regression test.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Fixes #2252 by making OAuth revocation deadlines monotonic and skipping requests with unusably small remaining budgets.
Changes:
- Uses
performance.now()and a shared 5 ms minimum budget. - Rounds fractional timeout values for
AbortSignal.timeout. - Adds core regression tests for fractional and near-exhausted budgets.
File summaries
| File | Description |
|---|---|
core/auth/revocation.ts |
Implements monotonic deadlines, budget flooring, and timeout rounding. |
core/auth/index.ts |
Exports the minimum-budget constant. |
clients/web/src/test/core/auth/revocation.test.ts |
Tests fractional and near-exhausted budgets. |
clients/cli/src/clear-stored-auth-for-relogin.ts |
Applies the same deadline floor across CLI plans. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Balanced
Copilot round 1 on #2256: the `budgetMs: 0` case passes under the old `remainingMs <= 0` bound too, so nothing in the CLI suite detected the floor added to `sendPlans`. The new case gives it a budget that is positive and below the floor, and asserts on *this loop's* exhaustion message — the one naming the server URL. That distinction is the test: with the floor removed the plan is handed a 4ms budget and core's identical floor declines it one level down, so a bare "budget was exhausted" match passes either way. Verified as a detector — 1 failed / 14 passed with the CLI floor reverted, 15 passed with it. 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>
|
Copilot review round 1 — addressed (c736f1d), mirrored here since inline replies go hidden once the fix is pushed.
The finding was right, and the first version of the fix was not good enough: asserting
|
There was a problem hiding this comment.
🟢 Approval recommended
The implementation consistently addresses the deadline race and includes targeted regression coverage for both shared-budget paths.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Both new floor regression tests rely on real scheduler timing and can pass against the old implementation under CI contention.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 2
- Review effort level: Balanced
Copilot round 3 on #2256: both detectors were one-sided. Each measured the sub-floor remainder against the real clock, so a preempted worker overshoots the deadline, the remainder goes negative, and the unfixed `remainingMs <= 0` bound takes the same branch and prints the same message — the test passes on an implementation with no floor. It could never fail wrongly, but it could silently stop testing anything, which on a flake fix is the wrong half of that guarantee to keep. Both now stub `performance.now()`: the web case advances it inside the fetch so the second grant's remainder is exactly MIN_REVOCATION_REQUEST_BUDGET_MS - 1, and the CLI case freezes it so the remainder at the check is the budget itself. Positive and under the floor on every machine, which is the only state that separates the two bounds. Verified as detectors with the clock stubbed: reverting the core floor gives 1 failed / 57 passed, reverting the CLI floor 1 failed / 14 passed. 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>
|
Copilot review round 3 — both comments addressed (927a708), mirrored here since inline replies go hidden once the fix is pushed.
The point stands and was the important half: neither test could fail wrongly, but either could silently stop testing anything — which on a flake fix is the wrong half of that guarantee to keep. Mutation table, re-run with the stubs in place:
The original flake,
|
Closes #2252
The race
executeOAuthRevocationspends one budget across every grant, and decided whether the next grant got a turn withremainingMs = deadlineAt - Date.now()againstremainingMs <= 0. Both halves of that sit on noise:setTimeout, so the grant that consumed it can return with the clock a hair short ofdeadlineAt;Date.now()is millisecond-resolution, so it can read short of the deadline it has actually passed.Either way the next grant inherits a sliver of budget — less than a TCP handshake — and spends it on a request guaranteed to time out. The same run therefore issues one request or two depending on scheduling, which is exactly what makes the
shares one deadline across grantstest intermittent on CI.The fix
performance.now()instead ofDate.now(): this is an elapsed-time measurement, so an NTP step or a suspend/resume mid-teardown must not expire or extend the budget, and sub-millisecond resolution means none of it is spent or preserved by rounding.MIN_REVOCATION_REQUEST_BUDGET_MS(5ms) — a budget too small to complete a request is treated as no budget at all. The skipped grant is still reported asfailedwith the exhausted-budget detail, so nothing is silently dropped; only the pointless call to the authorization server is. The decision is now an order of magnitude away from timer noise, so it comes out the same on every run.The CLI’s outer per-plan budget in
sendPlans(clear-stored-auth-for-relogin.ts) has the same shape and the same defect, so it gets the same treatment.One consequence, found by the gate
A monotonic clock hands
revokeTokena fractional budget, and Node’sAbortSignal.timeoutthrowsERR_OUT_OF_RANGEon a non-integer delay — before the fetch, so the request is never sent and the revocation reportsThe value of "delay" is out of rangeas its failure detail.revokeTokennow rounds its own budget to whole milliseconds, which also covers any other caller passing a fractional one. Rounded rather than floored so a caller’s own whole-millisecond timeout survives the trip through the clock and is still the number the timeout message names.Tests
does not issue a request with less than the minimum budget left— a first grant that lands the second inside the floor but above zero, i.e. the sliver the old bound would have spent. Verified as a real detector: reverting the bound to<= 0fails it (1 failed, 56 passed), and it degrades safely — a slower machine only pushes the remainder further below the floor.accepts a fractional budget and still sends the request—revokeTokenattimeoutMs: 19.996.npm run local:gategreen. Two unrelated 5s-timeout flakes surfaced on earlier runs of the gate (ServerImportJsonModal— that is #2250 — and oneServerSettingsModalcase); both pass in isolation and neither is touched by this diff.No UI change, so no screenshots.
🤖 Generated with Claude Code
https://claude.ai/code/session_017wEXtbs8UEHUMxDEAxbs99