Skip to content

Commit 32bc4b7

Browse files
David Liuclaude
andcommitted
fix(perf): stop re-parsing the whole Codex session history on every refresh
Users reported Dr. Claw becoming laggy, hanging on "loading", failing to create new sessions, and sitting with a running timer that never produces output — with the PowerShell window itself looking frozen. Root cause: buildCodexSessionsIndex() re-read and JSON.parse'd every line of every transcript under ~/.codex/sessions on every call, with no cache. That directory only grows; on a regular user it had reached 577 files / 10 GB. getProjects() awaited that scan, and the sync re-ran at least every 30s, so the app spent most of its time inside a scan. Measured against that 10 GB directory: first getProjects() 19,991 ms -> 543 ms repeat codex scan ~20-30 s -> 12 ms (off the request path) Changes: - Add server/utils/jsonlTailReader.js: a byte-accurate incremental reader for append-only JSONL. Session transcripts are append-only, so their metadata is a pure fold over lines; the reader reports the offset past the last complete line so a scan can resume instead of re-reading. Handles multi-byte UTF-8 across chunk boundaries, CRLF, and unterminated trailing lines. - Memoize the Codex session index per file on (ino, size, mtimeMs), resuming the fold from the cached offset on strict size growth and re-parsing fully otherwise. Concurrent scans collapse onto one pass. - Route session open and session delete through the memoized index instead of their own full-directory re-parses, with a session_meta header scan as a fallback for transcripts the project index cannot hold (no cwd). Verify the header id before returning a filename match, so deleting a session can no longer unlink a different transcript whose name merely contains the id. - Run the Codex discovery sync in the background and publish results via a 'projects-changed' event rather than making /api/projects wait for it. - Gate per-event logging in the Codex and Claude stream loops behind DRCLAW_DEBUG. These fired thousands of times per turn; Node writes to a Windows console TTY synchronously, so a console left in QuickEdit selection mode blocks the write — and with it the event loop — indefinitely. - Serialize the watcher's project payload once instead of twice per event. Adds 20 tests covering byte-offset accuracy, incremental-vs-full equivalence, rewrites, truncation, unterminated tails, cache eviction, concurrent scans, and delete targeting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6da7f7c commit 32bc4b7

9 files changed

Lines changed: 1099 additions & 132 deletions

