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/bug-report-server-exit-reason.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": patch
---

Report a bug now records why the server last exited. When the background server process goes away, the desktop app writes the exit code and the reason it left (a clean shutdown, a crash, or an out-of-memory / OS kill) to `state/last-server-exit.json` in the report bundle, next to `server.lock`. Until now a bundle could only show that the server's port was "unreachable", which looks identical whether the server crashed or was shut down cleanly, so a "my app crashed" report could not be told apart from a routine stop. The new record captures the death even when the server was killed and had no chance to log anything itself. It holds only the exit code, the reason, the pid, and a timestamp, so it carries no document content or paths.
22 changes: 22 additions & 0 deletions packages/cli/src/diagnose/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,28 @@ describe('collectBundle — state files', () => {
collected.cleanup();
});

test('last-server-exit.json is staged when the desktop host wrote one', async () => {
const contentDir = makeTmpDir();
const body = `${JSON.stringify(
{ at: '2026-07-16T21:02:24.000Z', pid: 51502, code: null, reason: 'killed' },
null,
2,
)}\n`;
writeAt(contentDir, '.ok/local/last-server-exit.json', body);
const collected = await collectBundle({ contentDir, deps: makeDeterministicDeps() });
expect(
readFileSync(join(collected.stagingDir, 'state', 'last-server-exit.json'), 'utf-8'),
).toBe(body);
collected.cleanup();
});

test('last-server-exit.json is omitted when the host never recorded an exit', async () => {
const contentDir = makeTmpDir();
const collected = await collectBundle({ contentDir, deps: makeDeterministicDeps() });
expect(existsSync(join(collected.stagingDir, 'state', 'last-server-exit.json'))).toBe(false);
collected.cleanup();
});

