diff --git a/dsh-plugin-desktop/src/crash-evidence.ts b/dsh-plugin-desktop/src/crash-evidence.ts new file mode 100644 index 0000000000..62a85940a4 --- /dev/null +++ b/dsh-plugin-desktop/src/crash-evidence.ts @@ -0,0 +1,84 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +/** Minimal Electron crash reporter surface used before the app is ready. */ +export interface DesktopCrashReporter { + start(options: { + readonly productName: string + readonly uploadToServer: false + readonly globalExtra: Record + }): void +} + +export interface DesktopCrashMetadata { + readonly productName: string + readonly version: string + readonly platform: string + readonly arch: string +} + +/** Start local-only Crashpad collection for native main and child process failures. */ +export function startDesktopCrashReporting( + reporter: DesktopCrashReporter, + metadata: DesktopCrashMetadata, +): void { + reporter.start({ + productName: metadata.productName, + uploadToServer: false, + globalExtra: { + appVersion: metadata.version, + platform: metadata.platform, + arch: metadata.arch, + }, + }) +} + +/** 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/src/desktop-logger.ts b/dsh-plugin-desktop/src/desktop-logger.ts index 77e1459cfb..8bb8fd7e61 100644 --- a/dsh-plugin-desktop/src/desktop-logger.ts +++ b/dsh-plugin-desktop/src/desktop-logger.ts @@ -15,6 +15,44 @@ export interface DesktopUncaughtExceptionProcess { off(event: 'uncaughtException', listener: (error: Error) => void): unknown } +export interface DesktopChildProcessDetails { + readonly type: string + readonly reason: string + readonly exitCode: number + readonly serviceName?: string + readonly name?: string +} + +/** Electron app events that expose native child process failures. */ +export interface DesktopChildProcessSource { + on(event: 'child-process-gone', listener: (event: unknown, details: DesktopChildProcessDetails) => void): unknown + off(event: 'child-process-gone', listener: (event: unknown, details: DesktopChildProcessDetails) => void): unknown +} + +/** Render signed Electron exit codes with their Windows NTSTATUS bit pattern. */ +export function formatDesktopExitCode(exitCode: number): string { + return `${String(exitCode)} / 0x${(exitCode >>> 0).toString(16).padStart(8, '0')}` +} + +/** Persist unexpected utility, GPU, and other Electron child process exits. */ +export function installDesktopChildProcessLogging( + app: DesktopChildProcessSource, + logger: DesktopLogger, +): () => void { + const handler = (_event: unknown, details: DesktopChildProcessDetails): void => { + const identity = [ + `type: ${details.type}`, + ...(details.name === undefined ? [] : [`name: ${details.name}`]), + ...(details.serviceName === undefined ? [] : [`service: ${details.serviceName}`]), + ] + logger.error( + `dsh-plugin-desktop: child process gone (${identity.join(', ')}, reason: ${details.reason}, exitCode: ${formatDesktopExitCode(details.exitCode)})`, + ) + } + app.on('child-process-gone', handler) + return () => { app.off('child-process-gone', handler) } +} + /** Persist the first uncaught exception before requesting a fatal exit. */ export function installDesktopUncaughtExceptionLogging( proc: DesktopUncaughtExceptionProcess, diff --git a/dsh-plugin-desktop/src/electron-runtime.ts b/dsh-plugin-desktop/src/electron-runtime.ts index 4b90cbdbf9..f681580f32 100644 --- a/dsh-plugin-desktop/src/electron-runtime.ts +++ b/dsh-plugin-desktop/src/electron-runtime.ts @@ -32,7 +32,7 @@ import type { DesktopUpdateAdapter, } from './runtime.ts' import type { RendererBootReport } from './renderer-boot-contract.ts' -import type { DesktopLogger } from './desktop-logger.ts' +import { formatDesktopExitCode, type DesktopLogger } from './desktop-logger.ts' import { exportDiagnosticsZip } from './diagnostic-export.ts' import { prepareTrayIcon } from './tray-icons.ts' import { @@ -619,7 +619,7 @@ export class ElectronDesktopRuntime implements DesktopRuntime { window.webContents.on('will-frame-navigate', navigate) window.webContents.on('will-redirect', navigate) window.webContents.on('render-process-gone', (_event, details) => { - this.logError(`dsh-plugin-desktop: renderer process gone (reason: ${details.reason}, exitCode: ${details.exitCode})`) + this.logError(`dsh-plugin-desktop: renderer process gone (reason: ${details.reason}, exitCode: ${formatDesktopExitCode(details.exitCode)})`) }) window.webContents.on('did-fail-load', (_event, errorCode, errorDescription) => { this.logError(`dsh-plugin-desktop: renderer failed to load (${errorCode}: ${errorDescription})`) diff --git a/dsh-plugin-desktop/src/main.ts b/dsh-plugin-desktop/src/main.ts index 53bd89c21c..feed8cba4a 100644 --- a/dsh-plugin-desktop/src/main.ts +++ b/dsh-plugin-desktop/src/main.ts @@ -1,6 +1,6 @@ /** DSH Desktop executable: minimal Electron bootstrap around the Host Cordis root. */ -import { app } from 'electron' +import { app, crashReporter } from 'electron' import type { Context } from '@deepseek-ai/cordis' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -20,9 +20,15 @@ import { import { desktopProductVersion, ElectronDesktopRuntime } from './electron-runtime.ts' import { ElectronStderrLogger, + installDesktopChildProcessLogging, installDesktopUncaughtExceptionLogging, type DesktopLogger, } from './desktop-logger.ts' +import { + beginDesktopRun, + startDesktopCrashReporting, + type DesktopRun, +} from './crash-evidence.ts' import { FileExporter } from './file-exporter.ts' import { DESKTOP_SETTINGS_NAMESPACE, type DesktopSettings } from './index.ts' import { LogFileSink } from './log-files.ts' @@ -122,6 +128,7 @@ async function start(): Promise { let shutdown: DesktopShutdown | undefined let removeShutdownRequests: (() => void) | undefined let removeUncaughtExceptionLogging: (() => void) | undefined + let removeChildProcessLogging: (() => void) | undefined let disposeDshRuntime: (() => void) | undefined let disposePnpmRuntime: (() => void) | undefined let fileExporter: FileExporter | undefined @@ -141,6 +148,36 @@ async function start(): Promise { logSink = undefined } const electronLogger = new ElectronStderrLogger(logSink) + try { + startDesktopCrashReporting(crashReporter, { + productName: PRODUCT_NAME, + version: desktopProductVersion(), + platform: process.platform, + arch: process.arch, + }) + } catch (cause) { + electronLogger.error(`${BIN_NAME}: local crash reporting unavailable: ${cause instanceof Error ? cause.message : String(cause)}`) + } + let desktopRun: DesktopRun | undefined + try { + desktopRun = beginDesktopRun( + join(app.getPath('userData'), 'crash-evidence', 'active-run.json'), + { + startedAt: new Date().toISOString(), + pid: process.pid, + version: desktopProductVersion(), + }, + ) + const previousRun = desktopRun.previousRun + if (previousRun !== undefined) { + electronLogger.error('unreadable' in previousRun + ? `${BIN_NAME}: previous desktop run did not shut down cleanly (active run marker unreadable)` + : `${BIN_NAME}: previous desktop run did not shut down cleanly (startedAt: ${previousRun.startedAt}, pid: ${String(previousRun.pid)}, version: ${previousRun.version})`) + } + } catch (cause) { + electronLogger.error(`${BIN_NAME}: active run tracking unavailable: ${cause instanceof Error ? cause.message : String(cause)}`) + } + removeChildProcessLogging = installDesktopChildProcessLogging(app, electronLogger) const nativeExit = createDesktopExitCoordinator( { prepareToQuit: () => { runtime.prepareToQuit() }, @@ -150,6 +187,12 @@ async function start(): Promise { () => { removeShutdownRequests?.() removeUncaughtExceptionLogging?.() + removeChildProcessLogging?.() + try { + desktopRun?.markClean() + } catch (cause) { + electronLogger.error(`${BIN_NAME}: failed to clear active run marker: ${cause instanceof Error ? cause.message : String(cause)}`) + } }, ) let restartRequested = false 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..9996ef0c3c --- /dev/null +++ b/dsh-plugin-desktop/tests/crash-evidence.spec.ts @@ -0,0 +1,84 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { beginDesktopRun, startDesktopCrashReporting } from '../src/crash-evidence.ts' + +describe('desktop crash evidence', () => { + it('starts Electron crash reporting without uploading dumps', () => { + const reporter = { start: vi.fn() } + + startDesktopCrashReporting(reporter, { + productName: 'DSH Desktop', + version: '2.0.1', + platform: 'win32', + arch: 'x64', + }) + + expect(reporter.start).toHaveBeenCalledWith({ + productName: 'DSH Desktop', + uploadToServer: false, + globalExtra: { + appVersion: '2.0.1', + platform: 'win32', + arch: 'x64', + }, + }) + }) + + 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() + }) +}) diff --git a/dsh-plugin-desktop/tests/desktop-logger.spec.ts b/dsh-plugin-desktop/tests/desktop-logger.spec.ts index 5d65222e18..0f8a34738e 100644 --- a/dsh-plugin-desktop/tests/desktop-logger.spec.ts +++ b/dsh-plugin-desktop/tests/desktop-logger.spec.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { ElectronStderrLogger, + installDesktopChildProcessLogging, installDesktopUncaughtExceptionLogging, } from '../src/desktop-logger.ts' import { LogFileSink } from '../src/log-files.ts' @@ -23,6 +24,26 @@ function sink(): { s: LogFileSink; dir: string } { } describe('ElectronStderrLogger', () => { + it('logs Electron child process crashes with the Windows exception code', () => { + const app = new EventEmitter() + const logger = { error: vi.fn(), errorCause: vi.fn() } + const remove = installDesktopChildProcessLogging(app, logger) + + app.emit('child-process-gone', {}, { + type: 'Utility', + reason: 'crashed', + exitCode: -1073741819, + serviceName: 'network.mojom.NetworkService', + name: 'Network Service', + }) + + expect(logger.error).toHaveBeenCalledWith( + 'dsh-plugin-desktop: child process gone (type: Utility, name: Network Service, service: network.mojom.NetworkService, reason: crashed, exitCode: -1073741819 / 0xc0000005)', + ) + remove() + expect(app.listenerCount('child-process-gone')).toBe(0) + }) + it('writes to the sink and to stderr', () => { const { s, dir } = sink() const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) diff --git a/dsh-plugin-desktop/tests/electron-runtime.spec.ts b/dsh-plugin-desktop/tests/electron-runtime.spec.ts index 2a5d312970..2f9ecbb289 100644 --- a/dsh-plugin-desktop/tests/electron-runtime.spec.ts +++ b/dsh-plugin-desktop/tests/electron-runtime.spec.ts @@ -321,6 +321,25 @@ describe('Electron compatibility runtime', () => { expect(electron.trays[0]?.off).toHaveBeenCalledWith('click', expect.any(Function)) }) + it('logs renderer crashes with the Windows exception code', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts') + const logger = { error: vi.fn(), errorCause: vi.fn() } + const runtime = new ElectronDesktopRuntime(async () => {}, undefined, logger) + const release = runtime.schedule(spec) + await runtime.mountScheduled() + + const gone = electron.browserWindows[0]?.webContents.on.mock.calls + .find(([event]) => event === 'render-process-gone')?.[1] + expect(gone).toEqual(expect.any(Function)) + gone({}, { reason: 'crashed', exitCode: -1073741819 }) + + expect(logger.error).toHaveBeenCalledWith( + 'dsh-plugin-desktop: renderer process gone (reason: crashed, exitCode: -1073741819 / 0xc0000005)', + ) + await release() + }) + it('starts from the saved locale and rebuilds native tray commands when it changes', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts') diff --git a/dsh-plugin-desktop/tests/package.spec.ts b/dsh-plugin-desktop/tests/package.spec.ts index 0098a8729f..4d84b19521 100644 --- a/dsh-plugin-desktop/tests/package.spec.ts +++ b/dsh-plugin-desktop/tests/package.spec.ts @@ -162,6 +162,25 @@ describe('published package surface', () => { expect(main).toContain('disposeDshRuntime?.()') }) + it('wires local crash evidence before Electron becomes ready', () => { + const main = readFileSync(new URL('src/main.ts', packageRoot), 'utf8') + const startCrashReporter = main.indexOf('startDesktopCrashReporting(crashReporter') + const beginRun = main.indexOf('beginDesktopRun(') + const childLogging = main.indexOf('installDesktopChildProcessLogging(app') + const exitCoordinator = main.indexOf('createDesktopExitCoordinator(') + const ready = main.indexOf('await app.whenReady()') + const markClean = main.indexOf('desktopRun?.markClean()') + const nativeExit = main.indexOf('app.exit(code)') + + expect(startCrashReporter).toBeGreaterThanOrEqual(0) + expect(beginRun).toBeGreaterThan(startCrashReporter) + expect(childLogging).toBeGreaterThan(beginRun) + expect(exitCoordinator).toBeGreaterThan(childLogging) + expect(nativeExit).toBeGreaterThan(exitCoordinator) + expect(markClean).toBeGreaterThan(nativeExit) + expect(ready).toBeGreaterThan(markClean) + }) + it('uses the upstream child-environment scrub around login-shell recovery', () => { const shellEnvironment = readFileSync(new URL('src/shell-environment.ts', packageRoot), 'utf8')