fix(web): terminate the five void-discarded OAuth recovery promises - #2190
Conversation
Each of the five sites in `useOAuthRecovery` discarded a promise with `void`, satisfying `@typescript-eslint/no-floating-promises` but not the narrower rule AGENTS.md states — `void` only where the callee owns its failures or the caller cannot await, with a comment saying which. Each now picks a user-visible outcome: - `prepareOAuthRedirect` clears the resume snapshot (so the next load cannot read a redirect that never started as an *abandoned* one), flags the server and raises the re-auth banner. - the `authChallengeInteractive` handler flags and banners, where before a rejection drew no UI response at all. - `resumePendingReauth` restores the pending slot and toasts, so the next trigger retries the deferred recovery instead of losing it. - the `/oauth/callback` effect flags and banners what falls between its existing arms — the client rebuild and the post-resume scope check. - `handleStepUpAuthorize`'s EMA branch reports exactly as its `failed` outcome arm does; the stored retry gets its own catch, since by then the permissions leg succeeded and the failure is the command's. The two surviving `void`s carry the justification AGENTS.md asks for. Closes #2165 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
🟡 Changes recommended
Navigation-state handling and asynchronous server/recovery races can misroute or lose OAuth recovery state.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Improves web OAuth recovery by surfacing previously discarded promise failures and preserving retry behavior.
Changes:
- Adds targeted failure handling for five OAuth recovery paths.
- Restores deferred recovery after transient failures.
- Adds shared UX copy and regression tests.
File summaries
| File | Description |
|---|---|
core/auth/oauthUx.ts |
Adds deferred-recovery failure copy. |
clients/web/src/utils/oauthUx.ts |
Re-exports the copy helper. |
clients/web/src/test/core/auth/oauthUx.test.ts |
Tests failure-message formatting. |
clients/web/src/hooks/useOAuthRecovery.ts |
Handles OAuth recovery rejections. |
clients/web/src/hooks/useOAuthRecovery.test.tsx |
Tests the new failure paths. |
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Three findings, all valid: - `beginInteractiveAuthorization` navigated *before* awaiting provider creation and flow-state recording, so a rejection could arrive after the browser had been handed over — and the web catch clears the resume snapshot on one. Fixed at the root: the navigation now resolves last, after every fallible step, which makes every rejection the method can produce a pre-navigation rejection. It is also better on its own terms — a provider that cannot be built means the callback could not have completed, so navigating first only bought a dead-end round trip. - `resumePendingReauth` restored the pending slot unconditionally, which could overwrite a newer challenge deferred while it was awaiting. Restores only into an empty slot, so latest wins. - the `authChallengeInteractive` catch re-read the active server rather than the one the challenge arrived for. `serverId` is now captured synchronously in the listener and used by both the body and the catch. Three new tests, one per finding, each driving the interleaving rather than the happy path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY Signed-off-by: cliffhall <cliff@futurescale.com>
Round 1 — all three Copilot findings addressedMirrored here because inline replies go hidden once the fix is pushed and the threads go outdated. 1. Fixed at the root rather than by adding a "did navigation start" flag for the call site to interpret: the navigation now resolves last, after every fallible step. Every rejection the method can produce is therefore a pre-navigation rejection, and the web catch's reading is unconditionally correct. It is also the better behaviour on its own terms — a provider that cannot be built, or client information that cannot be read, means the callback could not have completed anyway, so navigating first only bought the user a round trip to a dead end. New core test asserts neither 2. 3. the ambient catch re-read the active server — correct.
|
There was a problem hiding this comment.
🟡 Changes recommended
Concurrent recovery attempts can delete newer snapshots or restore stale challenges.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
clients/web/src/hooks/useOAuthRecovery.ts:554
- This clears whichever resume snapshot is current, not necessarily the one written by this invocation.
prepareOAuthRedirecthas no single-flight guard and can run from concurrent connect/challenge paths; if attempt A rejects after attempt B writes its snapshot (or requests navigation), A deletes B's callback routing state. Associate the snapshot with an attempt token and clear it only while that token is still current.
clearOAuthResumeSnapshot();
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Balanced
…them Copilot round 2, both findings valid: - `resumePendingReauth`'s restore could resurrect a challenge whose session ended during the awaits — a server switch or disconnect clears the slot on purpose. It is now gated on the attempt still targeting the live session, client identity included (reconnecting to the same server builds a replacement client an id-only check would admit), and uses a functional update so a newer deferral queued in the same batch wins rather than being read past. - `prepareOAuthRedirect` cleared whichever resume snapshot was current, not the one it wrote. Nothing single-flights that callback, so a redirect failing before navigation could delete a later attempt's callback-routing state. `writeOAuthResumeSnapshot` now returns the serialization it stored, and `clearOwnOAuthResumeSnapshot` removes it only while it is still the current one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY Signed-off-by: cliffhall <cliff@futurescale.com>
Round 2 — both findings addressed (including the suppressed one)1. if (
sessionRef.current.activeServerId === pending.serverId &&
sessionRef.current.inspectorClient === client
) {
setPendingReauth((prev) => prev ?? pending);
}2. Took the attempt-token remedy, with the token being the stored serialization rather than a new schema field: Five new tests: the ended-session restore, and four on the lib helper (only-its-own, no token, storage throw, no window).
|
There was a problem hiding this comment.
🟡 Changes recommended
Snapshot concurrency, stale retry state, misleading recovery copy, and incomplete connect teardown remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
clients/web/src/hooks/useOAuthRecovery.ts:1513
- When this EMA attempt rejects, the prompt has already been dismissed but its
pendingStepUpRetryRefis retained. A later ambient EMA step-up does not replace that ref, so if it succeeds it can unexpectedly rerun the old command. Clear the stored retry in this terminal failure path.
} catch (err) {
- Files reviewed: 9/9 changed files
- Comments generated: 3
- Review effort level: Balanced
Round 3 — four items, all addressed1. The snapshot token was not unique ( 2. The retry copy was shown when nothing was restored. Right, and my own ended-session test was what proved the toast lied. The message is now chosen by whether anything is left pending: 3. A failed connect-scoped redirect left the connection spinning. Confirmed at both ends: 4. The EMA failure path retained Gate rerunning; I will confirm green before requesting the next review. |
…rdown Copilot round 3, four items: - the snapshot "attempt token" was the stored serialization, which is not unique: the snapshot records neither the authorization URL nor its `state`, so two concurrent redirects for one server with unchanged UI serialize identically. `OAuthResumeSnapshot` now carries an optional `attemptId`, stamped per write and matched on clear. Optional because an older build's snapshot, mid-redirect across an upgrade, has none — and treating that as a match would strand a live callback. - the resume-failure toast promised a retry even when the session had ended and nothing was restored. Copy is now chosen by whether anything is left pending; `authRecoveryAbandonedMessage` covers the other case. - a connect-scoped redirect that failed left the connection at "connecting" with the active-server lock held, since the redirect was what would have ended the attempt. It now disconnects first, as the sibling arm in `useConnectionLifecycle` does — scoped to that context, because an ambient or step-up failure does not mean the session is dead. - both terminal EMA step-up failure arms now drop the stored retry. A later step-up does not overwrite the ref, so a retained operation could be re-run by an unrelated authorization. The `interactive` arm keeps it: that one is still on its way to a redirect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY Signed-off-by: cliffhall <cliff@futurescale.com>
|
Gate green on round 3: |
There was a problem hiding this comment.
🟡 Changes recommended
A failed EMA redirect can retain and later execute a stale command retry.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
clients/web/src/hooks/useOAuthRecovery.ts:1518
- The interactive arm is not guaranteed to navigate:
prepareOAuthRedirectreturns beforebeginInteractiveAuthorizationsettles, and its new catch handles a pre-navigation rejection. Keeping the command retry here leaves it inpendingStepUpRetryRefon that failure path; a later ambient EMA step-up does not replace the ref and can execute this stale command. A full-page redirect cannot preserve the closure anyway, so clear it before handing off.
// (#2165). The `interactive` arm above deliberately keeps it: that
// one is still on its way to a redirect.
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Balanced
Copilot round 4: - the EMA `interactive` arm kept `pendingStepUpRetryRef`, on a comment claiming it was "still on its way to a redirect". That is wrong twice: `prepareOAuthRedirect` returns before the navigation is resolved, so a pre-navigation failure leaves the operation for a later, unrelated step-up to re-run; and the full-page navigation destroys the closure on the success path anyway. It now clears the ref, matching the standard step-up below. - the two surviving `void (async () => …)()` sites now state why the caller cannot await, as AGENTS.md requires — the comments had described only what the catch handles. - the `randomUUID` fallback test restored an own descriptor that never existed (it is inherited from `Crypto.prototype`), leaving an own `randomUUID: undefined` that would push every later test in the file onto the fallback path. It deletes the temporary property instead, and asserts the global is a function again afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY Signed-off-by: cliffhall <cliff@futurescale.com>
Round 4 — three items, all addressed1. The EMA 2. The surviving 3. The
|
There was a problem hiding this comment.
🟡 Changes recommended
The shared EMA retry reference can be replaced during authorization awaits, causing newer retries to be executed, misrouted, or discarded by an older attempt.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
clients/web/src/lib/oauthResume.ts:190
- This docstring says the function returns the exact stored serialization, but the implementation returns only
attemptId; the stored serialization is the JSON string containing the entire snapshot. Describe the identifier return value directly so callers do not infer byte-comparison semantics.
* Persist the snapshot, returning the exact serialization stored — the
* "attempt token" {@link clearOwnOAuthResumeSnapshot} matches against (#2165).
clients/web/src/hooks/useOAuthRecovery.ts:1532
- This clears whichever retry is currently in the shared ref, not necessarily the retry owned by this authorization attempt. While
handleAuthChallengeis pending, the dismissed prompt allows a newer command to create another step-up and replace the ref; if this older attempt then returnsfailed, the newer prompt remains but its command retry is silently lost. Clear only the retry captured for this attempt.
pendingStepUpRetryRef.current = null;
clients/web/src/hooks/useOAuthRecovery.ts:1549
- The rejection arm has the same retry-ownership race: a newer step-up can replace
pendingStepUpRetryRef.currentwhile this attempt awaitshandleAuthChallenge, and this catch then deletes the newer command's retry. Associate the retry with the attempt before awaiting and avoid clearing a value installed by a later prompt.
pendingStepUpRetryRef.current = null;
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Balanced
Copilot round 5. `pendingStepUpRetryRef` is shared, and the EMA branch dismisses the prompt *before* its awaits — so a newer command can open its own step-up and install its own retry while the first authorization is in flight. Every arm that read or cleared the ref afterwards was acting on that newer operation: running it under the wrong authorization and reporting its failure to the wrong source, or deleting it outright. The attempt now takes its retry out of the ref before any await, and no arm touches the ref again. That covers all three sites at once and removed the interactive-arm clear added last round — the value is already out by then. The standard step-up path redirects and clears synchronously, so it has no such window. Also rewrites the `writeOAuthResumeSnapshot` docstring, which still described the byte-comparison token it no longer returns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY Signed-off-by: cliffhall <cliff@futurescale.com>
Round 5 — retry ownership, plus the stale docstring1. The shared Took the capture-before-awaiting option, which removes all three ref accesses rather than guarding each: setPendingStepUp(null);
const retry = pendingStepUpRetryRef.current;
pendingStepUpRetryRef.current = null;
// every arm below uses the local `retry` and touches the ref neverThe attempt now owns its retry for its whole lifetime — the satisfied arm runs its operation, the terminal arms let it die with the attempt, and a later prompt's retry is never read or cleared. That also removed the interactive-arm clear I added last round: the value is already out of the ref by then. The standard (non-EMA) path redirects and clears synchronously with no awaits in between, so it has no such window. New test drives the interleaving: hold the first EMA 2.
|
There was a problem hiding this comment.
🔵 Needs a closer look
A refused concurrent step-up can overwrite the open prompt’s retry before it is captured and executed.
Review details
Suppressed comments (1)
clients/web/src/hooks/useOAuthRecovery.ts:1475
- This capture still does not guarantee that the retry belongs to
stepUp.handleCommandScopedAuthRecoveryassignspendingStepUpRetryRef.currentat line 693 beforetrySetPendingStepUp; when another command requests step-up while this prompt is already open,trySetPendingStepUprefuses the new prompt but its retry has already replaced this prompt's retry. Authorizing the original prompt therefore captures and executes the rejected command's operation. Reserve the prompt and retry atomically (or assign the retry only aftertrySetPendingStepUpconfirms success) so the two cannot diverge.
const retry = pendingStepUpRetryRef.current;
pendingStepUpRetryRef.current = null;
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Copilot round 6. `handleCommandScopedAuthRecovery` wrote `pendingStepUpRetryRef` before calling `trySetPendingStepUp`, which REFUSES a second prompt while one is already open — so the refused command's operation replaced the open prompt's, and authorizing that prompt ran the command the user had just been told could not start. The retry now goes in only on a prompt that actually opened, which makes the pairing hold in both directions: the prompt and its retry are installed together, and the EMA branch takes them out together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY Signed-off-by: cliffhall <cliff@futurescale.com>
Round 6 — the other half of the retry pairingCopilot moved this to 🔵 Needs a closer look with no new inline comments and one suppressed finding, which is real and pre-existing rather than something last round introduced.
The retry now goes in only on a prompt that actually opened: if (trySetPendingStepUp({ /* … */ })) {
pendingStepUpRetryRef.current = options.retryOperation ?? null;
}Which makes the pairing hold in both directions — the prompt and its retry are installed together, and last round's capture takes them out together. A refused command leaves the open prompt's retry untouched; an accepted prompt with no retry still clears a stale one. New test: open a step-up with a retry, have a second refused, authorize — the first command runs and the refused one never does. Without the fix that test fails in both directions.
|
There was a problem hiding this comment.
🔵 Needs a closer look
OAuth navigation, session recovery, and concurrent retry ownership span multiple stateful layers and warrant final human validation.
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Fair ask — three stateful layers, zero inline comments, so the question is behavioral rather than about any particular line. I smoke tested all three against a live OAuth server in a clean worktree at the PR head (
Setup
1. OAuth navigation ✅Connect → redirect to the AS → consent → This is the one that could have regressed:
2. Session recovery ✅The resume snapshot is written pre-navigation carrying a real The step-up redirect writes its own snapshot with a distinct After the step-up callback, the Tools tab, the selected tool and the argument values are restored:
3. Retry ownership ✅ (with a caveat the PR already states)Real
Authorize → consent → return →
Worth being precise about what this does and does not cover. The standard step-up ends in a full-page redirect, so the retry closure cannot survive it — as the PR says in as many words — and the call is correctly not auto-run on return; the user re-runs it against the widened scope. The retry-ownership changes (
Finding: three of the five arms are not reachable from the browserThis qualifies the PR description, which frames each site as having a "user-visible consequence". That is true of the consequence, but I could not produce the failure under any user-facing condition:
Not an objection — these are terminations of promises ESLint proved were floating, and unreachable-by-fault-injection is exactly the case where unit tests are the only honest instrument. The seven new tests are the right call. I'm flagging it so nobody later reads "user-visible" as "reproducible by hand" and burns the afternoon I did. What is reachable is the recovery UI all five arms feed into — red border (#1621), banner, and the connect toggle correctly torn down rather than left spinning:
VerdictNavigation, session recovery and the reachable half of retry ownership all behave correctly at the PR head, with no console errors or unhandled rejections across any run. Nothing here changes my read of the diff. |





Closes #2165
Five promises in
useOAuthRecoverywere discarded withvoid. That satisfies@typescript-eslint/no-floating-promises(#1959), but not the narrower rule AGENTS.md states:voidis acceptable only when the callee already owns its failures or the caller genuinely cannot await, and with a one-line comment saying which. None of the five had that argument holding — each rejection became an unhandled rejection whose user-visible consequence was a wrong diagnosis or no response at all.Each site now picks a user-visible outcome rather than taking a blanket
.catch().The five sites
prepareOAuthRedirect→beginInteractiveAuthorizationauthChallengeInteractivehandlerresumePendingReauth/oauth/callbackeffectsetupClientForServerthrowing, and the post-resumecheckAuthChallengeSatisfied. Both land after the callback URL and the one-shot snapshot are spent, so it is reported here or nowhere.handleStepUpAuthorize, EMA branchoutcome.kind === "failed"arm is — toast plussetSourceScopedError(stepUp.source, …)— so the two cannot disagree.StepUpAuthModalcalls this asvoid onAuthorize(), so a rejection escaping reached nobody.A sixth, smaller decision rides the last one: the stored retry (
await retry()) gets its own catch rather than the outer one. The permissions leg succeeded by then, so a failure from there belongs to the retried command and is reported as that command's — routed to the panel that issued it — rather than dressed up as a step-up failure.The two
voids that surviveBoth callers of
resumePendingReauthstillvoidit, and each now carries the justification AGENTS.md asks for: a visibility listener and an effect body cannot be awaited, and the callee terminates its own failures.Copy
One new shared string,
authRecoveryRetryFailedMessageincore/auth/oauthUx.ts(the shared copy home), re-exported through the webutils/oauthUxbarrel. It is phrased as "will try again" rather than as a final failure, because the pending recovery is restored when it fires.Testing
Seven new tests — one per failure arm, plus the retry split and the copy helper:
authRecoveryRetryFailedMessageappends only a non-blank detailnpm run local:gategreen.No screenshots: every arm here is a failure path that renders UI the client already has — the existing re-auth banner (#1621 red border included) and Mantine toasts. Nothing new is drawn; what changed is whether they are drawn at all, which is what the tests assert.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY