fix: backport libghostty resize overflow fix

refs #465
This commit is contained in:
Ogulcan Celik 2026-06-04 16:32:43 +03:00
parent f5f753c728
commit 648afa1fd5
7 changed files with 262 additions and 2 deletions

View File

@ -49,6 +49,16 @@ Run `just check` before committing unless Can explicitly accepts narrower valida
Unit tests live next to the code (`#[cfg(test)] mod tests`). New `AppState` or `Workspace` behavior should be testable with `AppState::test_new()` and `Workspace::test_new()` without PTYs.
## Vendored libghostty-vt
`vendor/libghostty-vt.vendor.json` records the upstream source commit currently vendored.
Local patches on top of the vendored source must be tracked in `vendor/libghostty-vt.patches.md` and stored as patch files under `vendor/patches/libghostty-vt/`. Each entry should say why the patch exists, the Herdr issue, upstream PR/discussion, vendored base commit, touched files, verification, and the exact removal condition.
When updating libghostty-vt, check every active patch in `vendor/libghostty-vt.patches.md`. If the new upstream commit contains the fix, remove the local patch and index entry, then rerun the listed verification. If not, reapply the patch on top of the new vendored source.
`just check` runs maintenance tests that verify local libghostty-vt patch files are listed in the index and reverse-apply cleanly against the vendored tree. Do not leave a patch file untracked or an indexed patch unapplied.
## Docs
Stable public docs live in `website/src/content/docs/`. They are the currently released herdr.dev docs. Do not document unreleased behavior there during normal feature or fix work.

View File

