Skip to content

fix(terminal): guard wrap count when resize pushes cursor to scrollback - #12935

Merged
mitchellh merged 1 commit into
ghostty-org:mainfrom
bo2themax:push-a8deb748
Jun 5, 2026
Merged

mitchellh merged 1 commit into
ghostty-org:mainfrom
bo2themax:push-a8deb748

Conversation

@bo2themax

@bo2themax bo2themax commented Jun 5, 2026 •

Copy link
Copy Markdown
Member

Found this issue when testing some search features; follow up for #12907.

You can either reproduce using the PoC below with libghostty-vt or run Ghostty debug on macOS and follow these steps:

  1. Open Ghostty and start search 0.
  2. Press cmd+= to increase font size.
  3. You should see a panic crash.

AI Disclosure

As the commit suggests, Claude implemented the fix, the unit test, and PoC file.

I reviewed it(seems reasonable to me, but I’m not a Zig professional) and tested it myself.

// PoC: resize panic when shrinking both axes with the cursor near the top
// of a fully-populated screen.
//
// Build (with libghostty-vt headers + dylib on the standard search paths):
//   zig run poc.zig -lghostty-vt
//
// Or point at a local build:
//   zig run poc.zig -I <prefix>/include -L <prefix>/lib -lghostty-vt
//
// At runtime the dylib must be discoverable (DYLD_LIBRARY_PATH on macOS,
// LD_LIBRARY_PATH on Linux, or an rpath baked in at link time).
//
// Without the fix, this aborts with
//   reached unreachable code   (assert in PageList.Pin.pageIterator)
// at _terminal.PageList.resizeCols on a debug/safe build. On release it
// silently iterates an empty (reversed) range.

const std = @import("std");
const c = @cImport({
    @cInclude("ghostty/vt.h");
});

pub fn main() !void {
    var term: c.GhosttyTerminal = null;
    const opts: c.GhosttyTerminalOptions = .{
        .cols = 80,
        .rows = 24,
        .max_scrollback = 1000,
    };
    if (c.ghostty_terminal_new(null, &term, opts) != c.GHOSTTY_SUCCESS) {
        return error.InitFailed;
    }
    defer c.ghostty_terminal_free(term);

    // Fill every one of the 24 active rows with non-blank content. This is
    // what makes the bug reachable: when rows shrink, resizeWithoutReflow
    // can only trim *blank* trailing rows, so non-blank rows are instead
    // pushed up into scrollback and the active-area top moves down.
    {
        var buf: [256]u8 = undefined;
        var i: usize = 0;
        while (i < 24) : (i += 1) {
            // "X" on each row; CR+LF between rows but not after the last so
            // we don't scroll the top row away.
            const line = if (i + 1 < 24)
                std.fmt.bufPrint(&buf, "X\r\n", .{}) catch unreachable
            else
                std.fmt.bufPrint(&buf, "X", .{}) catch unreachable;
            c.ghostty_terminal_vt_write(term, line.ptr, line.len);
        }
    }

    // CSI 1;1H -> park the cursor on the TOP row (1-based). The active area is
    // anchored to the bottom, so once we shrink rows this row falls above the
    // new active-area top, i.e. into scrollback.
    const move = "\x1b[1;1H";
    c.ghostty_terminal_vt_write(term, move.ptr, move.len);

    // Shrink both axes. Columns must shrink to take resize()'s .lt branch,
    // which runs the row shrink first and then resizeCols with the original
    // (now out-of-active-area) cursor pin. Panics in
    // _terminal.PageList.resizeCols.
    _ = c.ghostty_terminal_resize(term, 79, 20, 8, 16);

    std.debug.print("survived resize (fix is present)\n", .{});
}

In the column-shrink (.lt) branch of PageList.resize, resizeWithoutReflow
lowers self.rows before resizeCols runs. Because the active area is anchored
to the bottom, shrinking rows moves the active-area top down; a cursor near
the top of the old active area then ends up above the new active area (in
scrollback).

resizeCols counts wrap continuations from the cursor pin up to the active-area
top via a .left_up rowIterator. When the cursor pin is above the limit, the
range is reversed and the iterator's order assertion fires (SIGABRT in debug;
silently iterates empty in release).

Count zero wraps when the cursor pin is above the active area, mirroring the
post-reflow preserved-cursor block which already no-ops for a non-active
cursor. Add a regression test.
@bo2themax
bo2themax requested a review from a team as a code owner June 5, 2026 19:14
@ghostty-bot ghostty-bot Bot added the vt Control sequence related label Jun 5, 2026
@mitchellh mitchellh added this to the 1.3.2 milestone Jun 5, 2026
@mitchellh
mitchellh merged commit 5a1edfb into ghostty-org:main Jun 5, 2026
94 checks passed
@bo2themax
bo2themax deleted the push-a8deb748 branch June 5, 2026 21:01
@mitchellh mitchellh modified the milestones: 1.3.2, 1.4.0 Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

vt Control sequence related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants