diff --git a/frontend/src/shared/viewTeardown.ts b/frontend/src/shared/viewTeardown.ts index 7c282033..c43af725 100644 --- a/frontend/src/shared/viewTeardown.ts +++ b/frontend/src/shared/viewTeardown.ts @@ -2,18 +2,29 @@ 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; +// A wedged app must never hold a card open; cap the whole quiesce so delete stays responsive. The +// old 250ms timer was fail-OPEN: on a loaded machine the about:blank commit takes longer, the timer +// won, and the destroy ripped a LIVE GPU surface mid-composite, which is the whole-app crash family +// (ENG-228, Haik's close-crashes). Now we wait for the commit EVENT with a generous ceiling; the +// UI already hid the card, so the extra wait costs nothing visible. +const QUIESCE_BUDGET_MS = 1500; -// Navigate a doomed card's webview to about:blank so the running app's heavy GPU surfaces are released BEFORE React destroys the , leaving only a trivial surface to tear down. Bounded + fail-open. +// Navigate a doomed card's webview to about:blank so the running app's heavy GPU surfaces are released BEFORE React destroys the , leaving only a trivial surface to tear down. export async function quiesceViewWebview(outputId: string): Promise { const wv = getViewWebview(outputId); if (!wv) return; try { + const committed = new Promise((resolve) => { + const done = (): void => { wv.removeEventListener('did-navigate', done); resolve(); }; + wv.addEventListener('did-navigate', done); + }); + void wv.loadURL('about:blank').catch(() => {}); await Promise.race([ - wv.loadURL('about:blank').catch(() => {}), + committed, new Promise((resolve) => setTimeout(resolve, QUIESCE_BUDGET_MS)), ]); + // One settle frame after commit so the compositor lets go of the old surface before React unmounts the element. + await new Promise((resolve) => requestAnimationFrame(() => resolve())); } catch { // webview already torn down; nothing to quiesce }