Files
ghostty-rs/docs/rewrite/notes/difftest-DF.md
T
ddidderr 4eb03f637d test: differential fuzzing of the terminal core against Ghostty's Zig code
Adds tools/difftest: a Zig driver built against a copy of Ghostty
1.3.1's own src/terminal (Zig 0.15.2, set up under target/ by
tools/difftest/setup.sh) and a Rust twin on ghostty-terminal, both
printing a canonical dump of every scrollback/active row (cells,
graphemes, styles, hyperlinks, row flags), cursor, modes, regions,
tabstops and colors. A seeded generator produces escape-heavy streams
(UTF-8/wide/combining/emoji, C0, CSI with edge params, SGR, modes, ESC,
OSC, DCS, margins, insert/delete); `just difftest` runs it with Zig
assertions on (ReleaseSafe vs debug Rust) and off (ReleaseFast vs
production Rust), with minimizers and a triage script.

Divergences found and fixed in Rust (each with a regression test):
- ill-formed UTF-8 decoding follows the SIMD path a default Ghostty
  build uses (not Zig's scalar fallback)
- PageList::erase_rows keeps Zig's row-flag behavior on regrow
- grapheme move on wrap with left/right margins uses the original cell

Upstream bugs found and deliberately NOT replicated (per the project
rule): a stale cell pointer after page growth in Terminal.zig:482/539
that drops a grapheme, plus several Zig assertion failures/segfaults;
documented with minimized repros in docs/rewrite/notes/difftest-DF.md.

Final run: 390k cases across many geometries, 0 divergences (Zig-abort
cases, ~0.1-0.5%, are not compared).

Test Plan:
- just difftest 20000 "1 2 3"
- cargo test -p ghostty-terminal (regression tests)
2026-10-12 13:24:12 +02:00

7.0 KiB

Differential fuzzing of the terminal core (agent DF)

Zig (Ghostty 1.3.1, the reference) vs Rust (ghostty-terminal + ghostty-vt read-only stream), on random escape-heavy byte streams.

Layout

Path What
tools/difftest/setup.sh Idempotent setup: Zig 0.15.2 -> target/zig-0.15.2/, a COPY of the Zig source -> target/zig-src/, builds the Zig driver (ReleaseSafe -> target/difftest-zig/, ReleaseFast -> target/difftest-zig-fast/). Nothing in /tmp, reference tree untouched.
tools/difftest/zig/ Zig driver (build.zig, src/main.zig), depends on the source copy via a path dependency and the ghostty-vt module (SIMD enabled, like a default Ghostty build).
tools/difftest/src/lib.rs, bin/difftest_rs.rs Rust twin: difftest_rs <cols> <rows> <max_scrollback> <file>; lib.rs::dump is the canonical dump (must stay byte-identical to the Zig one).
tools/difftest/src/inputgen.rs Deterministic (splitmix64) escape-heavy generator: ASCII/UTF-8/wide/combining/emoji/ZWJ/invalid UTF-8/C1 bytes, C0 controls, CSI with edge-value params and intermediates, SGR (incl. colon forms), DECSET/DECRST/XTSAVE/XTRESTORE/DECRQM, ESC sequences, charsets, OSC (colors, hyperlinks, pwd, 133...), DCS, APC, tabs, scroll regions + left/right margins, IL/DL/ICH/DCH/ECH/REP, alt screen, resets, byte mutations. Geometry (1x1 .. 300x3) and scrollback (0..100000) vary per case.
tools/difftest/src/bin/difftest_fuzz.rs Harness: run (N cases, threads, seed, --size CxR, --sb, --tokens, --zig PATH), check, min (ddmin minimiser on divergence), minz/minr (minimise an abort).
tools/difftest/triage.sh Minimise every saved failure and print the first differing lines.
just difftest [CASES] [SEEDS] Setup + run in both assertion modes.

Failures are saved in target/difftest-work/fails-<seed>/ (.bin, .zig.txt, .rs.txt).

Dump format

Per screen (primary + alternate if it exists): cursor (x, y, pending wrap, protected, style, hyperlink flag), saved cursor, charset state, protected mode, kitty keyboard flags, every row of scrollback+active (row flags wrap / wrap_continuation / semantic_prompt / kitty placeholder; every non-blank cell: content tag, codepoint(s) + grapheme tail, wide, protected/hyperlink flags, semantic content, full style, hyperlink URI + id). Terminal level: size, active screen, scrolling region, all mode values + saved modes, tabstops, mouse/shell flags, status display, mouse shape, pwd, previous char, dynamic colors, modified palette entries, plus the formatter's plain text (plainString).

Comparison rules

  • Identical dump = pass.
  • If the Zig driver aborts (failed assertion / segfault in Zig code, e.g. Devanagari conjunct at the right edge with left/right margins, REP with a huge count while a page grows under a hyperlink, grapheme-break assertion in Terminal.print) there is no defined behaviour to compare: counted under "zig aborted" and not a divergence.
  • A Rust panic where Zig succeeded is a divergence.
  • Two passes: assertions on (Rust debug vs Zig ReleaseSafe) and assertions off (Rust production profile vs Zig ReleaseFast).

Divergences found and fixed

  1. UTF-8 decoding of ill-formed input (ghostty-vt/src/simd.rs, stream.rs). The Rust port modelled Zig's scalar fallback (utf8DecodeUntilControlSeqScalar), but a default Ghostty build runs the C++ SIMD path (vt.cpp: simdutf + utfcpp replace_invalid + trim_partial_utf8). Differences: ONE U+FFFD per invalid sequence with all following continuation bytes swallowed (c0 a9, e4 a9 f0, ...); decoding of everything before an ESC without deferring truncated tails; byte-value based tail trimming when no ESC; and the remaining tail (not just one byte) goes to the scalar decoder. Rewrote utf8_decode_until_control_seq and next_slice_capped to mirror C++/Zig. Tests: simd::tests::*, stream_readonly::tests::difftest_invalid_utf8_*.
  2. PageList::erase_rows stale row flags (ghostty-terminal/src/page_list.rs). Zig swaps surviving rows into place and only clears the leftover rows' cells, so e.g. wrap stays set on rows that later become visible again when the page regrows (CSI 3 J then more output). Rust rotated and cleared. Now swaps exactly like Zig for erases starting at the top of a page. Test: difftest_erase_scrollback_keeps_stale_row_flags.
  3. Grapheme move on wrap with left/right margins (terminal.rs print_grapheme). Zig moves the grapheme from the original prev.cell pointer; Rust used old_pin = new_pin.up(1) with x = right_limit - 1, which is a different cell when margins stop the wrap from scrolling the previous cell (stream returned InvalidCoordinate). Now remembers the original cell index. Test: difftest_grapheme_wrap_with_margins_moves_prev_cell.

Upstream bugs found (Zig memory-safety / UB; NOT replicated in Rust)

  1. Stale cell pointer after page growth in the grapheme-attach path (src/terminal/Terminal.zig:482 sets prev.cell = cursor.page_cell, then printCell of the spacer tail can call increaseCapacity (hyperlink/string bytes) which reallocates the page, and :539 appendGrapheme(prev.cell, c) writes through the dangling pointer; the grapheme is lost). Repro (5x30, scrollback 1): the 107-byte minimized stream in stream_readonly::tests::difftest_upstream_uaf_grapheme_survives_page_growth (a hyperlinked cell then U+2764 U+FE0F wrapping at the edge). Rust keeps the grapheme. Harness: mismatches where Zig logged adjusting page capacity are saved as suspect* and not counted as divergences (see zig_run).
  2. Zig aborts / segfaults (not compared): the assert(!graphemeBreak(...)) in Terminal.print (Terminal.zig:375) after mode 2027 with some U+2028/U+2060/BOM sequences; assert(y < size.rows) (page.zig:1012) and segfaults for Devanagari conjuncts at the right edge with left/right margins; assert(!cell.hyperlink) (Terminal.zig:834) / segfault on REP with a huge count while a hyperlinked page grows. Repro e.g. 20x8: ESC[?2027h ESC[?69h ESC[;10s ESC[50;41H + U+0915 U+094D U+0937 (Zig faults; Rust now handles it, test difftest_grapheme_wrap_with_margins_moves_prev_cell).

Known Zig-side aborts (not Rust bugs)

See "Comparison rules". Roughly 0.1-0.4 % of generated cases (mostly the Devanagari/margins one, which the generator hits on purpose) abort in Zig.

Final run

Final campaign (after all fixes): 10 geometries x 20k (assertions on), 5 x 20k (assertions off, production Rust vs ReleaseFast Zig), 3 x 30k mixed geometries = 390k cases, 0 divergences, 2 suspects (both the upstream UAF above, 0.1-0.5 % of cases abort in Zig and are skipped). Not done: replaying Zig test-suite inputs (those tests are already ported as Rust unit tests).

cargo test --workspace --no-fail-fast: everything passes except ghostty-rs --test gtk_replay (needs a GPU/Vulkan: ZINK: vkEnumeratePhysicalDevices failed), an environment limitation unrelated to this work.