mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
fix(desktop): release foreground work after reconnect authentication
Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
This commit is contained in:
439
desktop/src/shared/api/relayClientRecoveryAvailability.test.mjs
Normal file
439
desktop/src/shared/api/relayClientRecoveryAvailability.test.mjs
Normal file
@@ -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]);
|
||||
}
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function publishSessionEvent(
|
||||
|
||||
try {
|
||||
retryGeneration = await session.reconnect();
|
||||
await waitForRateLimit();
|
||||
if (
|
||||
publishOwnership !== session.ownership() ||
|
||||
session.generation() !== retryGeneration ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<unknown>;
|
||||
};
|
||||
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<void>((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);
|
||||
});
|
||||
|
||||
86
perf/RECONNECT_FOREGROUND_AVAILABILITY.md
Normal file
86
perf/RECONNECT_FOREGROUND_AVAILABILITY.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user