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:
parent
55c5e82e09
commit
c8e703663d
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -277,6 +277,7 @@ function openMainWindow(): BrowserWindow {
|
|||
processType: 'renderer'
|
||||
})
|
||||
},
|
||||
shouldRecoverRenderer: () => !isQuitting && !isQuittingForUpdate(),
|
||||
deferLoad: true,
|
||||
title: devInstanceIdentity.name
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue