From 0e2396325b46364ce8d78229af6a575262e5ae15 Mon Sep 17 00:00:00 2001 From: Andrew Mikofalvy <5668128+amikofalvy@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:18:28 -0700 Subject: [PATCH] fix(desktop): suppress crash prompt when a system reboot killed the session (#2751) The boot-time dirty-shutdown scan now records the kernel boot-session identity (kern.bootsessionuuid on macOS) plus liveness and power markers in the sentinel. When the next launch sees a different kernel session, an OS-shutdown marker, or a never-resumed suspend marker, the previous session was ended by the machine, not the app: the report invitation is suppressed and a log breadcrumb is written instead. A fresh minidump overrides suppression (kernel panics never write app minidumps), and missing identity on either side fails open to the previous prompting behavior. PRD-7474 GitOrigin-RevId: 40baa17f17ce7c3eb3c796a0a58c2433776ddb1e --- .changeset/suppress-reboot-crash-prompt.md | 5 + .../reference/what-open-knowledge-writes.mdx | 2 +- .../desktop/src/main/boot-session.test.ts | 35 +++ packages/desktop/src/main/boot-session.ts | 38 +++ .../desktop/src/main/crash-detection.test.ts | 246 ++++++++++++++++ packages/desktop/src/main/crash-detection.ts | 267 ++++++++++++++++-- packages/desktop/src/main/index.ts | 24 ++ .../desktop/src/main/ipc/bug-report.test.ts | 3 + 8 files changed, 595 insertions(+), 25 deletions(-) create mode 100644 .changeset/suppress-reboot-crash-prompt.md create mode 100644 packages/desktop/src/main/boot-session.test.ts create mode 100644 packages/desktop/src/main/boot-session.ts diff --git a/.changeset/suppress-reboot-crash-prompt.md b/.changeset/suppress-reboot-crash-prompt.md new file mode 100644 index 000000000..2081c3c5b --- /dev/null +++ b/.changeset/suppress-reboot-crash-prompt.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": patch +--- + +The desktop app no longer invites you to file a bug report after your machine reboots (kernel panic, forced restart, or power loss) while OpenKnowledge was running. It now recognizes when the previous session ended because the machine went down — not because the app crashed — and skips the prompt in that case, logging the event instead. Genuine app crashes still prompt exactly as before, and a crash that produced a crash dump still prompts, even across a reboot. diff --git a/docs/content/reference/what-open-knowledge-writes.mdx b/docs/content/reference/what-open-knowledge-writes.mdx index 448ab6a3f..6e6519a81 100644 --- a/docs/content/reference/what-open-knowledge-writes.mdx +++ b/docs/content/reference/what-open-knowledge-writes.mdx @@ -38,7 +38,7 @@ The macOS app ships as a signed `.dmg` you drag into `/Applications`. On launch | `~/.ok/mcp-status.json` | Records your first-launch MCP-setup consent choice | Outside-project (home dir) | | `~/Library/Caches/OpenKnowledge-updater/` | Staged auto-update downloads (electron-updater) | Outside-project (home dir) | | `~/Library/Application Support/OpenKnowledge/Crashpad/` | Native crash minidumps, written by the OS crash handler (Electron's Crashpad) if the app ever crashes. Local-only: the crash reporter runs with `uploadToServer: false`, so no dump is ever uploaded automatically | Outside-project (home dir) | -| `~/Library/Application Support/OpenKnowledge/bug-report-dirty-shutdown.json` | Dirty-shutdown sentinel — written on **every** launch and removed on a clean quit, so the next launch can tell whether the previous session crashed | Outside-project (home dir) | +| `~/Library/Application Support/OpenKnowledge/bug-report-dirty-shutdown.json` | Dirty-shutdown sentinel — written on **every** launch, refreshed as it runs, and removed on a clean quit. The next launch reads it to tell an app crash (prompts you) from the machine ending the session — a reboot, an OS shutdown, or dying asleep (suppressed and logged instead) | Outside-project (home dir) | | `~/Library/Application Support/OpenKnowledge/bug-report-crash-acks.json` | Records which crash-report invitations you already answered or dismissed, so the same crash never re-prompts | Outside-project (home dir) | The app registers the `openknowledge://` URL scheme (for deep links) and checks for updates — on launch and periodically while it runs — against the OpenKnowledge update service at `openknowledge.ai/updates`, which redirects to the GitHub release asset and counts the update per version and channel (falling back to GitHub directly if it's unreachable). An update on your channel downloads in the background and installs on the next quit; you can also check on demand from **Check for updates…** in the app and Help menus. The channel (stable or beta) is fixed by the build you installed — switch by reinstalling the other from `openknowledge.ai/download/stable` or `/download/beta`. Override the feed with `OK_UPDATER_FEED_URL`. diff --git a/packages/desktop/src/main/boot-session.test.ts b/packages/desktop/src/main/boot-session.test.ts new file mode 100644 index 000000000..1f0e4147f --- /dev/null +++ b/packages/desktop/src/main/boot-session.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test'; +import { readBootSessionUuid } from './boot-session.ts'; + +const onDarwin = process.platform === 'darwin' ? test : test.skip; +const onLinux = process.platform === 'linux' ? test : test.skip; + +describe('readBootSessionUuid', () => { + test('unsupported platforms fail open to null', () => { + expect(readBootSessionUuid('win32')).toBeNull(); + expect(readBootSessionUuid('freebsd')).toBeNull(); + }); + + onDarwin('returns a stable per-boot UUID on macOS', () => { + const first = readBootSessionUuid('darwin'); + expect(first).toMatch(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i); + // Stable within one kernel session — the whole point of the identity. + expect(readBootSessionUuid('darwin')).toBe(first); + }); + + onLinux('returns a stable per-boot id on Linux', () => { + const first = readBootSessionUuid('linux'); + expect(first).toBeTruthy(); + expect(readBootSessionUuid('linux')).toBe(first); + }); + + test('a probe failure fails open to null rather than throwing', () => { + // Probing the "wrong" platform's code path always fails on any real + // host — the darwin branch's absolute sysctl path doesn't exist on + // Linux/Windows, and the linux branch's /proc file doesn't exist on + // macOS/Windows — exercising the catch-to-null contract unconditionally. + const crossPlatformProbe = + process.platform === 'linux' ? readBootSessionUuid('darwin') : readBootSessionUuid('linux'); + expect(crossPlatformProbe).toBeNull(); + }); +}); diff --git a/packages/desktop/src/main/boot-session.ts b/packages/desktop/src/main/boot-session.ts new file mode 100644 index 000000000..d142425ab --- /dev/null +++ b/packages/desktop/src/main/boot-session.ts @@ -0,0 +1,38 @@ +/** + * Identity of the running kernel session, for crash detection's boot-time + * scan: the value changes if and only if the kernel rebooted, so comparing + * the previous session's recorded value against the current one separates + * "the machine went down under the app" from "the app died on its own" with + * exact string equality — no clock arithmetic, no slack windows. + * + * Fail-open by contract: `null` (unsupported platform, probe failure) means + * "no epoch identity available" and callers must fall back to their + * unclassified behavior rather than guessing. + */ + +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +export function readBootSessionUuid(platform: NodeJS.Platform = process.platform): string | null { + try { + if (platform === 'darwin') { + // Absolute binary path — packaged apps launch with a minimal PATH. + const out = execFileSync('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid'], { + encoding: 'utf8', + timeout: 2_000, + }); + return normalize(out); + } + if (platform === 'linux') { + return normalize(readFileSync('/proc/sys/kernel/random/boot_id', 'utf8')); + } + return null; + } catch { + return null; + } +} + +function normalize(raw: string): string | null { + const value = raw.trim(); + return value === '' ? null : value; +} diff --git a/packages/desktop/src/main/crash-detection.test.ts b/packages/desktop/src/main/crash-detection.test.ts index 0f5b28e4f..0e78c0301 100644 --- a/packages/desktop/src/main/crash-detection.test.ts +++ b/packages/desktop/src/main/crash-detection.test.ts @@ -43,6 +43,8 @@ interface Rig { emitted: OkBugReportCrashDetectedEvent[]; /** Flip to false to simulate "no live renderer window can take the event". */ setRendererAvailable(available: boolean): void; + /** Swap the kernel boot-session identity, simulating a reboot between sessions. */ + setBootSessionUuid(uuid: string | null): void; /** Advance and return the fake clock (10s per tick). */ tick(): Date; dir: string; @@ -53,6 +55,7 @@ function makeRig(): Rig { tmpDirs.push(dir); const emitted: OkBugReportCrashDetectedEvent[] = []; let rendererAvailable = true; + let bootSessionUuid: string | null = 'boot-epoch-a'; let clockMs = Date.parse('2026-07-10T00:00:00.000Z'); return { dir, @@ -60,6 +63,9 @@ function makeRig(): Rig { setRendererAvailable(available: boolean) { rendererAvailable = available; }, + setBootSessionUuid(uuid: string | null) { + bootSessionUuid = uuid; + }, tick() { clockMs += 10_000; return new Date(clockMs); @@ -77,11 +83,19 @@ function makeRig(): Rig { clockMs += 10_000; return new Date(clockMs); }, + currentBootSessionUuid: () => bootSessionUuid, logger: silentLogger, }, }; } +function readSentinel(rig: Rig): Record { + return JSON.parse(readFileSync(rig.deps.sentinelPath, 'utf8')) as Record< + string, + string | undefined + >; +} + /** Seed a minidump whose mtime is pinned to the fake clock's timeline. */ function seedMinidump(rig: Rig, relPath: string, at: Date): void { const dumpPath = join(rig.deps.crashDumpsDir, relPath); @@ -297,6 +311,238 @@ describe('boot-time detection', () => { }); }); +describe('machine-level death suppression', () => { + test('a dirty shutdown from the same kernel session still prompts', () => { + const rig = makeRig(); + createCrashDetection(rig.deps).detectBootCrash(); + // Session A crashes; the machine keeps running (same boot-session uuid). + + const armed = createCrashDetection(rig.deps).detectBootCrash(); + expect(armed?.kind).toBe('boot'); + if (armed?.kind === 'boot') { + expect(armed.context.dirtyShutdown).toBe(true); + } + }); + + test('a dirty shutdown across a kernel reboot never prompts', () => { + const rig = makeRig(); + createCrashDetection(rig.deps).detectBootCrash(); + // Session A is killed by a machine reboot — next boot is a new kernel session. + + rig.setBootSessionUuid('boot-epoch-b'); + const sessionB = createCrashDetection(rig.deps); + expect(sessionB.detectBootCrash()).toBeNull(); + sessionB.notifyRendererReady(); + expect(rig.emitted).toHaveLength(0); + + // The replacement sentinel carries the new kernel session's identity. + expect(readSentinel(rig).bootSessionUuid).toBe('boot-epoch-b'); + }); + + test('suppression logs a breadcrumb naming the reboot', () => { + const rig = makeRig(); + const infoLines: Array> = []; + rig.deps.logger = { + info: (payload: Record) => { + infoLines.push(payload); + }, + warn: () => {}, + }; + createCrashDetection(rig.deps).detectBootCrash(); + + rig.setBootSessionUuid('boot-epoch-b'); + createCrashDetection(rig.deps).detectBootCrash(); + + const breadcrumb = infoLines.find( + (line) => line.event === 'crash-detection.machine-level-death', + ); + expect(breadcrumb?.reason).toBe('system-reboot'); + expect(breadcrumb?.prevBootSessionUuid).toBe('boot-epoch-a'); + expect(breadcrumb?.currentBootSessionUuid).toBe('boot-epoch-b'); + expect(breadcrumb?.lastAliveAt).toBeTruthy(); + }); + + test('a fresh minidump still prompts across a reboot, as the dump-driven variant', () => { + const rig = makeRig(); + createCrashDetection(rig.deps).detectBootCrash(); + seedMinidump(rig, 'pending/native-crash.dmp', rig.tick()); + // The app native-crashed (dump on disk), then the machine rebooted. + + rig.setBootSessionUuid('boot-epoch-b'); + const armed = createCrashDetection(rig.deps).detectBootCrash(); + expect(armed?.kind).toBe('boot'); + expect(armed?.eventId).toStartWith('boot:dump:'); + if (armed?.kind === 'boot') { + expect(armed.context.dirtyShutdown).toBe(false); + expect(armed.context.newMinidumps).toBe(1); + } + }); + + test('a session that died asleep (suspended, never resumed) never prompts', () => { + const rig = makeRig(); + const infoLines: Array> = []; + rig.deps.logger = { + info: (payload: Record) => { + infoLines.push(payload); + }, + warn: () => {}, + }; + const sessionA = createCrashDetection(rig.deps); + sessionA.detectBootCrash(); + sessionA.noteSuspend(); + // The machine loses power while asleep (e.g. the battery dies) — safe-sleep + // resume preserves the kernel boot session (Apple's IOPMrootDomain docs: + // BootSessionUUID "remain[s] same across sleep/wake/hibernate cycle"), so + // there is no reboot to detect, and no OS-shutdown marker either — the OS + // never got a chance to announce anything before power was cut. + + const sessionB = createCrashDetection(rig.deps); + const armed = sessionB.detectBootCrash(); + expect(armed).toBeNull(); + sessionB.notifyRendererReady(); + expect(rig.emitted).toHaveLength(0); + + const breadcrumb = infoLines.find( + (line) => line.event === 'crash-detection.machine-level-death', + ); + expect(breadcrumb?.reason).toBe('suspended'); + expect(breadcrumb?.suspendedAt).toBeTruthy(); + }); + + test('a session that resumed from suspend before a later, unrelated crash still prompts', () => { + const rig = makeRig(); + const sessionA = createCrashDetection(rig.deps); + sessionA.detectBootCrash(); + sessionA.noteSuspend(); + sessionA.noteResume(); + // Session A wakes normally, then later crashes for an unrelated reason + // (still no markCleanQuit) — this pins that `noteResume()` actually + // clears the suspend marker, since a regression there would silently + // suppress every crash following any sleep/wake cycle. + + const sessionB = createCrashDetection(rig.deps); + const armed = sessionB.detectBootCrash(); + expect(armed?.kind).toBe('boot'); + if (armed?.kind === 'boot') { + expect(armed.context.dirtyShutdown).toBe(true); + } + sessionB.notifyRendererReady(); + expect(rig.emitted).toHaveLength(1); + }); + + test('an OS shutdown that outran the quit sequence never prompts', () => { + const rig = makeRig(); + const sessionA = createCrashDetection(rig.deps); + sessionA.detectBootCrash(); + sessionA.noteOsShutdown(); + // The OS kills the app before will-quit completes — same kernel session + // on the next launch (shutdown was e.g. a logout-style teardown). + + const sessionB = createCrashDetection(rig.deps); + expect(sessionB.detectBootCrash()).toBeNull(); + sessionB.notifyRendererReady(); + expect(rig.emitted).toHaveLength(0); + }); + + test('an OS-shutdown suppression logs a warn breadcrumb naming the marker', () => { + const rig = makeRig(); + const warnLines: Array> = []; + rig.deps.logger = { + info: () => {}, + warn: (payload: Record) => { + warnLines.push(payload); + }, + }; + const sessionA = createCrashDetection(rig.deps); + sessionA.detectBootCrash(); + sessionA.noteOsShutdown(); + + createCrashDetection(rig.deps).detectBootCrash(); + + const breadcrumb = warnLines.find( + (line) => line.event === 'crash-detection.machine-level-death', + ); + expect(breadcrumb?.reason).toBe('os-shutdown'); + expect(breadcrumb?.pendingOsShutdownAt).toBeTruthy(); + expect(breadcrumb?.prevBootSessionUuid).toBe('boot-epoch-a'); + }); + + test('a cancelled OS shutdown stops suppressing once heartbeats outlive the marker', () => { + const rig = makeRig(); + const sessionA = createCrashDetection(rig.deps); + sessionA.detectBootCrash(); + sessionA.noteOsShutdown(); + // The user cancels the shutdown; the app keeps running and heartbeating + // past the marker TTL (the fake clock advances 10s per heartbeat), then + // genuinely crashes. + for (let i = 0; i < 15; i++) sessionA.noteAlive(); + expect(readSentinel(rig).pendingOsShutdownAt).toBeUndefined(); + + const armed = createCrashDetection(rig.deps).detectBootCrash(); + expect(armed?.kind).toBe('boot'); + }); + + test('a pre-upgrade sentinel without a kernel identity prompts as before', () => { + const rig = makeRig(); + mkdirSync(dirname(rig.deps.sentinelPath), { recursive: true }); + writeFileSync( + rig.deps.sentinelPath, + `${JSON.stringify({ bootId: '1784494925550', startedAt: '2026-07-09T21:02:05.550Z' })}\n`, + ); + + const armed = createCrashDetection(rig.deps).detectBootCrash(); + expect(armed?.kind).toBe('boot'); + expect(armed?.eventId).toBe('boot:1784494925550'); + }); + + test('no kernel identity available fails open to prompting', () => { + // Probe unavailable in the crashed session: its sentinel has no uuid. + const rig = makeRig(); + rig.setBootSessionUuid(null); + createCrashDetection(rig.deps).detectBootCrash(); + rig.setBootSessionUuid('boot-epoch-b'); + expect(createCrashDetection(rig.deps).detectBootCrash()?.kind).toBe('boot'); + + // Probe unavailable in the detecting session. + const rig2 = makeRig(); + createCrashDetection(rig2.deps).detectBootCrash(); + rig2.setBootSessionUuid(null); + expect(createCrashDetection(rig2.deps).detectBootCrash()?.kind).toBe('boot'); + }); + + test('the heartbeat refreshes liveness and freezes after a clean quit', () => { + const rig = makeRig(); + const detection = createCrashDetection(rig.deps); + detection.detectBootCrash(); + const first = readSentinel(rig).lastAliveAt; + if (first === undefined) throw new Error('expected lastAliveAt in the sentinel'); + + detection.noteAlive(); + const second = readSentinel(rig).lastAliveAt; + if (second === undefined) throw new Error('expected lastAliveAt after a heartbeat'); + expect(Date.parse(second)).toBeGreaterThan(Date.parse(first)); + + detection.markCleanQuit(); + expect(existsSync(rig.deps.sentinelPath)).toBe(false); + // A straggling timer tick after the orderly quit must not resurrect the + // sentinel — that would turn every clean quit into a phantom crash. + detection.noteAlive(); + expect(existsSync(rig.deps.sentinelPath)).toBe(false); + }); + + test('suspend and resume are mirrored into the sentinel', () => { + const rig = makeRig(); + const detection = createCrashDetection(rig.deps); + detection.detectBootCrash(); + + detection.noteSuspend(); + expect(readSentinel(rig).suspendedAt).toBeTruthy(); + + detection.noteResume(); + expect(readSentinel(rig).suspendedAt).toBeUndefined(); + }); +}); + describe('newest un-acked minidump lookup', () => { test('returns the newest dump past the ack baseline, and none once acked', () => { const rig = makeRig(); diff --git a/packages/desktop/src/main/crash-detection.ts b/packages/desktop/src/main/crash-detection.ts index 19dd79105..01b0c687d 100644 --- a/packages/desktop/src/main/crash-detection.ts +++ b/packages/desktop/src/main/crash-detection.ts @@ -18,6 +18,15 @@ * is armed at a time, and acknowledgments persist (userData JSON) so an * acked event never re-prompts across restarts. * + * A dirty shutdown only merits a prompt when the app itself died. The + * sentinel records the kernel boot-session identity plus liveness/power + * markers; when the next boot sees a different kernel session (the machine + * rebooted out from under the previous session) or an OS-shutdown marker, + * the invitation is suppressed — a reboot or power loss is not an app bug, + * so it becomes a log breadcrumb, never a report prompt. A fresh minidump + * overrides suppression: kernel panics never write app minidumps, so a dump + * proves the app native-crashed before the machine went down. + * * Deliberately absent: a userland `uncaughtException` handler. Electron * defers its main-process crash dialog to such a handler whenever one exists * (see `process-safety-net.ts`) — the boot-time sentinel/minidump scan is how @@ -57,6 +66,20 @@ const MAX_ACKED_EVENT_IDS = 50; /** Crashpad nests dumps (`pending/`, `completed/`, `new/`) — walk a bounded depth. */ const MINIDUMP_SCAN_DEPTH = 3; +/** + * A real OS shutdown kills the process within seconds of the announcement. + * If heartbeats are still arriving this long after one, the shutdown was + * cancelled — the marker must be dropped so a later genuine crash in the same + * session isn't misread as an OS-shutdown kill and wrongly suppressed. Must + * stay greater than `SENTINEL_HEARTBEAT_INTERVAL_MS` — cancelled-shutdown + * recovery relies on at least one heartbeat landing inside the TTL window so + * a later one can observe it's expired. + */ +const OS_SHUTDOWN_MARKER_TTL_MS = 120_000; + +/** How often the sentinel's `lastAliveAt` is refreshed while the app runs. */ +export const SENTINEL_HEARTBEAT_INTERVAL_MS = 60_000; + interface CrashLogger { info(payload: Record, msg: string): void; warn(payload: Record, msg: string): void; @@ -69,6 +92,27 @@ interface CrashAckStore { minidumpBaselineAt: string; } +/** + * On-disk sentinel contents for the running session. No version field — + * forward/backward compatibility instead relies on every field staying + * add-only (never renamed or repurposed) and `field()` in `detectBootCrash` + * returning null for any key a reader doesn't recognize, since this file is + * read across app-version boundaries (auto-update can start a new binary + * against a sentinel an older one wrote). + */ +interface SentinelState { + bootId: string; + startedAt: string; + /** Refreshed by the heartbeat; how close to death the session was known alive. */ + lastAliveAt: string; + /** Kernel session identity; absent when the platform probe returned null. */ + bootSessionUuid?: string; + /** Set when the OS announced a shutdown/restart; TTL-cleared if we survive it. */ + pendingOsShutdownAt?: string; + /** Set on suspend, cleared on resume — a never-resumed sentinel died asleep. */ + suspendedAt?: string; +} + export interface CrashDetectionDeps { /** Dirty-shutdown sentinel — written each boot, removed on clean quit. */ sentinelPath: string; @@ -83,6 +127,13 @@ export interface CrashDetectionDeps { */ emit(event: OkBugReportCrashDetectedEvent): boolean; now(): Date; + /** + * Identity of the running kernel session (`kern.bootsessionuuid` on macOS); + * changes if and only if the kernel rebooted. Null when unavailable — + * detection then skips the reboot classification entirely (fail-open + * toward prompting, i.e. the pre-classification behavior). + */ + currentBootSessionUuid(): string | null; logger: CrashLogger; } @@ -96,6 +147,22 @@ export interface CrashDetection { detectBootCrash(): OkBugReportCrashDetectedEvent | null; /** Clean-quit path: removes the sentinel so the next boot reads as clean. */ markCleanQuit(): void; + /** + * Liveness heartbeat: refreshes the sentinel's `lastAliveAt` (and expires a + * stale OS-shutdown marker). No-op after `markCleanQuit` — a straggling + * timer tick must never resurrect the sentinel an orderly quit removed, + * which would turn every clean quit into next boot's phantom crash. + */ + noteAlive(): void; + /** + * The OS announced a shutdown/restart. If the process is killed before the + * quit sequence completes, the next boot suppresses the report prompt (and + * warns about the unfinished quit) instead of blaming the app. + */ + noteOsShutdown(): void; + /** System is suspending; a sentinel that never resumes died asleep (power loss). */ + noteSuspend(): void; + noteResume(): void; handleRenderProcessGone(details: { reason: string; exitCode?: number }): void; handleChildProcessGone(details: { type: string; @@ -186,6 +253,39 @@ export function createCrashDetection(deps: CrashDetectionDeps): CrashDetection { let active: { event: OkBugReportCrashDetectedEvent; delivered: boolean } | null = null; let runtimeSeq = 0; + /** This session's sentinel; null until `detectBootCrash` writes the first version. */ + let sentinel: SentinelState | null = null; + /** Freezes every sentinel writer once the file was removed by an orderly quit. */ + let cleanQuitMarked = false; + + /** + * Which caller triggered the write — surfaced in the failure log so a + * write that fails on `noteOsShutdown` (losing the shutdown marker this + * feature depends on) doesn't read as "could not arm," which would send an + * investigator toward "did detection even run?" instead of "did the + * shutdown marker persist?" + */ + type SentinelWriteContext = 'arm' | 'alive' | 'os-shutdown' | 'suspend' | 'resume'; + + function writeSentinel(context: SentinelWriteContext): void { + if (sentinel === null || cleanQuitMarked) return; + try { + mkdirSync(dirname(deps.sentinelPath), { recursive: true }); + writeFileSync(deps.sentinelPath, `${JSON.stringify(sentinel)}\n`); + } catch (err) { + deps.logger.warn( + { + event: 'crash-detection.sentinel-write-failed', + context, + cause: err instanceof Error ? err.message : String(err), + }, + context === 'arm' + ? 'could not arm the dirty-shutdown sentinel' + : 'could not update the dirty-shutdown sentinel', + ); + } + } + let storeNeedsInit = false; let store: CrashAckStore; { @@ -271,6 +371,20 @@ export function createCrashDetection(deps: CrashDetectionDeps): CrashDetection { return { detectBootCrash(): OkBugReportCrashDetectedEvent | null { const detectedAt = deps.now(); + const bootSessionUuid = deps.currentBootSessionUuid(); + if ( + bootSessionUuid === null && + (process.platform === 'darwin' || process.platform === 'linux') + ) { + // Reboot suppression silently stops working if this ever goes null on + // a platform it should work on (sysctl timeout, sandboxed exec, + // renamed binary) — nothing else would surface why every reboot + // started prompting again. + deps.logger.warn( + { event: 'crash-detection.boot-session-unavailable', platform: process.platform }, + 'kernel boot-session identity unavailable — reboot suppression is disabled this launch', + ); + } let sentinelPresent = false; let sentinelRaw: string | null = null; @@ -283,11 +397,22 @@ export function createCrashDetection(deps: CrashDetectionDeps): CrashDetection { sentinelPresent = !isFileMissingError(err); } let prevBootId: string | null = null; + let prevBootSessionUuid: string | null = null; + let prevLastAliveAt: string | null = null; + let prevPendingOsShutdownAt: string | null = null; + let prevSuspendedAt: string | null = null; if (sentinelRaw !== null) { try { - const parsed: unknown = JSON.parse(sentinelRaw); - const bootId = (parsed as Record | null)?.bootId; - if (typeof bootId === 'string' && bootId !== '') prevBootId = bootId; + const parsed = JSON.parse(sentinelRaw) as Record | null; + const field = (key: string): string | null => { + const value = parsed?.[key]; + return typeof value === 'string' && value !== '' ? value : null; + }; + prevBootId = field('bootId'); + prevBootSessionUuid = field('bootSessionUuid'); + prevLastAliveAt = field('lastAliveAt'); + prevPendingOsShutdownAt = field('pendingOsShutdownAt'); + prevSuspendedAt = field('suspendedAt'); } catch { // Torn write from the crashed session — presence alone is the signal. } @@ -298,20 +423,85 @@ export function createCrashDetection(deps: CrashDetectionDeps): CrashDetection { const baselineMs = Date.parse(store.minidumpBaselineAt); const newDumps = dumpEntries.filter((e) => e.mtimeMs > baselineMs).map((e) => e.mtimeMs); + // A boot-session mismatch means the kernel rebooted after the previous + // session was last alive; an os-shutdown marker means the OS killed the + // app past its quit grace; a never-resumed suspend marker means the + // session died asleep (e.g. the battery ran out) — safe-sleep resume + // preserves the kernel boot session (Apple's IOPMrootDomain docs: + // BootSessionUUID "remain[s] same across sleep/wake/hibernate cycle"), + // so a reboot never happens for this case and it needs its own signal. + // Either way the machine ended the session, not the app. Missing + // identity on either side (old-format sentinel, probe failure) skips + // the reboot classification — fail-open toward prompting. Note this + // reboot signal has an inherent false-negative: an app crash followed + // by an unrelated user-initiated reboot before relaunch reads + // identically to "the reboot killed the app" and is suppressed too — + // accepted tradeoff of comparing boot-session identity alone. The + // suspend marker has an analogous narrow window: a whole-process crash + // between the OS delivering wake and `noteResume()`'s synchronous clear + // reads as "died asleep" too — mitigated by the fresh-minidump override + // below for native crashes, and by `noteResume()` running as the first + // step of the `resume` handler. + const rebootedBetweenSessions = + prevBootSessionUuid !== null && + bootSessionUuid !== null && + prevBootSessionUuid !== bootSessionUuid; + const machineLevelDeath = + sentinelPresent && + (rebootedBetweenSessions || prevPendingOsShutdownAt !== null || prevSuspendedAt !== null); + let armed: OkBugReportCrashDetectedEvent | null = null; - if (sentinelPresent || newDumps.length > 0) { + if (machineLevelDeath && newDumps.length === 0) { + const reason = rebootedBetweenSessions + ? 'system-reboot' + : prevPendingOsShutdownAt !== null + ? 'os-shutdown' + : 'suspended'; + const breadcrumb = { + event: 'crash-detection.machine-level-death', + reason, + detectedAt: detectedAt.toISOString(), + prevBootId, + prevBootSessionUuid, + currentBootSessionUuid: bootSessionUuid, + lastAliveAt: prevLastAliveAt, + suspendedAt: prevSuspendedAt, + pendingOsShutdownAt: prevPendingOsShutdownAt, + }; + if (reason === 'os-shutdown') { + // Same kernel session yet the shutdown marker survived: our quit + // sequence never completed before the OS killed the app. That gap + // is ours to watch in logs, but not the user's to report. + deps.logger.warn( + breadcrumb, + 'previous session was killed during an OS shutdown — suppressing the report prompt', + ); + } else { + deps.logger.info( + breadcrumb, + reason === 'system-reboot' + ? 'previous session was killed by a system reboot — suppressing the report prompt' + : 'previous session died asleep without resuming — suppressing the report prompt', + ); + } + } else if (sentinelPresent || newDumps.length > 0) { // Sentinel-derived ids stay stable for the same crashed session, so an // ack survives even if detection runs again before this boot rewrites // the sentinel. The dump-only and unreadable-sentinel fallbacks only // need in-session stability — the sentinel is replaced below either way. - const eventId = sentinelPresent - ? `boot:${prevBootId ?? `unreadable:${detectedAt.getTime()}`}` - : `boot:dump:${Math.max(...newDumps)}`; + // + // A machine-level death with fresh dumps still prompts, but as the + // dump-driven variant: the reboot ended the session, the dump is the + // crash — framing it as an app dirty-shutdown would misattribute. + const dumpDriven = !sentinelPresent || machineLevelDeath; + const eventId = dumpDriven + ? `boot:dump:${Math.max(...newDumps)}` + : `boot:${prevBootId ?? `unreadable:${detectedAt.getTime()}`}`; if (!store.ackedEventIds.includes(eventId)) { const event: OkBugReportCrashDetectedEvent = { eventId, kind: 'boot', - context: { dirtyShutdown: sentinelPresent, newMinidumps: newDumps.length }, + context: { dirtyShutdown: !dumpDriven, newMinidumps: newDumps.length }, // A prior-session dump is already on disk, so the freshness scan // that produced newDumps is the authoritative availability answer. minidumpAvailable: newDumps.length > 0, @@ -322,7 +512,8 @@ export function createCrashDetection(deps: CrashDetectionDeps): CrashDetection { { event: 'crash-detection.boot', eventId, - dirtyShutdown: sentinelPresent, + detectedAt: detectedAt.toISOString(), + dirtyShutdown: !dumpDriven, newMinidumps: newDumps.length, }, 'previous session ended uncleanly — arming report invitation', @@ -336,26 +527,19 @@ export function createCrashDetection(deps: CrashDetectionDeps): CrashDetection { storeNeedsInit = false; } - try { - mkdirSync(dirname(deps.sentinelPath), { recursive: true }); - writeFileSync( - deps.sentinelPath, - `${JSON.stringify({ bootId: String(detectedAt.getTime()), startedAt: detectedAt.toISOString() })}\n`, - ); - } catch (err) { - deps.logger.warn( - { - event: 'crash-detection.sentinel-write-failed', - cause: err instanceof Error ? err.message : String(err), - }, - 'could not arm the dirty-shutdown sentinel', - ); - } + sentinel = { + bootId: String(detectedAt.getTime()), + startedAt: detectedAt.toISOString(), + lastAliveAt: detectedAt.toISOString(), + ...(bootSessionUuid !== null ? { bootSessionUuid } : {}), + }; + writeSentinel('arm'); return armed; }, markCleanQuit(): void { + cleanQuitMarked = true; try { rmSync(deps.sentinelPath, { force: true }); } catch (err) { @@ -369,6 +553,41 @@ export function createCrashDetection(deps: CrashDetectionDeps): CrashDetection { } }, + noteAlive(): void { + if (sentinel === null || cleanQuitMarked) return; + const nowAt = deps.now(); + if (sentinel.pendingOsShutdownAt !== undefined) { + const announcedMs = Date.parse(sentinel.pendingOsShutdownAt); + if ( + Number.isFinite(announcedMs) && + nowAt.getTime() - announcedMs > OS_SHUTDOWN_MARKER_TTL_MS + ) { + delete sentinel.pendingOsShutdownAt; + } + } + sentinel.lastAliveAt = nowAt.toISOString(); + writeSentinel('alive'); + }, + + noteOsShutdown(): void { + if (sentinel === null || cleanQuitMarked) return; + sentinel.pendingOsShutdownAt = deps.now().toISOString(); + writeSentinel('os-shutdown'); + }, + + noteSuspend(): void { + if (sentinel === null || cleanQuitMarked) return; + sentinel.suspendedAt = deps.now().toISOString(); + writeSentinel('suspend'); + }, + + noteResume(): void { + if (sentinel === null || cleanQuitMarked) return; + delete sentinel.suspendedAt; + sentinel.lastAliveAt = deps.now().toISOString(); + writeSentinel('resume'); + }, + handleRenderProcessGone(details): void { if (!CRASH_REASONS.has(details.reason)) return; deps.logger.warn( diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index c296f5283..2fc277246 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -117,6 +117,7 @@ import { Menu, nativeImage, nativeTheme, + powerMonitor, screen, session, shell, @@ -145,6 +146,7 @@ import { type StartAutoUpdaterHandle, } from './auto-updater.ts'; import { resolveBootRestoreDecision } from './boot-restore-decision.ts'; +import { readBootSessionUuid } from './boot-session.ts'; import { runBootstrap } from './bootstrap.ts'; import { type BranchInfoProxyDeps, @@ -174,6 +176,7 @@ import { requestUserConsent, walkExceedsCap } from './consent-dialog.ts'; import { type CrashDetection, createCrashDetection, + SENTINEL_HEARTBEAT_INTERVAL_MS, startLocalCrashReporter, } from './crash-detection.ts'; import { @@ -938,6 +941,8 @@ let mcpWiringHandle: RunMcpWiringHandle | null = null; * boot paths, which never prompt. */ let crashDetection: CrashDetection | null = null; +/** Sentinel liveness heartbeat; cleared on `will-quit` with the other teardowns. */ +let crashSentinelHeartbeat: NodeJS.Timeout | null = null; /** * Records the server's last exit (code + Electron process-gone reason) to @@ -5129,9 +5134,22 @@ function bootPrimaryInstance(): void { return false; }, now: () => new Date(), + currentBootSessionUuid: readBootSessionUuid, logger: getLogger('crash-detection'), }); crashDetection.detectBootCrash(); + // Keep the sentinel's liveness fresh and mirror power transitions into it, + // so the next boot can tell "the machine went down under a running app" + // (suppress the report prompt) from "the app died on its own" (prompt). + // `bootPrimaryInstance` runs inside `whenReady`, so powerMonitor is usable. + crashSentinelHeartbeat = setInterval( + () => crashDetection?.noteAlive(), + SENTINEL_HEARTBEAT_INTERVAL_MS, + ); + crashSentinelHeartbeat.unref(); + powerMonitor.on('shutdown', () => crashDetection?.noteOsShutdown()); + powerMonitor.on('suspend', () => crashDetection?.noteSuspend()); + powerMonitor.on('resume', () => crashDetection?.noteResume()); app.on('child-process-gone', (_event, details) => { // Feed the server-exit recorder every Utility death (not just the crash // reasons the invitation pipeline acts on) so the bundle can distinguish a @@ -5938,7 +5956,13 @@ function bootPrimaryInstance(): void { getLogger('lifecycle').info({}, 'will-quit'); // A quit that reaches here was orderly — clear the dirty-shutdown // sentinel so the next boot doesn't read this session as a crash. + // (`markCleanQuit` also freezes the sentinel writers, so a heartbeat + // tick racing this teardown can't resurrect the file.) crashDetection?.markCleanQuit(); + if (crashSentinelHeartbeat !== null) { + clearInterval(crashSentinelHeartbeat); + crashSentinelHeartbeat = null; + } // Reap every window's PTY host first so no user shell / spawn-helper // outlives the app. Idempotent (clears the map; a second pass no-ops). terminalReaper?.killAll(); diff --git a/packages/desktop/src/main/ipc/bug-report.test.ts b/packages/desktop/src/main/ipc/bug-report.test.ts index 3d82abc45..2495c319b 100644 --- a/packages/desktop/src/main/ipc/bug-report.test.ts +++ b/packages/desktop/src/main/ipc/bug-report.test.ts @@ -1269,6 +1269,9 @@ describe('handleBugReportCrashAck', () => { clockMs += 10_000; return new Date(clockMs); }, + // Constant identity: every simulated restart happens inside one kernel + // session, so reboot suppression never engages in this ack round-trip. + currentBootSessionUuid: () => 'boot-epoch-test', logger: { info: () => {}, warn: () => {} }, }; const seedMinidump = (relPath: string): void => {