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"],