@ -2,6 +2,9 @@
## Unreleased
### Fixed
- Resizing restored panes no longer aborts the server when libghostty-vt reflows a terminal whose pre-resize cursor row is past the new height. (#465)
## [0.6.8] - 2026-06-04
This is a hotfix release for v0.6.7, prioritizing a server-crash fix for panes that print complex Unicode or emoji output.

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import io
import subprocess
import tarfile
import tempfile
import unittest
@ -63,6 +64,43 @@ class VendorLibghosttyVtTests(unittest.TestCase):
self.assertIn('"dist_archive"', text)
self.assertIn('"extracted_dir"', text)
def test_local_vendor_patches_are_listed_in_patch_index(self) -> None:
project_root = Path(__file__).resolve().parent.parent
index = project_root / "vendor" / "libghostty-vt.patches.md"
patch_dir = project_root / "vendor" / "patches" / "libghostty-vt"
patches = sorted(patch_dir.glob("*.patch"))
if not patches:
return
self.assertTrue(index.exists())
text = index.read_text()
missing = [
str(path.relative_to(project_root))
for path in patches
if str(path.relative_to(project_root)) not in text
]
self.assertEqual(missing, [])
def test_local_vendor_patches_are_applied_to_vendored_tree(self) -> None:
project_root = Path(__file__).resolve().parent.parent
patch_dir = project_root / "vendor" / "patches" / "libghostty-vt"
for patch in sorted(patch_dir.glob("*.patch")):
result = subprocess.run(
["git", "apply", "--check", "--reverse", str(patch.relative_to(project_root))],
cwd=project_root,
text=True,
capture_output=True,
)
self.assertEqual(
result.returncode,
0,
f"{patch.relative_to(project_root)} is not applied cleanly:\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}",
)
def test_embedded_libghostty_logging_is_silenced(self) -> None:
root = Path(__file__).resolve().parent.parent / "vendor" / "libghostty-vt"
lib_vt = root / "src" / "lib_vt.zig"

40
vendor/libghostty-vt.patches.md vendored Normal file
View File

@ -0,0 +1,40 @@
# libghostty-vt local patches
This file tracks intentional local changes applied on top of the vendored
`libghostty-vt` source. Remove a patch only when the vendored source commit
contains the upstream fix and the listed verification still passes.
## 0001 backport resizeCols cursor subtraction saturation
status: active
patch: `vendor/patches/libghostty-vt/0001-backport-resizecols-cursor-subtraction.patch`
herdr issue: https://github.com/ogulcancelik/herdr/issues/465
upstream discussion: https://github.com/ghostty-org/ghostty/discussions/12905
upstream pr: https://github.com/ghostty-org/ghostty/pull/12907
introduced upstream: `c44afa625`
vendored base: `0f7cd84b880b203c98683e520e84b9db0c5938d8`
local files:
- `vendor/libghostty-vt/src/terminal/PageList.zig`
- `vendor/libghostty-vt/src/terminal/c/terminal.zig`
reason: shrinking rows and columns in one resize can leave the pre-resize
cursor row past the new row count. `PageList.resizeCols` then computed rows
below the cursor with checked unsigned subtraction and aborted in safety builds.
remove when: the vendored source commit contains upstream PR #12907 and the
local ReleaseSafe resize regression tests pass without this patch.
verification:
```sh
zig build test-lib-vt -Demit-lib-vt -Doptimize=ReleaseSafe -Dtest-filter="resize shrinks both axes with cursor at bottom"
zig build test-lib-vt -Demit-lib-vt -Doptimize=ReleaseSafe -Dtest-filter="PageList resize less rows and cols cursor at bottom"
```

View File

@ -1059,7 +1059,7 @@ fn resizeCols(
break :cursor .{
.tracked_pin = c.pin orelse try self.trackPin(p),
.untrack = c.pin == null,
.remaining_rows = self.rows - c.y - 1,
.remaining_rows = self.rows -| (c.y + 1),
.wrapped_rows = wrapped,
};
} else null;
@ -1192,7 +1192,7 @@ fn resizeCols(
break :wrapped wrapped;
};
const current = self.rows - active_pt.active.y - 1;
const current = self.rows -| (active_pt.active.y + 1);
var req_rows = c.remaining_rows;
req_rows -|= wrapped -| c.wrapped_rows;
@ -10828,6 +10828,37 @@ test "PageList resize (no reflow) less rows and cols" {
}
}
test "PageList resize less rows and cols cursor at bottom" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 80, 24, 0);
defer s.deinit();
const cursor_pin = try s.trackPin(s.pin(.{ .active = .{
.x = 0,
.y = s.rows - 1,
} }).?);
defer s.untrackPin(cursor_pin);
// Shrink both axes so the original cursor.y is strictly past the new row
// count, after resizeWithoutReflow has already reduced self.rows.
try s.resize(.{
.cols = 79,
.rows = 20,
.reflow = true,
.cursor = .{ .x = 0, .y = 23, .pin = cursor_pin },
});
try testing.expectEqual(@as(usize, 79), s.cols);
try testing.expectEqual(@as(usize, 20), s.rows);
// remaining_rows saturates to 0, so the cursor lands on the new bottom row.
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = s.rows - 1,
} }, s.pointFromPin(.active, cursor_pin.*).?);
}
test "PageList resize (no reflow) more rows and less cols" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@ -1005,6 +1005,29 @@ test "resize invalid value" {
try testing.expectEqual(Result.invalid_value, resize(t, 80, 0, 9, 18));
}
test "resize shrinks both axes with cursor at bottom" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
// CSI 24;1H parks the cursor on the bottom row.
const move = "\x1b[24;1H";
vt_write(t, move, move.len);
// Shrink both axes; pre-resize cursor.y sits past the new bottom row.
try testing.expectEqual(Result.success, resize(t, 79, 23, 8, 16));
try testing.expectEqual(79, t.?.terminal.cols);
try testing.expectEqual(23, t.?.terminal.rows);
}
test "mode_get and mode_set" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(

View File

@ -0,0 +1,115 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: herdr maintainers <maintainers@herdr.dev>
Date: Thu, 4 Jun 2026 00:00:00 +0000
Subject: [PATCH] backport resizeCols cursor subtraction saturation
Backport ghostty-org/ghostty#12907 onto the vendored libghostty-vt source.
Shrink-both resize can leave the old cursor row past the new row count after
resizeWithoutReflow reduces self.rows. Saturating subtraction treats rows below
that cursor as zero instead of trapping in safety builds.
Herdr issue: https://github.com/ogulcancelik/herdr/issues/465
Upstream discussion: https://github.com/ghostty-org/ghostty/discussions/12905
Upstream PR: https://github.com/ghostty-org/ghostty/pull/12907
Vendored base: 0f7cd84b880b203c98683e520e84b9db0c5938d8
---
vendor/libghostty-vt/src/terminal/PageList.zig | 33 +++++++++++++++++++++--
vendor/libghostty-vt/src/terminal/c/terminal.zig | 23 ++++++++++++++++
2 files changed, 54 insertions(+), 2 deletions(-)
diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig
index 0000000..0000000 100644
--- a/vendor/libghostty-vt/src/terminal/PageList.zig
+++ b/vendor/libghostty-vt/src/terminal/PageList.zig
@@ -1059,7 +1059,7 @@ fn resizeCols(
break :cursor .{
.tracked_pin = c.pin orelse try self.trackPin(p),
.untrack = c.pin == null,
- .remaining_rows = self.rows - c.y - 1,
+ .remaining_rows = self.rows -| (c.y + 1),
.wrapped_rows = wrapped,
};
} else null;
@@ -1192,7 +1192,7 @@ fn resizeCols(
break :wrapped wrapped;
};
- const current = self.rows - active_pt.active.y - 1;
+ const current = self.rows -| (active_pt.active.y + 1);
var req_rows = c.remaining_rows;
req_rows -|= wrapped -| c.wrapped_rows;
@@ -10828,6 +10828,37 @@ test "PageList resize (no reflow) less rows and cols" {
}
}
+test "PageList resize less rows and cols cursor at bottom" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, 0);
+ defer s.deinit();
+
+ const cursor_pin = try s.trackPin(s.pin(.{ .active = .{
+ .x = 0,
+ .y = s.rows - 1,
+ } }).?);
+ defer s.untrackPin(cursor_pin);
+
+ // Shrink both axes so the original cursor.y is strictly past the new row
+ // count, after resizeWithoutReflow has already reduced self.rows.
+ try s.resize(.{
+ .cols = 79,
+ .rows = 20,
+ .reflow = true,
+ .cursor = .{ .x = 0, .y = 23, .pin = cursor_pin },
+ });
+ try testing.expectEqual(@as(usize, 79), s.cols);
+ try testing.expectEqual(@as(usize, 20), s.rows);
+
+ // remaining_rows saturates to 0, so the cursor lands on the new bottom row.
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 0,
+ .y = s.rows - 1,
+ } }, s.pointFromPin(.active, cursor_pin.*).?);
+}
+
test "PageList resize (no reflow) more rows and less cols" {
const testing = std.testing;
const alloc = testing.allocator;
diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig
index 0000000..0000000 100644
--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig
@@ -1005,6 +1005,29 @@ test "resize invalid value" {
try testing.expectEqual(Result.invalid_value, resize(t, 80, 0, 9, 18));
}
+test "resize shrinks both axes with cursor at bottom" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 80,
+ .rows = 24,
+ .max_scrollback = 0,
+ },
+ ));
+ defer free(t);
+
+ // CSI 24;1H parks the cursor on the bottom row.
+ const move = "\x1b[24;1H";
+ vt_write(t, move, move.len);
+
+ // Shrink both axes; pre-resize cursor.y sits past the new bottom row.
+ try testing.expectEqual(Result.success, resize(t, 79, 23, 8, 16));
+ try testing.expectEqual(79, t.?.terminal.cols);
+ try testing.expectEqual(23, t.?.terminal.rows);
+}
+
test "mode_get and mode_set" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
--
2.49.0