diff --git a/electron/main.js b/electron/main.js index 0ee24eb8..13162298 100644 --- a/electron/main.js +++ b/electron/main.js @@ -143,6 +143,7 @@ let cachedUpdateStatus = { status: 'idle', info: null, error: null }; let splashWindow = null; let mainWindowReady = false; let isQuittingFromSplash = false; // guards against double-quit during error shutdown +let rendererCrashTimes = []; // timestamps of recent render-process-gone events; caps the auto-reload retry storm const recentBackendStderr = []; // ring buffer (last ~60 lines) for splash error UI let splashDataUrlCache = null; @@ -738,6 +739,30 @@ function createWindow() { mainWindow = null; }); + // Renderer process death (GPU/native/OOM crash) is invisible to React error + // boundaries: the whole content process is gone, so JS never runs to catch + // anything. Without this the window just sits blank forever. Reload to + // recover, but cap retries so a deterministic crash-on-load can't pin the CPU + // in an infinite reload storm; after the cap we leave it so the splash/quit + // path can take over rather than thrash. + mainWindow.webContents.on('render-process-gone', (_event, details) => { + const reason = details && details.reason; + if (reason === 'clean-exit') return; + // Don't fight the shutdown: the renderer dying mid-quit-drain is expected, reloading it then would resurrect a window we're trying to close. + if (drainingForQuit) return; + console.error('[main] renderer process gone:', reason); + const now = Date.now(); + rendererCrashTimes = rendererCrashTimes.filter((t) => now - t < 60_000); + if (rendererCrashTimes.length >= 3) { + console.error('[main] renderer crashed 3x in 60s — not auto-reloading again'); + return; + } + rendererCrashTimes.push(now); + try { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.reload(); + } catch (_) {} + }); + // Window-blur / window-focus tracking — analytics signal for "user // switched to another app" (temp-churn). The renderer captures these // through the existing report() pipeline; we just emit IPC notices diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index d7ebc00f..922990f4 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -20,6 +20,7 @@ import { import AppShell from './components/Layout/AppShell'; import DashboardSelection from './pages/DashboardSelection/DashboardSelection'; import ErrorBoundary from './components/ErrorBoundary'; +import { setPanelMode, disableOnboardingAfterCrash } from './components/Onboarding/OnboardingProgressSlice'; const Skills = lazy(() => import('./pages/Skills/Skills')); const Tools = lazy(() => import('./pages/Tools/Tools')); const Modes = lazy(() => import('./pages/Modes/Modes')); @@ -448,9 +449,11 @@ const ThemedApp: React.FC = () => { - - - + + + + + @@ -462,6 +465,30 @@ const ThemedApp: React.FC = () => { ); }; +/** + * Onboarding must never be able to take the whole app down. It mounts beside the + * routes (not under them), so before this guard a render throw bubbled to the root + * boundary and blanked everything. Here we catch it locally: keep the dashboard + * alive (fallback null), report it under its own scope so the stack finally shows + * up in telemetry, and dismiss the tour in storage so the next launch doesn't drop + * the user straight back into the same crash. Settings > restart tour re-enables it. + */ +const OnboardingErrorGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const dispatch = useAppDispatch(); + return ( + { + try { dispatch(setPanelMode('hidden')); } catch {} + disableOnboardingAfterCrash(); + }} + > + {children} + + ); +}; + // useRouteTracker calls useLocation, must be inside HashRouter. const RouteTrackerMount: React.FC = () => { useRouteTracker(); diff --git a/frontend/src/app/components/ErrorBoundary.tsx b/frontend/src/app/components/ErrorBoundary.tsx index 9d36610d..4d5e2dbb 100644 --- a/frontend/src/app/components/ErrorBoundary.tsx +++ b/frontend/src/app/components/ErrorBoundary.tsx @@ -8,6 +8,10 @@ interface Props { onReset?: () => void; /** Where the boundary lives, for support ("root", "page:tools", etc.). */ scope?: string; + /** Render this instead of the full-screen card when set; pass `null` for a boundary that just quietly unmounts its subtree and leaves the rest of the app alone. */ + fallback?: React.ReactNode; + /** Fired once when an error is caught, so a parent can react (e.g. dismiss the crashed feature) without the whole app going down. */ + onError?: (error: Error, info: React.ErrorInfo) => void; children: React.ReactNode; } @@ -33,6 +37,7 @@ class ErrorBoundary extends React.Component { recent_actions: getRecentActions(10), }); } catch {} + try { this.props.onError?.(error, info); } catch {} if (typeof console !== 'undefined' && console.error) { console.error('[ErrorBoundary]', error, info); } @@ -51,7 +56,8 @@ class ErrorBoundary extends React.Component { try { const keys = Object.keys(localStorage); for (const k of keys) { - if (k.startsWith('openswarm:') || k.startsWith('redux-')) { + // Both namespaces exist in the wild: colon (openswarm:foo) and dot (openswarm.onboarding.v2, openswarm.migrations.*). Match either or the reset silently misses onboarding state. + if (k.startsWith('openswarm:') || k.startsWith('openswarm.') || k.startsWith('redux-')) { localStorage.removeItem(k); } } @@ -63,6 +69,9 @@ class ErrorBoundary extends React.Component { const { error } = this.state; if (!error) return this.props.children; + // Caller opted into a quiet fallback (e.g. null): unmount the broken subtree, leave the rest of the app standing. + if (this.props.fallback !== undefined) return <>{this.props.fallback}; + const title = this.props.title || 'Something broke.'; const wrap: React.CSSProperties = { minHeight: '100vh', diff --git a/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts b/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts index c9e959fb..3a0ed094 100644 --- a/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts +++ b/frontend/src/app/components/Onboarding/OnboardingProgressSlice.ts @@ -65,6 +65,24 @@ export function persistToStorage(state: OnboardingProgressState): void { } } +/** Persist a hidden-on-crash marker straight to storage. The error boundary unmounts OnboardingRoot, so its own debounced persist effect can't run; write here or the dismissal won't survive a reload and the user re-enters the crash. Settings > restart tour clears it. */ +export function disableOnboardingAfterCrash(): void { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + // Parse defensively: a corrupt value must not abort the write, or the dismiss never persists and the user re-enters the crash on reload. + let parsed: Record = {}; + if (raw) { + try { parsed = JSON.parse(raw) || {}; } catch { parsed = {}; } + } + parsed.version = SCHEMA_VERSION; + parsed.panelMode = 'hidden'; + parsed.dismissedAt = Date.now(); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(parsed)); + } catch { + /* localStorage unavailable */ + } +} + const initialState: OnboardingProgressState = { version: SCHEMA_VERSION, startedAt: 0,