Files
openswarm/frontend/src/shared/backendConnection.ts
T

89 lines
3.4 KiB
TypeScript

// One source of truth for "is our local backend answering", fed by the global fetch
// interceptor (config.ts) and consumed by the reconnecting pill. Exists so an unreachable
// backend can never again present as a silent forever-spinner (ENG-241/ENG-242): every
// consumer reads the same signal, and a background probe self-heals the moment it returns.
type ReachabilityListener = (reachable: boolean) => void;
let reachableNow = true;
let failStreak = 0;
const listeners = new Set<ReachabilityListener>();
let probeTimer: ReturnType<typeof setInterval> | null = null;
// Injected by config.ts with the UN-intercepted fetch so probes never recurse into retry logic.
let prober: (() => Promise<unknown>) | null = null;
export function backendReachable(): boolean {
return reachableNow;
}
export function onBackendReachability(cb: ReachabilityListener): () => void {
listeners.add(cb);
return () => { listeners.delete(cb); };
}
export function setBackendProber(fn: () => Promise<unknown>): void {
prober = fn;
}
function emit(value: boolean): void {
listeners.forEach((l) => { try { l(value); } catch { /* a listener must never break the signal */ } });
}
function startProbe(): void {
if (probeTimer || !prober) return;
probeTimer = setInterval(() => {
void (prober as () => Promise<unknown>)()
.then(() => noteBackendSuccess())
.catch(() => { /* still down; keep probing */ });
}, 1500);
}
function stopProbe(): void {
if (probeTimer) { clearInterval(probeTimer); probeTimer = null; }
}
export function noteBackendFailure(): void {
failStreak++;
// Two consecutive failures = down. One lone failure is never a state flip, so a single
// dropped request can't flash the reconnecting UI.
if (reachableNow && failStreak >= 2) {
reachableNow = false;
emit(false);
startProbe();
return;
}
// Waiting for a SECOND user request to corroborate the first failure means an idle app never
// notices its backend died (measured: backend down 24s, signal still green, no pill). Ask the
// prober instead: it answers in ms and a healthy backend clears the streak, so this cannot flap.
if (reachableNow && failStreak === 1) setTimeout(() => { if (reachableNow) probeNow(); }, 300);
}
export function noteBackendSuccess(): void {
failStreak = 0;
if (!reachableNow) {
reachableNow = true;
stopProbe();
emit(true);
}
}
// A request that STALLS (a squatted or wedged port accepts the connection and never answers) would
// otherwise take the full attempt timeout before counting as a failure, leaving the user staring at
// a spinner for ~25s+ with the signal still green. The stall watchdog asks the prober directly, so
// "is the backend actually alive" is answered in seconds regardless of how long the request hangs.
export function probeNow(): void {
if (!prober) return;
void prober().then(() => noteBackendSuccess()).catch(() => noteBackendFailure());
}
export function noteRequestStalled(): void {
if (!reachableNow) return;
probeNow();
// Two probe failures are what flips the signal, matching the normal path; a backend that answers
// either probe is simply slow, not down, and nothing is shown.
setTimeout(() => { if (reachableNow) probeNow(); }, 1200);
}
// Harness/debug handle: lets a live session (CDP, support) read the signal without a store import.
(window as unknown as { __OSW_CONN?: object }).__OSW_CONN = { backendReachable, onBackendReachability };