Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 53 additions & 40 deletions desktop/src/shared/api/relayClientSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 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,
};
});

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(
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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>(() => {});
}
Expand Down
27 changes: 27 additions & 0 deletions desktop/tests/e2e/relay-reconnect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down
2 changes: 2 additions & 0 deletions desktop/tests/helpers/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
Loading