mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -772,6 +772,9 @@ export function AppSidebar({
|
||||
isReconnectPending={
|
||||
sidebarRelayConnectionCard.isRelayReconnectPending
|
||||
}
|
||||
isWaitingOnReconnectHook={
|
||||
sidebarRelayConnectionCard.isWaitingOnReconnectHook
|
||||
}
|
||||
onDismiss={
|
||||
sidebarRelayConnectionCard.onDismissRelayConnectionCard
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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);
|
||||
|
||||
516
desktop/src/shared/api/relayReconnectController.test.mjs
Normal file
516
desktop/src/shared/api/relayReconnectController.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
289
desktop/src/shared/api/relayReconnectController.ts
Normal file
289
desktop/src/shared/api/relayReconnectController.ts
Normal 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();
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
167
desktop/src/shared/api/useRelayAutoHeal.test.mjs
Normal file
167
desktop/src/shared/api/useRelayAutoHeal.test.mjs
Normal 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());
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user