handling files when stages and dirty at once
This commit is contained in:
188
src/main/diagnostics.ts
Normal file
188
src/main/diagnostics.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { app, crashReporter, dialog, shell, BrowserWindow, type WebContents } from 'electron'
|
||||
import { formatErr, getLogDir, getLogPath, initLogger, log, logger } from './logger'
|
||||
|
||||
/**
|
||||
* Everything that turns a silent death into a log line. Wires the process-,
|
||||
* app- and window-level failure hooks Electron gives us, none of which were
|
||||
* connected before — which is why crashes left no trace.
|
||||
*
|
||||
* The hooks, and the crash each one actually catches:
|
||||
* uncaughtException / unhandledRejection → a throw in OUR main-process code
|
||||
* render-process-gone → the renderer died (OOM, segfault):
|
||||
* the classic "window went blank//white"
|
||||
* child-process-gone → GPU / utility process died
|
||||
* preload-error → preload threw: `window.helder` is
|
||||
* undefined and the app silently falls
|
||||
* back to MOCK DATA (see CLAUDE.md)
|
||||
* unresponsive → main thread wedged (the beachball)
|
||||
* crashReporter minidumps → NATIVE crashes (node-pty is native,
|
||||
* and a segfault there takes the whole
|
||||
* process down with no JS hook at all)
|
||||
*/
|
||||
|
||||
let fatalDialogOpen = false
|
||||
|
||||
/** Call FIRST, before app.whenReady() — crashReporter must start early to catch
|
||||
* native crashes, and the log file should exist before anything can fail. */
|
||||
export function initDiagnostics(isDev: boolean): void {
|
||||
// app.getPath('logs') is ~/Library/Logs/<name> on macOS, so the name must be
|
||||
// set before we ask for the path or the folder is called "Electron".
|
||||
app.setName('Helder')
|
||||
|
||||
initLogger({ dir: app.getPath('logs'), mirror: isDev })
|
||||
|
||||
// Native minidumps for crashes no JS handler can see. Local only — nothing is
|
||||
// uploaded anywhere (there is no server, and this is a personal tool).
|
||||
try {
|
||||
crashReporter.start({ productName: 'Helder', companyName: 'Helder', uploadToServer: false })
|
||||
} catch (e) {
|
||||
logger.warn('crash', 'crashReporter failed to start', { err: formatErr(e) })
|
||||
}
|
||||
|
||||
logger.info('session', 'starting', {
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron,
|
||||
chrome: process.versions.chrome,
|
||||
node: process.versions.node,
|
||||
platform: `${process.platform} ${process.arch}`,
|
||||
packaged: app.isPackaged,
|
||||
dev: isDev,
|
||||
crashDumps: app.getPath('crashDumps'),
|
||||
argv: process.argv.slice(1),
|
||||
project: process.env.HELDER_PROJECT ?? null,
|
||||
})
|
||||
|
||||
installProcessHooks()
|
||||
installAppHooks()
|
||||
}
|
||||
|
||||
function installProcessHooks(): void {
|
||||
process.on('uncaughtException', (err, origin) => {
|
||||
logger.error('fatal', `uncaughtException (${origin})`, err)
|
||||
showFatal(err)
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
// Not fatal in itself, but it's how a forgotten `await` on a failing IPC
|
||||
// handler shows up — and the stack here is the only place the cause exists.
|
||||
logger.error('fatal', 'unhandledRejection', reason)
|
||||
})
|
||||
|
||||
process.on('warning', (w) => {
|
||||
// Surfaces the "MaxListenersExceeded" / deprecation warnings that precede
|
||||
// a leak-driven crash.
|
||||
logger.warn('node', w.name, { message: w.message, stack: w.stack })
|
||||
})
|
||||
|
||||
app.on('before-quit', () => logger.info('session', 'quitting'))
|
||||
}
|
||||
|
||||
function installAppHooks(): void {
|
||||
// THE renderer-crash hook. `reason` is the useful bit: 'crashed', 'oom',
|
||||
// 'killed', 'launch-failed'.
|
||||
app.on('render-process-gone', (_e, contents, details) => {
|
||||
logger.error('renderer', `render process gone: ${details.reason}`, undefined, {
|
||||
exitCode: details.exitCode,
|
||||
reason: details.reason,
|
||||
url: safeUrl(contents),
|
||||
})
|
||||
if (details.reason !== 'clean-exit') {
|
||||
showFatal(new Error(`The window crashed (${details.reason}, exit ${details.exitCode}). See the log for details.`))
|
||||
}
|
||||
})
|
||||
|
||||
app.on('child-process-gone', (_e, details) => {
|
||||
logger.error('child', `${details.type} process gone: ${details.reason}`, undefined, {
|
||||
exitCode: details.exitCode,
|
||||
serviceName: details.serviceName,
|
||||
name: details.name,
|
||||
})
|
||||
})
|
||||
|
||||
// A preload failure is silent-by-design in Electron and the single nastiest
|
||||
// failure mode this app has: no window.helder → the renderer quietly serves
|
||||
// mock data and "terminal not available", as if nothing were wrong.
|
||||
app.on('web-contents-created', (_e, contents) => {
|
||||
contents.on('preload-error', (_ev, preloadPath, error) => {
|
||||
logger.error('preload', 'preload script threw — window.helder will be undefined (mock-data fallback)', error, { preloadPath })
|
||||
})
|
||||
contents.on('console-message', (...a: unknown[]) => {
|
||||
// Electron ≥36 passes a single event object; older versions pass
|
||||
// (event, level, message, line, sourceId). Support both so a version bump
|
||||
// doesn't quietly stop capturing renderer console output.
|
||||
const d = normaliseConsoleMessage(a)
|
||||
if (!d || d.level < 2) return // warnings + errors only; skip log/info noise
|
||||
log(d.level >= 3 ? 'error' : 'warn', 'console', d.message, { source: d.source, line: d.line })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Electron changed the console-message signature in v36; accept both shapes. */
|
||||
export function normaliseConsoleMessage(args: unknown[]): { level: number; message: string; source: string; line: number } | null {
|
||||
const first = args[0] as Record<string, unknown> | undefined
|
||||
if (first && typeof first === 'object' && 'message' in first && 'level' in first) {
|
||||
const lvl = first.level
|
||||
const asNum = typeof lvl === 'string' ? { debug: 0, info: 1, verbose: 1, warning: 2, error: 3 }[lvl] ?? 1 : Number(lvl)
|
||||
return {
|
||||
level: asNum,
|
||||
message: String(first.message),
|
||||
source: String(first.sourceId ?? ''),
|
||||
line: Number(first.lineNumber ?? 0),
|
||||
}
|
||||
}
|
||||
if (args.length >= 3 && typeof args[1] === 'number') {
|
||||
return { level: args[1] as number, message: String(args[2]), source: String(args[4] ?? ''), line: Number(args[3] ?? 0) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function safeUrl(contents: WebContents | null): string {
|
||||
try { return contents?.getURL() ?? '' } catch { return '' }
|
||||
}
|
||||
|
||||
/** Watch for the beachball: log it (with a stack-free note) rather than let the
|
||||
* user guess whether the app is hung or just slow. */
|
||||
export function watchWindow(win: BrowserWindow): void {
|
||||
win.on('unresponsive', () => logger.warn('window', 'became unresponsive (main thread blocked)'))
|
||||
win.on('responsive', () => logger.info('window', 'responsive again'))
|
||||
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
logger.error('window', 'did-fail-load', undefined, { code, desc, url })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the user something died, and put the log one click away — a crash the
|
||||
* user can't report is a crash we can't fix. Guarded so a crash loop doesn't
|
||||
* stack a hundred dialogs.
|
||||
*/
|
||||
function showFatal(err: unknown): void {
|
||||
if (fatalDialogOpen) return
|
||||
fatalDialogOpen = true
|
||||
const { message } = formatErr(err)
|
||||
const logPath = getLogPath()
|
||||
Promise.resolve(dialog.showMessageBox({
|
||||
type: 'error',
|
||||
buttons: logPath ? ['Open Log', 'Ignore'] : ['Ignore'],
|
||||
defaultId: 0,
|
||||
cancelId: logPath ? 1 : 0,
|
||||
message: 'Helder hit an error',
|
||||
detail: `${message}\n\n${logPath ? `Logged to ${logPath}` : ''}`,
|
||||
})).then(({ response }) => {
|
||||
if (logPath && response === 0) openLog()
|
||||
}).catch(() => { /* dialog can fail pre-ready; the log line is what matters */ })
|
||||
.finally(() => { fatalDialogOpen = false })
|
||||
}
|
||||
|
||||
/** Open the log in the default text editor. */
|
||||
export function openLog(): void {
|
||||
const p = getLogPath()
|
||||
if (p) shell.openPath(p).catch(() => {})
|
||||
}
|
||||
|
||||
/** Reveal the log folder (all rotated files + siblings) in Finder. */
|
||||
export function revealLog(): void {
|
||||
const p = getLogPath()
|
||||
if (p) shell.showItemInFolder(p)
|
||||
}
|
||||
|
||||
export { getLogDir, getLogPath }
|
||||
Reference in New Issue
Block a user