Skip to content

fix(perf): stop re-parsing the whole Codex session history on every refresh - #212

Merged
bbsngg merged 1 commit into
mainfrom
fix/perf-session-index-stalls
Aug 9, 2026
Merged

fix(perf): stop re-parsing the whole Codex session history on every refresh#212
bbsngg merged 1 commit into
mainfrom
fix/perf-session-index-stalls

Conversation

@davidliuk

Copy link
Copy Markdown
Collaborator

The reports

请问 大家有变卡的情况吗
dr claw卡顿的情况 就是要loading很久 然后创建不了新的session这样
我也这样就是不输出了然后一直在计时
看powershell里面就是卡住了
我还以为是我网卡了

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 ever grows — on a regular user's machine 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 wall clock inside a scan.

That explains why it felt intermittent and why it got worse over time: the cost is linear in total bytes ever written, and the 30s cooldown meant some requests were instant while others paid the full scan.

Measured, against that same 10 GB directory

before after
First getProjects() 19,991 ms 543 ms
Repeat Codex scan (every 30s) ~20–30 s, awaited on the request path 12 ms, off the request path

Changes

server/utils/jsonlTailReader.js (new) — a byte-accurate incremental reader for append-only JSONL. Session transcripts are append-only, so their derived metadata is a pure fold over lines; the reader reports the offset just past the last complete line, so a scan can resume rather than re-read. readline can't do this (it yields lines, not offsets), so this uses StringDecoder + per-line Buffer.byteLength to survive multi-byte UTF-8 across chunk boundaries, CRLF, and unterminated trailing lines.

Memoized Codex index — per file, keyed on (ino, size, mtimeMs). Resumes the fold from the cached offset on strict size growth; re-parses fully on anything else (rewrite, truncation, same-size rewrite). Concurrent scans collapse onto one pass.

Session open / delete no longer full-scan — both re-parsed the entire directory to map a session id to a file. They now use the memoized index, with a session_meta header scan as a fallback for transcripts the project index can't hold (sessions with no cwd). The header id is also verified before returning a filename match, so deleting a session can no longer unlink a different transcript whose name merely contains the id.

Discovery sync moved off the request path — runs in the background and publishes via a projects-changed event; /api/projects answers from the database, which is what it was already doing.

Hot-path logging gated behind DRCLAW_DEBUG — the Codex and Claude stream loops logged once per SDK event (the Codex one fired even for item.updated events that are discarded immediately after). Node writes to a Windows console TTY synchronously, so a console left in QuickEdit selection mode — one stray click in the PowerShell window — stops draining and blocks the write, and with it the event loop, indefinitely. That matches "不输出了然后一直在计时" and "powershell里面就是卡住了" precisely. Re-enable with DRCLAW_DEBUG=1 or DRCLAW_DEBUG=codex,claude.

Watcher payload serialized once instead of twice per event.

Testing

  • 20 new tests: byte-offset accuracy (UTF-8, CRLF, blank lines, partial tails), incremental-vs-full-parse equivalence, same-size rewrite, truncation, unterminated final record, cache eviction, concurrent scans, delete targeting.
  • Full suite: 132 passed. npm run typecheck and npm run build clean.
  • Verified against the real 10 GB session directory (numbers above).
  • Server boots and stays responsive; health probes ~3 ms during a cold background scan.
  • Reviewed independently with Codex, which found 4 correctness issues in the first draft (same-size rewrite treated as append, unterminated final record dropped, broadcast missing session-level changes, cwd-less sessions unreachable) plus 1 latent delete hazard. All 5 are fixed and covered by tests; the delete test was confirmed to fail without its guard.

Not addressed here

test/codex-discovery.test.mjs hangs on main as well (it sets CODEX_CLI_PATH to the node binary, which then waits on stdin). Pre-existing and not in CI — left alone.

…efresh

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>
Copilot AI lite review requested due to automatic review settings August 5, 2026 18:17
Comment thread server/projects.js
// Get messages for a specific Codex session
async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
try {
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses severe UI/server lag caused by repeatedly re-reading and JSON.parse-ing the entire Codex session history on a 30s cadence, and also mitigates event-loop stalls from hot-path console logging (notably on Windows TTYs).

Changes:

  • Added an incremental, byte-offset JSONL tail reader and used it to memoize Codex session indexing across scans (with concurrency collapse and bounded cache).
  • Moved Codex discovery sync off the /api/projects request path and added a background “projects changed” event to trigger client refreshes only when the project set materially changes.
  • Gated per-event provider stream logging behind DRCLAW_DEBUG scopes and reduced watcher broadcast overhead by serializing payloads once.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
server/utils/logger.js Adds scoped verbose logging gate (DRCLAW_DEBUG) and a debugLog() helper to avoid hot-path console stalls.
server/utils/jsonlTailReader.js Introduces byte-accurate incremental JSONL reader with offsets/trailing-line handling for append-only logs.
server/utils/tests/logger.test.js Adds unit tests covering default silence, enable-all, and scoped verbose logging behavior.
server/utils/tests/jsonlTailReader.test.js Adds tests for offset accuracy, CRLF/UTF-8, trailing partial lines, and yielding behavior.
server/projects.js Implements memoized Codex session index w/ incremental reads; background sync & change signatures; safer session-file resolution for open/delete.
server/openai-codex.js Gates noisy per-event Codex stream logging behind the new logger controls.
server/index.js Reuses a debounced projects broadcast for background discovery and avoids double JSON serialization per watcher event.
server/claude-sdk.js Gates noisy per-event Claude stream logging behind the new logger controls.
server/tests/codex-session-index-cache.test.mjs Regression suite validating caching behavior, incremental-vs-full equivalence, and delete targeting safety.
Suppressed comments (1)

server/projects.js:4537

  • For destructive deletes, consider resolving the transcript path with strict header verification (e.g. findCodexSessionFilePath(sessionId, { requireHeaderMatch: true })) so a filename substring match cannot delete an unrelated transcript if the header scan fails.
    const matchedFilePath = await findCodexSessionFilePath(sessionId);
    if (matchedFilePath && jsonlFiles.includes(matchedFilePath)) {
      await fs.unlink(matchedFilePath);

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/projects.js
Comment on lines +4186 to +4199
await readJsonlLinesFrom(filePath, 0, (line) => {
if (sessionId || seen >= CODEX_HEADER_SCAN_LINES) {
return;
}
seen += 1;
try {
const entry = JSON.parse(line);
if (entry?.type === 'session_meta' && entry.payload?.id) {
sessionId = entry.payload.id;
}
} catch (_) {
// Skip malformed lines
}
});
Comment thread server/projects.js
Comment on lines +4221 to +4240
async function findCodexSessionFilePath(sessionId) {
if (!sessionId) {
return null;
}

return null;
};
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);

const sessionFilePath = await findSessionFileByMetadata();
for (const filePath of jsonlFiles) {
if (!path.basename(filePath).includes(sessionId)) {
continue;
}
// A filename can merely *contain* the id, and callers act destructively on
// what we return (deleteCodexSession unlinks it). Confirm against the
// session_meta header, which costs a handful of lines. A transcript whose
// header cannot be read is still accepted, matching the long-standing
// filename-only behaviour; only a definite mismatch is rejected.
const headerId = await readCodexSessionIdFromHeader(filePath);
if (headerId === null || headerId === sessionId) {
return filePath;
Comment on lines +68 to +72
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
if (line.trim()) {
onLine(line);
lineCount += 1;
}
@bbsngg
bbsngg merged commit 4b35756 into main Aug 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants