From 2b76b47c8a087b81be139b552d9a41bb2006dcd3 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 28 Jul 2026 12:28:27 -0300 Subject: fixing some crashes --- build.zig | 12 ++++- src/pardes.zig | 49 ++++++++++++++++----- test/snapshots/badutf.golden | 62 ++++++++++++++++++++++++++ test/snapshots/badutf.snap | 16 +++++++ test/snapshots/reflow.golden | 25 +++++++++++ test/snapshots/reflow.snap | 20 +++++++++ test/snapshots/tinywin.golden | 97 +++++++++++++++++++++++++++++++++++++++++ test/snapshots/tinywin.snap | 31 +++++++++++++ test/snapshots/yankpaste.golden | 31 +++++++++++++ test/snapshots/yankpaste.snap | 9 ++++ 10 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 test/snapshots/badutf.golden create mode 100644 test/snapshots/badutf.snap create mode 100644 test/snapshots/reflow.golden create mode 100644 test/snapshots/reflow.snap create mode 100644 test/snapshots/tinywin.golden create mode 100644 test/snapshots/tinywin.snap diff --git a/build.zig b/build.zig index c62ee222..a4d530f1 100644 --- a/build.zig +++ b/build.zig @@ -314,10 +314,20 @@ pub fn build(b: *std.Build) void { // consumed, and the defaults otherwise drag ghostty's app graph into the // build — gtk4 header translation via host pkg-config for linux targets, // the Xcode app graph (iOS SDK, xcodebuild) on darwin hosts. + // NEVER hand ghostty `.Debug`: that flips its `slow_runtime_safety`, which + // walks the whole PageList after every mutation and PANICS the app on a + // transient state its own next lines repair — `PageList.resizeCols` grows + // rows BEFORE it moves a history viewport pin back into the active area, + // so widening a window whose scrollback holds wrapped lines dies with + // "PageList integrity check failed: ViewportPinInsufficientRows". Those + // checks are a ghostty-development tool (upstream ships them off); Zig's + // own safety checks come from OUR optimize mode and are unaffected, since + // ghostty-vt is a module compiled into this binary. See reflow.snap. + const ghostty_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseSafe else optimize; const ghostty_dep = if (is_emscripten) b.lazyDependency("ghostty", .{ .simd = false }) else - b.lazyDependency("ghostty", .{ .target = target, .optimize = optimize, .simd = ghostty_simd, .@"app-runtime" = .none, .@"emit-xcframework" = false }); + b.lazyDependency("ghostty", .{ .target = target, .optimize = ghostty_optimize, .simd = ghostty_simd, .@"app-runtime" = .none, .@"emit-xcframework" = false }); if (ghostty_dep) |dep| { const ghostty_vt = dep.module("ghostty-vt"); ghostty_vt_for_snap = ghostty_vt; diff --git a/src/pardes.zig b/src/pardes.zig index 8a947065..4d821e60 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -399,13 +399,28 @@ pub const Surface = struct { /// Print UTF-8 text into a row, no wrap, clipped to [x, x+w). Returns the /// column after the last written cell. Wide glyphs take two cells. + /// + /// The text is NOT trusted to be valid UTF-8 — a file pane holds whatever + /// bytes are on disk (latin-1 source, an ELF opened by mistake), a path can + /// be any bytes at all, and a search row splices both. std's unchecked + /// iterator panics on a bad start byte, so decode by hand and paint one + /// U+FFFD per undecodable byte (what a terminal does). fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 { var col = x; const end = x + w; - var it = std.unicode.Utf8View.initUnchecked(text).iterator(); - while (it.nextCodepointSlice()) |cp_slice| { + var i: usize = 0; + while (i < text.len) { if (col >= end) break; - const cp = std.unicode.utf8Decode(cp_slice) catch continue; + // n == 0: not a start byte at all. A short tail or a bad + // continuation decodes to null the same way — one U+FFFD, one byte. + const n = std.unicode.utf8ByteSequenceLength(text[i]) catch 0; + const decoded: ?u21 = if (n > 0 and i + n <= text.len) + (std.unicode.utf8Decode(text[i .. i + n]) catch null) + else + null; + const cp_slice = if (decoded == null) "\u{FFFD}" else text[i .. i + n]; + i += if (decoded == null) 1 else n; + const cp = decoded orelse 0xFFFD; if (cp == '\r') continue; const width: u16 = if (cp < 0x80) 1 else uucode.get(.width, cp); if (width == 0) continue; @@ -3292,7 +3307,10 @@ pub const Pardes = struct { const row0 = eb.row0; if (y[y.len - 1] == '\n') { const block_text = y[0 .. y.len - 1]; - const n = modal.lineCount(block_text); + // a yanked BLANK line is "\n": the block is empty and lineCount + // says 0 lines, but it still pastes as one (empty) line — without + // the floor every `n - 1` below underflows and panics. + const n = @max(1, modal.lineCount(block_text)); var out: []u8 = undefined; if (before) { const row: usize = @intCast(@max(0, b.lo_row - row0)); @@ -5461,7 +5479,8 @@ pub const Pardes = struct { for (0..p.ncol) |c| { const last = c + 1 == p.ncol; const fw = @as(f32, @floatFromInt(p.screen_w)) * p.col_weight[c] / wsum; - const cw: u16 = if (last) (p.screen_w -| x) else @max(1, @as(u16, @intFromFloat(@round(fw)))); + const wroom = p.screen_w -| x; // same clamp as the rows below + const cw: u16 = if (last) wroom else @min(wroom, @max(1, @as(u16, @intFromFloat(@round(fw))))); p.col_x[c] = x; p.col_w[c] = cw; @@ -5478,7 +5497,14 @@ pub const Pardes = struct { const pane = p.panes[id] orelse continue; const lastk = k + 1 == p.col_n[c]; const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum; - const ch: u16 = if (lastk) (p.screen_h -| y) else @max(1, @as(u16, @intFromFloat(@round(fh)))); + // every pane wants at least one row, so a column with more + // panes than the window has rows would walk `y` off the bottom + // and hand renderPane a rect outside the surface (assert, then + // panic — shrink a window with a few stacked panes). Clamp to + // what is left: the panes past the edge get h = 0 and render + // nothing until the window grows back. + const room = p.screen_h -| y; + const ch: u16 = if (lastk) room else @min(room, @max(1, @as(u16, @intFromFloat(@round(fh))))); p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch }; y +|= ch; } @@ -5558,17 +5584,20 @@ pub const Pardes = struct { }; // resize-handle hint / drag previews: a dash overlay that keeps the - // underlying colors (border drags + hover), or the move indicator + // underlying colors (border drags + hover), or the move indicator. A + // drag holds the coordinates of the last mouse event, so a resize + // mid-drag (tiling WM, font-size change) can leave them off the new + // surface — every arm below checks before it draws. switch (p.drag) { - .border_v => |d| { + .border_v => |d| if (d.cur_x < s.cols) { var row: u16 = TOPBAR_H; while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); }, - .border_h => |d| { + .border_h => |d| if (d.cur_y < s.rows) { var col = p.col_x[d.col]; while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); }, - .move => |d| { + .move => |d| if (d.cur_x < s.cols) { if (p.movePlacement(d.id, d.cur_x, d.cur_y)) |placement| { var col: u16 = p.col_x[placement.preview_col]; while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) { diff --git a/test/snapshots/badutf.golden b/test/snapshots/badutf.golden new file mode 100644 index 00000000..3873f5e8 --- /dev/null +++ b/test/snapshots/badutf.golden @@ -0,0 +1,62 @@ +== snap badbytes grid=100x30 cursor=7,2 +|Kill Newcol Tutor Debug NextColor Dump +| NOR /tmp/pardes-snap/badutf/cwd/bad.txt Save Del NOR /tmp/pardes-snap/badutf/cwd Del +| 1 hello �� world +| 2 latin-1 caf� tail bad.txt wide.txt +| 3 truncated �� here +| 4 end +| 5 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap hscroll-midglyph grid=100x30 cursor=46,2 +|Kill Newcol Tutor Debug NextColor Dump +| NOR /tmp/pardes-snap/badutf/cwd/wide.txt Save De NOR /tmp/pardes-snap/badutf/cwd Del +| 1 ��語 日本語 日本語 日本語 tail +| 2 bad.txt wide.txt +| 3 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/snapshots/badutf.snap b/test/snapshots/badutf.snap new file mode 100644 index 00000000..db9447b6 --- /dev/null +++ b/test/snapshots/badutf.snap @@ -0,0 +1,16 @@ +# Invalid UTF-8 must RENDER, never panic. Two sources, both ordinary use: +# a file pane holds whatever bytes are on disk (latin-1, a truncated sequence, +# an ELF opened by mistake), and the byte-column hscroll cuts a multi-byte +# glyph in half by design. Undecodable bytes come out as U+FFFD, one per byte. +file bad.txt hello \xff\xfe world\nlatin-1 caf\xe9 tail\ntruncated \xe6\x97 here\nend\n +file wide.txt \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e tail\nsecond line\n +start 30 100 bad.txt +wait 8000 Kill Newcol +stable 700 20000 +snap badbytes +start 30 100 wide.txt +wait 8000 Kill Newcol +stable 700 20000 +key $ +stable 700 10000 +snap hscroll-midglyph diff --git a/test/snapshots/reflow.golden b/test/snapshots/reflow.golden new file mode 100644 index 00000000..e38657cf --- /dev/null +++ b/test/snapshots/reflow.golden @@ -0,0 +1,25 @@ +== snap widened grid=200x24 cursor=4,19 +|Kill Newcol Tutor Debug NextColor Dump +| TTY /tmp/pardes-snap/reflow/cwd Del +| $ printf 'y%.0s' $(seq 1 3000); echo do''ne +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +| yyyyyyyyyyyyyyyyyyyyyyyyyyyyyydone +| $ +| +| +| +| diff --git a/test/snapshots/reflow.snap b/test/snapshots/reflow.snap new file mode 100644 index 00000000..0fa4a30d --- /dev/null +++ b/test/snapshots/reflow.snap @@ -0,0 +1,20 @@ +# Widening a window whose scrollback holds WRAPPED lines, with the viewport +# scrolled into history: ghostty's resizeCols unwraps, then grows rows while +# the history viewport pin is still short, and its Debug-only PageList +# integrity check panicked the whole app ("ViewportPinInsufficientRows"). +# build.zig now pins the ghostty dependency at ReleaseSafe so those +# development-only checks are off — the app's own safety checks are unchanged. +start 24 40 +wait 8000 $ +text printf 'y%.0s' $(seq 1 3000); echo do''ne +key enter +settle 2500 +stable 700 15000 +wheel up 10 10 +wheel up 10 10 +wheel up 10 10 +wheel up 10 10 +stable 500 8000 +resize 24 200 +stable 700 15000 +snap widened diff --git a/test/snapshots/tinywin.golden b/test/snapshots/tinywin.golden new file mode 100644 index 00000000..928241b2 --- /dev/null +++ b/test/snapshots/tinywin.golden @@ -0,0 +1,97 @@ +== snap stacked grid=100x30 cursor=4,14 +|Kill Newcol Tutor Debug NextColor Dump +| TTY /tmp/pardes-snap/tinywin/cwd Del +| $ +| NOR /tmp/pardes-snap/tinywin/cwd Del +| +| +| NOR /tmp/pardes-snap/tinywin/cwd Del +| +| +| NOR /tmp/pardes-snap/tinywin/cwd Del +| +| +| NOR /tmp/pardes-snap/tinywin/cwd Del +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap shrunk grid=60x3 cursor=59,2 +|Kill Newcol Tutor Debug NextColor Dump +| TTY /tmp/pardes-snap/tinywin/cwd Del +| NOR /tmp/pardes-snap/tinywin/cwd Del +== snap columns grid=100x30 cursor=79,3 +|Kill Newcol Tutor Debug NextColor Dump +| TTY /tmp/pardes-snap/ti NOR /tmp/pardes-snap/ti NOR /tmp/pardes-snap/ti NOR /tmp/pardes-snap/ti +| $ +| NOR /tmp/pardes-snap/ti +| +| +| NOR /tmp/pardes-snap/ti +| +| +| NOR /tmp/pardes-snap/ti +| +| +| NOR /tmp/pardes-snap/ti +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap narrow grid=4x30 cursor=3,29 +|Kill +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ +|╎ diff --git a/test/snapshots/tinywin.snap b/test/snapshots/tinywin.snap new file mode 100644 index 00000000..fab7f950 --- /dev/null +++ b/test/snapshots/tinywin.snap @@ -0,0 +1,31 @@ +# Shrinking the window past "one row per pane" used to panic: computeGeom gave +# every pane in a column at least one row, so `y` walked off the bottom and +# renderPane got a rect outside the surface (assert). Panes that no longer fit +# now get h = 0 and simply do not render until the window grows back. +start 30 100 +wait 8000 $ +key a-n +settle 400 +key a-n +settle 400 +key a-n +settle 400 +key a-n +stable 700 15000 +snap stacked +resize 3 60 +stable 700 10000 +snap shrunk +# ...and the same sideways: more columns than the window is wide +resize 30 100 +stable 700 10000 +key space c n +settle 600 +key space c n +settle 600 +key space c n +stable 700 15000 +snap columns +resize 30 4 +stable 700 10000 +snap narrow diff --git a/test/snapshots/yankpaste.golden b/test/snapshots/yankpaste.golden index 213495dc..3f335d56 100644 --- a/test/snapshots/yankpaste.golden +++ b/test/snapshots/yankpaste.golden @@ -91,3 +91,34 @@ | | | +== snap blankline grid=100x30 cursor=7,4 +|Kill Newcol Tutor Debug NextColor Dump +| NOR /tmp/pardes-snap/yankpaste/cwd/f.txt Save De NOR /tmp/pardes-snap/yankpaste/cwd Del +| 1 liline 1 +| 2 f.txt +| 3 +| 4 line 2 +| 5 line 3 +| 6 line 2 +| 7 line 4 +| 8 line 2 +| 9 line 4 +| 10 line 5 +| 11 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/snapshots/yankpaste.snap b/test/snapshots/yankpaste.snap index 38ca6e3b..96082ad6 100644 --- a/test/snapshots/yankpaste.snap +++ b/test/snapshots/yankpaste.snap @@ -26,3 +26,12 @@ stable 400 5000 key v l y p stable 400 5000 snap charwise-inline +# a BLANK line yanks as a bare "\n": the block is empty, lineCount says 0 +# lines, and every `n - 1` in the linewise paste used to underflow — panic on +# y then p. It pastes as one empty line. +key o +key esc +settle 700 +key y p +stable 400 5000 +snap blankline -- cgit v1.3