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
This commit is contained in:
Jinjing 2026-05-17 16:36:44 -07:00 committed by GitHub
parent 55c5e82e09
commit c8e703663d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 221 additions and 1 deletions

View File

@ -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

View File

@ -277,6 +277,7 @@ function openMainWindow(): BrowserWindow {
processType: 'renderer'
})
},
shouldRecoverRenderer: () => !isQuitting && !isQuittingForUpdate(),
deferLoad: true,
title: devInstanceIdentity.name
})

View File

@ -1425,6 +1425,182 @@ describe('createMainWindow', () => {
expect(onRendererProcessGone).toHaveBeenCalledWith(details)
})
const createRendererRecoveryWindowHarness = () => {
const windowHandlers: Record<string, (...args: any[]) => 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<string, (...args: any[]) => void> = {}
const webContents = {

View File

@ -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<typeof setTimeout> | 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)