test('runtime.json carries ok, host blocks; desktop is null by default', async () => {
const contentDir = makeTmpDir();
const collected = await collectBundle({ contentDir, deps: makeDeterministicDeps() });
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/diagnose/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import { arch as osArch, platform as osPlatform, tmpdir } from 'node:os';
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
import type { BundleRedaction as SecretScrubEntry } from '@inkeep/open-knowledge-core';
import { SERVER_EXIT_LOG } from '@inkeep/open-knowledge-core';
import {
DEFAULT_LOGS_MAX_BYTES,
DEFAULT_SPANS_MAX_BYTES,
Expand Down Expand Up @@ -634,6 +635,11 @@ export async function collectBundle(opts: CollectBundleOpts): Promise<CollectedB
join(stagingDir, 'state', 'last-spawn-error.log'),
);

// last-server-exit.json — why the server process last exited (code +
// Electron process-gone reason). Lets a bundle tell a crash / OS-kill from
// a managed shutdown, which `server-status.txt: not-running` alone cannot.
stageFileIfPresent(join(lockDir, SERVER_EXIT_LOG), join(stagingDir, 'state', SERVER_EXIT_LOG));

// Runtime + desktop block.
const runtime = readRuntime();
const desktop = readDesktopEnv();
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/constants/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,17 @@ export const DEFAULT_SIGTERM_POLL_MS = 200;
* to change if the convention ever moves.
*/
export const SPAWN_ERROR_LOG = 'last-spawn-error.log';

/**
* Filename under `<projectRoot>/.ok/local/` where the desktop host records why
* the server process last exited — the exit `code` plus Electron's
* process-gone `reason` (`clean-exit` / `abnormal-exit` / `killed` / `crashed`
* / `oom`). Written by the desktop main process (which observes the child's
* death even when the child could not report it) and collected into a
* bug-report bundle's `state/` dir beside `server.lock`.
*
* This closes a diagnostic gap: without it, a bundle can't tell a server that
* crashed or was OS-killed from one that shut down cleanly — the liveness
* probe only reports "unreachable" either way.
*/
export const SERVER_EXIT_LOG = 'last-server-exit.json';
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,7 @@ export {
export {
DEFAULT_SIGTERM_GRACE_MS,
DEFAULT_SIGTERM_POLL_MS,
SERVER_EXIT_LOG,
SPAWN_ERROR_LOG,
} from './constants/lifecycle.ts';
export {
Expand Down
25 changes: 25 additions & 0 deletions packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ import {
raiseMostRecentlyFocusedAfterRestore,
} from './restore-focus.ts';
import { handleRevealExternal } from './reveal-external.ts';
import { createServerExitRecorder, type ServerExitRecorder } from './server-exit-record.ts';
import { startFirstRunHandshake } from './share-handoff.ts';
import { handleShellOpenExternal } from './shell-allowlist.ts';
import { createShowGateRegistry, type ShowGateRegistry } from './show-gate.ts';
Expand Down Expand Up @@ -934,6 +935,23 @@ let mcpWiringHandle: RunMcpWiringHandle | null = null;
*/
let crashDetection: CrashDetection | null = null;

/**
* Records the server's last exit (code + Electron process-gone reason) to
* `<lockDir>/last-server-exit.json` for bug-report diagnosis. Lazy singleton so
* the window-manager fork path and the `child-process-gone` listener — which
* initialize on different boot paths — share one correlator.
*/
let serverExitRecorder: ServerExitRecorder | null = null;
function getServerExitRecorder(): ServerExitRecorder {
if (serverExitRecorder === null) {
serverExitRecorder = createServerExitRecorder({
now: () => new Date(),
logger: getLogger('server-exit'),
});
}
return serverExitRecorder;
}

/**
* Full-resolution PNG bytes of the app screenshot captured when a window's
* report-a-bug dialog opened, keyed by the sender `webContents.id`. Held in
Expand Down Expand Up @@ -1389,6 +1407,7 @@ function ensureWindowManager() {
onUtilityExit: (utility) => {
ensureDebugIpc().cancelPendingForUtility(utility);
},
recordServerExit: (info) => getServerExitRecorder().recordExit(info),
// Presence-invisible keepalive WS — registers the desktop as an active
// `/collab*` upgrade for as long as a project window is open, so a brief
// MCP disconnect does not trip the server's idle-shutdown timer. The
Expand Down Expand Up @@ -5078,6 +5097,12 @@ function bootPrimaryInstance(): void {
});
crashDetection.detectBootCrash();
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
// `killed` / `oom` / `crashed` exit from a `clean-exit`.
if (details.type === 'Utility') {
getServerExitRecorder().noteGoneReason(details.reason);
}
crashDetection?.handleChildProcessGone(details);
});

Expand Down
163 changes: 163 additions & 0 deletions packages/desktop/src/main/server-exit-record.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Server-exit recorder tests: tmpdir-backed lockDir, an injected advancing
* clock, and a capturing logger — the same injectable-deps posture as the
* sibling crash-detection tests. Covers the two arrival orders of the exit
* `code` and the process-gone `reason`, the correlation window that joins
* them, and the fail-soft write path.
*/

import { afterEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SERVER_EXIT_LOG } from '@inkeep/open-knowledge-core';
import { createServerExitRecorder, type ServerExitRecord } from './server-exit-record.ts';

const tmpDirs: string[] = [];

afterEach(() => {
for (const dir of tmpDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});

function makeLockDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'ok-exit-'));
tmpDirs.push(dir);
return dir;
}

/** Clock the recorder reads through `now()`; advance with `tick(ms)`. */
function makeClock(startMs = 1_000_000) {
let ms = startMs;
return {
now: () => new Date(ms),
tick: (delta: number) => {
ms += delta;
},
};
}

function makeLogger() {
const warnings: Array<{ payload: Record<string, unknown>; msg: string }> = [];
return {
warnings,
logger: {
warn: (payload: Record<string, unknown>, msg: string) => warnings.push({ payload, msg }),
},
};
}

function readRecord(lockDir: string): ServerExitRecord {
return JSON.parse(readFileSync(join(lockDir, SERVER_EXIT_LOG), 'utf8')) as ServerExitRecord;
}

describe('createServerExitRecorder', () => {
test('recordExit writes code, pid, and ISO timestamp with a null reason when none seen', () => {
const lockDir = makeLockDir();
const clock = makeClock();
const recorder = createServerExitRecorder({ now: clock.now, logger: makeLogger().logger });

recorder.recordExit({ lockDir, pid: 51502, code: 0 });

const record = readRecord(lockDir);
expect(record.pid).toBe(51502);
expect(record.code).toBe(0);
expect(record.reason).toBeNull();
expect(new Date(record.at).toISOString()).toBe(record.at);
});

test('a null code (signal kill) is preserved', () => {
const lockDir = makeLockDir();
const clock = makeClock();
const recorder = createServerExitRecorder({ now: clock.now, logger: makeLogger().logger });

recorder.recordExit({ lockDir, pid: 999, code: null });

expect(readRecord(lockDir).code).toBeNull();
});

test('noteGoneReason before recordExit attaches the reason', () => {
const lockDir = makeLockDir();
const clock = makeClock();
const recorder = createServerExitRecorder({ now: clock.now, logger: makeLogger().logger });

recorder.noteGoneReason('oom');
clock.tick(50);
recorder.recordExit({ lockDir, pid: 1, code: null });

expect(readRecord(lockDir).reason).toBe('oom');
});

test('recordExit before noteGoneReason patches the file within the window', () => {
const lockDir = makeLockDir();
const clock = makeClock();
const recorder = createServerExitRecorder({ now: clock.now, logger: makeLogger().logger });

recorder.recordExit({ lockDir, pid: 1, code: 1 });
expect(readRecord(lockDir).reason).toBeNull();

clock.tick(100);
recorder.noteGoneReason('killed');

const record = readRecord(lockDir);
expect(record.reason).toBe('killed');
// The patch preserves the originally-observed exit fields.
expect(record.code).toBe(1);
expect(record.pid).toBe(1);
});

test('a reason older than the correlation window is not attached to a later exit', () => {
const lockDir = makeLockDir();
const clock = makeClock();
const recorder = createServerExitRecorder({ now: clock.now, logger: makeLogger().logger });

recorder.noteGoneReason('killed');
clock.tick(3_001);
recorder.recordExit({ lockDir, pid: 1, code: null });

expect(readRecord(lockDir).reason).toBeNull();
});

test('a reason arriving after the window does not patch an earlier exit', () => {
const lockDir = makeLockDir();
const clock = makeClock();
const recorder = createServerExitRecorder({ now: clock.now, logger: makeLogger().logger });

recorder.recordExit({ lockDir, pid: 1, code: 0 });
clock.tick(3_001);
recorder.noteGoneReason('clean-exit');

expect(readRecord(lockDir).reason).toBeNull();
});

test('each exit overwrites the previous record (latest death wins)', () => {
const lockDir = makeLockDir();
const clock = makeClock();
const recorder = createServerExitRecorder({ now: clock.now, logger: makeLogger().logger });

recorder.recordExit({ lockDir, pid: 1, code: 0 });
clock.tick(60_000);
recorder.recordExit({ lockDir, pid: 2, code: 1 });

const record = readRecord(lockDir);
expect(record.pid).toBe(2);
expect(record.code).toBe(1);
});

test('an unwritable lockDir warns instead of throwing', () => {
const parent = makeLockDir();
// Put a regular file where the recorder expects a directory, so the
// recursive mkdir fails with ENOTDIR.
const filePath = join(parent, 'not-a-dir');
writeFileSync(filePath, 'x');
const badLockDir = join(filePath, 'nested');
const clock = makeClock();
const { warnings, logger } = makeLogger();
const recorder = createServerExitRecorder({ now: clock.now, logger });

expect(() => recorder.recordExit({ lockDir: badLockDir, pid: 1, code: 1 })).not.toThrow();
expect(warnings).toHaveLength(1);
expect(warnings[0]?.payload.event).toBe('server-exit-record.write-failed');
});
});
Loading