From 1c4bde30291535e7e7d07aa0ec511daa85376833 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 04:25:03 +0200 Subject: [PATCH] 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 --- packages/desktop/src/recovery.ts | 67 ++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/desktop/src/recovery.ts b/packages/desktop/src/recovery.ts index da15ee5b..be3f8191 100644 --- a/packages/desktop/src/recovery.ts +++ b/packages/desktop/src/recovery.ts @@ -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 | 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 ) + 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; + } + }); +}