Files
buzz/desktop/src/shared/api/relayClosedRecovery.ts
Wes 06e8be9098 fix(desktop): make reconnect repair lossless (#6415)
## 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>
2026-08-20 17:13:47 -06:00

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));
}
}