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 { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppShell } from './components/AppShell';
|
import { AppShell } from './components/AppShell';
|
||||||
|
import { CrashToast } from './components/CrashToast';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import { UpdateToast } from './components/UpdateToast';
|
import { UpdateToast } from './components/UpdateToast';
|
||||||
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
|
||||||
@@ -91,6 +92,7 @@ export function App() {
|
|||||||
<Route path="*" element={<Navigate to="/chats" replace />} />
|
<Route path="*" element={<Navigate to="/chats" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<UpdateToast />
|
<UpdateToast />
|
||||||
|
<CrashToast />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</CallProvider>
|
</CallProvider>
|
||||||
</ConversationsProvider>
|
</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 { App } from './App';
|
||||||
import { createLibsodiumBackend } from './lib/cryptoBackend';
|
import { createLibsodiumBackend } from './lib/cryptoBackend';
|
||||||
|
import { installCrashHandlers } from './lib/crashRecovery';
|
||||||
import { bootstrapI18n } from './lib/i18n';
|
import { bootstrapI18n } from './lib/i18n';
|
||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
|
|
||||||
async function boot(): Promise<void> {
|
async function boot(): Promise<void> {
|
||||||
|
// Install first so bootstrap errors (crypto backend, i18n) are captured.
|
||||||
|
installCrashHandlers();
|
||||||
bootstrapI18n();
|
bootstrapI18n();
|
||||||
setCryptoBackend(await createLibsodiumBackend());
|
setCryptoBackend(await createLibsodiumBackend());
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user