[eric] dashboard: quiesce + serialize app-card delete teardown to stop GPU-death crash

This commit is contained in:
ciregenz
2026-06-25 23:05:19 -07:00
parent ccd0bc3971
commit 4ef03b6508
6 changed files with 80 additions and 11 deletions
@@ -10,7 +10,8 @@ import RestartAltIcon from '@mui/icons-material/RestartAlt';
import CloseIcon from '@mui/icons-material/Close';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import { Output, SERVE_BASE } from '@/shared/state/outputsSlice';
import { setViewCardPosition, setViewCardSize, removeViewCard, setActiveViewCardId } from '@/shared/state/dashboardLayoutSlice';
import { setViewCardPosition, setViewCardSize, setActiveViewCardId } from '@/shared/state/dashboardLayoutSlice';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { API_BASE, getAuthToken } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -303,7 +304,7 @@ const DashboardViewCard: React.FC<Props> = ({
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
dispatch(removeViewCard(output.id));
void removeViewCardCleanly(output.id, dispatch);
};
const handleRefresh = (e: React.MouseEvent) => {
@@ -686,7 +687,7 @@ const DashboardOutputPreview: React.FC<{
This app's files are missing.
</Typography>
<Typography
onClick={() => dispatch(removeViewCard(output.id))}
onClick={() => void removeViewCardCleanly(output.id, dispatch)}
sx={{
color: tokens.accent.primary,
fontSize: '0.85rem',
@@ -713,6 +714,7 @@ const DashboardOutputPreview: React.FC<{
return (
<ViewPreview
ref={previewRef}
registryId={output.id}
serveUrl={url}
frontendCode={output.files?.['index.html'] ?? ''}
inputData={inputData}
@@ -2,9 +2,10 @@ import { useEffect, type Dispatch, type SetStateAction } from 'react';
import { report } from '@/shared/serviceClient';
import { useAppDispatch } from '@/shared/hooks';
import { closeSession, toggleExpandSession } from '@/shared/state/agentsSlice';
import { removeViewCard, removeNote, removeWorkflowCard, closeWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
import { removeNote, removeWorkflowCard, closeWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
import { closeWorkflowCard } from '@/shared/state/workflowsSlice';
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
import type { useDashboardSelection } from '../state/useDashboardSelection';
type Selection = ReturnType<typeof useDashboardSelection>;
@@ -27,7 +28,7 @@ export function useDashboardShortcuts({
const dispatch = useAppDispatch();
useEffect(() => {
const parts = newAgentShortcut.toLowerCase().split('+');
const parts = (newAgentShortcut || '').toLowerCase().split('+');
const key = parts[parts.length - 1];
const needsMeta = parts.includes('meta');
const needsCtrl = parts.includes('ctrl');
@@ -72,11 +73,12 @@ export function useDashboardShortcuts({
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
if (selection.selectedIds.size === 0) return;
e.preventDefault();
const viewIds: string[] = [];
for (const [id, type] of selection.selectedIds) {
if (type === 'agent') {
dispatch(closeSession({ sessionId: id }));
} else if (type === 'view') {
dispatch(removeViewCard(id));
viewIds.push(id);
} else if (type === 'browser') {
removeBrowserCardCleanly(id, dispatch);
} else if (type === 'note') {
@@ -88,6 +90,8 @@ export function useDashboardShortcuts({
dispatch(closeWorkflowsHub());
}
}
// Tear view cards down ONE AT A TIME (each quiesces its GPU surface first); ripping several large app webviews out in one frame is what piles up "non-existent mailbox" errors and kills the GPU process.
void (async () => { for (const id of viewIds) await removeViewCardCleanly(id, dispatch); })();
selection.deselectAll();
};
window.addEventListener('keydown', handleDelete);
@@ -14,13 +14,13 @@ import {
addBrowserCard,
addViewCard,
resetLayout,
removeViewCard,
clearPendingFocusBrowserId,
clearPendingFocusWorkflowId,
clearPendingFocusWorkflowsHub,
type ViewCardPosition,
} from '@/shared/state/dashboardLayoutSlice';
import { fetchOutputs, type Output } from '@/shared/state/outputsSlice';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
import { generateDashboardName } from '@/shared/state/dashboardsSlice';
import { fetchWorkflows, fetchAllRuns, fetchActiveRuns } from '@/shared/state/workflowsSlice';
import { fetchMissedRuns } from '@/shared/state/missedRunsSlice';
@@ -280,11 +280,17 @@ export function useDashboardLifecycle({
}, [sessions, layoutInitialized, dispatch, dashboardId, expandedSessionIds]);
// Prune orphan view cards whose underlying output was deleted (e.g. via the Views page). Without this, the layout entry persists in the minimap and contentBounds even though DashboardViewCard renders nothing. Gated on outputsRefetched (THIS open's fresh fetch), NOT the sticky global outputsLoaded: on a freshly-imported dashboard the global flag is already true from a prior dashboard, so the old gate pruned the just-imported app card against a stale apps list and the debounced save persisted the wipe.
const pruningRef = useRef(false);
useEffect(() => {
if (!layoutInitialized || !outputsRefetched) return;
for (const outputId of Object.keys(viewCards)) {
if (!outputs[outputId]) dispatch(removeViewCard(outputId));
}
if (!layoutInitialized || !outputsRefetched || pruningRef.current) return;
const orphans = Object.keys(viewCards).filter((outputId) => !outputs[outputId]);
if (!orphans.length) return;
pruningRef.current = true;
// Serialize the prune (one quiesce at a time) so deleting a couple of large apps via the Views page can't rip several live webview GPU surfaces out in one frame; the ref stops this effect's own dispatches from spawning overlapping loops.
void (async () => {
try { for (const outputId of orphans) await removeViewCardCleanly(outputId, dispatch); }
finally { pruningRef.current = false; }
})();
}, [layoutInitialized, outputsRefetched, viewCards, outputs, dispatch]);
// On first load after outputs settle, snapshot every existing Output id as "already accounted for." Any output that ARRIVES later (typically the agent:output_upserted WS broadcast the backend fires the instant a view-builder session is seeded, at session start) whose session_id points at a view-builder chat on this dashboard gets a view card dropped on the canvas right away. Per-mount tracked so a manual close after auto-open stays closed. Prior approach keyed off a pending-set populated inside launchAndSendFirstMessage.then(): the WS upsert won the race and the effect saw an empty set, so the card didn't pop until the session-end meta-sync re-broadcast.
@@ -6,6 +6,7 @@ import { useElementSelection } from '@/app/components/editor/ElementSelectionCon
import { useIframeElementSelector } from './useIframeElementSelector';
import { getAuthToken, ensureAuthToken } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { registerViewWebview, unregisterViewWebview, type ViewWebview } from '@/shared/viewWebviewRegistry';
// In Electron use <webview> to escape iframe restrictions (popups, mic/camera, WebAuthn, cookied fetch); outside Electron fall back to iframe.
const isElectron = navigator.userAgent.includes('Electron');
@@ -57,6 +58,8 @@ interface Props {
interactive?: boolean;
/** Fired when the preload reports a mousedown inside the guest, so the host can flip the card into interactive mode. */
onAppClicked?: () => void;
/** Dashboard card's output id. When set, the live webview registers under it so the delete path can quiesce its GPU surface before unmount. Omitted in the App Builder (no card teardown). */
registryId?: string;
}
function buildSrcdoc(
@@ -96,6 +99,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
onContentLoad,
interactive = false,
onAppClicked,
registryId,
}, ref) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const webviewRef = useRef<any>(null);
@@ -276,6 +280,15 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
};
}, [useWebview, onConsoleMessage, onAppClicked, iframeSrc]);
// Register the live webview so the dashboard delete path can quiesce its GPU surface before unmount; unregister on teardown so a stale handle never gets navigated.
useEffect(() => {
if (!useWebview || !registryId) return;
const wv = webviewRef.current;
if (!wv) return;
registerViewWebview(registryId, wv as ViewWebview);
return () => unregisterViewWebview(registryId);
}, [useWebview, registryId, iframeSrc]);
// Mirror `interactive` into a ref so the once-per-load did-finish-load listener can read the latest value when it pushes initial state.
const interactiveRef = useRef(interactive);
interactiveRef.current = interactive;
+26
View File
@@ -0,0 +1,26 @@
import type { Dispatch } from '@reduxjs/toolkit';
import { removeViewCard } from '@/shared/state/dashboardLayoutSlice';
import { getViewWebview } from '@/shared/viewWebviewRegistry';
// A wedged app must never hold a card open; cap the whole quiesce so delete stays responsive. Common case (about:blank is a trivial nav) resolves in well under this.
const QUIESCE_BUDGET_MS = 250;
// Navigate a doomed card's webview to about:blank so the running app's heavy GPU surfaces are released BEFORE React destroys the <webview>, leaving only a trivial surface to tear down. Bounded + fail-open.
export async function quiesceViewWebview(outputId: string): Promise<void> {
const wv = getViewWebview(outputId);
if (!wv) return;
try {
await Promise.race([
wv.loadURL('about:blank').catch(() => {}),
new Promise<void>((resolve) => setTimeout(resolve, QUIESCE_BUDGET_MS)),
]);
} catch {
// webview already torn down; nothing to quiesce
}
}
// Quiesce a card's live preview surface, THEN remove it. Every view-card delete path routes through here so none rips a live <webview> GPU surface out mid-composite. Awaited in a loop (multi-select Delete, orphan prune) the teardowns SERIALIZE, which is what stops the simultaneous "non-existent mailbox" pile-up that kills the GPU process.
export async function removeViewCardCleanly(outputId: string, dispatch: Dispatch): Promise<void> {
await quiesceViewWebview(outputId);
dispatch(removeViewCard(outputId));
}
@@ -0,0 +1,18 @@
// Live app-card preview webviews keyed by output id. The delete path looks a card's <webview> up here to quiesce its GPU surface BEFORE React rips the element out; without it, deleting a couple of large app cards at once tears down several live SharedImage surfaces in one frame, which piles up "non-existent mailbox" errors and kills the GPU process (taking the whole app down with no dump). Mirror of browserRegistry, for the non-CDP preview webviews.
export interface ViewWebview extends HTMLElement {
loadURL: (url: string) => Promise<void>;
}
const registry = new Map<string, ViewWebview>();
export function registerViewWebview(outputId: string, wv: ViewWebview): void {
registry.set(outputId, wv);
}
export function unregisterViewWebview(outputId: string): void {
registry.delete(outputId);
}
export function getViewWebview(outputId: string): ViewWebview | undefined {
return registry.get(outputId);
}