File tree

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { mkdtemp, mkdir, rm, writeFile, appendFile, readFile } from 'fs/promises';
3+
import fsSync from 'fs';
4+
import os from 'os';
5+
import path from 'path';
6+
7+
/**
8+
* Regression tests for the Codex session index.
9+
*
10+
* Before the fix, buildCodexSessionsIndex() re-read and JSON.parse'd every line
11+
* of every transcript under ~/.codex/sessions on every call. With a realistic
12+
* multi-gigabyte session history that is tens of seconds of blocking work on the
13+
* main thread, repeated at least every 30 seconds — which is what made the app
14+
* lag, stall on "loading", and refuse to create new sessions.
15+
*/
16+
17+
const originalHome = process.env.HOME;
18+
const originalUserProfile = process.env.USERPROFILE;
19+
const originalDatabasePath = process.env.DATABASE_PATH;
20+
21+
let tempRoot = null;
22+
let projectRoot = null;
23+
24+
async function loadProjects() {
25+
vi.resetModules();
26+
const projects = await import('../projects.js');
27+
return projects;
28+
}
29+
30+
function sessionLines({ sessionId, cwd, timestamp, userMessage }) {
31+
return [
32+
{ timestamp, type: 'session_meta', payload: { id: sessionId, timestamp, cwd, model: 'gpt-5.6' } },
33+
{ timestamp, type: 'event_msg', payload: { type: 'user_message', message: userMessage } },
34+
{ timestamp, type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] } },
35+
].map((entry) => JSON.stringify(entry)).join('\n') + '\n';
36+
}
37+
38+
async function writeRollout({ sessionId, cwd, userMessage = 'hello', timestamp = '2026-06-09T11:00:00.000Z' }) {
39+
const sessionFile = path.join(
40+
tempRoot, '.codex', 'sessions', '2026', '06', '09',
41+
`rollout-2026-06-09T11-00-00-${sessionId}.jsonl`,
42+
);
43+
await mkdir(path.dirname(sessionFile), { recursive: true });
44+
await writeFile(sessionFile, sessionLines({ sessionId, cwd, timestamp, userMessage }), 'utf8');
45+
return sessionFile;
46+
}
47+
48+
describe('Codex session index caching', () => {
49+
beforeEach(async () => {
50+
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'dr-claw-codex-cache-'));
51+
process.env.HOME = tempRoot;
52+
process.env.USERPROFILE = tempRoot;
53+
process.env.DATABASE_PATH = path.join(tempRoot, 'db', 'auth.db');
54+
55+
projectRoot = path.join(tempRoot, 'workspace', 'demo');
56+
await mkdir(projectRoot, { recursive: true });
57+
});
58+
59+
afterEach(async () => {
60+
if (tempRoot) {
61+
await rm(tempRoot, { recursive: true, force: true });
62+
}
63+
process.env.HOME = originalHome;
64+
process.env.USERPROFILE = originalUserProfile;
65+
process.env.DATABASE_PATH = originalDatabasePath;
66+
vi.restoreAllMocks();
67+
});
68+
69+
it('does not re-read a transcript whose size and mtime are unchanged', async () => {
70+
await writeRollout({ sessionId: 'sess-a', cwd: projectRoot, userMessage: 'first prompt' });
71+
const { buildCodexSessionsIndex } = await loadProjects();
72+
73+
const first = await buildCodexSessionsIndex();
74+
expect([...first.values()].flat()).toHaveLength(1);
75+
76+
// Count file reads on the second pass only, so the cold pass is not counted.
77+
const createReadStream = vi.spyOn(fsSync, 'createReadStream');
78+
const second = await buildCodexSessionsIndex();
79+
80+
expect(createReadStream).not.toHaveBeenCalled();
81+
expect([...second.values()].flat()).toHaveLength(1);
82+
expect([...second.values()].flat()[0].summary).toContain('first prompt');
83+
});
84+
85+
it('parses only the appended tail when a transcript grows', async () => {
86+
const sessionFile = await writeRollout({ sessionId: 'sess-b', cwd: projectRoot, userMessage: 'first prompt' });
87+
const { buildCodexSessionsIndex } = await loadProjects();
88+
89+
await buildCodexSessionsIndex();
90+
const sizeAfterFirstPass = (await readFile(sessionFile)).length;
91+
92+
await appendFile(sessionFile, JSON.stringify({
93+
timestamp: '2026-06-09T12:00:00.000Z',
94+
type: 'event_msg',
95+
payload: { type: 'user_message', message: 'second prompt' },
96+
}) + '\n', 'utf8');
97+
98+
const createReadStream = vi.spyOn(fsSync, 'createReadStream');
99+
const index = await buildCodexSessionsIndex();
100+
101+
expect(createReadStream).toHaveBeenCalledTimes(1);
102+
// The resumed read starts at the previously consumed offset, not at 0.
103+
expect(createReadStream.mock.calls[0][1]).toMatchObject({ start: sizeAfterFirstPass });
104+
105+
const sessions = [...index.values()].flat();
106+
expect(sessions).toHaveLength(1);
107+
// Folded state carried across the incremental read: both the earlier and the
108+
// appended message are counted, and the summary reflects the newest prompt.
109+
expect(sessions[0].summary).toContain('second prompt');
110+
expect(sessions[0].messageCount).toBe(3);
111+
});
112+
113+
it('matches a full re-parse after an incremental read', async () => {
114+
const sessionFile = await writeRollout({ sessionId: 'sess-c', cwd: projectRoot, userMessage: 'alpha' });
115+
const projects = await loadProjects();
116+
117+
await projects.buildCodexSessionsIndex();
118+
await appendFile(sessionFile, JSON.stringify({
119+
timestamp: '2026-06-09T12:30:00.000Z',
120+
type: 'event_msg',
121+
payload: { type: 'user_message', message: 'beta 请问大家有变卡的情况吗' },
122+
}) + '\n', 'utf8');
123+
124+
const incremental = [...(await projects.buildCodexSessionsIndex()).values()].flat();
125+
126+
projects.resetCodexSessionFileCache();
127+
const fullReparse = [...(await projects.buildCodexSessionsIndex()).values()].flat();
128+
129+
expect(incremental).toEqual(fullReparse);
130+
});
131+
132+
it('re-parses from scratch when a transcript is rewritten smaller', async () => {
133+
const sessionFile = await writeRollout({ sessionId: 'sess-d', cwd: projectRoot, userMessage: 'original prompt' });
134+
const { buildCodexSessionsIndex } = await loadProjects();
135+
136+
await buildCodexSessionsIndex();
137+
138+
// Simulate a rewrite/truncation rather than an append.
139+
await writeFile(sessionFile, sessionLines({
140+
sessionId: 'sess-d',
141+
cwd: projectRoot,
142+
timestamp: '2026-06-09T13:00:00.000Z',
143+
userMessage: 'rewritten',
144+
}), 'utf8');
145+
146+
const sessions = [...(await buildCodexSessionsIndex()).values()].flat();
147+
expect(sessions).toHaveLength(1);
148+
expect(sessions[0].summary).toContain('rewritten');
149+
// A stale accumulator would have double-counted the original messages.
150+
expect(sessions[0].messageCount).toBe(2);
151+
});
152+
153+
it('re-parses a same-size rewrite instead of treating it as an append', async () => {
154+
// A rewrite that lands on an identical byte count is not growth. Resuming
155+
// from the cached offset would keep the stale prefix folded in and read
156+
// none of the new content.
157+
const sessionFile = await writeRollout({ sessionId: 'sess-g', cwd: projectRoot, userMessage: 'aaaaa' });
158+
const { buildCodexSessionsIndex } = await loadProjects();
159+
160+
const before = [...(await buildCodexSessionsIndex()).values()].flat();
161+
expect(before[0].summary).toContain('aaaaa');
162+
const sizeBefore = fsSync.statSync(sessionFile).size;
163+
164+
await writeFile(sessionFile, sessionLines({
165+
sessionId: 'sess-g',
166+
cwd: projectRoot,
167+
timestamp: '2026-06-09T14:00:00.000Z',
168+
userMessage: 'bbbbb', // same length as 'aaaaa' => same file size
169+
}), 'utf8');
170+
expect(fsSync.statSync(sessionFile).size).toBe(sizeBefore);
171+
172+
const after = [...(await buildCodexSessionsIndex()).values()].flat();
173+
expect(after[0].summary).toContain('bbbbb');
174+
expect(after[0].messageCount).toBe(2);
175+
});
176+
177+
it('includes a final record that has no trailing newline', async () => {
178+
const dir = path.join(tempRoot, '.codex', 'sessions', '2026', '06', '09');
179+
await mkdir(dir, { recursive: true });
180+
const sessionFile = path.join(dir, 'rollout-sess-h.jsonl');
181+
// Note: no terminating newline on the last record.
182+
await writeFile(sessionFile, [
183+
JSON.stringify({ timestamp: '2026-06-09T11:00:00.000Z', type: 'session_meta', payload: { id: 'sess-h', cwd: projectRoot, model: 'gpt-5.6' } }),
184+
JSON.stringify({ timestamp: '2026-06-09T11:01:00.000Z', type: 'event_msg', payload: { type: 'user_message', message: 'unterminated tail' } }),
185+
].join('\n'), 'utf8');
186+
187+
const { buildCodexSessionsIndex } = await loadProjects();
188+
const sessions = [...(await buildCodexSessionsIndex()).values()].flat();
189+
190+
expect(sessions).toHaveLength(1);
191+
expect(sessions[0].summary).toContain('unterminated tail');
192+
expect(sessions[0].messageCount).toBe(1);
193+
});
194+
195+
it('does not double-count a trailing record once it is terminated', async () => {
196+
const dir = path.join(tempRoot, '.codex', 'sessions', '2026', '06', '09');
197+
await mkdir(dir, { recursive: true });
198+
const sessionFile = path.join(dir, 'rollout-sess-i.jsonl');
199+
await writeFile(sessionFile, [
200+
JSON.stringify({ timestamp: '2026-06-09T11:00:00.000Z', type: 'session_meta', payload: { id: 'sess-i', cwd: projectRoot, model: 'gpt-5.6' } }),
201+
JSON.stringify({ timestamp: '2026-06-09T11:01:00.000Z', type: 'event_msg', payload: { type: 'user_message', message: 'mid-write' } }),
202+
].join('\n'), 'utf8');
203+
204+
const { buildCodexSessionsIndex } = await loadProjects();
205+
const first = [...(await buildCodexSessionsIndex()).values()].flat();
206+
expect(first[0].messageCount).toBe(1);
207+
208+
// The writer completes that line and appends nothing else.
209+
await appendFile(sessionFile, '\n', 'utf8');
210+
211+
const second = [...(await buildCodexSessionsIndex()).values()].flat();
212+
expect(second[0].messageCount).toBe(1);
213+
expect(second[0].summary).toContain('mid-write');
214+
});
215+
216+
it('finds a session with no cwd, which the project index cannot hold', async () => {
217+
const dir = path.join(tempRoot, '.codex', 'sessions', '2026', '06', '09');
218+
await mkdir(dir, { recursive: true });
219+
// Opaque filename AND no cwd: neither the basename match nor the project
220+
// index can resolve this, so the header scan has to.
221+
const sessionFile = path.join(dir, 'rollout-no-cwd.jsonl');
222+
await writeFile(sessionFile, [
223+
JSON.stringify({ timestamp: '2026-06-09T11:00:00.000Z', type: 'session_meta', payload: { id: 'sess-nocwd', model: 'gpt-5.6' } }),
224+
JSON.stringify({ timestamp: '2026-06-09T11:01:00.000Z', type: 'event_msg', payload: { type: 'user_message', message: 'orphaned session' } }),
225+
JSON.stringify({ timestamp: '2026-06-09T11:02:00.000Z', type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'still reachable' }] } }),
226+
].join('\n') + '\n', 'utf8');
227+
228+
const { getCodexSessionMessages, deleteCodexSession } = await loadProjects();
229+
230+
const result = await getCodexSessionMessages('sess-nocwd');
231+
expect(result.messages.length).toBeGreaterThan(0);
232+
233+
// Deleting must remove the transcript, not just a database row.
234+
await deleteCodexSession('sess-nocwd');
235+
expect(fsSync.existsSync(sessionFile)).toBe(false);
236+
});
237+
238+
it('does not delete a transcript whose filename merely contains the id', async () => {
239+
const dir = path.join(tempRoot, '.codex', 'sessions', '2026', '06', '09');
240+
await mkdir(dir, { recursive: true });
241+
242+
// Filename contains "abc123" as a substring but belongs to another session.
243+
const decoy = path.join(dir, 'rollout-abc1234567.jsonl');
244+
await writeFile(decoy, sessionLines({
245+
sessionId: 'abc1234567',
246+
cwd: projectRoot,
247+
timestamp: '2026-06-09T10:00:00.000Z',
248+
userMessage: 'do not touch me',
249+
}), 'utf8');
250+
251+
const target = path.join(dir, 'rollout-opaque.jsonl');
252+
await writeFile(target, sessionLines({
253+
sessionId: 'abc123',
254+
cwd: projectRoot,
255+
timestamp: '2026-06-09T11:00:00.000Z',
256+
userMessage: 'delete me',
257+
}), 'utf8');
258+
259+
const { deleteCodexSession } = await loadProjects();
260+
await deleteCodexSession('abc123');
261+
262+
expect(fsSync.existsSync(decoy)).toBe(true);
263+
expect(fsSync.existsSync(target)).toBe(false);
264+
});
265+
266+
it('drops cache entries for deleted transcripts', async () => {
267+
const sessionFile = await writeRollout({ sessionId: 'sess-e', cwd: projectRoot });
268+
const { buildCodexSessionsIndex } = await loadProjects();
269+
270+
expect([...(await buildCodexSessionsIndex()).values()].flat()).toHaveLength(1);
271+
272+
await rm(sessionFile);
273+
274+
expect([...(await buildCodexSessionsIndex()).values()].flat()).toHaveLength(0);
275+
});
276+
277+
it('resolves a session file by id without re-parsing every transcript', async () => {
278+
// Filename intentionally does NOT contain the session id, so the lookup has
279+
// to fall through to the index rather than the cheap basename match.
280+
const dir = path.join(tempRoot, '.codex', 'sessions', '2026', '06', '09');
281+
await mkdir(dir, { recursive: true });
282+
const sessionFile = path.join(dir, 'rollout-opaque-name.jsonl');
283+
await writeFile(sessionFile, sessionLines({
284+
sessionId: 'sess-lookup',
285+
cwd: projectRoot,
286+
timestamp: '2026-06-09T11:00:00.000Z',
287+
userMessage: 'find me',
288+
}), 'utf8');
289+
290+
// Decoys the old implementation would also have parsed line by line.
291+
for (let i = 0; i < 5; i++) {
292+
await writeFile(path.join(dir, `rollout-decoy-${i}.jsonl`), sessionLines({
293+
sessionId: `decoy-${i}`,
294+
cwd: projectRoot,
295+
timestamp: '2026-06-09T10:00:00.000Z',
296+
userMessage: 'decoy',
297+
}), 'utf8');
298+
}
299+
300+
const { buildCodexSessionsIndex, getCodexSessionMessages } = await loadProjects();
301+
await buildCodexSessionsIndex();
302+
303+
const createReadStream = vi.spyOn(fsSync, 'createReadStream');
304+
const result = await getCodexSessionMessages('sess-lookup');
305+
306+
expect(result.messages.length).toBeGreaterThan(0);
307+
// Only the resolved transcript is read — not the five decoys.
308+
const readPaths = new Set(createReadStream.mock.calls.map((call) => String(call[0])));
309+
expect([...readPaths]).toEqual([sessionFile]);
310+
});
311+
312+
it('collapses concurrent scans onto a single pass', async () => {
313+
await writeRollout({ sessionId: 'sess-f', cwd: projectRoot });
314+
const { buildCodexSessionsIndex, resetCodexSessionFileCache } = await loadProjects();
315+
resetCodexSessionFileCache();
316+
317+
const createReadStream = vi.spyOn(fsSync, 'createReadStream');
318+
const [a, b, c] = await Promise.all([
319+
buildCodexSessionsIndex(),
320+
buildCodexSessionsIndex(),
321+
buildCodexSessionsIndex(),
322+
]);
323+
324+
expect(createReadStream).toHaveBeenCalledTimes(1);
325+
expect(a).toBe(b);
326+
expect(b).toBe(c);
327+
});
328+
});

server/claude-sdk.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { createRequestId, waitForToolApproval, resolveToolApproval as resolvePer
2828
import { buildMemoryBlock } from './utils/memoryPrompt.js';
2929
import { BTW_SYSTEM_PROMPT, buildBtwUserMessage } from './utils/btw.js';
3030
import { COMPUTE_GUARD_BLOCK } from './utils/computeGuardPrompt.js';
31+
import { debugLog } from './utils/logger.js';
3132

3233
const activeSessions = new Map();
3334
const pendingClaudeSessionIndexReconciles = new Map();
@@ -690,10 +691,13 @@ async function queryClaudeSDK(command, options = {}, ws) {
690691
mode: sessionMode || 'research'
691692
});
692693
} else {
693-
console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
694+
debugLog('claude', 'Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
694695
}
695696
} else {
696-
console.log('No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
697+
// Fires for every message in the stream once the session id is known.
698+
// Left unguarded this was thousands of console writes per turn, which
699+
// stalls the event loop on a Windows console TTY.
700+
debugLog('claude', 'No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
697701
}
698702

699703
// Track usage from assistant messages (per-API-call, not cumulative)

0 commit comments

Comments
 (0)