fix(auth): bound the whole teardown, and scope the TUI clear to its server (#2144)

Copilot review round 7.

- The five-second timeout was per request while grants are revoked
  sequentially, so a server with N issuer slots could block the disconnect for
  N x 5s — the "short timeout" bounded a request rather than anything the user
  feels. It is now one budget for the whole call: each grant gets the remaining
  time, and a grant reached after it is exhausted is reported as never
  attempted rather than starting a fresh five seconds. `--relogin` shares one
  budget across its two key spellings for the same reason.
- `AuthTab` called the same settle handler on rejection, so a failed local
  clear or disconnect announced "OAuth state cleared". A rejection is not a
  revocation failure — those come back as outcomes — so it now has its own
  path and reports the real error. The completion is also scoped: a clear
  carries the server it started on and an attempt number, and a switch retires
  it, so server A's confirmation cannot appear under B.
- `clearOAuthAttemptRef` is now retired by the server-switch effect alongside
  `disconnectAttemptRef`; without that an A -> B -> A round trip left both the
  token and the captured name matching again. `handleClearOAuth` also
  revalidates after `await disconnectInspector()`, since a switch during that
  second await would have put the revision bump on the new selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-28 03:55:37 -04:00
commit 43c817c8e9

View file

@ -464,6 +464,11 @@ function App({
// server name matching again, so the stale rejection would land on the
// re-selected A. Retiring the token on every switch is what closes that.
disconnectAttemptRef.current++;
// Same reasoning for the clear (#2144): the A → B → A round trip would
// otherwise leave both its attempt token and the captured server name
// matching again, so a clear started on the first A could publish into the
// re-selected one.
clearOAuthAttemptRef.current++;
setDisconnectError(null);
const stepUp = pendingStepUpRef.current;
if (stepUp && selectedServer && stepUp.serverName !== selectedServer) {
@ -983,6 +988,14 @@ function App({
setConnectError(null);
if (inspectorStatus === "connected" || inspectorStatus === "connecting") {
await disconnectInspector();
// Revalidate: the disconnect is a second await, and a switch during it
// would make the revision bump below land on the new selection.
if (
clearOAuthAttemptRef.current !== attempt ||
selectedServerRef.current !== attemptServer
) {
return;
}
}
setOauthRevision((n) => n + 1);
}, [