// Crash / uncaught-error recovery. // // The React already catches render errors. This module covers // the OTHER half: async code outside React (realtime handlers, Promises // that never hit a .catch, setTimeout callbacks, LiveKit event listeners, // etc.). Those bubble to window.onerror / onunhandledrejection and would // otherwise disappear silently in production. // // Behaviour: // - First error: emit a toast + log. // - Repeated identical errors within DEDUPE_MS: swallow (no spam). // - More than BURST_LIMIT distinct errors within BURST_WINDOW_MS: force a // full page reload. Something is systematically broken and the UI // probably can't recover in-place. export interface CrashEntry { id: string; message: string; stack: string | null; source: 'window' | 'promise'; at: number; } type Listener = (entry: CrashEntry) => void; const DEDUPE_MS = 10_000; const BURST_WINDOW_MS = 30_000; const BURST_LIMIT = 8; const listeners = new Set(); const recent = new Map(); // message hash → last seen let burst: number[] = []; let installed = false; function hash(message: string): string { let h = 0; for (let i = 0; i < message.length; i++) { h = ((h << 5) - h + message.charCodeAt(i)) | 0; } return h.toString(36); } function emit(entry: CrashEntry): void { // Dedupe on the message hash so a handler that fires every animation // frame doesn't drown the UI in toasts. const key = hash(entry.message); const now = entry.at; const last = recent.get(key); if (last && now - last < DEDUPE_MS) return; recent.set(key, now); // Purge stale dedupe entries to keep the map bounded. for (const [k, ts] of recent) { if (now - ts > DEDUPE_MS * 3) recent.delete(k); } // Burst detection: drop samples older than window, count what's left. burst = burst.filter((t) => now - t < BURST_WINDOW_MS); burst.push(now); if (burst.length >= BURST_LIMIT) { console.error('[crash-recovery] burst threshold reached — reloading'); // Let existing toast handlers see the final entry first, then reload. // Delay lets the log flush + any pending realtime ack go through. window.setTimeout(() => window.location.reload(), 500); } for (const fn of listeners) { try { fn(entry); } catch (err) { // Listener itself threw — log but don't recurse. console.error('[crash-recovery] listener threw', err); } } } function toEntry( err: unknown, source: CrashEntry['source'], ): CrashEntry { const message = err instanceof Error ? err.message : typeof err === 'string' ? err : (() => { try { return JSON.stringify(err); } catch { return String(err); } })(); const stack = err instanceof Error ? err.stack ?? null : null; return { id: crypto.randomUUID(), message: message || 'unknown error', stack, source, at: Date.now(), }; } export function installCrashHandlers(): void { if (installed) return; installed = true; window.addEventListener('error', (ev: ErrorEvent) => { // Some events (ResizeObserver loop, benign extension injections) arrive // as errors without a real message. Skip those to avoid spam. if (!ev.message && !ev.error) return; emit(toEntry(ev.error ?? ev.message, 'window')); }); window.addEventListener('unhandledrejection', (ev: PromiseRejectionEvent) => { emit(toEntry(ev.reason, 'promise')); }); } export function subscribeCrashes(fn: Listener): () => void { listeners.add(fn); return () => { listeners.delete(fn); }; } // Test / escape hatch — lets the reload threshold get checked in dev. export function reportManualCrash(err: unknown): void { emit(toEntry(err, 'window')); }