From d13dcc8a7f729c8bc2390388312f0591f4e63cce Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 13:24:12 +0200 Subject: [PATCH] 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) --- Cargo.lock | 9 + Cargo.toml | 2 +- crates/ghostty-terminal/src/page_list.rs | 24 +- .../ghostty-terminal/src/stream_readonly.rs | 90 +++ crates/ghostty-terminal/src/terminal.rs | 16 +- crates/ghostty-vt/src/simd.rs | 314 +++++---- crates/ghostty-vt/src/stream.rs | 61 +- docs/rewrite/notes/difftest-DF.md | 107 +++ justfile | 20 + tools/difftest/Cargo.toml | 14 + tools/difftest/setup.sh | 39 ++ tools/difftest/src/bin/difftest_fuzz.rs | 307 ++++++++ tools/difftest/src/bin/difftest_rs.rs | 18 + tools/difftest/src/inputgen.rs | 655 ++++++++++++++++++ tools/difftest/src/lib.rs | 294 ++++++++ tools/difftest/triage.sh | 11 + tools/difftest/zig/build.zig | 17 + tools/difftest/zig/build.zig.zon | 12 + tools/difftest/zig/src/main.zig | 188 +++++ 19 files changed, 2033 insertions(+), 165 deletions(-) create mode 100644 docs/rewrite/notes/difftest-DF.md create mode 100644 tools/difftest/Cargo.toml create mode 100755 tools/difftest/setup.sh create mode 100644 tools/difftest/src/bin/difftest_fuzz.rs create mode 100644 tools/difftest/src/bin/difftest_rs.rs create mode 100644 tools/difftest/src/inputgen.rs create mode 100644 tools/difftest/src/lib.rs create mode 100755 tools/difftest/triage.sh create mode 100644 tools/difftest/zig/build.zig create mode 100644 tools/difftest/zig/build.zig.zon create mode 100644 tools/difftest/zig/src/main.zig diff --git a/Cargo.lock b/Cargo.lock index 0e5d897..f4ab1ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1519,6 +1519,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "ghostty-difftest" +version = "0.1.0" +dependencies = [ + "ghostty-terminal", + "ghostty-unicode", + "ghostty-vt", +] + [[package]] name = "ghostty-font" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f8800ba..406fe2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ workspace = true [workspace] resolver = "3" -members = [".", "crates/*"] +members = [".", "crates/*", "tools/difftest"] # The upstream Ghostty version this rewrite tracks (Zig: `build.zig.zon` # `.version`); consumed by `crates/ghostty-cli/build.rs`. diff --git a/crates/ghostty-terminal/src/page_list.rs b/crates/ghostty-terminal/src/page_list.rs index 2591284..926597e 100644 --- a/crates/ghostty-terminal/src/page_list.rs +++ b/crates/ghostty-terminal/src/page_list.rs @@ -1094,10 +1094,28 @@ impl PageList { page.size.rows = 0; } else { let page = &mut self.node_mut(id).ok_or(Error::InvalidId)?.data; - for y in a..b { - page.clear_cells(y, 0, usize::from(page.size.cols), false)?; + if a == 0 { + // Zig (`eraseRows`) SWAPS the surviving rows into place + // rather than rotating, then clears the cells of the + // rows left over past the new size. Row metadata (e.g. + // the `wrap` flag) of those leftover rows is NOT reset + // and becomes visible again when the page regrows into + // them, so the exact arrangement must match. + let cols = usize::from(page.size.cols); + let scroll_amount = rows - b; + for i in 0..scroll_amount { + page.rows.swap(i, i + b); + page.rows[i].set_dirty(true); + } + for y in scroll_amount..rows { + page.clear_cells(y, 0, cols, false)?; + } + } else { + for y in a..b { + page.clear_cells(y, 0, usize::from(page.size.cols), false)?; + } + page.rows[a..rows].rotate_left(b - a); } - page.rows[a..rows].rotate_left(b - a); page.size.rows -= (b - a) as u16; for row in &mut page.rows[a..usize::from(page.size.rows)] { row.set_dirty(true); diff --git a/crates/ghostty-terminal/src/stream_readonly.rs b/crates/ghostty-terminal/src/stream_readonly.rs index 30c0aba..5b274a9 100644 --- a/crates/ghostty-terminal/src/stream_readonly.rs +++ b/crates/ghostty-terminal/src/stream_readonly.rs @@ -1022,4 +1022,94 @@ mod tests { s.next_slice(b"\x1b[?W").unwrap(); } + + // ---- Regressions found by the Zig-vs-Rust differential fuzzer + // (tools/difftest, docs/rewrite/notes/difftest-DF.md). Expected values + // come from the Zig reference driver. + + fn screen_row_wrap(t: &Terminal, y: u32) -> bool { + use crate::point::{Coordinate, Point}; + let pages = &t.screen().pages; + let pin = pages + .pin(Point::Screen(Coordinate { x: 0, y })) + .expect("row exists"); + pin.row_and_cell(pages).expect("row").0.wrap() + } + + fn cell_cps(t: &Terminal, row: u32) -> Vec { + use crate::point::{Coordinate, Point}; + (0..t.cols) + .map(|x| { + t.screen() + .pages + .get_cell(Point::Active(Coordinate { x, y: row })) + .map_or(0, crate::page::Cell::codepoint) + }) + .collect() + } + + #[test] + fn difftest_invalid_utf8_overlong_swallows_trail_byte() { + // Zig's SIMD path (utfcpp replace_invalid) emits ONE U+FFFD for an + // overlong lead plus its continuation byte. + let mut t = term(10, 2); + t.vt_stream().next_slice(b"\xc0\xa9").unwrap(); + assert_eq!(1, t.screen().cursor.x); + assert_eq!(0xFFFD, cell_cps(&t, 0)[0]); + } + + #[test] + fn difftest_invalid_utf8_truncated_then_control() { + // e4 a9 followed by a non-trail byte: the truncated sequence is a + // single replacement and the following byte decodes normally. + let mut t = term(10, 2); + t.vt_stream().next_slice(b"\xe4\xa9A\xf0\x9f").unwrap(); + let cps = cell_cps(&t, 0); + assert_eq!(&cps[..2], &[0xFFFD, 'A' as u32]); + } + + #[test] + fn difftest_erase_scrollback_keeps_stale_row_flags() { + // Zig's eraseRows swaps rows and leaves the stale `wrap` flag in the + // leftover rows, which is visible once the page regrows. + let mut t = term(1, 1); + t.vt_stream().next_slice(b"xt\n\x1b[3Jex").unwrap(); + assert!(screen_row_wrap(&t, 0)); + assert!(!screen_row_wrap(&t, 1)); + } + + #[test] + fn difftest_grapheme_wrap_with_margins_moves_prev_cell() { + // Devanagari conjunct widening the cell at the last column while a + // left/right margin keeps the wrap from scrolling that cell: Zig moves + // the grapheme from the original cell pointer (not `up(1)`). + use crate::point::{Coordinate, Point}; + let mut t = term(20, 8); + t.vt_stream() + .next_slice("\x1b[?2027h\x1b[?69h\x1b[;10s\x1b[50;41H\u{915}\u{94d}\u{937}".as_bytes()) + .unwrap(); + let pages = &t.screen().pages; + let pin = pages.pin(Point::Active(Coordinate { x: 0, y: 7 })).unwrap(); + let (_, cell) = pin.row_and_cell(pages).unwrap(); + assert_eq!(cell.codepoint(), 0x915); + assert_eq!(crate::page::Wide::Wide, cell.wide()); + assert_eq!(Some(&[0x94d, 0x937][..]), pin.grapheme(pages)); + } + + /// Upstream (Zig) bug, deliberately NOT replicated: Zig's + /// `Terminal.print` grapheme path keeps `prev.cell` across a page + /// reallocation (hyperlink/string capacity growth) and appends the + /// grapheme into freed memory, silently losing U+FE0F here. Rust + /// addresses cells by index and keeps it. See difftest-DF.md. + #[test] + fn difftest_upstream_uaf_grapheme_survives_page_growth() { + let mut t = Terminal::new(Options { + max_scrollback: 1, + ..Options::new(5, 30) + }) + .unwrap(); + let input = b"\x1b\x5d\x38\x3b\x3b\x6c\x07\x35\x36\x37\x38\x39\x1b\x5b\x38\x65\x5f\x2c\xba\x9f\x87\xb8\x80\xbc\xbb\x1b\x5b\x35\x32\x72\x54\x68\x65\x20\x71\x75\x69\x63\x6b\x20\x62\x72\x6f\x77\x6e\x20\x66\x6f\x78\x1b\x5c\x61\x20\x40\x1b\x5b\x53\x1b\x4b\x30\x31\x32\x33\x34\x35\x36\x37\x31\x53\x1b\x73\x5d\x5b\x32\x6d\x9b\xff\x80\x80\x1b\x5b\x3f\x32\x30\x32\x37\x68\x1b\x5b\x3b\x33\x48\xf0\xc0\x5c\x77\x70\x5e\x6f\x20\x70\xe2\x9d\xa4\xef\xb8\x8f"; + t.vt_stream().next_slice(input).unwrap(); + assert!(t.plain_string().unwrap().contains("\u{2764}\u{fe0f}")); + } } diff --git a/crates/ghostty-terminal/src/terminal.rs b/crates/ghostty-terminal/src/terminal.rs index 0c56cf2..64597f0 100644 --- a/crates/ghostty-terminal/src/terminal.rs +++ b/crates/ghostty-terminal/src/terminal.rs @@ -904,6 +904,17 @@ impl Terminal { // This is like print_cell but without clearing the // grapheme data from the cell, so we can move it // later. + // Zig keeps a raw pointer (`prev.cell`) to this cell; + // remember its page cell index, because the wrap below + // may scroll (rows move, cell memory does not) and the + // grapheme move must still use THIS cell. + let prev_idx = { + let scr = self.screen_mut(); + let (x, pin) = (scr.cursor.x, scr.cursor_pin()); + scr.page(pin.node) + .cell_index(usize::from(x), usize::from(pin.y)) + .expect("prev cell") + }; { let cell = self.screen_mut().cursor_cell_mut(); cell.set_wide(if row_wrap { @@ -929,8 +940,11 @@ impl Terminal { if new_pin.node == old_pin.node { let page = self.screen_mut().page_mut(new_pin.node); - let old_idx = page.cell_index(old_x, old_y).expect("old cell"); let new_idx = page.cell_index(new_x, new_y).expect("new cell"); + // Zig moves from `prev.cell`, NOT from `old_pin` + // (they differ when margins keep the row from + // scrolling the cell out of `old_pin`). + let old_idx = prev_idx; page.move_grapheme(old_idx, new_idx)?; page.cells[old_idx].set_content_tag(ContentTag::Codepoint); page.cells[new_idx].set_content_tag(ContentTag::CodepointGrapheme); diff --git a/crates/ghostty-vt/src/simd.rs b/crates/ghostty-vt/src/simd.rs index a199ed2..5cee72d 100644 --- a/crates/ghostty-vt/src/simd.rs +++ b/crates/ghostty-vt/src/simd.rs @@ -1,7 +1,10 @@ //! SIMD-optimized routines (ZIG/src/simd). The Rust port is scalar-only: -//! `memchr` provides the vectorized byte search, and UTF-8 decoding follows -//! the Zig scalar fallback (`utf8DecodeUntilControlSeqScalar`), which is -//! tested to match the SIMD behavior. +//! `memchr` provides the vectorized byte search, and UTF-8 decoding +//! reproduces the observable behaviour of the C++ SIMD path (`vt.cpp`: +//! simdutf + utfcpp `replace_invalid` + `trim_partial_utf8`), which is what a +//! default Ghostty build runs. (It differs from Zig's scalar fallback +//! `utf8DecodeUntilControlSeqScalar` for ill-formed input; found by the +//! differential fuzzer, see docs/rewrite/notes/difftest-DF.md.) //! //! Not ported: `codepoint_width` (belongs to `ghostty-unicode`) and //! `base64` (the `base64` crate is used by `kitty::graphics_command`). @@ -22,113 +25,155 @@ pub struct DecodeResult { pub decoded: usize, } -/// Length of a UTF-8 sequence given its leading byte, `None` if the byte -/// cannot start a sequence (Zig `std.unicode.utf8ByteSequenceLength`). -fn utf8_byte_sequence_length(first: u8) -> Option { - match first { - 0x00..=0x7F => Some(1), - 0xC0..=0xDF => Some(2), - 0xE0..=0xEF => Some(3), - 0xF0..=0xF7 => Some(4), - _ => None, +/// `simdutf::scalar::utf8::trim_partial_utf8`: the length of `input` with a +/// trailing (possibly) incomplete UTF-8 sequence removed. Purely byte-value +/// based (it does not validate), exactly like the C++. +fn trim_partial_utf8(input: &[u8]) -> usize { + let length = input.len(); + if length < 3 { + match length { + 2 => { + if input[1] >= 0xc0 { + return 1; + } + if input[0] >= 0xe0 { + return 0; + } + return length; + } + 1 => { + if input[0] >= 0xc0 { + return 0; + } + return length; + } + _ => return length, + } } + if input[length - 1] >= 0xc0 { + return length - 1; + } + if input[length - 2] >= 0xe0 { + return length - 2; + } + if input[length - 3] >= 0xf0 { + return length - 3; + } + length } -/// Decode `bytes` (exactly one sequence whose length matches its leading -/// byte) strictly; `None` if ill-formed (overlong, surrogate, out of range or -/// a bad continuation byte). Equivalent to `std::str::from_utf8` on one char. -#[inline] -fn utf8_decode(bytes: &[u8]) -> Option { +/// Decode `input` into `output` replacing invalid sequences with U+FFFD the +/// way `DecodeUTF8` in `vt.cpp` does: simdutf for the all-valid fast path and +/// `utf8::replace_invalid` (utfcpp) otherwise. Because replacement output is +/// valid UTF-8, this is equivalent to a single utfcpp pass: +/// +/// - an invalid lead byte (`0x80..=0xC1`-style continuation bytes, `0xF8..`) +/// becomes one U+FFFD and only that byte is skipped; +/// - an incomplete sequence, overlong encoding, surrogate or out-of-range +/// code point becomes ONE U+FFFD and the lead byte plus every following +/// continuation byte is skipped; +/// - a sequence truncated by the end of input becomes one U+FFFD. +fn decode_utf8_replace(input: &[u8], output: &mut [u32]) -> usize { #[inline] - fn cont(b: u8) -> bool { + fn is_trail(b: u8) -> bool { b & 0xC0 == 0x80 } - match *bytes { - [b0] if b0 < 0x80 => Some(u32::from(b0)), - [b0 @ 0xC2..=0xDF, b1] if cont(b1) => { - Some((u32::from(b0 & 0x1F) << 6) | u32::from(b1 & 0x3F)) + let mut count = 0; + let mut i = 0; + while i < input.len() { + let lead = input[i]; + let len = if lead < 0x80 { + 1 + } else if lead >> 5 == 0x6 { + 2 + } else if lead >> 4 == 0xe { + 3 + } else if lead >> 3 == 0x1e { + 4 + } else { + // INVALID_LEAD + output[count] = 0xFFFD; + count += 1; + i += 1; + continue; + }; + // Gather the sequence, tracking utfcpp's error classes. + let mut cp = if len == 1 { + u32::from(lead) + } else { + u32::from(lead) & (0x7f >> len) + }; + let mut err = None; // Some(true) = NOT_ENOUGH_ROOM, Some(false) = other + for k in 1..len { + match input.get(i + k) { + None => { + err = Some(true); + break; + } + Some(&b) if !is_trail(b) => { + err = Some(false); + break; + } + Some(&b) => cp = (cp << 6) | u32::from(b & 0x3f), + } } - [b0 @ 0xE0..=0xEF, b1, b2] if cont(b1) && cont(b2) => { - let ok = match b0 { - 0xE0 => b1 >= 0xA0, - 0xED => b1 < 0xA0, - _ => true, + if err.is_none() { + let valid = cp <= 0x10_FFFF && !(0xD800..=0xDFFF).contains(&cp); + let overlong = match cp { + 0..=0x7f => len != 1, + 0x80..=0x7ff => len != 2, + 0x800..=0xffff => len != 3, + _ => false, }; - ok.then(|| { - (u32::from(b0 & 0x0F) << 12) | (u32::from(b1 & 0x3F) << 6) | u32::from(b2 & 0x3F) - }) + if valid && !overlong { + output[count] = cp; + count += 1; + i += len; + continue; + } + err = Some(false); } - [b0 @ 0xF0..=0xF4, b1, b2, b3] if cont(b1) && cont(b2) && cont(b3) => { - let ok = match b0 { - 0xF0 => b1 >= 0x90, - 0xF4 => b1 < 0x90, - _ => true, - }; - ok.then(|| { - (u32::from(b0 & 0x07) << 18) - | (u32::from(b1 & 0x3F) << 12) - | (u32::from(b2 & 0x3F) << 6) - | u32::from(b3 & 0x3F) - }) + output[count] = 0xFFFD; + count += 1; + if err == Some(true) { + // NOT_ENOUGH_ROOM: one replacement for the truncated tail. + break; + } + i += 1; + while i < input.len() && is_trail(input[i]) { + i += 1; } - _ => None, } + count } -/// Decode UTF-8 from `input` into `output` until an ESC (0x1B) is found. -/// `output.len()` must be at least `input.len()`. +/// Decode UTF-8 from `input` into `output` until an ESC (0x1B) is found, +/// faithfully following `ghostty_simd_decode_utf8_until_control_seq` +/// (`ZIG/src/simd/vt.cpp`, the real SIMD path of a default Ghostty build): /// -/// Invalid sequences have their leading byte replaced with U+FFFD and -/// decoding continues after that byte. Decoding stops (without consuming) -/// at an incomplete trailing sequence or at a byte that can't start a -/// sequence (e.g. 0xFF or a stray continuation byte); the caller must handle -/// those bytes itself (see `stream.rs`). +/// - if an ESC is found, everything before it is decoded (invalid and +/// truncated sequences become U+FFFD, see [`decode_utf8_replace`]) and the +/// ESC position is returned; +/// - otherwise a trailing partial sequence is trimmed with +/// `trim_partial_utf8` (a byte-value heuristic, it may trim valid trailing +/// bytes too) and the rest is decoded; the caller feeds the untouched tail +/// to the scalar decoder. +/// +/// `output.len()` must be at least `input.len()`. pub fn utf8_decode_until_control_seq(input: &[u8], output: &mut [u32]) -> DecodeResult { assert!(output.len() >= input.len()); - - let idx = index_of(input, 0x1B).unwrap_or(input.len()); - let decode = &input[..idx]; - - let mut decode_offset = 0; - let mut decode_count = 0; - while decode_offset < decode.len() { - // ASCII fast path: a run of single-byte sequences. - let rem = &decode[decode_offset..]; - if rem[0] < 0x80 { - let run = rem.iter().take_while(|&&b| b < 0x80).count(); - for (o, &b) in output[decode_count..decode_count + run].iter_mut().zip(rem) { - *o = u32::from(b); - } - decode_count += run; - decode_offset += run; - continue; - } - let Some(cp_len) = utf8_byte_sequence_length(rem[0]) else { - break; + if let Some(esc) = index_of(input, 0x1B) { + let decoded = decode_utf8_replace(&input[..esc], output); + return DecodeResult { + consumed: esc, + decoded, }; - - // Partial input: defer to the future. - if rem.len() < cp_len { - break; - } - - match utf8_decode(&rem[..cp_len]) { - Some(cp) => { - output[decode_count] = cp; - decode_offset += cp_len; - } - None => { - // Replace only the leading byte and continue after it. - output[decode_count] = 0xFFFD; - decode_offset += 1; - } - } - decode_count += 1; } - + let trimmed = trim_partial_utf8(input); + let decoded = decode_utf8_replace(&input[..trimmed], output); DecodeResult { - consumed: decode_offset, - decoded: decode_count, + consumed: trimmed, + decoded, } } @@ -137,56 +182,55 @@ mod tests { use super::*; #[test] - fn utf8_decode_matches_std() { - fn std_decode(bytes: &[u8]) -> Option { - std::str::from_utf8(bytes) - .ok() - .and_then(|s| s.chars().next()) - .map(|c| c as u32) - } - for b0 in 0..=255u8 { - let Some(len) = utf8_byte_sequence_length(b0) else { + fn decode_valid_matches_std() { + let mut out = [0u32; 8]; + for cp in (0..=0x10FFFFu32) + .step_by(7) + .chain([0x7f, 0x80, 0x7ff, 0x800, 0xffff, 0x10000, 0x10FFFF]) + { + let Some(c) = char::from_u32(cp) else { continue; }; - match len { - 1 => assert_eq!(utf8_decode(&[b0]), std_decode(&[b0])), - 2 => { - for b1 in 0..=255u8 { - assert_eq!( - utf8_decode(&[b0, b1]), - std_decode(&[b0, b1]), - "{b0:x} {b1:x}" - ); - } - } - 3 => { - for b1 in 0..=255u8 { - for b2 in 0..=255u8 { - assert_eq!( - utf8_decode(&[b0, b1, b2]), - std_decode(&[b0, b1, b2]), - "{b0:x} {b1:x} {b2:x}" - ); - } - } - } - _ => { - for b1 in 0..=255u8 { - for b2 in [0x00, 0x7F, 0x80, 0xA0, 0xBF, 0xC0] { - for b3 in [0x00, 0x7F, 0x80, 0xBF, 0xC0] { - assert_eq!( - utf8_decode(&[b0, b1, b2, b3]), - std_decode(&[b0, b1, b2, b3]), - "{b0:x} {b1:x} {b2:x} {b3:x}" - ); - } - } - } - } + if c == '\x1b' { + continue; } + let mut b = [0u8; 4]; + let s = c.encode_utf8(&mut b); + assert_eq!(decode_utf8_replace(s.as_bytes(), &mut out), 1); + assert_eq!(out[0], cp); } } + // Regressions found by the differential fuzzer (utfcpp replace_invalid + // semantics: ONE U+FFFD per invalid sequence, trail bytes swallowed). + #[test] + fn decode_replace_swallows_trail_bytes() { + let mut out = [0u32; 16]; + // overlong lead + trail + assert_eq!(decode_utf8_replace(b"\xc0\xa9", &mut out), 1); + assert_eq!(out[0], 0xFFFD); + // truncated 3-byte sequence followed by a non-trail byte + assert_eq!(decode_utf8_replace(b"\xe4\xa9\x41", &mut out), 2); + assert_eq!(&out[..2], &[0xFFFD, 0x41]); + // surrogate + assert_eq!(decode_utf8_replace(b"\xed\xa0\x80", &mut out), 1); + // invalid lead only skips itself + assert_eq!(decode_utf8_replace(b"\xff\x80", &mut out), 2); + // stray trail bytes are each invalid leads + assert_eq!(decode_utf8_replace(b"\x80\x80", &mut out), 2); + // truncated at end of input: one replacement + assert_eq!(decode_utf8_replace(b"\xf0\x9f", &mut out), 1); + } + + #[test] + fn trim_partial_is_byte_value_based() { + assert_eq!(trim_partial_utf8(b"ab\xe4\x41"), 2); + assert_eq!(trim_partial_utf8(b"abc"), 3); + assert_eq!(trim_partial_utf8(b"ab\xff"), 2); + assert_eq!(trim_partial_utf8(b"\xe4\xb8"), 0); + assert_eq!(trim_partial_utf8(b""), 0); + } + #[test] fn index_of_basic() { assert_eq!(index_of(b"hello", b' '), None); diff --git a/crates/ghostty-vt/src/stream.rs b/crates/ghostty-vt/src/stream.rs index 6568240..1dfd120 100644 --- a/crates/ghostty-vt/src/stream.rs +++ b/crates/ghostty-vt/src/stream.rs @@ -347,29 +347,33 @@ impl Stream { 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 Stream { 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(()) } diff --git a/docs/rewrite/notes/difftest-DF.md b/docs/rewrite/notes/difftest-DF.md new file mode 100644 index 0000000..6b8e549 --- /dev/null +++ b/docs/rewrite/notes/difftest-DF.md @@ -0,0 +1,107 @@ +# 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 `; `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-/` (`.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. diff --git a/justfile b/justfile index 09dcb7a..4234e22 100644 --- a/justfile +++ b/justfile @@ -47,6 +47,26 @@ test: clean: cargo clean +# Differential fuzzing of the terminal core, Zig (reference) vs Rust. Sets up +# Zig 0.15.2 + a copy of the Zig source + the Zig driver under target/ on the +# first run (network needed), then fuzzes SEEDS x CASES random escape-heavy +# streams twice: assertions on (debug Rust vs ReleaseSafe Zig) and assertions +# off (production Rust vs ReleaseFast Zig). Divergences are saved under +# target/difftest-work/fails-/ (minimise with `difftest_fuzz min`). See +# docs/rewrite/notes/difftest-DF.md. +difftest CASES="20000" SEEDS="1 2 3": + #!/usr/bin/env bash + set -euo pipefail + tools/difftest/setup.sh + cargo build -p ghostty-difftest + cargo build --profile production -p ghostty-difftest + jobs="$(nproc)" + for s in {{ SEEDS }}; do + timeout 3000 target/debug/difftest_fuzz run --seed "$s" --cases {{ CASES }} --jobs "$jobs" --tokens 120 + timeout 3000 target/production/difftest_fuzz run --seed "$s" --cases {{ CASES }} --jobs "$jobs" --tokens 120 \ + --zig target/difftest-zig-fast/bin/difftest_zig + done + # Build the build-data generator (Zig: `ghostty-build-data`, # src/main_build_data.zig) in release mode. build-data: diff --git a/tools/difftest/Cargo.toml b/tools/difftest/Cargo.toml new file mode 100644 index 0000000..e01b3ab --- /dev/null +++ b/tools/difftest/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ghostty-difftest" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +ghostty-terminal.workspace = true +ghostty-vt.workspace = true +ghostty-unicode.workspace = true + +[lints] +workspace = true diff --git a/tools/difftest/setup.sh b/tools/difftest/setup.sh new file mode 100755 index 0000000..a207a17 --- /dev/null +++ b/tools/difftest/setup.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Prepare the Zig side of the differential fuzzer (idempotent). +# +# 1. Zig 0.15.2 (Ghostty 1.3.1's required version) -> target/zig-0.15.2/ +# 2. A COPY of the reference Ghostty source -> target/zig-src/ +# (the reference tree itself is never modified) +# 3. The Zig driver, built twice: +# target/difftest-zig/bin/difftest_zig ReleaseSafe (assertions on) +# target/difftest-zig-fast/bin/difftest_zig ReleaseFast (assertions off) +# +# Everything lives under target/ (never /tmp, which is RAM-backed). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +REF="${GHOSTTY_ZIG_REF:-$ROOT/../ghostty}" +ZIGVER=0.15.2 +ZIGDIR="$ROOT/target/zig-$ZIGVER" +ZIG="$ZIGDIR/zig-x86_64-linux-$ZIGVER/zig" + +if [ ! -x "$ZIG" ]; then + mkdir -p "$ZIGDIR" + timeout 900 curl -sSfL -o "$ZIGDIR/zig.tar.xz" \ + "https://ziglang.org/download/$ZIGVER/zig-x86_64-linux-$ZIGVER.tar.xz" + timeout 300 tar -C "$ZIGDIR" -xf "$ZIGDIR/zig.tar.xz" + rm -f "$ZIGDIR/zig.tar.xz" +fi + +if [ ! -f "$ROOT/target/zig-src/build.zig" ]; then + mkdir -p "$ROOT/target/zig-src" + timeout 600 rsync -a --exclude .git --exclude .zig-cache --exclude zig-cache \ + "$REF/" "$ROOT/target/zig-src/" +fi + +cd "$ROOT/tools/difftest/zig" +for m in ReleaseSafe:difftest-zig ReleaseFast:difftest-zig-fast; do + timeout 1800 "$ZIG" build -Doptimize="${m%%:*}" \ + --cache-dir "$ROOT/target/zig-cache/local" \ + --global-cache-dir "$ROOT/target/zig-cache/global" \ + --prefix "$ROOT/target/${m##*:}" +done diff --git a/tools/difftest/src/bin/difftest_fuzz.rs b/tools/difftest/src/bin/difftest_fuzz.rs new file mode 100644 index 0000000..40e0e02 --- /dev/null +++ b/tools/difftest/src/bin/difftest_fuzz.rs @@ -0,0 +1,307 @@ +//! Differential fuzz harness: Rust dump vs Zig driver output. +//! +//! ```text +//! difftest_fuzz run [--seed N] [--cases N] [--jobs N] [--tokens N] +//! [--zig PATH] [--work DIR] [--size CxR] [--sb BYTES] +//! difftest_fuzz minz|minr # minimise an abort +//! difftest_fuzz min # minimise a divergence +//! difftest_fuzz check # compare one input +//! ``` +use std::{ + path::{Path, PathBuf}, + process::Command, + sync::{ + Arc, + Mutex, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use ghostty_difftest::{ + dump, + inputgen::{Rng, config, generate}, +}; + +struct Ctx { + zig: PathBuf, + work: PathBuf, +} + +fn rust_dump(input: &[u8], c: u16, r: u16, sb: usize) -> String { + let input = input.to_vec(); + std::panic::catch_unwind(move || dump(&input, c, r, sb)) + .unwrap_or_else(|_| "RUST PANIC\n".to_string()) +} + +fn zig_dump(ctx: &Ctx, tag: &str, input: &[u8], c: u16, r: u16, sb: usize) -> String { + zig_run(ctx, tag, input, c, r, sb).0 +} + +/// Run the Zig driver. The bool is true when Zig grew a page's capacity +/// (`info(page_list): adjusting page capacity`) while running: several Zig +/// code paths keep raw cell pointers across such a reallocation (e.g. the +/// grapheme-attach after a wrap in `Terminal.print`), so a mismatch there can +/// be a use-after-free in Zig rather than a Rust bug. +fn zig_run(ctx: &Ctx, tag: &str, input: &[u8], c: u16, r: u16, sb: usize) -> (String, bool) { + let f = ctx.work.join(format!("in-{tag}.bin")); + std::fs::write(&f, input).expect("write input"); + let out = Command::new(&ctx.zig) + .args([c.to_string(), r.to_string(), sb.to_string()]) + .arg(&f) + .output() + .expect("spawn zig"); + let _ = std::fs::remove_file(&f); + let grown = String::from_utf8_lossy(&out.stderr).contains("adjusting page capacity"); + let text = if out.status.success() { + String::from_utf8_lossy(&out.stdout).into_owned() + } else { + format!( + "ZIG CRASH {:?}\n{}", + out.status, + String::from_utf8_lossy(&out.stderr) + .lines() + .take(3) + .collect::>() + .join("\n") + ) + }; + (text, grown) +} + +fn crashed(s: &str) -> bool { + s.starts_with("ZIG CRASH") || s.starts_with("RUST PANIC") +} + +/// Two outputs are equivalent if identical, or if the Zig reference itself +/// aborted (a failed debug assertion or memory-safety fault in the Zig code +/// is undefined behaviour there, so there is no behaviour to compare; these +/// are counted and saved separately as `zigcrash`). +fn equivalent(rs: &str, z: &str) -> bool { + rs == z || z.starts_with("ZIG CRASH") +} + +fn differs(ctx: &Ctx, tag: &str, input: &[u8], c: u16, r: u16, sb: usize) -> bool { + !equivalent( + &rust_dump(input, c, r, sb), + &zig_dump(ctx, tag, input, c, r, sb), + ) +} + +/// Predicate for `minc`: the Zig driver (or Rust, per `which`) aborts. +fn aborts(ctx: &Ctx, which: &str, input: &[u8], c: u16, r: u16, sb: usize) -> bool { + if which == "zig" { + crashed(&zig_dump(ctx, "minc", input, c, r, sb)) + } else { + crashed(&rust_dump(input, c, r, sb)) + } +} + +fn minimize(ctx: &Ctx, input: &[u8], c: u16, r: u16, sb: usize, crash: Option<&str>) -> Vec { + let mut cur = input.to_vec(); + let mut chunk = cur.len().div_ceil(2).max(1); + loop { + let mut i = 0; + let mut changed = false; + while i < cur.len() { + let end = (i + chunk).min(cur.len()); + let mut cand = cur[..i].to_vec(); + cand.extend_from_slice(&cur[end..]); + let keep = match crash { + Some(w) => aborts(ctx, w, &cand, c, r, sb), + None => differs(ctx, "min", &cand, c, r, sb), + }; + if keep { + cur = cand; + changed = true; + } else { + i += chunk; + } + } + if chunk == 1 { + if !changed { + break; + } + } else { + chunk = chunk.div_ceil(2); + } + } + cur +} + +fn show_diff(a: &str, b: &str) { + let (al, bl): (Vec<_>, Vec<_>) = (a.lines().collect(), b.lines().collect()); + let mut shown = 0; + for i in 0..al.len().max(bl.len()) { + let (x, y) = ( + al.get(i).copied().unwrap_or(""), + bl.get(i).copied().unwrap_or(""), + ); + if x != y { + println!(" line {i}\n zig : {x}\n rust: {y}"); + shown += 1; + if shown >= 4 { + break; + } + } + } +} + +fn arg(a: &[String], name: &str, default: T) -> T { + a.iter() + .position(|x| x == name) + .and_then(|i| a.get(i + 1)) + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +fn main() { + let a: Vec = std::env::args().skip(1).collect(); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let zig = a + .iter() + .position(|x| x == "--zig") + .and_then(|i| a.get(i + 1)) + .map_or_else( + || root.join("target/difftest-zig/bin/difftest_zig"), + PathBuf::from, + ); + let work = root.join("target/difftest-work"); + std::fs::create_dir_all(&work).expect("mkdir"); + let ctx = Arc::new(Ctx { zig, work }); + std::panic::set_hook(Box::new(|_| {})); + match a.first().map(String::as_str) { + Some("check" | "min" | "minz" | "minr") => { + let (c, r, sb): (u16, u16, usize) = ( + a[1].parse().expect("numeric arg"), + a[2].parse().expect("numeric arg"), + a[3].parse().expect("numeric arg"), + ); + let input = std::fs::read(&a[4]).expect("read"); + let crash = match a[0].as_str() { + "minz" => Some("zig"), + "minr" => Some("rust"), + _ => None, + }; + let input = if a[0] == "check" { + input + } else { + minimize(&ctx, &input, c, r, sb, crash) + }; + if a[0] != "check" { + std::fs::write(ctx.work.join("min.bin"), &input).expect("write"); + println!("minimised to {} bytes: {:02x?}", input.len(), input); + } + let (z, rs) = ( + zig_dump(&ctx, "chk", &input, c, r, sb), + rust_dump(&input, c, r, sb), + ); + if equivalent(&rs, &z) { + println!("SAME{}", if z == rs { "" } else { " (both abort)" }); + } else { + show_diff(&z, &rs); + } + } + _ => run(&ctx, &a), + } +} + +fn run(ctx: &Arc, a: &[String]) { + let seed: u64 = arg(a, "--seed", 1); + let cases: usize = arg(a, "--cases", 1000); + let jobs: usize = arg(a, "--jobs", 8); + let tokens: usize = arg(a, "--tokens", 60); + let size: String = arg(a, "--size", String::new()); + let sb_fixed: Option = a + .iter() + .position(|x| x == "--sb") + .and_then(|i| a.get(i + 1)?.parse().ok()); + let next = Arc::new(AtomicUsize::new(0)); + let bad = Arc::new(Mutex::new(Vec::::new())); + let zig_only = Arc::new(AtomicUsize::new(0)); + let suspects = Arc::new(AtomicUsize::new(0)); + let zig_and_rust = Arc::new(AtomicUsize::new(0)); + let fails_dir = ctx.work.join(format!("fails-{seed}")); + let handles: Vec<_> = (0..jobs) + .map(|j| { + let (ctx, next, bad, fails_dir, size) = ( + ctx.clone(), + next.clone(), + bad.clone(), + fails_dir.clone(), + size.clone(), + ); + let (zc, zboth, zsus) = (zig_only.clone(), zig_and_rust.clone(), suspects.clone()); + std::thread::spawn(move || { + loop { + let i = next.fetch_add(1, Ordering::SeqCst); + if i >= cases { + break; + } + let mut rng = Rng(seed.wrapping_mul(0x1_0000_0001).wrapping_add(i as u64)); + let (mut c, mut r, mut sb) = config(&mut rng); + if let Some((cc, rr)) = size.split_once('x') { + c = cc.parse().unwrap_or(c); + r = rr.parse().unwrap_or(r); + } + if let Some(s) = sb_fixed { + sb = s; + } + let n = 1 + rng.below(tokens); + let input = generate(&mut rng, n); + let rs = rust_dump(&input, c, r, sb); + let (z, grown) = zig_run(&ctx, &format!("w{j}"), &input, c, r, sb); + if z.starts_with("ZIG CRASH") + && std::env::var_os("DIFFTEST_SAVE_ABORTS").is_some() + { + std::fs::create_dir_all(&fails_dir).expect("mkdir"); + let stem = format!("abort{i}-{c}x{r}-sb{sb}"); + std::fs::write(fails_dir.join(format!("{stem}.bin")), &input).expect("w"); + std::fs::write(fails_dir.join(format!("{stem}.zig.txt")), &z).expect("w"); + } + if z.starts_with("ZIG CRASH") && !rs.starts_with("RUST PANIC") { + zc.fetch_add(1, Ordering::SeqCst); + } else if z.starts_with("ZIG CRASH") { + zboth.fetch_add(1, Ordering::SeqCst); + } + if !equivalent(&rs, &z) { + std::fs::create_dir_all(&fails_dir).expect("mkdir"); + let suspect = grown && !rs.starts_with("RUST PANIC"); + let stem = format!( + "{}{i}-{c}x{r}-sb{sb}", + if suspect { "suspect" } else { "case" } + ); + if suspect { + zsus.fetch_add(1, Ordering::SeqCst); + } + std::fs::write(fails_dir.join(format!("{stem}.bin")), &input).expect("w"); + std::fs::write(fails_dir.join(format!("{stem}.zig.txt")), &z).expect("w"); + std::fs::write(fails_dir.join(format!("{stem}.rs.txt")), &rs).expect("w"); + if !suspect { + bad.lock().expect("lock").push(i); + } + } + } + }) + }) + .collect(); + for h in handles { + h.join().expect("join"); + } + let mut bad = bad.lock().expect("lock").clone(); + bad.sort_unstable(); + println!( + "seed={seed} cases={cases} divergences={} (zig aborted: {} rust-ok, {} rust-also-panicked; {} suspect: mismatch after Zig page growth, saved as suspect*)", + bad.len(), + zig_only.load(Ordering::SeqCst), + zig_and_rust.load(Ordering::SeqCst), + suspects.load(Ordering::SeqCst) + ); + if !bad.is_empty() { + println!( + "failing cases saved in {} (first: {:?})", + fails_dir.display(), + &bad[..bad.len().min(10)] + ); + std::process::exit(1); + } +} diff --git a/tools/difftest/src/bin/difftest_rs.rs b/tools/difftest/src/bin/difftest_rs.rs new file mode 100644 index 0000000..91eb18e --- /dev/null +++ b/tools/difftest/src/bin/difftest_rs.rs @@ -0,0 +1,18 @@ +//! Rust twin of the Zig driver: `difftest_rs `. +use std::io::Write as _; + +fn main() { + let a: Vec = std::env::args().collect(); + assert!( + a.len() == 5, + "usage: difftest_rs cols rows max_scrollback file" + ); + let input = std::fs::read(&a[4]).expect("read input"); + let out = ghostty_difftest::dump( + &input, + a[1].parse().expect("cols"), + a[2].parse().expect("rows"), + a[3].parse().expect("scrollback"), + ); + std::io::stdout().write_all(out.as_bytes()).expect("write"); +} diff --git a/tools/difftest/src/inputgen.rs b/tools/difftest/src/inputgen.rs new file mode 100644 index 0000000..ec4dece --- /dev/null +++ b/tools/difftest/src/inputgen.rs @@ -0,0 +1,655 @@ +//! Deterministic, escape-heavy random byte-stream generator. + +/// splitmix64 PRNG (deterministic, dependency-free). +pub struct Rng(pub u64); + +impl Rng { + pub fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) + } + /// Uniform in `0..n` (n > 0). + pub fn below(&mut self, n: usize) -> usize { + (self.next_u64() % n as u64) as usize + } + pub fn chance(&mut self, num: usize, den: usize) -> bool { + self.below(den) < num + } + pub fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T { + &xs[self.below(xs.len())] + } +} + +const WORDS: &[&str] = &[ + "hello", + "world", + "The quick brown fox", + "x", + "ab", + "0123456789", + "~~~", + " ", + "foo bar baz", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", +]; + +/// Interesting non-ASCII text: wide, combining, emoji, ZWJ, VS, regional +/// indicators, Hangul jamo, Indic, zero-width, ambiguous. +const UNICODE: &[&str] = &[ + "é", + "e\u{301}", + "\u{301}", + "中", + "文字", + "ア", + "A", + "한", + "ᄀ\u{1161}\u{11a8}", + "😀", + "👍🏽", + "👨\u{200d}👩\u{200d}👧", + "🇺🇸", + "🇺", + "\u{fe0f}", + "\u{fe0e}", + "❤\u{fe0f}", + "❤\u{fe0e}", + "☺", + "\u{200b}", + "\u{200d}", + "\u{200c}", + "\u{feff}", + "\u{ad}", + "ä\u{308}\u{323}", + "क्षि", + "ก็", + "\u{1f3f4}\u{e0067}\u{e0062}\u{e007f}", + "®", + "©", + "™", + "\u{2764}", + "\u{1f600}\u{fe0f}", + "ß", + "─", + "│", + "█", + "\u{1b000}", + "\u{10ffff}", + "\u{e000}", + "\u{ffff}", + "\u{3000}", + "〜", + "\u{2060}", + "\u{0600}", + "\u{2028}", + "\u{85}", +]; + +const MODES_DEC: &[u32] = &[ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 25, 40, 41, 45, 47, 66, 67, 69, 80, 95, 1000, 1002, 1003, 1004, + 1005, 1006, 1007, 1015, 1016, 1035, 1036, 1039, 1045, 1047, 1048, 1049, 2004, 2026, 2027, 2031, + 2048, 7727, 0, 9999, 65535, 12345, +]; +const MODES_ANSI: &[u32] = &[2, 4, 12, 20, 0, 1, 3, 25, 99]; + +const CSI_FINALS: &[u8] = b"@ABCDEFGHIJKLMPSTXZ^`abcdefghijklmnpqrstuvwxyz{|}~"; +const EDGE_NUMS: &[&str] = &[ + "", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "24", + "25", + "80", + "100", + "255", + "256", + "999", + "1000", + "32767", + "32768", + "65534", + "65535", + "65536", + "65537", + "99999", + "2147483647", + "2147483648", + "4294967295", + "4294967296", + "18446744073709551616", + "-1", +]; + +fn num(r: &mut Rng, out: &mut Vec) { + if r.chance(3, 5) { + out.extend_from_slice(r.below(30).to_string().as_bytes()); + } else { + out.extend_from_slice(r.pick(EDGE_NUMS).as_bytes()); + } +} + +fn params(r: &mut Rng, out: &mut Vec, max: usize) { + let n = r.below(max + 1); + for i in 0..n { + if i > 0 { + out.push(if r.chance(1, 12) { b':' } else { b';' }); + } + num(r, out); + } +} + +fn sgr(r: &mut Rng, out: &mut Vec) { + out.extend_from_slice(b"\x1b["); + let n = 1 + r.below(6); + for i in 0..n { + if i > 0 { + out.push(b';'); + } + match r.below(14) { + 0 => out.extend_from_slice(b"0"), + 1 => out.extend_from_slice( + r.pick(&[ + "1", "2", "3", "4", "5", "7", "8", "9", "21", "53", "22", "23", "24", "25", + "27", "28", "29", "55", + ]) + .as_bytes(), + ), + 2 => out.extend_from_slice( + r.pick(&[ + "30", "31", "32", "37", "39", "40", "41", "47", "49", "90", "97", "100", "107", + "59", + ]) + .as_bytes(), + ), + 3 => { + out.extend_from_slice(b"38;2;"); + for k in 0..3 { + if k > 0 { + out.push(b';'); + } + num(r, out); + } + } + 4 => { + out.extend_from_slice(r.pick(&["48", "58", "38"]).as_bytes()); + out.extend_from_slice(b";5;"); + num(r, out); + } + 5 => { + out.extend_from_slice( + r.pick(&["4:0", "4:1", "4:2", "4:3", "4:4", "4:5", "4:9", "4:"]) + .as_bytes(), + ); + } + 6 => { + out.extend_from_slice(b"38:2::"); + for k in 0..3 { + if k > 0 { + out.push(b':'); + } + num(r, out); + } + } + 7 => { + out.extend_from_slice(b"48:2:"); + for k in 0..4 { + if k > 0 { + out.push(b':'); + } + num(r, out); + } + } + 8 => out.extend_from_slice(b"58:5:"), + 9 => num(r, out), + 10 => out.extend_from_slice( + r.pick(&["38", "48", "58", "38;2", "38;5", "48;2;1", "58;2;1;2"]) + .as_bytes(), + ), + _ => out.extend_from_slice(r.below(110).to_string().as_bytes()), + } + } + out.push(b'm'); +} + +fn osc(r: &mut Rng, out: &mut Vec) { + out.extend_from_slice(b"\x1b]"); + let colors = [ + "rgb:ff/00/80", + "#ff0000", + "red", + "rgb:1/2/3", + "?", + "#abc", + "rgbi:0.5/0.5/0.5", + "#12345", + "blue;green", + "", + ]; + let c = |r: &mut Rng| *r.pick(&colors); + match r.below(18) { + 0 => out.extend_from_slice(format!("0;title{}", r.below(100)).as_bytes()), + 1 => out.extend_from_slice(format!("2;{}", c(r)).as_bytes()), + 2 => out.extend_from_slice(format!("4;{};{}", r.below(260), c(r)).as_bytes()), + 3 => out.extend_from_slice( + format!("4;{};{};{};{}", r.below(256), c(r), r.below(256), c(r)).as_bytes(), + ), + 4 => out.extend_from_slice( + format!( + "{};{}", + r.pick(&[10, 11, 12, 13, 14, 15, 16, 17, 18, 19]), + c(r) + ) + .as_bytes(), + ), + 5 => out.extend_from_slice( + r.pick(&["104", "110", "111", "112", "104;1;2", "104;300"]) + .as_bytes(), + ), + 6 => out.extend_from_slice(format!("7;file://host/tmp/{}", r.below(10)).as_bytes()), + 7 => out.extend_from_slice( + r.pick(&[ + "8;;http://example.com", + "8;id=1;http://a.b", + "8;;", + "8;id=x:y=z;u", + ]) + .as_bytes(), + ), + 8 => out.extend_from_slice( + r.pick(&[ + "133;A", + "133;B", + "133;C", + "133;D;0", + "133;A;k=s", + "133;L", + "133;P;k=c", + "133;N", + ]) + .as_bytes(), + ), + 9 => out.extend_from_slice(r.pick(&["52;c;aGVsbG8=", "52;c;?", "52;;"]).as_bytes()), + 10 => out.extend_from_slice( + r.pick(&[ + "9;4;1;50", + "9;hello", + "777;notify;a;b", + "1337;File=x", + "22;pointer", + "66;s=2;x", + "5522;x", + ]) + .as_bytes(), + ), + 11 => out.extend_from_slice(format!("{};", r.below(2000)).as_bytes()), + 12 => out.extend_from_slice( + r.pick(&[ + "21;foreground=?", + "21;foreground=red;background=#fff", + "21;palette=1=?", + ]) + .as_bytes(), + ), + _ => { + let n = r.below(20); + for _ in 0..n { + out.push(32 + r.below(95) as u8); + } + } + } + match r.below(6) { + 0..=2 => out.push(0x07), + 3 | 4 => out.extend_from_slice(b"\x1b\\"), + _ => {} // unterminated + } +} + +fn dcs(r: &mut Rng, out: &mut Vec) { + out.extend_from_slice(b"\x1bP"); + out.extend_from_slice( + r.pick(&[ + "$qm", + "$q\"p", + "$qr", + "+q544e", + "1000p", + "1000p%begin 1 2 3\n", + "p", + "0;1|", + "1;2;3q", + "$q r", + "$t", + "=1s", + "garbage", + ]) + .as_bytes(), + ); + let n = r.below(12); + for _ in 0..n { + out.push(32 + r.below(95) as u8); + } + if !r.chance(1, 8) { + out.extend_from_slice(b"\x1b\\"); + } +} + +fn apc(r: &mut Rng, out: &mut Vec) { + out.extend_from_slice(b"\x1b_"); + out.extend_from_slice( + r.pick(&[ + "Ga=T,f=24,s=1,v=1;AAAA", + "Ga=d", + "Gi=1,a=q;AAAA", + "Ga=p,i=1", + "Gq=2,a=T,f=100;", + "x", + "Ga=T,t=s,i=3;/x", + ]) + .as_bytes(), + ); + out.extend_from_slice(b"\x1b\\"); +} + +fn csi(r: &mut Rng, out: &mut Vec) { + out.extend_from_slice(b"\x1b["); + match r.below(10) { + 0 => out.push(b'?'), + 1 => out.push(*r.pick(b">= {} + } + params(r, out, 5); + if r.chance(1, 10) { + out.push(*r.pick(b" $\"!#'*+%,")); + } + let f = *r.pick(CSI_FINALS); + out.push(f); +} + +fn csi_common(r: &mut Rng, out: &mut Vec) { + out.extend_from_slice(b"\x1b["); + let finals = b"HABCDEFGdJKLMP@XSTIZbg`aef"; + let f = *r.pick(finals); + let n = r.below(3); + for i in 0..n { + if i > 0 { + out.push(b';'); + } + if r.chance(3, 4) { + out.extend_from_slice(r.below(12).to_string().as_bytes()); + } else { + num(r, out); + } + } + out.push(f); +} + +fn margins(r: &mut Rng, out: &mut Vec) { + let fin = if r.chance(1, 3) { b's' } else { b'r' }; + if fin == b's' && r.chance(2, 3) { + out.extend_from_slice(b"\x1b[?69h"); + } + out.extend_from_slice(b"\x1b["); + if r.chance(9, 10) { + out.extend_from_slice(r.below(10).to_string().as_bytes()); + out.push(b';'); + out.extend_from_slice(r.below(14).to_string().as_bytes()); + } + out.push(fin); +} + +fn mode(r: &mut Rng, out: &mut Vec) { + let set = r.chance(1, 2); + if r.chance(3, 4) { + out.extend_from_slice(b"\x1b[?"); + let n = 1 + r.below(3); + for i in 0..n { + if i > 0 { + out.push(b';'); + } + out.extend_from_slice(r.pick(MODES_DEC).to_string().as_bytes()); + } + out.push(match r.below(8) { + 0 => b's', + 1 => b'r', + _ => { + if set { + b'h' + } else { + b'l' + } + } + }); + } else { + out.extend_from_slice(b"\x1b["); + out.extend_from_slice(r.pick(MODES_ANSI).to_string().as_bytes()); + out.push(if set { b'h' } else { b'l' }); + } +} + +fn esc(r: &mut Rng, out: &mut Vec) { + out.push(0x1b); + match r.below(12) { + 0 => out.push(*r.pick(b"78DEMHc=>NOno|}~Z\\69VWFlm")), + 1 => { + out.push(*r.pick(b"()*+-./")); + out.push(*r.pick(b"B0AUK<>12456789=%7HCEQRYZ`afi")); + } + 2 => { + out.push(b'#'); + out.push(*r.pick(b"3456789")); + } + 3 => { + out.push(b'%'); + out.push(*r.pick(b"G@8")); + } + 4 => { + out.push(b' '); + out.push(*r.pick(b"FGLMN")); + } + _ => out.push(32 + r.below(95) as u8), + } +} + +fn text(r: &mut Rng, out: &mut Vec) { + match r.below(12) { + 0..=3 => out.extend_from_slice(r.pick(WORDS).as_bytes()), + 4..=7 => out.extend_from_slice(r.pick(UNICODE).as_bytes()), + 8 => { + let n = 1 + r.below(30); + let c = 33 + r.below(90) as u8; + out.extend(std::iter::repeat_n(c, n)); + } + 9 => { + // random unicode scalar + let cp = match r.below(5) { + 0 => r.below(0x80) as u32, + 1 => 0x80 + r.below(0x780) as u32, + 2 => 0x800 + r.below(0xf000) as u32, + _ => 0x10000 + r.below(0x10000) as u32, + }; + if let Some(ch) = char::from_u32(cp) { + let mut b = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut b).as_bytes()); + } + } + 10 => { + // invalid / truncated utf8 and C1 bytes + let n = 1 + r.below(3); + for _ in 0..n { + out.push(*r.pick(&[ + 0x80, 0x9b, 0x90, 0x9d, 0x9c, 0xc0, 0xc3, 0xe4, 0xe4, 0xb8, 0xf0, 0x9f, 0xf8, + 0xfe, 0xff, 0xa9, 0x85, 0xed, 0xa0, + ])); + } + } + _ => { + let n = 1 + r.below(8); + for _ in 0..n { + out.push(32 + r.below(95) as u8); + } + } + } +} + +fn token(r: &mut Rng, out: &mut Vec) { + match r.below(100) { + 0..=29 => text(r, out), + 30..=39 => out.push(*r.pick(&[ + b'\r', b'\n', b'\n', b'\t', b'\t', 0x08, 0x0b, 0x0c, 0x0e, 0x0f, 0x07, 0x00, 0x05, + 0x7f, 0x1a, 0x18, 0x1f, 0x01, 0x11, 0x13, + ])), + 40..=47 => sgr(r, out), + 48..=57 => csi_common(r, out), + 58..=64 => csi(r, out), + 65..=71 => mode(r, out), + 72..=75 => margins(r, out), + 76..=81 => esc(r, out), + 82..=85 => osc(r, out), + 86..=87 => dcs(r, out), + 88 => apc(r, out), + 89..=91 => { + // tabs stuff + out.extend_from_slice( + r.pick(&[ + "\x1b[3g", + "\x1b[0g", + "\x1bH", + "\t\t", + "\x1b[2I", + "\x1b[3Z", + "\x1b[?5W", + "\x1b[2 W", + "\x1b[0;5;9 W", + ]) + .as_bytes(), + ); + } + 92..=94 => { + // fill screen to provoke scrolling + let n = 1 + r.below(40); + for _ in 0..n { + out.extend_from_slice(b"line text\r\n"); + } + } + 95 => out.extend_from_slice( + r.pick(&[ + "\x1b[?1049h", + "\x1b[?1049l", + "\x1b[?47h", + "\x1b[?47l", + "\x1b[?1047h", + "\x1b[?1048h", + "\x1b[?1048l", + "\x1bc", + "\x1b[!p", + "\x1b[2J", + "\x1b[3J", + "\x1b[H\x1b[J", + "\x1b[?25l", + ]) + .as_bytes(), + ), + 96 => out.extend_from_slice( + r.pick(&[ + "\x1b[1\"q", + "\x1b[2\"q", + "\x1b[0\"q", + "\x1bV", + "\x1bW", + "\x1b[?1J", + "\x1b[?2K", + "\x1b[1;5r\x1b[?6h", + "\x1b[0 q", + "\x1b[5 q", + "\x1b[>1u", + "\x1b[=5;1u", + "\x1b[4;2m", + ]) + .as_bytes(), + ), + 97 if r.chance(1, 2) => { + // hyperlink span with text, sometimes left open + let uri = r.pick(&["http://a.example/x", "file:///tmp/y", "", "https://z"]); + let id = r.pick(&["", "id=1", "id=abc", "foo=bar:id=q"]); + out.extend_from_slice(format!("\x1b]8;{id};{uri}\x1b\\").as_bytes()); + text(r, out); + if r.chance(3, 4) { + out.extend_from_slice(b"\x1b]8;;\x1b\\"); + } + } + 97 => out.extend_from_slice( + b"\x1b[?7h\x1b[?7l\x1b[?45h\x1b[?1045h\x1b[?4h\x1b[4l\x1b[?2027h\x1b[?2027l", + ), + 98 => { + // grapheme clustering + margins + right-edge cursor + out.extend_from_slice(b"\x1b[?2027h\x1b[?69h"); + out.extend_from_slice( + format!("\x1b[{};{}s", 1 + r.below(5), 3 + r.below(12)).as_bytes(), + ); + out.extend_from_slice( + format!("\x1b[{};{}H", 1 + r.below(30), 1 + r.below(200)).as_bytes(), + ); + text(r, out); + text(r, out); + } + _ => { + // move cursor to a random edge before writing + out.extend_from_slice(format!("\x1b[{};{}H", r.below(60), r.below(120)).as_bytes()); + } + } +} + +/// Generate a byte stream of roughly `tokens` tokens. +pub fn generate(r: &mut Rng, tokens: usize) -> Vec { + let mut out = Vec::new(); + for _ in 0..tokens { + token(r, &mut out); + } + // Occasionally mutate raw bytes to hit odd parser paths. + if r.chance(1, 8) && !out.is_empty() { + for _ in 0..=r.below(4) { + let i = r.below(out.len()); + out[i] = r.below(256) as u8; + } + } + out +} + +/// Choose terminal geometry and scrollback for a case. +pub fn config(r: &mut Rng) -> (u16, u16, usize) { + let sizes: &[(u16, u16)] = &[ + (1, 1), + (2, 1), + (1, 2), + (2, 2), + (3, 3), + (5, 3), + (8, 4), + (10, 5), + (16, 6), + (20, 8), + (40, 10), + (80, 24), + (132, 12), + (7, 30), + (255, 5), + (300, 3), + ]; + let (c, rr) = *r.pick(sizes); + let sb = *r.pick(&[0usize, 1, 100, 10_000, 10_000, 100_000]); + (c, rr, sb) +} diff --git a/tools/difftest/src/lib.rs b/tools/difftest/src/lib.rs new file mode 100644 index 0000000..2ca9e67 --- /dev/null +++ b/tools/difftest/src/lib.rs @@ -0,0 +1,294 @@ +//! Differential-testing support: the canonical terminal dump (Rust twin of +//! `tools/difftest/zig/src/main.zig`) and the deterministic input generator. +//! +//! The dump format must stay byte-identical to the Zig driver's output. + +pub mod inputgen; + +use std::fmt::Write as _; + +use ghostty_terminal::{ + hyperlink::HyperlinkId, + point::{Coordinate, Point}, + screen::Screen, + screen_set::Key, + style::{Color, Style}, + terminal::{Options, Terminal}, +}; +use ghostty_vt::modes::ENTRIES; + +fn color(w: &mut String, c: Color) { + match c { + Color::None => w.push('n'), + Color::Palette(p) => { + let _ = write!(w, "p{p}"); + } + Color::Rgb(r) => { + let _ = write!(w, "r{:02x}{:02x}{:02x}", r.r, r.g, r.b); + } + } +} + +fn style_str(w: &mut String, s: &Style) { + color(w, s.fg_color); + w.push(','); + color(w, s.bg_color); + w.push(','); + color(w, s.underline_color); + let f = &s.flags; + let bits = u16::from(f.bold) + | (u16::from(f.italic) << 1) + | (u16::from(f.faint) << 2) + | (u16::from(f.blink) << 3) + | (u16::from(f.inverse) << 4) + | (u16::from(f.invisible) << 5) + | (u16::from(f.strikethrough) << 6) + | (u16::from(f.overline) << 7) + | ((f.underline as u16) << 8); + let _ = write!(w, ",{:x}", bits & 0x7ff); +} + +fn charset_str(w: &mut String, c: ghostty_terminal::screen::CharsetState) { + let _ = write!( + w, + "gl={} gr={} ss={} g={},{},{},{}", + c.gl as u8, + c.gr as u8, + c.single_shift.map_or(-1, |s| i32::from(s as u8)), + c.charsets.g0 as u8, + c.charsets.g1 as u8, + c.charsets.g2 as u8, + c.charsets.g3 as u8, + ); +} + +fn dump_screen(w: &mut String, name: &str, s: &Screen) { + let _ = writeln!(w, "[screen {name}]"); + let c = &s.cursor; + let _ = write!( + w, + "cursor {} {} wrap={} prot={} cstyle={} style=", + c.x, + c.y, + u8::from(c.pending_wrap), + u8::from(c.protected), + c.cursor_style as u8 + ); + style_str(w, &c.style); + let _ = writeln!(w, " hl={}", u8::from(c.hyperlink_id != 0)); + if let Some(sc) = &s.saved_cursor { + let _ = write!( + w, + "saved {} {} wrap={} prot={} origin={} style=", + sc.x, + sc.y, + u8::from(sc.pending_wrap), + u8::from(sc.protected), + u8::from(sc.origin) + ); + style_str(w, &sc.style); + w.push(' '); + charset_str(w, sc.charset); + w.push('\n'); + } else { + w.push_str("saved none\n"); + } + w.push_str("charset "); + charset_str(w, s.charset); + w.push('\n'); + let _ = writeln!(w, "protected_mode {}", s.protected_mode as u8); + let _ = writeln!(w, "kitty_kb {}", s.kitty_keyboard.current().int()); + let pages = &s.pages; + let _ = writeln!(w, "rows {}", pages.total_rows); + for y in 0..pages.total_rows { + let pt = Point::Screen(Coordinate { x: 0, y: y as u32 }); + let pin = pages.pin(pt).expect("pin"); + let (row, _) = pin.row_and_cell(pages).expect("row"); + let _ = write!( + w, + "row {y} w={} wc={} sp={} kv={} |", + u8::from(row.wrap()), + u8::from(row.wrap_continuation()), + row.semantic_prompt() as u8, + u8::from(row.kitty_virtual_placeholder()), + ); + for x in 0..pages.cols { + let cpin = ghostty_terminal::page_list::Pin { x, ..pin }; + let (_, cl) = cpin.row_and_cell(pages).expect("cell"); + let st = cpin.style(pages).expect("style"); + let tag = cl.content_tag() as u8; + let plain = tag == 0 + && cl.codepoint() == 0 + && cl.wide() as u8 == 0 + && !cl.protected() + && !cl.hyperlink() + && cl.semantic_content() as u8 == 0 + && st == Style::default(); + if plain { + continue; + } + let _ = write!(w, " {x}:{tag}:"); + match tag { + 0 | 1 => { + let _ = write!(w, "{:x}", cl.codepoint()); + } + 2 => { + let _ = write!(w, "{}", cl.color_palette()); + } + _ => { + let r = cl.color_rgb(); + let _ = write!(w, "{:02x}{:02x}{:02x}", r.r, r.g, r.b); + } + } + if tag == 1 { + match cpin.grapheme(pages) { + Some(g) => { + for cp in g { + let _ = write!(w, "+{cp:x}"); + } + } + None => w.push_str("+?"), + } + } + let _ = write!( + w, + ":{}:{}{}{}:", + cl.wide() as u8, + if cl.protected() { "p" } else { "" }, + if cl.hyperlink() { "h" } else { "" }, + cl.semantic_content() as u8, + ); + style_str(w, &st); + if cl.hyperlink() { + let page = &pages.node(cpin.node).expect("node").data; + let idx = page + .cell_index(usize::from(cpin.x), usize::from(cpin.y)) + .expect("idx"); + match page + .lookup_hyperlink(idx) + .and_then(|id| page.hyperlink_set.get(id)) + { + Some(h) => { + let _ = write!(w, " @{}", String::from_utf8_lossy(&h.uri)); + match &h.id { + HyperlinkId::Explicit(e) => { + let _ = write!(w, "@E:{}", String::from_utf8_lossy(e)); + } + HyperlinkId::Implicit(n) => { + let _ = write!(w, "@I:{n}"); + } + } + } + None => w.push_str(" @?"), + } + } + } + w.push('\n'); + } +} + +/// Feed `input` to a fresh terminal through the read-only stream and return +/// the canonical dump. +pub fn dump(input: &[u8], cols: u16, rows: u16, max_scrollback: usize) -> String { + let mut opts = Options::new(cols, rows); + opts.max_scrollback = max_scrollback; + let mut t = Terminal::new(opts).expect("terminal"); + { + let mut s = t.vt_stream(); + s.next_slice(input).expect("stream"); + } + dump_terminal(&t) +} + +/// Canonical dump of an already-driven terminal. +pub fn dump_terminal(t: &Terminal) -> String { + let mut w = String::new(); + let _ = writeln!(w, "size {} {}", t.cols, t.rows); + let _ = writeln!( + w, + "active {}", + match t.screens.active_key { + Key::Primary => "primary", + Key::Alternate => "alternate", + } + ); + let sr = &t.scrolling_region; + let _ = writeln!( + w, + "region t={} b={} l={} r={}", + sr.top, sr.bottom, sr.left, sr.right + ); + w.push_str("modes"); + for e in ENTRIES { + if t.modes.get(e.mode) { + let _ = write!(w, " {}", e.name); + } + } + w.push_str("\nsaved_modes"); + for e in ENTRIES { + if t.modes.saved.get(e.mode) { + let _ = write!(w, " {}", e.name); + } + } + w.push_str("\ntabstops"); + for x in 0..usize::from(t.cols) { + if t.tabstops.get(x) { + let _ = write!(w, " {x}"); + } + } + let f = &t.flags; + let _ = writeln!( + w, + "\nflags srp={} mok2={} mev={} mfmt={} msc={}", + f.shell_redraws_prompt as u8, + u8::from(f.modify_other_keys_2), + f.mouse_event as u8, + f.mouse_format as u8, + f.mouse_shift_capture as u8 + ); + let _ = writeln!(w, "status_display {}", t.status_display as u8); + let _ = writeln!(w, "mouse_shape {}", t.mouse_shape as u8); + let _ = writeln!(w, "pwd {}", String::from_utf8_lossy(&t.pwd)); + match t.previous_char { + Some(pc) => { + let _ = writeln!(w, "prev {pc:x}"); + } + None => w.push_str("prev none\n"), + } + for (n, d) in [ + ("foreground", &t.colors.foreground), + ("background", &t.colors.background), + ("cursor", &t.colors.cursor), + ] { + let _ = write!(w, "color {n}"); + for (tag, v) in [("o", d.override_), ("d", d.default)] { + match v { + Some(o) => { + let _ = write!(w, " {tag}={:02x}{:02x}{:02x}", o.r, o.g, o.b); + } + None => { + let _ = write!(w, " {tag}=-"); + } + } + } + w.push('\n'); + } + w.push_str("palette"); + for (i, c) in t.colors.palette.current.iter().enumerate() { + let o = &t.colors.palette.original[i]; + if (c.r, c.g, c.b) != (o.r, o.g, o.b) { + let _ = write!(w, " {i}={:02x}{:02x}{:02x}", c.r, c.g, c.b); + } + } + w.push('\n'); + if let Some(s) = t.screens.get(Key::Primary) { + dump_screen(&mut w, "primary", s); + } + if let Some(s) = t.screens.get(Key::Alternate) { + dump_screen(&mut w, "alternate", s); + } + w.push_str("[plain]\n"); + w.push_str(&t.plain_string().expect("plain")); + w.push_str("\n[end]\n"); + w +} diff --git a/tools/difftest/triage.sh b/tools/difftest/triage.sh new file mode 100755 index 0000000..824550c --- /dev/null +++ b/tools/difftest/triage.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Minimise every failing case in a fails- dir and print a compact report. +# usage: triage.sh [max-cases] +set -u +D=$1; N=${2:-30} +BIN="$(dirname "$0")/../../target/debug/difftest_fuzz" +for f in $(ls "$D"/*.bin | head -n "$N"); do + n=$(basename "$f" .bin); IFS=- read -r _ sz sb <<<"$n" + echo "== $n" + timeout 300 "$BIN" min "${sz%x*}" "${sz#*x}" "${sb#sb}" "$f" | head -8 +done diff --git a/tools/difftest/zig/build.zig b/tools/difftest/zig/build.zig new file mode 100644 index 0000000..10cd033 --- /dev/null +++ b/tools/difftest/zig/build.zig @@ -0,0 +1,17 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const exe_mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + if (b.lazyDependency("ghostty", .{ .simd = true, .optimize = optimize })) |dep| { + exe_mod.addImport("ghostty-vt", dep.module("ghostty-vt")); + } + const exe = b.addExecutable(.{ .name = "difftest_zig", .root_module = exe_mod }); + b.installArtifact(exe); +} diff --git a/tools/difftest/zig/build.zig.zon b/tools/difftest/zig/build.zig.zon new file mode 100644 index 0000000..9f78b6c --- /dev/null +++ b/tools/difftest/zig/build.zig.zon @@ -0,0 +1,12 @@ +.{ + .name = .difftest_zig, + .version = "0.0.0", + .fingerprint = 0x5509a6937e82f017, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // A COPY of the reference Ghostty source (never the reference tree + // itself), created by `just difftest-setup`. + .ghostty = .{ .path = "../../../target/zig-src" }, + }, + .paths = .{ "build.zig", "build.zig.zon", "src" }, +} diff --git a/tools/difftest/zig/src/main.zig b/tools/difftest/zig/src/main.zig new file mode 100644 index 0000000..18ac8d0 --- /dev/null +++ b/tools/difftest/zig/src/main.zig @@ -0,0 +1,188 @@ +//! Differential-testing driver (Zig side). Usage: +//! difftest_zig +//! Feeds the file through the read-only stream and prints a canonical dump. +//! The Rust twin (tools/difftest/src/main.rs) must print byte-identical text. +const std = @import("std"); +const vt = @import("ghostty-vt"); + +const Screen = vt.Screen; + +fn color(w: *std.Io.Writer, c: vt.Style.Color) !void { + switch (c) { + .none => try w.writeAll("n"), + .palette => |p| try w.print("p{d}", .{p}), + .rgb => |r| try w.print("r{x:0>2}{x:0>2}{x:0>2}", .{ r.r, r.g, r.b }), + } +} + +fn styleStr(w: *std.Io.Writer, s: vt.Style) !void { + try color(w, s.fg_color); + try w.writeAll(","); + try color(w, s.bg_color); + try w.writeAll(","); + try color(w, s.underline_color); + const bits: u16 = @bitCast(s.flags); + try w.print(",{x}", .{bits & 0x7ff}); +} + +fn charsetStr(w: *std.Io.Writer, c: Screen.CharsetState) !void { + try w.print("gl={d} gr={d} ss={d} g={d},{d},{d},{d}", .{ + @intFromEnum(c.gl), + @intFromEnum(c.gr), + if (c.single_shift) |ss| @as(i32, @intFromEnum(ss)) else -1, + @intFromEnum(c.charsets.g0), + @intFromEnum(c.charsets.g1), + @intFromEnum(c.charsets.g2), + @intFromEnum(c.charsets.g3), + }); +} + +fn dumpScreen(w: *std.Io.Writer, name: []const u8, s: *Screen) !void { + try w.print("[screen {s}]\n", .{name}); + const c = s.cursor; + try w.print("cursor {d} {d} wrap={d} prot={d} cstyle={d} style=", .{ c.x, c.y, @intFromBool(c.pending_wrap), @intFromBool(c.protected), @intFromEnum(c.cursor_style) }); + try styleStr(w, c.style); + try w.print(" hl={d}\n", .{@intFromBool(c.hyperlink_id != 0)}); + if (s.saved_cursor) |sc| { + try w.print("saved {d} {d} wrap={d} prot={d} origin={d} style=", .{ sc.x, sc.y, @intFromBool(sc.pending_wrap), @intFromBool(sc.protected), @intFromBool(sc.origin) }); + try styleStr(w, sc.style); + try w.writeAll(" "); + try charsetStr(w, sc.charset); + try w.writeAll("\n"); + } else try w.writeAll("saved none\n"); + try w.writeAll("charset "); + try charsetStr(w, s.charset); + try w.writeAll("\n"); + try w.print("protected_mode {d}\n", .{@intFromEnum(s.protected_mode)}); + try w.print("kitty_kb {d}\n", .{@as(u5, @bitCast(s.kitty_keyboard.current()))}); + try w.print("rows {d}\n", .{s.pages.total_rows}); + var y: usize = 0; + while (y < s.pages.total_rows) : (y += 1) { + const cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = @intCast(y) } }).?; + const row = cell.row; + const pin = s.pages.pin(.{ .screen = .{ .x = 0, .y = @intCast(y) } }).?; + const cells = pin.cells(.all); + try w.print("row {d} w={d} wc={d} sp={d} kv={d} |", .{ + y, + @intFromBool(row.wrap), + @intFromBool(row.wrap_continuation), + @intFromEnum(row.semantic_prompt), + @intFromBool(row.kitty_virtual_placeholder), + }); + for (cells, 0..) |*cl, x| { + const st: vt.Style = if (cl.style_id == 0) .{} else pin.node.data.styles.get(pin.node.data.memory, cl.style_id).*; + const plain = cl.content_tag == .codepoint and cl.content.codepoint == 0 and + cl.wide == .narrow and !cl.protected and !cl.hyperlink and + @intFromEnum(cl.semantic_content) == 0 and st.eql(.{}); + if (plain) continue; + try w.print(" {d}:{d}:", .{ x, @intFromEnum(cl.content_tag) }); + switch (cl.content_tag) { + .codepoint, .codepoint_grapheme => try w.print("{x}", .{cl.content.codepoint}), + .bg_color_palette => try w.print("{d}", .{cl.content.color_palette}), + .bg_color_rgb => try w.print("{x:0>2}{x:0>2}{x:0>2}", .{ cl.content.color_rgb.r, cl.content.color_rgb.g, cl.content.color_rgb.b }), + } + if (cl.content_tag == .codepoint_grapheme) { + if (pin.node.data.lookupGrapheme(cl)) |g| { + for (g) |cp| try w.print("+{x}", .{cp}); + } else try w.writeAll("+?"); + } + try w.print(":{d}:{s}{s}{d}:", .{ + @intFromEnum(cl.wide), + if (cl.protected) "p" else "", + if (cl.hyperlink) "h" else "", + @intFromEnum(cl.semantic_content), + }); + try styleStr(w, st); + if (cl.hyperlink) { + const page = &pin.node.data; + if (page.lookupHyperlink(cl)) |hid| { + const e = page.hyperlink_set.get(page.memory, hid); + try w.print(" @{s}", .{e.uri.slice(page.memory)}); + switch (e.id) { + .explicit => |ex| try w.print("@E:{s}", .{ex.slice(page.memory)}), + .implicit => |n| try w.print("@I:{d}", .{n}), + } + } else try w.writeAll(" @?"); + } + } + try w.writeAll("\n"); + } +} + +pub fn main() !void { + var gpa: std.heap.DebugAllocator(.{}) = .init; + defer _ = gpa.deinit(); + const alloc = gpa.allocator(); + + const args = try std.process.argsAlloc(alloc); + defer std.process.argsFree(alloc, args); + if (args.len != 5) return error.Usage; + const cols = try std.fmt.parseInt(u16, args[1], 10); + const rows = try std.fmt.parseInt(u16, args[2], 10); + const max_sb = try std.fmt.parseInt(usize, args[3], 10); + const input = try std.fs.cwd().readFileAlloc(alloc, args[4], 1 << 30); + defer alloc.free(input); + + var t: vt.Terminal = try .init(alloc, .{ .cols = cols, .rows = rows, .max_scrollback = max_sb }); + defer t.deinit(alloc); + + var s: vt.ReadonlyStream = .initAlloc(alloc, .init(&t)); + defer s.deinit(); + try s.nextSlice(input); + + var buf: [8192]u8 = undefined; + var fw = std.fs.File.stdout().writer(&buf); + const w = &fw.interface; + + try w.print("size {d} {d}\n", .{ t.cols, t.rows }); + try w.print("active {s}\n", .{@tagName(t.screens.active_key)}); + const sr = t.scrolling_region; + try w.print("region t={d} b={d} l={d} r={d}\n", .{ sr.top, sr.bottom, sr.left, sr.right }); + try w.writeAll("modes"); + inline for (@typeInfo(vt.Mode).@"enum".fields) |f| { + const m: vt.Mode = @enumFromInt(f.value); + if (t.modes.get(m)) try w.print(" {s}", .{f.name}); + } + try w.writeAll("\nsaved_modes"); + inline for (@typeInfo(vt.Mode).@"enum".fields) |f| { + const bit = @field(t.modes.saved, f.name); + if (bit) try w.print(" {s}", .{f.name}); + } + try w.writeAll("\ntabstops"); + for (0..t.cols) |x| if (t.tabstops.get(x)) try w.print(" {d}", .{x}); + try w.print("\nflags srp={d} mok2={d} mev={d} mfmt={d} msc={d}\n", .{ + @intFromEnum(t.flags.shell_redraws_prompt), + @intFromBool(t.flags.modify_other_keys_2), + @intFromEnum(t.flags.mouse_event), + @intFromEnum(t.flags.mouse_format), + @intFromEnum(t.flags.mouse_shift_capture), + }); + try w.print("status_display {d}\n", .{@intFromEnum(t.status_display)}); + try w.print("mouse_shape {d}\n", .{@intFromEnum(t.mouse_shape)}); + try w.print("pwd {s}\n", .{t.pwd.items}); + if (t.previous_char) |pc| try w.print("prev {x}\n", .{pc}) else try w.writeAll("prev none\n"); + inline for (.{ "foreground", "background", "cursor" }) |n| { + const d = @field(t.colors, n); + try w.print("color {s}", .{n}); + if (d.override) |o| try w.print(" o={x:0>2}{x:0>2}{x:0>2}", .{ o.r, o.g, o.b }) else try w.writeAll(" o=-"); + if (d.default) |o| try w.print(" d={x:0>2}{x:0>2}{x:0>2}", .{ o.r, o.g, o.b }) else try w.writeAll(" d=-"); + try w.writeAll("\n"); + } + try w.writeAll("palette"); + for (t.colors.palette.current, 0..) |c, i| { + const o = t.colors.palette.original[i]; + if (c.r != o.r or c.g != o.g or c.b != o.b) + try w.print(" {d}={x:0>2}{x:0>2}{x:0>2}", .{ i, c.r, c.g, c.b }); + } + try w.writeAll("\n"); + + if (t.screens.get(.primary)) |sc| try dumpScreen(w, "primary", sc); + if (t.screens.get(.alternate)) |sc| try dumpScreen(w, "alternate", sc); + + try w.writeAll("[plain]\n"); + const str = try t.plainString(alloc); + defer alloc.free(str); + try w.writeAll(str); + try w.writeAll("\n[end]\n"); + try w.flush(); +}