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
+54
View File
@@ -0,0 +1,54 @@
# Desktop Window Recovery
This is recovery hardening for intermittent blank windows, not an established
diagnosis of their cause.
- `src/renderer/src/main.tsx` wraps the locale provider, App and storage-upgrade
page in `AppErrorBoundary`. A React render/lifecycle failure replaces the tree
with a blocking recovery page and an explicit Reload button. It uses the saved
UI language (including system preference resolution), shared theme tokens and
primary-button styling, without depending on the failed application providers.
- Reload refreshes the current document, retaining its URL and storage-upgrade
query. It can lose unsaved input. There are no automatic React retries.
- `src/main/window.ts` listens for `render-process-gone`. Abnormal exits open one
native dialog at a time, using the OS locale because renderer language storage
is unavailable to Main. Reload occurs only on explicit confirmation. Cancel
leaves the window unchanged; persistent failures require quitting and reopening
GoodBuddy. Clean exits, shutdown and destroyed windows do not trigger recovery.
- Main-frame load failures are logged; aborted navigation and subframe failures
are ignored. Initial load promises are caught, without automatic load retries
or a new load-failure UI.
- Native crashes and main-frame load failures use the existing bounded desktop
diagnostics store (`desktop.renderer.gone`, `desktop.renderer.load-failed`,
stage `renderer`). Persistent records retain fixed summaries rather than raw
errors. Crash reason/exit code and React errors/component stacks go to their
respective process consoles. There is no new diagnostic IPC.
The boundary does not catch module evaluation/startup failures before React
mounts, event-handler exceptions, asynchronous rejections, a hung renderer, or
main-process failure. Reload does not establish that a repeated underlying error
has been fixed. Agent runtimes and remote execution are unchanged.
Targeted regressions are in `AppErrorBoundary.test.tsx`, `window.test.ts` and
`desktop-diagnostics.test.ts`. Electron API mocks verify decision paths but do
not establish native dialog appearance or full application recovery.
Validation on Windows, 2026-09-15:
- Targeted Vitest run: 20 tests passed across the three files above.
- `npm test`: 359 files passed, 9 skipped; 4,225 tests passed, 67 skipped.
- `npm run typecheck`, `npm run lint` and `git diff --check` passed.
- An isolated Electron fixture bundled the current boundary, shared styles and
production window module. Both languages and themes at requested content sizes
1280x800, 960x720, 720x640 and 640x420 passed visibility, focus and horizontal
overflow checks. Windows scaling rounded actual widths down by one CSS pixel.
The 16-image overview and Chinese/light and English/dark short-window originals
were visually reviewed; no clipping or focus-style defect was found.
- A native Space key activated the recovery button and reloaded the document.
Two actual renderer crashes verified confirmed reload with URL retention and
cancellation without retry. Only native dialog responses were mocked; native
dialog appearance, full App business-state recovery and other OSes were not
verified. No model calls were made by this fixture.
- The first temporary Electron driver timed out during ESM startup. Removing its
top-level readiness await fixed the driver; the subsequent run passed. This
was a test-driver failure, not evidence of the reported application root cause.
+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)
})
}
}
+22
View File
@@ -0,0 +1,22 @@
.app-error-recovery {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
width: 100%;
height: 100vh;
overflow: auto;
padding: var(--space-6);
background: var(--surface-raised);
color: var(--text-primary);
-webkit-app-region: no-drag;
}
.app-error-recovery h1 {
font-size: var(--font-page-title);
}
.app-error-recovery p {
color: var(--text-secondary);
margin-block: var(--space-4);
}
@@ -0,0 +1,46 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AppErrorBoundary } from './AppErrorBoundary'
function Broken(): never {
throw new Error('test render failure')
}
afterEach(() => {
cleanup()
localStorage.removeItem('goodbuddy.ui-locale')
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('AppErrorBoundary', () => {
it('leaves healthy children visible', () => {
render(<AppErrorBoundary><p>Working application</p></AppErrorBoundary>)
expect(screen.getByText('Working application')).toBeVisible()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it.each([
['zh-CN', '重新加载'],
['en-US', 'Reload']
])('recovers a failed tree without providers in %s and reloads only on click', (locale, label) => {
localStorage.setItem('goodbuddy.ui-locale', locale)
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const reload = vi.fn()
vi.stubGlobal('window', { location: { reload } })
render(<AppErrorBoundary><Broken /></AppErrorBoundary>)
expect(screen.getByRole('alert')).toBeVisible()
expect(screen.getByRole('main')).toHaveAttribute('lang', locale)
expect(log).toHaveBeenCalledWith('GoodBuddy UI failed', expect.any(Error), expect.stringContaining('Broken'))
expect(reload).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: label }))
expect(reload).toHaveBeenCalledOnce()
})
it('still displays recovery when locale storage is unavailable', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('storage unavailable') })
vi.spyOn(console, 'error').mockImplementation(() => undefined)
render(<AppErrorBoundary><Broken /></AppErrorBoundary>)
expect(screen.getByRole('button', { name: '重新加载' })).toBeVisible()
})
})
+39
View File
@@ -0,0 +1,39 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { loadUiLocalePreference, resolveUiLocale, systemUiLanguages } from './i18n/locale'
import './AppErrorBoundary.css'
export class AppErrorBoundary extends Component<
{ children: ReactNode },
{ failed: boolean }
> {
state = { failed: false }
static getDerivedStateFromError(): { failed: boolean } {
return { failed: true }
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('GoodBuddy UI failed', error, info.componentStack)
}
render(): ReactNode {
if (!this.state.failed) return this.props.children
// Recovery must work even when the locale provider or application failed.
const locale = resolveUiLocale(loadUiLocalePreference(), systemUiLanguages())
const en = locale === 'en-US'
return (
<main className="app-error-recovery" lang={locale} aria-labelledby="app-error-title">
<div role="alert">
<h1 id="app-error-title">{en ? 'GoodBuddy could not display this page' : 'GoodBuddy 无法显示当前页面'}</h1>
<p>{en
? 'Reload to try again. Unsaved input may be lost. If this happens again, quit and reopen GoodBuddy.'
: '请重新加载后重试。尚未保存的输入可能丢失。如果再次出现,请退出并重新打开 GoodBuddy。'}</p>
</div>
<button className="primary-button" type="button" onClick={() => window.location.reload()}>
{en ? 'Reload' : '重新加载'}
</button>
</main>
)
}
}
+10 -7
View File
@@ -4,6 +4,7 @@ import '@fontsource-variable/noto-sans-sc/wght.css'
import 'katex/dist/katex.min.css'
import './markdown-math.css'
import App from './App'
import { AppErrorBoundary } from './AppErrorBoundary'
import { StorageUpgrade } from './StorageUpgrade'
import { installBundledUiFonts } from './fonts'
import { changeUiLocale } from './i18n'
@@ -51,12 +52,14 @@ installDocumentOcrBridge()
createRoot(root).render(
<StrictMode>
<UiLocaleProvider
initialPreference={initialUiLocalePreference}
>
{new URLSearchParams(window.location.search).get('storageUpgrade') === '1'
? <StorageUpgrade />
: <App />}
</UiLocaleProvider>
<AppErrorBoundary>
<UiLocaleProvider
initialPreference={initialUiLocalePreference}
>
{new URLSearchParams(window.location.search).get('storageUpgrade') === '1'
? <StorageUpgrade />
: <App />}
</UiLocaleProvider>
</AppErrorBoundary>
</StrictMode>
)