fix-deviant(terminal): no panics on input-reachable terminal states

Intentional deviation from Ghostty 1.3.1 (DEVIANT_FIXES.md entry 10).
Two terminal inputs reach states that Zig handles only by assertion
(Debug/ReleaseSafe abort; ReleaseFast continues):
- Toggling mode 2027 around a BOM before a wide character hits
  `assert(!b)` in print_grapheme. This repository's release profile
  keeps debug assertions, so terminal output could crash a release
  build. The assertion is removed and the path behaves like Zig
  ReleaseFast.
- A grapheme (e.g. a Devanagari conjunct) at the right margin on the
  bottom row with scrolling margins returned InvalidCoordinate and left
  a grapheme flag without data, panicking later in the formatter. The
  grapheme now moves from the cell that actually holds it and no cell is
  flagged without data.

tools/difftest gains a Rust-only panic hunt (`difftest_fuzz panics`,
lenient driver): ~60k debug-build cases now run without any panic, and
3000 Zig differential cases show 0 divergences.

Test Plan:
- cargo test -p ghostty-terminal difftest_mode_2027_toggle_with_bom_does_not_panic
  difftest_grapheme_wrap_scrolling_margins_keeps_grapheme
- cargo run -p difftest --bin difftest_fuzz -- panics
This commit is contained in:
ddidderr committed 2026-10-12 16:53:27 +02:00
1 parent 7b59457cb8
commit 325288b7ee
5 files changed
+107 -10

No files matched your search

@@ -1078,6 +1078,31 @@ mod tests {
assert!(!screen_row_wrap(&t, 1));
}
#[test]
fn difftest_grapheme_wrap_scrolling_margins_keeps_grapheme() {
// Same Devanagari conjunct at the right margin, but on the bottom row
// so the wrap scrolls the margin region and the grapheme data travels
// with its row. Zig asserts/faults (`moveGrapheme` on a blank cell);
// Rust must neither error nor leave a grapheme flag without data.
let mut t = term(255, 5);
t.vt_stream()
.next_slice("\x1b[?2027h\x1b[?69h\x1b[;9s\x1b[24H\t\u{915}\u{94d}\u{937}".as_bytes())
.unwrap();
let text = t.plain_string().unwrap();
assert!(text.contains('\u{915}'));
}
#[test]
fn difftest_mode_2027_toggle_with_bom_does_not_panic() {
// Zig asserts `!graphemeBreak` while replaying an existing grapheme
// (aborts in safe builds, ignored in ReleaseFast). It must never panic
// here: this repository's release profile enables debug assertions.
let mut t = term(20, 8);
t.vt_stream()
.next_slice(b"\x1b[?2027la\xef\xbb\xbf\x1b[?2027h\xe4\xb8\xad")
.unwrap();
}
#[test]
fn difftest_grapheme_wrap_with_margins_moves_prev_cell() {
// Devanagari conjunct widening the cell at the last column while a
+26 -9
View File
@@ -820,8 +820,12 @@ impl Terminal {
.expect("prev cell");
let cps = page.lookup_grapheme(idx).expect("grapheme data");
for &cp2 in cps {
let b = ghostty_unicode::grapheme_break(cp1, cp2, &mut state);
debug_assert!(!b);
// Zig asserts `!graphemeBreak(cp1, cp2)` here, but that
// is reachable from input (mode 2027 toggled after the
// grapheme was built, e.g. with a BOM), so it must not
// panic. ReleaseFast ignores the result; we do too. This
// only advances `state`.
let _ = ghostty_unicode::grapheme_break(cp1, cp2, &mut state);
cp1 = cp2;
}
}
@@ -943,18 +947,31 @@ impl Terminal {
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);
page.rows[new_y].set_grapheme(true);
// scrolling the cell out of `old_pin`). When
// the wrap DID scroll the margin region, the
// data travelled with its row to `old_pin` and
// `prev.cell` is blank: Zig then asserts
// (page.zig moveGrapheme) or faults in
// ReleaseFast. Move from where the data really
// is, and never flag a cell without data.
let old_idx = if page.cells[prev_idx].has_grapheme() {
prev_idx
} else {
page.cell_index(old_x, old_y).expect("old cell")
};
if page.cells[old_idx].has_grapheme() {
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);
page.rows[new_y].set_grapheme(true);
}
page.update_row_grapheme_flag(old_y);
} else {
let cps: Vec<u32> = {
let page = self.screen().page(old_pin.node);
let old_idx = page.cell_index(old_x, old_y).expect("old cell");
page.lookup_grapheme(old_idx).expect("grapheme").to_vec()
page.lookup_grapheme(old_idx).unwrap_or_default().to_vec()
};
for cp in cps {
let x = self.screen().cursor.x;