From 74cd5712191bffd84ae688d59bb8b451c6eec1b0 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 16:04:14 -0600 Subject: [PATCH] fix(desktop): report authenticated relay recovery (#3812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - report the relay as connected immediately after socket open and successful AUTH - keep rate-limited subscription replay, the connect promise, and reconnect listeners unchanged - cover authenticated reconnect while replay is held behind the shared rate-limit gate ## Why After WARP recovery, the socket could reopen and authenticate successfully while subscription replay waited behind the existing rate-limit gate. `connect()` kept `ConnectionState` at `reconnecting` during that intentional delay, so the desktop displayed “Can’t reach the relay” despite authenticated traffic already flowing. This is separate from #3774: that fix keeps routine operations from bypassing scheduled reconnect backoff. This patch preserves those protections and only corrects the authenticated transport-state boundary. ## Failure semantics If replay fails after the early `connected` transition, the existing `replayLiveSubscriptions()` catch calls `resetConnection()`, closes the socket, returns state to `reconnecting`, and schedules recovery. Operation waiters and reconnect notifications still do not complete until replay succeeds. ## Validation At commit `c8a4308e1079f4f9e6a72f0f0bfba280fe822ec0` with a clean working tree: - `pnpm --dir desktop typecheck` - `pnpm --dir desktop test` — 3,847 passed - `pnpm --dir desktop check` — passed; two pre-existing informational template-literal notices - `pnpm --dir desktop exec playwright test tests/e2e/relay-reconnect.spec.ts` — 8 passed - regression test proven red before the production ordering change (`reconnecting` after 3 seconds) and green after it Signed-off-by: Wes Co-authored-by: Carl --- desktop/src/shared/api/relayClientSession.ts | 2 +- desktop/src/testing/e2eBridge.ts | 5 +++ desktop/tests/e2e/relay-reconnect.spec.ts | 35 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 8274034ed5..94438386eb 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -566,8 +566,8 @@ export class RelayClient { this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; }, BACKOFF_RESET_STABLE_MS); - await this.replayLiveSubscriptions(); this.connectionStateEmitter.set("connected"); + await this.replayLiveSubscriptions(); this.stallWatchdog.start(); this.emitReconnectIfNeeded(); } catch (error) { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 841e6ba83f..9bb3feabda 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11,6 +11,7 @@ import { } from "./e2eBridgeCustomHarnesses.ts"; import { relayClient } from "@/shared/api/relayClient"; +import { activateRateLimit } from "@/shared/api/relayRateLimitGate"; import type { ConnectionState } from "@/shared/api/relayClientShared"; import type { ChannelTemplate, RelayEvent } from "@/shared/api/types"; import { getMarkdownParseCount } from "@/shared/ui/markdown/nodeCache"; @@ -1115,6 +1116,7 @@ declare global { unavailable: boolean, ) => void; __BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => number[]; + __BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__?: (seconds: number) => void; __BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => void; __BUZZ_E2E_SET_MESH__?: (mesh: { admitted?: boolean; @@ -9706,6 +9708,9 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => [ ...relayWebsocketConnectAttemptStarts, ]; + window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__ = (seconds) => { + activateRateLimit(seconds); + }; window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => { relayWebsocketConnectAttemptStarts.length = 0; }; diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 67ce725da8..6606d5f04d 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -62,6 +62,19 @@ async function setMockWebsocketUnavailable( }, unavailable); } +async function activateRelayRateLimit( + page: import("@playwright/test").Page, + seconds: number, +) { + await page.evaluate((duration) => { + const activate = window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__; + if (!activate) { + throw new Error("E2E relay rate-limit seam is not installed."); + } + activate(duration); + }, seconds); +} + async function getMockWebsocketConnectAttempts( page: import("@playwright/test").Page, ) { @@ -195,6 +208,28 @@ test("routine traffic cannot bypass outage backoff and recovery stays automatic" ); }); +test("authenticated reconnect reports connected while replay is rate-limited", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("channel-general")).toBeVisible(); + + await activateRelayRateLimit(page, 5); + await disconnectMockWebsockets(page); + + // Replay remains intentionally blocked behind admission control, but socket + // open + successful AUTH is already a healthy connection. The UI must not + // claim the relay is unreachable for the rest of the gate window. + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { timeout: 3_000 }, + ) + .toBe("connected"); + await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0); +}); + test("service restart close resets accumulated backoff", async ({ page }) => { await installMockBridge(page, { websocketConnectErrors: ["down 1", "down 2", "down 3"],