From 3221a1ce5102a333e0e8aab773a3e4859f085238 Mon Sep 17 00:00:00 2001 From: Arnab758 <194850649+Arnab758@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:05:42 +0530 Subject: [PATCH 1/2] fix(memory): write memory graph atomically to prevent corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveGraph() wrote the memory file directly with fs.writeFile, so an interruption mid-write (crash, kill, power loss) could leave a truncated or corrupted memory file. Write to a unique temp file first, then rename into place — atomic on POSIX and Windows. Clean up the temp file on failure. Includes a regression test. --- src/memory/__tests__/knowledge-graph.test.ts | 36 +++++++++++++++++++- src/memory/index.ts | 18 +++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/memory/__tests__/knowledge-graph.test.ts b/src/memory/__tests__/knowledge-graph.test.ts index 236242413a..a8e777784f 100644 --- a/src/memory/__tests__/knowledge-graph.test.ts +++ b/src/memory/__tests__/knowledge-graph.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -18,6 +18,8 @@ describe('KnowledgeGraphManager', () => { }); afterEach(async () => { + vi.restoreAllMocks(); + // Clean up test file try { await fs.unlink(testFilePath); @@ -409,6 +411,38 @@ describe('KnowledgeGraphManager', () => { expect(graph.entities[0].name).toBe('Alice'); }); + it('should preserve existing data when a write is interrupted', async () => { + await manager.createEntities([ + { name: 'Alice', entityType: 'person', observations: ['persistent data'] }, + ]); + + const originalFileContent = await fs.readFile(testFilePath, 'utf-8'); + const writeFile = fs.writeFile.bind(fs); + + // Simulate the process being killed mid-write: partial bytes written, then error + vi.spyOn(fs, 'writeFile').mockImplementation(async (file, data) => { + await writeFile(file, data.toString().slice(0, 1)); + throw new Error('interrupted write'); + }); + + await expect( + manager.createEntities([ + { name: 'Bob', entityType: 'person', observations: [] }, + ]) + ).rejects.toThrow('interrupted write'); + + // The original memory file must be untouched + await expect(fs.readFile(testFilePath, 'utf-8')).resolves.toBe( + originalFileContent + ); + + // No temp files may leak from the failed write + const files = await fs.readdir(path.dirname(testFilePath)); + expect( + files.filter(file => file.startsWith(`${path.basename(testFilePath)}.`)) + ).toEqual([]); + }); + it('should handle JSONL format correctly', async () => { await manager.createEntities([ { name: 'Alice', entityType: 'person', observations: [] }, diff --git a/src/memory/index.ts b/src/memory/index.ts index 9865c5318e..0b0f9823a7 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -4,6 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { SubscribeRequestSchema, UnsubscribeRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; +import { randomBytes } from 'crypto'; import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -114,7 +115,22 @@ export class KnowledgeGraphManager { relationType: r.relationType })), ]; - await fs.writeFile(this.memoryFilePath, lines.join("\n")); + // Write atomically: write to a unique temp file, then rename into place, so + // an interruption mid-write can never leave a truncated or corrupted file. + // The random suffix avoids collisions when saves overlap. + const tmpPath = `${this.memoryFilePath}.${randomBytes(16).toString('hex')}.tmp`; + try { + await fs.writeFile(tmpPath, lines.join("\n")); + await fs.rename(tmpPath, this.memoryFilePath); + } catch (error) { + // Clean up the temp file so a failed save never leaks artifacts. + try { + await fs.unlink(tmpPath); + } catch { + /* best-effort cleanup: never mask the original error */ + } + throw error; + } } async createEntities(entities: Entity[]): Promise { From 5a29e182b65d996bad1fe09137cc4086bbd97889 Mon Sep 17 00:00:00 2001 From: Arnab758 <194850649+Arnab758@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:14:31 +0530 Subject: [PATCH 2/2] docs(memory): document SIGKILL temp-file limitation in saveGraph --- src/memory/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/memory/index.ts b/src/memory/index.ts index 0b0f9823a7..149391e19e 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -118,6 +118,10 @@ export class KnowledgeGraphManager { // Write atomically: write to a unique temp file, then rename into place, so // an interruption mid-write can never leave a truncated or corrupted file. // The random suffix avoids collisions when saves overlap. + // + // Known limitation: a hard kill (SIGKILL) between the write and the rename + // leaves a stray .tmp file behind. This is unavoidable without a journal; + // the guarantee we provide is that memory.jsonl itself is never corrupted. const tmpPath = `${this.memoryFilePath}.${randomBytes(16).toString('hex')}.tmp`; try { await fs.writeFile(tmpPath, lines.join("\n"));