fix: terminate the OAuth connection-details refresh promise chain - #2236
fix: terminate the OAuth connection-details refresh promise chain#2236cliffhall wants to merge 2 commits into
Conversation
The connection-details refresh effect in useOAuthRecovery voided inspectorClient.getOAuthState() with a lone .then and no rejection handler. In the browser that read goes through the remote OAuth store, so it is a network round trip that can reject — a backend that is down or restarting, a 401 on the API token, malformed stored state. void silences no-floating-promises without terminating anything, so every such failure became an unhandled rejection, on the initial refresh and on every oauthComplete refresh. Terminate the chain with a .catch that clears the panel details rather than leaving the last successful read on screen: the panel reports the current OAuth state, and a stale answer is indistinguishable from a fresh one. The cancelled guard is repeated on the catch so a rejection arriving after unmount does not write. The void stays, now with the one-line justification AGENTS.md asks for — a synchronous useEffect body cannot await. Two tests cover it. Both fail against the unfixed source with unhandled rejections, which is the failure mode itself: an unhandled rejection fails the whole vitest run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBkPzbuxgyqiz39ytmQzMB Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
🟡 Changes recommended
Out-of-order refreshes can clear newer state, and the added tests do not fully verify the intended state transitions.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Fixes unhandled OAuth state-refresh rejections and clears unavailable connection details.
Changes:
- Terminates the refresh promise chain with rejection handling.
- Adds rejection and effect-cleanup tests.
File summaries
| File | Description |
|---|---|
useOAuthRecovery.ts |
Handles failed OAuth-state reads. |
useOAuthRecovery.test.tsx |
Adds rejection-path tests. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 3
- Review effort level: Balanced
| // The read failed (backend down, 401 on the API token, malformed | ||
| // stored state). Clear rather than keep the last successful read — | ||
| // a stale answer is indistinguishable from a fresh one in the panel. | ||
| if (cancelled) return; | ||
| setConnectionInfoOAuthWhenConnected(undefined); |
There was a problem hiding this comment.
Fixed — good catch, and it is a real bug the .catch newly exposed.
Reads are concurrent (an oauthComplete can start a second one while the first is in flight) and nothing ordered them. Added a monotonically increasing sequence, checked in both handlers:
let latest = 0;
const refresh = (): void => {
const seq = ++latest;
void inspectorClient
.getOAuthState()
.then((state) => {
if (cancelled || seq !== latest) return;
…
})
.catch(() => {
if (cancelled || seq !== latest) return;
setConnectionInfoOAuthWhenConnected(undefined);
});
};New test ignores an earlier read that rejects after a newer one succeeded covers exactly the sequence you described: read #1 hangs, oauthComplete starts read #2 which resolves, then read #1 rejects last. Without the guard that test fails.
| const client = fakeClient({ | ||
| getOAuthState: vi.fn().mockRejectedValue(new Error("backend down")), | ||
| }); | ||
| const props: HarnessProps = { | ||
| servers: [entry("a")], | ||
| activeServerId: "a", | ||
| client, | ||
| }; | ||
| const h = harness(props); | ||
| await waitFor(() => expect(client.getOAuthState).toHaveBeenCalled()); | ||
| expect(h.api().connectionInfoOAuth).toBeUndefined(); | ||
|
|
||
| // The rejection is terminated, not floated: a later resolving read still | ||
| // populates the panel, which an unhandled rejection would have prevented | ||
| // by failing the run. | ||
| client.getOAuthState = vi | ||
| .fn() | ||
| .mockResolvedValue({ tokens: { access_token: "t" } }); | ||
| await act(async () => { | ||
| client.emit("oauthComplete", {}); | ||
| }); | ||
| await waitFor(() => expect(h.api().connectionInfoOAuth).toBeDefined()); |
There was a problem hiding this comment.
Fixed — you are right, that test could not tell a correct handler from one that just swallowed the error.
clears already-loaded details when a refresh read rejects now seeds a successful read, waits for connectionInfoOAuth to be defined, then makes the oauthComplete refresh reject and asserts the details go back to undefined. Verified by mutation: replacing the catch body with a bare return fails this test and only this test.
| const h = harness(props); | ||
| h.rerender({ ...props, connectionStatus: "disconnected" }); | ||
| await act(async () => { | ||
| fail(new Error("backend down")); | ||
| }); | ||
| expect(h.api().connectionInfoOAuth).toBeUndefined(); |
There was a problem hiding this comment.
Fixed, along the lines you suggested. drops a rejected read that lands after the client was replaced now:
- starts with a client whose read hangs,
- rerenders with a replacement client that resolves and populates the details,
- rejects the stale read, and asserts the current details are still defined.
Verified by mutation: removing cancelled from the catch (leaving only the sequence check) fails this test and only this test — so it now detects the guard it exists for.
Copilot review round 1. Refreshes are concurrent — an `oauthComplete` can start a second read while the first is still in flight — and nothing makes them settle in order. The new catch made that visible: a slow earlier read could reject *after* a newer one succeeded and clear the fresh result. Guard both handlers with a monotonically increasing sequence so only the newest read writes. The two tests were also too weak to detect their own guards, as the review pointed out. Both started from an undefined panel, so neither could tell a correct handler from one that merely swallowed the error: - The rejection test now seeds a successful read first, then rejects an `oauthComplete` refresh and asserts the already-loaded details clear. - The post-cleanup test now reconnects with a replacement client that populates details, then rejects the stale read, and asserts the current details survive. - A third test covers the ordering guard directly. Each of the three guards has exactly one test that fails without it, verified by mutation: dropping the sequence check, dropping the clear, and dropping the `cancelled` check each break one test and no others. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBkPzbuxgyqiz39ytmQzMB Signed-off-by: cliffhall <cliff@futurescale.com>
Review round 1 — all three comments addressedInline replies will go outdated once this push lands, so mirroring here.
Comment 1 was a genuine bug the new Each guard is now covered by exactly one testRather than assert the tests are stronger, I mutation-tested them — each guard removed in turn, full file re-run:
One test per guard, no overlap and no redundancy. Verification
|
There was a problem hiding this comment.
🟢 Approval recommended
The promise chain is safely terminated, stale async writes are guarded, and the relevant failure paths have focused regression coverage.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
CI is green. The one red run was |
Closes #2221
The bug
clients/web/src/hooks/useOAuthRecovery.ts, the connection-details refresh effect, voidedinspectorClient.getOAuthState()with a lone.thenand no rejection handler:In the browser that read goes through the remote OAuth store, so it is a network round trip that can reject — a backend that is down or restarting, a 401 on the API token, malformed stored state.
voidsilences@typescript-eslint/no-floating-promiseswithout terminating anything, so every such failure became an unhandled rejection: on the initial refresh, and on everyoauthCompleterefresh — the latter firing exactly when OAuth has just been exercised and a transient backend failure is most plausible.Per
AGENTS.md,voidis only acceptable when the callee owns its failures, with a one-line comment saying so.getOAuthState()has nocatchand surfaces no message, and there was no comment. This is the same class as #2165, whose five void-discarded OAuth recovery promises were fixed in #2190 — this call sits in the same file and was missed.The fix
Terminate the chain with a
.catchthat clears the panel details rather than leaving the last successful read on screen. The panel’s job is to report the current OAuth state, so holding a stale answer makes it indistinguishable from a fresh one. Thecancelledguard is repeated on the catch so a rejection arriving after unmount does not write.The
voidstays, now with the justificationAGENTS.mdasks for — a synchronoususeEffectbody is one of the named cases where the caller genuinely cannot await.Tests
Two cases in
useOAuthRecovery.test.tsx:clears the details when the state read rejects— the read rejects, the details stay undefined, and a later resolving read still populates the panel.drops a rejected state read that lands after the session ended— the rejection settles after the effect cleanup ran; nothing is written.Both fail against the unfixed source, and the failure is the bug itself:
An unhandled rejection fails the whole vitest run, which is the #1947 experience the rule exists to prevent.
Verification
npm run formatclean,npm run local:gatepasses end to end (exit 0) — 7,389 web tests, all four coverage gates, smokes and Storybook.No screenshots: this is a failure-path fix behind a backend error with no reproducible visual change.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GBkPzbuxgyqiz39ytmQzMB