From c3c090f83b62b317ab2cf223310189142d2e9131 Mon Sep 17 00:00:00 2001 From: Ryder Sun Date: Thu, 15 Oct 2026 19:38:33 +0800 Subject: [PATCH] fix: write state compactly and durably, and record main-process faults - The workspace state is rewritten synchronously on the main process for every save. It is now compact JSON written through the private atomic writer, which fsyncs before renaming (the old path could leave a truncated file after a crash). The Run Ledger snapshot is compact too. - Unhandled rejections and uncaught exceptions in the main process are written to a size-capped, secret-redacted meldwork-diagnostics.log in userData instead of disappearing, and a renderer that crashes is reloaded instead of leaving a blank window. Checks: run-ledger, local-workspace-state, local-workspace and main-security tests (185 pass). Full desktop suite: 1540 pass; frontend web and desktop builds pass (existing large-chunk warning only). --- desktop/src/main.cjs | 27 +++++++++++++++++++ desktop/src/runs/run-ledger.cjs | 2 +- .../src/workspace/local-workspace-state.cjs | 10 ++++--- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index e21f295..7d303f3 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -1,6 +1,8 @@ const electron = require('electron') const { app, BrowserWindow, dialog, ipcMain, Menu, Notification, protocol, shell } = electron +const fs = require('node:fs') const path = require('node:path') +const { redactSecrets } = require('./security/secret-redaction.cjs') const { AttachmentStore } = require('./attachments/attachment-store.cjs') const { ContentBlobStore } = require('./attachments/content-blob-store.cjs') const { ContextPackStore } = require('./collaboration/context-pack-store.cjs') @@ -77,6 +79,23 @@ const LOCAL_AGENT_KINDS = new Set([ ]) const QUIT_CLEANUP_TIMEOUT_MS = 2500 let mainWindow = null + +// Faults outside a request handler are written to a small private log in +// userData (secrets redacted, size-capped) instead of disappearing or +// taking the app down, so instability reports have evidence. +function recordMainProcessFault(kind, detail) { + const text = String(detail?.stack || detail?.message || detail || '').slice(0, 4000) + console.error(`[meldwork] ${kind}`, text) + try { + const file = path.join(app.getPath('userData'), 'meldwork-diagnostics.log') + try { + if (fs.statSync(file).size > 1024 * 1024) fs.renameSync(file, `${file}.1`) + } catch { /* first entry */ } + fs.appendFileSync(file, `${new Date().toISOString()} ${kind} ${redactSecrets(text)}\n`, { mode: 0o600 }) + } catch { /* diagnostics must never throw */ } +} +process.on('unhandledRejection', reason => recordMainProcessFault('unhandledRejection', reason)) +process.on('uncaughtException', error => recordMainProcessFault('uncaughtException', error)) let workspace = null let workspaceChangedListener = null let workspaceRunFinishedListener = null @@ -677,6 +696,14 @@ function createWindow() { showOfflinePage(errorCode) }) mainWindow.webContents.on('did-finish-load', flushPendingOpenGroup) + // A crashed renderer used to leave a blank window; reload it so the + // workspace (whose state lives in the main process) comes back. + mainWindow.webContents.on('render-process-gone', (_event, details) => { + recordMainProcessFault('render-process-gone', details?.reason || 'unknown') + if (details?.reason !== 'clean-exit' && mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.reload() + } + }) mainWindow.on('closed', () => { mainWindow = null }) diff --git a/desktop/src/runs/run-ledger.cjs b/desktop/src/runs/run-ledger.cjs index 68f1a8e..98414f3 100644 --- a/desktop/src/runs/run-ledger.cjs +++ b/desktop/src/runs/run-ledger.cjs @@ -311,7 +311,7 @@ class RunLedger { persist(runs = this.runs) { atomicWritePrivateFile( this.storagePath, - `${JSON.stringify({ version: STORE_VERSION, runs }, null, 2)}\n`, + `${JSON.stringify({ version: STORE_VERSION, runs })}\n`, ) } diff --git a/desktop/src/workspace/local-workspace-state.cjs b/desktop/src/workspace/local-workspace-state.cjs index 3e5bc68..fd9180a 100644 --- a/desktop/src/workspace/local-workspace-state.cjs +++ b/desktop/src/workspace/local-workspace-state.cjs @@ -1,4 +1,5 @@ const fs = require('node:fs') +const { atomicWritePrivateFile } = require('../security/private-file.cjs') const path = require('node:path') const { RUN_STATUSES } = require('../runs/run-ledger-records.cjs') const { normalizeSessionMeta } = require('../runs/run-harness.cjs') @@ -247,11 +248,12 @@ function loadWorkspaceState(storagePath) { } } +// The whole workspace is rewritten on every save, synchronously on the main +// process. Compact JSON cuts the bytes written (and the time the UI is +// blocked) for long histories, and the private atomic writer fsyncs before +// the rename so a crash cannot leave a truncated file behind. function saveWorkspaceState(storagePath, state) { - fs.mkdirSync(path.dirname(storagePath), { recursive: true }) - const tempPath = `${storagePath}.tmp` - fs.writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }) - fs.renameSync(tempPath, storagePath) + atomicWritePrivateFile(storagePath, `${JSON.stringify(state)}\n`) } function durableWaitingRunSnapshots({ state, records, pendingGates, liveRunIds, liveGroupIds }) {