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:
parent
56ac83051b
commit
43c817c8e9
2 changed files with 198 additions and 13 deletions
|
|
@ -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);
|
||||
}, [
|
||||
|
|
|
|||
|
|
@ -90,9 +90,16 @@ export function AuthTab({
|
|||
const [oauthState, setOauthState] = useState<
|
||||
OAuthConnectionState | undefined
|
||||
>(undefined);
|
||||
const [clearState, setClearState] = useState<"idle" | "clearing" | "cleared">(
|
||||
"idle",
|
||||
);
|
||||
const [clearState, setClearState] = useState<
|
||||
"idle" | "clearing" | "cleared" | "failed"
|
||||
>("idle");
|
||||
const [clearFailure, setClearFailure] = useState<string | null>(null);
|
||||
/**
|
||||
* The server a clear was started for, and a sequence number. The clear is a
|
||||
* bounded network request now, so it can settle after the user has moved on
|
||||
* — at which point server A's confirmation must not appear under server B.
|
||||
*/
|
||||
const clearAttemptRef = useRef(0);
|
||||
/**
|
||||
* Set synchronously when a clear starts, and consumed by the reset effect
|
||||
* below. The clear itself bumps `oauthRevision` on its way out, so without
|
||||
|
|
@ -100,6 +107,18 @@ export function AuthTab({
|
|||
* which of the two lands first is not something the ordering guarantees.
|
||||
*/
|
||||
const ownClearRef = useRef(false);
|
||||
const serverNameRef = useRef(serverName);
|
||||
useEffect(() => {
|
||||
serverNameRef.current = serverName;
|
||||
}, [serverName]);
|
||||
// A new selection retires any in-flight clear and drops the previous one's
|
||||
// banner: neither belongs to the server now on screen.
|
||||
useEffect(() => {
|
||||
clearAttemptRef.current++;
|
||||
setClearState("idle");
|
||||
setClearFailure(null);
|
||||
setLastClearDisconnected(false);
|
||||
}, [serverName]);
|
||||
const [lastClearDisconnected, setLastClearDisconnected] = useState(false);
|
||||
const [stepUpChoiceIndex, setStepUpChoiceIndex] = useState(0);
|
||||
|
||||
|
|
@ -123,6 +142,7 @@ export function AuthTab({
|
|||
return;
|
||||
}
|
||||
setClearState("idle");
|
||||
setClearFailure(null);
|
||||
setLastClearDisconnected(false);
|
||||
}, [oauthRevision]);
|
||||
|
||||
|
|
@ -189,13 +209,31 @@ export function AuthTab({
|
|||
// first over the same store entry, and the user cannot see that the
|
||||
// first is still running except by this state.
|
||||
if (clearState === "clearing") return;
|
||||
const attempt = ++clearAttemptRef.current;
|
||||
const attemptServer = serverName;
|
||||
setLastClearDisconnected(isLiveConnection);
|
||||
setClearFailure(null);
|
||||
ownClearRef.current = true;
|
||||
setClearState("clearing");
|
||||
const settle = () => setClearState("cleared");
|
||||
// Settled either way: a failed revocation still cleared local state,
|
||||
// and `handleClearOAuth` reports the failure through the message line.
|
||||
void Promise.resolve(onClearOAuth()).then(settle, settle);
|
||||
// A completion is only ours if nothing has superseded it and the
|
||||
// selection has not moved on.
|
||||
const current = () =>
|
||||
clearAttemptRef.current === attempt &&
|
||||
serverNameRef.current === attemptServer;
|
||||
void Promise.resolve(onClearOAuth()).then(
|
||||
() => {
|
||||
if (current()) setClearState("cleared");
|
||||
},
|
||||
(err: unknown) => {
|
||||
// A rejection is NOT a revocation failure — those come back as
|
||||
// outcomes and are reported through the message line. This is the
|
||||
// local clear or the disconnect itself failing, so announcing
|
||||
// "OAuth state cleared" here would be a plain lie.
|
||||
if (!current()) return;
|
||||
setClearFailure(err instanceof Error ? err.message : String(err));
|
||||
setClearState("failed");
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
|
|
@ -371,6 +409,12 @@ export function AuthTab({
|
|||
: "OAuth state cleared."}
|
||||
</Text>
|
||||
)}
|
||||
{clearState === "failed" && (
|
||||
<Text color="red">
|
||||
Could not clear OAuth state
|
||||
{clearFailure ? `: ${clearFailure}` : "."}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</ScrollView>
|
||||
|
|
|
|||
Loading…
Reference in a new issue