You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/08/23 02:58:47 UTC
(maka) branch feat/desktop-main-process-recovery created (now d07b8d60f)
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a change to branch feat/desktop-main-process-recovery
in repository https://gitbox.apache.org/repos/asf/maka.git
at d07b8d60f feat(desktop): preserve bounded main-process recovery evidence
This branch includes the following new commits:
new d07b8d60f feat(desktop): preserve bounded main-process recovery evidence
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
(maka) 01/01: feat(desktop): preserve bounded main-process recovery evidence
Posted by wy...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
M4n5ter pushed a commit to branch feat/desktop-main-process-recovery
in repository https://gitbox.apache.org/repos/asf/maka.git
commit d07b8d60ffce6fbc3ee969e5a7eb8d8d63ad08b5
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Sun Aug 23 10:58:23 2026 +0800
feat(desktop): preserve bounded main-process recovery evidence
Persist a redacted latest-log snapshot at a bounded cadence so the next launch can offer copyable diagnostics when the prior main process did not complete a clean shutdown. Keep the signal intentionally cause-agnostic, local-only, and disposable after the first recovery prompt.
Generated-by: Codex
---
.../main-process-recovery-journal.test.ts | 221 +++++++++++
.../__tests__/native-diagnostic-dialog.test.ts | 57 +++
apps/desktop/src/main/main-process-diagnostics.ts | 96 +++--
.../src/main/main-process-recovery-journal.ts | 417 +++++++++++++++++++++
apps/desktop/src/main/main.ts | 77 +++-
apps/desktop/src/main/native-diagnostic-dialog.ts | 34 ++
apps/desktop/src/main/runtime-host-boot.ts | 4 +-
packages/core/src/node-diagnostic-log.ts | 6 +-
8 files changed, 882 insertions(+), 30 deletions(-)
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 000000000..df7a6a338
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/main-process-recovery-journal.test.ts
@@ -0,0 +1,221 @@
+/*
+ * 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 { 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,
+} 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();
+ 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('expires stale evidence and ignores a corrupt pending record', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'maka-main-recovery-expiry-'));
+ const errors: unknown[] = [];
+ try {
+ createJournal(directory, new DiagnosticLogBuffer(), new Date('2026-08-01T00:00:00Z'));
+ const afterExpiry = createJournal(
+ directory,
+ new DiagnosticLogBuffer(),
+ new Date(new Date('2026-08-01T00:00:00Z').getTime() + MAIN_PROCESS_RECOVERY_MAX_AGE_MS + 1),
+ );
+ assert.equal(afterExpiry.pending, undefined);
+ afterExpiry.markClean();
+
+ 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();
+ } 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 f2db20c36..9793eb0c4 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<MessageBoxReturnValue> => {
+ 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 618da6e02..9cc6c35ec 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 ?? '<unknown>'}`,
- `Renderer user agent: ${rendererContext?.rendererUserAgent ?? '<unknown>'}`,
- `Workspace: ${environment.workspacePath}`,
- `Main process uptime: ${Math.max(0, Math.floor(environment.processUptimeSeconds))}s`,
- '',
- `Recent main-process logs (${mainLogs.length})`,
- ...(mainLogs.length > 0 ? mainLogs : ['<none captured>']),
- );
+ 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 ?? '<none captured>'}`,
+ `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 : ['<none captured>']),
+ );
+ } 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 ?? '<unknown>'}`,
+ `Renderer user agent: ${rendererContext?.rendererUserAgent ?? '<unknown>'}`,
+ `Workspace: ${environment.workspacePath}`,
+ `Main process uptime: ${Math.max(0, Math.floor(environment.processUptimeSeconds))}s`,
+ '',
+ `Recent main-process logs (${mainLogs.length})`,
+ ...(mainLogs.length > 0 ? mainLogs : ['<none captured>']),
+ );
+ }
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 000000000..30318cd85
--- /dev/null
+++ b/apps/desktop/src/main/main-process-recovery-journal.ts
@@ -0,0 +1,417 @@
+/*
+ * 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,
+ 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<recovery log entry truncated>';
+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 cleanPath = join(input.root, 'active.clean');
+ const pendingPath = join(input.root, 'pending.json');
+ const temporaryPath = join(input.root, '.active.json.tmp');
+ rotatePriorRun({ activePath, cleanPath, pendingPath, temporaryPath });
+ const pending = readPendingEvidence(pendingPath, now(), input.onError);
+
+ const run: MainProcessRecoveryRun = {
+ startedAt: now().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 {
+ writeFileSync(cleanPath, '', {
+ encoding: 'utf8',
+ flag: 'wx',
+ mode: 0o600,
+ });
+ } catch (error) {
+ if (!isAlreadyExists(error)) input.onError(error);
+ }
+ },
+ discardPending(): void {
+ try {
+ removeFileEntry(pendingPath);
+ } catch (error) {
+ input.onError(error);
+ }
+ },
+ };
+}
+
+export function appendUncaughtMainProcessError(
+ logs: { append(level: 'error', message: string, capturedAt?: Date): void },
+ journal: Pick<MainProcessRecoveryJournal, 'markDirty' | 'flushNow'>,
+ 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 cleanPath: string;
+ readonly pendingPath: string;
+ readonly temporaryPath: string;
+}): void {
+ removeFileEntry(paths.temporaryPath);
+ const active = lstatOrUndefined(paths.activePath);
+ if (!active) {
+ removeFileEntry(paths.cleanPath);
+ return;
+ }
+ if (!active.isFile() || active.isSymbolicLink()) {
+ throw new Error('Main-process recovery active record is invalid');
+ }
+ if (isRegularFile(paths.cleanPath)) {
+ removeFileEntry(paths.activePath);
+ } else {
+ removeFileEntry(paths.pendingPath);
+ renameSync(paths.activePath, paths.pendingPath);
+ }
+ removeFileEntry(paths.cleanPath);
+}
+
+function readPendingEvidence(
+ pendingPath: string,
+ now: Date,
+ onError: (error: unknown) => void,
+): MainProcessRecoveryEvidence | undefined {
+ if (!lstatOrUndefined(pendingPath)) return undefined;
+ try {
+ const evidence = decodeEvidence(readJsonFile(pendingPath, EVIDENCE_MAX_BYTES));
+ const lastEvidenceAt = evidence.capturedAt ?? evidence.run.startedAt;
+ if (now.getTime() - Date.parse(lastEvidenceAt) > 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 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 bounded: 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 = bounded.length > 0 ? 1 : 0;
+ if (bytes + separatorBytes + entryBytes > MAIN_PROCESS_RECOVERY_LOG_MAX_BYTES) break;
+ bounded.unshift(entry);
+ bytes += separatorBytes + entryBytes;
+ }
+ return bounded;
+}
+
+function readJsonFile(path: string, maximumBytes: number): unknown {
+ 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 JSON.parse(readFileSync(fd, 'utf8'));
+ } 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 isRegularFile(path: string): boolean {
+ const metadata = lstatOrUndefined(path);
+ return Boolean(metadata?.isFile() && !metadata.isSymbolicLink());
+}
+
+function lstatOrUndefined(path: string): ReturnType<typeof lstatSync> | 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';
+}
+
+function isAlreadyExists(error: unknown): boolean {
+ return error instanceof Error && 'code' in error && error.code === 'EEXIST';
+}
diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts
index 1cf8bf341..e410f170f 100644
--- a/apps/desktop/src/main/main.ts
+++ b/apps/desktop/src/main/main.ts
@@ -23,13 +23,24 @@ 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,
+ 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 +68,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 +99,45 @@ if (!app.requestSingleInstanceLock()) {
// store/db write".
app
.whenReady()
- .then(() => {
+ .then(async () => {
console.log('[startup] app ready');
+ const pending = recoveryJournal?.pending;
+ if (pending) {
+ try {
+ if (!isIsolatedE2e) {
+ await 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),
+ });
+ }
+ } catch (error) {
+ console.error('[diagnostics] previous-session recovery dialog failed:', error);
+ } finally {
+ recoveryJournal?.discardPending();
+ }
+ }
return import('./runtime-host-boot.js');
})
.catch(async (error: unknown) => {
@@ -93,6 +163,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 d7d2fb467..249132673 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<void> {
+ 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<void>,
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 8ae077969..3736e82b5 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<never>(() => {});
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<never>(() => undefined);
}
throw error;
diff --git a/packages/core/src/node-diagnostic-log.ts b/packages/core/src/node-diagnostic-log.ts
index 8786ae818..d4f134469 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.
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org