Files
platform/todo/known-bugs/officer-blocks-its-event-loop-parsing-transcripts.md
T
pastilhasandClaude Opus 5 73a9b0bf51 officer blocks its event loop re-parsing a live 196 MB transcript
Chasing "Minesweeper is laggy" ended somewhere else entirely. Press-to-paint is
29ms median at x11vnc's socket and 29ms through the sidecar relay, but 35-200ms+
in the browser — so every bit of the variance is added by the one hop between,
which is officer.

Officer answering a trivial 401 on /api/health: median 1ms, p90 2ms, max 490ms.
summarizeTranscript full-reads and JSON.parses an entire transcript to build a
session-list row; timed against the largest here (196 MB, 70,137 entries) that
is 323ms to read plus 541ms to parse — 864ms of blocked loop per call. It is
cached on mtime, and a LIVE session appends every few seconds, so the cache
misses exactly when the agent is busy.

Filed rather than fixed: it is a hot path shared by every surface, and the right
shape (incremental parse keyed on size, versus moving summaries off the request
path) is an architecture call. The file already contains the argument for the
incremental version — "Claude only ever appends".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
2026-08-29 13:10:20 +01:00

3.0 KiB

id, title, theme, status, created, date_source, order
id title theme status created date_source order
known-bugs/officer-blocks-its-event-loop-parsing-transcripts Officer blocks its own event loop for ~0.9s re-parsing a live transcript, and everything it serves stalls with it known-bugs open 2026-08-29 stated 7

Found 2026-08-29 while chasing "Minesweeper is laggy today". It is not a Minesweeper bug. The game's display is relayed through officer, and officer periodically stops answering anything for up to a second.

The measurements, in order. Press-to-paint at x11vnc's own socket: median 29ms, max 35ms, stable even with a 1.5s idle gap before each click. Through the sidecar's own /ws relay: median 29ms, max 43ms — so the sidecar adds nothing. In the browser, where it is actually felt: 35ms to 200ms+, varying per click. All of the variance is introduced between the sidecar and the browser, and the only thing there is officer.

Officer answering a trivial unauthenticated 401 on /api/health, 30 samples: median 1ms, p90 2ms, max 490ms. The loop is fine until it is blocked outright.

What blocks it. summarizeTranscript (api/chat/claude-sessions.ts) full-reads and JSON.parses a whole transcript to build a session-list row. Timed against the largest on this machine — 196 MB, 70,137 entries: readFileSync 323ms, split + parse every line 541ms, 864ms blocking, per call.

It is cached, and the cache cannot help. The key is mtime, and the comment above it says why that was believed to be exact: "Claude only ever appends". But a live session appends every few seconds, so its mtime moves, the entry misses, and the whole 196 MB is re-read and re-parsed — for a row in a list. The busier the agent, the more often. 468 MB across 174 transcripts here, and the biggest one belongs to a commander that has been running all day.

Why it reads as a Minesweeper regression. Nothing in the plugin changed (last commit two days ago), and the same cost was invisible when that transcript was 10 MB and this took ~45ms. It scales with transcript size, so it arrives gradually and then all at once.

Scope is much wider than the game. Every HTTP request, every chat socket frame and every relayed plugin websocket shares that loop. The game is just the one surface where a 500ms stall is impossible to miss.

The fix the file argues for itself. "Claude only ever appends" is exactly what makes incremental summarisation correct: keep the running summary keyed on (path, size) and consume only the bytes appended since last time, instead of re-reading from zero. Turns ~864ms into the cost of the new tail. listClaudePwds already does a bounded head read for cwd (claude-sessions.ts, "a bounded head read"), so the precedent for not reading the whole file is in the same module.

Not attempted here: it is a hot path shared by every surface, and the right shape — incremental parse versus moving summaries out of the request path entirely — is an architecture call rather than a patch.