From 4cdfde07765723950b3a524b83e852bcace73eaa Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Sat, 22 Aug 2026 20:49:09 +0800 Subject: [PATCH 1/3] fix(desktop): recover from main renderer loss Observe unexpected termination of the main BrowserWindow renderer while the Electron main process is still alive. Reuse the native diagnostic dialog and existing report formatter so users can copy bounded Desktop evidence before choosing to relaunch or exit, without querying an unrelated Runtime Host. Generated-by: Codex --- .../main-renderer-process-gone.test.ts | 21 +++++ .../native-diagnostic-dialog.test.ts | 69 ++++++++++++---- .../src/main/main-process-diagnostics.ts | 39 +++++++++- .../src/main/main-renderer-process-gone.ts | 8 ++ apps/desktop/src/main/main-window.ts | 14 ++++ .../src/main/native-diagnostic-dialog.ts | 78 +++++++++++++++++-- apps/desktop/src/main/runtime-host-boot.ts | 16 +++- 7 files changed, 218 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts create mode 100644 apps/desktop/src/main/main-renderer-process-gone.ts diff --git a/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts b/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts new file mode 100644 index 0000000000..d1ab24b25f --- /dev/null +++ b/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { shouldReportMainRendererProcessGone } from '../main-renderer-process-gone.js'; + +test('reports only an unexpected main Renderer exit while the app is running', () => { + for (const scenario of [ + { aborted: false, reason: 'clean-exit' as const, expected: false }, + { aborted: true, reason: 'killed' as const, expected: false }, + { aborted: false, reason: 'crashed' as const, expected: true }, + ]) { + const abort = new AbortController(); + if (scenario.aborted) abort.abort(); + assert.equal( + shouldReportMainRendererProcessGone( + { reason: scenario.reason, exitCode: scenario.reason === 'clean-exit' ? 0 : 1 }, + abort.signal, + ), + scenario.expected, + ); + } +}); diff --git a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts index efc638ba94..9908918a27 100644 --- a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts @@ -1,7 +1,27 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'; -import { showFatalStartupError, showMessageBoxWithDiagnostics } from '../native-diagnostic-dialog.js'; +import { + showFatalStartupError, + showMainRendererProcessGoneDialog, + showMessageBoxWithDiagnostics, +} from '../native-diagnostic-dialog.js'; + +const diagnosticEnvironment = () => ({ + appVersion: '0.1.8', + buildMode: 'packaged' as const, + buildCommit: null, + electronVersion: '38.0.0', + nodeVersion: '22.0.0', + chromeVersion: '140.0.0', + platform: 'linux' as const, + arch: 'x64', + osRelease: '6.6.0', + locale: 'en-US', + workspacePath: '/home/tester/.local/share/maka/workspaces/default', + homePath: '/home/tester', + processUptimeSeconds: 3, +}); test('copies diagnostics as an auxiliary native-dialog action', async () => { const shown: MessageBoxOptions[] = []; @@ -43,21 +63,7 @@ test('fatal startup errors remain copyable without a renderer or BrowserWindow', await showFatalStartupError(new Error('Authorization: Bearer very-secret-token'), { locale: 'en', - environment: () => ({ - appVersion: '0.1.8', - buildMode: 'packaged', - buildCommit: null, - electronVersion: '38.0.0', - nodeVersion: '22.0.0', - chromeVersion: '140.0.0', - platform: 'linux', - arch: 'x64', - osRelease: '6.6.0', - locale: 'en-US', - workspacePath: '/home/tester/.local/share/maka/workspaces/default', - homePath: '/home/tester', - processUptimeSeconds: 3, - }), + environment: diagnosticEnvironment, mainLogs: () => ['startup failed with Authorization: Bearer very-secret-token'], writeClipboard: (value) => { clipboard = value; @@ -75,3 +81,34 @@ test('fatal startup errors remain copyable without a renderer or BrowserWindow', assert.match(clipboard, /Recent main-process logs \(1\)/); assert.doesNotMatch(clipboard, /very-secret-token/); }); + +test('main Renderer loss keeps Copy Diagnostics auxiliary to recovery', async () => { + const shown: MessageBoxOptions[] = []; + const responses = [2, 0]; + let clipboard = ''; + + const decision = await showMainRendererProcessGoneDialog( + { reason: 'oom', exitCode: 137 }, + { + locale: 'en', + environment: diagnosticEnvironment, + mainLogs: () => ['renderer stopped with api_key=very-secret-token'], + writeClipboard: (value) => { + clipboard = value; + }, + showMessageBox: async (options): Promise => { + shown.push(options); + return { response: responses.shift() ?? 1, checkboxChecked: false }; + }, + }, + ); + + assert.equal(decision, 'relaunch'); + assert.deepEqual(shown[0]?.buttons, ['Relaunch', 'Exit', 'Copy Diagnostics']); + assert.deepEqual(shown[1]?.buttons, ['Relaunch', 'Exit', 'Copy Again']); + assert.match(clipboard, /Surface: renderer_process_gone/); + assert.match(clipboard, /Reason: oom/); + assert.match(clipboard, /Exit code: 137/); + assert.match(clipboard, /Recent main-process logs \(1\)/); + assert.doesNotMatch(clipboard, /very-secret-token/); +}); diff --git a/apps/desktop/src/main/main-process-diagnostics.ts b/apps/desktop/src/main/main-process-diagnostics.ts index ce6811bb1d..089e443c17 100644 --- a/apps/desktop/src/main/main-process-diagnostics.ts +++ b/apps/desktop/src/main/main-process-diagnostics.ts @@ -56,9 +56,18 @@ export interface DesktopStartupDiagnosticInput { readonly hostTarget: 'none'; } +export interface DesktopMainRendererDiagnosticInput { + readonly surface: 'renderer_process_gone'; + readonly title: string; + readonly description?: string; + readonly details?: string; + readonly hostTarget: 'none'; +} + export type DesktopDiagnosticReportInput = | DesktopDiagnosticWireInput - | DesktopStartupDiagnosticInput; + | DesktopStartupDiagnosticInput + | DesktopMainRendererDiagnosticInput; export type RuntimeHostDiagnosticRead = | { readonly ok: true; readonly value: HostDiagnosticsResult } @@ -120,6 +129,27 @@ export function createDesktopStartupDiagnosticInput(input: { }): DesktopStartupDiagnosticInput { return { surface: 'startup', + ...createDesktopNativeDiagnosticFields(input), + }; +} + +export function createDesktopMainRendererDiagnosticInput(input: { + readonly title: string; + readonly description?: string; + readonly details?: string; +}): DesktopMainRendererDiagnosticInput { + return { + surface: 'renderer_process_gone', + ...createDesktopNativeDiagnosticFields(input), + }; +} + +function createDesktopNativeDiagnosticFields(input: { + readonly title: string; + readonly description?: string; + readonly details?: string; +}): Omit { + return { hostTarget: 'none', title: requireDiagnosticString(input.title, 'title', INPUT_LIMITS.title), ...(input.description @@ -308,7 +338,12 @@ export function formatDesktopDiagnosticReport( capturedAt = new Date(), ): string { const lines = ['Maka Desktop diagnostic report', `Captured at: ${capturedAt.toISOString()}`]; - const rendererContext = input.surface === 'startup' ? undefined : input; + const rendererContext = + input.surface === 'manual' || + input.surface === 'toast' || + input.surface === 'renderer_crash' + ? input + : undefined; if (input.surface === 'manual') { lines.push('', 'Capture', 'Surface: manual'); } else { diff --git a/apps/desktop/src/main/main-renderer-process-gone.ts b/apps/desktop/src/main/main-renderer-process-gone.ts new file mode 100644 index 0000000000..862e8a2042 --- /dev/null +++ b/apps/desktop/src/main/main-renderer-process-gone.ts @@ -0,0 +1,8 @@ +import type { RenderProcessGoneDetails } from 'electron'; + +export function shouldReportMainRendererProcessGone( + details: RenderProcessGoneDetails, + shutdownSignal: AbortSignal, +): boolean { + return !shutdownSignal.aborted && details.reason !== 'clean-exit'; +} diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index 67703c5158..1bd4e4edee 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -10,6 +10,7 @@ import { BrowserViewController } from './browser/controller.js'; import { BrowserViewManager } from './browser/view-manager.js'; import type { E2eFixture } from './e2e-fixture.js'; import { installMainWindowPermissionPolicy } from './main-window-permission-policy.js'; +import { shouldReportMainRendererProcessGone } from './main-renderer-process-gone.js'; import { isThemePreference, toNativeThemeSource } from './theme-source.js'; import { createWindowRevealGate } from './window-reveal.js'; import { @@ -66,6 +67,7 @@ interface MainWindowControllerDeps { // and the fake backend, so main-window.ts owns no env policy of its own. startHidden: boolean; onClose?: () => void; + onRendererProcessGone: (details: Electron.RenderProcessGoneDetails) => void | Promise; } let mainWindow: BrowserWindow | null = null; @@ -348,6 +350,18 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main allowRunningInsecureContent: false, }, }); + mainWindow.webContents.once('render-process-gone', (_event, details) => { + if (!shouldReportMainRendererProcessGone(details, signal)) return; + console.error( + `[renderer] main Renderer process exited unexpectedly: reason=${details.reason} exitCode=${details.exitCode}`, + ); + void Promise.resolve() + .then(() => deps.onRendererProcessGone(details)) + .catch((error) => { + console.error('[renderer] failed to handle main Renderer process exit:', error); + app.quit(); + }); + }); installMainWindowPermissionPolicy(mainWindow.webContents, rendererEntryUrl); // Two-layer external-link hygiene: assistant markdown often emits `` diff --git a/apps/desktop/src/main/native-diagnostic-dialog.ts b/apps/desktop/src/main/native-diagnostic-dialog.ts index 65fff3443d..04bf65e71a 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog.ts @@ -1,12 +1,25 @@ import type { UiLocale } from '@maka/core/ui-locale'; -import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'; +import type { + MessageBoxOptions, + MessageBoxReturnValue, + RenderProcessGoneDetails, +} from 'electron'; import { + createDesktopMainRendererDiagnosticInput, createDesktopStartupDiagnosticInput, formatDesktopDiagnosticReport, type DesktopDiagnosticEnvironment, } from './main-process-diagnostics.js'; import { whileAwaitingPerson } from './startup-step.js'; +interface NativeDiagnosticDialogDeps { + readonly locale: UiLocale; + readonly environment: () => DesktopDiagnosticEnvironment; + readonly mainLogs: () => readonly string[]; + readonly writeClipboard: (value: string) => void; + readonly showMessageBox: (options: MessageBoxOptions) => Promise; +} + export async function showMessageBoxWithDiagnostics( options: MessageBoxOptions, deps: { @@ -26,13 +39,7 @@ export async function showMessageBoxWithDiagnostics( export async function showFatalStartupError( error: unknown, - deps: { - readonly locale: UiLocale; - readonly environment: () => DesktopDiagnosticEnvironment; - readonly mainLogs: () => readonly string[]; - readonly writeClipboard: (value: string) => void; - readonly showMessageBox: (options: MessageBoxOptions) => Promise; - }, + deps: NativeDiagnosticDialogDeps, ): Promise { const copy = FATAL_STARTUP_COPY[deps.locale]; const message = error instanceof Error ? error.message : String(error); @@ -68,6 +75,44 @@ export async function showFatalStartupError( ); } +export async function showMainRendererProcessGoneDialog( + details: RenderProcessGoneDetails, + deps: NativeDiagnosticDialogDeps, +): Promise<'relaunch' | 'exit'> { + const copy = MAIN_RENDERER_GONE_COPY[deps.locale]; + const input = createDesktopMainRendererDiagnosticInput({ + title: 'Maka main Renderer process exited unexpectedly', + description: `Reason: ${details.reason}`, + details: `Exit code: ${details.exitCode}`, + }); + const result = await showMessageBoxWithDiagnostics( + { + type: 'error', + title: copy.title, + message: copy.message, + detail: copy.detail, + buttons: [copy.relaunch, copy.exit], + defaultId: 0, + cancelId: 1, + noLink: true, + }, + { + locale: deps.locale, + showMessageBox: deps.showMessageBox, + copyDiagnostics: () => + deps.writeClipboard( + formatDesktopDiagnosticReport( + input, + deps.environment(), + deps.mainLogs(), + { ok: false, error: 'No Runtime Host authority was associated with this error' }, + ), + ), + }, + ); + return result.response === 0 ? 'relaunch' : 'exit'; +} + async function copyDiagnostics( copy: () => void | Promise, locale: UiLocale, @@ -132,3 +177,20 @@ const FATAL_STARTUP_COPY = { exit: '退出', }, } as const; + +const MAIN_RENDERER_GONE_COPY = { + en: { + title: 'Maka needs to recover', + message: "Maka's interface stopped unexpectedly.", + detail: 'Relaunch Maka to continue, or exit and reopen it later.', + relaunch: 'Relaunch', + exit: 'Exit', + }, + zh: { + title: 'Maka 需要恢复', + message: 'Maka 界面意外停止运行。', + detail: '重新启动 Maka 以继续,或退出后稍后再打开。', + relaunch: '重新启动', + exit: '退出', + }, +} as const; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 87b3df9750..c3d339c111 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -78,7 +78,10 @@ import { mainProcessLogBuffer, type DesktopDiagnosticsDeps, } from "./main-process-diagnostics.js"; -import { showMessageBoxWithDiagnostics } from "./native-diagnostic-dialog.js"; +import { + showMainRendererProcessGoneDialog, + showMessageBoxWithDiagnostics, +} from "./native-diagnostic-dialog.js"; import { resolveDesktopSessionWorkspace, } from "./new-session-project.js"; @@ -323,6 +326,17 @@ const mainWindowController = createMainWindowController({ settingsStore, startHidden, onClose: () => onMainWindowClose(), + onRendererProcessGone: async (details) => { + const decision = await showMainRendererProcessGoneDialog(details, { + locale: desktopLocale.current(), + environment: desktopDiagnostics.environment, + mainLogs: desktopDiagnostics.mainLogs, + writeClipboard: desktopDiagnostics.writeClipboard, + showMessageBox: (options) => dialog.showMessageBox(options), + }); + if (decision === "relaunch") app.relaunch(); + app.quit(); + }, }); const runtimeHostSshTerminal = createDesktopRuntimeHostSshTerminal({ ipcMain, From ddc048ae608a9d3280f582eec12ab0f5435e71e8 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Sat, 22 Aug 2026 21:18:33 +0800 Subject: [PATCH 2/3] fix(desktop): preserve renderer recovery during initial load Route every window creation through the quit coordinator so rejected initial loads are reported instead of escaping as unhandled rejections. Test the real render-process-gone observation boundary while preserving the existing native recovery decision flow. Generated-by: Codex --- .../__tests__/app-quit-coordinator.test.ts | 34 +++++++++++--- .../main-renderer-process-gone.test.ts | 45 ++++++++++++++----- apps/desktop/src/main/app-quit-coordinator.ts | 15 ++++--- .../src/main/main-renderer-process-gone.ts | 21 ++++++--- apps/desktop/src/main/main-window.ts | 27 ++++++----- apps/desktop/src/main/runtime-host-boot.ts | 7 +-- 6 files changed, 106 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts b/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts index e243103f92..0cc923c239 100644 --- a/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts +++ b/apps/desktop/src/main/__tests__/app-quit-coordinator.test.ts @@ -10,6 +10,7 @@ describe('app quit coordinator', () => { cleanup: async () => {}, focusOrCreateWindow: () => {}, onCleanupError: () => {}, + onWindowCreationError: () => {}, resumeQuit: () => { resumeQuitCount += 1; }, @@ -50,6 +51,7 @@ describe('app quit coordinator', () => { focusOrCreateCount += 1; }, onCleanupError: () => {}, + onWindowCreationError: () => {}, resumeQuit: () => { resumeQuitCount += 1; }, @@ -85,22 +87,44 @@ describe('app quit coordinator', () => { it('does not reopen the main window after quit cleanup starts', () => { let focusOrCreateCount = 0; + let windowCreationSignal: AbortSignal | undefined; const coordinator = createAppQuitCoordinator({ cleanup: () => new Promise(() => {}), - focusOrCreateWindow: () => { + focusOrCreateWindow: (signal) => { focusOrCreateCount += 1; + windowCreationSignal = signal; }, onCleanupError: () => {}, + onWindowCreationError: () => {}, resumeQuit: () => {}, }); - const windowCreationSignal = coordinator.getWindowCreationSignal(); + coordinator.focusOrCreateWindow(); coordinator.handleBeforeQuit({ preventDefault: () => {} }); coordinator.focusOrCreateWindow(); - assert.equal(focusOrCreateCount, 0); + assert.equal(focusOrCreateCount, 1); assert.equal(windowCreationSignal?.aborted, true); - assert.equal(coordinator.getWindowCreationSignal(), undefined); + }); + + it('reports window creation failure without leaking an unhandled rejection', async () => { + const failure = new Error('window load failed'); + const reportedErrors: unknown[] = []; + const coordinator = createAppQuitCoordinator({ + cleanup: async () => {}, + focusOrCreateWindow: async () => { + throw failure; + }, + onCleanupError: () => {}, + onWindowCreationError: (error) => reportedErrors.push(error), + resumeQuit: () => {}, + }); + + coordinator.focusOrCreateWindow(); + await Promise.resolve(); + await Promise.resolve(); + + assert.deepEqual(reportedErrors, [failure]); }); it('reports cleanup failure without leaking an unhandled rejection', async () => { @@ -118,6 +142,7 @@ describe('app quit coordinator', () => { onCleanupError: (error: unknown) => { reportedErrors.push(error); }, + onWindowCreationError: () => {}, resumeQuit: () => { resumeQuitCount += 1; }, @@ -139,6 +164,5 @@ describe('app quit coordinator', () => { assert.equal(focusOrCreateCount, 0); assert.equal(resumeQuitCount, 1); assert.equal(secondQuitPrevented, false); - assert.equal(coordinator.getWindowCreationSignal(), undefined); }); }); diff --git a/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts b/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts index d1ab24b25f..ac7453280c 100644 --- a/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts +++ b/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts @@ -1,21 +1,42 @@ import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; import { test } from 'node:test'; -import { shouldReportMainRendererProcessGone } from '../main-renderer-process-gone.js'; +import type { RenderProcessGoneDetails } from 'electron'; +import { observeMainRendererProcessGone } from '../main-renderer-process-gone.js'; -test('reports only an unexpected main Renderer exit while the app is running', () => { +test('observes one unexpected main Renderer exit while the app is running', () => { + const source = new EventEmitter(); + const observed: RenderProcessGoneDetails[] = []; + observeMainRendererProcessGone({ + source, + shutdownSignal: new AbortController().signal, + onUnexpectedExit: (details) => observed.push(details), + }); + + source.emit('render-process-gone', {}, { reason: 'oom', exitCode: 137 }); + source.emit('render-process-gone', {}, { reason: 'crashed', exitCode: 11 }); + + assert.deepEqual(observed, [{ reason: 'oom', exitCode: 137 }]); +}); + +test('ignores clean exits and app shutdown', () => { for (const scenario of [ - { aborted: false, reason: 'clean-exit' as const, expected: false }, - { aborted: true, reason: 'killed' as const, expected: false }, - { aborted: false, reason: 'crashed' as const, expected: true }, + { aborted: false, details: { reason: 'clean-exit', exitCode: 0 } as const }, + { aborted: true, details: { reason: 'killed', exitCode: 9 } as const }, ]) { + const source = new EventEmitter(); const abort = new AbortController(); if (scenario.aborted) abort.abort(); - assert.equal( - shouldReportMainRendererProcessGone( - { reason: scenario.reason, exitCode: scenario.reason === 'clean-exit' ? 0 : 1 }, - abort.signal, - ), - scenario.expected, - ); + let observed = false; + observeMainRendererProcessGone({ + source, + shutdownSignal: abort.signal, + onUnexpectedExit: () => { + observed = true; + }, + }); + + source.emit('render-process-gone', {}, scenario.details); + assert.equal(observed, false); } }); diff --git a/apps/desktop/src/main/app-quit-coordinator.ts b/apps/desktop/src/main/app-quit-coordinator.ts index 8bc7b50dc3..9bf6b8be7a 100644 --- a/apps/desktop/src/main/app-quit-coordinator.ts +++ b/apps/desktop/src/main/app-quit-coordinator.ts @@ -4,14 +4,14 @@ export interface AppQuitEvent { export interface AppQuitCoordinator { focusOrCreateWindow(): void; - getWindowCreationSignal(): AbortSignal | undefined; handleBeforeQuit(event: AppQuitEvent): void; } export interface AppQuitCoordinatorDeps { cleanup(): Promise; - focusOrCreateWindow(signal: AbortSignal): void; + focusOrCreateWindow(signal: AbortSignal): void | Promise; onCleanupError(error: unknown): void; + onWindowCreationError(error: unknown): void; resumeQuit(): void; } @@ -24,10 +24,13 @@ export function createAppQuitCoordinator(deps: AppQuitCoordinatorDeps): AppQuitC return { focusOrCreateWindow(): void { if (phase !== 'running') return; - deps.focusOrCreateWindow(windowCreationAbort.signal); - }, - getWindowCreationSignal(): AbortSignal | undefined { - return phase === 'running' ? windowCreationAbort.signal : undefined; + try { + void Promise.resolve(deps.focusOrCreateWindow(windowCreationAbort.signal)).catch( + deps.onWindowCreationError, + ); + } catch (error) { + deps.onWindowCreationError(error); + } }, handleBeforeQuit(event): void { if (phase === 'ready-to-exit') return; diff --git a/apps/desktop/src/main/main-renderer-process-gone.ts b/apps/desktop/src/main/main-renderer-process-gone.ts index 862e8a2042..9e741df8f4 100644 --- a/apps/desktop/src/main/main-renderer-process-gone.ts +++ b/apps/desktop/src/main/main-renderer-process-gone.ts @@ -1,8 +1,19 @@ import type { RenderProcessGoneDetails } from 'electron'; -export function shouldReportMainRendererProcessGone( - details: RenderProcessGoneDetails, - shutdownSignal: AbortSignal, -): boolean { - return !shutdownSignal.aborted && details.reason !== 'clean-exit'; +interface RenderProcessGoneSource { + once( + event: 'render-process-gone', + listener: (event: unknown, details: RenderProcessGoneDetails) => void, + ): void; +} + +export function observeMainRendererProcessGone(deps: { + readonly source: RenderProcessGoneSource; + readonly shutdownSignal: AbortSignal; + readonly onUnexpectedExit: (details: RenderProcessGoneDetails) => void; +}): void { + deps.source.once('render-process-gone', (_event, details) => { + if (deps.shutdownSignal.aborted || details.reason === 'clean-exit') return; + deps.onUnexpectedExit(details); + }); } diff --git a/apps/desktop/src/main/main-window.ts b/apps/desktop/src/main/main-window.ts index 1bd4e4edee..28664e9d51 100644 --- a/apps/desktop/src/main/main-window.ts +++ b/apps/desktop/src/main/main-window.ts @@ -10,7 +10,7 @@ import { BrowserViewController } from './browser/controller.js'; import { BrowserViewManager } from './browser/view-manager.js'; import type { E2eFixture } from './e2e-fixture.js'; import { installMainWindowPermissionPolicy } from './main-window-permission-policy.js'; -import { shouldReportMainRendererProcessGone } from './main-renderer-process-gone.js'; +import { observeMainRendererProcessGone } from './main-renderer-process-gone.js'; import { isThemePreference, toNativeThemeSource } from './theme-source.js'; import { createWindowRevealGate } from './window-reveal.js'; import { @@ -350,17 +350,20 @@ export function createMainWindowController(deps: MainWindowControllerDeps): Main allowRunningInsecureContent: false, }, }); - mainWindow.webContents.once('render-process-gone', (_event, details) => { - if (!shouldReportMainRendererProcessGone(details, signal)) return; - console.error( - `[renderer] main Renderer process exited unexpectedly: reason=${details.reason} exitCode=${details.exitCode}`, - ); - void Promise.resolve() - .then(() => deps.onRendererProcessGone(details)) - .catch((error) => { - console.error('[renderer] failed to handle main Renderer process exit:', error); - app.quit(); - }); + observeMainRendererProcessGone({ + source: mainWindow.webContents, + shutdownSignal: signal, + onUnexpectedExit: (details) => { + console.error( + `[renderer] main Renderer process exited unexpectedly: reason=${details.reason} exitCode=${details.exitCode}`, + ); + void Promise.resolve() + .then(() => deps.onRendererProcessGone(details)) + .catch((error) => { + console.error('[renderer] failed to handle main Renderer process exit:', error); + app.quit(); + }); + }, }); installMainWindowPermissionPolicy(mainWindow.webContents, rendererEntryUrl); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index c3d339c111..b3c821f719 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1435,10 +1435,12 @@ function wireLifecycle(): void { cleanup: closeRuntimeHostDesktop, focusOrCreateWindow: (signal) => { if (mainWindowController.hasOpenWindows()) mainWindowController.focus(); - else void mainWindowController.createWindow(signal); + else return mainWindowController.createWindow(signal); }, onCleanupError: (error) => console.error("[runtime-host] shutdown failed:", error), + onWindowCreationError: (error) => + console.error("[window] creation failed:", error), resumeQuit: () => app.quit(), }); installDesktopShellPresentation({ @@ -1459,8 +1461,7 @@ function wireLifecycle(): void { if (process.platform !== "darwin") app.quit(); }); app.on("before-quit", quitCoordinator.handleBeforeQuit); - const initialWindowSignal = quitCoordinator.getWindowCreationSignal(); - if (initialWindowSignal) void mainWindowController.createWindow(initialWindowSignal); + quitCoordinator.focusOrCreateWindow(); } async function closeRuntimeHostDesktop(): Promise { From 94b689b20f71dde2ccbfb8bb7b85f495a8504066 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Sat, 22 Aug 2026 21:45:39 +0800 Subject: [PATCH 3/3] refactor(desktop): centralize renderer recovery diagnostics Route Renderer-loss copying through the existing Desktop diagnostic authority so collection, Host attribution, redaction, and clipboard behavior keep one owner. Align the shutdown regression test with the production register-then-abort lifecycle. Generated-by: Codex --- .../main-renderer-process-gone.test.ts | 2 +- .../native-diagnostic-dialog.test.ts | 46 +++++++++++++------ .../src/main/native-diagnostic-dialog.ts | 40 +++++----------- apps/desktop/src/main/runtime-host-boot.ts | 13 ++++-- 4 files changed, 53 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts b/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts index ac7453280c..b07e6d8b0c 100644 --- a/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts +++ b/apps/desktop/src/main/__tests__/main-renderer-process-gone.test.ts @@ -26,7 +26,6 @@ test('ignores clean exits and app shutdown', () => { ]) { const source = new EventEmitter(); const abort = new AbortController(); - if (scenario.aborted) abort.abort(); let observed = false; observeMainRendererProcessGone({ source, @@ -35,6 +34,7 @@ test('ignores clean exits and app shutdown', () => { observed = true; }, }); + if (scenario.aborted) abort.abort(); source.emit('render-process-gone', {}, scenario.details); assert.equal(observed, false); diff --git a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts index 9908918a27..366fb9eaf5 100644 --- a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'; +import { + copyDesktopDiagnosticReport, + createDesktopMainRendererDiagnosticInput, +} from '../main-process-diagnostics.js'; import { showFatalStartupError, showMainRendererProcessGoneDialog, @@ -86,22 +90,36 @@ test('main Renderer loss keeps Copy Diagnostics auxiliary to recovery', async () const shown: MessageBoxOptions[] = []; const responses = [2, 0]; let clipboard = ''; + const diagnosticInput = createDesktopMainRendererDiagnosticInput({ + title: 'Maka main Renderer process exited unexpectedly', + description: 'Reason: oom', + details: 'Exit code: 137', + }); - const decision = await showMainRendererProcessGoneDialog( - { reason: 'oom', exitCode: 137 }, - { - locale: 'en', - environment: diagnosticEnvironment, - mainLogs: () => ['renderer stopped with api_key=very-secret-token'], - writeClipboard: (value) => { - clipboard = value; - }, - showMessageBox: async (options): Promise => { - shown.push(options); - return { response: responses.shift() ?? 1, checkboxChecked: false }; - }, + const decision = await showMainRendererProcessGoneDialog({ + locale: 'en', + copyDiagnostics: () => + copyDesktopDiagnosticReport( + { + environment: diagnosticEnvironment, + mainLogs: () => ['renderer stopped with api_key=very-secret-token'], + resolveActiveRuntimeHost: () => { + throw new Error('Renderer-loss diagnostics must remain Desktop-only'); + }, + resolveRuntimeHost: () => { + throw new Error('Renderer-loss diagnostics must not resolve a task Host'); + }, + writeClipboard: (value) => { + clipboard = value; + }, + }, + diagnosticInput, + ), + showMessageBox: async (options): Promise => { + shown.push(options); + return { response: responses.shift() ?? 1, checkboxChecked: false }; }, - ); + }); assert.equal(decision, 'relaunch'); assert.deepEqual(shown[0]?.buttons, ['Relaunch', 'Exit', 'Copy Diagnostics']); diff --git a/apps/desktop/src/main/native-diagnostic-dialog.ts b/apps/desktop/src/main/native-diagnostic-dialog.ts index 04bf65e71a..a641042421 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog.ts @@ -2,17 +2,21 @@ import type { UiLocale } from '@maka/core/ui-locale'; import type { MessageBoxOptions, MessageBoxReturnValue, - RenderProcessGoneDetails, } from 'electron'; import { - createDesktopMainRendererDiagnosticInput, createDesktopStartupDiagnosticInput, formatDesktopDiagnosticReport, type DesktopDiagnosticEnvironment, } from './main-process-diagnostics.js'; import { whileAwaitingPerson } from './startup-step.js'; -interface NativeDiagnosticDialogDeps { +interface DiagnosticDialogDeps { + readonly locale: UiLocale; + readonly copyDiagnostics: () => void | Promise; + readonly showMessageBox: (options: MessageBoxOptions) => Promise; +} + +interface FatalStartupDiagnosticDialogDeps { readonly locale: UiLocale; readonly environment: () => DesktopDiagnosticEnvironment; readonly mainLogs: () => readonly string[]; @@ -22,11 +26,7 @@ interface NativeDiagnosticDialogDeps { export async function showMessageBoxWithDiagnostics( options: MessageBoxOptions, - deps: { - readonly locale: UiLocale; - readonly copyDiagnostics: () => void | Promise; - readonly showMessageBox: (options: MessageBoxOptions) => Promise; - }, + deps: DiagnosticDialogDeps, ): Promise { let status: string | undefined; for (;;) { @@ -39,7 +39,7 @@ export async function showMessageBoxWithDiagnostics( export async function showFatalStartupError( error: unknown, - deps: NativeDiagnosticDialogDeps, + deps: FatalStartupDiagnosticDialogDeps, ): Promise { const copy = FATAL_STARTUP_COPY[deps.locale]; const message = error instanceof Error ? error.message : String(error); @@ -76,15 +76,9 @@ export async function showFatalStartupError( } export async function showMainRendererProcessGoneDialog( - details: RenderProcessGoneDetails, - deps: NativeDiagnosticDialogDeps, + deps: DiagnosticDialogDeps, ): Promise<'relaunch' | 'exit'> { const copy = MAIN_RENDERER_GONE_COPY[deps.locale]; - const input = createDesktopMainRendererDiagnosticInput({ - title: 'Maka main Renderer process exited unexpectedly', - description: `Reason: ${details.reason}`, - details: `Exit code: ${details.exitCode}`, - }); const result = await showMessageBoxWithDiagnostics( { type: 'error', @@ -96,19 +90,7 @@ export async function showMainRendererProcessGoneDialog( cancelId: 1, noLink: true, }, - { - locale: deps.locale, - showMessageBox: deps.showMessageBox, - copyDiagnostics: () => - deps.writeClipboard( - formatDesktopDiagnosticReport( - input, - deps.environment(), - deps.mainLogs(), - { ok: false, error: 'No Runtime Host authority was associated with this error' }, - ), - ), - }, + deps, ); return result.response === 0 ? 'relaunch' : 'exit'; } diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index b3c821f719..02e98ce0d0 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -74,6 +74,7 @@ import { createMainWindowController } from "./main-window.js"; import { captureDesktopDiagnosticEnvironment, copyDesktopDiagnosticReport, + createDesktopMainRendererDiagnosticInput, createDesktopStartupDiagnosticInput, mainProcessLogBuffer, type DesktopDiagnosticsDeps, @@ -327,11 +328,15 @@ const mainWindowController = createMainWindowController({ startHidden, onClose: () => onMainWindowClose(), onRendererProcessGone: async (details) => { - const decision = await showMainRendererProcessGoneDialog(details, { + const diagnosticInput = createDesktopMainRendererDiagnosticInput({ + title: "Maka main Renderer process exited unexpectedly", + description: `Reason: ${details.reason}`, + details: `Exit code: ${details.exitCode}`, + }); + const decision = await showMainRendererProcessGoneDialog({ locale: desktopLocale.current(), - environment: desktopDiagnostics.environment, - mainLogs: desktopDiagnostics.mainLogs, - writeClipboard: desktopDiagnostics.writeClipboard, + copyDiagnostics: () => + copyDesktopDiagnosticReport(desktopDiagnostics, diagnosticInput), showMessageBox: (options) => dialog.showMessageBox(options), }); if (decision === "relaunch") app.relaunch();