fix: paint dropped rows and preserve clusters across resizes - #148
Merged
Conversation
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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 |
taciturnaxolotl
force-pushed
the
fix/fuzz-renderer-resize-clusters
branch
from
July 31, 2026 23:14
5b5577f to
0c8481e
Compare
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
force-pushed
the
fix/fuzz-renderer-resize-clusters
branch
from
August 8, 2026 19:21
0c8481e to
8897afb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The nightly conformance fuzzer (run 30611521363) caught the renderer dropping content:
printStringsplit zero-width combining sequences into phantom width-0 cells that the renderer discards, and the x/ansi decoder's ASCII fast path returned1without 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— resizecurbufbefore diffing so every row of a grown screen is walked. Invalidate the cursor model inResize, and force a full repaint on shrink, both scoped to the mode that can act on them:-1ismoveCursor'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, andTerminalScreen.Resizeforwards every resize event.styled.go— re-decode ASCII fast-path bytes withFirstGraphemeClusterwhen a longer cluster actually starts there, gated on a>= 0xc0lead 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 aWidthMethodfrom 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
WcWidthmode 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.go—FuzzScreenShowsContentcounts 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 notScreenBuffer.Resize.Verification
Full test suite, conformance suite,
go vetandgolangci-lintacross 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
dropslist records:Two-minute fuzz runs per target.
FuzzRedrawResyncsclean 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
WcWidthand ghostty-legacy's per-codepoint summing, already documented ininternal/conformance/program.go.💘 Generated with Crush