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:
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user