[eric] onboarding: crash can't blank app; isolate boundary + dismiss + renderer reload

This commit is contained in:
ciregenz
2026-05-23 19:16:04 -07:00
parent b7fdb10dae
commit 4c5457c751
4 changed files with 83 additions and 4 deletions
+25
View File
@@ -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
+30 -3
View File
@@ -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 = () => {
</Routes>
</Suspense>
</ErrorBoundary>
<Suspense fallback={null}>
<OnboardingRoot />
</Suspense>
<OnboardingErrorGuard>
<Suspense fallback={null}>
<OnboardingRoot />
</Suspense>
</OnboardingErrorGuard>
</DeepLinkListener>
</UpdateListener>
</DefaultModelGuard>
@@ -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 (
<ErrorBoundary
scope="onboarding"
fallback={null}
onError={() => {
try { dispatch(setPanelMode('hidden')); } catch {}
disableOnboardingAfterCrash();
}}
>
{children}
</ErrorBoundary>
);
};
// useRouteTracker calls useLocation, must be inside HashRouter.
const RouteTrackerMount: React.FC = () => {
useRouteTracker();
+10 -1
View File
@@ -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<Props, State> {
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<Props, State> {
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<Props, State> {
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',
@@ -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<string, unknown> = {};
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,