fix: offer recovery for blank desktop windows

React render failures now show a localized reload page, and renderer crashes offer native reload confirmation. Record renderer and load failures in existing diagnostics without automatic retry loops. Reload may lose unsaved input; the intermittent failure cause remains unconfirmed.

Release note: Recover from desktop rendering failures using a visible Reload action instead of being left without a recovery entry point.
This commit is contained in:
mesalogo
2026-09-15 08:07:08 +08:00
parent d4f1668c9b
commit b755951ecd
10 changed files with 339 additions and 14 deletions
+16
View File
@@ -34,6 +34,22 @@ afterEach(async () => {
})
describe('DesktopDiagnostics', () => {
it('persists renderer failure codes without storing raw error content', async () => {
const diagnostics = new DesktopDiagnostics(await temporaryDirectory())
for (const code of ['desktop.renderer.gone', 'desktop.renderer.load-failed']) {
await diagnostics.recordFailure({
component: 'desktop', stage: 'renderer', code,
error: new Error('private renderer details')
})
}
const records = await diagnostics.readRecent()
expect(records.map(record => record.code)).toEqual([
'desktop.renderer.gone', 'desktop.renderer.load-failed'
])
expect(records.every(record => record.message === 'Desktop renderer failed')).toBe(true)
expect(JSON.stringify(records)).not.toContain('private renderer details')
await diagnostics.dispose()
})
it('rotates within fixed file and byte bounds', async () => {
const directory = await temporaryDirectory()
let sequence = 0
+4
View File
@@ -14,6 +14,7 @@ const DEFAULT_MAXIMUM_RECORDS = 1_000
export const MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES = 32
const DIAGNOSTIC_FILE_NAME = 'desktop-diagnostics.ndjson'
const allowedStages = new Set([
'renderer',
'startup',
'create',
'status',
@@ -25,6 +26,8 @@ const allowedStages = new Set([
'disconnect'
])
const allowedCodes = new Set([
'desktop.renderer.gone',
'desktop.renderer.load-failed',
'desktop.startup.failed',
'runtime.operation.failed',
'runtime.run.failed',
@@ -108,6 +111,7 @@ function fixedMessage(
stage: string
): string {
if (component === 'desktop') {
if (stage === 'renderer') return 'Desktop renderer failed'
return 'Desktop startup failed'
}
if (component === 'remote-agent') {
+1 -1
View File
@@ -547,7 +547,7 @@ if (hasSingleInstanceLock) {
details.mediaType === 'audio'
)
mainWindow = createMainWindow(() => isQuitting)
mainWindow = createMainWindow(() => isQuitting, observeDesktopFailure)
registerDesktopNotificationActivation(mainWindow)
tray = buildTray()
storageUpgrade = prepareAssistantStorage(
+90 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { createMainWindow, resolveWindowIcon } from './window'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createMainWindow, loadMainWindow, resolveWindowIcon } from './window'
import { dialog } from 'electron'
const electronMocks = vi.hoisted(() => ({
options: [] as Array<Record<string, unknown>>,
@@ -9,11 +10,14 @@ const electronMocks = vi.hoisted(() => ({
vi.mock('electron', () => ({
app: {
isPackaged: false,
getLocale: vi.fn(() => 'en-US'),
getAppPath: vi.fn(() => 'C:\\source')
},
BrowserWindow: class {
webContents = {
on: vi.fn(),
reload: vi.fn(),
isDestroyed: vi.fn(() => false),
setWindowOpenHandler: vi.fn()
}
@@ -22,6 +26,9 @@ vi.mock('electron', () => ({
}
setIcon = vi.fn()
isDestroyed = vi.fn(() => false)
loadURL = vi.fn(async () => undefined)
loadFile = vi.fn(async () => undefined)
once = vi.fn()
hide = vi.fn()
on = vi.fn(
@@ -40,11 +47,92 @@ vi.mock('electron', () => ({
isEmpty: vi.fn(() => false)
}))
},
dialog: { showMessageBox: vi.fn(async () => ({ response: 1 })) },
shell: {
openExternal: vi.fn()
}
}))
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
describe('window recovery', () => {
function setup(shouldQuit = () => false) {
const observe = vi.fn()
const observedWindow = createMainWindow(shouldQuit, observe)
const listeners = vi.mocked(observedWindow.webContents.on).mock.calls as unknown as Array<[string, unknown]>
const gone = listeners.find(([event]) => event === 'render-process-gone')![1] as
(event: unknown, details: { reason: string; exitCode: number }) => void
vi.spyOn(console, 'error').mockImplementation(() => undefined)
return { window: observedWindow, gone, observe, listeners }
}
it('logs a crash and reloads only after explicit confirmation, without duplicate dialogs', async () => {
let respond!: (value: { response: number; checkboxChecked: boolean }) => void
vi.mocked(dialog.showMessageBox).mockReturnValueOnce(new Promise(resolve => { respond = resolve }))
const { window, gone, observe } = setup()
const before = vi.mocked(dialog.showMessageBox).mock.calls.length
gone({}, { reason: 'oom', exitCode: 9 })
gone({}, { reason: 'crashed', exitCode: 1 })
expect(dialog.showMessageBox).toHaveBeenCalledTimes(before + 1)
expect(window.webContents.reload).not.toHaveBeenCalled()
expect(observe).toHaveBeenCalledWith(expect.objectContaining({ code: 'desktop.renderer.gone' }))
respond({ response: 0, checkboxChecked: false })
await vi.waitFor(() => expect(window.webContents.reload).toHaveBeenCalledOnce())
gone({}, { reason: 'crashed', exitCode: 1 })
await Promise.resolve()
expect(window.webContents.reload).toHaveBeenCalledOnce()
})
it('does not recover clean exits or shutdown, or reload a window destroyed during the dialog', async () => {
let quitting = false
const { window, gone, observe } = setup(() => quitting)
gone({}, { reason: 'clean-exit', exitCode: 0 })
quitting = true
gone({}, { reason: 'crashed', exitCode: 1 })
expect(observe).not.toHaveBeenCalled()
quitting = false
vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 0, checkboxChecked: false })
gone({}, { reason: 'crashed', exitCode: 1 })
vi.mocked(window.isDestroyed).mockReturnValue(true)
await Promise.resolve()
expect(window.webContents.reload).not.toHaveBeenCalled()
})
it('handles dialog rejection and allows a later user-driven recovery', async () => {
const { window, gone } = setup()
vi.mocked(dialog.showMessageBox).mockRejectedValueOnce(new Error('dialog unavailable'))
gone({}, { reason: 'crashed', exitCode: 1 })
await vi.waitFor(() => expect(console.error).toHaveBeenCalledWith('GoodBuddy renderer recovery failed', expect.any(Error)))
vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 0, checkboxChecked: false })
gone({}, { reason: 'crashed', exitCode: 1 })
await vi.waitFor(() => expect(window.webContents.reload).toHaveBeenCalledOnce())
})
it('records main-frame load failures but ignores aborted navigation and subframes', () => {
const { listeners, observe } = setup()
const failed = listeners.find(([event]) => event === 'did-fail-load')![1] as
(event: unknown, code: number, description: string, url: string, main: boolean) => void
failed({}, -3, 'aborted', '', true)
failed({}, -6, 'missing', '', false)
expect(observe).not.toHaveBeenCalled()
failed({}, -6, 'missing', '', true)
expect(observe).toHaveBeenCalledWith(expect.objectContaining({ code: 'desktop.renderer.load-failed' }))
})
it.each([true, false])('consumes rejected load promises (development=%s)', async development => {
vi.stubEnv('ELECTRON_RENDERER_URL', development ? 'http://localhost:5173' : '')
const { window } = setup()
vi.mocked(development ? window.loadURL : window.loadFile).mockRejectedValueOnce(new Error('load failed'))
loadMainWindow(window, true)
await vi.waitFor(() => expect(console.error).toHaveBeenCalledWith('GoodBuddy window load rejected', expect.any(Error)))
if (development) expect(window.loadURL).toHaveBeenCalledWith('http://localhost:5173/?storageUpgrade=1')
else expect(window.loadFile).toHaveBeenCalledWith(expect.any(String), { query: { storageUpgrade: '1' } })
})
})
describe('createMainWindow', () => {
it('disables the system frame while preserving renderer isolation', () => {
createMainWindow(() => false)
+57 -4
View File
@@ -1,6 +1,7 @@
import { app, BrowserWindow, nativeImage, shell } from 'electron'
import { app, BrowserWindow, dialog, nativeImage, shell } from 'electron'
import { dirname, join, posix, win32 } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { DesktopDiagnosticFailureObserver } from './desktop-diagnostics'
const currentDirectory = dirname(fileURLToPath(import.meta.url))
@@ -51,7 +52,10 @@ function hasSameOrigin(url: string, allowedUrl: string): boolean {
}
}
export function createMainWindow(shouldQuit: () => boolean): BrowserWindow {
export function createMainWindow(
shouldQuit: () => boolean,
observeFailure?: DesktopDiagnosticFailureObserver
): BrowserWindow {
const iconPath = resolveWindowIcon()
const icon = iconPath
? nativeImage.createFromPath(iconPath)
@@ -102,6 +106,51 @@ export function createMainWindow(shouldQuit: () => boolean): BrowserWindow {
}
})
let recoveryPending = false
window.webContents.on('render-process-gone', (_event, details) => {
if (details.reason === 'clean-exit' || shouldQuit() || window.isDestroyed()) return
console.error('GoodBuddy renderer exited', details.reason, details.exitCode)
observeFailure?.({
component: 'desktop',
stage: 'renderer',
code: 'desktop.renderer.gone',
error: new Error(`Renderer exited: ${details.reason} (${details.exitCode})`)
})
if (recoveryPending) return
recoveryPending = true
// No automatic retries: a persistent crash requires a fresh user action.
const en = !app.getLocale().toLowerCase().startsWith('zh')
void dialog.showMessageBox(window, {
type: 'error',
title: 'GoodBuddy',
message: en ? 'The GoodBuddy window stopped working' : 'GoodBuddy 窗口已停止工作',
detail: en
? 'Reload to try again. Unsaved input may be lost. If this happens again, quit and reopen GoodBuddy.'
: '请重新加载后重试。尚未保存的输入可能丢失。如果再次出现,请退出并重新打开 GoodBuddy。',
buttons: en ? ['Reload', 'Cancel'] : ['重新加载', '取消'],
defaultId: 0,
cancelId: 1,
noLink: true
}).then(({ response }) => {
if (response === 0 && !shouldQuit() && !window.isDestroyed() && !window.webContents.isDestroyed()) {
window.webContents.reload()
}
}).catch((error: unknown) => {
console.error('GoodBuddy renderer recovery failed', error)
}).finally(() => {
recoveryPending = false
})
})
window.webContents.on('did-fail-load', (_event, code, description, _url, isMainFrame) => {
if (!isMainFrame || code === -3 || shouldQuit() || window.isDestroyed()) return
console.error('GoodBuddy window load failed', code, description)
observeFailure?.({
component: 'desktop', stage: 'renderer', code: 'desktop.renderer.load-failed',
error: new Error(`Window load failed: ${code}`)
})
})
return window
}
@@ -112,12 +161,16 @@ export function loadMainWindow(
if (process.env.ELECTRON_RENDERER_URL) {
const url = new URL(process.env.ELECTRON_RENDERER_URL)
if (storageUpgrade) url.searchParams.set('storageUpgrade', '1')
void window.loadURL(url.href)
void window.loadURL(url.href).catch((error: unknown) => {
console.error('GoodBuddy window load rejected', error)
})
} else {
void window.loadFile(
join(currentDirectory, '../renderer/index.html'),
storageUpgrade ? { query: { storageUpgrade: '1' } } : undefined
)
).catch((error: unknown) => {
console.error('GoodBuddy window load rejected', error)
})
}
}