From 4b7fb3d81e9d5d3fe6b9c610943a7f7a49f1606b Mon Sep 17 00:00:00 2001 From: npub1yxv5wk0u0fh6dwt925wntn7h397jvteyj4r87ttcd9xae7n2t3lqqj9jmm <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 09:34:55 -0600 Subject: [PATCH 1/3] fix(desktop): close reconnect gaps that previously required CMD+R MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users report the desktop app can wedge in states where only a manual CMD+R (or full restart) restores connectivity. Pinky's light-switch matrix (real relay, SIGTERM/SIGKILL x 1s/45s/3min) proved the backoff state machine itself recovers cleanly, isolating the failures to four special states a reload resets but the auto flow never did: - G1 resume triggers: recovery rode solely on the backoff timer (max 30s), which WKWebView throttles in occluded/background windows — after sleep/VPN reauth the scheduled attempt may not fire until focus. New useRelayResumeTriggers fires preconnect() on network online, window focus, and visibility-visible when the session is reconnecting/stalled, rate-limited to one attempt per 5s. Deliberately inert for the terminal 'disconnected' state. - G2 terminal AUTH latch: any AUTH OK=false latched the session terminal forever, but the relay also rejects for transient causes (duplicate-AUTH 'already authenticated' race, +/-60s clock skew, fail-closed allowlist DB errors). New AuthOkTracker treats 'already authenticated' as success, retries transient rejections with normal backoff, and only latches on 'restricted:' or after 3 consecutive rejections. - G3 terminal CLOSED: an 'auth-required:' CLOSED (REQ racing the AUTH handshake after reconnect) permanently deleted the live subscription with no UI signal — frozen channel while the state reads connected. Reclassified as retryable; genuinely terminal classes still delete. - G4 sub-2s flap heal: useRelayAutoHeal observed the 2s-debounced connection hook, so a brief flap never registered a degraded-> connected transition even though resetConnection had already rejected every in-flight query. It now observes the raw connection-state emitter; the existing 15s heal rate-limit still guards against flap storms. Co-authored-by: Wes Signed-off-by: Wes --- .../src/app/useAppShellLifecycleEffects.ts | 5 ++ .../src/shared/api/relayAuthPolicy.test.mjs | 81 +++++++++++++++++++ desktop/src/shared/api/relayAuthPolicy.ts | 56 +++++++++++++ desktop/src/shared/api/relayClientSession.ts | 18 ++--- .../src/shared/api/relayClosedPolicy.test.mjs | 17 +++- desktop/src/shared/api/relayClosedPolicy.ts | 6 +- .../api/relayResumeTriggerPolicy.test.mjs | 70 ++++++++++++++++ .../shared/api/relayResumeTriggerPolicy.ts | 39 +++++++++ desktop/src/shared/api/useRelayAutoHeal.ts | 30 ++++--- .../src/shared/api/useRelayResumeTriggers.ts | 54 +++++++++++++ 10 files changed, 352 insertions(+), 24 deletions(-) create mode 100644 desktop/src/shared/api/relayAuthPolicy.test.mjs create mode 100644 desktop/src/shared/api/relayAuthPolicy.ts create mode 100644 desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs create mode 100644 desktop/src/shared/api/relayResumeTriggerPolicy.ts create mode 100644 desktop/src/shared/api/useRelayResumeTriggers.ts diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index 02c97bac57..2d56d24cb3 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; import { relayClient } from "@/shared/api/relayClient"; +import { useRelayResumeTriggers } from "@/shared/api/useRelayResumeTriggers"; type AppShellLifecycleEffectsOptions = { desktopBadgeEnabled: boolean; @@ -16,6 +17,10 @@ export function useAppShellLifecycleEffects({ unreadChannelIds, unreadChannelNotificationCount, }: AppShellLifecycleEffectsOptions) { + // Event-driven reconnect: network online / focus / visibility short-circuit + // the backoff timer when the relay session is degraded (CMD+R gap G1). + useRelayResumeTriggers(); + // Prevent webview file:/// navigation on file drop outside the composer. // Scoped to file drags only (text drag-and-drop into inputs still works). // Composer's onDrop fires first (React synthetic before window bubble). diff --git a/desktop/src/shared/api/relayAuthPolicy.test.mjs b/desktop/src/shared/api/relayAuthPolicy.test.mjs new file mode 100644 index 0000000000..b547161552 --- /dev/null +++ b/desktop/src/shared/api/relayAuthPolicy.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + AuthOkTracker, + MAX_CONSECUTIVE_AUTH_REJECTIONS, +} from "./relayAuthPolicy.ts"; + +test("success resolves authenticated and resets the streak", () => { + const tracker = new AuthOkTracker(); + tracker.record(false, "auth-required: verification failed"); + tracker.record(false, "auth-required: verification failed"); + assert.equal(tracker.record(true, ""), "authenticated"); + // Streak reset: the next rejection starts a fresh count. + assert.equal( + tracker.record(false, "auth-required: verification failed"), + "retry", + ); +}); + +test("already-authenticated rejection is treated as authenticated (duplicate-AUTH race)", () => { + const tracker = new AuthOkTracker(); + assert.equal( + tracker.record(false, "auth-required: already authenticated"), + "authenticated", + ); +}); + +test("restricted rejections latch terminal immediately", () => { + for (const message of [ + "restricted: not a relay member", + "restricted: you are banned", + ]) { + assert.equal(new AuthOkTracker().record(false, message), "terminal"); + } +}); + +test("verification failures retry with backoff (clock skew, DB fail-closed)", () => { + const tracker = new AuthOkTracker(); + assert.equal( + tracker.record(false, "auth-required: verification failed"), + "retry", + ); +}); + +test("unknown rejection reasons retry rather than latch", () => { + assert.equal(new AuthOkTracker().record(false, ""), "retry"); + assert.equal(new AuthOkTracker().record(false, "error: internal"), "retry"); +}); + +test("consecutive rejections latch terminal at the cap", () => { + const tracker = new AuthOkTracker(); + let decision = "retry"; + for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) { + decision = tracker.record(false, "auth-required: verification failed"); + } + assert.equal(decision, "terminal"); +}); + +test("reset grants a fresh retry streak after explicit re-engagement", () => { + const tracker = new AuthOkTracker(); + for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS; i++) { + tracker.record(false, "auth-required: verification failed"); + } + tracker.reset(); + assert.equal( + tracker.record(false, "auth-required: verification failed"), + "retry", + ); +}); + +test("already-authenticated wins even past the cap (session is usable)", () => { + const tracker = new AuthOkTracker(); + for (let i = 0; i < MAX_CONSECUTIVE_AUTH_REJECTIONS + 1; i++) { + tracker.record(false, "auth-required: verification failed"); + } + assert.equal( + tracker.record(false, "auth-required: already authenticated"), + "authenticated", + ); +}); diff --git a/desktop/src/shared/api/relayAuthPolicy.ts b/desktop/src/shared/api/relayAuthPolicy.ts new file mode 100644 index 0000000000..78d50a6ad6 --- /dev/null +++ b/desktop/src/shared/api/relayAuthPolicy.ts @@ -0,0 +1,56 @@ +/** + * Policy for NIP-42 AUTH `OK` responses (G2 of the CMD+R gap audit). + * + * Historically ANY auth `OK false` latched the session terminal — no + * reconnect until explicit user re-engagement. But the relay sends + * `OK false` for conditions that are transient from the client's side: + * + * - `auth-required: already authenticated` — a duplicate/late AUTH event on + * a connection that is in fact authenticated. The session is usable; + * treat it as authenticated. + * - `auth-required: verification failed` — covers ±60s clock-skew rejects + * and the relay's fail-closed allowlist DB lookup errors, both of which + * can clear on retry. + * + * Only `restricted:` rejections (banned / not a relay member) are known + * permanent. Everything else retries with normal backoff, but latches + * terminal after `MAX_CONSECUTIVE_AUTH_REJECTIONS` consecutive rejections so + * a genuinely broken identity (e.g. persistently wrong system clock) still + * surfaces the terminal error card instead of flapping forever. + */ +export type AuthOkDecision = "authenticated" | "retry" | "terminal"; + +export const MAX_CONSECUTIVE_AUTH_REJECTIONS = 3; + +/** Tracks consecutive AUTH rejections across reconnect attempts. */ +export class AuthOkTracker { + private consecutiveRejections = 0; + + /** + * Record an AUTH `OK` and decide the session's next move. + * A success — real or "already authenticated" — resets the streak. + */ + record(success: boolean, message: string): AuthOkDecision { + const normalized = message.trim().toLowerCase(); + if ( + success || + normalized.startsWith("auth-required: already authenticated") + ) { + this.consecutiveRejections = 0; + return "authenticated"; + } + + this.consecutiveRejections++; + + if (normalized.startsWith("restricted:")) return "terminal"; + if (this.consecutiveRejections >= MAX_CONSECUTIVE_AUTH_REJECTIONS) { + return "terminal"; + } + return "retry"; + } + + /** Called on explicit re-engagement (disconnect / manual preconnect). */ + reset(): void { + this.consecutiveRejections = 0; + } +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 53d541ff0f..b8a989d785 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -65,6 +65,7 @@ import { STALL_IDLE_TIMEOUT_MS, } from "@/shared/api/relayClientTimings"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; +import { AuthOkTracker } from "@/shared/api/relayAuthPolicy"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; export class RelayClient { @@ -92,6 +93,7 @@ export class RelayClient { private connectionGeneration = 0; private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; + private authOkTracker = new AuthOkTracker(); private terminal = false; @@ -128,6 +130,7 @@ export class RelayClient { this.notifyReconnectListeners = false; this.terminal = false; this.visibleChannelId = null; + this.authOkTracker.reset(); this.connectionStateEmitter.set("idle"); if (this.wsId !== null) { @@ -430,6 +433,7 @@ export class RelayClient { // reconnect also bypasses the current delay once; ordinary operations do // not, so background traffic cannot continuously defeat backoff. this.terminal = false; + this.authOkTracker.reset(); this.keepAliveRequested = true; if (this.reconnectTimeout !== null) { window.clearTimeout(this.reconnectTimeout); @@ -892,12 +896,14 @@ export class RelayClient { const authRequest = this.authRequest; this.authRequest = null; - if (success) { + // Decision table lives in relayAuthPolicy.ts. + const decision = this.authOkTracker.record(success, message); + if (decision === "authenticated") { authRequest.resolve(); } else { const error = new Error(message || "Relay authentication rejected."); authRequest.reject(error); - this.resetConnection(error, { reconnect: false }); + this.resetConnection(error, { reconnect: decision === "retry" }); } return; @@ -919,13 +925,7 @@ export class RelayClient { } private hasLiveSubscriptions() { - for (const subscription of this.subscriptions.values()) { - if (subscription.mode === "live") { - return true; - } - } - - return false; + return [...this.subscriptions.values()].some((s) => s.mode === "live"); } private async replayLiveSubscriptions() { diff --git a/desktop/src/shared/api/relayClosedPolicy.test.mjs b/desktop/src/shared/api/relayClosedPolicy.test.mjs index 715df77f6a..2e5cad4b7c 100644 --- a/desktop/src/shared/api/relayClosedPolicy.test.mjs +++ b/desktop/src/shared/api/relayClosedPolicy.test.mjs @@ -19,7 +19,6 @@ test("classifyRelayClosed: terminal messages return terminal", () => { for (const message of [ "restricted: not a channel member", "restricted: channel access revoked", - "auth-required: not authenticated", "blocked: banned", "invalid: malformed filter", "pow: difficulty too low", @@ -38,6 +37,21 @@ test("classifyRelayClosed: transient errors return retryable", () => { } }); +test("classifyRelayClosed: auth-required is retryable (REQ/AUTH reconnect race)", () => { + // A REQ that lands before the AUTH handshake completes gets CLOSED with + // auth-required. Deleting the subscription would silently freeze the + // channel while the connection state still reads "connected" — the sub + // must survive and be retried after backoff. + assert.equal( + classifyRelayClosed("auth-required: not authenticated"), + "retryable", + ); + assert.equal( + classifyRelayClosed("auth-required: authenticate before subscribing"), + "retryable", + ); +}); + // ── Subscription-survival semantics ────────────────────────────────────────── // These replace the removed isRetryableRelayClosed wrapper tests. // rate-limited must not delete the subscription; terminal must. @@ -59,7 +73,6 @@ test("classifyRelayClosed: retryable class survives (subscription must not be de test("classifyRelayClosed: terminal class triggers deletion (no retry)", () => { for (const message of [ "restricted: not a channel member", - "auth-required: not authenticated", "blocked: banned", "invalid: malformed filter", "pow: difficulty too low", diff --git a/desktop/src/shared/api/relayClosedPolicy.ts b/desktop/src/shared/api/relayClosedPolicy.ts index 8a39e8a4d5..9e747a2df1 100644 --- a/desktop/src/shared/api/relayClosedPolicy.ts +++ b/desktop/src/shared/api/relayClosedPolicy.ts @@ -19,9 +19,13 @@ export function classifyRelayClosed(message: string): RelayClosedClass { if (normalized.startsWith("rate-limited:")) { return "rate-limited"; } + // `auth-required:` is deliberately retryable, NOT terminal: it occurs + // transiently when a REQ races the AUTH handshake after a reconnect. The + // backoff retry re-sends the REQ once the session is authenticated. A + // session that is genuinely unauthenticated latches `terminal` at the + // connection level (AUTH OK=false), so this cannot loop forever. if ( normalized.startsWith("restricted:") || - normalized.startsWith("auth-required:") || normalized.startsWith("blocked:") || normalized.startsWith("invalid:") || normalized.startsWith("pow:") || diff --git a/desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs b/desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs new file mode 100644 index 0000000000..0430beb0bf --- /dev/null +++ b/desktop/src/shared/api/relayResumeTriggerPolicy.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RESUME_TRIGGER_MIN_INTERVAL_MS, + shouldTriggerResumeReconnect, +} from "./relayResumeTriggerPolicy.ts"; + +const base = Object.freeze({ + connectionState: "reconnecting", + lastAttemptAt: 0, + now: RESUME_TRIGGER_MIN_INTERVAL_MS + 1, +}); + +test("reconnecting + interval elapsed triggers", () => { + assert.equal(shouldTriggerResumeReconnect({ ...base }), true); +}); + +test("stalled + interval elapsed triggers", () => { + assert.equal( + shouldTriggerResumeReconnect({ ...base, connectionState: "stalled" }), + true, + ); +}); + +test("healthy and terminal states never trigger", () => { + for (const connectionState of [ + "idle", + "connecting", + "connected", + "disconnected", + ]) { + assert.equal( + shouldTriggerResumeReconnect({ ...base, connectionState }), + false, + connectionState, + ); + } +}); + +test("burst of triggers within the rate window fires once", () => { + assert.equal( + shouldTriggerResumeReconnect({ + ...base, + lastAttemptAt: 1_000, + now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS - 1, + }), + false, + ); + assert.equal( + shouldTriggerResumeReconnect({ + ...base, + lastAttemptAt: 1_000, + now: 1_000 + RESUME_TRIGGER_MIN_INTERVAL_MS, + }), + true, + ); +}); + +test("custom interval override is honoured", () => { + assert.equal( + shouldTriggerResumeReconnect({ + ...base, + lastAttemptAt: 0, + now: 10, + minIntervalMs: 5, + }), + true, + ); +}); diff --git a/desktop/src/shared/api/relayResumeTriggerPolicy.ts b/desktop/src/shared/api/relayResumeTriggerPolicy.ts new file mode 100644 index 0000000000..bb2ec80489 --- /dev/null +++ b/desktop/src/shared/api/relayResumeTriggerPolicy.ts @@ -0,0 +1,39 @@ +/** + * Policy for event-driven reconnect triggers (G1 of the CMD+R gap audit). + * + * The exponential-backoff timer is the only thing driving recovery after an + * outage — and WKWebView throttles JS timers in occluded/background windows, + * so at max backoff (30s) a scheduled attempt may not fire until the user + * focuses the window. These triggers short-circuit the wait the moment the + * environment signals recovery: network `online`, window focus, and + * visibility becoming visible. `preconnect()` already clears the pending + * backoff timer, so a trigger converts "wait up to 30s (or forever, if + * throttled)" into "reconnect now". + */ +import type { ConnectionState } from "@/shared/api/relayClientShared"; + +/** Min ms between trigger-driven preconnect attempts. */ +export const RESUME_TRIGGER_MIN_INTERVAL_MS = 5_000; + +export function shouldTriggerResumeReconnect(inputs: { + connectionState: ConnectionState; + lastAttemptAt: number; + now: number; + minIntervalMs?: number; +}): boolean { + const minInterval = inputs.minIntervalMs ?? RESUME_TRIGGER_MIN_INTERVAL_MS; + + // Only degraded-but-recoverable states. `disconnected` is the terminal + // latch — explicit user re-engagement owns that path, and `idle` / + // `connecting` / `connected` need no help. + if ( + inputs.connectionState !== "reconnecting" && + inputs.connectionState !== "stalled" + ) { + return false; + } + + // Rate-limit: focus/online events arrive in bursts (e.g. wake fires all + // three); one attempt per window is enough. + return inputs.now - inputs.lastAttemptAt >= minInterval; +} diff --git a/desktop/src/shared/api/useRelayAutoHeal.ts b/desktop/src/shared/api/useRelayAutoHeal.ts index 11909228b8..429dcc502b 100644 --- a/desktop/src/shared/api/useRelayAutoHeal.ts +++ b/desktop/src/shared/api/useRelayAutoHeal.ts @@ -2,12 +2,12 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; -import type { ConnectionState } from "@/shared/api/relayClientShared"; -import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation"; +import { relayClient } from "@/shared/api/relayClient"; import { isRelayConnectionDegraded, - useRelayConnection, -} from "@/shared/api/useRelayConnection"; + type ConnectionState, +} from "@/shared/api/relayClientShared"; +import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation"; import { isRateLimited, waitForRateLimit, @@ -100,8 +100,6 @@ export class RelayAutoHealScheduler { */ export function useRelayAutoHeal(): void { const queryClient = useQueryClient(); - const connectionState = useRelayConnection(); - const prevConnectionStateRef = React.useRef(connectionState); const schedulerRef = React.useRef(null); if (schedulerRef.current === null) { @@ -129,14 +127,22 @@ export function useRelayAutoHeal(): void { } React.useEffect(() => { + // Observe the RAW connection-state emitter, not the 2s-debounced + // useRelayConnection() hook. A sub-2s flap never surfaces through the + // debounced hook (its degraded report is cancelled by the recovery), yet + // resetConnection() already rejected every in-flight query — the heal + // must still fire or errored panes persist until a manual reconnect. + let prev: ConnectionState | null = null; + const unsubscribe = relayClient.subscribeToConnectionState((next) => { + if (prev !== null) { + schedulerRef.current?.onTransition(prev, next); + } + prev = next; + }); + return () => { + unsubscribe(); schedulerRef.current?.dispose(); }; }, []); - - React.useEffect(() => { - const prev = prevConnectionStateRef.current; - prevConnectionStateRef.current = connectionState; - schedulerRef.current?.onTransition(prev, connectionState); - }, [connectionState]); } diff --git a/desktop/src/shared/api/useRelayResumeTriggers.ts b/desktop/src/shared/api/useRelayResumeTriggers.ts new file mode 100644 index 0000000000..a2e8878ac6 --- /dev/null +++ b/desktop/src/shared/api/useRelayResumeTriggers.ts @@ -0,0 +1,54 @@ +import * as React from "react"; + +import { relayClient } from "@/shared/api/relayClient"; +import { shouldTriggerResumeReconnect } from "@/shared/api/relayResumeTriggerPolicy"; + +/** + * Event-driven reconnect triggers: network `online`, window `focus`, and + * visibility→visible each attempt an immediate `preconnect()` when the relay + * session is degraded (reconnecting/stalled), rate-limited by + * `RESUME_TRIGGER_MIN_INTERVAL_MS`. + * + * Rationale (CMD+R gap audit G1): without these, recovery rides solely on + * the backoff timer, which WKWebView throttles while the window is occluded + * or the system was asleep. The moment a user focuses the window to hit + * CMD+R *is* a focus event — this fires the reconnect first. Deliberately + * does not trigger on `disconnected` (terminal latch): that path requires + * explicit user re-engagement via the reconnect card. + */ +export function useRelayResumeTriggers(): void { + React.useEffect(() => { + let lastAttemptAt = -Infinity; + + const attempt = () => { + const now = Date.now(); + if ( + !shouldTriggerResumeReconnect({ + connectionState: relayClient.getConnectionState(), + lastAttemptAt, + now, + }) + ) { + return; + } + lastAttemptAt = now; + // preconnect() clears any pending backoff timer and connects now. + // Failures re-arm the normal backoff loop; nothing to handle here. + void relayClient.preconnect().catch(() => {}); + }; + + const onVisibilityChange = () => { + if (document.visibilityState === "visible") attempt(); + }; + + window.addEventListener("online", attempt); + window.addEventListener("focus", attempt); + document.addEventListener("visibilitychange", onVisibilityChange); + + return () => { + window.removeEventListener("online", attempt); + window.removeEventListener("focus", attempt); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, []); +} From ad44fe94ae0435528fb41e93eb9bc7115f91f484 Mon Sep 17 00:00:00 2001 From: npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6 <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 09:55:21 -0600 Subject: [PATCH 2/3] test(desktop): cover adversarial reconnect recovery Co-authored-by: npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6 <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz> Signed-off-by: npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6 <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz> --- desktop/src/testing/e2eBridge.ts | 33 ++++- desktop/tests/e2e/relay-reconnect.spec.ts | 157 +++++++++++++++++++++- 2 files changed, 188 insertions(+), 2 deletions(-) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 33b1fb38f4..f94d7883b8 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1184,6 +1184,12 @@ declare global { }; __BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (state: ConnectionState) => void; __BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => ConnectionState; + /** Queue deterministic mock AUTH outcomes, consumed in order. */ + __BUZZ_E2E_QUEUE_AUTH_RESPONSES__?: ( + responses: Array<{ success: boolean; message: string }>, + ) => void; + /** Inject CLOSED into every active mock live subscription. */ + __BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__?: (reason: string) => number; __BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void; __BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number; __BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?: () => number; @@ -2938,6 +2944,7 @@ const mockReminderEvents: RelayEvent[] = []; const mockPersonaEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); +const mockAuthResponses: Array<{ success: boolean; message: string }> = []; let mockWebsocketUnavailable = false; const relayWebsocketConnectAttemptStarts: number[] = []; let mockWebsocketSendMutexWedged = false; @@ -9439,7 +9446,16 @@ function sendToMockSocket(args: { if (type === "AUTH") { const event = rest[0] as RelayEvent; - sendWsText(socket.handler, ["OK", event.id, true, ""]); + const response = mockAuthResponses.shift() ?? { + success: true, + message: "", + }; + sendWsText(socket.handler, [ + "OK", + event.id, + response.success, + response.message, + ]); return; } @@ -9802,6 +9818,7 @@ export function maybeInstallE2eTauriMocks() { mockClosedChannelLiveSubscription = false; mockWebsocketUnavailable = false; + mockAuthResponses.length = 0; relayWebsocketConnectAttemptStarts.length = 0; mockGlobalAgentConfig = config.mock?.globalAgentConfig ? { ...config.mock.globalAgentConfig } @@ -10049,6 +10066,20 @@ export function maybeInstallE2eTauriMocks() { }; window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__ = () => relayClient.getConnectionState(); + window.__BUZZ_E2E_QUEUE_AUTH_RESPONSES__ = (responses) => { + mockAuthResponses.push(...responses); + }; + window.__BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__ = (reason) => { + let closed = 0; + for (const socket of mockSockets.values()) { + for (const subId of [...socket.subscriptions.keys()]) { + sendWsText(socket.handler, ["CLOSED", subId, reason]); + socket.subscriptions.delete(subId); + closed += 1; + } + } + return closed; + }; window.__BUZZ_E2E_SEED_MOCK_REMINDERS__ = (reminders) => { mockReminderEvents.length = 0; diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 6606d5f04d..c883a739e8 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -111,9 +111,32 @@ async function emitMockMessages( }, messages); } +async function queueAuthResponses( + page: import("@playwright/test").Page, + responses: Array<{ success: boolean; message: string }>, +) { + await page.evaluate((queued) => { + const queue = window.__BUZZ_E2E_QUEUE_AUTH_RESPONSES__; + if (!queue) throw new Error("E2E AUTH response seam is not installed."); + queue(queued); + }, responses); +} + +async function closeLiveSubscriptions( + page: import("@playwright/test").Page, + reason: string, +) { + const closed = await page.evaluate((message) => { + const close = window.__BUZZ_E2E_CLOSE_LIVE_SUBSCRIPTIONS__; + if (!close) throw new Error("E2E live CLOSED seam is not installed."); + return close(message); + }, reason); + expect(closed).toBeGreaterThan(0); +} + async function driveConnectionDegraded( page: import("@playwright/test").Page, - state: "reconnecting" | "stalled" | "disconnected", + state: "connected" | "reconnecting" | "stalled" | "disconnected", ) { await page.evaluate((s) => { const setter = ( @@ -322,6 +345,138 @@ test("profile popover does not show relay reconnect controls", async ({ await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0); }); +test("resume event short-circuits accumulated reconnect backoff", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("channel-general")).toBeVisible(); + + await setMockWebsocketUnavailable(page, true); + await disconnectMockWebsockets(page); + await expect + .poll(() => getMockWebsocketConnectAttempts(page), { timeout: 10_000 }) + .toHaveLength(3); + + await setMockWebsocketUnavailable(page, false); + const resumedAt = Date.now(); + await page.evaluate(() => window.dispatchEvent(new Event("online"))); + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { + timeout: 2_000, + }, + ) + .toBe("connected"); + expect(Date.now() - resumedAt).toBeLessThan(2_000); +}); + +test("sub-2s degraded flap invalidates relay queries on recovery", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("channel-general")).toBeVisible(); + + await page.evaluate(() => { + const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__ as + | { + invalidateQueries: (...args: unknown[]) => unknown; + __rawHealInvalidations?: number; + } + | undefined; + if (!queryClient) + throw new Error("E2E query client seam is not installed."); + const original = queryClient.invalidateQueries.bind(queryClient); + queryClient.__rawHealInvalidations = 0; + queryClient.invalidateQueries = (...args: unknown[]) => { + queryClient.__rawHealInvalidations = + (queryClient.__rawHealInvalidations ?? 0) + 1; + return original(...args); + }; + }); + + await driveConnectionDegraded(page, "reconnecting"); + await page.waitForTimeout(100); + await driveConnectionDegraded(page, "connected"); + + await expect + .poll(() => + page.evaluate( + () => + ( + window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as { + __rawHealInvalidations?: number; + } + )?.__rawHealInvalidations ?? 0, + ), + ) + .toBeGreaterThan(0); +}); + +test("transient AUTH rejection reconnects and restores live traffic", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await queueAuthResponses(page, [ + { success: false, message: "auth-required: verification failed" }, + { success: true, message: "" }, + ]); + await disconnectMockWebsockets(page); + + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { + timeout: 10_000, + }, + ) + .toBe("connected"); + + const recovered = `live after transient AUTH rejection ${Date.now()}`; + await emitMockMessages(page, [ + { content: recovered, createdAt: Math.floor(Date.now() / 1_000) }, + ]); + await expect(page.getByTestId("message-timeline")).toContainText(recovered); + + const sent = `send after transient AUTH rejection ${Date.now()}`; + await page.getByTestId("message-input").fill(sent); + await page.getByTestId("send-message").click(); + await expect(page.getByTestId("message-timeline")).toContainText(sent); +}); + +test("auth-required CLOSED restores the active live subscription", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await closeLiveSubscriptions(page, "auth-required: not authenticated"); + + const recovered = `live after auth-required CLOSED ${Date.now()}`; + await expect + .poll( + async () => { + await emitMockMessages(page, [ + { content: recovered, createdAt: Math.floor(Date.now() / 1_000) }, + ]); + return page + .getByTestId("message-timeline") + .evaluate( + (element, content) => (element.textContent ?? "").includes(content), + recovered, + ); + }, + { intervals: [1_100], timeout: 5_000 }, + ) + .toBe(true); +}); + test("reconnect backfills more missed channel messages than the live subscription limit", async ({ page, }) => { From b0853e027431548edda155eb63d724fe500ed6f0 Mon Sep 17 00:00:00 2001 From: npub1yxv5wk0u0fh6dwt925wntn7h397jvteyj4r87ttcd9xae7n2t3lqqj9jmm <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 11:25:59 -0600 Subject: [PATCH 3/3] fix(desktop): address AUTH-policy review findings on reconnect PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings from wpfleger96 on PR #4737, both confirmed: 1. Resume triggers (focus/online/visibility) called preconnect(), which resets the AUTH rejection streak — a resume event interleaved with repeated AUTH rejections could clear the counter before the next OK=false, making MAX_CONSECUTIVE_AUTH_REJECTIONS unreachable and retrying a dead session forever. Resume now uses a separate resumeReconnect() path that bypasses the pending backoff timer but preserves the terminal latch and rejection streak; only explicit re-engagement (reconnect card / community switch) resets them. 2. The relay's permanent ban response is 'blocked: you are banned from this community' (handlers/auth.rs ban seam), which classified as retry. 'blocked:' now latches terminal immediately; the policy test covers the relay's exact ban string. Also fixes a latent race the new integration test exposed: a late failure from an in-flight publish/subscribe retry landing after the terminal latch called resetConnection() with default options and painted 'reconnecting' over the terminal 'disconnected' state — permanently, since the latch blocks actual reconnection. resetConnection now preserves 'disconnected' when the session is terminal. New e2e regression: three AUTH rejections interleaved with resume events still latch terminal on the third, and later resume events do not revive the session. Co-authored-by: Wes Signed-off-by: Wes --- .../src/shared/api/relayAuthPolicy.test.mjs | 12 +++ desktop/src/shared/api/relayAuthPolicy.ts | 21 +++-- desktop/src/shared/api/relayClientSession.ts | 86 +++++++++---------- .../src/shared/api/relayReconnectPolicy.ts | 10 +++ .../src/shared/api/useRelayResumeTriggers.ts | 22 +++-- desktop/tests/e2e/relay-reconnect.spec.ts | 60 +++++++++++++ 6 files changed, 154 insertions(+), 57 deletions(-) diff --git a/desktop/src/shared/api/relayAuthPolicy.test.mjs b/desktop/src/shared/api/relayAuthPolicy.test.mjs index b547161552..e2b709c0e8 100644 --- a/desktop/src/shared/api/relayAuthPolicy.test.mjs +++ b/desktop/src/shared/api/relayAuthPolicy.test.mjs @@ -35,6 +35,18 @@ test("restricted rejections latch terminal immediately", () => { } }); +test("the relay's actual ban string latches terminal immediately", () => { + // Exact string emitted by crates/buzz-relay/src/handlers/auth.rs at the + // ban seam. A known-permanent ban must never enter the retry loop. + assert.equal( + new AuthOkTracker().record( + false, + "blocked: you are banned from this community", + ), + "terminal", + ); +}); + test("verification failures retry with backoff (clock skew, DB fail-closed)", () => { const tracker = new AuthOkTracker(); assert.equal( diff --git a/desktop/src/shared/api/relayAuthPolicy.ts b/desktop/src/shared/api/relayAuthPolicy.ts index 78d50a6ad6..2d76e0c3f7 100644 --- a/desktop/src/shared/api/relayAuthPolicy.ts +++ b/desktop/src/shared/api/relayAuthPolicy.ts @@ -12,11 +12,15 @@ * and the relay's fail-closed allowlist DB lookup errors, both of which * can clear on retry. * - * Only `restricted:` rejections (banned / not a relay member) are known - * permanent. Everything else retries with normal backoff, but latches - * terminal after `MAX_CONSECUTIVE_AUTH_REJECTIONS` consecutive rejections so - * a genuinely broken identity (e.g. persistently wrong system clock) still - * surfaces the terminal error card instead of flapping forever. + * Only `restricted:` and `blocked:` rejections (not a relay member / banned) + * are known permanent. Everything else retries with normal backoff, but + * latches terminal after `MAX_CONSECUTIVE_AUTH_REJECTIONS` consecutive + * rejections so a genuinely broken identity (e.g. persistently wrong system + * clock) still surfaces the terminal error card instead of flapping forever. + * + * The rejection streak is preserved across environment-driven resume + * attempts (focus/online/visibility); only explicit user re-engagement — + * the reconnect card or a community switch — may reset it. */ export type AuthOkDecision = "authenticated" | "retry" | "terminal"; @@ -42,7 +46,12 @@ export class AuthOkTracker { this.consecutiveRejections++; - if (normalized.startsWith("restricted:")) return "terminal"; + if ( + normalized.startsWith("restricted:") || + normalized.startsWith("blocked:") + ) { + return "terminal"; + } if (this.consecutiveRejections >= MAX_CONSECUTIVE_AUTH_REJECTIONS) { return "terminal"; } diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index b8a989d785..fd6758f791 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -47,6 +47,7 @@ import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEm import { isServiceRestartClose, isWebSocketClose, + isWebSocketError, shouldRefuseConnect, shouldScheduleReconnect, shouldWaitForScheduledReconnect, @@ -298,7 +299,7 @@ export class RelayClient { parentEventId?: string | null, rootEventId?: string | null, ) { - // Bail when disconnected — not worth triggering a reconnect for ephemeral typing events. + // Disconnected: not worth triggering a reconnect for ephemeral typing. if (this.wsId === null) { return; } @@ -328,11 +329,11 @@ export class RelayClient { channelId: string, onEvent: (event: RelayEvent) => void, ) { + // 39005 rides only this window-store subscription — CHANNEL_EVENT_KINDS' + // other consumers (unread tracking, cache merges) must never see + // summary overlays. return this.subscribe( { - // 39005 rides only this window-store subscription — not - // CHANNEL_EVENT_KINDS, whose other consumers (unread tracking, - // timeline-cache merges) must never see summary overlays. kinds: [...CHANNEL_EVENT_KINDS, KIND_CHANNEL_THREAD_SUMMARY], "#h": [channelId], limit: 1000, @@ -343,10 +344,9 @@ export class RelayClient { } /** - * Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel. - * Used by HuddleIndicator to detect active huddles without being drowned - * out by regular channel messages in the generic subscription window. - * Includes both historical (last 10) and live events. + * Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel, + * so HuddleIndicator detects active huddles without being drowned out by + * regular channel messages. Includes the last 10 historical events. */ async subscribeToHuddleEvents( channelId: string, @@ -428,13 +428,26 @@ export class RelayClient { } async preconnect() { - // Explicit re-engagement. If the session went terminal (auth rejection) - // the caller is asking us to try again, so clear the latch. A manual - // reconnect also bypasses the current delay once; ordinary operations do - // not, so background traffic cannot continuously defeat backoff. + // Explicit re-engagement (reconnect card / community switch): clears the + // terminal latch and AUTH rejection streak, and bypasses backoff once. this.terminal = false; this.authOkTracker.reset(); this.keepAliveRequested = true; + await this.connectBypassingBackoff(); + } + + /** + * Environment-driven resume (online/focus/visibility): bypasses a pending + * backoff timer but preserves the terminal latch and AUTH rejection streak + * — only `preconnect()` clears those, so resume events during repeated + * AUTH rejection cannot defeat the consecutive-rejection cap. + */ + async resumeReconnect() { + if (this.terminal) return; + await this.connectBypassingBackoff(); + } + + private async connectBypassingBackoff() { if (this.reconnectTimeout !== null) { window.clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; @@ -452,7 +465,6 @@ export class RelayClient { subscribeToReconnects(listener: () => void) { this.reconnectListeners.add(listener); - return () => { this.reconnectListeners.delete(listener); }; @@ -464,8 +476,8 @@ export class RelayClient { } /** - * Subscribe to connection-state transitions. The listener is invoked - * immediately with the current state so callers don't need a separate + * Subscribe to connection-state transitions. The listener fires + * immediately with the current state, so callers need no separate * `getConnectionState()` call to seed their UI. */ subscribeToConnectionState(listener: (state: ConnectionState) => void) { @@ -474,11 +486,10 @@ export class RelayClient { private async ensureConnected() { if (shouldRefuseConnect({ terminal: this.terminal })) { - // Session is terminal (e.g. relay rejected auth). Refuse to connect - // until an explicit re-engagement (disconnect()/preconnect()) clears - // the flag. Without this, the reconnect timer's catch handler — and - // the retry wrappers in publishEvent / sendRawWithReconnectRetry — - // would race the terminal "disconnected" state back to "reconnecting". + // Terminal (e.g. relay rejected auth): refuse until disconnect() or + // preconnect() clears the latch, else the reconnect-timer catch and + // the publish/subscribe retry wrappers would race the terminal + // "disconnected" state back to "reconnecting". throw new Error("Relay session is terminal; cannot reconnect."); } @@ -498,7 +509,7 @@ export class RelayClient { // The reconnect coordinator owns outage pacing. Query, publish, and // subscription callers must wait for its scheduled attempt instead of // clearing the timer and creating an immediate reconnect storm. - return this.waitForScheduledReconnect(); + return this.reconnectWaiters.wait(); } const connectPromise = this.connect(); @@ -674,7 +685,6 @@ export class RelayClient { error, fallbackMessage, ); - try { await this.ensureConnected(); await this.sendRaw(payload); @@ -753,12 +763,7 @@ export class RelayClient { this.resetConnection(new Error("Relay connection closed.")); return; } - if ( - typeof message === "object" && - message !== null && - "type" in message && - message.type === "Error" - ) { + if (isWebSocketError(message)) { this.resetConnection(new Error("Relay connection errored.")); return; } @@ -823,8 +828,7 @@ export class RelayClient { if (type === "NOTICE" && typeof rest[0] === "string") { const notice: string = rest[0]; - // Relay back-pressure signal — activate the gate so pending operations - // back off until the window expires. + // Relay back-pressure — arm the gate until the window expires. if (notice.startsWith("rate-limited:")) { activateRateLimit(parseRateLimitHint(notice)); } @@ -948,13 +952,6 @@ export class RelayClient { } } - private waitForScheduledReconnect(): Promise { - if (this.reconnectTimeout === null) { - return this.ensureConnected(); - } - return this.reconnectWaiters.wait(); - } - private scheduleReconnect() { if ( !shouldScheduleReconnect({ @@ -968,9 +965,8 @@ export class RelayClient { return; } - // Apply ±25% jitter so a fleet of clients reconnecting simultaneously - // spreads their AUTH storms across a 50% window instead of all hitting - // the relay at the same instant. + // ±25% jitter spreads a fleet's AUTH storms across a 50% window instead + // of hitting the relay at the same instant. const jitter = this.reconnectDelayMs * (0.75 + Math.random() * 0.5); const delay = Math.min(jitter, RECONNECT_MAX_DELAY_MS); this.reconnectDelayMs = Math.min( @@ -1031,9 +1027,13 @@ export class RelayClient { if (options?.reconnect === false) { this.terminal = true; this.connectionStateEmitter.set("disconnected"); - } else if (this.connectionStateEmitter.get() !== "stalled") { - // Stall is a stronger signal than a generic drop; keep it until the - // reconnect timer transitions us back to "reconnecting" in connect(). + } else if ( + // A late retry failure racing a terminal latch must not paint + // "reconnecting" over the terminal "disconnected" state; stall is a + // stronger signal than a generic drop and is kept until reconnect. + !this.terminal && + this.connectionStateEmitter.get() !== "stalled" + ) { this.connectionStateEmitter.set("reconnecting"); } diff --git a/desktop/src/shared/api/relayReconnectPolicy.ts b/desktop/src/shared/api/relayReconnectPolicy.ts index 00d8e412bd..780ad3516b 100644 --- a/desktop/src/shared/api/relayReconnectPolicy.ts +++ b/desktop/src/shared/api/relayReconnectPolicy.ts @@ -70,3 +70,13 @@ export function isServiceRestartClose(message: unknown): boolean { data.code === 1012 ); } + +/** Whether a WS-layer message is the plugin's `Error` frame. */ +export function isWebSocketError(message: unknown): boolean { + return ( + typeof message === "object" && + message !== null && + "type" in message && + message.type === "Error" + ); +} diff --git a/desktop/src/shared/api/useRelayResumeTriggers.ts b/desktop/src/shared/api/useRelayResumeTriggers.ts index a2e8878ac6..abb2e5d49b 100644 --- a/desktop/src/shared/api/useRelayResumeTriggers.ts +++ b/desktop/src/shared/api/useRelayResumeTriggers.ts @@ -5,16 +5,21 @@ import { shouldTriggerResumeReconnect } from "@/shared/api/relayResumeTriggerPol /** * Event-driven reconnect triggers: network `online`, window `focus`, and - * visibility→visible each attempt an immediate `preconnect()` when the relay - * session is degraded (reconnecting/stalled), rate-limited by + * visibility→visible each attempt an immediate `resumeReconnect()` when the + * relay session is degraded (reconnecting/stalled), rate-limited by * `RESUME_TRIGGER_MIN_INTERVAL_MS`. * * Rationale (CMD+R gap audit G1): without these, recovery rides solely on * the backoff timer, which WKWebView throttles while the window is occluded * or the system was asleep. The moment a user focuses the window to hit - * CMD+R *is* a focus event — this fires the reconnect first. Deliberately - * does not trigger on `disconnected` (terminal latch): that path requires - * explicit user re-engagement via the reconnect card. + * CMD+R *is* a focus event — this fires the reconnect first. + * + * Uses `resumeReconnect()`, NOT `preconnect()`: resume events bypass the + * pending backoff timer but must preserve the terminal latch and the AUTH + * rejection streak. Otherwise a focus/online event arriving during repeated + * AUTH rejection would reset the consecutive-rejection cap and the session + * could retry indefinitely instead of surfacing `disconnected`. The + * terminal state stays user-owned via the reconnect card. */ export function useRelayResumeTriggers(): void { React.useEffect(() => { @@ -32,9 +37,10 @@ export function useRelayResumeTriggers(): void { return; } lastAttemptAt = now; - // preconnect() clears any pending backoff timer and connects now. - // Failures re-arm the normal backoff loop; nothing to handle here. - void relayClient.preconnect().catch(() => {}); + // resumeReconnect() clears any pending backoff timer and connects now, + // preserving terminal/AUTH-streak state. Failures re-arm the normal + // backoff loop; nothing to handle here. + void relayClient.resumeReconnect().catch(() => {}); }; const onVisibilityChange = () => { diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index c883a739e8..ec3e87171d 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -372,6 +372,66 @@ test("resume event short-circuits accumulated reconnect backoff", async ({ expect(Date.now() - resumedAt).toBeLessThan(2_000); }); +test("resume events during repeated AUTH rejection cannot defeat the terminal cap", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Three consecutive rejections must latch terminal even when resume + // events interleave with the handshakes: resume attempts bypass backoff + // but must preserve the AUTH rejection streak (review finding on + // PR #4737 — preconnect()'s streak reset previously made the cap + // unreachable under focus/online bursts). + // + // Queue MORE rejections than the cap: superseded connection attempts + // (resume racing the backoff timer) consume queue entries on handshakes + // whose frames the client drops as stale, exactly like a real relay that + // rejects every attempt. A queue of exactly 3 can be silently eaten and + // a default-success AUTH would then reset the streak. + await queueAuthResponses( + page, + Array.from({ length: 12 }, () => ({ + success: false, + message: "auth-required: verification failed", + })), + ); + await disconnectMockWebsockets(page); + + // Fire resume events while the rejection sequence plays out. Repeated + // dispatch (post-rate-limit spacing is irrelevant here: each poll tick + // dispatches both events) guarantees at least one lands between + // handshakes. + await expect + .poll( + async () => { + await page.evaluate(() => { + window.dispatchEvent(new Event("online")); + window.dispatchEvent(new Event("focus")); + }); + return page.evaluate(() => + window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.(), + ); + }, + { intervals: [500], timeout: 20_000 }, + ) + .toBe("disconnected"); + + // Terminal is user-owned: further resume events must not revive the + // session. + await page.evaluate(() => { + window.dispatchEvent(new Event("online")); + window.dispatchEvent(new Event("focus")); + }); + await page.waitForTimeout(1_000); + expect( + await page.evaluate(() => + window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.(), + ), + ).toBe("disconnected"); +}); + test("sub-2s degraded flap invalidates relay queries on recovery", async ({ page, }) => {