Skip to content

fix: paint dropped rows and preserve clusters across resizes - #148

Merged
taciturnaxolotl merged 1 commit into
mainfrom
fix/fuzz-renderer-resize-clusters
Aug 8, 2026
Merged

fix: paint dropped rows and preserve clusters across resizes#148
taciturnaxolotl merged 1 commit into
mainfrom
fix/fuzz-renderer-resize-clusters

Conversation

@taciturnaxolotl

@taciturnaxolotl taciturnaxolotl commented Jul 31, 2026

Copy link
Copy Markdown
Member

Why

The nightly conformance fuzzer (run 30611521363) caught the renderer dropping content:

  • FuzzRenderer/8e096e0ae397d854: after a fullscreen grow, rows added below the old height were never diffed, so content drawn into them never reached the terminal. The post-loop resize-and-copy then marked the content as on-screen, hiding the loss.
  • FuzzScreenShowsContent seeds ci: sync golangci-lint config #28–37: keycap clusters lost their VS16 and combining marks. printString split zero-width combining sequences into phantom width-0 cells that the renderer discards, and the x/ansi decoder's ASCII fast path returned 1 without checking for trailing combiners.

Local fuzzing found two more in the same family: terminals move the cursor on resize (the renderer's cursor model went stale), and a shrink makes terminals reflow in emulator-defined ways the incremental model cannot predict.

What changed

terminal_renderer.go — resize curbuf before diffing so every row of a grown screen is walked. Invalidate the cursor model in Resize, and force a full repaint on shrink, both scoped to the mode that can act on them:

  • The invalidation is for absolute-cursor mode only. In relative cursor mode -1 is moveCursor's first-move sentinel, not an unknown, so invalidating there asserts the origin rather than forgetting a position and every inline row after a resize lands low. Even a resize that changes nothing corrupts it, and TerminalScreen.Resize forwards every resize event.
  • The shrink repaint is for fullscreen only, where the renderer owns every cell it clears. Its guard was otherwise a superset of the inline partial clear below it, leaving that branch unreachable, and a full clear in inline mode erases rows the renderer does not own.

styled.go — re-decode ASCII fast-path bytes with FirstGraphemeCluster when a longer cluster actually starts there, gated on a >= 0xc0 lead byte so plain-ASCII draws skip the segmenter. The segmenter's width method now falls out of the same branch as the decoder instead of a type assertion, so a WidthMethod from outside the ansi package gets wcwidth for both rather than silently skipping the fold. A mark the re-decode cannot reach, because an escape sequence sits between it and its base as in "a\x1b[31ḿ", folds into the cell it belongs to; the zero-width accumulator previously let the next glyph clobber it.

Note this makes WcWidth mode grapheme-cluster an ASCII base with its combining marks, where before it emitted them as separate cells. That is a width-model change, and it matches what terminals do in both modes.

internal/conformance/fuzz_test.goFuzzScreenShowsContent counts each codepoint of a drift cluster rather than the cluster itself, since the emulators disagree about how one lands in cells (x/vt splits a keycap across two, so the bytes are present but not contiguous). Codepoints an emulator swallows outright are listed per oracle rather than tolerated for everyone: VS16 for both, the keycap mark for x/vt, which loses it on a fresh full paint too. Asserting only on the base codepoint would no longer fail when a renderer drops every combining mark, which is the bug this target exists to catch.

terminal_renderer_test.go, styled_test.go — regression tests for the grow, the inline cursor model, the inline shrink clear, and cluster folding. TestRendererSwitchBuffer's expectation is updated (strictly shorter output) with an explanatory comment. Benchmarks for the touched paths live next to their subjects; the resize benchmark builds its buffers up front so it measures the renderer and not ScreenBuffer.Resize.

Verification

  • Full test suite, conformance suite, go vet and golangci-lint across both modules: clean.

  • Conformance was run against a locally built libghostty-vt, not just CI. Tightening the assertion immediately failed seeds Write tests for the event decoder #34–37; probing a fresh full paint showed the cause is x/vt rather than the renderer, which is what the per-oracle drops list records:

    vt      gw=true   -> "111️⃣"
    ghostty gw=true   -> "1️⃣1️⃣1️⃣"
    ghostty gw=false  -> "1⃣1⃣1⃣"     (VS16 consumed, keycap mark kept)
    
  • Two-minute fuzz runs per target. FuzzRedrawResyncs clean at 650k execs. The other two found two new crashers, both skin-tone emoji, both reproducing before this branch, so they belong to the pre-existing class below and are not committed.

  • Benchmarks, interleaved min-of-8: plain-ASCII draws identical, cluster-heavy within a couple of percent on the mean. Resize-every-frame is slower than main, the cost of the shrink repaint and the grow fix, which previously did the wrong thing cheaply; at a realistic cadence of 1 resize per 100 frames the difference is not statistically detectable.

One pre-existing crasher class remains out of scope: skin-tone emoji width disagreement between WcWidth and ghostty-legacy's per-codepoint summing, already documented in internal/conformance/program.go.

💘 Generated with Crush

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 69.23077% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.39%. Comparing base (19049f2) to head (8897afb).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
styled.go 66.66% 5 Missing and 1 partial ⚠️
terminal_renderer.go 75.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #148      +/-   ##
==========================================
- Coverage   60.49%   60.39%   -0.11%     
==========================================
  Files          52       52              
  Lines        6751     6766      +15     
==========================================
+ Hits         4084     4086       +2     
- Misses       2391     2401      +10     
- Partials      276      279       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@taciturnaxolotl

Copy link
Copy Markdown
Member Author

this was pretty much fully autonomously done by kimi k3 with claude opus 5 reviewing; working against the fuzzer ended up working shockingly well

The conformance fuzzer caught the renderer dropping content in two
ways. After a fullscreen grow, rows added below the old height were
never diffed, so anything drawn into them never reached the terminal;
resize the model before diffing so every new row is walked. Combining
sequences (VS16, keycap marks) were split into phantom width-0 cells
the renderer then discarded; re-decode ASCII fast-path bytes with
FirstGraphemeCluster so a cluster stays in one cell, and fold a mark
separated from its base by an escape sequence into the cell it belongs
to rather than letting the next glyph clobber it.

Two related resynchronisation fixes: terminals move the cursor on any
resize, so invalidate the renderer's cursor model in Resize; and a
shrink makes terminals reflow in emulator-defined ways the incremental
model cannot predict, so force a full repaint. Both are scoped to the
mode that can act on them. In relative cursor mode -1 is the first-move
sentinel rather than an unknown, so invalidating there would assert the
origin instead of forgetting a position, and a full clear in inline mode
erases rows the renderer does not own; inline keeps its narrower partial
clear.

FuzzScreenShowsContent counts each codepoint of a drift cluster rather
than the cluster itself, since the emulators disagree about how one
lands in cells. Codepoints an emulator swallows outright are listed per
oracle: VS16 for both, the keycap mark for x/vt, which loses it on a
fresh full paint too. Adds benchmarks for the touched paths: plain and
cluster-heavy draws, steady frames, and resize renders.

💘 Generated with Crush

Assisted-by: Crush:kimi-k3
@taciturnaxolotl
taciturnaxolotl force-pushed the fix/fuzz-renderer-resize-clusters branch from 0c8481e to 8897afb Compare August 8, 2026 19:21
@taciturnaxolotl
taciturnaxolotl merged commit d38ea0f into main Aug 8, 2026
26 checks passed
@taciturnaxolotl
taciturnaxolotl deleted the fix/fuzz-renderer-resize-clusters branch August 8, 2026 19:28
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.

1 participant