feat: crash recovery — global error handlers + toast + burst-reload
- 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).
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
// 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'));
|
||||
}
|
||||
Reference in New Issue
Block a user