feat(desktop): attach Electron webContents handlers for recovery detection

- did-navigate + did-finish-load arm/disarm boot timer (skips SPA routing)
- did-fail-load filtered by isMainFrame and ERR_ABORTED
- render-process-gone filtered to exclude clean-exit
- unresponsive uses 10s grace with responsive cancellation
This commit is contained in:
Jannis Braun
2026-05-03 04:25:03 +02:00
parent 0c489701fc
commit 1c4bde3029
+67
View File
@@ -393,3 +393,70 @@ export function handleRecoveryAction(action: RecoveryAction): void {
} }
} }
} }
// ---------------------------------------------------------------------------
// webContents event handlers
// ---------------------------------------------------------------------------
// Attaches Electron BrowserWindow event listeners that feed into recovery mode.
// All closure-local state (pendingArm, unresponsiveTimer) is scoped to the
// function call so each window attachment gets its own independent instance.
// ---------------------------------------------------------------------------
const UNRESPONSIVE_GRACE_MS = 10_000;
export function attachRecoveryHandlers(win: BrowserWindow): void {
let pendingArm = false;
let unresponsiveTimer: ReturnType<typeof setTimeout> | null = null;
// did-navigate fires only for top-level non-same-document navigation.
// SPA in-page routing (history.pushState, hash) fires did-navigate-in-page,
// which we deliberately ignore — keeps the boot timer from misfiring on
// every channel switch.
win.webContents.on('did-navigate', () => {
clearBootTimer();
pendingArm = true;
});
win.webContents.on('did-finish-load', () => {
if (pendingArm) {
pendingArm = false;
armBootTimer(win);
}
});
win.webContents.on('did-fail-load',
(_e, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (!isMainFrame) return; // ignore sub-resource failures (favicon, broken <img>)
if (errorCode === -3) return; // ERR_ABORTED — intentional nav interruption
enterRecoveryMode({
code: 'load-failed',
detail: `${errorCode} ${errorDescription} @ ${validatedURL}`,
});
});
win.webContents.on('render-process-gone', (_e, details) => {
if (details.reason === 'clean-exit') return; // normal quit, ignore
enterRecoveryMode({
code: 'render-gone',
detail: details.reason, // crashed | killed | oom | launch-failed | integrity-failure
});
});
// unresponsive grace period — Chrome's own pattern. Renderers often recover
// from a long sync op or GC pause within seconds; only enter recovery if
// the renderer stays stuck for the full grace window.
win.webContents.on('unresponsive', () => {
if (unresponsiveTimer) return;
unresponsiveTimer = setTimeout(() => {
unresponsiveTimer = null;
enterRecoveryMode({ code: 'unresponsive', detail: `main thread blocked >${UNRESPONSIVE_GRACE_MS}ms` });
}, UNRESPONSIVE_GRACE_MS);
});
win.webContents.on('responsive', () => {
if (unresponsiveTimer) {
clearTimeout(unresponsiveTimer);
unresponsiveTimer = null;
}
});
}