fix(relay-reconnect): resilient reconnect with fast-path, escalation, and polling (#1456)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-02 15:30:24 -04:00
committed by GitHub
parent 5bfd5ca270
commit 02ff06cac2
16 changed files with 1455 additions and 104 deletions

View File

@@ -10,6 +10,15 @@
// `include!`. See reconnect_hook_config.rs for why this is shared, not a module.
include!("reconnect_hook_config.rs");
/// Returns `true` when an internal build has a reconnect hook configured.
///
/// Used by the frontend to decide whether to show "Waiting to reconnect…"
/// copy and to skip escalation in OSS builds where the hook is a no-op.
#[tauri::command]
pub fn relay_reconnect_hook_configured() -> bool {
option_env!("BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD").is_some()
}
#[tauri::command]
pub async fn relay_reconnect_hook() -> Result<(), String> {
let Some(config_str) = option_env!("BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD") else {

View File

@@ -611,6 +611,7 @@ pub fn run() {
set_prevent_sleep_active,
get_agent_memory,
relay_reconnect_hook,
relay_reconnect_hook_configured,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import { toast } from "sonner";
import { SidebarRelayConnectionCompactCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard";
import { useRelayConnection } from "@/shared/api/useRelayConnection";
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
import { cn } from "@/shared/lib/cn";
import { isRelayUnreachableError } from "@/shared/lib/relayError";
@@ -30,7 +31,15 @@ function OnboardingRelayConnectionErrorCard({
isSaving: boolean;
message: string;
}) {
const { isPending: isReconnectPending, reconnect } = useReconnectRelay();
const {
isPending: isReconnectPending,
isWaitingOnReconnectHook,
reconnect,
} = useReconnectRelay();
// Track whether a reconnect attempt was ever initiated from this card so we
// don't call markSuccess() on a "connected" state that pre-dates any click.
const hadActiveReconnectRef = React.useRef(false);
const relayConnectionState = useRelayConnection();
const [dismissedErrorMessage, setDismissedErrorMessage] = React.useState<
string | null
>(null);
@@ -73,12 +82,24 @@ function OnboardingRelayConnectionErrorCard({
}, ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS);
}, [message]);
// Observe real relay connection state. When this card initiated a reconnect
// attempt (phase-3 path: reconnect() returns false) and the relay later
// becomes connected, mark success. Guard: only fire when we actually
// started a reconnect so a pre-existing connected state doesn't trigger it.
React.useEffect(() => {
if (relayConnectionState === "connected" && hadActiveReconnectRef.current) {
hadActiveReconnectRef.current = false;
markSuccess();
}
}, [relayConnectionState, markSuccess]);
const runConnectivityAction = React.useCallback(
(runAction: () => Promise<boolean | undefined>) => {
if (reconnectActionPendingRef.current) {
return;
}
hadActiveReconnectRef.current = true;
reconnectActionPendingRef.current = true;
setIsReconnectActionPending(true);
setHasSuccess(false);
@@ -86,10 +107,18 @@ function OnboardingRelayConnectionErrorCard({
.then(runAction)
.then((didReconnect) => {
if (didReconnect !== false) {
// Synchronous success (phase 1) — clear the ref and mark success
// immediately. The connection-state effect won't fire because the
// ref was just cleared.
hadActiveReconnectRef.current = false;
markSuccess();
}
// didReconnect === false means phase 3 is active; the
// connection-state effect will call markSuccess() when the relay
// becomes connected.
})
.catch((error) => {
hadActiveReconnectRef.current = false;
const detail = error instanceof Error ? error.message : String(error);
toast.error(`Could not reconnect to the relay. ${detail}`);
})
@@ -116,6 +145,7 @@ function OnboardingRelayConnectionErrorCard({
isActionDisabled={isActionPending}
isConnected={hasSuccess}
isReconnectPending={isActionPending}
isWaitingOnReconnectHook={isWaitingOnReconnectHook}
onDismiss={() => setDismissedErrorMessage(message)}
onReconnect={handleReconnectRelay}
surface="secondary"

View File

@@ -772,6 +772,9 @@ export function AppSidebar({
isReconnectPending={
sidebarRelayConnectionCard.isRelayReconnectPending
}
isWaitingOnReconnectHook={
sidebarRelayConnectionCard.isWaitingOnReconnectHook
}
onDismiss={
sidebarRelayConnectionCard.onDismissRelayConnectionCard
}

View File

@@ -12,6 +12,7 @@ type SidebarRelayConnectionCardProps = {
className?: string;
isConnected?: boolean;
isReconnectPending: boolean;
isWaitingOnReconnectHook?: boolean;
onDismiss?: () => void;
onReconnect: () => void;
surface?: SidebarActionCardSurface;
@@ -24,6 +25,7 @@ export function SidebarRelayConnectionCard({
isActionDisabled = false,
isConnected = false,
isReconnectPending,
isWaitingOnReconnectHook = false,
onDismiss,
onReconnect,
surface,
@@ -35,6 +37,7 @@ export function SidebarRelayConnectionCard({
isActionDisabled={isActionDisabled}
isConnected={isConnected}
isReconnectPending={isReconnectPending}
isWaitingOnReconnectHook={isWaitingOnReconnectHook}
onDismiss={onDismiss}
onReconnect={onReconnect}
surface={surface}
@@ -49,11 +52,19 @@ export function SidebarRelayConnectionCompactCard({
isActionDisabled = false,
isConnected = false,
isReconnectPending,
isWaitingOnReconnectHook = false,
onDismiss,
onReconnect,
surface,
testId = "sidebar-relay-unreachable-compact",
}: SidebarRelayConnectionCardProps) {
const reconnectTitle = isWaitingOnReconnectHook
? "Waiting to reconnect"
: "Connecting";
const reconnectDescription = isWaitingOnReconnectHook
? "Complete any prompts opened by the reconnect helper to continue."
: "Reconnecting";
return (
<SidebarCompactActionCard
actionAriaLabel={isConnected ? "Connected" : "Connect to relay"}
@@ -63,7 +74,7 @@ export function SidebarRelayConnectionCompactCard({
isConnected
? undefined
: isReconnectPending
? "Reconnecting"
? reconnectDescription
: "Click to connect"
}
dismissLabel="Dismiss relay notification"
@@ -89,7 +100,7 @@ export function SidebarRelayConnectionCompactCard({
isConnected
? "Connected"
: isReconnectPending
? "Connecting"
? reconnectTitle
: "Can't reach the relay"
}
tone={isConnected ? "success" : "neutral"}

View File

@@ -82,14 +82,18 @@ export function useSidebarRelayConnectionCard(
const [isWindowVisible, setIsWindowVisible] =
React.useState(isDocumentVisible);
const hasActiveRelayUnreachableError =
hasRelayUnreachableError && !hasSuccess;
hasRelayUnreachableError && !hasSuccess && !isRelayConnectionConnected;
const isRelayConnectionActuallyDegraded =
hasActiveRelayUnreachableError || isRelayConnectionStateDegraded;
const isRelayConnectionSuccess = hasSuccess && isRelayConnectionConnected;
const canShow = isRelayConnectionActuallyDegraded || isRelayConnectionSuccess;
const show = canShow && !isDismissed;
const wasProblemCardVisibleRef = React.useRef(false);
const { isPending: isReconnectPending, reconnect } = useReconnectRelay();
const {
isPending: isReconnectPending,
isWaitingOnReconnectHook,
reconnect,
} = useReconnectRelay();
const [connectivityAction, setConnectivityAction] = React.useState<
"relay-connection" | null
>(null);
@@ -212,6 +216,7 @@ export function useSidebarRelayConnectionCard(
hasRelayUnreachableError,
isRelayConnectionSuccess,
isRelayReconnectPending,
isWaitingOnReconnectHook,
onDismissRelayConnectionCard: () => {
setRelayConnectivitySuccess(relayUrl, false);
setIsDismissed(true);

View File

@@ -0,0 +1,516 @@
/**
* Unit tests for RelayReconnectController.
*
* Tests the production controller class with injected dependencies — no
* React, no Tauri, no DOM required. Covers the three-phase strategy,
* cancellation token correctness, single-flight guard, and state transitions.
*/
import assert from "node:assert/strict";
import test, { mock } from "node:test";
import { RelayReconnectController } from "./relayReconnectController.ts";
// ── Dep builder helpers ───────────────────────────────────────────────────────
/** Build a minimal deps object with controllable preconnect behaviour. */
function makeDeps({
preconnectResult = async () => {},
hookConfiguredResult = async () => false,
runHookResult = async () => {},
connectionStateListener = null,
} = {}) {
const timers = [];
const intervals = [];
let timerSeq = 0;
let intervalSeq = 0;
const deps = {
preconnect: mock.fn(preconnectResult),
hookConfigured: mock.fn(hookConfiguredResult),
runHook: mock.fn(runHookResult),
subscribeToConnectionState: mock.fn((listener) => {
if (connectionStateListener) connectionStateListener.set(listener);
return () => {};
}),
onSuccess: mock.fn(),
onBackstop: mock.fn(),
setTimeout: mock.fn((fn, ms) => {
const id = ++timerSeq;
timers.push({ id, fn, ms });
return id;
}),
clearTimeout: mock.fn((id) => {
const idx = timers.findIndex((t) => t.id === id);
if (idx !== -1) timers.splice(idx, 1);
}),
setInterval: mock.fn((fn, ms) => {
const id = ++intervalSeq;
intervals.push({ id, fn, ms });
return id;
}),
clearInterval: mock.fn((id) => {
const idx = intervals.findIndex((t) => t.id === id);
if (idx !== -1) intervals.splice(idx, 1);
}),
// Test helpers to fire timers/intervals.
_fireTimer: (id) => timers.find((t) => t.id === id)?.fn(),
_fireInterval: (id) => intervals.find((t) => t.id === id)?.fn(),
_timers: timers,
_intervals: intervals,
};
return deps;
}
// ── Phase 1: fast path ────────────────────────────────────────────────────────
test("fast-path success — hook never invoked, onSuccess fires, state resets", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({ preconnectResult: async () => {} });
const result = await ctrl.start(deps);
assert.equal(result, true, "start() returns true on fast-path success");
assert.equal(deps.hookConfigured.mock.calls.length, 0, "hook not queried");
assert.equal(deps.runHook.mock.calls.length, 0, "hook not run");
assert.equal(deps.onSuccess.mock.calls.length, 1, "onSuccess fired");
assert.deepEqual(ctrl.getState(), {
isPending: false,
isWaitingOnReconnectHook: false,
});
});
test("fast-path success — no poll interval or backstop scheduled", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({ preconnectResult: async () => {} });
await ctrl.start(deps);
assert.equal(deps.setInterval.mock.calls.length, 0, "no poll interval");
// The fast-path withDeadline schedules one timeout for the deadline, but
// that is cleared by finally(). After success no backstop should remain.
assert.equal(
deps._timers.length,
0,
"no outstanding timers after fast-path win",
);
});
// ── Phase 2: escalation ───────────────────────────────────────────────────────
test("escalation fires only when fast path fails and hook is configured", async () => {
const ctrl = new RelayReconnectController();
let callIdx = 0;
const deps = makeDeps({
preconnectResult: async () => {
// First call (fast path) fails; subsequent calls (poll) succeed.
if (++callIdx === 1) throw new Error("relay unreachable");
},
hookConfiguredResult: async () => true,
});
// Don't await — it enters phase 3 and returns false.
const promise = ctrl.start(deps);
// Let the async phases run.
await promise;
assert.equal(
deps.hookConfigured.mock.calls.length,
1,
"hook-configured checked once",
);
assert.equal(deps.runHook.mock.calls.length, 1, "hook run once");
});
test("escalation skipped when hook not configured", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("fail");
},
hookConfiguredResult: async () => false,
});
await ctrl.start(deps);
assert.equal(
deps.runHook.mock.calls.length,
0,
"hook not run when not configured",
);
});
test("hook failure is non-fatal — polling still starts", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("fail");
},
hookConfiguredResult: async () => true,
runHookResult: async () => {
throw new Error("hook blew up");
},
});
// Should not throw.
let threw = false;
try {
await ctrl.start(deps);
} catch {
threw = true;
}
assert.equal(threw, false, "hook failure does not propagate");
assert.ok(
deps._intervals.length > 0 || deps._timers.length > 0,
"phase 3 timers scheduled",
);
});
// ── Phase 3: poll-until-connected ─────────────────────────────────────────────
test("poll preempts backstop — onSuccess fires when poll wins", async () => {
const ctrl = new RelayReconnectController();
let callIdx = 0;
const deps = makeDeps({
preconnectResult: async () => {
if (++callIdx <= 2) throw new Error("not yet");
// Third call succeeds — simulates poll winning.
},
hookConfiguredResult: async () => false,
});
// Phase 1 fails, enters phase 3. start() returns false.
const promise = ctrl.start(deps);
await promise;
// Fire the poll interval twice to reach the succeeding call.
const intervalId = deps._intervals[0]?.id;
assert.ok(intervalId !== undefined, "poll interval is active");
await deps._fireInterval(intervalId); // call 2 — fails
await deps._fireInterval(intervalId); // call 3 — succeeds
assert.equal(
deps.onSuccess.mock.calls.length,
1,
"onSuccess fired after poll win",
);
assert.deepEqual(ctrl.getState(), {
isPending: false,
isWaitingOnReconnectHook: false,
});
});
test("connection-state emitter fires onSuccess and cancels poll/backstop", async () => {
let capturedListener = null;
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("fail");
},
hookConfiguredResult: async () => false,
connectionStateListener: {
set: (l) => {
capturedListener = l;
},
},
});
await ctrl.start(deps);
// Emitter fires "connected" — should resolve without waiting for poll.
assert.ok(capturedListener !== null, "connection-state listener registered");
capturedListener("connected");
assert.equal(
deps.onSuccess.mock.calls.length,
1,
"onSuccess fired via emitter",
);
assert.deepEqual(ctrl.getState(), {
isPending: false,
isWaitingOnReconnectHook: false,
});
assert.equal(deps._intervals.length, 0, "poll interval cancelled");
assert.equal(deps._timers.length, 0, "backstop cancelled");
});
test("backstop fires onBackstop, not onSuccess, and resets state", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("always fails");
},
hookConfiguredResult: async () => false,
});
await ctrl.start(deps);
// Locate and fire the backstop timer (largest ms).
const backstopTimer = deps._timers.reduce(
(max, t) => (t.ms >= max.ms ? t : max),
{ ms: 0, id: -1, fn: null },
);
assert.ok(backstopTimer.id !== -1, "backstop timer exists");
backstopTimer.fn();
assert.equal(deps.onBackstop.mock.calls.length, 1, "onBackstop called");
assert.equal(deps.onSuccess.mock.calls.length, 0, "onSuccess NOT called");
assert.deepEqual(ctrl.getState(), {
isPending: false,
isWaitingOnReconnectHook: false,
});
});
// ── Cancellation token ────────────────────────────────────────────────────────
test("cancellation token — superseded poll callback does not call onSuccess", async () => {
const ctrl = new RelayReconnectController();
let preconnectCallCount = 0;
let resolveFirstPreconnect;
const deps = makeDeps({
preconnectResult: async () => {
preconnectCallCount++;
if (preconnectCallCount === 1) {
// First fast-path call: stall so we can cancel mid-flight.
await new Promise((resolve) => {
resolveFirstPreconnect = resolve;
});
throw new Error("cancelled");
}
},
hookConfiguredResult: async () => false,
});
// Start attempt #1 — stalls in fast path.
const attempt1 = ctrl.start(deps);
// Cancel the attempt before the stalled preconnect resolves.
ctrl.cancel();
// Now resolve the stalled promise — it should be ignored.
resolveFirstPreconnect?.();
await attempt1;
// Start attempt #2 — clean state.
const deps2 = makeDeps({ preconnectResult: async () => {} });
const result2 = await ctrl.start(deps2);
assert.equal(result2, true, "second attempt succeeds on fast path");
assert.equal(
deps2.onSuccess.mock.calls.length,
1,
"only second attempt's onSuccess fires",
);
assert.equal(
deps.onSuccess.mock.calls.length,
0,
"first attempt's onSuccess never fires",
);
});
test("cancel mid fast-path — preconnect resolves SUCCESSFULLY after cancel but onSuccess never fires", async () => {
const ctrl = new RelayReconnectController();
let resolvePreconnect;
const deps = makeDeps({
// preconnect stalls then SUCCEEDS — worst case: cancel races a fast-path win.
preconnectResult: () =>
new Promise((resolve) => {
resolvePreconnect = resolve;
}),
hookConfiguredResult: async () => false,
});
const attempt = ctrl.start(deps);
// Cancel while fast-path is in flight.
ctrl.cancel();
// Let preconnect resolve successfully after cancel.
resolvePreconnect?.();
await attempt;
assert.equal(
deps.onSuccess.mock.calls.length,
0,
"onSuccess never fires after cancel",
);
assert.deepEqual(
ctrl.getState(),
{ isPending: false, isWaitingOnReconnectHook: false },
"state is idle",
);
assert.equal(deps._timers.length, 0, "no timers outstanding after cancel");
assert.equal(
deps._intervals.length,
0,
"no intervals outstanding after cancel",
);
});
test("last subscriber unsubscribe cancels in-flight attempt", async () => {
const ctrl = new RelayReconnectController();
let resolvePreconnect;
const deps = makeDeps({
preconnectResult: () =>
new Promise((resolve) => {
resolvePreconnect = resolve;
}),
hookConfiguredResult: async () => false,
});
// Two subscribers.
const unsub1 = ctrl.subscribe(() => {});
const unsub2 = ctrl.subscribe(() => {});
const attempt = ctrl.start(deps);
// First unsub — still one subscriber, should NOT cancel.
unsub1();
assert.equal(
ctrl.getState().isPending,
true,
"still pending after first unsub",
);
// Last unsub — should cancel.
unsub2();
assert.equal(ctrl.getState().isPending, false, "cancelled after last unsub");
// Let preconnect resolve successfully — should be ignored.
resolvePreconnect?.();
await attempt;
assert.equal(
deps.onSuccess.mock.calls.length,
0,
"onSuccess never fires after last-subscriber cancel",
);
});
// ── Single-flight guard ───────────────────────────────────────────────────────
test("second start() while first is pending returns false immediately", async () => {
const ctrl = new RelayReconnectController();
let resolvePreconnect;
const deps = makeDeps({
preconnectResult: () =>
new Promise((resolve) => {
resolvePreconnect = resolve;
}),
});
const deps2 = makeDeps({ preconnectResult: async () => {} });
// Start attempt #1 — stalls in fast path.
const attempt1 = ctrl.start(deps);
// Second start should be rejected immediately.
const result2 = await ctrl.start(deps2);
assert.equal(
result2,
false,
"second start returns false while first pending",
);
assert.equal(
deps2.preconnect.mock.calls.length,
0,
"second attempt never calls preconnect",
);
// Finish the first attempt.
resolvePreconnect?.();
await attempt1;
});
// ── State listener ────────────────────────────────────────────────────────────
test("subscribers receive state transitions and can unsubscribe", async () => {
const ctrl = new RelayReconnectController();
const states = [];
const unsub = ctrl.subscribe((s) => states.push({ ...s }));
const deps = makeDeps({ preconnectResult: async () => {} });
await ctrl.start(deps);
unsub();
// Should have seen: initial idle, pending=true, pending=false (success).
assert.ok(states.length >= 2, "at least 2 state transitions observed");
assert.equal(
states[states.length - 1].isPending,
false,
"last state is not pending",
);
// After unsubscribe, no further updates.
const countBefore = states.length;
const deps2 = makeDeps({ preconnectResult: async () => {} });
await ctrl.start(deps2);
assert.equal(states.length, countBefore, "no updates after unsubscribe");
});
// ── OSS build boundary ────────────────────────────────────────────────────────
test("OSS build (hookConfigured returns false) — runHook never called", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("relay unreachable");
},
hookConfiguredResult: async () => false,
});
await ctrl.start(deps);
assert.equal(
deps.runHook.mock.calls.length,
0,
"runHook not called in OSS build",
);
});
// ── Synchronous connected emission ───────────────────────────────────────────
test("sync connected emission — onSuccess fires once, no interval/backstop installed, subscription cleaned up", async () => {
const ctrl = new RelayReconnectController();
// subscribeToConnectionState fake that invokes the listener synchronously
// with "connected" BEFORE returning the cleanup handle. This models the
// production subscribeToConnectionState documented behaviour: it fires the
// listener with the current state before returning.
let cleanupCalled = false;
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("relay unreachable");
},
hookConfiguredResult: async () => false,
});
// Override subscribeToConnectionState with the synchronous-emission fake.
deps.subscribeToConnectionState = mock.fn((listener) => {
// Invoke immediately — simulates "already connected" at subscribe time.
listener("connected");
// Return cleanup handle (production unsubscribe fn).
const cleanup = () => {
cleanupCalled = true;
};
return cleanup;
});
await ctrl.start(deps);
assert.equal(
deps.onSuccess.mock.calls.length,
1,
"onSuccess fires exactly once",
);
assert.equal(deps._intervals.length, 0, "no poll interval installed");
assert.equal(deps._timers.length, 0, "no backstop timer installed");
assert.equal(cleanupCalled, true, "subscription cleanup handle was called");
assert.deepEqual(
ctrl.getState(),
{ isPending: false, isWaitingOnReconnectHook: false },
"state is idle after sync success",
);
});

