diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index b2ede00a..49c2ba22 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -33,6 +33,7 @@ import { } from './linux-autostart.mjs'; import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs'; import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs'; +import { createRendererRecoveryPolicy } from './renderer-recovery.mjs'; import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js'; const execFileAsync = promisify(execFile); @@ -2496,6 +2497,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } }; const browserWindow = new BrowserWindow(options); + const rendererRecoveryPolicy = createRendererRecoveryPolicy(); browserWindow.__ocLabel = label || nextWindowLabel(); browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken, requestHeaders: desktopRequestHeaders }; browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders); @@ -2656,6 +2658,19 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } browserWindow.webContents.on('zoom-changed', () => { browserWindow.webContents.setZoomFactor(1); }); + browserWindow.webContents.on('render-process-gone', (_event, details) => { + if (!rendererRecoveryPolicy.shouldReload(details.reason)) return; + log.warn('[electron] renderer exited unexpectedly; reloading window', { + label: browserWindow.__ocLabel, + reason: details.reason, + exitCode: details.exitCode, + }); + setTimeout(() => { + if (!browserWindow.isDestroyed()) { + browserWindow.webContents.reload(); + } + }, 100); + }); browserWindow.webContents.on('dom-ready', () => { if (browserWindow.__ocLabel === 'main') { diff --git a/packages/electron/renderer-recovery.mjs b/packages/electron/renderer-recovery.mjs new file mode 100644 index 00000000..77037210 --- /dev/null +++ b/packages/electron/renderer-recovery.mjs @@ -0,0 +1,30 @@ +const RECOVERY_WINDOW_MS = 60_000; +const MAX_RECOVERY_ATTEMPTS = 3; + +const RECOVERABLE_REASONS = new Set([ + 'abnormal-exit', + 'crashed', + 'oom', + 'memory-eviction', +]); + +export const createRendererRecoveryPolicy = (now = Date.now) => { + let windowStartedAt = 0; + let attempts = 0; + + return { + shouldReload: (reason) => { + if (!RECOVERABLE_REASONS.has(reason)) return false; + + const currentTime = now(); + if (currentTime - windowStartedAt >= RECOVERY_WINDOW_MS) { + windowStartedAt = currentTime; + attempts = 0; + } + if (attempts >= MAX_RECOVERY_ATTEMPTS) return false; + + attempts += 1; + return true; + }, + }; +}; diff --git a/packages/electron/renderer-recovery.test.mjs b/packages/electron/renderer-recovery.test.mjs new file mode 100644 index 00000000..a0240051 --- /dev/null +++ b/packages/electron/renderer-recovery.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createRendererRecoveryPolicy } from './renderer-recovery.mjs'; + +test('allows a bounded number of reloads for recoverable renderer failures', () => { + const policy = createRendererRecoveryPolicy(() => 1_000); + + assert.equal(policy.shouldReload('crashed'), true); + assert.equal(policy.shouldReload('oom'), true); + assert.equal(policy.shouldReload('abnormal-exit'), true); + assert.equal(policy.shouldReload('memory-eviction'), false); +}); + +test('ignores clean and externally killed renderer exits', () => { + const policy = createRendererRecoveryPolicy(() => 1_000); + + assert.equal(policy.shouldReload('clean-exit'), false); + assert.equal(policy.shouldReload('killed'), false); + assert.equal(policy.shouldReload('launch-failed'), false); +}); + +test('resets the recovery budget after the recovery window', () => { + let currentTime = 1_000; + const policy = createRendererRecoveryPolicy(() => currentTime); + + assert.equal(policy.shouldReload('crashed'), true); + assert.equal(policy.shouldReload('crashed'), true); + assert.equal(policy.shouldReload('crashed'), true); + assert.equal(policy.shouldReload('crashed'), false); + + currentTime += 60_000; + assert.equal(policy.shouldReload('crashed'), true); +});