mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +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>
158 lines
4.7 KiB
TypeScript
158 lines
4.7 KiB
TypeScript
import type { RelayEvent } from "@/shared/api/types";
|
|
|
|
/**
|
|
* Observable connection state for the relay singleton.
|
|
*
|
|
* - `idle` — never tried to connect yet (post-init, pre-community).
|
|
* - `connecting` — initial socket + AUTH handshake in flight.
|
|
* - `connected` — socket open and AUTH'd.
|
|
* - `reconnecting` — socket dropped, waiting for the backoff timer.
|
|
* - `stalled` — socket is *open* per the WS layer but no inbound frames
|
|
* for a long time (half-open socket / VPN split-brain). We
|
|
* surface this so the UI can warn even though tungstenite
|
|
* hasn't reported anything wrong yet.
|
|
* - `disconnected` — final/terminal disconnect (auth rejected, community
|
|
* switch, etc.) — no auto-reconnect scheduled.
|
|
*/
|
|
export type ConnectionState =
|
|
| "idle"
|
|
| "connecting"
|
|
| "connected"
|
|
| "reconnecting"
|
|
| "stalled"
|
|
| "disconnected";
|
|
|
|
/** True when the UI should surface a "connection lost" indicator. */
|
|
export function isRelayConnectionDegraded(state: ConnectionState): boolean {
|
|
return (
|
|
state === "reconnecting" || state === "stalled" || state === "disconnected"
|
|
);
|
|
}
|
|
|
|
export type RelaySubscriptionFilter = {
|
|
ids?: string[];
|
|
kinds: number[];
|
|
limit: number;
|
|
authors?: string[];
|
|
since?: number;
|
|
until?: number;
|
|
} & Partial<Record<`#${string}`, string[]>>;
|
|
|
|
type HistorySubscription = {
|
|
mode: "history";
|
|
events: RelayEvent[];
|
|
resolve: (events: RelayEvent[]) => void;
|
|
reject: (error: Error) => void;
|
|
timeout: number;
|
|
};
|
|
|
|
type FirstEventSubscription = {
|
|
mode: "first";
|
|
onEvent: (event: RelayEvent) => void;
|
|
resolve: (event: RelayEvent | null) => void;
|
|
reject: (error: Error) => void;
|
|
timeout: number;
|
|
};
|
|
|
|
export type LiveSubscriptionReadiness = "eose" | "closed" | "timeout";
|
|
|
|
type LiveSubscription = {
|
|
mode: "live";
|
|
filter: RelaySubscriptionFilter;
|
|
onEvent: (event: RelayEvent) => void;
|
|
resolveReady?: (readiness: LiveSubscriptionReadiness) => void;
|
|
lastSeenCreatedAt?: number;
|
|
/**
|
|
* Lower bound of a reconnect backfill window that has not yet completed.
|
|
*
|
|
* Events on the restored live REQ advance `lastSeenCreatedAt` regardless of
|
|
* backfill success, so after an exhausted backfill the cursor alone would
|
|
* make the next reconnect skip the unresolved older window — silent message
|
|
* loss. This floor is pinned when paging starts and cleared only when a
|
|
* backfill pass completes; the next replay starts from
|
|
* `min(pendingReplaySince, cursor window)`.
|
|
*/
|
|
pendingReplaySince?: number;
|
|
/** Dispatch-level duplicate suppression shared by restored-live and repair. */
|
|
reconnectReplay?: {
|
|
generation: number;
|
|
seenEventIds: Set<string>;
|
|
liveEose: boolean;
|
|
repairDone: boolean;
|
|
};
|
|
closedRetryAttempt?: number;
|
|
closedRetryTimeout?: number;
|
|
};
|
|
|
|
export type PendingEvent = {
|
|
event: RelayEvent;
|
|
resolve: (event: RelayEvent) => void;
|
|
reject: (error: Error) => void;
|
|
timeout: number;
|
|
};
|
|
|
|
export type RelaySubscription =
|
|
| HistorySubscription
|
|
| FirstEventSubscription
|
|
| LiveSubscription;
|
|
|
|
export type SubscriptionEventBufferItem = {
|
|
subId: string;
|
|
event: RelayEvent;
|
|
generation: number;
|
|
};
|
|
|
|
export function sortEvents(events: RelayEvent[]) {
|
|
return [...events].sort((left, right) => {
|
|
if (left.created_at !== right.created_at) {
|
|
return left.created_at - right.created_at;
|
|
}
|
|
// Same (created_at, id) tiebreak as the cache sort (sortMessages) so a
|
|
// history REQ resolves same-second events in a stable, relay-matching
|
|
// order. Currently every consumer re-sorts downstream, but keeping the
|
|
// two sorts on one invariant avoids a latent ordering drift.
|
|
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Splits a native websocket delivery into individual frames.
|
|
*
|
|
* The native plugin coalesces inbound frames into one IPC delivery, so a
|
|
* delivery is an array of frames. Older single-frame deliveries stay valid:
|
|
* `getTextPayload` rejects arrays, so every consumer must unwrap here rather
|
|
* than per-client — an un-unwrapped consumer drops batched frames silently
|
|
* instead of failing.
|
|
*/
|
|
export function toRelayFrames(message: unknown): unknown[] {
|
|
return Array.isArray(message) ? message : [message];
|
|
}
|
|
|
|
export function getTextPayload(message: unknown) {
|
|
if (typeof message === "string") {
|
|
return message;
|
|
}
|
|
|
|
if (
|
|
typeof message === "object" &&
|
|
message !== null &&
|
|
"type" in message &&
|
|
message.type === "Text" &&
|
|
"data" in message &&
|
|
typeof message.data === "string"
|
|
) {
|
|
return message.data;
|
|
}
|
|
|
|
if (
|
|
typeof message === "object" &&
|
|
message !== null &&
|
|
"Text" in message &&
|
|
typeof message.Text === "string"
|
|
) {
|
|
return message.Text;
|
|
}
|
|
|
|
return null;
|
|
}
|