hermes-agent/apps/desktop/electron/crash-forensics.ts
Brooklyn Nicholson f18e50a070 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>
2026-07-27 13:41:59 -05:00

51 lines
1.7 KiB
TypeScript

/**
* 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'))
}