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,