diff --git a/apps/desktop/src/main/__tests__/main-process-recovery-journal.test.ts b/apps/desktop/src/main/__tests__/main-process-recovery-journal.test.ts new file mode 100644 index 0000000000..4c8fd68532 --- /dev/null +++ b/apps/desktop/src/main/__tests__/main-process-recovery-journal.test.ts @@ -0,0 +1,317 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DiagnosticLogBuffer } from '@maka/core/diagnostic-log'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + appendUncaughtMainProcessError, + createMainProcessRecoveryJournal, + MAIN_PROCESS_RECOVERY_FLUSH_DEBOUNCE_MS, + MAIN_PROCESS_RECOVERY_FLUSH_INTERVAL_MS, + MAIN_PROCESS_RECOVERY_LOG_MAX_BYTES, + MAIN_PROCESS_RECOVERY_MAX_AGE_MS, + presentPendingMainProcessRecovery, +} from '../main-process-recovery-journal.js'; + +test('recovers one bounded redacted snapshot after an unclean exit', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-main-recovery-')); + try { + const buffer = new DiagnosticLogBuffer({ maxBytes: 256 * 1024 }); + buffer.append( + 'error', + `failed under ${homedir()} with api_key=sk-secretvalue123`, + new Date('2026-08-20T00:00:01Z'), + ); + const first = createJournal(directory, buffer, new Date('2026-08-20T00:00:00Z')); + first.markDirty(); + first.flushNow(); + + const second = createJournal( + directory, + new DiagnosticLogBuffer(), + new Date('2026-08-20T01:00:00Z'), + ); + + assert.equal(second.pending?.run.startedAt, '2026-08-20T00:00:00.000Z'); + assert.equal(second.pending?.snapshotAt, '2026-08-20T00:00:00.000Z'); + assert.match(second.pending?.logs.join('\n') ?? '', /failed under ~/); + assert.doesNotMatch(second.pending?.logs.join('\n') ?? '', /sk-secretvalue123/); + if (process.platform !== 'win32') { + assert.equal(statSync(directory).mode & 0o777, 0o700); + assert.equal(statSync(join(directory, 'active.json')).mode & 0o777, 0o600); + assert.equal(statSync(join(directory, 'pending.json')).mode & 0o777, 0o600); + } + + second.discardPending(); + second.markClean(); + second.markClean(); + assert.equal(existsSync(join(directory, 'active.json')), false); + const third = createJournal( + directory, + new DiagnosticLogBuffer(), + new Date('2026-08-20T02:00:00Z'), + ); + assert.equal(third.pending, undefined); + third.markClean(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('debounces the first snapshot and rate-limits a continuing log stream', async (context) => { + context.mock.timers.enable({ + apis: ['Date', 'setTimeout'], + now: new Date('2026-08-20T00:00:00Z').getTime(), + }); + const directory = await mkdtemp(join(tmpdir(), 'maka-main-recovery-rate-')); + try { + const buffer = new DiagnosticLogBuffer(); + const journal = createJournal(directory, buffer); + const snapshotPath = join(directory, 'active.json'); + + buffer.append('info', 'first'); + journal.markDirty(); + context.mock.timers.tick(MAIN_PROCESS_RECOVERY_FLUSH_DEBOUNCE_MS - 1); + assert.doesNotMatch(readFileSync(snapshotPath, 'utf8'), /first/); + context.mock.timers.tick(1); + assert.match(readFileSync(snapshotPath, 'utf8'), /first/); + + buffer.append('info', 'second'); + journal.markDirty(); + context.mock.timers.tick(MAIN_PROCESS_RECOVERY_FLUSH_INTERVAL_MS - 1); + assert.doesNotMatch(readFileSync(snapshotPath, 'utf8'), /second/); + context.mock.timers.tick(1); + assert.match(readFileSync(snapshotPath, 'utf8'), /second/); + journal.markClean(); + } finally { + context.mock.timers.reset(); + await rm(directory, { recursive: true, force: true }); + } +}); + +test('flushes an uncaught JavaScript failure without intercepting process exit', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-main-recovery-fatal-')); + try { + const buffer = new DiagnosticLogBuffer(); + const first = createJournal(directory, buffer, new Date('2026-08-20T00:00:00Z')); + appendUncaughtMainProcessError( + buffer, + first, + new Error('Authorization: Bearer very-secret-token'), + 'uncaughtException', + ); + + const second = createJournal( + directory, + new DiagnosticLogBuffer(), + new Date('2026-08-20T00:01:00Z'), + ); + const logs = second.pending?.logs.join('\n') ?? ''; + assert.match(logs, /uncaughtException/); + assert.doesNotMatch(logs, /very-secret-token/); + second.markClean(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('persists only the bounded newest log tail', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-main-recovery-bound-')); + try { + const buffer = new DiagnosticLogBuffer({ maxBytes: 512 * 1024 }); + for (let index = 0; index < 400; index += 1) { + buffer.append('info', `entry ${index} ${'x'.repeat(1_024)}`); + } + const journal = createJournal(directory, buffer, new Date('2026-08-20T00:00:00Z')); + journal.markDirty(); + journal.flushNow(); + const snapshot = JSON.parse(readFileSync(join(directory, 'active.json'), 'utf8')) as { + logs: string[]; + }; + + assert.ok(Buffer.byteLength(JSON.stringify(snapshot.logs)) <= MAIN_PROCESS_RECOVERY_LOG_MAX_BYTES); + assert.match(snapshot.logs.at(-1) ?? '', /entry 399/); + assert.doesNotMatch(snapshot.logs[0] ?? '', /entry 0 /); + journal.markClean(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('retains a newly discovered interruption and expires pending evidence after seven days', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-main-recovery-expiry-')); + try { + createJournal(directory, new DiagnosticLogBuffer(), new Date('2026-08-01T00:00:00Z')); + const discovered = createJournal( + directory, + new DiagnosticLogBuffer(), + new Date('2026-08-20T00:00:00Z'), + ); + assert.equal(discovered.pending?.run.startedAt, '2026-08-01T00:00:00.000Z'); + discovered.markClean(); + + const afterExpiry = createJournal( + directory, + new DiagnosticLogBuffer(), + new Date( + new Date('2026-08-20T00:00:00Z').getTime() + + MAIN_PROCESS_RECOVERY_MAX_AGE_MS + + 2_000, + ), + ); + assert.equal(afterExpiry.pending, undefined); + afterExpiry.markClean(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('ignores corrupt pending records without amplifying invalid log arrays', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-main-recovery-corrupt-')); + const errors: unknown[] = []; + try { + writeFileSync(join(directory, 'pending.json'), '{not json', { mode: 0o600 }); + const afterCorruption = createJournal( + directory, + new DiagnosticLogBuffer(), + new Date('2026-08-20T00:00:00Z'), + errors, + ); + assert.equal(afterCorruption.pending, undefined); + assert.equal(errors.length, 1); + afterCorruption.markClean(); + + writeFileSync( + join(directory, 'pending.json'), + JSON.stringify({ logs: Array.from({ length: 50_000 }, () => 0) }), + { mode: 0o600 }, + ); + const afterInvalidLogs = createJournal( + directory, + new DiagnosticLogBuffer(), + new Date('2026-08-20T00:00:00Z'), + errors, + ); + assert.equal(afterInvalidLogs.pending, undefined); + assert.equal(errors.length, 2); + assert.equal((errors[1] as Error).message, 'Main-process recovery logs are invalid'); + afterInvalidLogs.markClean(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('discards pending evidence only after its recovery presentation succeeds', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-main-recovery-presentation-')); + const errors: unknown[] = []; + try { + const interrupted = createJournal( + directory, + new DiagnosticLogBuffer(), + new Date('2026-08-20T00:00:00Z'), + ); + interrupted.markDirty(); + interrupted.flushNow(); + + const failedPresentation = createJournal( + directory, + new DiagnosticLogBuffer(), + new Date('2026-08-20T01:00:00Z'), + ); + await presentPendingMainProcessRecovery( + failedPresentation, + async () => { + throw new Error('dialog unavailable'); + }, + (error) => errors.push(error), + ); + assert.equal(errors.length, 1); + failedPresentation.markClean(); + + const retriedPresentation = createJournal( + directory, + new DiagnosticLogBuffer(), + new Date('2026-08-20T02:00:00Z'), + ); + await presentPendingMainProcessRecovery( + retriedPresentation, + async (evidence) => { + assert.equal(evidence.run.startedAt, '2026-08-20T00:00:00.000Z'); + }, + (error) => errors.push(error), + ); + retriedPresentation.markClean(); + + const afterSuccess = createJournal( + directory, + new DiagnosticLogBuffer(), + new Date('2026-08-20T03:00:00Z'), + ); + assert.equal(afterSuccess.pending, undefined); + assert.equal(errors.length, 1); + afterSuccess.markClean(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('refuses to read a symlinked recovery record', async (context) => { + if (process.platform === 'win32') context.skip('Creating symlinks requires optional privileges on Windows'); + const directory = await mkdtemp(join(tmpdir(), 'maka-main-recovery-symlink-')); + const errors: unknown[] = []; + try { + const target = join(directory, 'outside.json'); + writeFileSync(target, '{}', { mode: 0o600 }); + symlinkSync(target, join(directory, 'pending.json')); + + const journal = createJournal( + directory, + new DiagnosticLogBuffer(), + new Date('2026-08-20T00:00:00Z'), + errors, + ); + assert.equal(journal.pending, undefined); + assert.equal(errors.length, 1); + journal.markClean(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +function createJournal( + root: string, + buffer: DiagnosticLogBuffer, + currentTime?: Date, + errors: unknown[] = [], +) { + return createMainProcessRecoveryJournal({ + root, + appVersion: '0.1.11', + buildMode: 'packaged', + buildCommit: 'a'.repeat(40), + logs: () => buffer.snapshot(), + onError: (error) => errors.push(error), + ...(currentTime ? { now: () => currentTime } : {}), + }); +} diff --git a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts index f2db20c362..9793eb0c45 100644 --- a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts @@ -23,11 +23,13 @@ import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'; import { copyDesktopDiagnosticReport, createDesktopMainRendererDiagnosticInput, + createDesktopPreviousMainProcessDiagnosticInput, } from '../main-process-diagnostics.js'; import { showFatalStartupError, showMainRendererProcessGoneDialog, showMessageBoxWithDiagnostics, + showPreviousMainProcessInterruptionDialog, } from '../native-diagnostic-dialog.js'; const diagnosticEnvironment = () => ({ @@ -149,3 +151,58 @@ test('main Renderer loss keeps Copy Diagnostics auxiliary to recovery', async () assert.match(clipboard, /Recent main-process logs \(1\)/); assert.doesNotMatch(clipboard, /very-secret-token/); }); + +test('previous main-process evidence remains copyable before a Renderer exists', async () => { + const shown: MessageBoxOptions[] = []; + const responses = [1, 0]; + let clipboard = ''; + const input = createDesktopPreviousMainProcessDiagnosticInput({ + run: { + startedAt: '2026-08-20T00:00:00.000Z', + appVersion: '0.1.10', + buildMode: 'packaged', + buildCommit: 'b'.repeat(40), + electronVersion: '43.2.0', + nodeVersion: '24.0.0', + chromeVersion: '144.0.0', + platform: 'win32', + arch: 'x64', + osRelease: '10.0.26100', + }, + snapshotAt: '2026-08-20T00:10:00.000Z', + logs: ['previous main log with api_key=very-secret-token'], + }); + + await showPreviousMainProcessInterruptionDialog({ + locale: 'en', + copyDiagnostics: () => + copyDesktopDiagnosticReport( + { + environment: diagnosticEnvironment, + mainLogs: () => ['current main log'], + resolveActiveRuntimeHost: () => { + throw new Error('Previous-run diagnostics must remain Desktop-only'); + }, + resolveRuntimeHost: () => { + throw new Error('Previous-run diagnostics must not resolve a task Host'); + }, + writeClipboard: (value) => { + clipboard = value; + }, + }, + input, + ), + showMessageBox: async (options): Promise => { + shown.push(options); + return { response: responses.shift() ?? 0, checkboxChecked: false }; + }, + }); + + assert.deepEqual(shown[0]?.buttons, ['Continue', 'Copy Diagnostics']); + assert.deepEqual(shown[1]?.buttons, ['Continue', 'Copy Again']); + assert.match(clipboard, /Surface: previous_main_process_interruption/); + assert.match(clipboard, /clean shutdown was not observed/); + assert.match(clipboard, /Maka: 0\.1\.10/); + assert.match(clipboard, /Recent previous main-process logs \(1\)/); + assert.doesNotMatch(clipboard, /current main log|very-secret-token/); +}); diff --git a/apps/desktop/src/main/main-process-diagnostics.ts b/apps/desktop/src/main/main-process-diagnostics.ts index 618da6e027..9cc6c35ec4 100644 --- a/apps/desktop/src/main/main-process-diagnostics.ts +++ b/apps/desktop/src/main/main-process-diagnostics.ts @@ -31,6 +31,7 @@ import { requireDesktopTargetScope, type DesktopTargetScope, } from '../shared/runtime-host-identity.js'; +import type { MainProcessRecoveryEvidence } from './main-process-recovery-journal.js'; const INPUT_LIMITS = { title: 512, @@ -83,10 +84,20 @@ export interface DesktopMainRendererDiagnosticInput { readonly hostTarget: 'none'; } +export interface DesktopPreviousMainProcessDiagnosticInput { + readonly surface: 'previous_main_process_interruption'; + readonly title: string; + readonly description?: string; + readonly details?: string; + readonly hostTarget: 'none'; + readonly evidence: MainProcessRecoveryEvidence; +} + export type DesktopDiagnosticReportInput = | DesktopDiagnosticWireInput | DesktopStartupDiagnosticInput - | DesktopMainRendererDiagnosticInput; + | DesktopMainRendererDiagnosticInput + | DesktopPreviousMainProcessDiagnosticInput; export type RuntimeHostDiagnosticRead = | { readonly ok: true; readonly value: HostDiagnosticsResult } @@ -119,10 +130,13 @@ export const mainProcessLogBuffer = new DiagnosticLogBuffer({ let logCaptureInstalled = false; -export function installMainProcessLogCapture(buffer: DiagnosticLogBuffer = mainProcessLogBuffer): void { +export function installMainProcessLogCapture( + buffer: DiagnosticLogBuffer = mainProcessLogBuffer, + onAppend?: () => void, +): void { if (logCaptureInstalled) return; logCaptureInstalled = true; - installConsoleDiagnosticLogCapture(buffer); + installConsoleDiagnosticLogCapture(buffer, onAppend); } export function captureDesktopDiagnosticEnvironment( @@ -163,6 +177,17 @@ export function createDesktopMainRendererDiagnosticInput(input: { }; } +export function createDesktopPreviousMainProcessDiagnosticInput( + evidence: MainProcessRecoveryEvidence, +): DesktopPreviousMainProcessDiagnosticInput { + return { + surface: 'previous_main_process_interruption', + hostTarget: 'none', + title: 'Previous Maka session ended before shutdown completed', + evidence, + }; +} + function createDesktopNativeDiagnosticFields(input: { readonly title: string; readonly description?: string; @@ -295,9 +320,12 @@ export async function copyDesktopDiagnosticReport( if (!runtime) { let error: string; if (input.hostTarget === 'none') { - error = input.surface === 'startup' - ? 'Runtime Host diagnostics were unavailable before the app opened' - : 'No Runtime Host authority was associated with this error'; + error = + input.surface === 'startup' + ? 'Runtime Host diagnostics were unavailable before the app opened' + : input.surface === 'previous_main_process_interruption' + ? 'Runtime Host diagnostics were not persisted for the previous Desktop session' + : 'No Runtime Host authority was associated with this error'; } else if (input.hostTarget === 'default') { error = input.surface === 'manual' ? 'Runtime Host is unavailable' @@ -371,24 +399,44 @@ export function formatDesktopDiagnosticReport( if (input.details) lines.push('', 'Details:', input.details); } - lines.push( - '', - 'Environment', - `Maka: ${environment.appVersion}`, - `Build: ${environment.buildMode}${environment.buildCommit ? ` @ ${environment.buildCommit.slice(0, 12)}` : ''}`, - `Electron: ${environment.electronVersion}`, - `Chrome: ${environment.chromeVersion}`, - `Node: ${environment.nodeVersion}`, - `OS: ${environment.platform} ${environment.osRelease} (${environment.arch})`, - `Locale: ${environment.locale}`, - `Renderer locale: ${rendererContext?.rendererLocale ?? ''}`, - `Renderer user agent: ${rendererContext?.rendererUserAgent ?? ''}`, - `Workspace: ${environment.workspacePath}`, - `Main process uptime: ${Math.max(0, Math.floor(environment.processUptimeSeconds))}s`, - '', - `Recent main-process logs (${mainLogs.length})`, - ...(mainLogs.length > 0 ? mainLogs : ['']), - ); + if (input.surface === 'previous_main_process_interruption') { + const { run, snapshotAt, logs } = input.evidence; + lines.push( + 'Classification: clean shutdown was not observed; the termination cause is unknown', + '', + 'Previous run', + `Started at: ${run.startedAt}`, + `Last snapshot: ${snapshotAt ?? ''}`, + `Maka: ${run.appVersion}`, + `Build: ${run.buildMode}${run.buildCommit ? ` @ ${run.buildCommit.slice(0, 12)}` : ''}`, + `Electron: ${run.electronVersion}`, + `Chrome: ${run.chromeVersion}`, + `Node: ${run.nodeVersion}`, + `OS: ${run.platform} ${run.osRelease} (${run.arch})`, + '', + `Recent previous main-process logs (${logs.length})`, + ...(logs.length > 0 ? logs : ['']), + ); + } else { + lines.push( + '', + 'Environment', + `Maka: ${environment.appVersion}`, + `Build: ${environment.buildMode}${environment.buildCommit ? ` @ ${environment.buildCommit.slice(0, 12)}` : ''}`, + `Electron: ${environment.electronVersion}`, + `Chrome: ${environment.chromeVersion}`, + `Node: ${environment.nodeVersion}`, + `OS: ${environment.platform} ${environment.osRelease} (${environment.arch})`, + `Locale: ${environment.locale}`, + `Renderer locale: ${rendererContext?.rendererLocale ?? ''}`, + `Renderer user agent: ${rendererContext?.rendererUserAgent ?? ''}`, + `Workspace: ${environment.workspacePath}`, + `Main process uptime: ${Math.max(0, Math.floor(environment.processUptimeSeconds))}s`, + '', + `Recent main-process logs (${mainLogs.length})`, + ...(mainLogs.length > 0 ? mainLogs : ['']), + ); + } lines.push('', 'Runtime Host'); if (runtimeHost.ok) { diff --git a/apps/desktop/src/main/main-process-recovery-journal.ts b/apps/desktop/src/main/main-process-recovery-journal.ts new file mode 100644 index 0000000000..9c27ab4cf0 --- /dev/null +++ b/apps/desktop/src/main/main-process-recovery-journal.ts @@ -0,0 +1,427 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { collapseHomePath, truncateUtf8 } from '@maka/core/diagnostic-log'; +import { + chmodSync, + closeSync, + constants, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { arch as osArch, homedir, release as osRelease } from 'node:os'; +import { basename, join } from 'node:path'; +import { z } from 'zod'; + +const SCHEMA_VERSION = 1 as const; +const RUN_MAX_BYTES = 16 * 1024; +export const MAIN_PROCESS_RECOVERY_LOG_MAX_BYTES = 256 * 1024; +const EVIDENCE_MAX_BYTES = MAIN_PROCESS_RECOVERY_LOG_MAX_BYTES + RUN_MAX_BYTES; +const ENTRY_TRUNCATION_MARKER = '\n'; +export const MAIN_PROCESS_RECOVERY_FLUSH_DEBOUNCE_MS = 2_000; +export const MAIN_PROCESS_RECOVERY_FLUSH_INTERVAL_MS = 5 * 60_000; +export const MAIN_PROCESS_RECOVERY_MAX_AGE_MS = 7 * 24 * 60 * 60_000; + +export interface MainProcessRecoveryRun { + readonly startedAt: string; + readonly appVersion: string; + readonly buildMode: 'dev' | 'packaged'; + readonly buildCommit: string | null; + readonly electronVersion: string; + readonly nodeVersion: string; + readonly chromeVersion: string; + readonly platform: NodeJS.Platform; + readonly arch: string; + readonly osRelease: string; +} + +export interface MainProcessRecoveryEvidence { + readonly run: MainProcessRecoveryRun; + readonly snapshotAt: string | null; + readonly logs: readonly string[]; +} + +export interface MainProcessRecoveryJournal { + readonly pending: MainProcessRecoveryEvidence | undefined; + markDirty(): void; + flushNow(): void; + markClean(): void; + discardPending(): void; +} + +interface MainProcessRecoveryJournalInput { + readonly root: string; + readonly appVersion: string; + readonly buildMode: 'dev' | 'packaged'; + readonly buildCommit: string | null; + readonly logs: () => readonly string[]; + readonly onError: (error: unknown) => void; + readonly now?: () => Date; +} + +interface StoredEvidence { + readonly schemaVersion: 1; + readonly run: MainProcessRecoveryRun; + readonly capturedAt: string | null; + readonly logs: readonly string[]; +} + +const boundedStringSchema = z + .string() + .refine((value) => Buffer.byteLength(value, 'utf8') <= 1_024); +const isoDateSchema = boundedStringSchema.refine((value) => { + const parsed = new Date(value); + return Number.isFinite(parsed.getTime()) && parsed.toISOString() === value; +}); +const recoveryRunSchema = z + .object({ + startedAt: isoDateSchema, + appVersion: boundedStringSchema, + buildMode: z.enum(['dev', 'packaged']), + buildCommit: boundedStringSchema.nullable(), + electronVersion: boundedStringSchema, + nodeVersion: boundedStringSchema, + chromeVersion: boundedStringSchema, + platform: z.enum([ + 'aix', + 'android', + 'darwin', + 'freebsd', + 'haiku', + 'linux', + 'openbsd', + 'sunos', + 'win32', + 'cygwin', + 'netbsd', + ]), + arch: boundedStringSchema, + osRelease: boundedStringSchema, + }) + .strict(); +const storedEvidenceSchema = z + .object({ + schemaVersion: z.literal(SCHEMA_VERSION), + run: recoveryRunSchema, + capturedAt: isoDateSchema.nullable(), + logs: z.array(z.string()), + }) + .strict(); + +export function createMainProcessRecoveryJournal( + input: MainProcessRecoveryJournalInput, +): MainProcessRecoveryJournal { + const now = input.now ?? (() => new Date()); + ensurePrivateDirectory(input.root); + const activePath = join(input.root, 'active.json'); + const pendingPath = join(input.root, 'pending.json'); + const temporaryPath = join(input.root, '.active.json.tmp'); + const startupAt = now(); + rotatePriorRun({ activePath, pendingPath, temporaryPath }, startupAt); + const pending = readPendingEvidence(pendingPath, startupAt, input.onError); + + const run: MainProcessRecoveryRun = { + startedAt: startupAt.toISOString(), + appVersion: input.appVersion, + buildMode: input.buildMode, + buildCommit: input.buildCommit, + electronVersion: process.versions.electron ?? '', + nodeVersion: process.versions.node, + chromeVersion: process.versions.chrome ?? '', + platform: process.platform, + arch: osArch(), + osRelease: osRelease(), + }; + writeEvidenceAtomically(activePath, temporaryPath, { + schemaVersion: SCHEMA_VERSION, + run, + capturedAt: null, + logs: [], + }); + + let dirty = false; + let disabled = false; + let timer: NodeJS.Timeout | undefined; + let lastFlushAt = Number.NEGATIVE_INFINITY; + + const cancelTimer = (): void => { + if (!timer) return; + clearTimeout(timer); + timer = undefined; + }; + + const fail = (error: unknown): void => { + disabled = true; + cancelTimer(); + input.onError(error); + }; + + const flush = (): void => { + if (disabled || !dirty) return; + cancelTimer(); + const capturedAt = now(); + try { + const logs = boundedLogTail( + input.logs().map((entry) => collapseHomePath(entry, homedir(), process.platform)), + ); + writeEvidenceAtomically(activePath, temporaryPath, { + schemaVersion: SCHEMA_VERSION, + run, + capturedAt: capturedAt.toISOString(), + logs, + }); + dirty = false; + lastFlushAt = capturedAt.getTime(); + } catch (error) { + fail(error); + } + }; + + const scheduleFlush = (): void => { + if (disabled || timer) return; + const delay = Math.max( + MAIN_PROCESS_RECOVERY_FLUSH_DEBOUNCE_MS, + lastFlushAt + MAIN_PROCESS_RECOVERY_FLUSH_INTERVAL_MS - now().getTime(), + ); + timer = setTimeout(flush, delay); + timer.unref(); + }; + + return { + pending, + markDirty(): void { + if (disabled) return; + dirty = true; + scheduleFlush(); + }, + flushNow(): void { + flush(); + }, + markClean(): void { + disabled = true; + cancelTimer(); + try { + removeFileEntry(activePath); + } catch (error) { + input.onError(error); + } + }, + discardPending(): void { + try { + removeFileEntry(pendingPath); + } catch (error) { + input.onError(error); + } + }, + }; +} + +export async function presentPendingMainProcessRecovery( + journal: Pick, + present: (evidence: MainProcessRecoveryEvidence) => Promise, + onError: (error: unknown) => void, +): Promise { + const evidence = journal.pending; + if (!evidence) return; + try { + await present(evidence); + journal.discardPending(); + } catch (error) { + onError(error); + } +} + +export function appendUncaughtMainProcessError( + logs: { append(level: 'error', message: string, capturedAt?: Date): void }, + journal: Pick, + error: unknown, + origin: NodeJS.UncaughtExceptionOrigin, +): void { + try { + const detail = error instanceof Error ? error.stack || error.message : String(error); + logs.append('error', `[process] ${origin}: ${detail}`); + journal.markDirty(); + journal.flushNow(); + } catch { + // Evidence capture must not replace or delay Node's original fatal path. + } +} + +function rotatePriorRun(paths: { + readonly activePath: string; + readonly pendingPath: string; + readonly temporaryPath: string; +}, promotedAt: Date): void { + removeFileEntry(paths.temporaryPath); + const active = lstatOrUndefined(paths.activePath); + if (!active) return; + if (!active.isFile() || active.isSymbolicLink()) { + throw new Error('Main-process recovery active record is invalid'); + } + // The pending file's mtime is its retention authority. Touch before rename + // so even an interruption between these operations leaves recoverable state. + utimesSync(paths.activePath, promotedAt, promotedAt); + removeFileEntry(paths.pendingPath); + renameSync(paths.activePath, paths.pendingPath); +} + +function readPendingEvidence( + pendingPath: string, + now: Date, + onError: (error: unknown) => void, +): MainProcessRecoveryEvidence | undefined { + if (!lstatOrUndefined(pendingPath)) return undefined; + try { + const stored = readJsonFile(pendingPath, EVIDENCE_MAX_BYTES); + const evidence = decodeEvidence(stored.value); + if (now.getTime() - stored.modifiedAtMs > MAIN_PROCESS_RECOVERY_MAX_AGE_MS) { + removeFileEntry(pendingPath); + return undefined; + } + return { + run: evidence.run, + snapshotAt: evidence.capturedAt, + logs: evidence.logs, + }; + } catch (error) { + onError(error); + removeFileEntry(pendingPath); + return undefined; + } +} + +function decodeEvidence(value: unknown): StoredEvidence { + const logs = + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record).logs + : undefined; + if (Array.isArray(logs) && logs.some((entry) => typeof entry !== 'string')) { + throw new Error('Main-process recovery logs are invalid'); + } + const evidence = storedEvidenceSchema.parse(value); + return { + schemaVersion: SCHEMA_VERSION, + run: evidence.run, + capturedAt: evidence.capturedAt, + logs: boundedLogTail(evidence.logs), + }; +} + +function boundedLogTail(logs: readonly string[]): readonly string[] { + const newestFirst: string[] = []; + let bytes = 2; + for (let index = logs.length - 1; index >= 0; index -= 1) { + const entry = truncateUtf8( + logs[index] ?? '', + MAIN_PROCESS_RECOVERY_LOG_MAX_BYTES - 2, + ENTRY_TRUNCATION_MARKER, + ); + const entryBytes = Buffer.byteLength(JSON.stringify(entry)); + const separatorBytes = newestFirst.length > 0 ? 1 : 0; + if (bytes + separatorBytes + entryBytes > MAIN_PROCESS_RECOVERY_LOG_MAX_BYTES) break; + newestFirst.push(entry); + bytes += separatorBytes + entryBytes; + } + return newestFirst.reverse(); +} + +function readJsonFile( + path: string, + maximumBytes: number, +): { readonly value: unknown; readonly modifiedAtMs: number } { + const linkMetadata = lstatSync(path); + if (!linkMetadata.isFile() || linkMetadata.isSymbolicLink()) { + throw new Error(`Main-process recovery file ${basename(path)} is invalid`); + } + const flags = constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NOFOLLOW); + const fd = openSync(path, flags); + try { + const metadata = fstatSync(fd); + if (!metadata.isFile() || metadata.size > maximumBytes) { + throw new Error(`Main-process recovery file ${basename(path)} is invalid`); + } + return { + value: JSON.parse(readFileSync(fd, 'utf8')), + modifiedAtMs: metadata.mtimeMs, + }; + } finally { + closeSync(fd); + } +} + +function writeEvidenceAtomically( + activePath: string, + temporaryPath: string, + evidence: StoredEvidence, +): void { + const contents = JSON.stringify(evidence); + if (Buffer.byteLength(contents) > EVIDENCE_MAX_BYTES) { + throw new Error('Main-process recovery evidence exceeds its size limit'); + } + removeFileEntry(temporaryPath); + try { + writeFileSync(temporaryPath, contents, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); + renameSync(temporaryPath, activePath); + } finally { + removeFileEntry(temporaryPath); + } +} + +function ensurePrivateDirectory(path: string): void { + const existing = lstatOrUndefined(path); + if (!existing) mkdirSync(path, { recursive: true, mode: 0o700 }); + const metadata = lstatSync(path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error('Main-process recovery root is not a private directory'); + } + chmodSync(path, 0o700); +} + +function removeFileEntry(path: string): void { + const metadata = lstatOrUndefined(path); + if (!metadata) return; + if (!metadata.isFile() && !metadata.isSymbolicLink()) { + throw new Error(`Main-process recovery path ${basename(path)} is not a file`); + } + rmSync(path, { force: true }); +} + +function lstatOrUndefined(path: string): ReturnType | undefined { + try { + return lstatSync(path); + } catch (error) { + if (isMissing(error)) return undefined; + throw error; + } +} + +function isMissing(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT'; +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 1cf8bf3419..e7bb10e5fa 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -23,13 +23,25 @@ import { join } from 'node:path'; import { resolveBuildInfo } from './build-info.js'; import { captureDesktopDiagnosticEnvironment, + copyDesktopDiagnosticReport, + createDesktopPreviousMainProcessDiagnosticInput, installMainProcessLogCapture, mainProcessLogBuffer, } from './main-process-diagnostics.js'; -import { showFatalStartupError } from './native-diagnostic-dialog.js'; +import { + appendUncaughtMainProcessError, + createMainProcessRecoveryJournal, + presentPendingMainProcessRecovery, + type MainProcessRecoveryJournal, +} from './main-process-recovery-journal.js'; +import { + showFatalStartupError, + showPreviousMainProcessInterruptionDialog, +} from './native-diagnostic-dialog.js'; import { isIsolatedE2e } from './startup-context.js'; -installMainProcessLogCapture(); +let recoveryJournal: MainProcessRecoveryJournal | undefined; +installMainProcessLogCapture(mainProcessLogBuffer, () => recoveryJournal?.markDirty()); // The macOS app menu title and app.getName() consumers read this name. Set it // before ready, unchanged from its historical pre-ready position. @@ -57,6 +69,28 @@ if (isIsolatedE2e && process.env.MAKA_E2E_USER_DATA_DIR) { if (!app.requestSingleInstanceLock()) { app.exit(0); } else { + const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath()); + try { + recoveryJournal = createMainProcessRecoveryJournal({ + root: join(app.getPath('userData'), 'main-process-recovery'), + appVersion: app.getVersion(), + buildMode: buildInfo.mode, + buildCommit: buildInfo.commit, + logs: () => mainProcessLogBuffer.snapshot(), + onError: (error) => console.error('[diagnostics] main-process recovery failed:', error), + }); + const journal = recoveryJournal; + process.on('uncaughtExceptionMonitor', (error, origin) => { + appendUncaughtMainProcessError(mainProcessLogBuffer, journal, error, origin); + }); + app.on('quit', () => journal.markClean()); + app.on('browser-window-created', (_event, window) => { + window.on('session-end', () => journal.markClean()); + }); + } catch (error) { + console.error('[diagnostics] main-process recovery unavailable:', error); + } + // The full boot must not run in the top-level module-evaluation chain: // Electron ESM emits `ready` only after the entry module finishes // evaluating, so a top-level `await app.whenReady()` (which the @@ -66,8 +100,47 @@ if (!app.requestSingleInstanceLock()) { // store/db write". app .whenReady() - .then(() => { + .then(async () => { console.log('[startup] app ready'); + const journal = recoveryJournal; + if (journal?.pending) { + if (isIsolatedE2e) { + journal.discardPending(); + } else { + await presentPendingMainProcessRecovery( + journal, + (pending) => + showPreviousMainProcessInterruptionDialog({ + locale: resolveSystemUiLocale(app.getPreferredSystemLanguages()), + copyDiagnostics: () => + copyDesktopDiagnosticReport( + { + environment: () => + captureDesktopDiagnosticEnvironment({ + appVersion: app.getVersion(), + buildMode: buildInfo.mode, + buildCommit: buildInfo.commit, + locale: app.getLocale(), + workspacePath: join( + app.getPath('userData'), + 'workspaces', + 'default', + ), + }), + mainLogs: () => mainProcessLogBuffer.snapshot(), + resolveActiveRuntimeHost: () => undefined, + resolveRuntimeHost: () => undefined, + writeClipboard: (report) => clipboard.writeText(report), + }, + createDesktopPreviousMainProcessDiagnosticInput(pending), + ), + showMessageBox: (options) => dialog.showMessageBox(options), + }), + (error) => + console.error('[diagnostics] previous-session recovery dialog failed:', error), + ); + } + } return import('./runtime-host-boot.js'); }) .catch(async (error: unknown) => { @@ -93,6 +166,7 @@ if (!app.requestSingleInstanceLock()) { }); } } finally { + recoveryJournal?.markClean(); app.exit(1); } }); diff --git a/apps/desktop/src/main/native-diagnostic-dialog.ts b/apps/desktop/src/main/native-diagnostic-dialog.ts index d7d2fb4677..2491326731 100644 --- a/apps/desktop/src/main/native-diagnostic-dialog.ts +++ b/apps/desktop/src/main/native-diagnostic-dialog.ts @@ -114,6 +114,25 @@ export async function showMainRendererProcessGoneDialog( return result.response === 0 ? 'relaunch' : 'exit'; } +export async function showPreviousMainProcessInterruptionDialog( + deps: DiagnosticDialogDeps, +): Promise { + const copy = PREVIOUS_MAIN_PROCESS_INTERRUPTION_COPY[deps.locale]; + await showMessageBoxWithDiagnostics( + { + type: 'warning', + title: copy.title, + message: copy.message, + detail: copy.detail, + buttons: [copy.continue], + defaultId: 0, + cancelId: 0, + noLink: true, + }, + deps, + ); +} + async function copyDiagnostics( copy: () => void | Promise, locale: UiLocale, @@ -195,3 +214,18 @@ const MAIN_RENDERER_GONE_COPY = { exit: '退出', }, } as const; + +const PREVIOUS_MAIN_PROCESS_INTERRUPTION_COPY = { + en: { + title: 'Previous session ended unexpectedly', + message: 'Maka did not finish shutting down during its previous session.', + detail: 'You can copy the available diagnostics before continuing.', + continue: 'Continue', + }, + zh: { + title: '上一次会话意外结束', + message: 'Maka 上一次运行时未能完成退出流程。', + detail: '继续前,你可以复制当前可用的诊断信息。', + continue: '继续', + }, +} as const; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 8ae077969e..3736e82b52 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -290,7 +290,7 @@ const resolveLocalStorageRoot = () => const startupLocalStorageRoot = await resolveLocalStorageRoot(); if (!startupLocalStorageRoot) { - app.exit(0); + app.quit(); await new Promise(() => {}); throw new Error("Desktop storage root resolution did not complete"); } @@ -840,7 +840,7 @@ runtimeHostManager = await startRuntimeHostDesktopManager( }, ).catch((error: unknown) => { if (error instanceof RuntimeHostUpgradeCancelledError) { - app.exit(0); + app.quit(); return new Promise(() => undefined); } throw error; diff --git a/packages/core/src/node-diagnostic-log.ts b/packages/core/src/node-diagnostic-log.ts index 8786ae8185..d4f1344690 100644 --- a/packages/core/src/node-diagnostic-log.ts +++ b/packages/core/src/node-diagnostic-log.ts @@ -22,7 +22,10 @@ import type { DiagnosticLogBuffer } from './diagnostic-log.js'; const MAX_LOG_ENTRY_CODE_POINTS = 8 * 1024; -export function installConsoleDiagnosticLogCapture(buffer: DiagnosticLogBuffer): void { +export function installConsoleDiagnosticLogCapture( + buffer: DiagnosticLogBuffer, + onAppend?: () => void, +): void { for (const level of ['debug', 'info', 'log', 'warn', 'error'] as const) { const original = console[level].bind(console); console[level] = (...args: unknown[]) => { @@ -41,6 +44,7 @@ export function installConsoleDiagnosticLogCapture(buffer: DiagnosticLogBuffer): ...args, ), ); + onAppend?.(); } catch { // Diagnostic capture must not change console behavior. }