mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
## Summary - repair exact-channel reconnect gaps from a source-coupled 1,865-second floor (`900` seconds of relay future tolerance + `960` seconds of DB created-at floor + `5` seconds of fence margin) - page the existing authenticated `POST /query` bridge losslessly with `(until, before_id)`, including dense timestamp boundaries and an unbounded first page that does not trust the renderer clock - keep the restored live REQ unchanged while deduplicating live/repair overlap until both live EOSE and repair completion for the active connection generation ## Scope and behavior The wider repair applies only to live subscriptions containing the exact channel-event kind set and one `#h`. Profile, read-state, global, channel-less, and other subscription behavior is unchanged. Failed or stale repairs retain their pinned lower bound for the next reconnect. This intersects the replay path described in #3215, but does not claim to fix that issue. Desktop already has a bounded notification-side event-ID guard; this PR adds transport dispatch dedupe for the restored-live/repair overlap. Notification behavior should remain a focused regression check while this wider repair window rolls out. ## Testing - `cd desktop && pnpm typecheck` - `cd desktop && pnpm biome check src/shared/api/channelReconnectRepair.ts src/shared/api/relayClientSession.ts src/shared/api/relayClientShared.ts src/shared/api/relayClosedRecovery.ts src/shared/api/relayReconnectReplay.ts src/shared/api/relayReconnectReplay.test.mjs src/testing/e2eBridge.ts` - `cd desktop && node --test src/shared/api/relayReconnectReplay.test.mjs` (26 passed) - `cargo test --manifest-path desktop/src-tauri/Cargo.toml channel_reconnect_repair` (2 passed) - pre-push hooks passed at `743283439eb6be7969e19951cbb6720c5e493a04` --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
227 lines
6.6 KiB
TypeScript
227 lines
6.6 KiB
TypeScript
import { classifyRelayClosed } from "@/shared/api/relayClosedPolicy";
|
|
import {
|
|
activateRateLimit,
|
|
parseRateLimitHint,
|
|
rateLimitRemainingMs,
|
|
} from "@/shared/api/relayRateLimitGate";
|
|
import {
|
|
sortEvents,
|
|
type RelaySubscription,
|
|
type RelaySubscriptionFilter,
|
|
type SubscriptionEventBufferItem,
|
|
} from "@/shared/api/relayClientShared";
|
|
import type { RelayEvent } from "@/shared/api/types";
|
|
|
|
const RETRY_BASE_DELAY_MS = 1_000;
|
|
const RETRY_MAX_DELAY_MS = 30_000;
|
|
|
|
type LiveSubscription = Extract<RelaySubscription, { mode: "live" }>;
|
|
|
|
export function clearClosedRetry(subscription: LiveSubscription) {
|
|
if (subscription.closedRetryTimeout === undefined) return;
|
|
window.clearTimeout(subscription.closedRetryTimeout);
|
|
subscription.closedRetryTimeout = undefined;
|
|
}
|
|
|
|
export function handleRelayClosed({
|
|
subscriptions,
|
|
subId,
|
|
message,
|
|
sendReq,
|
|
}: {
|
|
subscriptions: Map<string, RelaySubscription>;
|
|
subId: string;
|
|
message: string;
|
|
sendReq: (subId: string, filter: RelaySubscriptionFilter) => Promise<void>;
|
|
}) {
|
|
const subscription = subscriptions.get(subId);
|
|
if (!subscription) return;
|
|
if (subscription.mode !== "live") {
|
|
// Classify before rejecting so a `rate-limited:` history CLOSED arms the
|
|
// gate for concurrent ops. A history sub can't be retried (the caller holds
|
|
// the promise), so we still reject immediately after arming.
|
|
const closedClass = classifyRelayClosed(message);
|
|
if (closedClass === "rate-limited") {
|
|
const hintSeconds = parseRateLimitHint(message);
|
|
activateRateLimit(hintSeconds);
|
|
}
|
|
window.clearTimeout(subscription.timeout);
|
|
subscriptions.delete(subId);
|
|
subscription.reject(
|
|
new Error(message || "Relay closed the history subscription."),
|
|
);
|
|
return;
|
|
}
|
|
recoverLiveSubscriptionFromClosed({
|
|
subscriptions,
|
|
subId,
|
|
subscription,
|
|
message,
|
|
sendReq,
|
|
});
|
|
}
|
|
|
|
function recoverLiveSubscriptionFromClosed({
|
|
subscriptions,
|
|
subId,
|
|
subscription,
|
|
message,
|
|
sendReq,
|
|
}: {
|
|
subscriptions: Map<string, RelaySubscription>;
|
|
subId: string;
|
|
subscription: LiveSubscription;
|
|
message: string;
|
|
sendReq: (subId: string, filter: RelaySubscriptionFilter) => Promise<void>;
|
|
}) {
|
|
subscription.resolveReady?.("closed");
|
|
subscription.resolveReady = undefined;
|
|
|
|
const closedClass = classifyRelayClosed(message);
|
|
|
|
if (closedClass === "terminal") {
|
|
// Auth/access/filter failure — permanently remove the subscription so it
|
|
// doesn't silently loop.
|
|
subscriptions.delete(subId);
|
|
return;
|
|
}
|
|
|
|
if (subscription.closedRetryTimeout !== undefined) return;
|
|
|
|
const attempt = subscription.closedRetryAttempt ?? 0;
|
|
const backoffMs = Math.min(
|
|
RETRY_BASE_DELAY_MS * 2 ** attempt,
|
|
RETRY_MAX_DELAY_MS,
|
|
);
|
|
|
|
let delayMs = backoffMs;
|
|
|
|
if (closedClass === "rate-limited") {
|
|
// Activate the gate so concurrent operations back off too.
|
|
const hintSeconds = parseRateLimitHint(message);
|
|
activateRateLimit(hintSeconds);
|
|
// Use the gate's actual remaining time so a shorter hint arriving under a
|
|
// longer active gate does not schedule a premature retry that just gets
|
|
// another CLOSED. The fallback covers the gate-inactive edge case
|
|
// (hint * 1000, or 10s default when no hint).
|
|
const fallbackMs = (hintSeconds ?? 10) * 1_000;
|
|
delayMs = Math.max(backoffMs, rateLimitRemainingMs() || fallbackMs);
|
|
}
|
|
|
|
subscription.closedRetryAttempt = attempt + 1;
|
|
subscription.closedRetryTimeout = window.setTimeout(() => {
|
|
subscription.closedRetryTimeout = undefined;
|
|
if (subscriptions.get(subId) !== subscription) return;
|
|
void sendReq(subId, subscription.filter).catch((error) => {
|
|
if (subscriptions.get(subId) !== subscription) return;
|
|
console.error("Failed to restore closed relay subscription", error);
|
|
recoverLiveSubscriptionFromClosed({
|
|
subscriptions,
|
|
subId,
|
|
subscription,
|
|
message,
|
|
sendReq,
|
|
});
|
|
});
|
|
}, delayMs);
|
|
}
|
|
|
|
export function prepareSubscriptionEvent(
|
|
subscription: RelaySubscription,
|
|
event: RelayEvent,
|
|
) {
|
|
if (subscription.mode === "history") {
|
|
subscription.events.push(event);
|
|
return false;
|
|
}
|
|
if (subscription.mode === "first") {
|
|
return false;
|
|
}
|
|
subscription.closedRetryAttempt = 0;
|
|
clearClosedRetry(subscription);
|
|
subscription.lastSeenCreatedAt = Math.max(
|
|
subscription.lastSeenCreatedAt ?? 0,
|
|
event.created_at,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
export function shouldDispatchSubscriptionEvent(
|
|
subscription: Extract<RelaySubscription, { mode: "live" }>,
|
|
event: RelayEvent,
|
|
) {
|
|
const replay = subscription.reconnectReplay;
|
|
if (replay?.seenEventIds.has(event.id)) return false;
|
|
replay?.seenEventIds.add(event.id);
|
|
return true;
|
|
}
|
|
|
|
export function flushEvents(
|
|
buffer: SubscriptionEventBufferItem[],
|
|
subscriptions: Map<string, RelaySubscription>,
|
|
generation: number,
|
|
) {
|
|
for (const item of buffer) {
|
|
const subscription = subscriptions.get(item.subId);
|
|
if (
|
|
subscription?.mode === "live" &&
|
|
item.generation === generation &&
|
|
shouldDispatchSubscriptionEvent(subscription, item.event)
|
|
) {
|
|
subscription.onEvent(item.event);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function markReconnectLiveEose(
|
|
subscription: Extract<RelaySubscription, { mode: "live" }>,
|
|
generation: number,
|
|
) {
|
|
const replay = subscription.reconnectReplay;
|
|
if (!replay || replay.generation !== generation) return;
|
|
replay.liveEose = true;
|
|
if (replay.repairDone) subscription.reconnectReplay = undefined;
|
|
}
|
|
|
|
export function markReconnectRepairDone(
|
|
subscription: Extract<RelaySubscription, { mode: "live" }>,
|
|
generation: number,
|
|
) {
|
|
const replay = subscription.reconnectReplay;
|
|
if (!replay || replay.generation !== generation) return;
|
|
replay.repairDone = true;
|
|
if (replay.liveEose) subscription.reconnectReplay = undefined;
|
|
}
|
|
|
|
export function handleSubscriptionEose({
|
|
subscriptions,
|
|
subId,
|
|
closeSubscription,
|
|
generation,
|
|
}: {
|
|
subscriptions: Map<string, RelaySubscription>;
|
|
subId: string;
|
|
closeSubscription: (subId: string) => Promise<void>;
|
|
generation?: number;
|
|
}) {
|
|
const subscription = subscriptions.get(subId);
|
|
if (!subscription) return;
|
|
if (subscription.mode === "live") {
|
|
if (generation !== undefined)
|
|
markReconnectLiveEose(subscription, generation);
|
|
subscription.resolveReady?.("eose");
|
|
subscription.resolveReady = undefined;
|
|
subscription.closedRetryAttempt = 0;
|
|
clearClosedRetry(subscription);
|
|
return;
|
|
}
|
|
window.clearTimeout(subscription.timeout);
|
|
subscriptions.delete(subId);
|
|
void closeSubscription(subId);
|
|
if (subscription.mode === "first") {
|
|
subscription.resolve(null);
|
|
} else {
|
|
subscription.resolve(sortEvents(subscription.events));
|
|
}
|
|
}
|