fix(desktop): retry failed initial relay dials (#2564)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
This commit is contained in:
Tyler
2026-07-23 13:15:39 -07:00
committed by GitHub
parent daeaf7c33d
commit 9ec52cfedf
4 changed files with 89 additions and 40 deletions

View File

@@ -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<unknown>((message) => {
void this.handleWsMessage(message, generation);
});
this.wsId = await invoke<number>("plugin:websocket|connect", {
url: this.relayUrl,
onMessage: this.onMessageChannel,
config: {},
});
await new Promise<void>((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<number>("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 this.replayLiveSubscriptions();
this.connectionStateEmitter.set("connected");
this.stallWatchdog.start();
this.emitReconnectIfNeeded();
await new Promise<void>((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,
};
});
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(

View File

@@ -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<number>(() => {});
}

View File

@@ -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,
}) => {

View File

@@ -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;
/**