Bug report: Ink rendering corruption during heavy text streaming — cumulative frame buffer contamination
Bug report: Ink rendering corruption during heavy text streaming — cumulative frame buffer contamination
We investigated a persistent rendering corruption bug in Claude Code's terminal output. The corruption manifests as visual glyph-level rendering breakage (not character encoding issues) during heavy text processing. Analysis was performed against v2.1.88.
---
Symptoms
- Rendering corruption during heavy text output — A single glyph block renders incorrectly (visual/drawing corruption, not wrong codepoints). Occurs most frequently during long streaming responses
- Terminal resize temporarily fixes it — Slightly changing the terminal width resets the corruption. This is a known user workaround
- Corruption persists and escalates — Once noise enters the session, subsequent rendering becomes increasingly fragile. The session never fully recovers
---
Root cause analysis
Why resize fixes it — the key clue
Terminal resize triggers two reset paths that normal rendering never invokes:
- Full screen clear and repaint: When viewport width changes, the renderer issues a full terminal clear (
ESC[2J+ESC[3J+CSI H) and repaints from scratch, bypassing the incremental diff pipeline entirely.
- Frame buffer replacement: The resize handler replaces both front and back frame buffers with freshly created blank screens and sets a contamination flag that forces a full repaint on the next frame, bypassing all diff/blit logic.
This tells us: the corruption lives in the diff rendering pipeline's internal state — specifically in the previous frame's screen data and/or style transition caches that are used during normal incremental rendering but bypassed during a full repaint.
---
Level 1: Frame buffer contamination via DECSTBM scroll — most likely cause
The DECSTBM scroll optimization mutates the previous frame's screen buffer in-place by shifting rows to match the terminal's scroll region movement. The intent is to keep the previous screen in sync with what the terminal actually shows after a DECSTBM scroll, so the subsequent cell-by-cell diff produces minimal output.
The problem: The mutated previous screen belongs to the front frame buffer. After the diff is computed, the frame lifecycle swaps buffers — the mutated screen moves to the back position, where it is reused as the write target for the next render cycle. When the blit operation copies unchanged cells from the new front buffer onto the contaminated back buffer, cell data from misaligned rows can bleed through.
During streaming, DECSTBM scroll fires on nearly every frame (10-50 SSE events/sec triggers continuous scrolling). Each in-place row shift compounds on the previous one. The contamination accumulates until rendering becomes visibly corrupt.
Evidence: The resize handler is the only code path that replaces both frame buffers with fresh screens. Normal rendering always reuses the previous frame's screen as the next frame's write target. This is why corruption persists until resize — nothing else clears the contaminated back buffer.
Fix: Clone the screen's row data before applying the row shift, so the mutation is used only for computing the diff patch and does not persist into the next frame's write target.
---
Level 2: Style transition cache key collision — latent bug, escalation factor
The style pool's transition caching mechanism stores ANSI escape sequences for transitioning between two styles. The cache key is computed by packing two style IDs into a single number using fixed-width multiplication (conceptually fromId * N + toId with a fixed N).
Style IDs are generated by the style interning mechanism with a bit-shift encoding. When the number of unique styles exceeds ~524K, the packed key overflows and different style pairs map to the same key. The cached transition string for one pair silently overwrites or is returned for another.
Critically, the style pool is never reset during a session. The pool reset logic only resets the character pool and hyperlink pool — the style pool and its transition cache live for the entire session with no eviction or size limit. As unique styles accumulate (syntax highlighting × hyperlinks × decorations), the collision probability steadily increases.
This explains "once corrupted, stays corrupted": even after a resize clears the screen and repaints from scratch, the poisoned transition cache entry persists. The next time the colliding style pair is encountered, the wrong ANSI transition sequence is emitted again.
Practical reachability: Whether 524K unique styles are reached in a single session depends on the diversity of styled content. In long sessions with heavy syntax-highlighted code output, this is plausible but unconfirmed.
Fix: Replace the numeric key encoding with a collision-free alternative, such as a string key. The performance impact is negligible since the cache is hit once per unique style pair.
---
Level 3: Additional contributing factors
Multi-codepoint emoji viewport edge miscalculation
When rendering wide characters (width = 2) near the right edge of the viewport, the renderer applies a stricter boundary check for multi-codepoint graphemes (flag emoji, ZWJ sequences) than for single-codepoint wide characters (CJK). The stricter threshold causes a valid cell at position vw - 2 — which fits perfectly in the last two columns — to be incorrectly skipped. The previous frame's content remains at that position, and subsequent diffs make the same skip decision, so the stale cell becomes permanent.
Fix: Apply the same viewport edge threshold for all wide characters regardless of codepoint count, or re-examine whether the stricter threshold is truly necessary.
Style segment desync in wrapped text
The post-wrap style reapplication logic uses a heuristic to skip whitespace characters in the original text and keep a character index synchronized with the wrapped output. The heuristic compares the current character against the next line's first character to decide when to stop skipping. This breaks when the next line starts with a whitespace character (premature stop → under-advance) or when the next line is empty (no stop condition → over-advance). Either way, the wrong style segment is referenced, causing one block of text to render with the adjacent segment's style.
Cache cliff-edge eviction
The character cache (which stores tokenized + clustered representations of text lines) is cleared in its entirety when it exceeds a fixed threshold (~16K entries). The next frame must re-tokenize and re-cluster every visible line, causing a CPU spike and frame delay. Not a direct corruption cause, but the frame timing disruption amplifies the scroll contamination (Level 1) by increasing the chance of stale data surviving across frames.
---
Environment
- Claude Code: v2.1.88
- OS: macOS (Darwin 25.3.0, Apple Silicon)
- Terminal: various (reproduction is terminal-independent)
The source map leak may have been an unfortunate accident — but it would be nice if, looking back, both sides could say it became an opportunity for engineers around the world to contribute to a better product.
---
<details>
<summary><strong>Investigation notes</strong> — full analysis process (hypotheses, methodology, findings)</summary>
Investigation: Hypotheses
- Diff rendering internal state is corrupted — Ink is a React-based TUI framework that outputs only the diff from the previous frame. The internal state used for diff calculation (caches, previous frame buffer) is somehow corrupted, and the corruption propagates to subsequent frames
- Resize triggers a special reset that normal rendering doesn't — The cleanup target of the resize handler is the corruption itself
- Large text volume is the trigger — Cache bloat, style ID exhaustion, buffer threshold overflow — problems that depend on quantity
Investigation: Methodology
The Ink rendering subsystem (~96 source modules) was investigated along three axes in parallel:
- Axis A: Output & rendering pipeline — output buffering, screen diffing, log-update logic, screen/style pool management, terminal I/O
- Axis B: Text measurement & ANSI parsing — text measurement, string width calculation, text wrapping, ANSI tokenization and parsing
- Axis C: Resize & frame management — the main Ink instance lifecycle, frame scheduling, node-to-output rendering, DOM management, React reconciler
Investigation: Key findings
Finding 1 — Resize reset mechanism
Two paths are triggered on resize. The critical one replaces both front and back frame buffers with blank screens and sets a contamination flag. This is the only code path that replaces both frame buffers — normal rendering always reuses the previous frame's screen as the next frame's write target.
Finding 2 — Style pool lifetime
The pool reset logic resets the character pool and hyperlink pool but not the style pool. The style pool — including its transition cache — lives for the entire session with no eviction or size limit.
Finding 3 — DECSTBM scroll optimization mutates the previous screen
The scroll optimization destructively shifts rows in the previous screen. This screen belongs to the front frame buffer, which moves to the back position on the next frame — where it is reused as the write target for the next render.
Finding 4 — Character cache cliff-edge eviction
The character cache is cleared entirely when exceeding a fixed threshold, forcing a full re-tokenize on the next frame and causing a CPU spike.
Finding 5 — Multi-codepoint emoji edge miscalculation
Flag emoji and ZWJ sequences use a stricter viewport edge threshold than CJK characters, causing valid cells near the right edge to be incorrectly skipped.
Finding 6 — Style segment desync in wrapped text
The whitespace-skipping heuristic for character index synchronization after text wrapping has edge cases where the index under-advances or over-advances, causing style segments to shift by one block.
Investigation: Ruled out
- ANSI tokenizer — OSC/CSI terminator detection logic is correct
- Line width cache — eviction does not produce stale data
- String width calculation — consistent across implementations for major character classes
- SGR parsing — no issues found
</details>
This issue has 5 comments on GitHub. Read the full discussion on GitHub ↗