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)
This commit is contained in:
ddidderr committed 2026-10-12 13:24:12 +02:00
1 parent 580ed4d52d
commit d13dcc8a7f
19 files changed
+2033 -165

No files matched your search

+36 -25
View File
@@ -347,29 +347,33 @@ impl<H: Handler> Stream<H> {
debug_assert!(input.len() <= cp_buf.len());
let mut offset = 0;
while offset < input.len() {
// If the scalar UTF-8 decoder was in the middle of processing a
// code sequence, we continue until it's not.
if self.utf8decoder.in_sequence() {
self.next_utf8(input[offset])?;
offset += 1;
continue;
}
// If we're not in the ground state then we process until we are.
// This can happen if the last chunk of input put us in the middle
// of a control sequence.
if self.parser.state != State::Ground {
offset += self.consume_until_ground(&input[offset..])?;
continue;
// If the scalar UTF-8 decoder was in the middle of processing a code
// sequence, we continue until it's not.
while self.utf8decoder.in_sequence() {
if offset >= input.len() {
return Ok(());
}
self.next_utf8(input[offset])?;
offset += 1;
}
if offset >= input.len() {
return Ok(());
}
if input[offset] == 0x1B {
offset += self.consume_all_escapes(&input[offset..])?;
continue;
}
// If we're not in the ground state then we process until we are. This
// can happen if the last chunk of input put us in the middle of a
// control sequence.
offset += self.consume_until_ground(&input[offset..])?;
if offset >= input.len() {
return Ok(());
}
offset += self.consume_all_escapes(&input[offset..])?;
// Ground state: everything up to the next ESC is UTF-8.
// If we're in the ground state then we can use SIMD to process input
// until we see an ESC (0x1B), since all other characters up to that
// point are just UTF-8.
while self.parser.state == State::Ground && offset < input.len() {
let res = simd::utf8_decode_until_control_seq(&input[offset..], cp_buf);
for &cp in &cp_buf[..res.decoded] {
if cp <= 0xF {
@@ -378,18 +382,25 @@ impl<H: Handler> Stream<H> {
self.print(cp)?;
}
}
// Consume the bytes we just processed.
offset += res.consumed;
if offset >= input.len() {
break;
return Ok(());
}
// If our offset is NOT an escape then we must have a partial or
// invalid UTF-8 sequence. Pass that byte to the scalar decoder,
// which handles replacement and carries partial state over.
// If our offset is NOT an escape then we must have a partial
// UTF-8 sequence. In that case, we pass the whole remainder off
// to the scalar parser.
if input[offset] != 0x1B {
self.next_utf8(input[offset])?;
offset += 1;
for &c in &input[offset..] {
self.next_utf8(c)?;
}
return Ok(());
}
// Process control sequences until we run out.
offset += self.consume_all_escapes(&input[offset..])?;
}
Ok(())
}