diff --git a/docs/development/window-recovery.md b/docs/development/window-recovery.md new file mode 100644 index 00000000..ea5b71ad --- /dev/null +++ b/docs/development/window-recovery.md @@ -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. diff --git a/src/main/desktop-diagnostics.test.ts b/src/main/desktop-diagnostics.test.ts index a1b47514..d8ca0a22 100644 --- a/src/main/desktop-diagnostics.test.ts +++ b/src/main/desktop-diagnostics.test.ts @@ -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 diff --git a/src/main/desktop-diagnostics.ts b/src/main/desktop-diagnostics.ts index 9e815a79..d958016a 100644 --- a/src/main/desktop-diagnostics.ts +++ b/src/main/desktop-diagnostics.ts @@ -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') { diff --git a/src/main/index.ts b/src/main/index.ts index 240962f4..64f8945e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -547,7 +547,7 @@ if (hasSingleInstanceLock) { details.mediaType === 'audio' ) - mainWindow = createMainWindow(() => isQuitting) + mainWindow = createMainWindow(() => isQuitting, observeDesktopFailure) registerDesktopNotificationActivation(mainWindow) tray = buildTray() storageUpgrade = prepareAssistantStorage( diff --git a/src/main/window.test.ts b/src/main/window.test.ts index 6051de92..460ea241 100644 --- a/src/main/window.test.ts +++ b/src/main/window.test.ts @@ -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>, @@ -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) diff --git a/src/main/window.ts b/src/main/window.ts index d51a9d01..e92e9ad2 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -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) + }) } } diff --git a/src/renderer/src/AppErrorBoundary.css b/src/renderer/src/AppErrorBoundary.css new file mode 100644 index 00000000..7a345ed4 --- /dev/null +++ b/src/renderer/src/AppErrorBoundary.css @@ -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); +} diff --git a/src/renderer/src/AppErrorBoundary.test.tsx b/src/renderer/src/AppErrorBoundary.test.tsx new file mode 100644 index 00000000..0d3a8116 --- /dev/null +++ b/src/renderer/src/AppErrorBoundary.test.tsx @@ -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(

Working application

) + 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() + 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() + expect(screen.getByRole('button', { name: '重新加载' })).toBeVisible() + }) +}) diff --git a/src/renderer/src/AppErrorBoundary.tsx b/src/renderer/src/AppErrorBoundary.tsx new file mode 100644 index 00000000..43488077 --- /dev/null +++ b/src/renderer/src/AppErrorBoundary.tsx @@ -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 ( +
+
+

{en ? 'GoodBuddy could not display this page' : 'GoodBuddy 无法显示当前页面'}

+

{en + ? 'Reload to try again. Unsaved input may be lost. If this happens again, quit and reopen GoodBuddy.' + : '请重新加载后重试。尚未保存的输入可能丢失。如果再次出现,请退出并重新打开 GoodBuddy。'}

+
+ +
+ ) + } +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 58ffc28f..11d4436b 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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( - - {new URLSearchParams(window.location.search).get('storageUpgrade') === '1' - ? - : } - + + + {new URLSearchParams(window.location.search).get('storageUpgrade') === '1' + ? + : } + + )