mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
* fix(recovery): back off a launch-failed renderer instead of tripping the crash breaker
A renderer that the OS refused to spawn (macOS exit 1003 = LAUNCH_RESULT_FAILURE; field
cause: per-user process limit, posix_spawn EAGAIN) burned the 3-reload crash-loop budget
in ~750ms and raised a "graphics driver" prompt, while the condition lasted minutes.
- launch-failed retries in place on a 250ms..60s backoff (~2 min), outside the breaker;
a loaded document resets it. Other crash reasons keep the breaker.
- Each launch failure records renderer_launch_failed_probe {spawnError} from a cheap
spawn probe, so bundles name EAGAIN/EACCES/ENOENT directly.
- The exhausted prompt says the process limit was hit (probe EAGAIN), drops the
graphics-driver wording, keeps Try Again as default, and offers no Restart:
app.relaunch also needs a free process slot and silently fails without one.
* fix(recovery): skip the launch probe on Windows and probe the prompt once
- Re-check quitting after the prompt's probe; don't re-probe on Copy Commands.
- recordRendererLaunchFailureProbe never rejects (breadcrumb write guarded).
- Windows: no spawn probe; a child per failed launch is the per-operation burst EDR scores.
* test: cover quitting and duplicate renderer launch failures
* test: use typed access in PTY delay regression fixture
* fix: scope extended launch retries to POSIX hosts
* test: cover launch probe behavior on native Windows
---------
Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
Co-authored-by: m4air <m4air@Mac.localdomain>
238 lines
9.6 KiB
TypeScript
238 lines
9.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
vi.mock('electron', async () =>
|
|
(await import('./createMainWindow-test-harness')).electronModuleMock()
|
|
)
|
|
vi.mock('@electron-toolkit/utils', async () =>
|
|
(await import('./createMainWindow-test-harness')).electronToolkitUtilsMock()
|
|
)
|
|
vi.mock('./macos-tahoe-release', async () =>
|
|
(await import('./createMainWindow-test-harness')).macosTahoeReleaseMock()
|
|
)
|
|
vi.mock('../app-icon', async () => (await import('./createMainWindow-test-harness')).appIconMock())
|
|
vi.mock('../browser/browser-manager', async () =>
|
|
(await import('./createMainWindow-test-harness')).browserManagerMock()
|
|
)
|
|
vi.mock('../browser/browser-client-page-renderer-runtime', async () => {
|
|
const harness = await import('./createMainWindow-test-harness')
|
|
return {
|
|
attachBrowserClientPageRenderer: harness.attachClientPageRendererMock,
|
|
retireBrowserClientPageRenderer: harness.retireClientPageRendererMock
|
|
}
|
|
})
|
|
|
|
import { createMainWindow } from './createMainWindow'
|
|
import type { CreateMainWindowOptions } from './main-window-contracts'
|
|
import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification'
|
|
import {
|
|
browserWindowMock,
|
|
resetMainWindowMocks,
|
|
withPlatform
|
|
} from './createMainWindow-test-harness'
|
|
import { RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS } from './renderer-launch-failure-backoff'
|
|
|
|
// macOS LAUNCH_RESULT_FAILURE: posix_spawn of the Renderer helper failed (field: EAGAIN, per-user process limit).
|
|
const LAUNCH_FAILED: Electron.RenderProcessGoneDetails = { reason: 'launch-failed', exitCode: 1003 }
|
|
const CRASHED: Electron.RenderProcessGoneDetails = { reason: 'crashed', exitCode: 5 }
|
|
|
|
/**
|
|
* Field shape (v1.4.218, bundle F0C6NHQF4C8): while the OS refuses spawns, every load emits launch-failed and then
|
|
* rejects ERR_FAILED within ~10ms, before any did-finish-load. Once headroom returns the same webContents loads.
|
|
*/
|
|
function createSpawnRefusingWindow(platform: NodeJS.Platform = 'darwin') {
|
|
const handlers: Record<string, (...args: any[]) => void> = {}
|
|
const spawn = { refused: true }
|
|
const webContents = {
|
|
id: 143,
|
|
getURL: vi.fn(() => 'file:///opt/orca/renderer/index.html'),
|
|
isDestroyed: vi.fn(() => false),
|
|
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
|
|
handlers[event] = handler
|
|
}),
|
|
setZoomLevel: vi.fn(),
|
|
setBackgroundThrottling: vi.fn(),
|
|
invalidate: vi.fn(),
|
|
setWindowOpenHandler: vi.fn(),
|
|
send: vi.fn()
|
|
}
|
|
const load = vi.fn((): Promise<void> => {
|
|
if (!spawn.refused) {
|
|
queueMicrotask(() => handlers['did-finish-load']?.())
|
|
return Promise.resolve()
|
|
}
|
|
queueMicrotask(() =>
|
|
withPlatform(platform, () => handlers['render-process-gone']?.({}, LAUNCH_FAILED))
|
|
)
|
|
return Promise.reject(
|
|
new Error("ERR_FAILED (-2) loading 'file:///opt/orca/renderer/index.html'")
|
|
)
|
|
})
|
|
browserWindowMock.mockImplementation(function () {
|
|
return {
|
|
webContents,
|
|
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
|
|
handlers[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: load,
|
|
loadURL: load
|
|
}
|
|
})
|
|
return { handlers, load, spawn }
|
|
}
|
|
|
|
function open(overrides: CreateMainWindowOptions = {}) {
|
|
const onRendererRecoveryExhausted = vi.fn()
|
|
const onRecoveryReloadOutcome = vi.fn()
|
|
createMainWindow(null, {
|
|
onRendererRecoveryExhausted,
|
|
onRecoveryReloadOutcome,
|
|
shouldRecoverRenderer: (details) =>
|
|
shouldRecoverRendererAfterProcessGone({ reason: details.reason, expectedTeardown: 'none' }),
|
|
...overrides
|
|
})
|
|
return { onRendererRecoveryExhausted, onRecoveryReloadOutcome }
|
|
}
|
|
|
|
const BACKOFF_TOTAL_MS = RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0)
|
|
|
|
describe('renderer launch-failed recovery', () => {
|
|
beforeEach(() => {
|
|
resetMainWindowMocks()
|
|
vi.useFakeTimers()
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
})
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('backs off instead of opening the crash-loop breaker after 3 quick failures', async () => {
|
|
const { load } = createSpawnRefusingWindow()
|
|
const { onRendererRecoveryExhausted } = open()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(load).toHaveBeenCalledTimes(1)
|
|
|
|
// Field: 3 reloads in ~750ms, then the breaker opened. Now retries spread out: 250ms, 1s, 2s, 4s, ...
|
|
await vi.advanceTimersByTimeAsync(250)
|
|
expect(load).toHaveBeenCalledTimes(2)
|
|
await vi.advanceTimersByTimeAsync(999)
|
|
expect(load).toHaveBeenCalledTimes(2)
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(load).toHaveBeenCalledTimes(3)
|
|
await vi.advanceTimersByTimeAsync(2_000)
|
|
expect(load).toHaveBeenCalledTimes(4)
|
|
await vi.advanceTimersByTimeAsync(4_000)
|
|
expect(load).toHaveBeenCalledTimes(5)
|
|
expect(onRendererRecoveryExhausted).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('recovers in place once spawn headroom returns, without any prompt', async () => {
|
|
const { handlers, load, spawn } = createSpawnRefusingWindow()
|
|
const { onRendererRecoveryExhausted, onRecoveryReloadOutcome } = open()
|
|
// Field: the process table stayed full for minutes.
|
|
await vi.advanceTimersByTimeAsync(250 + 1_000 + 2_000 + 4_000 + 8_000 + 15_000)
|
|
expect(load).toHaveBeenCalledTimes(7)
|
|
spawn.refused = false
|
|
|
|
await vi.advanceTimersByTimeAsync(30_000)
|
|
expect(load).toHaveBeenCalledTimes(8)
|
|
expect(onRecoveryReloadOutcome).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ status: 'loaded' })
|
|
)
|
|
await vi.advanceTimersByTimeAsync(5 * 60_000)
|
|
expect(load).toHaveBeenCalledTimes(8)
|
|
expect(onRendererRecoveryExhausted).not.toHaveBeenCalled()
|
|
|
|
// A loaded document starts a fresh schedule: the next launch failure retries after 250ms again.
|
|
spawn.refused = true
|
|
handlers['render-process-gone']?.({}, LAUNCH_FAILED)
|
|
await vi.advanceTimersByTimeAsync(250)
|
|
expect(load).toHaveBeenCalledTimes(9)
|
|
})
|
|
|
|
it('prompts only after the ~2 minute schedule is spent, then a failed manual retry re-prompts', async () => {
|
|
const { load } = createSpawnRefusingWindow()
|
|
const { onRendererRecoveryExhausted } = open()
|
|
await vi.advanceTimersByTimeAsync(BACKOFF_TOTAL_MS - 1)
|
|
expect(onRendererRecoveryExhausted).not.toHaveBeenCalled()
|
|
|
|
// The last retry fails too; its prompt follows on the usual 250ms recovery tick.
|
|
await vi.advanceTimersByTimeAsync(251)
|
|
expect(BACKOFF_TOTAL_MS).toBeLessThanOrEqual(125_000)
|
|
expect(load).toHaveBeenCalledTimes(1 + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length)
|
|
expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce()
|
|
expect(onRendererRecoveryExhausted).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
details: LAUNCH_FAILED,
|
|
cause: 'launch-failed',
|
|
recentRecoveryCount: RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length
|
|
})
|
|
)
|
|
|
|
onRendererRecoveryExhausted.mock.calls[0]?.[0].retry()
|
|
expect(load).toHaveBeenCalledTimes(2 + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length)
|
|
await vi.advanceTimersByTimeAsync(250)
|
|
expect(onRendererRecoveryExhausted).toHaveBeenCalledTimes(2)
|
|
await vi.advanceTimersByTimeAsync(5 * 60_000)
|
|
expect(load).toHaveBeenCalledTimes(2 + RENDERER_LAUNCH_FAILURE_RETRY_DELAYS_MS.length)
|
|
})
|
|
|
|
it('stops recovery when quitting during the long backoff', async () => {
|
|
const { load } = createSpawnRefusingWindow()
|
|
let quitting = false
|
|
const { onRendererRecoveryExhausted } = open({ getIsQuitting: () => quitting })
|
|
await vi.advanceTimersByTimeAsync(250 + 1_000 + 2_000 + 4_000)
|
|
const loadsBeforeQuit = load.mock.calls.length
|
|
quitting = true
|
|
await vi.advanceTimersByTimeAsync(BACKOFF_TOTAL_MS * 2)
|
|
expect(load).toHaveBeenCalledTimes(loadsBeforeQuit)
|
|
expect(onRendererRecoveryExhausted).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not spend retry attempts on duplicate failure events', async () => {
|
|
const { handlers, load } = createSpawnRefusingWindow()
|
|
const { onRendererRecoveryExhausted } = open()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
handlers['render-process-gone']?.({}, LAUNCH_FAILED)
|
|
handlers['render-process-gone']?.({}, LAUNCH_FAILED)
|
|
await vi.advanceTimersByTimeAsync(250)
|
|
expect(load).toHaveBeenCalledTimes(2)
|
|
await vi.advanceTimersByTimeAsync(999)
|
|
expect(load).toHaveBeenCalledTimes(2)
|
|
expect(onRendererRecoveryExhausted).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps Windows launch failures on the short recovery schedule', async () => {
|
|
const { load } = createSpawnRefusingWindow('win32')
|
|
const { onRendererRecoveryExhausted } = open()
|
|
await vi.advanceTimersByTimeAsync(1_000)
|
|
expect(load).toHaveBeenCalledTimes(4)
|
|
expect(onRendererRecoveryExhausted).toHaveBeenCalledOnce()
|
|
expect(onRendererRecoveryExhausted).toHaveBeenCalledWith(
|
|
expect.objectContaining({ cause: 'launch-failed', recentRecoveryCount: 3 })
|
|
)
|
|
})
|
|
|
|
it('keeps the crash-loop breaker for renderers that actually crash', async () => {
|
|
const { handlers, load, spawn } = createSpawnRefusingWindow()
|
|
spawn.refused = false
|
|
const { onRendererRecoveryExhausted } = open()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
for (let i = 0; i < 4; i += 1) {
|
|
handlers['render-process-gone']?.({}, CRASHED)
|
|
await vi.advanceTimersByTimeAsync(250)
|
|
}
|
|
expect(load).toHaveBeenCalledTimes(4)
|
|
expect(onRendererRecoveryExhausted).toHaveBeenCalledWith(
|
|
expect.objectContaining({ details: CRASHED, cause: 'crash-loop', recentRecoveryCount: 3 })
|
|
)
|
|
})
|
|
})
|