View File

@@ -0,0 +1,289 @@
/**
* Reconnect controller for the relay transport.
*
* Implements a three-phase strategy:
*
* 1. **Fast path** — attempt `preconnect()` with a short timeout. Succeeds
* immediately for transient network blips where the client-side VPN is
* already healthy; no configured hook fires and no browser action is taken.
*
* 2. **Escalation** (only when fast path fails AND a hook is configured) —
* invoke the build-time configured transport-recovery hook. The hook
* returns quickly; any browser-based reconnect flow it triggers runs
* asynchronously.
*
* 3. **Poll-until-connected** — retry `preconnect()` every POLL_INTERVAL_MS.
* The moment the relay becomes reachable the next poll succeeds and
* success is declared — the backstop is a ceiling, not a delay. The
* connection-state emitter is also watched: if the session's background
* retry loop reconnects first, we catch it immediately.
*
* The controller is a module-level singleton so all hook instances in the
* same app share a single in-flight state. A cancellation token is bumped on
* every new attempt and checked after every `await` and inside every async
* continuation, preventing state mutations from a superseded attempt.
*
* Dependencies (`preconnect`, `hookConfigured`, `runHook`,
* `subscribeToConnectionState`) are injected, making the controller
* testable without React or Tauri.
*/
/** Short deadline for the optimistic fast-path attempt. */
const FAST_PATH_TIMEOUT_MS = 4_000;
/** Interval between poll attempts during phase 3. */
const POLL_INTERVAL_MS = 3_000;
/** Maximum total time to keep polling before giving up. */
const BACKSTOP_MS = 120_000;
export type ReconnectState = {
isPending: boolean;
isWaitingOnReconnectHook: boolean;
};
type Listener = (state: ReconnectState) => void;
export type ReconnectDeps = {
preconnect: () => Promise<void>;
hookConfigured: () => Promise<boolean>;
runHook: () => Promise<void>;
subscribeToConnectionState: (listener: (state: string) => void) => () => void;
onSuccess: () => void;
onBackstop: () => void;
setTimeout: (fn: () => void, ms: number) => number;
clearTimeout: (id: number) => void;
setInterval: (fn: () => void, ms: number) => number;
clearInterval: (id: number) => void;
};
function withDeadline<T>(
promise: Promise<T>,
timeoutMs: number,
label: string,
setTimeoutFn: (fn: () => void, ms: number) => number,
clearTimeoutFn: (id: number) => void,
): Promise<T> {
let id: number | null = null;
const deadline = new Promise<never>((_, reject) => {
id = setTimeoutFn(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, deadline]).finally(() => {
if (id !== null) clearTimeoutFn(id);
});
}
export class RelayReconnectController {
private state: ReconnectState = {
isPending: false,
isWaitingOnReconnectHook: false,
};
private listeners = new Set<Listener>();
// Cancellation token: bumped at the start of each attempt AND on cancel.
// All async continuations capture the token at their creation point and
// bail if it has since been superseded.
private attemptToken = 0;
// Active timers and subscription for the current attempt. The timer-clear
// functions are stored from the deps of the most recent start() call so
// that cancel() and teardown do not need the caller to supply deps again.
private pollIntervalId: number | null = null;
private backstopId: number | null = null;
private unsubscribeConnectionState: (() => void) | null = null;
private clearTimeoutFn: ((id: number) => void) | null = null;
private clearIntervalFn: ((id: number) => void) | null = null;
/** Subscribe to state changes. Fires immediately with the current state. */
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
listener(this.state);
return () => {
this.listeners.delete(listener);
// Cancel the in-flight attempt when the last subscriber leaves — no UI
// is watching the result, so letting poll/backstop callbacks fire
// (possibly mutating state or invoking onSuccess/onBackstop into a
// stale closure) would be a resource and correctness leak.
if (this.listeners.size === 0) {
this.cancel();
}
};
}
/** Current state snapshot — synchronous read. */
getState(): ReconnectState {
return this.state;
}
/**
* Start a reconnect attempt. Returns false if one is already in flight.
* On phase-3 entry returns false immediately; success/failure are
* delivered asynchronously via state updates.
*/
async start(deps: ReconnectDeps): Promise<boolean> {
if (this.state.isPending) return false;
// Store timer-clear fns so cancel() can clear timers without caller deps.
this.clearTimeoutFn = deps.clearTimeout;
this.clearIntervalFn = deps.clearInterval;
// Bump the cancellation token before any await.
const token = ++this.attemptToken;
this.cancelTimers();
this.setState({ isPending: true, isWaitingOnReconnectHook: false });
const cancelled = () => token !== this.attemptToken;
// ── Phase 1: fast path ───────────────────────────────────────────────────
try {
await withDeadline(
deps.preconnect(),
FAST_PATH_TIMEOUT_MS,
"fast-path",
deps.setTimeout,
deps.clearTimeout,
);
if (cancelled()) return false;
this.finish(deps.onSuccess, true);
return true;
} catch {
if (cancelled()) return false;
// Fast path failed — continue to escalation or polling.
}
// ── Phase 2: escalation (hook-configured builds only) ───────────────────
let hookConfigured = false;
try {
hookConfigured = await deps.hookConfigured();
} catch (err) {
console.warn(
"[RelayReconnectController] hook configured check failed:",
err,
);
}
if (cancelled()) return false;
if (hookConfigured) {
try {
await deps.runHook();
} catch (err) {
// Non-fatal — hook failure means the browser-based reconnect flow may
// not have opened, but we still poll for relay reachability.
console.warn(
"[RelayReconnectController] transport recovery hook failed:",
err,
);
}
if (cancelled()) return false;
this.setState({ isPending: true, isWaitingOnReconnectHook: true });
}
// ── Phase 3: poll-until-connected ────────────────────────────────────────
// Retry preconnect on a fixed interval. The moment the relay becomes
// reachable the next poll fires success. The connection-state emitter is
// also watched; if the session's background retry loop reconnects first,
// we catch it here too.
let resolved = false;
// Capture onSuccess/onBackstop at phase-3 entry so that cancel() (which
// bumps the token) never races with a late finish() invocation: the token
// check in onConnected/backstop always wins before the callback is called.
const { onSuccess, onBackstop } = deps;
const onConnected = () => {
if (resolved || cancelled()) return;
resolved = true;
this.finish(onSuccess, true);
};
// Subscribe FIRST. subscribeToConnectionState may invoke the listener
// synchronously with the current state (documented on the production
// implementation). If that sync emission fires onConnected(), `resolved`
// is set to true inside the call — but the return value (the cleanup
// handle) hasn't been assigned to `this.unsubscribeConnectionState` yet,
// so cancelTimers() inside finish() cannot reach it. The `if (resolved)`
// block below handles that late-assignment cleanup.
this.unsubscribeConnectionState = deps.subscribeToConnectionState(
(state) => {
if (state === "connected") onConnected();
},
);
// If the sync emission already finished the attempt, don't install timers
// that would live for the app lifetime. The cleanup handle was assigned
// after cancelTimers() ran inside finish(), so unsubscribe it now.
if (resolved) {
if (this.unsubscribeConnectionState !== null) {
this.unsubscribeConnectionState();
this.unsubscribeConnectionState = null;
}
return false;
}
this.pollIntervalId = deps.setInterval(() => {
if (resolved || cancelled()) return;
void deps
.preconnect()
.then(onConnected)
.catch(() => {
// Poll failed — keep trying.
});
}, POLL_INTERVAL_MS);
this.backstopId = deps.setTimeout(() => {
if (resolved || cancelled()) return;
resolved = true;
// keepAliveRequested remains true via preconnect() — the session's
// background retry loop keeps running. The notification is soft.
onBackstop();
this.finish(() => {}, false);
}, BACKSTOP_MS);
// Return false now; async success is delivered via state updates.
return false;
}
/**
* Cancel the active attempt. Safe to call without a running attempt.
* Bumps the cancellation token so any in-flight async continuations
* (including a pending fast-path await) become no-ops.
*/
cancel(): void {
// Invalidate the token FIRST so any pending await that resolves
// immediately after this call still sees a cancelled state.
++this.attemptToken;
this.cancelTimers();
if (this.state.isPending) {
this.setState({ isPending: false, isWaitingOnReconnectHook: false });
}
}
private finish(onSuccess: () => void, success: boolean): void {
this.cancelTimers();
this.setState({ isPending: false, isWaitingOnReconnectHook: false });
if (success) onSuccess();
}
private cancelTimers(): void {
if (this.pollIntervalId !== null && this.clearIntervalFn !== null) {
this.clearIntervalFn(this.pollIntervalId);
this.pollIntervalId = null;
}
if (this.backstopId !== null && this.clearTimeoutFn !== null) {
this.clearTimeoutFn(this.backstopId);
this.backstopId = null;
}
if (this.unsubscribeConnectionState !== null) {
this.unsubscribeConnectionState();
this.unsubscribeConnectionState = null;
}
}
private setState(next: ReconnectState): void {
this.state = next;
for (const listener of this.listeners) {
listener(next);
}
}
}
/** Module-level singleton — shared by all `useReconnectRelay` instances. */
export const relayReconnectController = new RelayReconnectController();

