Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions __tests__/lib/codex-sessions.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): string => JSON.stringify(obj);

Expand Down Expand Up @@ -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;
Expand All @@ -206,7 +215,9 @@ describe("lib/codex-sessions: findCodexTranscript", () => {
const actual = await vi.importActual<typeof import("node:os")>("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(() => {
Expand Down Expand Up @@ -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");
Expand Down
16 changes: 14 additions & 2 deletions lib/codex-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -48,12 +57,15 @@ function readCache(): Record<string, string> {
}

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);
Comment on lines 63 to +66
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Atomic rename here still allows concurrent lost updates

This is still a read-modify-write race: two writers can both read the same old cache, each add a different sessionId, and the last renameSync wins, dropping the other entry. Atomic replacement prevents partial JSON, but not clobbering.

Use a cross-process critical section for read+write (e.g., lock file with retry/backoff) or a merge-on-conflict retry loop after failed/changed writes so concurrent writers preserve both entries.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/codex-sessions.ts` around lines 63 - 66, The read-modify-write around
readCache() -> modify cache[sessionId] -> writeFileSync(tmpPath) ->
renameSync(CACHE_PATH) is racy and can clobber concurrent updates; wrap that
whole sequence in a cross-process critical section (e.g., obtain an advisory
lock on CACHE_PATH or a dedicated lockfile with retry/backoff) so only one
process reads+updates+renames at a time, or implement an optimistic merge-retry:
after writing the tmp file but before rename, re-read CACHE_PATH (or
stat+contents), merge any new keys into your in-memory cache, and retry the
write/rename loop until no concurrent changes remain. Use the existing symbols
(readCache, CACHE_PATH, tmpPath, sessionId, writeFileSync, renameSync) to locate
and protect the critical region.

} catch {
try { unlinkSync(tmpPath); } catch { /* ignore */ }
// Cache is best-effort
}
}
Expand Down