Image tool_results store base64 twice in the transcript and get re-measured every render frame: multi-MB lines hard-freeze sessions (100% CPU)
Environment
- Claude Code 2.1.215 and 2.1.217 (npm bundle)
- Runtime: community launcher claude-on-node
(runs the npm bundle under Node 24 on CPUs where the Bun single-file binary
SIGILLs; Bun.stringWidth is polyfilled with string-width@4). This
amplifies per-call cost vs native Bun, but see "Related" for reports of the
same freeze shape on native runtimes.
- Hardware where measured: 2-core 2 GHz x86-64 (Core 2 era)
- OS: Linux 7.1.4 (Gentoo)
Symptom
Resuming a session whose transcript contains a few multi-MB image
tool_results hard-freezes the TUI: main thread pinned at 100% CPU
indefinitely, zero disk I/O, no TCP connection to the API (the pending turn's
request is never sent), SIGINT/SIGTERM have no effect (their handlers never
run on the starved event loop), only SIGKILL works.
Data shape that triggers it
One Read of a ~5 MB screenshot PNG produces ONE jsonl line of ~10 MB,
because the base64 payload is stored twice per line:
- as the
tool_resultimage block undermessage.content[].content[]
(source.data), and
- as a full duplicate under the same line's
toolUseResult.file.base64
mirror field.
The affected session: 551 lines, 21.8 MB total, three ~5 MB lines plus five
more between 0.3 and 1.8 MB, all from screenshot Reads.
What the process does while frozen
Sampled V8 stacks (SIGUSR1 inspector, Debugger.pause, repeated samples,
stable across minutes and across two independent sessions):
processImmediate
-> Ink render
-> reconciler
-> layout width measurement helper (minified `Nt`)
-> Bun.stringWidth(<multi-MB transcript string>, {ambiguousIsNarrow:true})
The layout path re-measures transcript strings on every render frame. With
multi-MB strings a single frame takes seconds (in our Node polyfill a single
width call on a 5.1 MB line costs ~0.3 s; native Bun.stringWidth is faster,
but the per-frame cost stays O(total transcript payload)), the render loop
immediately schedules the next frame via setImmediate, and the event loop
starves: input is dead and the next API request is never dispatched.
Validated mitigation (data-level workaround)
Rewriting the session file to replace the base64 payloads with small stubs
(image block replaced by a short text note; toolUseResult.file.base64
replaced by a 1x1 PNG) shrank the file 21.8 MB -> 1.6 MB and instantly
restored a fully usable session (same launcher, same machine). That suggests
three upstream fixes, roughly in order of leverage:
- Do not duplicate image bytes into
toolUseResult(halves the on-disk and
render payload for free).
- Elide or clamp huge payloads from the render tree; render a placeholder
instead. Base64 is not human-readable, so nothing is lost visually.
- Truncation-with-metadata for oversized tool_results, as already proposed
in #16251.
Second trigger, same mechanism: Edit on files with multi-MB single lines
After slimming the transcript, the same session froze again the moment it
used the Edit tool on an 8.2 MB HTML file that contains one 6.7 MB line (an
embedded data block). Two failed Edits (small error results) rendered fine;
the first successful Edit froze the TUI before its tool_result ever reached
the session file. The Edit result's structuredPatch carries the changed
line twice (old and new), so the render tree suddenly contains two ~6.7 MB
strings, and a 3 s CPU profile during the freeze shows >99% of self time in
width measurement called from the Ink layout path, frame after frame.
Suggested fix here: clamp structuredPatch hunk lines to a display length
(the full content is on disk anyway).
Repro
- In a session,
Reada ~5 MB PNG (e.g. a full-page Chrome screenshot). - Work for a few more turns.
- Resume the session. On slow CPUs the freeze is immediate and permanent;
related reports show the same shape on fast machines at larger scale.
Alternative repro without images: run Edit with a small change on a file
that has a single multi-MB line (e.g. minified HTML with an embedded data
blob) and watch the diff render freeze the TUI.
Related
- #16251 (closed as not planned; same class with 50-100 KB lines)
- #19036 (closed as duplicate of #16251; resume hang with large tool outputs)
- #21567 (open; terminal renderer full-rewrite spin at 100% CPU)
- #51560 (main thread tight loop, 100% CPU, API connections lost)
New in this report relative to those: the exact per-frame width-measurement
stack, the 2x base64 duplication in the session file, the image-payload
specifics, and a validated data-level mitigation.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