From f9846c0ff4af0b47489ed2ec6e4b68118dcde288 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 1 Dec 2025 12:00:27 -0600 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20fix:=20use=20atomic=20writes=20f?= =?UTF-8?q?or=20session=20files=20to=20prevent=20corruption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-atomic fs.writeFile can leave truncated/malformed JSON if the app crashes mid-write. This causes 'Unexpected end of JSON input' errors when reading partial.json on the next stream start, leading to amnesia (messages being lost). Use write-file-atomic which writes to a temp file then renames, ensuring readers always see either the old complete file or the new complete file, never a partial write. Fixed in: - partialService.ts (partial.json) - historyService.ts (chat.jsonl rewrites) - sessionFile.ts (generic session file utility) Fixes #803 --- src/node/services/historyService.ts | 13 +++++++++---- src/node/services/partialService.ts | 5 ++++- src/node/utils/sessionFile.ts | 4 +++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 13be7fd38..708cd4bcd 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -1,5 +1,6 @@ import * as fs from "fs/promises"; import * as path from "path"; +import writeFileAtomic from "write-file-atomic"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { MuxMessage } from "@/common/types/message"; @@ -235,7 +236,8 @@ export class HistoryService { .map((msg) => JSON.stringify({ ...msg, workspaceId }) + "\n") .join(""); - await fs.writeFile(historyPath, historyEntries); + // Atomic write prevents corruption if app crashes mid-write + await writeFileAtomic(historyPath, historyEntries); return Ok(undefined); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -272,7 +274,8 @@ export class HistoryService { .map((msg) => JSON.stringify({ ...msg, workspaceId }) + "\n") .join(""); - await fs.writeFile(historyPath, historyEntries); + // Atomic write prevents corruption if app crashes mid-write + await writeFileAtomic(historyPath, historyEntries); // Update sequence counter to continue from where we truncated if (truncatedMessages.length > 0) { @@ -399,7 +402,8 @@ export class HistoryService { .map((msg) => JSON.stringify({ ...msg, workspaceId }) + "\n") .join(""); - await fs.writeFile(historyPath, historyEntries); + // Atomic write prevents corruption if app crashes mid-write + await writeFileAtomic(historyPath, historyEntries); // Update sequence counter to continue from where we are if (remainingMessages.length > 0) { @@ -455,7 +459,8 @@ export class HistoryService { .map((msg) => JSON.stringify({ ...msg, workspaceId: newWorkspaceId }) + "\n") .join(""); - await fs.writeFile(newHistoryPath, historyEntries); + // Atomic write prevents corruption if app crashes mid-write + await writeFileAtomic(newHistoryPath, historyEntries); // Transfer sequence counter to new workspace ID const oldCounter = this.sequenceCounters.get(oldWorkspaceId) ?? 0; diff --git a/src/node/services/partialService.ts b/src/node/services/partialService.ts index bfb258149..6bb20c3cb 100644 --- a/src/node/services/partialService.ts +++ b/src/node/services/partialService.ts @@ -1,5 +1,6 @@ import * as fs from "fs/promises"; import * as path from "path"; +import writeFileAtomic from "write-file-atomic"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { MuxMessage } from "@/common/types/message"; @@ -80,7 +81,9 @@ export class PartialService { }, }; - await fs.writeFile(partialPath, JSON.stringify(partialMessage, null, 2)); + // Atomic write: writes to temp file then renames, preventing corruption + // if app crashes mid-write (prevents "Unexpected end of JSON input" on read) + await writeFileAtomic(partialPath, JSON.stringify(partialMessage, null, 2)); return Ok(undefined); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/node/utils/sessionFile.ts b/src/node/utils/sessionFile.ts index b25313820..e1b690127 100644 --- a/src/node/utils/sessionFile.ts +++ b/src/node/utils/sessionFile.ts @@ -1,5 +1,6 @@ import * as fs from "fs/promises"; import * as path from "path"; +import writeFileAtomic from "write-file-atomic"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { Config } from "@/node/config"; @@ -55,7 +56,8 @@ export class SessionFileManager { const sessionDir = this.config.getSessionDir(workspaceId); await fs.mkdir(sessionDir, { recursive: true }); const filePath = this.getFilePath(workspaceId); - await fs.writeFile(filePath, JSON.stringify(data, null, 2)); + // Atomic write prevents corruption if app crashes mid-write + await writeFileAtomic(filePath, JSON.stringify(data, null, 2)); return Ok(undefined); } catch (error) { const message = error instanceof Error ? error.message : String(error);