From 49c8ff8c08dade1866f80e8453d2d5041624aad6 Mon Sep 17 00:00:00 2001 From: Sober Date: Tue, 5 May 2026 20:09:08 +0000 Subject: [PATCH] fix: write Codex session cache atomically --- __tests__/lib/codex-sessions.test.ts | 38 +++++++++++++++++++++++++--- lib/codex-sessions.ts | 16 ++++++++++-- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/__tests__/lib/codex-sessions.test.ts b/__tests__/lib/codex-sessions.test.ts index cbe20942..dfc90d1d 100644 --- a/__tests__/lib/codex-sessions.test.ts +++ b/__tests__/lib/codex-sessions.test.ts @@ -1,8 +1,16 @@ // @vitest-environment node import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir, homedir } from "node:os"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; const line = (obj: Record): string => JSON.stringify(obj); @@ -195,6 +203,7 @@ describe("lib/codex-sessions: findCodexTranscript", () => { let originalHome: string | undefined; let fakeHome: string; let findCodexTranscript: typeof import("@/lib/codex-sessions").findCodexTranscript; + let getCacheFilePath: typeof import("@/lib/codex-sessions")._getCacheFilePath; beforeEach(async () => { originalHome = process.env.HOME; @@ -206,7 +215,9 @@ describe("lib/codex-sessions: findCodexTranscript", () => { const actual = await vi.importActual("node:os"); return { ...actual, homedir: () => fakeHome }; }); - ({ findCodexTranscript } = await import("@/lib/codex-sessions")); + const mod = await import("@/lib/codex-sessions"); + findCodexTranscript = mod.findCodexTranscript; + getCacheFilePath = mod._getCacheFilePath; }); afterEach(() => { @@ -236,6 +247,25 @@ describe("lib/codex-sessions: findCodexTranscript", () => { expect(result).toBe(file); }); + it("writes discovered transcript paths through the cache file", () => { + const sid = "019dd672-cache-7a30-8671-deadbeefcafe"; + const today = new Date(); + const y = String(today.getUTCFullYear()); + const m = String(today.getUTCMonth() + 1).padStart(2, "0"); + const d = String(today.getUTCDate()).padStart(2, "0"); + const dir = join(fakeHome, ".codex", "sessions", y, m, d); + mkdirSync(dir, { recursive: true }); + const file = join(dir, `rollout-${sid}.jsonl`); + writeFileSync(file, "{}\n"); + + expect(findCodexTranscript(sid)).toBe(file); + + const cachePath = getCacheFilePath(); + expect(JSON.parse(readFileSync(cachePath, "utf-8"))).toEqual({ [sid]: file }); + expect(existsSync(`${cachePath}.${process.pid}.tmp`)).toBe(false); + expect(readdirSync(dirname(cachePath)).filter((name) => name.endsWith(".tmp"))).toEqual([]); + }); + it("locates a transcript via full tree scan when not in today/yesterday", () => { const sid = "019dd672-bbbb-7a30-8671-deadbeefcafe"; const dir = join(fakeHome, ".codex", "sessions", "2024", "01", "15"); diff --git a/lib/codex-sessions.ts b/lib/codex-sessions.ts index f5af8c72..07d64750 100644 --- a/lib/codex-sessions.ts +++ b/lib/codex-sessions.ts @@ -15,7 +15,16 @@ * 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"; @@ -48,12 +57,15 @@ function readCache(): Record { } function writeCacheEntry(sessionId: string, path: string): void { + const tmpPath = `${CACHE_PATH}.${process.pid}.tmp`; try { mkdirSync(dirname(CACHE_PATH), { recursive: true }); const cache = readCache(); cache[sessionId] = path; - writeFileSync(CACHE_PATH, JSON.stringify(cache), "utf-8"); + writeFileSync(tmpPath, JSON.stringify(cache), "utf-8"); + renameSync(tmpPath, CACHE_PATH); } catch { + try { unlinkSync(tmpPath); } catch { /* ignore */ } // Cache is best-effort } }