From 028d9dcd62fa728f69bf80e1364b114773d5864e Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 23:24:21 -0700 Subject: [PATCH] [eric] crash: quiesce+detach outgoing webviews before dashboard-switch resetLayout (it unmounted every app/browser webview in one frame w/ CDP attached = GPU/browser SIGSEGV, the 'navigate away kills the app' self-quit) --- .../hooks/lifecycle/useDashboardLifecycle.ts | 15 ++++++++---- frontend/src/shared/browserRegistry.ts | 4 ++++ .../src/shared/dashboardSwitchTeardown.ts | 23 +++++++++++++++++++ frontend/src/shared/viewWebviewRegistry.ts | 4 ++++ 4 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 frontend/src/shared/dashboardSwitchTeardown.ts diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index d2d65512..6e585575 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -29,6 +29,7 @@ import { fetchProviderHealth } from '@/shared/state/subscriptionsSlice'; import { dashboardWs } from '@/shared/ws/WebSocketManager'; import { initBrowserCommandHandler } from '@/shared/browserCommandHandler'; import { getKeepAliveBrowserIds } from '@/shared/browserFocus'; +import { prepareDashboardSwitch } from '@/shared/dashboardSwitchTeardown'; import { clearPendingBrowserUrl, clearPendingFocusAgentId } from '@/shared/state/tempStateSlice'; import { API_BASE } from '@/shared/config'; import type { CanvasActions } from '../interaction/useCanvasControls'; @@ -118,10 +119,16 @@ export function useDashboardLifecycle({ hasFittedRef.current = false; restoredExpandedRef.current = false; setOutputsRefetched(false); - dispatch(resetLayout({ keepBrowserIds: getKeepAliveBrowserIds() })); - // CRITICAL path: these populate the cards the user expects to see on first paint. Don't defer. - dispatch(fetchSessions({ dashboardId })); - dispatch(fetchLayout({ dashboardId })); + const keep = getKeepAliveBrowserIds(); + // Quiesce + CDP-detach the OUTGOING dashboard's webviews serially BEFORE the store reset + // unmounts them all in one frame (the "navigate away kills the app" self-quit). Bounded + + // fail-open, so a wedged webview can never block the switch. See dashboardSwitchTeardown. + void prepareDashboardSwitch(keep).finally(() => { + dispatch(resetLayout({ keepBrowserIds: keep })); + // CRITICAL path: these populate the cards the user expects to see on first paint. Don't defer. + dispatch(fetchSessions({ dashboardId })); + dispatch(fetchLayout({ dashboardId })); + }); } const cleanupBrowserHandler = initBrowserCommandHandler(); // Global broadcasts (spawned browser cards) skip the replay log, so a socket gap loses them; a reconnect refetch is the only way they return. diff --git a/frontend/src/shared/browserRegistry.ts b/frontend/src/shared/browserRegistry.ts index d4151a52..69299581 100644 --- a/frontend/src/shared/browserRegistry.ts +++ b/frontend/src/shared/browserRegistry.ts @@ -116,6 +116,10 @@ export function getWebview(browserId: string, tabId?: string): BrowserWebview | return registry.get(makeKey(browserId, resolvedTabId)); } +export function getAllBrowserIds(): string[] { + return [...new Set([...registry.keys()].map((k) => k.split(':')[0]))]; +} + export function getBrowserWebviews(browserId: string): BrowserWebview[] { const out: BrowserWebview[] = []; for (const [key, wv] of registry.entries()) { diff --git a/frontend/src/shared/dashboardSwitchTeardown.ts b/frontend/src/shared/dashboardSwitchTeardown.ts new file mode 100644 index 00000000..42a66fe2 --- /dev/null +++ b/frontend/src/shared/dashboardSwitchTeardown.ts @@ -0,0 +1,23 @@ +import { getAllViewOutputIds } from '@/shared/viewWebviewRegistry'; +import { getAllBrowserIds } from '@/shared/browserRegistry'; +import { quiesceViewWebview } from '@/shared/viewTeardown'; +import { detachBrowserCdp } from '@/shared/browserTeardown'; + +// Switching dashboards clears every card from the store in ONE reducer (resetLayout), so React +// unmounts all the outgoing app + browser s in a single frame. Ripping several live GPU +// surfaces out at once (app previews) or unmounting a browser with its CDP debugger still attached +// piles up "non-existent mailbox" errors and SIGSEGVs the GPU/browser process, taking the whole app +// down with no crash dump (the "navigate away and it quits itself" bug). Quiesce + detach the +// outgoing webviews ONE AT A TIME first, so only trivial surfaces are left to tear down. Bounded per +// item (the helpers self-cap), fail-open, and keep-alive browsers are skipped so they survive the +// switch with their session intact. +export async function prepareDashboardSwitch(keepBrowserIds: string[]): Promise { + const keep = new Set(keepBrowserIds); + for (const outputId of getAllViewOutputIds()) { + await quiesceViewWebview(outputId); + } + for (const browserId of getAllBrowserIds()) { + if (keep.has(browserId)) continue; + await detachBrowserCdp(browserId); + } +} diff --git a/frontend/src/shared/viewWebviewRegistry.ts b/frontend/src/shared/viewWebviewRegistry.ts index 866825e0..7702dc7a 100644 --- a/frontend/src/shared/viewWebviewRegistry.ts +++ b/frontend/src/shared/viewWebviewRegistry.ts @@ -11,6 +11,10 @@ export function registerViewWebview(outputId: string, wv: ViewWebview): void { registry.set(outputId, wv); } +export function getAllViewOutputIds(): string[] { + return [...registry.keys()]; +} + export function unregisterViewWebview(outputId: string): void { registry.delete(outputId); }