mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
d7e738af90
commit
f18e50a070
4 changed files with 142 additions and 14 deletions
70
apps/desktop/electron/crash-forensics.test.ts
Normal file
70
apps/desktop/electron/crash-forensics.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
51
apps/desktop/electron/crash-forensics.ts
Normal file
51
apps/desktop/electron/crash-forensics.ts
Normal 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'))
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue