fix: close three stale-completion holes from the round-16 suppressed block (#2144)

Copilot's round 16 reported "no new comments", but its suppressed block carried
three real findings.

- `AuthTab` released `clearInFlightRef` before checking whether the completion
  was still current, so server A settling after the user moved to B and started
  a clear there dropped B's lock and let a second B clear run concurrently
  against the same store entry. `current()` is now checked before either shared
  ref is touched: a stale clear owns nothing.
- Switching servers retired the clear but left `ownClearRef` set. The retired
  clear returns before its `oauthRevision` bump, so the marker had no bump to
  skip and would swallow the first unrelated revision on the newly selected
  server, stranding that server's banner.
- The web session check compared only the server id, which a
  disconnect/reconnect to the SAME server passes — that flow builds a
  replacement `InspectorClient`, so the old clear would have run its
  session-wide cleanup against the new session. The client identity is part of
  the check now.

Both new tests were verified to fail with their fix reverted.

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 06:47:31 -04:00
commit b9012da988
2 changed files with 102 additions and 3 deletions

View file

@ -1409,6 +1409,50 @@ describe("useOAuthRecovery", () => {
expect(client.disconnect).not.toHaveBeenCalled();
});
// A disconnect/reconnect to the SAME server builds a replacement client, so
// an id-only check passes again and the old clear would run its
// session-wide cleanup — including the disconnect — against the new session.
it("does not disconnect a replacement client for the same server", async () => {
let settle: (r: { cleared: boolean }) => void = () => {};
clearServerOAuthStateMock.mockImplementation(
() =>
new Promise((resolve) => {
settle = resolve as typeof settle;
}),
);
const original = fakeClient();
const h = harness({
servers: [entry("a")],
activeServerId: "a",
client: original,
});
let done: Promise<void>;
await act(async () => {
done = h.api().clearServerOAuthAndDisconnect(entry("a"));
await Promise.resolve();
});
// Same server, new client — a reconnect while the clear is pending.
const replacement = fakeClient();
h.rerender({
servers: [entry("a")],
activeServerId: "a",
client: replacement,
});
await act(async () => {
settle({ cleared: true });
await done;
});
// Neither client is torn down: the replacement is the live session and
// must not be touched, and the original is already gone — disconnecting
// it would only drag `finalizeExplicitDisconnect` across the new one.
expect(original.disconnect).not.toHaveBeenCalled();
expect(replacement.disconnect).not.toHaveBeenCalled();
});
it("clears the resume snapshot on an explicit disconnect", () => {
writeOAuthResumeSnapshot({
version: 1,

View file

@ -1265,8 +1265,14 @@ export function useOAuthRecovery({
// The session ref is the live answer. Only the parts that touch the
// *session* are gated on it; the store write already happened and the
// toast still belongs to the server the user asked about.
// The client identity is part of the check, not just the server id: a
// disconnect/reconnect to the SAME server builds a replacement
// `InspectorClient`, so an id-only check passes again and the old clear
// would run its session-wide cleanup against the new session.
const stillTargetsActiveSession = (): boolean =>
isActive && sessionRef.current.activeServerId === server.id;
isActive &&
sessionRef.current.activeServerId === server.id &&
sessionRef.current.inspectorClient === client;
const { cleared, revocation } = await clearServerOAuthState({
config: server.config,