[eric] canvas: a dead webview guest reloads itself instead of painting a black rectangle; crash fires neither load event so nothing healed it (ENG-322)

This commit is contained in:
ciregenz
2026-08-16 14:27:26 -07:00
parent 1e490619b9
commit 267f692410
4 changed files with 42 additions and 0 deletions
+1
View File
@@ -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.",
],
),
@@ -538,6 +538,8 @@ const BrowserCard: React.FC<Props> = ({
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);
@@ -379,13 +379,27 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
}, 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]);
@@ -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');
});