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:
@@ -1,6 +1,7 @@
|
||||
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from './components/AppShell';
|
||||
import { CrashToast } from './components/CrashToast';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { UpdateToast } from './components/UpdateToast';
|
||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||
@@ -91,6 +92,7 @@ export function App() {
|
||||
<Route path="*" element={<Navigate to="/chats" replace />} />
|
||||
</Routes>
|
||||
<UpdateToast />
|
||||
<CrashToast />
|
||||
</BrowserRouter>
|
||||
</CallProvider>
|
||||
</ConversationsProvider>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { type CrashEntry, subscribeCrashes } from '../lib/crashRecovery';
|
||||
import { AlertIcon, XIcon } from './icons';
|
||||
|
||||
const VISIBLE_MS = 7_000;
|
||||
const MAX_STACK = 3;
|
||||
|
||||
// Bottom-right stack of toasts for uncaught errors. Auto-dismisses each
|
||||
// entry after VISIBLE_MS. The user can X-out earlier.
|
||||
export function CrashToast() {
|
||||
const [entries, setEntries] = useState<CrashEntry[]>([]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribeCrashes((entry) => {
|
||||
setEntries((prev) => [...prev.slice(-(MAX_STACK - 1)), entry]);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (entries.length === 0) return;
|
||||
const latest = entries[entries.length - 1]!;
|
||||
const id = window.setTimeout(() => {
|
||||
setEntries((prev) => prev.filter((e) => e.id !== latest.id));
|
||||
}, VISIBLE_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [entries]);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Fehler"
|
||||
className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2"
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
role="alert"
|
||||
className="pointer-events-auto flex max-w-sm items-start gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-700 shadow-xl backdrop-blur-sm dark:text-rose-100"
|
||||
>
|
||||
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider">
|
||||
{entry.source === 'promise' ? 'Promise-Fehler' : 'Unerwarteter Fehler'}
|
||||
</p>
|
||||
<p className="mt-0.5 break-words text-xs">{entry.message}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Schließen"
|
||||
onClick={() =>
|
||||
setEntries((prev) => prev.filter((e) => e.id !== entry.id))
|
||||
}
|
||||
className="shrink-0 cursor-pointer rounded-md p-1 text-rose-700 transition hover:bg-rose-500/20 dark:text-rose-100"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
@@ -4,10 +4,13 @@ import ReactDOM from 'react-dom/client';
|
||||
|
||||
import { App } from './App';
|
||||
import { createLibsodiumBackend } from './lib/cryptoBackend';
|
||||
import { installCrashHandlers } from './lib/crashRecovery';
|
||||
import { bootstrapI18n } from './lib/i18n';
|
||||
import './styles/globals.css';
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
// Install first so bootstrap errors (crypto backend, i18n) are captured.
|
||||
installCrashHandlers();
|
||||
bootstrapI18n();
|
||||
setCryptoBackend(await createLibsodiumBackend());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user