Files
hermes-agent/apps/desktop/electron/linux-crash-diagnostics.ts
Austin Pickett 9a21794450 fix(desktop): optional Linux crash diagnostics must not be fatal or unbounded
Two follow-ups to #117851, both raised in review by @ehz0ah:

1. The diagnostics block created HERMES_HOME/logs with an unguarded
   module-level mkdirSync, before app readiness. A read-only or invalid
   logs path terminated the Linux desktop at startup — optional
   diagnostics killing the app they exist to diagnose. Wiring now runs
   through enableLinuxCrashDiagnostics(), where every step is
   best-effort: no writable logs dir degrades to "no Chromium log" (and
   keeps the crash reporter, which writes elsewhere), and a Crashpad
   handler that refuses to start is not a startup failure either.

2. Electron opens an explicit --log-file with APPEND_TO_OLD_LOG_FILE, so
   desktop-chromium.log accumulated ERROR/FATAL output across launches
   with no bound. desktop.log is capped at 10 MiB x 3 backups precisely
   because it once reached ~326 GB and exhausted the disk; the new file
   now goes through the same planner before Chromium appends to it.

Co-authored-by: ehz0ah <ehz0ah@users.noreply.github.com>

Refs #100573
2026-09-21 10:23:04 -04:00

100 lines
3.4 KiB
TypeScript

import path from 'node:path'
// Every Chromium CHECK/LOG(FATAL) in the shell ends at the same instruction —
// base::ImmediateCrash at the tail of logging::LogMessage::HandleFatal — so a
// core dump alone says "something fatal happened" and nothing about what
// (#100573: ten Linux reports, one shared trap address, zero fatal messages).
// The message itself goes to stderr a moment before the trap, and every Linux
// launcher (.desktop entry, Omarchy's hermes-desktop wrapper) discards stderr.
// Route Chromium's own log to a file next to desktop.log and let Crashpad keep
// local minidumps, so the next crash carries its FATAL line with it.
export interface LinuxCrashDiagnostics {
/** Chromium command-line switches, applied before `app` is ready. */
switches: ReadonlyArray<readonly [name: string, value: string]>
/** `crashReporter.start` options; local database only, nothing leaves the machine. */
crashReporter: { uploadToServer: false; compress: false }
}
// Chromium log severities: 0 INFO, 1 WARNING, 2 ERROR, 3 FATAL. ERROR keeps the
// file quiet during normal use (INFO would mirror every renderer console line).
const CHROMIUM_LOG_LEVEL_ERROR = '2'
export const CHROMIUM_LOG_FILENAME = 'desktop-chromium.log'
export function linuxCrashDiagnostics(
logsDir: string,
platform: NodeJS.Platform = process.platform
): LinuxCrashDiagnostics | null {
if (platform !== 'linux') {
return null
}
return {
switches: [
['enable-logging', 'file'],
['log-file', path.join(logsDir, CHROMIUM_LOG_FILENAME)],
['log-level', CHROMIUM_LOG_LEVEL_ERROR]
],
crashReporter: { uploadToServer: false, compress: false }
}
}
/** The side effects the plan needs, injected so the failure paths are provable. */
export interface CrashDiagnosticsHost {
/** Create the logs directory. May throw (read-only or invalid HERMES_HOME). */
ensureLogsDir(dir: string): void
/** Bound the Chromium log before Chromium appends to it (APPEND_TO_OLD_LOG_FILE). */
reclaimChromiumLog(file: string): void
appendSwitch(name: string, value: string): void
startCrashReporter(options: LinuxCrashDiagnostics['crashReporter']): void
}
// Diagnostics are optional; startup is not. Every step is best-effort, because
// a read-only or invalid HERMES_HOME/logs must degrade to "no crash log", never
// to a desktop that dies before app readiness. The existing desktop log path
// swallows the same failures for the same reason.
export function enableLinuxCrashDiagnostics(
plan: LinuxCrashDiagnostics | null,
logsDir: string,
host: CrashDiagnosticsHost
): void {
if (!plan) {
return
}
let logsDirReady = true
try {
host.ensureLogsDir(logsDir)
} catch {
// No writable logs dir: Chromium could not open the file anyway. Skip the
// logging switches and keep the crash reporter, which writes elsewhere.
logsDirReady = false
}
if (logsDirReady) {
for (const [name, value] of plan.switches) {
if (name === 'log-file') {
try {
host.reclaimChromiumLog(value)
} catch {
// Best-effort — an unbounded log beats no app, but try every launch.
}
}
try {
host.appendSwitch(name, value)
} catch {
// Ignore: a switch we cannot set only costs us the diagnostic.
}
}
}
try {
host.startCrashReporter(plan.crashReporter)
} catch {
// Crashpad unavailable (sandbox, missing helper) — not a startup failure.
}
}