From c8e703663d37de16a297a4458cd299f2d84604c5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 17 May 2026 16:36:44 -0700 Subject: [PATCH] Recover main window after renderer crashes (#2181) * Recover the main window after renderer crashes - Reload the app shell once after unexpected renderer process loss - Skip recovery during clean exits, quits, and window close paths - Cover renderer recovery behavior with focused window tests * fix: harden renderer crash recovery --- src/main/hermes/hook-service.test.ts | 2 +- src/main/index.ts | 1 + src/main/window/createMainWindow.test.ts | 176 +++++++++++++++++++++++ src/main/window/createMainWindow.ts | 43 ++++++ 4 files changed, 221 insertions(+), 1 deletion(-) diff --git a/src/main/hermes/hook-service.test.ts b/src/main/hermes/hook-service.test.ts index bc6bc1a5a..33218042b 100644 --- a/src/main/hermes/hook-service.test.ts +++ b/src/main/hermes/hook-service.test.ts @@ -121,7 +121,7 @@ describe('HermesHookService', () => { expect(output).toContain(_internals.HERMES_PLUGIN_NAME) expect(output.toLowerCase()).toContain('enabled') - }) + }, 20_000) it('registered plugin hooks post normalized JSON to Orca', async () => { const pythonAvailable = spawnSync('python3', ['--version'], { encoding: 'utf-8' }).status === 0 diff --git a/src/main/index.ts b/src/main/index.ts index 3f0e82354..8d4a08c05 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -277,6 +277,7 @@ function openMainWindow(): BrowserWindow { processType: 'renderer' }) }, + shouldRecoverRenderer: () => !isQuitting && !isQuittingForUpdate(), deferLoad: true, title: devInstanceIdentity.name }) diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index a0bf129cb..fe38b9cc5 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -1425,6 +1425,182 @@ describe('createMainWindow', () => { expect(onRendererProcessGone).toHaveBeenCalledWith(details) }) + const createRendererRecoveryWindowHarness = () => { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn((event, handler) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + return { browserWindowInstance, windowHandlers } + } + + it('reloads the app shell after an unexpected renderer process loss', () => { + vi.useFakeTimers() + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() + + createMainWindow(null) + + windowHandlers['render-process-gone']?.( + {} as never, + { + reason: 'crashed', + exitCode: 5 + } as Electron.RenderProcessGoneDetails + ) + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(250) + + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(2) + expect(browserWindowInstance.loadURL).not.toHaveBeenCalled() + + consoleError.mockRestore() + }) + + it('does not reload after renderer loss when recovery is disabled', () => { + vi.useFakeTimers() + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() + + createMainWindow(null, { shouldRecoverRenderer: () => false }) + + windowHandlers['render-process-gone']?.( + {} as never, + { + reason: 'crashed', + exitCode: 5 + } as Electron.RenderProcessGoneDetails + ) + vi.advanceTimersByTime(250) + + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(1) + expect(browserWindowInstance.loadURL).not.toHaveBeenCalled() + + consoleError.mockRestore() + }) + + it('rechecks the renderer recovery predicate before reloading', () => { + vi.useFakeTimers() + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() + let shouldRecover = true + + createMainWindow(null, { shouldRecoverRenderer: () => shouldRecover }) + + windowHandlers['render-process-gone']?.( + {} as never, + { + reason: 'crashed', + exitCode: 5 + } as Electron.RenderProcessGoneDetails + ) + shouldRecover = false + vi.advanceTimersByTime(250) + + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(1) + expect(browserWindowInstance.loadURL).not.toHaveBeenCalled() + + consoleError.mockRestore() + }) + + it('coalesces repeated renderer losses into one recovery reload', () => { + vi.useFakeTimers() + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() + + createMainWindow(null) + + const details = { + reason: 'crashed', + exitCode: 5 + } as Electron.RenderProcessGoneDetails + windowHandlers['render-process-gone']?.({} as never, details) + windowHandlers['render-process-gone']?.({} as never, details) + vi.advanceTimersByTime(250) + + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(2) + expect(browserWindowInstance.loadURL).not.toHaveBeenCalled() + + consoleError.mockRestore() + }) + + it('does not reload after a clean renderer exit', () => { + vi.useFakeTimers() + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() + + createMainWindow(null) + + windowHandlers['render-process-gone']?.( + {} as never, + { + reason: 'clean-exit', + exitCode: 0 + } as Electron.RenderProcessGoneDetails + ) + vi.advanceTimersByTime(250) + + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(1) + expect(browserWindowInstance.loadURL).not.toHaveBeenCalled() + + consoleError.mockRestore() + }) + + it('cancels renderer recovery when the crashed window is closing', () => { + vi.useFakeTimers() + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { browserWindowInstance, windowHandlers } = createRendererRecoveryWindowHarness() + + createMainWindow(null) + + windowHandlers['render-process-gone']?.( + {} as never, + { + reason: 'crashed', + exitCode: 5 + } as Electron.RenderProcessGoneDetails + ) + windowHandlers.close({ preventDefault: vi.fn() } as never) + vi.advanceTimersByTime(250) + + expect(browserWindowInstance.loadFile).toHaveBeenCalledTimes(1) + expect(browserWindowInstance.loadURL).not.toHaveBeenCalled() + + consoleError.mockRestore() + }) + it('ignores duplicate ready-to-show events after startup maximize has already run', () => { const windowHandlers: Record void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 11a515b17..f5b12728e 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -11,6 +11,7 @@ import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl } from '../../shared/browser-url' +import { isCrashReportReason } from '../../shared/crash-reporting' import { resolveWindowShortcutAction } from '../../shared/window-shortcut-policy' import { getMainE2EConfig } from '../e2e-config' import { buildEditableContextMenuTemplate } from './editable-context-menu' @@ -69,6 +70,10 @@ type CreateMainWindowOptions = { * quit attempts. */ onQuitAborted?: () => void onRendererProcessGone?: (details: Electron.RenderProcessGoneDetails) => void + /** Returns true when Orca should reload after an unexpected renderer loss. + * Why: update relaunch and app quit intentionally tear down child + * processes; recovering those paths can fight Electron's shutdown. */ + shouldRecoverRenderer?: (details: Electron.RenderProcessGoneDetails) => boolean /** Why: main-process startup must register IPC handlers before the renderer * begins booting, or eager renderer calls can race into missing channels. */ deferLoad?: boolean @@ -483,11 +488,47 @@ export function createMainWindow( markdownEditorFocused = false } let rendererProcessGone = false + let rendererRecoveryTimer: ReturnType | null = null + const clearRendererRecoveryTimer = (): void => { + if (rendererRecoveryTimer) { + clearTimeout(rendererRecoveryTimer) + rendererRecoveryTimer = null + } + } + const scheduleRendererRecovery = (details: Electron.RenderProcessGoneDetails): void => { + if ( + rendererRecoveryTimer || + !details || + !isCrashReportReason(details.reason) || + windowClosing || + opts?.getIsQuitting?.() || + opts?.shouldRecoverRenderer?.(details) === false || + mainWindow.isDestroyed() + ) { + return + } + rendererRecoveryTimer = setTimeout(() => { + rendererRecoveryTimer = null + if ( + windowClosing || + opts?.getIsQuitting?.() || + opts?.shouldRecoverRenderer?.(details) === false || + mainWindow.isDestroyed() + ) { + return + } + // Why: a transient Network Service / renderer loss can leave Chromium + // showing a blank shell. Reload the app document once so the user gets + // back to a usable window instead of needing a full relaunch. + loadMainWindow(mainWindow) + }, 250) + } mainWindow.webContents.on('render-process-gone', (_event, details) => { rendererProcessGone = true resetMarkdownEditorFocus() opts?.onRendererProcessGone?.(details) console.error('[window] Renderer process gone; close confirmation will be bypassed', details) + scheduleRendererRecovery(details) }) mainWindow.webContents.on('destroyed', resetMarkdownEditorFocus) mainWindow.webContents.on('did-start-navigation', (_e, _url, _isInPlace, isMainFrame) => { @@ -497,6 +538,7 @@ export function createMainWindow( }) mainWindow.webContents.on('did-finish-load', () => { rendererProcessGone = false + clearRendererRecoveryTimer() }) let ctrlTabSwitching = false @@ -772,6 +814,7 @@ export function createMainWindow( // stale-true flag can't leak past subsequent state transitions. Paired // with the webContents lifecycle resets above. markdownEditorFocused = false + clearRendererRecoveryTimer() ipcMain.removeListener(trafficLightChannel, onSyncTrafficLights) ipcMain.removeListener(minimizeChannel, onMinimize) ipcMain.removeListener(maximizeChannel, onMaximize)