diff --git a/backend/apps/help/changelog.py b/backend/apps/help/changelog.py index 6286e6cf..75ac9fb9 100644 --- a/backend/apps/help/changelog.py +++ b/backend/apps/help/changelog.py @@ -94,6 +94,7 @@ P_RELEASES: List[ReleaseNote] = [ "A newly connected ChatGPT or Gemini subscription works immediately. The routing layer restarts itself the moment a connect completes, so new subscriptions no longer sit dead behind rate-limit errors until you restart the app.", "Finished apps opened from the dock no longer sit on \"Starting preview\" forever. Apps served straight from their built files have no server process by design, and the preview was waiting for one that would never exist.", "A chat that was cut off mid-answer now says so right on the board with an amber \"Stopped mid-task, click to resume\" chip, instead of looking idle until you open it and hunt for the resume button.", + "An app or browser card whose page process dies now reloads itself instead of sitting as a solid black rectangle. The crash fired no load event at all, so nothing ever repainted it.", "Heavy sessions no longer vanish without a trace. When memory climbs past the safe line the app now sheds weight itself: preview thumbnails pause and refetchable caches drop, instead of growing until the operating system kills it mid-task.", ], ), diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index c43a5f31..9ead6283 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -538,6 +538,8 @@ const BrowserCard: React.FC = ({ const onGuestGone = (e: Event): void => { const d = e as Event & { reason?: string; exitCode?: number }; report('process', 'webview_gone', { reason: d.reason ?? 'crashed', exit_code: d.exitCode ?? null }); + // Reporting alone left a black rectangle on the board; the guest is dead either way, so a reload can only win (ENG-322). + window.setTimeout(() => { try { (wv as unknown as { reload?: () => void }).reload?.(); } catch (_) {} }, 800); }; wv.addEventListener('render-process-gone', onGuestGone); wv.addEventListener('crashed', onGuestGone); diff --git a/frontend/src/app/pages/Views/ViewPreview.tsx b/frontend/src/app/pages/Views/ViewPreview.tsx index 62f336a7..e7a03496 100644 --- a/frontend/src/app/pages/Views/ViewPreview.tsx +++ b/frontend/src/app/pages/Views/ViewPreview.tsx @@ -379,13 +379,27 @@ const ViewPreview = forwardRef(({ }, retryDelay); }; + // A crashed guest fires NEITHER finish nor fail; the element stays mounted painting solid + // black (caught live: 7 dead guests under 7 mounted app cards, ENG-322). Reload on the spot. + const onGuestGone = () => { + if (retryTimer != null) return; + retryTimer = window.setTimeout(() => { + retryTimer = null; + try { wv.reload?.(); } catch (_) {} + retryDelay = Math.min(retryDelay * 2, MAX_DELAY); + }, retryDelay); + }; wv.addEventListener?.('did-finish-load', onFinish); wv.addEventListener?.('did-fail-load', onFail); + wv.addEventListener?.('render-process-gone', onGuestGone); + wv.addEventListener?.('crashed', onGuestGone); return () => { cancelRetry(); try { wv.removeEventListener?.('did-finish-load', onFinish); wv.removeEventListener?.('did-fail-load', onFail); + wv.removeEventListener?.('render-process-gone', onGuestGone); + wv.removeEventListener?.('crashed', onGuestGone); } catch (_e) {} }; }, [useWebview, handleNavigationLoad]); diff --git a/frontend/src/app/pages/Views/guestGoneHeals.test.ts b/frontend/src/app/pages/Views/guestGoneHeals.test.ts new file mode 100644 index 00000000..c662b173 --- /dev/null +++ b/frontend/src/app/pages/Views/guestGoneHeals.test.ts @@ -0,0 +1,25 @@ +// A crashed webview guest fires NEITHER did-finish-load nor did-fail-load; the element stays +// mounted painting solid black, and nothing recovered it (caught live 2026-08-16: 7 dead guests +// under 7 mounted app cards on the packaged build, read straight off /json target list). Both +// card types now reload on guest death (ENG-322). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const view = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/Views/ViewPreview.tsx'), 'utf8'); +const browser = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/Dashboard/cards/BrowserCard.tsx'), 'utf8'); + +test('app cards reload a dead guest with the same backoff as fail-load', () => { + const effect = view.slice(view.indexOf('const onGuestGone'), view.indexOf('return () => {', view.indexOf('const onGuestGone'))); + assert.ok(effect.includes('wv.reload'), 'the heal is a reload, not just a listener'); + assert.ok(view.includes("addEventListener?.('render-process-gone', onGuestGone)")); + assert.ok(view.includes("removeEventListener?.('render-process-gone', onGuestGone)"), 'leak-free both directions'); +}); + +test('browser cards heal too, not only report', () => { + const start = browser.indexOf('const onGuestGone'); + const handler = browser.slice(start, browser.indexOf("addEventListener('render-process-gone'", start)); + assert.ok(handler.includes('webview_gone'), 'telemetry stays'); + assert.ok(handler.includes('reload'), 'reporting alone left a black rectangle on the board'); +});