Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/suppress-reboot-crash-prompt.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/content/reference/what-open-knowledge-writes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
35 changes: 35 additions & 0 deletions packages/desktop/src/main/boot-session.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
38 changes: 38 additions & 0 deletions packages/desktop/src/main/boot-session.ts
Original file line number Diff line number Diff line change
@@ -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;
}
246 changes: 246 additions & 0 deletions packages/desktop/src/main/crash-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -53,13 +55,17 @@ 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,
emitted,
setRendererAvailable(available: boolean) {
rendererAvailable = available;
},
setBootSessionUuid(uuid: string | null) {
bootSessionUuid = uuid;
},
tick() {
clockMs += 10_000;
return new Date(clockMs);
Expand All @@ -77,11 +83,19 @@ function makeRig(): Rig {
clockMs += 10_000;
return new Date(clockMs);
},
currentBootSessionUuid: () => bootSessionUuid,
logger: silentLogger,
},
};
}

function readSentinel(rig: Rig): Record<string, string | undefined> {
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);
Expand Down Expand Up @@ -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<Record<string, unknown>> = [];
rig.deps.logger = {
info: (payload: Record<string, unknown>) => {
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<Record<string, unknown>> = [];
rig.deps.logger = {
info: (payload: Record<string, unknown>) => {
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<Record<string, unknown>> = [];
rig.deps.logger = {
info: () => {},
warn: (payload: Record<string, unknown>) => {
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();
Expand Down
Loading