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:
1 parent
6def1f3b5d
commit
4eb03f637d
19 files changed
+2033
-165
No files matched your search
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user