mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Finding 1 (relayReconnectController.ts): subscribeToConnectionState can invoke its listener synchronously with the current state before returning. When that sync emission signals connected, onConnected() → finish() → cancelTimers() runs while unsubscribeConnectionState is still null — then the caller assigns all three handles after finish, leaking them for the app lifetime. Fix: subscribe first, then check resolved immediately after the return value is assigned. If resolved, unsubscribe the handle (which cancelTimers couldn't reach because the assignment hadn't happened yet) and return before installing the poll interval and backstop timer. Finding 2 (ProfileStep.tsx): reconnect() always returns false in phase 3, so runConnectivityAction never calls markSuccess(), and the onboarding component had no connection-state subscription. The error card stayed showing failure even after the relay healed. Fix: observe the shared relayConnectivitySuccess store (already used by the sidebar) via useSyncExternalStore + useEffect in OnboardingRelayConnectionErrorCard. Export subscribeRelayConnectivitySuccess and getRelayConnectivitySuccessSnapshot from useSidebarRelayConnectionCard.ts so both surfaces share exactly one success-signalling mechanism. Test: add a sync-emission unit test with a subscribeToConnectionState fake that calls the listener before returning its cleanup handle — asserts onSuccess fires once, no interval/backstop installed, cleanup called. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
/**
|
|
* 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 resolves us, finish() runs and
|
|
// cancelTimers() cleans up the subscription handle before we reach the
|
|
// timer-install lines below.
|
|
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. Also, cancelTimers() ran inside
|
|
// finish() before the return value of subscribeToConnectionState was
|
|
// assigned, so the subscription handle may not have been cleaned up yet —
|
|
// call unsubscribe now if it is still set.
|
|
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();
|