From f18e50a0704034aada3096821467213fec9101ca Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 27 Jul 2026 13:07:18 -0500 Subject: [PATCH] fix(desktop): record main-process faults in desktop.log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron pre-installs its own uncaughtException listener and only warns on unhandled rejections, so a main-process fault usually leaves the app running with the reason on stderr — which nothing captures when the app is launched from Finder or the Start menu. The fault never reaches desktop.log, so it is absent from `hermes debug share` and the user can only describe symptoms. Record both to desktop.log and flush synchronously, since a fault that does prove fatal leaves no chance for the batched async flush. Five loadURL calls were also unhandled, each able to leave a blank window with no explanation anywhere the user can send us; they now name the surface that failed. Co-authored-by: Rodrigo Fernandez --- apps/desktop/electron/crash-forensics.test.ts | 70 +++++++++++++++++++ apps/desktop/electron/crash-forensics.ts | 51 ++++++++++++++ apps/desktop/electron/main.ts | 33 +++++---- apps/desktop/electron/remote-liveness.test.ts | 2 + 4 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/electron/crash-forensics.test.ts create mode 100644 apps/desktop/electron/crash-forensics.ts diff --git a/apps/desktop/electron/crash-forensics.test.ts b/apps/desktop/electron/crash-forensics.test.ts new file mode 100644 index 000000000000..ea53493b4c1f --- /dev/null +++ b/apps/desktop/electron/crash-forensics.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest' + +import { describeCrashReason, installCrashForensics } from './crash-forensics' + +const harness = () => { + const listeners = new Map void>() + const flush = vi.fn() + const log = vi.fn() + + installCrashForensics({ + flush, + log, + target: { on: (event, listener) => listeners.set(event, listener) } + }) + + return { flush, listeners, log } +} + +describe('describeCrashReason', () => { + it('prefers a stack, then a message, for thrown errors', () => { + const withStack = new Error('boom') + withStack.stack = 'Error: boom\n at somewhere' + + expect(describeCrashReason(withStack)).toBe('Error: boom\n at somewhere') + + const withoutStack = new Error('boom') + withoutStack.stack = '' + + expect(describeCrashReason(withoutStack)).toBe('boom') + }) + + it('renders non-error rejections without throwing', () => { + expect(describeCrashReason('plain string')).toBe('plain string') + expect(describeCrashReason({ code: 'ECONNRESET' })).toBe('{"code":"ECONNRESET"}') + expect(describeCrashReason(undefined)).toBe('undefined') + + const circular: Record = {} + circular.self = circular + + expect(describeCrashReason(circular)).toBe('[object Object]') + }) +}) + +describe('installCrashForensics', () => { + it('records and synchronously flushes an uncaught exception', () => { + const { flush, listeners, log } = harness() + const error = new Error('renderer gone') + error.stack = 'Error: renderer gone\n at main' + + listeners.get('uncaughtException')?.(error) + + expect(log).toHaveBeenCalledWith('[main] Uncaught exception: Error: renderer gone\n at main') + expect(flush).toHaveBeenCalledTimes(1) + }) + + it('records and synchronously flushes an unhandled rejection', () => { + const { flush, listeners, log } = harness() + + listeners.get('unhandledRejection')?.('gateway ticket mint failed') + + expect(log).toHaveBeenCalledWith('[main] Unhandled rejection: gateway ticket mint failed') + expect(flush).toHaveBeenCalledTimes(1) + }) + + it('registers both handlers', () => { + const { listeners } = harness() + + expect([...listeners.keys()].sort()).toEqual(['uncaughtException', 'unhandledRejection']) + }) +}) diff --git a/apps/desktop/electron/crash-forensics.ts b/apps/desktop/electron/crash-forensics.ts new file mode 100644 index 000000000000..7ee0aedd48f3 --- /dev/null +++ b/apps/desktop/electron/crash-forensics.ts @@ -0,0 +1,51 @@ +/** + * Last-chance forensics for the Electron main process. + * + * Electron installs its own `uncaughtException` listener and only warns on + * unhandled rejections, so the app usually survives — but the reason lands on + * stderr alone, which is discarded entirely when the app is launched from + * Finder or the Start menu. Without a record in desktop.log, a main-process + * fault is invisible in a `hermes debug share` bundle and the user is left + * describing symptoms instead of showing a stack. + */ + +export interface CrashForensicsTarget { + on: (event: 'uncaughtException' | 'unhandledRejection', listener: (value: unknown) => void) => unknown +} + +export interface CrashForensicsOptions { + flush: () => void + log: (message: string) => void + target?: CrashForensicsTarget +} + +/** Render a thrown value for the log, preferring a stack over a bare message. */ +export function describeCrashReason(reason: unknown): string { + if (reason instanceof Error) { + return reason.stack || reason.message || reason.name || 'Error' + } + + if (typeof reason === 'string') { + return reason + } + + try { + return JSON.stringify(reason) ?? String(reason) + } catch { + return String(reason) + } +} + +/** + * Record main-process faults to desktop.log and flush synchronously, since a + * fault that does prove fatal leaves no chance for the batched async flush. + */ +export function installCrashForensics({ flush, log, target = process }: CrashForensicsOptions): void { + const record = (label: string) => (reason: unknown) => { + log(`[main] ${label}: ${describeCrashReason(reason)}`) + flush() + } + + target.on('uncaughtException', record('Uncaught exception')) + target.on('unhandledRejection', record('Unhandled rejection')) +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index f859a285347c..25154f9161c3 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -69,6 +69,7 @@ import { savedProfileSsh, tokenPreview } from './connection-config' +import { describeCrashReason, installCrashForensics } from './crash-forensics' import { adoptServedDashboardToken } from './dashboard-token' import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation' import { @@ -1219,6 +1220,14 @@ function rememberLog(chunk) { scheduleDesktopLogFlush() } +installCrashForensics({ flush: flushDesktopLogBufferSync, log: rememberLog }) + +// A rejected loadURL leaves a blank window and, unhandled, no trace anywhere +// the user can send us. `label` names the surface so the log says which one. +function loadWindowUrl(win, url, label) { + win.loadURL(url).catch(error => rememberLog(`${label} failed to load: ${describeCrashReason(error)}`)) +} + function openExternalUrl(rawUrl) { const raw = String(rawUrl || '').trim() @@ -7780,6 +7789,7 @@ async function ensureBackend(profile) { lastActiveAt: Date.now(), remoteBaseUrl: null } + entry.connectionPromise = spawnPoolBackend(key, entry).catch(error => { backendPool.delete(key) throw error @@ -8470,12 +8480,14 @@ function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch? wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) - win.loadURL( + loadWindowUrl( + win, buildSessionWindowUrl(sessionId, { devServer: DEV_SERVER, rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(), watch - }) + }), + 'Session window' ) return win @@ -8555,11 +8567,7 @@ function createInstanceWindow() { instanceWindows.delete(win) }) - if (DEV_SERVER) { - win.loadURL(DEV_SERVER) - } else { - win.loadURL(pathToFileURL(resolveRendererIndex()).toString()) - } + loadWindowUrl(win, DEV_SERVER || pathToFileURL(resolveRendererIndex()).toString(), 'Instance window') return win } @@ -8671,7 +8679,7 @@ function spawnPetOverlayWindow(bounds) { } }) - win.loadURL(petOverlayUrl()) + loadWindowUrl(win, petOverlayUrl(), 'Pet overlay') return win } @@ -8823,7 +8831,7 @@ function spawnQuickEntryWindow() { } }) - win.loadURL(quickEntryUrl()) + loadWindowUrl(win, quickEntryUrl(), 'Quick entry') return win } @@ -9117,11 +9125,7 @@ function createWindow() { rememberLog(`[renderer console] ${text} (${src}:${lineNo})`) }) - if (DEV_SERVER) { - mainWindow.loadURL(DEV_SERVER) - } else { - mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString()) - } + loadWindowUrl(mainWindow, DEV_SERVER || pathToFileURL(resolveRendererIndex()).toString(), 'Renderer') // Start the Python backend NOW, in parallel with the renderer load — not on // did-finish-load. The backend cold boot (spawn → port announce → /api/status) @@ -9203,6 +9207,7 @@ function revalidatePool() { tracker: remoteLiveness }) } + ipcMain.handle('hermes:backend:touch', async (_event, profile) => { touchPoolBackend(profile) diff --git a/apps/desktop/electron/remote-liveness.test.ts b/apps/desktop/electron/remote-liveness.test.ts index 2fc9ceb59521..950d6fb1f58a 100644 --- a/apps/desktop/electron/remote-liveness.test.ts +++ b/apps/desktop/electron/remote-liveness.test.ts @@ -258,6 +258,7 @@ describe('revalidatePooledRemoteBackends', () => { const unreachable = new Set() const log = vi.fn() const stopBackend = vi.fn() + const probe = vi.fn(async (url: string) => { if ([...unreachable].some(base => url.startsWith(base))) { throw new Error('unreachable') @@ -336,6 +337,7 @@ describe('revalidatePooledRemoteBackends', () => { ['coder', { process: null, remoteBaseUrl: 'https://dead.example.com' }], ['writer', { process: null, remoteBaseUrl: 'https://live.example.com' }] ]) + pool.unreachable.add('https://dead.example.com') const tracker = new RemoteLivenessTracker()