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)
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
productionprofile vs Zig ReleaseFast).
Divergences found and fixed
- 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 + utfcppreplace_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. Rewroteutf8_decode_until_control_seqandnext_slice_cappedto mirror C++/Zig. Tests:simd::tests::*,stream_readonly::tests::difftest_invalid_utf8_*. PageList::erase_rowsstale row flags (ghostty-terminal/src/page_list.rs). Zig swaps surviving rows into place and only clears the leftover rows' cells, so e.g.wrapstays 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.- Grapheme move on wrap with left/right margins (
terminal.rsprint_grapheme). Zig moves the grapheme from the originalprev.cellpointer; Rust usedold_pin = new_pin.up(1)withx = right_limit - 1, which is a different cell when margins stop the wrap from scrolling the previous cell (stream returnedInvalidCoordinate). 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)
- Stale cell pointer after page growth in the grapheme-attach path
(
src/terminal/Terminal.zig:482setsprev.cell = cursor.page_cell, thenprintCellof the spacer tail can callincreaseCapacity(hyperlink/string bytes) which reallocates the page, and:539appendGrapheme(prev.cell, c)writes through the dangling pointer; the grapheme is lost). Repro (5x30, scrollback 1): the 107-byte minimized stream instream_readonly::tests::difftest_upstream_uaf_grapheme_survives_page_growth(a hyperlinked cell thenU+2764 U+FE0Fwrapping at the edge). Rust keeps the grapheme. Harness: mismatches where Zig loggedadjusting page capacityare saved assuspect*and not counted as divergences (seezig_run). - Zig aborts / segfaults (not compared): the
assert(!graphemeBreak(...))inTerminal.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, testdifftest_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.