fix(desktop): record main-process faults in desktop.log

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 <rod@nxtlevel.dev>
This commit is contained in:
Brooklyn Nicholson 2026-07-27 13:07:18 -05:00
parent d7e738af90
commit f18e50a070
4 changed files with 142 additions and 14 deletions

View file

@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest'
import { describeCrashReason, installCrashForensics } from './crash-forensics'
const harness = () => {
const listeners = new Map<string, (value: unknown) => 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<string, unknown> = {}
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'])
})
})

View file

@ -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'))
}

View file

@ -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)

View file

@ -258,6 +258,7 @@ describe('revalidatePooledRemoteBackends', () => {
const unreachable = new Set<string>()
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()