View File

@@ -1,12 +1,13 @@
/**
* Light reconnect hook — clears the relay client's terminal flag and refetches
* all queries without tearing down the workspace.
* React binding for the relay reconnect controller.
*
* Deliberately uses `relayClient.preconnect()` + `queryClient.invalidateQueries()`
* rather than the full `reconnectWorkspace()` path, which unmounts the entire
* React tree and clears drafts. The goal here is a transparent re-handshake
* when the transport comes back online; the user should not lose their in-progress
* compose state.
* Delegates all reconnect logic to the module-level `relayReconnectController`
* singleton so that all mounted hook instances (banner + sidebar card) share
* a single in-flight state. Deliberately uses `preconnect()` rather than the
* full `reconnectWorkspace()` path to avoid unmounting the React tree and
* clearing draft state.
*
* See `relayReconnectController.ts` for the three-phase strategy details.
*/
import * as React from "react";
@@ -15,84 +16,73 @@ import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner";
import { relayClient } from "@/shared/api/relayClient";
import { relayReconnectController } from "@/shared/api/relayReconnectController";
const RECONNECT_HOOK_TIMEOUT_MS = 20_000;
const RELAY_PRECONNECT_TIMEOUT_MS = 15_000;
function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string,
): Promise<T> {
let timeoutId: number | null = null;
const timeout = new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
});
function buildDeps(onSuccess: () => void, onBackstop: () => void) {
return {
preconnect: () => relayClient.preconnect(),
hookConfigured: () => invoke<boolean>("relay_reconnect_hook_configured"),
runHook: () => invoke<void>("relay_reconnect_hook"),
subscribeToConnectionState: (listener: (state: string) => void) =>
relayClient.subscribeToConnectionState(listener),
onSuccess,
onBackstop,
setTimeout: window.setTimeout.bind(window),
clearTimeout: window.clearTimeout.bind(window),
setInterval: window.setInterval.bind(window),
clearInterval: window.clearInterval.bind(window),
};
}
export function useReconnectRelay(): {
reconnect: () => Promise<boolean>;
isPending: boolean;
isWaitingOnReconnectHook: boolean;
} {
const queryClient = useQueryClient();
const [isPending, setIsPending] = React.useState(false);
// Ref-based guard prevents a second fire if the user clicks while the first
// preconnect is still in flight — stale closure over `isPending` state would
// allow a double-trigger between the click and the next render.
const inFlightRef = React.useRef(false);
const reconnect = React.useCallback(async () => {
if (inFlightRef.current) return false;
inFlightRef.current = true;
setIsPending(true);
try {
// Run the transport-layer reconnect hook configured by internal builds.
// No-op in OSS builds. Non-fatal — transport failure shouldn't block relay reconnect.
try {
await withTimeout(
invoke("relay_reconnect_hook"),
RECONNECT_HOOK_TIMEOUT_MS,
"reconnect hook",
const [controllerState, setControllerState] = React.useState(() =>
relayReconnectController.getState(),
);
// Subscribe to controller state changes for the lifetime of this component.
// Multiple hook instances receive the same state from the shared singleton.
React.useEffect(() => {
return relayReconnectController.subscribe(setControllerState);
}, []);
// Stable mutable refs for callbacks — updated every render so stale closures
// are never captured, but the reconnect callback itself never changes identity.
const onSuccessRef = React.useRef<(() => void) | null>(null);
const onBackstopRef = React.useRef<(() => void) | null>(null);
onSuccessRef.current = React.useCallback(() => {
// Defer query invalidation so callers render the recovered state first.
window.setTimeout(() => {
void queryClient.invalidateQueries().catch((err) => {
console.error(
"[useReconnectRelay] failed to refresh queries after reconnect:",
err,
);
} catch (err) {
console.warn("[useReconnectRelay] reconnect hook failed:", err);
}
await withTimeout(
relayClient.preconnect(),
RELAY_PRECONNECT_TIMEOUT_MS,
"relay preconnect",
);
// Let callers render the recovered/connected state before refetching the
// sidebar data. The refetch can briefly swap the sidebar into loading UI.
window.setTimeout(() => {
void queryClient.invalidateQueries().catch((error) => {
console.error(
"[useReconnectRelay] failed to refresh queries after reconnect:",
error,
);
});
}, 0);
// No success toast — the banner auto-hides once the connection state
// transitions back to "connected", which is the user-visible confirmation.
return true;
} catch (err) {
toast.error("Reconnect failed — check your network.");
console.error("[useReconnectRelay] reconnect failed:", err);
return false;
} finally {
inFlightRef.current = false;
setIsPending(false);
}
});
}, 0);
}, [queryClient]);
return { reconnect, isPending };
onBackstopRef.current = React.useCallback(() => {
toast("Still trying to reconnect — check your network.");
}, []);
const reconnect = React.useCallback(async () => {
const deps = buildDeps(
() => onSuccessRef.current?.(),
() => onBackstopRef.current?.(),
);
return relayReconnectController.start(deps);
}, []);
return {
reconnect,
isPending: controllerState.isPending,
isWaitingOnReconnectHook: controllerState.isWaitingOnReconnectHook,
};
}

View File

@@ -0,0 +1,167 @@
/**
* Unit tests for RelayAutoHealScheduler.
*
* Tests the rate-limited, deferred-heal state machine without React or DOM.
* Covers: first recovery fires immediately, rate-limit suppression schedules
* deferred heal, second recovery within window supersedes the deferred, heal
* fires after deferred window expires, dispose cancels pending heal.
*/
import assert from "node:assert/strict";
import test, { mock } from "node:test";
import { RelayAutoHealScheduler } from "./useRelayAutoHeal.ts";
function makeScheduler({ minIntervalMs = 100, now: initialNow = 0 } = {}) {
const heals = [];
const timers = [];
let timerSeq = 0;
let nowMs = initialNow;
const sched = new RelayAutoHealScheduler(
() => heals.push(nowMs),
minIntervalMs,
mock.fn((fn, ms) => {
const id = ++timerSeq;
timers.push({ id, fn, ms });
return id;
}),
mock.fn((id) => {
const idx = timers.findIndex((t) => t.id === id);
if (idx !== -1) timers.splice(idx, 1);
}),
() => nowMs,
);
return {
sched,
heals,
timers,
setNow: (ms) => {
nowMs = ms;
},
fireTimer: (id) => timers.find((t) => t.id === id)?.fn(),
};
}
// ── First recovery — fires immediately ───────────────────────────────────────
test("first recovery fires onHeal immediately", () => {
const { sched, heals } = makeScheduler();
sched.onTransition("reconnecting", "connected");
assert.equal(heals.length, 1, "onHeal fired once");
});
test("non-recovery transitions are ignored", () => {
const { sched, heals, timers } = makeScheduler();
sched.onTransition("connected", "reconnecting");
sched.onTransition("reconnecting", "stalled");
sched.onTransition("connected", "connected");
assert.equal(heals.length, 0, "no heals for non-recovery transitions");
assert.equal(timers.length, 0, "no timers scheduled");
});
// ── Rate limiting ─────────────────────────────────────────────────────────────
test("second recovery within window is rate-limited and schedules deferred heal", () => {
const { sched, heals, timers, setNow } = makeScheduler({
minIntervalMs: 100,
});
setNow(0);
sched.onTransition("reconnecting", "connected"); // fires immediately
assert.equal(heals.length, 1);
// Second recovery 50ms later — within the 100ms window.
setNow(50);
sched.onTransition("stalled", "connected");
assert.equal(heals.length, 1, "onHeal not called again immediately");
assert.equal(timers.length, 1, "deferred heal timer scheduled");
assert.equal(
timers[0].ms,
50,
"deferred fires after remaining window (100-50=50ms)",
);
});
test("deferred heal fires onHeal when timer fires", () => {
const { sched, heals, timers, setNow, fireTimer } = makeScheduler({
minIntervalMs: 100,
});
setNow(0);
sched.onTransition("reconnecting", "connected");
setNow(50);
sched.onTransition("stalled", "connected"); // rate-limited, timer id=1
setNow(100);
fireTimer(timers[0].id);
assert.equal(heals.length, 2, "deferred onHeal fired after timer");
});
test("second recovery supersedes deferred — old timer cancelled, new one scheduled", () => {
const { sched, heals, timers, setNow, fireTimer } = makeScheduler({
minIntervalMs: 100,
});
setNow(0);
sched.onTransition("reconnecting", "connected"); // immediate heal
setNow(40);
sched.onTransition("stalled", "connected"); // rate-limited → deferred at remaining=60ms
const firstTimerId = timers[0].id;
assert.equal(timers.length, 1);
setNow(60);
sched.onTransition("reconnecting", "connected"); // supersedes — cancel first, schedule new
assert.equal(
timers.findIndex((t) => t.id === firstTimerId),
-1,
"first timer cancelled",
);
assert.equal(timers.length, 1, "new deferred timer scheduled");
assert.equal(
timers[0].ms,
40,
"new deferred fires after remaining window (100-60=40ms)",
);
setNow(100);
fireTimer(timers[0].id);
assert.equal(heals.length, 2, "exactly two heals total (initial + deferred)");
});
// ── Dispose ───────────────────────────────────────────────────────────────────
test("dispose cancels pending deferred heal", () => {
const { sched, heals, timers, setNow } = makeScheduler({
minIntervalMs: 100,
});
setNow(0);
sched.onTransition("reconnecting", "connected");
setNow(50);
sched.onTransition("stalled", "connected"); // deferred scheduled
sched.dispose();
assert.equal(timers.length, 0, "timer cancelled on dispose");
// Firing the timer after dispose should be a no-op (timer is gone).
// The heals array should still be 1 (only the initial immediate heal).
assert.equal(heals.length, 1, "no additional heals after dispose");
});
test("dispose with no pending timer is a no-op", () => {
const { sched } = makeScheduler();
assert.doesNotThrow(() => sched.dispose());
});

View File

@@ -2,12 +2,83 @@ import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { ConnectionState } from "@/shared/api/relayClientShared";
import {
isRelayConnectionDegraded,
useRelayConnection,
} from "@/shared/api/useRelayConnection";
const AUTO_HEAL_MIN_INTERVAL_MS = 15_000;
export const AUTO_HEAL_MIN_INTERVAL_MS = 15_000;
/**
* Tracks degraded→connected transitions and fires `onHeal` at most once per
* `minIntervalMs`. When a transition is suppressed by the rate limiter, a
* deferred heal is scheduled for the remaining window so the *last* recovery
* always wins — stale query errors do not persist after reconnect.
*
* Injectable deps make this testable without React or DOM.
*/
export class RelayAutoHealScheduler {
private lastHealAt = -Infinity;
private deferredId: number | null = null;
private readonly onHeal: () => void;
private readonly minIntervalMs: number;
private readonly setTimeoutFn: (fn: () => void, ms: number) => number;
private readonly clearTimeoutFn: (id: number) => void;
private readonly nowFn: () => number;
constructor(
onHeal: () => void,
minIntervalMs: number,
setTimeoutFn: (fn: () => void, ms: number) => number,
clearTimeoutFn: (id: number) => void,
nowFn: () => number = () => Date.now(),
) {
this.onHeal = onHeal;
this.minIntervalMs = minIntervalMs;
this.setTimeoutFn = setTimeoutFn;
this.clearTimeoutFn = clearTimeoutFn;
this.nowFn = nowFn;
}
/**
* Call on every connection-state change. Fires `onHeal` (immediately or
* deferred) when the transition is degraded→connected.
*/
onTransition(prev: ConnectionState, next: ConnectionState): void {
if (!(isRelayConnectionDegraded(prev) && next === "connected")) return;
// A new recovery supersedes any pending deferred heal.
if (this.deferredId !== null) {
this.clearTimeoutFn(this.deferredId);
this.deferredId = null;
}
const now = this.nowFn();
const elapsed = now - this.lastHealAt;
if (elapsed < this.minIntervalMs) {
// Rate-limited — schedule for when the window expires.
const remaining = this.minIntervalMs - elapsed;
this.deferredId = this.setTimeoutFn(() => {
this.deferredId = null;
this.lastHealAt = this.nowFn();
this.onHeal();
}, remaining);
} else {
this.lastHealAt = now;
this.onHeal();
}
}
/** Cancel any pending deferred heal (call on unmount). */
dispose(): void {
if (this.deferredId !== null) {
this.clearTimeoutFn(this.deferredId);
this.deferredId = null;
}
}
}
/**
* Auto-heal: when the connection recovers from a degraded state, invalidate
@@ -17,23 +88,35 @@ const AUTO_HEAL_MIN_INTERVAL_MS = 15_000;
* Rate-limited to prevent a flappy connection (e.g. VPN toggling) from
* firing an unfiltered invalidation — ~20-40 requests across active queries
* with retry:1 — every time the relay briefly recovers.
*
* When a recovery is suppressed by the rate limiter (an earlier flap consumed
* the budget), a deferred heal is scheduled for the remaining window so the
* *last* recovery always wins and stale query errors do not persist.
*/
export function useRelayAutoHeal(): void {
const queryClient = useQueryClient();
const connectionState = useRelayConnection();
const prevConnectionStateRef = React.useRef(connectionState);
const lastAutoHealAtRef = React.useRef(0);
const schedulerRef = React.useRef<RelayAutoHealScheduler | null>(null);
if (schedulerRef.current === null) {
schedulerRef.current = new RelayAutoHealScheduler(
() => void queryClient.invalidateQueries(),
AUTO_HEAL_MIN_INTERVAL_MS,
window.setTimeout.bind(window),
window.clearTimeout.bind(window),
);
}
React.useEffect(() => {
return () => {
schedulerRef.current?.dispose();
};
}, []);
React.useEffect(() => {
const prev = prevConnectionStateRef.current;
prevConnectionStateRef.current = connectionState;
if (isRelayConnectionDegraded(prev) && connectionState === "connected") {
const now = Date.now();
if (now - lastAutoHealAtRef.current < AUTO_HEAL_MIN_INTERVAL_MS) {
return;
}
lastAutoHealAtRef.current = now;
void queryClient.invalidateQueries();
}
}, [connectionState, queryClient]);
schedulerRef.current?.onTransition(prev, connectionState);
}, [connectionState]);
}

View File

@@ -28,10 +28,12 @@ type ConnectionBannerProps = {
export function ConnectionBanner({ errorMessage }: ConnectionBannerProps) {
const state = useRelayConnection();
const { isPending, reconnect } = useReconnectRelay();
const { isPending, isWaitingOnReconnectHook, reconnect } =
useReconnectRelay();
const { state: sidebarState } = useSidebar();
const hasCollapsedRelayError =
sidebarState === "collapsed" &&
state !== "connected" &&
Boolean(errorMessage && isRelayUnreachableError(errorMessage));
if (!isRelayConnectionDegraded(state) && !hasCollapsedRelayError) {
@@ -42,6 +44,12 @@ export function ConnectionBanner({ errorMessage }: ConnectionBannerProps) {
? "Can't reach the relay."
: (COPY[state] ?? "Connection issue detected.");
const buttonLabel = isWaitingOnReconnectHook
? "Waiting to reconnect…"
: isPending
? "Reconnecting…"
: "Reconnect";
return (
<div
className="relative z-30 mt-10 flex shrink-0 items-center gap-2 border-b border-warning/30 bg-warning/5 px-3 py-2 text-xs"
@@ -57,7 +65,7 @@ export function ConnectionBanner({ errorMessage }: ConnectionBannerProps) {
onClick={reconnect}
type="button"
>
{isPending ? "Reconnecting…" : "Reconnect"}
{buttonLabel}
</button>
</div>
);

View File

@@ -664,6 +664,7 @@ declare global {
tags: string[][];
}>;
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (state: ConnectionState) => void;
__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => ConnectionState;
__BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void;
__BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number;
__BUZZ_E2E_SET_MESH__?: (mesh: {
@@ -7247,6 +7248,8 @@ export function maybeInstallE2eTauriMocks() {
}
).connectionStateEmitter.set(state);
};
window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__ = () =>
relayClient.getConnectionState();
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__ = (reminders) => {
mockReminderEvents.length = 0;
@@ -7995,6 +7998,8 @@ export function maybeInstallE2eTauriMocks() {
return handleUpdaterDownloadAndInstall(payload, activeConfig);
case "relay_reconnect_hook":
return null;
case "relay_reconnect_hook_configured":
return false;
case "plugin:resources|close":
return null;
case "plugin:process|restart":

View File

@@ -4,6 +4,60 @@ import { nsecEncode } from "nostr-tools/nip19";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
type RelayConnectionState =
| "connected"
| "connecting"
| "disconnected"
| "idle"
| "reconnecting"
| "stalled";
/**
* Drive the mock relay connection state via the E2E bridge seam.
* When targeting a degraded state, waits for baseline "connected" first so
* the mock auth handshake can't race the override back to "connected".
*/
async function setRelayConnectionState(
page: Page,
state: RelayConnectionState,
) {
if (state !== "connected") {
await page.waitForFunction(() => {
const win = window as Window & {
__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => string;
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
};
return (
typeof win.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function" &&
typeof win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__ === "function" &&
win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__() === "connected"
);
});
} else {
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
}
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function",
);
}
await page.evaluate((nextState) => {
const testWindow = window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (
state: RelayConnectionState,
) => void;
};
const setConnectionState =
testWindow.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__;
if (!setConnectionState) {
throw new Error("Mock relay connection state helper is not installed.");
}
setConnectionState(nextState);
}, state);
}
const E2E_IDENTITY_OVERRIDE_STORAGE_KEY = "buzz:e2e-identity-override.v1";
const HOME_SEEN_STORAGE_KEY_PREFIX = "buzz-home-feed-seen.v1:";
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
@@ -1285,3 +1339,83 @@ test("membership denial can import a different invited key", async ({
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expectHomeView(page);
});
test("onboarding relay reconnect — click shows Connected then auto-dismisses", async ({
page,
}) => {
// Produce the relay reconnect card via a relay-unreachable profile save error.
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
profileUpdateError: "relay unreachable: could not connect to relay",
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
const card = page.getByTestId("onboarding-relay-reconnect-card");
await expect(card).toBeVisible();
await expect(card).toContainText("Can't reach the relay");
// Drive degraded before clicking so the card is in the expected error state.
await setRelayConnectionState(page, "disconnected");
// Click the reconnect button. The controller attempts reconnect; the mock
// relay reconnects successfully (fast-path or via the state seam). Either
// path should result in the card transitioning to Connected and then
// auto-dismissing.
await page.getByTestId("onboarding-reconnect-relay").click();
// Drive connected to ensure the controller's connection-state path fires
// and the component's hadActiveReconnectRef guard is satisfied.
await setRelayConnectionState(page, "connected");
await expect(card).toContainText("Connected", { timeout: 5_000 });
await expect(card).not.toContainText("Can't reach the relay");
// Auto-dismiss fires after ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS
// (2500ms). Allow generous headroom for CI.
await expect(card).toBeHidden({ timeout: 10_000 });
});
test("onboarding relay reconnect — connected without a prior click does not show Connected", async ({
page,
}) => {
// Tests the hadActiveReconnectRef guard: if the relay transitions to
// "connected" without the user having clicked the reconnect button, the card
// must NOT show Connected. This guards against spurious success on initial
// connection or background recovery with no user action.
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
profileUpdateError: "relay unreachable: could not connect to relay",
},
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("onboarding-display-name").fill("Morty QA");
await page.getByTestId("onboarding-next").click();
const card = page.getByTestId("onboarding-relay-reconnect-card");
await expect(card).toBeVisible();
// Drive to disconnected state (no reconnect click has happened).
await setRelayConnectionState(page, "disconnected");
// Drive to connected WITHOUT clicking — this would happen on a spontaneous
// background recovery. The hadActiveReconnectRef guard must block markSuccess().
await setRelayConnectionState(page, "connected");
// Wait long enough for any spurious markSuccess() to fire, then assert the
// card stayed in the error state.
await page.waitForTimeout(500);
await expect(card).toBeVisible();
await expect(card).toContainText("Can't reach the relay");
await expect(card).not.toContainText("Connected");
});

View File

@@ -31,11 +31,38 @@ type ConnectionState =
| "stalled"
| "disconnected";
/** Directly emit "reconnecting" on the relay client singleton via the E2E test seam. */
/** Directly drive the relay client singleton state via the E2E test seam. */
async function driveConnectionDegraded(
page: import("@playwright/test").Page,
state: ConnectionState = "reconnecting",
) {
if (state !== "connected") {
// When driving to a degraded state, wait for the relay to reach "connected"
// first. Without this guard, the mock relay's async auth handshake can race
// the state override and write "connected" back over it.
await page.waitForFunction(() => {
const win = window as Window & {
__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => string;
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
};
return (
typeof win.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function" &&
typeof win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__ === "function" &&
win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__() === "connected"
);
});
} else {
// When driving to "connected", just wait for the seam to be installed —
// no need to gate on current state since we're overriding it directly.
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
}
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function",
);
}
await page.evaluate((s) => {
const setter = (
window as Window & {
@@ -52,12 +79,33 @@ test.describe("relay connectivity", () => {
await installMockBridge(page, { channelsReadError: RELAY_UNREACHABLE });
await page.goto("/");
// Wait for the E2E seam to be installed (bridge init is complete), then force
// the relay into a degraded state. The card is gated on !isRelayConnectionConnected:
// a stale channelsReadError alone (with state still "connected") no longer pins
// the card — connected state is now authoritative so stale query errors don't
// hold the UI open after an auto-reconnect.
//
// Use "reconnecting" (rather than "disconnected"): the card shows via
// isRelayConnectionStateDegraded for reconnecting/stalled states, so the test
// doesn't race the channels-query retry window. useRelayConnection debounces
// non-healthy states by 2 s, so await with timeout 5 s.
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
}
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function",
);
await driveConnectionDegraded(page, "reconnecting");
const relayCard = page.getByTestId("sidebar-relay-unreachable");
await expect(relayCard).toBeVisible();
await expect(relayCard).toBeVisible({
timeout: 5_000,
});
await expect(relayCard).toContainText("Can't reach the relay");
await expect(relayCard).toContainText("Click to connect");
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
await expect(page.getByTestId("connection-banner")).toHaveCount(0);
await settle(page);
// Clip to sidebar width (256px) so the card and channel list are both visible.

View File

@@ -39,14 +39,34 @@ async function setRelayConnectionState(
page: Page,
state: RelayConnectionState,
) {
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
}
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function",
);
if (state !== "connected") {
// When driving to a degraded state, wait for the relay to reach "connected"
// first. Without this guard, the mock relay's async auth handshake (started
// on page load) can race the override and set state back to "connected"
// after we've driven it to a degraded value.
await page.waitForFunction(() => {
const win = window as Window & {
__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?: () => string;
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
};
return (
typeof win.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function" &&
typeof win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__ === "function" &&
win.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__() === "connected"
);
});
} else {
// When driving to "connected", just wait for the seam to be installed —
// no need to gate on the current state since we're overriding it directly.
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
}
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function",
);
}
await page.evaluate((nextState) => {
const testWindow = window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (
@@ -80,6 +100,12 @@ test("sidebar generic relay failures use the reconnect card", async ({
await page.goto("/");
// The card is gated on !isRelayConnectionConnected — a stale channelsReadError
// alone (with state still "connected") no longer pins the card. Drive the relay
// into a disconnected state so the card appears immediately (disconnected is
// reported without debounce, unlike the 2-second delay for reconnecting/stalled).
await setRelayConnectionState(page, "disconnected");
await expectGenericReconnectCard(page);
});
@@ -90,6 +116,9 @@ test("sidebar proxy sign-in failures use the reconnect card", async ({
await page.goto("/");
// Drive disconnected so the card appears immediately (connected is now authoritative).
await setRelayConnectionState(page, "disconnected");
await expectGenericReconnectCard(page);
});
@@ -98,6 +127,9 @@ test("sidebar access failures use the reconnect card", async ({ page }) => {
await page.goto("/");
// Drive disconnected so the card appears immediately (connected is now authoritative).
await setRelayConnectionState(page, "disconnected");
await expectGenericReconnectCard(page);
});
@@ -108,6 +140,9 @@ test("collapsed sidebar relay failures use the connection banner", async ({
await page.goto("/");
// Drive degraded state so the card (and subsequently banner) appears.
await setRelayConnectionState(page, "disconnected");
await expectGenericReconnectCard(page);
await page
@@ -123,6 +158,8 @@ test("collapsed sidebar relay failures use the connection banner", async ({
await setChannelsReadError(page, null);
await page.getByTestId("connection-banner-reconnect").click();
// Drive connected so the banner shows "Connected" and auto-dismisses.
await setRelayConnectionState(page, "connected");
await expect(banner).toBeHidden({ timeout: 10_000 });
});
@@ -157,10 +194,15 @@ test("sidebar reconnect action shows connected before hiding", async ({
await page.goto("/");
// Drive disconnected state so the card appears immediately.
await setRelayConnectionState(page, "disconnected");
const card = await expectGenericReconnectCard(page);
await setChannelsReadError(page, null);
await page.getByTestId("sidebar-reconnect").click();
// Drive connected to simulate the relay recovering after the reconnect action.
await setRelayConnectionState(page, "connected");
await expect(card).toContainText("Connected");
await expect(card).not.toContainText("Click to connect");
@@ -177,9 +219,14 @@ test("sidebar reconnect action suppresses stale refresh errors after success", a
await page.goto("/");
// Drive disconnected state so the card appears immediately.
await setRelayConnectionState(page, "disconnected");
const card = await expectGenericReconnectCard(page);
await page.getByTestId("sidebar-reconnect").click();
// Drive connected to simulate the relay recovering after the reconnect action.
await setRelayConnectionState(page, "connected");
await page.waitForTimeout(500);
await expect(card).toBeVisible();
@@ -197,10 +244,15 @@ test("sidebar connected success clears when relay degrades again", async ({
await page.goto("/");
// Drive disconnected state so the card appears immediately.
await setRelayConnectionState(page, "disconnected");
const card = await expectGenericReconnectCard(page);
await setChannelsReadError(page, null);
await page.getByTestId("sidebar-reconnect").click();
// Drive connected to simulate the relay recovering after the reconnect action.
await setRelayConnectionState(page, "connected");
await expect(card).toContainText("Connected");