diff --git a/dsh-plugin-desktop/src/crash-evidence.ts b/dsh-plugin-desktop/src/crash-evidence.ts new file mode 100644 index 0000000000..7dff2dd023 --- /dev/null +++ b/dsh-plugin-desktop/src/crash-evidence.ts @@ -0,0 +1,52 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +/** Identity persisted while one desktop process is active. */ +export interface DesktopRunRecord { + readonly startedAt: string + readonly pid: number + readonly version: string +} + +export interface UnreadableDesktopRun { + readonly unreadable: true +} + +/** Active run marker and evidence recovered from the previous launch. */ +export interface DesktopRun { + readonly previousRun: DesktopRunRecord | UnreadableDesktopRun | undefined + /** Remove this process's active marker before a controlled exit. */ + markClean(): void +} + +function readPreviousRun(statePath: string): DesktopRunRecord | UnreadableDesktopRun | undefined { + if (!existsSync(statePath)) return undefined + try { + const value: unknown = JSON.parse(readFileSync(statePath, 'utf8')) + if (typeof value !== 'object' || value === null) return { unreadable: true } + const record = value as Partial + if (typeof record.startedAt !== 'string' + || typeof record.pid !== 'number' + || typeof record.version !== 'string') return { unreadable: true } + return { startedAt: record.startedAt, pid: record.pid, version: record.version } + } catch (cause) { + if (cause instanceof SyntaxError) return { unreadable: true } + throw cause + } +} + +/** Persist this launch and return a marker left behind by an unclean exit. */ +export function beginDesktopRun(statePath: string, currentRun: DesktopRunRecord): DesktopRun { + const previousRun = readPreviousRun(statePath) + mkdirSync(dirname(statePath), { recursive: true }) + writeFileSync(statePath, `${JSON.stringify(currentRun)}\n`, 'utf8') + let clean = false + return { + previousRun, + markClean() { + if (clean) return + clean = true + unlinkSync(statePath) + }, + } +} diff --git a/dsh-plugin-desktop/tests/crash-evidence.spec.ts b/dsh-plugin-desktop/tests/crash-evidence.spec.ts new file mode 100644 index 0000000000..f18c3a6eb0 --- /dev/null +++ b/dsh-plugin-desktop/tests/crash-evidence.spec.ts @@ -0,0 +1,63 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { beginDesktopRun } from '../src/crash-evidence.ts' + +describe('desktop crash evidence', () => { + it('reports the previous run when it did not shut down cleanly', () => { + const statePath = join(mkdtempSync(join(tmpdir(), 'dsh-run-')), 'active-run.json') + beginDesktopRun(statePath, { + startedAt: '2026-08-18T00:00:00.000Z', + pid: 41, + version: '2.0.1', + }) + + const next = beginDesktopRun(statePath, { + startedAt: '2026-08-18T00:01:00.000Z', + pid: 42, + version: '2.0.1', + }) + + expect(next.previousRun).toEqual({ + startedAt: '2026-08-18T00:00:00.000Z', + pid: 41, + version: '2.0.1', + }) + }) + + it('does not report a run that marked its shutdown clean', () => { + const statePath = join(mkdtempSync(join(tmpdir(), 'dsh-run-')), 'active-run.json') + const run = beginDesktopRun(statePath, { + startedAt: '2026-08-18T00:00:00.000Z', + pid: 41, + version: '2.0.1', + }) + + expect(() => { + run.markClean() + run.markClean() + }).not.toThrow() + + const next = beginDesktopRun(statePath, { + startedAt: '2026-08-18T00:01:00.000Z', + pid: 42, + version: '2.0.1', + }) + expect(next.previousRun).toBeUndefined() + }) + + it('reports an unreadable previous marker without blocking the next run', () => { + const statePath = join(mkdtempSync(join(tmpdir(), 'dsh-run-')), 'active-run.json') + writeFileSync(statePath, '{partial', 'utf8') + + const run = beginDesktopRun(statePath, { + startedAt: '2026-08-18T00:01:00.000Z', + pid: 42, + version: '2.0.1', + }) + + expect(run.previousRun).toEqual({ unreadable: true }) + expect(() => run.markClean()).not.toThrow() + }) +})