636565d552
- lib/crashRecovery.ts: window.onerror + unhandledrejection handlers, dedupe identical messages within 10s, burst-reload after 8 distinct errors in 30s - components/CrashToast.tsx: portal stack in bottom-right, max 3 visible, auto-dismiss after 7s, per-entry close button - Wired in main.tsx before bootstrap so crypto/i18n errors are captured, rendered from App.tsx alongside UpdateToast Covers the async layer ErrorBoundary misses (realtime handlers, stray Promises, setTimeout callbacks, LiveKit listeners).
129 lines
3.7 KiB
TypeScript
129 lines
3.7 KiB
TypeScript
// Crash / uncaught-error recovery.
|
|
//
|
|
// The React <ErrorBoundary> 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<Listener>();
|
|
const recent = new Map<string, number>(); // 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'));
|
|
}
|