mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(relay): surface connection loss in the workspace header (#623)
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -43,7 +43,7 @@ const overrides = new Map([
|
||||
["src/features/messages/ui/MessageComposer.tsx", 710], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572)
|
||||
["src/features/settings/ui/SettingsView.tsx", 600],
|
||||
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
|
||||
["src/shared/api/relayClientSession.ts", 930], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38)
|
||||
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
|
||||
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
|
||||
["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission
|
||||
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Check, ChevronDown, MoreHorizontal, Plus } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
@@ -14,9 +20,24 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/shared/ui/sidebar";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
||||
import {
|
||||
isRelayConnectionDegraded,
|
||||
useRelayConnection,
|
||||
} from "@/shared/api/useRelayConnection";
|
||||
|
||||
import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
|
||||
|
||||
const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
|
||||
idle: "Not connected",
|
||||
connecting: "Connecting…",
|
||||
connected: "Connected",
|
||||
reconnecting: "Reconnecting to relay…",
|
||||
stalled: "Connection lost — relay is not responding",
|
||||
disconnected: "Disconnected from relay",
|
||||
};
|
||||
|
||||
type WorkspaceSwitcherProps = {
|
||||
activeWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
@@ -40,6 +61,9 @@ export function WorkspaceSwitcher({
|
||||
const [editingWorkspace, setEditingWorkspace] =
|
||||
React.useState<Workspace | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
||||
const connectionState = useRelayConnection();
|
||||
const degraded = isRelayConnectionDegraded(connectionState);
|
||||
const connectionLabel = CONNECTION_STATE_LABEL[connectionState];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -48,14 +72,43 @@ export function WorkspaceSwitcher({
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
aria-label={
|
||||
degraded
|
||||
? `${activeWorkspace?.name ?? "Workspace"} — ${connectionLabel}`
|
||||
: undefined
|
||||
}
|
||||
className="h-auto gap-2 rounded-xl px-2.5 py-2 data-[state=open]:bg-sidebar-accent"
|
||||
data-testid="workspace-switcher"
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs leading-none">
|
||||
🌱
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{degraded ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-hidden="false"
|
||||
className="flex h-5 w-5 shrink-0 animate-pulse items-center justify-center text-destructive"
|
||||
data-testid="relay-connection-warning"
|
||||
role="img"
|
||||
>
|
||||
<WifiOff className="h-4 w-4" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{connectionLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs leading-none">
|
||||
🌱
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
degraded
|
||||
? "min-w-0 flex-1 truncate text-sm font-medium text-destructive animate-pulse"
|
||||
: "min-w-0 flex-1 truncate text-sm font-medium"
|
||||
}
|
||||
>
|
||||
{activeWorkspace?.name ?? "No workspace"}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50" />
|
||||
|
||||
@@ -16,10 +16,17 @@ import {
|
||||
import {
|
||||
getTextPayload,
|
||||
sortEvents,
|
||||
type ConnectionState,
|
||||
type PendingEvent,
|
||||
type RelaySubscription,
|
||||
type RelaySubscriptionFilter,
|
||||
} from "@/shared/api/relayClientShared";
|
||||
import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEmitter";
|
||||
import {
|
||||
shouldRefuseConnect,
|
||||
shouldScheduleReconnect,
|
||||
} from "@/shared/api/relayReconnectPolicy";
|
||||
import { RelayStallWatchdog } from "@/shared/api/relayStallWatchdog";
|
||||
import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
|
||||
|
||||
const RECONNECT_BASE_DELAY_MS = 1_000,
|
||||
@@ -27,6 +34,25 @@ const RECONNECT_BASE_DELAY_MS = 1_000,
|
||||
const RECONNECT_REPLAY_SKEW_SECS = 5,
|
||||
EVENT_BATCH_MS = 16;
|
||||
|
||||
/**
|
||||
* Application-level liveness probe.
|
||||
*
|
||||
* Tungstenite auto-pongs and the OS keeps the TCP socket open, so a
|
||||
* half-open WS (Warp's orange-icon state, an asleep VPN, etc.) presents as
|
||||
* "fully connected" to the WS layer indefinitely — no Close, no Error.
|
||||
*
|
||||
* We work around that by periodically sending a cheap NIP-01 `REQ` with
|
||||
* `limit: 0` and waiting for the matching `EOSE`. A single missed probe
|
||||
* (no EOSE within `STALL_PROBE_TIMEOUT_MS`) — or a send-side failure on the
|
||||
* probe itself — flips state to `stalled` and force-resets the socket so
|
||||
* the existing reconnect path runs.
|
||||
*
|
||||
* The filter intentionally matches nothing real so the relay only ever
|
||||
* answers with EOSE.
|
||||
*/
|
||||
const STALL_PROBE_INTERVAL_MS = 20_000;
|
||||
const STALL_PROBE_TIMEOUT_MS = 10_000;
|
||||
|
||||
export class RelayClient {
|
||||
private wsId: number | null = null;
|
||||
private relayUrl: string | null = null;
|
||||
@@ -49,6 +75,29 @@ export class RelayClient {
|
||||
private notifyReconnectListeners = false;
|
||||
private onMessageChannel: Channel<unknown> | null = null;
|
||||
|
||||
/**
|
||||
* Sticky terminal flag. Set when `resetConnection` is called with
|
||||
* `reconnect: false` (today: auth rejection). Acts as a hard guard against
|
||||
* the reconnect-timer / retry-wrapper paths racing back to "reconnecting"
|
||||
* after we've already declared the session dead.
|
||||
*
|
||||
* Cleared only on explicit user re-engagement: `disconnect()` (workspace
|
||||
* switch — the singleton is being reused for a different workspace) and
|
||||
* `preconnect()` (caller is asking us to come back up).
|
||||
*/
|
||||
private terminal = false;
|
||||
|
||||
private connectionStateEmitter = new RelayConnectionStateEmitter("idle");
|
||||
private stallWatchdog = new RelayStallWatchdog({
|
||||
intervalMs: STALL_PROBE_INTERVAL_MS,
|
||||
probeTimeoutMs: STALL_PROBE_TIMEOUT_MS,
|
||||
sendRaw: (payload) => this.sendRaw(payload),
|
||||
onStall: (error) => {
|
||||
this.connectionStateEmitter.set("stalled");
|
||||
this.resetConnection(error);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Cleanly tear down the connection without scheduling a reconnect.
|
||||
* Used during workspace switches to reset the singleton before the
|
||||
@@ -61,10 +110,13 @@ export class RelayClient {
|
||||
window.clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
this.stallWatchdog.stop();
|
||||
this.keepAliveRequested = false;
|
||||
this.relayUrl = null;
|
||||
this.hasConnectedOnce = false;
|
||||
this.notifyReconnectListeners = false;
|
||||
this.terminal = false;
|
||||
this.connectionStateEmitter.set("idle");
|
||||
|
||||
if (this.wsId !== null) {
|
||||
void invoke("plugin:websocket|disconnect", { id: this.wsId }).catch(
|
||||
@@ -101,6 +153,7 @@ export class RelayClient {
|
||||
}
|
||||
this.eventBuffer = [];
|
||||
this.reconnectListeners.clear();
|
||||
this.connectionStateEmitter.clear();
|
||||
this.onMessageChannel = null;
|
||||
this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
|
||||
}
|
||||
@@ -334,6 +387,9 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
async preconnect() {
|
||||
// Explicit re-engagement. If the session went terminal (auth rejection)
|
||||
// the caller is asking us to try again, so clear the latch.
|
||||
this.terminal = false;
|
||||
this.keepAliveRequested = true;
|
||||
await this.ensureConnected();
|
||||
}
|
||||
@@ -346,7 +402,30 @@ export class RelayClient {
|
||||
};
|
||||
}
|
||||
|
||||
/** Current connection state — synchronous read. */
|
||||
getConnectionState(): ConnectionState {
|
||||
return this.connectionStateEmitter.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to connection-state transitions. The listener is invoked
|
||||
* immediately with the current state so callers don't need a separate
|
||||
* `getConnectionState()` call to seed their UI.
|
||||
*/
|
||||
subscribeToConnectionState(listener: (state: ConnectionState) => void) {
|
||||
return this.connectionStateEmitter.subscribe(listener);
|
||||
}
|
||||
|
||||
private async ensureConnected() {
|
||||
if (shouldRefuseConnect({ terminal: this.terminal })) {
|
||||
// Session is terminal (e.g. relay rejected auth). Refuse to connect
|
||||
// until an explicit re-engagement (disconnect()/preconnect()) clears
|
||||
// the flag. Without this, the reconnect timer's catch handler — and
|
||||
// the retry wrappers in publishEvent / sendRawWithReconnectRetry —
|
||||
// would race the terminal "disconnected" state back to "reconnecting".
|
||||
throw new Error("Relay session is terminal; cannot reconnect.");
|
||||
}
|
||||
|
||||
if (this.connectPromise) {
|
||||
return this.connectPromise;
|
||||
}
|
||||
@@ -373,6 +452,10 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
private async connect() {
|
||||
this.connectionStateEmitter.set(
|
||||
this.hasConnectedOnce ? "reconnecting" : "connecting",
|
||||
);
|
||||
|
||||
if (!this.relayUrl) {
|
||||
this.relayUrl = await getRelayWsUrl();
|
||||
}
|
||||
@@ -406,6 +489,8 @@ export class RelayClient {
|
||||
|
||||
this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
|
||||
await this.replayLiveSubscriptions();
|
||||
this.connectionStateEmitter.set("connected");
|
||||
this.stallWatchdog.start();
|
||||
this.emitReconnectIfNeeded();
|
||||
}
|
||||
|
||||
@@ -724,6 +809,12 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
private handleEose(subId: string) {
|
||||
if (this.stallWatchdog.handleEose(subId)) {
|
||||
// Probe round-trip succeeded — silently CLOSE the sub.
|
||||
void this.closeSubscription(subId).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = this.subscriptions.get(subId);
|
||||
if (!subscription) {
|
||||
return;
|
||||
@@ -827,9 +918,13 @@ export class RelayClient {
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (
|
||||
this.reconnectTimeout ||
|
||||
this.wsId !== null ||
|
||||
(!this.keepAliveRequested && !this.hasLiveSubscriptions())
|
||||
!shouldScheduleReconnect({
|
||||
terminal: this.terminal,
|
||||
hasPendingReconnect: this.reconnectTimeout !== null,
|
||||
hasLiveSocket: this.wsId !== null,
|
||||
keepAliveRequested: this.keepAliveRequested,
|
||||
hasLiveSubscriptions: this.hasLiveSubscriptions(),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -875,10 +970,20 @@ export class RelayClient {
|
||||
},
|
||||
) {
|
||||
this.onMessageChannel = null;
|
||||
this.stallWatchdog.stop();
|
||||
if (this.flushTimeout !== null) window.clearTimeout(this.flushTimeout);
|
||||
this.flushTimeout = null;
|
||||
this.eventBuffer = [];
|
||||
|
||||
if (options?.reconnect === false) {
|
||||
this.terminal = true;
|
||||
this.connectionStateEmitter.set("disconnected");
|
||||
} else if (this.connectionStateEmitter.get() !== "stalled") {
|
||||
// Stall is a stronger signal than a generic drop; keep it until the
|
||||
// reconnect timer transitions us back to "reconnecting" in connect().
|
||||
this.connectionStateEmitter.set("reconnecting");
|
||||
}
|
||||
|
||||
if (options?.reconnect !== false && this.hasConnectedOnce) {
|
||||
this.notifyReconnectListeners = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isRelayConnectionDegraded } from "./relayClientShared.ts";
|
||||
|
||||
test("isRelayConnectionDegraded — healthy states are not degraded", () => {
|
||||
assert.equal(isRelayConnectionDegraded("idle"), false);
|
||||
assert.equal(isRelayConnectionDegraded("connecting"), false);
|
||||
assert.equal(isRelayConnectionDegraded("connected"), false);
|
||||
});
|
||||
|
||||
test("isRelayConnectionDegraded — non-healthy states are degraded", () => {
|
||||
assert.equal(isRelayConnectionDegraded("reconnecting"), true);
|
||||
assert.equal(isRelayConnectionDegraded("stalled"), true);
|
||||
assert.equal(isRelayConnectionDegraded("disconnected"), true);
|
||||
});
|
||||
@@ -1,5 +1,34 @@
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
|
||||
/**
|
||||
* Observable connection state for the relay singleton.
|
||||
*
|
||||
* - `idle` — never tried to connect yet (post-init, pre-workspace).
|
||||
* - `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 / Warp 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, workspace
|
||||
* 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 = {
|
||||
kinds: number[];
|
||||
limit: number;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { RelayConnectionStateEmitter } from "./relayConnectionStateEmitter.ts";
|
||||
|
||||
test("starts in the constructor-provided state", () => {
|
||||
const e = new RelayConnectionStateEmitter("connecting");
|
||||
assert.equal(e.get(), "connecting");
|
||||
});
|
||||
|
||||
test("defaults to 'idle' when no initial state given", () => {
|
||||
const e = new RelayConnectionStateEmitter();
|
||||
assert.equal(e.get(), "idle");
|
||||
});
|
||||
|
||||
test("set() notifies subscribers and updates state", () => {
|
||||
const e = new RelayConnectionStateEmitter("connecting");
|
||||
const seen = [];
|
||||
e.subscribe((s) => seen.push(s));
|
||||
// Initial replay: subscriber sees current state.
|
||||
assert.deepEqual(seen, ["connecting"]);
|
||||
|
||||
e.set("connected");
|
||||
assert.deepEqual(seen, ["connecting", "connected"]);
|
||||
assert.equal(e.get(), "connected");
|
||||
});
|
||||
|
||||
test("set() is a no-op when state is unchanged", () => {
|
||||
const e = new RelayConnectionStateEmitter("connected");
|
||||
const seen = [];
|
||||
e.subscribe((s) => seen.push(s));
|
||||
assert.deepEqual(seen, ["connected"]);
|
||||
|
||||
e.set("connected");
|
||||
// No duplicate emission.
|
||||
assert.deepEqual(seen, ["connected"]);
|
||||
});
|
||||
|
||||
test("unsubscribe stops further notifications", () => {
|
||||
const e = new RelayConnectionStateEmitter("idle");
|
||||
const seen = [];
|
||||
const unsub = e.subscribe((s) => seen.push(s));
|
||||
unsub();
|
||||
e.set("connecting");
|
||||
assert.deepEqual(seen, ["idle"]);
|
||||
});
|
||||
|
||||
test("listener exceptions do not break other listeners", () => {
|
||||
const e = new RelayConnectionStateEmitter("idle");
|
||||
const seenA = [];
|
||||
const seenB = [];
|
||||
// Quiet the expected error log.
|
||||
const originalError = console.error;
|
||||
const errors = [];
|
||||
console.error = (...args) => errors.push(args);
|
||||
try {
|
||||
e.subscribe(() => {
|
||||
throw new Error("boom A");
|
||||
});
|
||||
e.subscribe((s) => seenA.push(s));
|
||||
e.subscribe((s) => seenB.push(s));
|
||||
e.set("connected");
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
assert.deepEqual(seenA, ["idle", "connected"]);
|
||||
assert.deepEqual(seenB, ["idle", "connected"]);
|
||||
// One during subscribe-replay, one during the set() emit.
|
||||
assert.ok(errors.length >= 2, "expected console.error to be called");
|
||||
});
|
||||
|
||||
test("clear() drops listeners", () => {
|
||||
const e = new RelayConnectionStateEmitter("idle");
|
||||
const seen = [];
|
||||
e.subscribe((s) => seen.push(s));
|
||||
e.clear();
|
||||
e.set("connecting");
|
||||
assert.deepEqual(seen, ["idle"]);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
||||
|
||||
/**
|
||||
* Small observable for `ConnectionState`. Kept separate from the relay client
|
||||
* so the file-size budget stays sane and so the contract is unit-testable
|
||||
* without dragging the whole session manager into scope.
|
||||
*
|
||||
* Semantics:
|
||||
* - `set(next)` is a no-op if `next === current` (no duplicate events).
|
||||
* - New subscribers receive the current state immediately (synchronously),
|
||||
* so React hooks don't need a separate "getState" call to seed their UI.
|
||||
* - Listener exceptions are caught and logged, never propagated back into
|
||||
* the caller of `set`.
|
||||
*/
|
||||
export class RelayConnectionStateEmitter {
|
||||
private state: ConnectionState;
|
||||
private listeners = new Set<(state: ConnectionState) => void>();
|
||||
|
||||
constructor(initial: ConnectionState = "idle") {
|
||||
this.state = initial;
|
||||
}
|
||||
|
||||
get(): ConnectionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
set(next: ConnectionState): void {
|
||||
if (this.state === next) {
|
||||
return;
|
||||
}
|
||||
this.state = next;
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(next);
|
||||
} catch (error) {
|
||||
console.error("Failed to deliver relay connection state", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (state: ConnectionState) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
try {
|
||||
listener(this.state);
|
||||
} catch (error) {
|
||||
console.error("Failed to deliver initial relay connection state", error);
|
||||
}
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Drop all listeners. Called on workspace teardown to match the legacy
|
||||
* pattern used for the reconnect listener set. */
|
||||
clear(): void {
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
shouldRefuseConnect,
|
||||
shouldScheduleReconnect,
|
||||
} from "./relayReconnectPolicy.ts";
|
||||
|
||||
// The "happy" baseline that *should* schedule a reconnect: not terminal,
|
||||
// no pending timer, no live socket, and at least something keeping the
|
||||
// session alive (a live subscription, in this case).
|
||||
const baseline = Object.freeze({
|
||||
terminal: false,
|
||||
hasPendingReconnect: false,
|
||||
hasLiveSocket: false,
|
||||
keepAliveRequested: false,
|
||||
hasLiveSubscriptions: true,
|
||||
});
|
||||
|
||||
test("baseline scenario schedules a reconnect", () => {
|
||||
assert.equal(shouldScheduleReconnect({ ...baseline }), true);
|
||||
});
|
||||
|
||||
test("terminal session refuses to schedule (Max's auth-rejection scenario)", () => {
|
||||
assert.equal(shouldScheduleReconnect({ ...baseline, terminal: true }), false);
|
||||
});
|
||||
|
||||
test("terminal beats every other reason to reconnect", () => {
|
||||
// Even with every "yes please reconnect" predicate flipped on, terminal
|
||||
// wins. This is the critical guarantee against the reconnect timer's
|
||||
// catch handler resurrecting a dead session.
|
||||
assert.equal(
|
||||
shouldScheduleReconnect({
|
||||
terminal: true,
|
||||
hasPendingReconnect: false,
|
||||
hasLiveSocket: false,
|
||||
keepAliveRequested: true,
|
||||
hasLiveSubscriptions: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("pending reconnect timer suppresses scheduling another", () => {
|
||||
assert.equal(
|
||||
shouldScheduleReconnect({ ...baseline, hasPendingReconnect: true }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("live socket suppresses scheduling", () => {
|
||||
assert.equal(
|
||||
shouldScheduleReconnect({ ...baseline, hasLiveSocket: true }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("no live subs and no keep-alive → don't keep an idle socket up", () => {
|
||||
assert.equal(
|
||||
shouldScheduleReconnect({
|
||||
...baseline,
|
||||
hasLiveSubscriptions: false,
|
||||
keepAliveRequested: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("keep-alive alone is enough to schedule", () => {
|
||||
assert.equal(
|
||||
shouldScheduleReconnect({
|
||||
...baseline,
|
||||
hasLiveSubscriptions: false,
|
||||
keepAliveRequested: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldRefuseConnect mirrors terminal", () => {
|
||||
assert.equal(shouldRefuseConnect({ terminal: false }), false);
|
||||
assert.equal(shouldRefuseConnect({ terminal: true }), true);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Pure helpers for the relay reconnect policy.
|
||||
*
|
||||
* Extracted from `RelayClient` so the decision rules — when to schedule a
|
||||
* reconnect, when to refuse to connect — live in one legible place that
|
||||
* unit tests can reach without booting the WS layer.
|
||||
*
|
||||
* The rules:
|
||||
*
|
||||
* - **Terminal sessions never reconnect.** When the relay has explicitly
|
||||
* rejected us (today: kind:22242 AUTH OK=false) the session is dead
|
||||
* until the user re-engages (workspace switch or explicit preconnect).
|
||||
* This guards the reconnect-timer catch handler — and the retry wrappers
|
||||
* in `publishEvent` / `sendRawWithReconnectRetry` — from racing the
|
||||
* `disconnected` state back to `reconnecting`.
|
||||
*
|
||||
* - **No-op when a reconnect is already scheduled or in progress.**
|
||||
* A pending timer or a live `wsId` means we have nothing to do.
|
||||
*
|
||||
* - **No reconnect needed when nothing wants the socket.** No live
|
||||
* subscription, no `keepAliveRequested` from `preconnect()` → don't
|
||||
* keep an idle socket.
|
||||
*/
|
||||
export type RelayReconnectInputs = {
|
||||
terminal: boolean;
|
||||
hasPendingReconnect: boolean;
|
||||
hasLiveSocket: boolean;
|
||||
keepAliveRequested: boolean;
|
||||
hasLiveSubscriptions: boolean;
|
||||
};
|
||||
|
||||
export function shouldScheduleReconnect(inputs: RelayReconnectInputs): boolean {
|
||||
if (inputs.terminal) return false;
|
||||
if (inputs.hasPendingReconnect) return false;
|
||||
if (inputs.hasLiveSocket) return false;
|
||||
if (!inputs.keepAliveRequested && !inputs.hasLiveSubscriptions) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether `ensureConnected()` should refuse with a terminal error. */
|
||||
export function shouldRefuseConnect(inputs: { terminal: boolean }): boolean {
|
||||
return inputs.terminal;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { RelayStallWatchdog } from "./relayStallWatchdog.ts";
|
||||
|
||||
// Shim `window` to expose the timer + crypto APIs the watchdog uses. The
|
||||
// real RelayClient runs in a Tauri WebView where `window` exists; under
|
||||
// node:test we wire it to the same globals.
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {
|
||||
setInterval: (...args) => setInterval(...args),
|
||||
clearInterval: (id) => clearInterval(id),
|
||||
setTimeout: (...args) => setTimeout(...args),
|
||||
clearTimeout: (id) => clearTimeout(id),
|
||||
};
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function makeWatchdog(overrides = {}) {
|
||||
const sends = [];
|
||||
const stalls = [];
|
||||
const wd = new RelayStallWatchdog({
|
||||
intervalMs: overrides.intervalMs ?? 30,
|
||||
probeTimeoutMs: overrides.probeTimeoutMs ?? 30,
|
||||
sendRaw:
|
||||
overrides.sendRaw ??
|
||||
(async (payload) => {
|
||||
sends.push(payload);
|
||||
}),
|
||||
onStall: (err) => {
|
||||
stalls.push(err);
|
||||
},
|
||||
now: overrides.now,
|
||||
});
|
||||
return { wd, sends, stalls };
|
||||
}
|
||||
|
||||
test("first probe carries the expected NIP-01 REQ shape", async () => {
|
||||
const { wd, sends } = makeWatchdog();
|
||||
wd.start();
|
||||
// Wait until a probe is observed.
|
||||
for (let i = 0; i < 50 && sends.length === 0; i++) await sleep(5);
|
||||
wd.stop();
|
||||
assert.equal(sends.length, 1);
|
||||
const [verb, subId, filter] = sends[0];
|
||||
assert.equal(verb, "REQ");
|
||||
assert.match(subId, /^probe-/);
|
||||
assert.deepEqual(filter.kinds, [9999]);
|
||||
assert.equal(filter.limit, 0);
|
||||
assert.ok(typeof filter.since === "number");
|
||||
});
|
||||
|
||||
test("EOSE for the current probe clears in-flight + lets the next probe fire", async () => {
|
||||
const { wd, sends, stalls } = makeWatchdog();
|
||||
wd.start();
|
||||
for (let i = 0; i < 50 && sends.length === 0; i++) await sleep(5);
|
||||
const firstSubId = sends[0][1];
|
||||
// Resolve the probe.
|
||||
assert.equal(wd.handleEose(firstSubId), true);
|
||||
// Within the next interval+probe window, another probe should fire.
|
||||
for (let i = 0; i < 50 && sends.length < 2; i++) await sleep(5);
|
||||
wd.stop();
|
||||
assert.ok(sends.length >= 2, `expected ≥2 probes, got ${sends.length}`);
|
||||
assert.equal(stalls.length, 0, "no stall expected when EOSE arrives");
|
||||
});
|
||||
|
||||
test("EOSE for a non-probe subId returns false", () => {
|
||||
const { wd } = makeWatchdog();
|
||||
assert.equal(wd.handleEose("live-abc"), false);
|
||||
});
|
||||
|
||||
test("timeout without EOSE triggers onStall", async () => {
|
||||
const { wd, stalls } = makeWatchdog();
|
||||
wd.start();
|
||||
// intervalMs (30) before first send + probeTimeoutMs (30) — wait a bit
|
||||
// past their sum.
|
||||
for (let i = 0; i < 50 && stalls.length === 0; i++) await sleep(10);
|
||||
wd.stop();
|
||||
assert.ok(stalls.length >= 1, "expected at least one stall");
|
||||
assert.match(stalls[0].message, /stalled/i);
|
||||
});
|
||||
|
||||
test("send-side failure triggers onStall immediately", async () => {
|
||||
const { wd, stalls } = makeWatchdog({
|
||||
sendRaw: async () => {
|
||||
throw new Error("ws is dead");
|
||||
},
|
||||
});
|
||||
wd.start();
|
||||
for (let i = 0; i < 50 && stalls.length === 0; i++) await sleep(5);
|
||||
wd.stop();
|
||||
assert.ok(stalls.length >= 1, "expected stall on send failure");
|
||||
assert.match(stalls[0].message, /ws is dead/);
|
||||
});
|
||||
|
||||
test("stop() cancels a pending stall timeout", async () => {
|
||||
const { wd, sends, stalls } = makeWatchdog();
|
||||
wd.start();
|
||||
for (let i = 0; i < 50 && sends.length === 0; i++) await sleep(5);
|
||||
// Probe is in-flight; stop before it can time out.
|
||||
wd.stop();
|
||||
// Wait well past the timeout window.
|
||||
await sleep(80);
|
||||
assert.equal(stalls.length, 0, "stop() should cancel the pending stall");
|
||||
});
|
||||
|
||||
test("start() is idempotent — does not create duplicate intervals", async () => {
|
||||
const { wd, sends } = makeWatchdog({ intervalMs: 25, probeTimeoutMs: 200 });
|
||||
wd.start();
|
||||
wd.start();
|
||||
wd.start();
|
||||
// Allow one probe to fire and resolve it so the *next* probe can fire if
|
||||
// the interval was somehow doubled.
|
||||
for (let i = 0; i < 50 && sends.length === 0; i++) await sleep(5);
|
||||
wd.handleEose(sends[0][1]);
|
||||
// Within one more interval window, exactly one more probe should fire
|
||||
// (not two), which is the contract for `start()` being idempotent.
|
||||
await sleep(45);
|
||||
wd.stop();
|
||||
assert.ok(
|
||||
sends.length <= 2,
|
||||
`expected ≤2 probes despite triple-start(), got ${sends.length}`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Application-level liveness probe for the relay WebSocket.
|
||||
*
|
||||
* Tungstenite auto-pongs and the OS keeps the TCP socket open, so a
|
||||
* half-open WS (Warp's orange-icon state, an asleep VPN, etc.) presents as
|
||||
* "fully connected" to the WS layer indefinitely — no Close, no Error.
|
||||
*
|
||||
* We work around that by periodically sending a cheap NIP-01 `REQ` with a
|
||||
* filter that matches nothing real (kind 9999, far-future `since`) and
|
||||
* waiting for the matching `EOSE`. If the relay doesn't answer within
|
||||
* `probeTimeoutMs` (or the send itself fails), `onStall` is invoked with an
|
||||
* `Error` describing the failure. The relay client then force-resets the
|
||||
* socket so its existing reconnect path runs.
|
||||
*
|
||||
* The watchdog has no opinion on connection state or reconnects; it just
|
||||
* detects that the socket is unhealthy and reports it.
|
||||
*/
|
||||
export type RelayStallWatchdogConfig = {
|
||||
intervalMs: number;
|
||||
probeTimeoutMs: number;
|
||||
/** Send a raw NIP-01 frame. Returns the same promise as the WS layer. */
|
||||
sendRaw: (payload: unknown[]) => Promise<void>;
|
||||
/** Called once when a stall is detected. The watchdog stops itself first. */
|
||||
onStall: (error: Error) => void;
|
||||
/** Optional override for tests. */
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export class RelayStallWatchdog {
|
||||
private readonly intervalMs: number;
|
||||
private readonly probeTimeoutMs: number;
|
||||
private readonly sendRaw: (payload: unknown[]) => Promise<void>;
|
||||
private readonly onStall: (error: Error) => void;
|
||||
private readonly now: () => number;
|
||||
|
||||
private intervalHandle: number | null = null;
|
||||
private probeTimeoutHandle: number | null = null;
|
||||
private currentProbeSubId: string | null = null;
|
||||
|
||||
constructor(config: RelayStallWatchdogConfig) {
|
||||
this.intervalMs = config.intervalMs;
|
||||
this.probeTimeoutMs = config.probeTimeoutMs;
|
||||
this.sendRaw = config.sendRaw;
|
||||
this.onStall = config.onStall;
|
||||
this.now = config.now ?? (() => Math.floor(Date.now() / 1_000));
|
||||
}
|
||||
|
||||
/** Idempotent. Safe to call from `connect()` completion. */
|
||||
start(): void {
|
||||
if (this.intervalHandle !== null) {
|
||||
return;
|
||||
}
|
||||
this.intervalHandle = window.setInterval(
|
||||
() => this.sendProbe(),
|
||||
this.intervalMs,
|
||||
);
|
||||
}
|
||||
|
||||
/** Idempotent. Clears any in-flight probe + the interval. */
|
||||
stop(): void {
|
||||
if (this.intervalHandle !== null) {
|
||||
window.clearInterval(this.intervalHandle);
|
||||
this.intervalHandle = null;
|
||||
}
|
||||
if (this.probeTimeoutHandle !== null) {
|
||||
window.clearTimeout(this.probeTimeoutHandle);
|
||||
this.probeTimeoutHandle = null;
|
||||
}
|
||||
this.currentProbeSubId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the relay client's `handleEose` to satisfy the in-flight
|
||||
* probe. Returns `true` if the subId belonged to the watchdog (and the
|
||||
* caller should not look it up in the normal subscription map).
|
||||
*/
|
||||
handleEose(subId: string): boolean {
|
||||
if (subId !== this.currentProbeSubId) {
|
||||
return false;
|
||||
}
|
||||
if (this.probeTimeoutHandle !== null) {
|
||||
window.clearTimeout(this.probeTimeoutHandle);
|
||||
this.probeTimeoutHandle = null;
|
||||
}
|
||||
this.currentProbeSubId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private sendProbe(): void {
|
||||
if (this.probeTimeoutHandle !== null) {
|
||||
// A probe is still outstanding — don't pile on; the timeout handler
|
||||
// will declare the stall when it fires.
|
||||
return;
|
||||
}
|
||||
|
||||
const subId = `probe-${crypto.randomUUID()}`;
|
||||
this.currentProbeSubId = subId;
|
||||
this.probeTimeoutHandle = window.setTimeout(() => {
|
||||
this.probeTimeoutHandle = null;
|
||||
this.currentProbeSubId = null;
|
||||
this.fail(
|
||||
new Error("Relay socket stalled — no response to liveness probe."),
|
||||
);
|
||||
}, this.probeTimeoutMs);
|
||||
|
||||
const farFuture = this.now() + 86_400;
|
||||
void this.sendRaw([
|
||||
"REQ",
|
||||
subId,
|
||||
{ kinds: [9999], limit: 0, since: farFuture },
|
||||
]).catch((error) => {
|
||||
// Send failed → the socket is dead.
|
||||
if (this.probeTimeoutHandle !== null) {
|
||||
window.clearTimeout(this.probeTimeoutHandle);
|
||||
this.probeTimeoutHandle = null;
|
||||
}
|
||||
this.currentProbeSubId = null;
|
||||
this.fail(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Relay socket stalled — probe send failed."),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private fail(error: Error): void {
|
||||
this.stop();
|
||||
this.onStall(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import {
|
||||
isRelayConnectionDegraded,
|
||||
type ConnectionState,
|
||||
} from "@/shared/api/relayClientShared";
|
||||
|
||||
export { isRelayConnectionDegraded };
|
||||
|
||||
/**
|
||||
* Subscribe to the relay singleton's connection state with a debounce on
|
||||
* "transient" transitions.
|
||||
*
|
||||
* Why debounce? In normal use the socket can flap to `reconnecting` for a
|
||||
* second or two between events (initial AUTH, brief network blips) — we don't
|
||||
* want a red "connection lost" banner painting itself for every blink. We
|
||||
* only surface non-healthy states once they've persisted past
|
||||
* `degradedAfterMs` (default 2 seconds). `connected` / `idle` are reported
|
||||
* immediately so the UI clears the warning the moment things recover.
|
||||
*/
|
||||
export function useRelayConnection(options?: {
|
||||
/** Min ms a non-healthy state must persist before being reported. */
|
||||
degradedAfterMs?: number;
|
||||
}): ConnectionState {
|
||||
const degradedAfterMs = options?.degradedAfterMs ?? 2_000;
|
||||
const [state, setState] = React.useState<ConnectionState>(() =>
|
||||
relayClient.getConnectionState(),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
let pendingTimer: number | null = null;
|
||||
|
||||
const clearPending = () => {
|
||||
if (pendingTimer !== null) {
|
||||
window.clearTimeout(pendingTimer);
|
||||
pendingTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = relayClient.subscribeToConnectionState((next) => {
|
||||
clearPending();
|
||||
|
||||
if (next === "connected" || next === "idle" || next === "disconnected") {
|
||||
// Healthy or terminal — report immediately.
|
||||
setState(next);
|
||||
return;
|
||||
}
|
||||
|
||||
// Transient degraded states — wait before showing the user a warning.
|
||||
pendingTimer = window.setTimeout(() => {
|
||||
pendingTimer = null;
|
||||
setState(next);
|
||||
}, degradedAfterMs);
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearPending();
|
||||
unsubscribe();
|
||||
};
|
||||
}, [degradedAfterMs]);
|
||||
|
||||
return state;
|
||||
}
|
||||
Reference in New Issue
Block a user