diff --git a/desktop/src/shared/api/relayClientRecoveryAvailability.test.mjs b/desktop/src/shared/api/relayClientRecoveryAvailability.test.mjs new file mode 100644 index 0000000000..970cbd3f01 --- /dev/null +++ b/desktop/src/shared/api/relayClientRecoveryAvailability.test.mjs @@ -0,0 +1,439 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const timers = new Map(); +let nextTimer = 0; +let invoke; +globalThis.window = { + setTimeout: (fn, ms) => { + timers.set(++nextTimer, { fn, ms }); + return nextTimer; + }, + clearTimeout: (id) => timers.delete(id), + setInterval: (fn, ms) => window.setTimeout(fn, ms), + clearInterval: (id) => timers.delete(id), + __TAURI_INTERNALS__: { + transformCallback: () => ++nextTimer, + unregisterCallback: () => {}, + invoke: (...args) => invoke(...args), + }, +}; +const { RelayClient } = await import("./relayClientSession.ts"); +const { resetRateLimitGate } = await import("./relayRateLimitGate.ts"); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +async function flush() { + for (let i = 0; i < 100; i++) await Promise.resolve(); +} + +function row(id) { + return { + id: id.repeat(64), + pubkey: "a".repeat(64), + kind: 9, + created_at: Math.floor(Date.now() / 1000), + tags: [["h", "background"]], + content: id, + sig: "b".repeat(128), + }; +} + +function fixture(t) { + timers.clear(); + resetRateLimitGate(); + const client = new RelayClient(); + const sockets = []; + const frames = []; + const repairs = []; + let authHeld = false; + let failReplay = null; + let failNextOperation = false; + invoke = async (command, args) => { + if (command === "get_relay_ws_url") return "wss://fixture.invalid"; + if (command === "sign_event") return JSON.stringify(row("3")); + if (command === "create_auth_event") return JSON.stringify({ id: "auth" }); + if (command === "plugin:websocket|connect") { + sockets.push(args.onMessage); + args.onMessage.onmessage({ type: "Text", data: '["AUTH","challenge"]' }); + return sockets.length; + } + if (command === "plugin:websocket|disconnect") return; + if (command === "plugin:websocket|send") { + const frame = JSON.parse(args.message.data); + frames.push({ socket: args.id, frame }); + if (failNextOperation && (frame[0] === "REQ" || frame[0] === "EVENT")) { + failNextOperation = false; + authHeld = true; + throw new Error("fixture initial send failed"); + } + if (frame[0] === "EVENT") deliver(args.id, ["OK", frame[1].id, true, ""]); + if (frame[0] === "AUTH" && !authHeld) + deliver(args.id, ["OK", "auth", true, ""]); + if (frame[0] === "REQ") { + if (failReplay && frame[2]["#h"]?.[0] === "background") + return failReplay.promise; + deliver(args.id, ["EOSE", frame[1]]); + } + return; + } + if (command === "get_channel_reconnect_repair") { + const gate = deferred(); + repairs.push({ args, ...gate }); + return gate.promise; + } + assert.fail(`Unexpected native call: ${command}`); + }; + function deliver(socket, frame) { + sockets[socket - 1].onmessage({ + type: "Text", + data: JSON.stringify(frame), + }); + } + t.after(async () => { + for (const repair of repairs) repair.resolve([]); + failReplay?.resolve(); + await flush(); + client.disconnect(); + resetRateLimitGate(); + }); + return { + client, + sockets, + frames, + repairs, + deliver, + failNextOperation: () => { + failNextOperation = true; + }, + holdAuth: () => { + authHeld = true; + }, + failReplay: (gate) => { + failReplay = gate; + }, + }; +} + +async function seedAndDisconnect(f) { + const delivered = []; + await f.client.subscribeToChannelLive("background", (event) => + delivered.push(event.id), + ); + const sub = f.frames.find(({ frame }) => frame[0] === "REQ").frame[1]; + f.deliver(1, ["EVENT", sub, row("1")]); + f.deliver(1, ["EOSE", sub]); + await flush(); + assert.deepEqual(delivered, [row("1").id]); + f.sockets[0].onmessage({ + type: "Close", + data: { code: 1006, reason: "fixture drop" }, + }); + await flush(); + return delivered; +} + +for (const beforeAuth of [false, true]) { + test(`foreground subscription does not wait for background repair (${beforeAuth ? "queued before auth" : "after auth"})`, async (t) => { + const f = fixture(t); + const delivered = await seedAndDisconnect(f); + let completed = 0; + f.client.subscribeToReconnects(() => completed++); + if (beforeAuth) f.holdAuth(); + const reconnect = f.client.resumeReconnect(); + await flush(); + let ready = false; + const foreground = f.client + .subscribeToChannelLive("foreground", () => {}) + .then((dispose) => { + ready = true; + return dispose; + }); + await flush(); + if (beforeAuth) { + assert.equal(ready, false, "authentication remains mandatory"); + assert.equal( + f.frames.some( + ({ socket, frame }) => socket === 2 && frame[0] === "REQ", + ), + false, + ); + f.deliver(2, ["OK", "auth", true, ""]); + await flush(); + } + assert.equal(f.repairs.length, 1, "real native repair must be in flight"); + assert.equal(completed, 0, "reconnect completion still waits for repair"); + try { + assert.equal( + ready, + true, + "authenticated foreground REQ must not wait for unrelated HTTP repair", + ); + } finally { + f.repairs[0].resolve([row("2")]); + await reconnect; + const dispose = await foreground; + await flush(); + await dispose(); + } + assert.deepEqual( + delivered, + [row("1").id, row("2").id], + "missed history still arrives", + ); + assert.equal(completed, 1); + }); +} + +test("late repair completion cannot notify or deliver into a replacement community", async (t) => { + const f = fixture(t); + const delivered = await seedAndDisconnect(f); + await f.client.resumeReconnect(); + await flush(); + assert.equal(f.repairs.length, 1); + f.client.disconnect(); + await f.client.preconnect(); + await flush(); + let notified = 0; + f.client.subscribeToReconnects(() => notified++); + const socket = f.client.wsId; + f.repairs[0].resolve([row("2")]); + await flush(); + assert.equal(f.client.wsId, socket); + assert.equal(f.client.getConnectionState(), "connected"); + assert.equal(notified, 0); + assert.deepEqual(delivered, [row("1").id]); +}); + +test("current replay transport failure resets the socket and schedules recovery", async (t) => { + const f = fixture(t); + await seedAndDisconnect(f); + const send = deferred(); + f.failReplay(send); + await f.client.resumeReconnect(); + await flush(); + assert.equal(f.client.getConnectionState(), "connected"); + send.reject(new Error("fixture send failed")); + await flush(); + assert.equal(f.client.wsId, null); + assert.equal(f.client.getConnectionState(), "reconnecting"); + assert.notEqual(f.client.reconnectTimeout, null); +}); + +test("stale replay transport failure cannot reset a replacement socket", async (t) => { + const f = fixture(t); + await seedAndDisconnect(f); + const send = deferred(); + f.failReplay(send); + await f.client.resumeReconnect(); + await flush(); + f.client.disconnect(); + f.failReplay(null); + await f.client.preconnect(); + await flush(); + const socket = f.client.wsId; + send.reject(new Error("late fixture send failure")); + await flush(); + assert.equal(f.client.wsId, socket); + assert.equal(f.client.getConnectionState(), "connected"); + assert.equal(f.client.reconnectTimeout, null); +}); + +test("rejected authentication never releases a waiting foreground request", async (t) => { + const f = fixture(t); + f.holdAuth(); + const connecting = f.client.preconnect(); + const rejected = assert.rejects(connecting, /restricted/); + await flush(); + const foreground = f.client.subscribeToChannelLive("foreground", () => {}); + const foregroundRejected = assert.rejects(foreground, /restricted/); + f.deliver(1, ["OK", "auth", false, "restricted: fixture denial"]); + await Promise.all([rejected, foregroundRejected]); + assert.equal( + f.frames.some(({ frame }) => frame[0] === "REQ"), + false, + ); + assert.equal(f.client.wsId, null); + assert.equal(f.client.getConnectionState(), "disconnected"); +}); + +function expireCooldown() { + const entry = [...timers.entries()].find(([, timer]) => timer.ms === 10_000); + assert.ok(entry, "expected WS admission timer"); + timers.delete(entry[0]); + entry[1].fn(); +} + +for (const switchCommunity of [false, true]) { + test(`foreground admission preserves WS cooldown (switch=${switchCommunity})`, async (t) => { + const f = fixture(t); + await seedAndDisconnect(f); + await f.client.resumeReconnect(); + f.deliver(2, ["NOTICE", "rate-limited: retry in 10s"]); + await flush(); + const foreground = f.client.subscribeToChannelLive("foreground", () => {}); + const outcome = foreground.then( + () => "ready", + () => "superseded", + ); + await flush(); + assert.equal( + f.frames.some( + ({ frame }) => + frame[0] === "REQ" && frame[2]["#h"]?.[0] === "foreground", + ), + false, + ); + if (switchCommunity) { + f.client.disconnect(); + resetRateLimitGate(); + await f.client.preconnect(); + assert.equal(await outcome, "superseded"); + assert.equal(f.client.subscriptions.size, 0); + assert.equal(f.client.getConnectionState(), "connected"); + } else { + expireCooldown(); + await flush(); + assert.equal(await outcome, "ready"); + assert.equal(f.repairs.length, 1, "HTTP repair stays held"); + } + }); +} + +for (const operation of ["subscribe", "publish"]) { + for (const switchCommunity of [false, true]) { + test(`${operation} retry respects cooldown armed during reconnect (switch=${switchCommunity})`, async (t) => { + const f = fixture(t); + await f.client.preconnect(); + f.failNextOperation(); + const pending = + operation === "subscribe" + ? f.client.subscribeToChannelLive("foreground", () => {}) + : f.client.publishEvent(row("3"), "timeout", "send failed"); + const outcome = pending.then( + () => "ready", + () => "superseded", + ); + await flush(); + const reconnect = f.client.resumeReconnect(); + await flush(); + f.deliver(2, ["NOTICE", "rate-limited: retry in 10s"]); + f.deliver(2, ["OK", "auth", true, ""]); + await reconnect; + await flush(); + assert.equal( + f.frames.some( + ({ socket, frame }) => + socket === 2 && ["REQ", "EVENT"].includes(frame[0]), + ), + false, + ); + if (switchCommunity) { + f.client.disconnect(); + resetRateLimitGate(); + const replacement = f.client.preconnect(); + await flush(); + f.deliver(3, ["OK", "auth", true, ""]); + await replacement; + assert.equal(await outcome, "superseded"); + assert.equal( + f.frames.some( + ({ socket, frame }) => + socket === 3 && ["REQ", "EVENT"].includes(frame[0]), + ), + false, + ); + assert.equal(f.client.getConnectionState(), "connected"); + } else { + expireCooldown(); + await flush(); + assert.equal(await outcome, "ready"); + } + }); + } +} + +test("queued old repair workers cannot re-pin floors after same-community successor completes", async (t) => { + const f = fixture(t); + for (let i = 0; i < 6; i++) { + await f.client.subscribeToChannelLive(`background-${i}`, () => {}); + const sub = f.frames.at(-1).frame[1]; + f.deliver(1, [ + "EVENT", + sub, + { ...row("1"), tags: [["h", `background-${i}`]] }, + ]); + f.deliver(1, ["EOSE", sub]); + } + await flush(); + const close = (socket) => + f.sockets[socket - 1].onmessage({ + type: "Close", + data: { code: 1006, reason: "drop" }, + }); + close(1); + await flush(); + await f.client.resumeReconnect(); + await flush(); + assert.equal(f.repairs.length, 4); + close(2); + await flush(); + await f.client.resumeReconnect(); + await flush(); + assert.equal(f.repairs.length, 8); + for (const repair of f.repairs.slice(4)) repair.resolve([]); + await flush(); + assert.equal(f.repairs.length, 10); + for (const repair of f.repairs.slice(8)) repair.resolve([]); + await flush(); + assert.ok( + [...f.client.subscriptions.values()].every( + (sub) => sub.pendingReplaySince === undefined, + ), + ); + for (const repair of f.repairs.slice(0, 4)) repair.resolve([row("2")]); + await flush(); + assert.ok( + [...f.client.subscriptions.values()].every( + (sub) => sub.pendingReplaySince === undefined, + ), + "stale queued workers must not mutate completed successor repair", + ); + assert.equal(f.repairs.length, 10, "old queued repair stays inert"); +}); + +test("finite read and accepted send proceed while unrelated recovery is held", async (t) => { + const f = fixture(t); + await seedAndDisconnect(f); + f.holdAuth(); + const reconnect = f.client.resumeReconnect(); + await flush(); + let readDone = false; + let sendDone = false; + const read = f.client.fetchEvents({ kinds: [1] }).then(() => { + readDone = true; + }); + const send = f.client.sendMessage("foreground", "hello").then(() => { + sendDone = true; + }); + await flush(); + assert.equal(readDone || sendDone, false); + f.deliver(2, ["OK", "auth", true, ""]); + await flush(); + assert.equal(f.repairs.length, 1); + try { + assert.equal(readDone, true, "EOSE completes finite read before repair"); + assert.equal(sendDone, true, "OK acknowledges send before repair"); + } finally { + f.repairs[0].resolve([]); + await Promise.all([reconnect, read, send]); + } +}); diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index d9be0e6c9b..b1877f830f 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -38,7 +38,10 @@ import { import { getChannelReconnectRepairEvents } from "@/shared/api/channelReconnectRepair"; import { replayLiveSubscriptions } from "@/shared/api/relayReconnectReplay"; import { publishSessionEvent } from "@/shared/api/relayEventPublisher"; -import { activateRateLimitIfSignalled } from "@/shared/api/relayRateLimitGate"; +import { + activateRateLimitIfSignalled, + waitForRateLimit, +} from "@/shared/api/relayRateLimitGate"; import { fetchChunkedHistory, requestFirstEventGated, @@ -562,15 +565,34 @@ export class RelayClient { await Promise.race([drain, authentication, inbound.overflow]); await drain; await authentication; + if (generation !== this.connectionGeneration || this.wsId !== wsId) { + throw new Error("Relay authentication was superseded."); + } this.stabilityTimer = window.setTimeout(() => { this.stabilityTimer = null; this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; }, BACKOFF_RESET_STABLE_MS); this.connectionStateEmitter.set("connected"); - await this.replayLiveSubscriptions(); - this.stallWatchdog.start(); - this.emitReconnectIfNeeded(); + // Authentication makes the socket usable. Unrelated channel backfill + // must not hold new subscriptions, reads, or writes behind connectPromise. + // Reconnect notifications still mean the recovery pass has finished. + void this.replayLiveSubscriptions().then( + () => { + if (generation !== this.connectionGeneration) return; + this.stallWatchdog.start(); + this.emitReconnectIfNeeded(); + }, + (error) => { + if (generation !== this.connectionGeneration) return; + this.resetConnection( + this.normalizeRelayError( + error, + "Failed to restore relay subscriptions.", + ), + ); + }, + ); return generation; } catch (error) { const connectionError = this.normalizeRelayError( @@ -590,7 +612,15 @@ export class RelayClient { onReady?: (readiness: LiveSubscriptionReadiness) => void, readinessTimeoutMs = 250, ) { - await this.ensureConnected(); + const ownership = this.sessionEpoch; + const generation = await this.ensureConnected(); + await waitForRateLimit(); + if ( + ownership !== this.sessionEpoch || + generation !== this.connectionGeneration + ) { + throw new Error("Relay subscription was superseded by a session change."); + } const subId = `live-${crypto.randomUUID()}`; let resolveReady = (_readiness: LiveSubscriptionReadiness) => {}; @@ -679,21 +709,38 @@ export class RelayClient { payload: unknown[], fallbackMessage: string, ) { + const ownership = this.sessionEpoch; + let generation = this.connectionGeneration; try { - await this.sendRaw(payload); + await this.sendRawForGeneration(payload, generation); } catch (error) { + if ( + ownership !== this.sessionEpoch || + generation !== this.connectionGeneration + ) { + throw this.normalizeRelayError(error, fallbackMessage); + } const normalizedError = this.recoverFromSocketFailure( error, fallbackMessage, ); try { - await this.ensureConnected(); - await this.sendRaw(payload); + generation = await this.ensureConnected(); + await waitForRateLimit(); + if ( + ownership !== this.sessionEpoch || + generation !== this.connectionGeneration + ) { + throw new Error( + "Relay subscription was superseded by a session change.", + ); + } + await this.sendRawForGeneration(payload, generation); } catch (retryError) { - throw this.recoverFromSocketFailure( - retryError, - normalizedError.message, - ); + throw ownership === this.sessionEpoch && + generation === this.connectionGeneration + ? this.recoverFromSocketFailure(retryError, normalizedError.message) + : this.normalizeRelayError(retryError, normalizedError.message); } } } @@ -908,23 +955,14 @@ export class RelayClient { private async replayLiveSubscriptions() { const generation = this.connectionGeneration; - try { - await replayLiveSubscriptions({ - subscriptions: this.subscriptions, - sendRaw: (payload) => this.sendRaw(payload), - requestRepair: getChannelReconnectRepairEvents, - generation, - visibleChannelId: this.visibleChannelId, - isActive: () => this.connectionGeneration === generation, - }); - } catch (error) { - const reconnectError = - error instanceof Error - ? error - : new Error("Failed to restore relay subscriptions."); - this.resetConnection(reconnectError); - throw reconnectError; - } + await replayLiveSubscriptions({ + subscriptions: this.subscriptions, + sendRaw: (payload) => this.sendRawForGeneration(payload, generation), + requestRepair: getChannelReconnectRepairEvents, + generation, + visibleChannelId: this.visibleChannelId, + isActive: () => this.connectionGeneration === generation, + }); } private scheduleReconnect() { diff --git a/desktop/src/shared/api/relayEventPublisher.ts b/desktop/src/shared/api/relayEventPublisher.ts index ff71992670..4119182eff 100644 --- a/desktop/src/shared/api/relayEventPublisher.ts +++ b/desktop/src/shared/api/relayEventPublisher.ts @@ -56,6 +56,7 @@ export async function publishSessionEvent( try { retryGeneration = await session.reconnect(); + await waitForRateLimit(); if ( publishOwnership !== session.ownership() || session.generation() !== retryGeneration || diff --git a/desktop/src/shared/api/relayReconnectReplay.ts b/desktop/src/shared/api/relayReconnectReplay.ts index 8fce219750..33b8b355b9 100644 --- a/desktop/src/shared/api/relayReconnectReplay.ts +++ b/desktop/src/shared/api/relayReconnectReplay.ts @@ -373,6 +373,8 @@ export async function replayLiveSubscriptions({ ), pageReplayConcurrency, async ({ subId, subscription, channelId, replaySince }) => { + // Queued workers can start after a successor has repaired this same entry. + if (!isActive() || subscriptions.get(subId) !== subscription) return; // Backfill is best-effort: a failure here (typically a `rate-limited:` // CLOSED on a history REQ) must never escape to the session and tear // down the healthy, authenticated socket carrying the live REQs — that diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index f3bf33f84c..e2ed33994d 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -826,3 +826,178 @@ test("reconnect shares overlapping native repair pages and recovers offline mess }); expect(reads[1].beforeId).toBeTruthy(); }); + +test("warm channel freshness is not blocked by another channel's reconnect repair", async ({ + page, +}) => { + const start = new Date("2026-09-21T12:00:00Z"); + await page.clock.setFixedTime(start); + await page.goto("/"); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page.getByTestId("message-input")).toBeVisible(); + // Warm the actual query; the subsequent visit uses its cached head and the + // post-subscribe authoritative refresh, not a cold independent HTTP query. + await expect + .poll(() => + page.evaluate(() => { + const state = window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState([ + "channel-messages", + "9dae0116-799b-5071-a0a8-fdd30a91a35d", + ]); + return state?.status === "success" && state.fetchStatus === "idle"; + }), + ) + .toBe(true); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await emitMockMessages(page, [ + { + content: "foreground availability cursor", + createdAt: start.getTime() / 1000, + }, + ]); + await expect(page.getByTestId("message-timeline")).toContainText( + "foreground availability cursor", + ); + await page.evaluate(() => { + const w = window as unknown as { + __TAURI_INTERNALS__: { + invoke: ( + command: string, + args?: unknown, + options?: unknown, + ) => Promise; + }; + recoveryGate: { + started: number; + finished: number; + foregroundReqs: number; + windows: number; + release: () => void; + }; + }; + const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__); + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + w.recoveryGate = { + started: 0, + finished: 0, + foregroundReqs: 0, + windows: 0, + release, + }; + w.__TAURI_INTERNALS__.invoke = async (command, args, options) => { + if (command === "plugin:websocket|send") { + const frame = JSON.parse( + (args as { message: { data: string } }).message.data, + ); + if ( + frame[0] === "REQ" && + frame[2]?.["#h"]?.includes("9dae0116-799b-5071-a0a8-fdd30a91a35d") && + frame[2]?.kinds?.includes(39005) + ) { + w.recoveryGate.foregroundReqs++; + } + } + if ( + command === "get_channel_window" && + w.recoveryGate.foregroundReqs > 0 && + (args as { channelId?: string }).channelId === + "9dae0116-799b-5071-a0a8-fdd30a91a35d" + ) { + w.recoveryGate.windows++; + } + if (command !== "get_channel_reconnect_repair") + return original(command, args, options); + w.recoveryGate.started++; + await gate; + const result = await original(command, args, options); + w.recoveryGate.finished++; + return result; + }; + }); + await setMockWebsocketUnavailable(page, true); + await disconnectMockWebsockets(page); + const missed = "fresh random head during unrelated recovery"; + await page.evaluate( + ({ content, createdAt }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content, + createdAt, + }); + }, + { content: missed, createdAt: start.getTime() / 1000 + 1 }, + ); + await page.clock.setFixedTime(new Date(start.getTime() + 10_000)); + await setMockWebsocketUnavailable(page, false); + await expect + .poll(() => + page.evaluate( + () => + (window as unknown as { recoveryGate: { started: number } }) + .recoveryGate.started, + ), + ) + .toBeGreaterThan(0); + await page.evaluate(() => { + const gate = ( + window as unknown as { + recoveryGate: { foregroundReqs: number; windows: number }; + } + ).recoveryGate; + gate.foregroundReqs = 0; + gate.windows = 0; + }); + try { + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect + .poll(() => + page.evaluate( + () => + (window as unknown as { recoveryGate: { foregroundReqs: number } }) + .recoveryGate.foregroundReqs, + ), + ) + .toBeGreaterThan(0); + await expect + .poll(() => + page.evaluate( + () => + (window as unknown as { recoveryGate: { windows: number } }) + .recoveryGate.windows, + ), + ) + .toBeGreaterThan(0); + await expect(page.getByTestId("message-timeline")).toContainText(missed); + expect( + await page.evaluate( + () => + (window as unknown as { recoveryGate: { finished: number } }) + .recoveryGate.finished, + ), + ).toBe(0); + } finally { + await page.evaluate(() => + ( + window as unknown as { recoveryGate: { release: () => void } } + ).recoveryGate.release(), + ); + } + await expect + .poll(() => + page.evaluate(() => { + const gate = ( + window as unknown as { + recoveryGate: { started: number; finished: number }; + } + ).recoveryGate; + return gate.finished > 0 && gate.finished === gate.started; + }), + ) + .toBe(true); +}); diff --git a/perf/RECONNECT_FOREGROUND_AVAILABILITY.md b/perf/RECONNECT_FOREGROUND_AVAILABILITY.md new file mode 100644 index 0000000000..f4d86d915a --- /dev/null +++ b/perf/RECONNECT_FOREGROUND_AVAILABILITY.md @@ -0,0 +1,86 @@ +# Authenticated foreground availability during reconnect + +## Change + +Base: `564a7da84e7d7096649bc22126ca86354fa81437`. + +`RelayClient.ensureConnected()` previously kept its shared promise pending until +all retained live subscriptions and their native HTTP history repairs finished. +Even after successful socket AUTH, new live subscriptions, finite WS reads and +plain-text sends inherited that unrelated wait. + +Keep authentication as the availability boundary, but run recovery as an +observed generation-fenced continuation. Retain history repair, visible-first +replay ordering, batching, readiness timers, server cooldowns and post-recovery +reconnect callbacks. New subscription admission and reconnect send retries check +the existing WS cooldown, then revalidate session ownership/generation. Queued +repair workers must be current before mutating repair floors. + +Production scope: three files, +70/-29 lines. No new scheduler, transport, +loading-state changes or timeout reductions. + +## Controlled browser evidence + +A production E2E bundle with the existing mock native bridge: + +1. Warm random, return to general, deliver a general event to establish a cursor. +2. Disconnect; inject an offline random message; hold native reconnect repair. +3. Reconnect successfully, wait until repair is in flight, then navigate to random. +4. Observe a **new foreground REQ containing kind 39005**, not background cache + delivery, and a subsequent random channel-window request while repair is held. +5. Release repair and require all held repair requests to finish. + +The strict regression fails on the original bundle (no foreground REQ while +repair is held), passes the candidate, and is registered in the existing smoke +`relay-reconnect.spec.ts`. Merely asserting visible message content was rejected: +background subscriptions can merge that content into the cache without this fix. +Window requests before the foreground REQ are also excluded from the assertion. + +Two controlled timing runs each, with repair released five seconds after the +measurement starts immediately before navigation: + +| Bundle | New foreground REQ | First window request after that REQ | +| --- | ---: | ---: | +| Original, run 1 | 5,004ms | 5,006ms | +| Original, run 2 | 5,003ms | 5,005ms | +| Candidate, run 1 | 41ms | 41ms | +| Candidate, run 2 | 40ms | 40ms | + +These timings prove removal of the injected dependency, **not measured savings +in the live native app**. They are not a benchmark of total channel rendering. + +## Correctness gates + +- 14 new tests bind the real RelayClient connection owner, AUTH and native repair + seam: before/after AUTH availability, finite-read EOSE and accepted-send OK, + rejected AUTH, current/stale replay failures, community replacement, cooldown + admission and retry ownership, and queued six-subscription recovery overlap. +- Removing each new subscription gate, retry gate, publisher retry gate or queued + worker fence in an isolated source copy makes its regression fail. Clean control + and restored control pass; the candidate working tree was not mutated. +- Full desktop suite: **6,545 passing** through the standard pre-push test lane. +- All **18 reconnect browser tests** pass, including dense missed-history replay + and shared repair reads; the final stricter window assertion also passes in + standard smoke discovery and fails the original bundle. +- Desktop check, TypeScript, production artifact matrix and file-size check pass. +- Independent read-only lifecycle review found two issues in the first slice; + cooldown admission and queued-worker ownership were fixed and re-reviewed. + +Run the regression with the repository Hermit environment activated: + +```sh +cd desktop +node --import ./test-loader.mjs --experimental-strip-types --test \ + src/shared/api/relayClientRecoveryAvailability.test.mjs +pnpm test:e2e:smoke relay-reconnect.spec.ts +``` + +## Boundaries + +- Fresh cold startup has no retained subscriptions; its native freeze is not + explained by this mechanism. +- Initial channel windows and thread pages use native HTTP directly. Their + latency is not removed here; the request-size cliff is a separate lead. +- Existing-channel refresh listeners still run after recovery, intentionally. +- Real relay/native UI timing and repository CI remain separate validation gates. +- No live cache edits, deployment or relay changes are part of this patch.