From c2c76537c1ff5342549baea49510924ff10c1e79 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Date: Thu, 23 Jul 2026 10:11:37 -0700 Subject: [PATCH] fix(desktop): retry failed initial relay dials Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> --- desktop/src/shared/api/relayClientSession.ts | 93 +++++++++++--------- desktop/src/testing/e2eBridge.ts | 7 ++ desktop/tests/e2e/relay-reconnect.spec.ts | 27 ++++++ desktop/tests/helpers/bridge.ts | 2 + 4 files changed, 89 insertions(+), 40 deletions(-) diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 6dcc857156..a5fd8a0eb0 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -534,8 +534,6 @@ export class RelayClient { } private async connect() { - // Clear any pending stability timer from a previous connection — a new - // connect attempt resets the clock and must re-arm the timer on success. if (this.stabilityTimer !== null) { window.clearTimeout(this.stabilityTimer); this.stabilityTimer = null; @@ -545,51 +543,66 @@ export class RelayClient { this.hasConnectedOnce ? "reconnecting" : "connecting", ); - if (!this.relayUrl) { - this.relayUrl = await getRelayWsUrl(); - } - const generation = ++this.connectionGeneration; this.onMessageChannel = new Channel((message) => { - void this.handleWsMessage(message, generation); - }); - - this.wsId = await invoke("plugin:websocket|connect", { - url: this.relayUrl, - onMessage: this.onMessageChannel, - config: {}, - }); - - await new Promise((resolve, reject) => { - const timeout = window.setTimeout(() => { - this.authRequest = null; + void this.handleWsMessage(message, generation).catch((error) => { + if (generation !== this.connectionGeneration) return; this.resetConnection( - new Error("Timed out while waiting for relay authentication."), + this.normalizeRelayError(error, "Relay connection errored."), ); - reject(new Error("Timed out while waiting for relay authentication.")); - }, AUTH_TIMEOUT_MS); - - this.authRequest = { - pendingEventId: "", - resolve, - reject, - timeout, - }; + }); }); - // Start a stability timer instead of resetting backoff immediately. - // The backoff resets to its base value only after BACKOFF_RESET_STABLE_MS - // of uninterrupted uptime, preventing fast reconnect loops from erasing - // the exponential backoff that throttles them. - this.stabilityTimer = window.setTimeout(() => { - this.stabilityTimer = null; - this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; - }, BACKOFF_RESET_STABLE_MS); + try { + if (!this.relayUrl) { + this.relayUrl = await getRelayWsUrl(); + } + const wsId = await invoke("plugin:websocket|connect", { + url: this.relayUrl, + onMessage: this.onMessageChannel, + config: {}, + }); + if (generation !== this.connectionGeneration) { + void closeWebSocket(wsId, "stale connection attempt"); + throw new Error("Relay connection attempt was superseded."); + } + this.wsId = wsId; + + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + const error = new Error("Relay authentication timed out."); + this.authRequest = null; + this.resetConnection(error); + reject(error); + }, AUTH_TIMEOUT_MS); + + this.authRequest = { + pendingEventId: "", + resolve, + reject, + timeout, + }; + }); - await this.replayLiveSubscriptions(); - this.connectionStateEmitter.set("connected"); - this.stallWatchdog.start(); - this.emitReconnectIfNeeded(); + this.stabilityTimer = window.setTimeout(() => { + this.stabilityTimer = null; + this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; + }, BACKOFF_RESET_STABLE_MS); + + await this.replayLiveSubscriptions(); + this.connectionStateEmitter.set("connected"); + this.stallWatchdog.start(); + this.emitReconnectIfNeeded(); + } catch (error) { + const connectionError = this.normalizeRelayError( + error, + "Failed to connect to relay.", + ); + if (generation === this.connectionGeneration) { + this.resetConnection(connectionError); + } + throw connectionError; + } } private async subscribe( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4ef5ee56e9..86491f6e27 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -250,6 +250,8 @@ type E2eConfig = { openerError?: string; /** Delay binding signatures so specs can exercise request supersession. */ nostrBindSignDelayMs?: number; + /** Reject successive mock WebSocket connect attempts, then resume. */ + websocketConnectErrors?: string[]; stallWebsocketSends?: boolean; userSearchDelayMs?: number; // NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for @@ -8498,6 +8500,11 @@ async function connectRealSocket(args: { url?: string; onMessage: unknown }) { } async function connectMockSocket(args: { onMessage: unknown }) { + const connectError = getConfig()?.mock?.websocketConnectErrors?.shift(); + if (connectError) { + throw new Error(connectError); + } + if (mockWebsocketSendMutexWedged) { return new Promise(() => {}); } diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 2df9075d64..4f47ceded0 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -80,6 +80,33 @@ test.beforeEach(async ({ page }) => { await installMockBridge(page); }); +test("failed initial relay dial retries automatically", async ({ page }) => { + await installMockBridge(page, { + websocketConnectErrors: ["mock relay pod unavailable"], + }); + await page.goto("/"); + + // App-shell preconnect owns a keep-alive request. The first native dial is + // rejected before a socket ID exists; the session must still enter its + // backoff loop and recover without a click, query, or reload. + await expect + .poll( + () => + page.evaluate(() => { + const getState = ( + window as Window & { + __BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => string; + } + ).__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__; + if (!getState) throw new Error("Relay state seam is not installed."); + return getState(); + }), + { timeout: 10_000 }, + ) + .toBe("connected"); + await expect(page.getByTestId("channel-general")).toBeVisible(); +}); + test("passive relay watchdog does not write while the websocket is half-open", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 36b80aef28..21f69776d8 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -261,6 +261,8 @@ type MockBridgeOptions = { openerError?: string; /** Delay binding signatures so specs can exercise request supersession. */ nostrBindSignDelayMs?: number; + /** Reject successive mock WebSocket connect attempts, then resume. */ + websocketConnectErrors?: string[]; stallWebsocketSends?: boolean; userSearchDelayMs?: number; /**