From 0911062a3761889e4b563f475869b719aae35c82 Mon Sep 17 00:00:00 2001 From: lakshya-dhariwal Date: Sun, 27 Sep 2026 19:18:37 +0530 Subject: [PATCH 1/3] fix(lib): write codex session cache atomically (#689) --- lib/codex-sessions.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/codex-sessions.ts b/lib/codex-sessions.ts index f30fd6980..a60551b75 100644 --- a/lib/codex-sessions.ts +++ b/lib/codex-sessions.ts @@ -15,7 +15,7 @@ * parser produces (`lib/log-entries.ts`) so the existing log viewer renders * Codex sessions without any UI-side branching. */ -import { readFileSync, readdirSync, existsSync, writeFileSync, mkdirSync, statSync } from "node:fs"; +import { readFileSync, readdirSync, existsSync, writeFileSync, mkdirSync, statSync, renameSync, unlinkSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; @@ -53,7 +53,22 @@ function writeCacheEntry(sessionId: string, path: string): void { mkdirSync(dirname(CACHE_PATH), { recursive: true }); const cache = readCache(); cache[sessionId] = path; - writeFileSync(CACHE_PATH, JSON.stringify(cache), "utf-8"); + // Atomic write: dump to a per-process temp file, then rename over the + // cache. rename(2) is atomic on POSIX/NTFS, so a reader can never see a + // torn JSON payload, and two concurrent writers leave the last-complete + // file behind instead of an interleaved one. + const tmp = `${CACHE_PATH}.${process.pid}.tmp`; + try { + writeFileSync(tmp, JSON.stringify(cache), "utf-8"); + renameSync(tmp, CACHE_PATH); + } catch (err) { + try { + unlinkSync(tmp); + } catch { + // temp file already gone + } + throw err; + } } catch { // Cache is best-effort } From 25cf716049e70ba3c8fcfa0bfaa166615c8b6761 Mon Sep 17 00:00:00 2001 From: lakshya-dhariwal Date: Sun, 27 Sep 2026 19:24:24 +0530 Subject: [PATCH 2/3] test(lib): cover atomic codex cache write and temp-file cleanup --- __tests__/lib/codex-sessions-cache.test.ts | 59 ++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 __tests__/lib/codex-sessions-cache.test.ts diff --git a/__tests__/lib/codex-sessions-cache.test.ts b/__tests__/lib/codex-sessions-cache.test.ts new file mode 100644 index 000000000..e5acbeafc --- /dev/null +++ b/__tests__/lib/codex-sessions-cache.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// findCodexTranscript walks ~/.codex/sessions and writes its cache under +// FAILPROOFAI_HOME/state - point both at a scratch dir. +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => process.env.__TEST_HOME as string }; +}); + +describe("lib/codex-sessions: findCodexTranscript cache writes", () => { + let home: string; + let fpHome: string; + let sessionFile: string; + const sessionId = "atomic-cache-test-session"; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "codex-home-")); + fpHome = mkdtempSync(join(tmpdir(), "codex-fp-")); + process.env.__TEST_HOME = home; + process.env.FAILPROOFAI_HOME = fpHome; + const today = new Date(); + const dir = join( + home, ".codex", "sessions", + String(today.getUTCFullYear()), + String(today.getUTCMonth() + 1).padStart(2, "0"), + String(today.getUTCDate()).padStart(2, "0"), + ); + mkdirSync(dir, { recursive: true }); + sessionFile = join(dir, `rollout-${sessionId}.jsonl`); + writeFileSync(sessionFile, "", "utf-8"); + vi.resetModules(); + }); + + afterEach(() => { + delete process.env.__TEST_HOME; + delete process.env.FAILPROOFAI_HOME; + rmSync(home, { recursive: true, force: true }); + rmSync(fpHome, { recursive: true, force: true }); + }); + + it("writes a readable cache entry for the discovered transcript", async () => { + const { findCodexTranscript } = await import("@/lib/codex-sessions"); + expect(findCodexTranscript(sessionId)).toBe(sessionFile); + const cachePath = join(fpHome, "state", "codex-session-paths.json"); + const cache = JSON.parse(readFileSync(cachePath, "utf-8")) as Record; + expect(cache[sessionId]).toBe(sessionFile); + }); + + it("leaves no .tmp files behind after the write", async () => { + const { findCodexTranscript } = await import("@/lib/codex-sessions"); + findCodexTranscript(sessionId); + const stateDir = join(fpHome, "state"); + expect(readdirSync(stateDir).filter((f) => f.endsWith(".tmp"))).toEqual([]); + }); +}); From 3d34b96f10e8af6940402d3161aa1725fb40dfe4 Mon Sep 17 00:00:00 2001 From: lakshya-dhariwal Date: Sun, 27 Sep 2026 19:43:29 +0530 Subject: [PATCH 3/3] test(lib): cover cache write and rename failure cleanup --- __tests__/lib/codex-sessions-cache.test.ts | 27 +++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/__tests__/lib/codex-sessions-cache.test.ts b/__tests__/lib/codex-sessions-cache.test.ts index e5acbeafc..fa5a5a23c 100644 --- a/__tests__/lib/codex-sessions-cache.test.ts +++ b/__tests__/lib/codex-sessions-cache.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -38,6 +38,12 @@ describe("lib/codex-sessions: findCodexTranscript cache writes", () => { afterEach(() => { delete process.env.__TEST_HOME; delete process.env.FAILPROOFAI_HOME; + chmodSync(fpHome, 0o700); + try { + chmodSync(join(fpHome, "state"), 0o700); + } catch { + // state dir never created + } rmSync(home, { recursive: true, force: true }); rmSync(fpHome, { recursive: true, force: true }); }); @@ -50,6 +56,25 @@ describe("lib/codex-sessions: findCodexTranscript cache writes", () => { expect(cache[sessionId]).toBe(sessionFile); }); + it("removes the temp file when the write itself fails", async () => { + const stateDir = join(fpHome, "state"); + mkdirSync(stateDir, { recursive: true }); + chmodSync(stateDir, 0o500); + const { findCodexTranscript } = await import("@/lib/codex-sessions"); + expect(() => findCodexTranscript(sessionId)).not.toThrow(); + expect(readdirSync(stateDir).filter((f) => f.endsWith(".tmp"))).toEqual([]); + }); + + it("removes the temp file when the rename fails", async () => { + // A non-empty directory where the cache file belongs makes rename(2) fail + // after the temp write succeeded - the path that used to leak .tmp files. + const stateDir = join(fpHome, "state"); + mkdirSync(join(stateDir, "codex-session-paths.json", "blocking"), { recursive: true }); + const { findCodexTranscript } = await import("@/lib/codex-sessions"); + expect(() => findCodexTranscript(sessionId)).not.toThrow(); + expect(readdirSync(stateDir).filter((f) => f.endsWith(".tmp"))).toEqual([]); + }); + it("leaves no .tmp files behind after the write", async () => { const { findCodexTranscript } = await import("@/lib/codex-sessions"); findCodexTranscript(sessionId);