a73x

7c8dbc8a

fix: a resize is reported in-band to an app that turned on mode 2048

a73x   2026-08-22 19:40

Commit message
fix: a resize is reported in-band to an app that turned on mode 2048

nvim 0.11+ asks the terminal for in-band size reports (DECRQM 2048).
ghostty-vt's stock handler answers "recognised", so nvim turns the mode
on and from then on ignores SIGWINCH, waiting for CSI 48;rows;cols;h;w t
on its input — which the daemon never sent, because in ghostty proper the
app layer speaks it. A resized session stayed painted at its old size, and
a drag-resize left two screens overlaid, until nvim was restarted.

The engine now queues the report when the mode is set and after every
resize; the server flushes the engine's pty output after applySize, the
one engine event that answers the app without a feed.

src/engine.zig
Old New
@@ -29,6 +29,15 @@ pub const MuxHandler = struct {
29 if (comptime action == .clipboard_contents) self.onClipboard(value); 29 if (comptime action == .clipboard_contents) self.onClipboard(value);
30 if (comptime action == .bell) self.onBell(); 30 if (comptime action == .bell) self.onBell();
31 self.inner.vt(action, value); 31 self.inner.vt(action, value);
32 // After the stock handler, so the mode is already on when the first
33 // report goes out. The stock handler (a bare vt, no app above it)
34 // answers DECRQM 2048 as "recognised" but never speaks the report —
35 // in ghostty proper the app layer does. An app that turns it on
36 // (nvim 0.11+) then IGNORES SIGWINCH and waits for this; without it
37 // the session paints at the old size forever.
38 if (comptime action == .set_mode) {
39 if (value.mode == .in_band_size_reports) self.engineOf().reportSize();
40 }
32 } 41 }
33 42
34 fn engineOf(self: *MuxHandler) *Engine { 43 fn engineOf(self: *MuxHandler) *Engine {
@@ -699,6 +708,16 @@ pub const Engine = struct {
699 708
700 pub fn resize(self: *Engine, cols: u16, rows: u16) !void { 709 pub fn resize(self: *Engine, cols: u16, rows: u16) !void {
701 try self.term.resize(self.alloc, @intCast(cols), @intCast(rows)); 710 try self.term.resize(self.alloc, @intCast(cols), @intCast(rows));
711 if (self.term.modes.get(.in_band_size_reports)) self.reportSize();
712 }
713
714 /// The mode-2048 in-band size report, queued for the pty like any other
715 /// answer. Pixel fields are 0: a headless engine has no cell metrics,
716 /// and the protocol allows it.
717 fn reportSize(self: *Engine) void {
718 var buf: [48]u8 = undefined;
719 const rep = std.fmt.bufPrint(&buf, "\x1b[48;{d};{d};0;0t", .{ self.term.rows, self.term.cols }) catch return;
720 self.pty_out.appendSlice(self.alloc, rep) catch {};
702 } 721 }
703 722
704 /// The Attributes type is not re-exported from the vt module root, so it 723 /// The Attributes type is not re-exported from the vt module root, so it
@@ -1668,3 +1687,25 @@ fn expectSpansAgree(alloc: std.mem.Allocator, eng: *Engine, cols: u16) !void {
1668 fn firstLine(text: []const u8) []const u8 { 1687 fn firstLine(text: []const u8) []const u8 {
1669 return text[0 .. std.mem.indexOfScalar(u8, text, '\n') orelse text.len]; 1688 return text[0 .. std.mem.indexOfScalar(u8, text, '\n') orelse text.len];
1670 } 1689 }
1690
1691 test "mode 2048: setting in-band size reports answers with the size at once" {
1692 var e = try Engine.init(std.testing.allocator, .{ .cols = 80, .rows = 26 });
1693 defer e.deinit();
1694 e.feed("\x1b[?2048h");
1695 try std.testing.expectEqualStrings("\x1b[48;26;80;0;0t", e.ptyOutput());
1696 }
1697
1698 test "mode 2048: a resize reports the new size in-band, and only when asked" {
1699 var e = try Engine.init(std.testing.allocator, .{ .cols = 80, .rows = 26 });
1700 defer e.deinit();
1701 try e.resize(100, 30);
1702 try std.testing.expectEqualStrings("", e.ptyOutput());
1703 e.feed("\x1b[?2048h");
1704 e.clearPtyOutput();
1705 try e.resize(120, 40);
1706 try std.testing.expectEqualStrings("\x1b[48;40;120;0;0t", e.ptyOutput());
1707 e.clearPtyOutput();
1708 e.feed("\x1b[?2048l");
1709 try e.resize(90, 28);
1710 try std.testing.expectEqualStrings("", e.ptyOutput());
1711 }
src/server.zig
Old New
@@ -1281,11 +1281,7 @@ pub const Server = struct {
1281 // long the engine took to digest what broke it. 1281 // long the engine took to digest what broke it.
1282 s.last_pty_ms = std.time.milliTimestamp(); 1282 s.last_pty_ms = std.time.milliTimestamp();
1283 s.eng.feed(buf[0..n]); 1283 s.eng.feed(buf[0..n]);
1284 const resp = s.eng.ptyOutput(); 1284 self.flushPtyOutput(si);
1285 if (resp.len > 0) {
1286 proto.writeAllFd(s.pty.master, resp) catch {};
1287 s.eng.clearPtyOutput();
1288 }
1289 self.sendUpdate(si); 1285 self.sendUpdate(si);
1290 self.drainMarkEvents(si); 1286 self.drainMarkEvents(si);
1291 self.drainSideEvents(si); 1287 self.drainSideEvents(si);
@@ -2796,9 +2792,23 @@ pub const Server = struct {
2796 if (cols < min_session_cols or rows < min_session_rows) return false; 2792 if (cols < min_session_cols or rows < min_session_rows) return false;
2797 self.ses(si).eng.resize(cols, rows) catch return false; 2793 self.ses(si).eng.resize(cols, rows) catch return false;
2798 self.ses(si).pty.resize(cols, rows) catch {}; 2794 self.ses(si).pty.resize(cols, rows) catch {};
2795 // A resize is the one engine event that answers the app without a
2796 // feed (the mode-2048 size report), so it flushes on its own.
2797 self.flushPtyOutput(si);
2799 return true; 2798 return true;
2800 } 2799 }
2801 2800
2801 /// Whatever the engine owes the app — query answers, size reports —
2802 /// goes to the pty now. Best-effort: a pty that will not take it is a
2803 /// session on its way out.
2804 fn flushPtyOutput(self: *Server, si: usize) void {
2805 const s = self.ses(si);
2806 const resp = s.eng.ptyOutput();
2807 if (resp.len == 0) return;
2808 proto.writeAllFd(s.pty.master, resp) catch {};
2809 s.eng.clearPtyOutput();
2810 }
2811
2802 /// Record the grid as client `i`'s size. ONLY call this after an 2812 /// Record the grid as client `i`'s size. ONLY call this after an
2803 /// applySize that returned true: the grid is then the size this client 2813 /// applySize that returned true: the grid is then the size this client
2804 /// asked for, and recording it is recording the client's own size. Call 2814 /// asked for, and recording it is recording the client's own size. Call
@@ -11185,3 +11195,37 @@ test "Server: a channel nobody has asked anything on is not timed out" {
11185 test { 11195 test {
11186 std.testing.refAllDeclsRecursive(@This()); 11196 std.testing.refAllDeclsRecursive(@This());
11187 } 11197 }
11198
11199 test "Server: a resize reaches the pty as an in-band size report when the app asked for one" {
11200 const alloc = std.testing.allocator;
11201 var tmp = try TmpDir.make();
11202 defer tmp.cleanup();
11203 const sock_path = try std.fmt.allocPrint(alloc, "{s}/inband.sock", .{tmp.path()});
11204 defer alloc.free(sock_path);
11205 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
11206 defer srv.deinit();
11207
11208 const s = &srv.sessions[0].?;
11209 // The app opts in. The engine queues the immediate report; drop it so
11210 // only the resize's own report is on trial below.
11211 s.eng.feed("\x1b[?2048h");
11212 s.eng.clearPtyOutput();
11213 try std.testing.expect(srv.applySize(0, 100, 30));
11214
11215 // The report is INPUT to the child, so the only place to see it from
11216 // here is the tty's own echo of it back on the master. Canonical mode
11217 // may spell the ESC as ^[, so the match starts after it.
11218 var got: std.ArrayList(u8) = .empty;
11219 defer got.deinit(alloc);
11220 var tries: usize = 0;
11221 while (tries < 50) : (tries += 1) {
11222 var pfd = [_]std.posix.pollfd{.{ .fd = s.pty.master, .events = std.posix.POLL.IN, .revents = 0 }};
11223 if ((try std.posix.poll(&pfd, 100)) > 0) {
11224 var buf: [512]u8 = undefined;
11225 const n = std.posix.read(s.pty.master, &buf) catch 0;
11226 try got.appendSlice(alloc, buf[0..n]);
11227 }
11228 if (std.mem.indexOf(u8, got.items, "[48;30;100;0;0t") != null) break;
11229 }
11230 try std.testing.expect(std.mem.indexOf(u8, got.items, "[48;30;100;0;0t") != null);
11231 }
test/e2e.sh
Old New
@@ -443,6 +443,8 @@ D47PID=""
443 # session shell, so the room it has is the room every other socket in this 443 # session shell, so the room it has is the room every other socket in this
444 # file was given. 444 # file was given.
445 SOCK48="${TMPDIR:-/tmp}/muxd-e2e-agentfwd-$$.sock" 445 SOCK48="${TMPDIR:-/tmp}/muxd-e2e-agentfwd-$$.sock"
446 SOCK51="${TMPDIR:-/tmp}/muxd-e2e-inband-$$.sock"
447 D51PID=""
446 AGENT48="${TMPDIR:-/tmp}/mux-e2e-agent-$$.sock" 448 AGENT48="${TMPDIR:-/tmp}/mux-e2e-agent-$$.sock"
447 AGENT48PID="" 449 AGENT48PID=""
448 AGENT48KEY="${TMPDIR:-/tmp}/mux-e2e-agentkey-$$" 450 AGENT48KEY="${TMPDIR:-/tmp}/mux-e2e-agentkey-$$"
@@ -1284,6 +1286,7 @@ cleanup() {
1284 [ -n "$D41PID" ] && kill "$D41PID" 2>/dev/null || true 1286 [ -n "$D41PID" ] && kill "$D41PID" 2>/dev/null || true
1285 [ -n "$D42PID" ] && kill "$D42PID" 2>/dev/null || true 1287 [ -n "$D42PID" ] && kill "$D42PID" 2>/dev/null || true
1286 [ -n "$D43PID" ] && kill "$D43PID" 2>/dev/null || true 1288 [ -n "$D43PID" ] && kill "$D43PID" 2>/dev/null || true
1289 [ -n "${D51PID:-}" ] && kill "$D51PID" 2>/dev/null || true
1287 # The ssh-agents the forwarding legs start. Not mux processes and so not 1290 # The ssh-agents the forwarding legs start. Not mux processes and so not
1288 # the leak sweep's business, but they are daemons this file forked: left 1291 # the leak sweep's business, but they are daemons this file forked: left
1289 # alive they outlive the suite holding a private key, which is the one 1292 # alive they outlive the suite holding a private key, which is the one
@@ -1556,6 +1559,7 @@ cleanup() {
1556 "$OUT.zscap.err" "$OUT.zs.d" "$OUT.zsfa" "$OUT.zsfb" "$OUT.zspc" \ 1559 "$OUT.zscap.err" "$OUT.zs.d" "$OUT.zsfa" "$OUT.zsfb" "$OUT.zspc" \
1557 "$OUT.zssta" "$OUT.zsstb" "$OUT.zsstop" "$OUT.zswatch" 1560 "$OUT.zssta" "$OUT.zsstb" "$OUT.zsstop" "$OUT.zswatch"
1558 rm -f "$OUT.swcap" "$OUT.swcap.err" "$OUT.swpc" 1561 rm -f "$OUT.swcap" "$OUT.swcap.err" "$OUT.swpc"
1562 rm -f "$OUT.inband" "$OUT.inband.err" "$OUT.inband.log" "$OUT.inband.d" "$OUT.inbstop"
1559 rm -f "$SOCK50" "$OUT.wmse.d" "$OUT.wmsa" "$OUT.wmsa.err" "$OUT.wmsb" \ 1563 rm -f "$SOCK50" "$OUT.wmse.d" "$OUT.wmsa" "$OUT.wmsa.err" "$OUT.wmsb" \
1560 "$OUT.wmsb.err" "$OUT.wmcap" "$OUT.wmcap.err" "$OUT.wmpc" \ 1564 "$OUT.wmsb.err" "$OUT.wmcap" "$OUT.wmcap.err" "$OUT.wmpc" \
1561 "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \ 1565 "$OUT.wmcap2" "$OUT.wmcap2.err" "$OUT.wmpc2" "$OUT.wmfa" "$OUT.wmfb" \
@@ -7832,6 +7836,43 @@ assert_stopped "$SOCK50" "$D48PID" "wall mouse" "$OUT.wmstop"
7832 D48PID="" 7836 D48PID=""
7833 ok "a drag copies on release, and a click copies nothing" 7837 ok "a drag copies on release, and a click copies nothing"
7834 7838
7839 # --- a resize is told in-band to an app that asked for it (mode 2048) ------
7840 #
7841 # nvim 0.11+ asks the terminal for in-band size reports (DECRQM 2048), and
7842 # the engine says "recognised" — so nvim turns the mode on and from then on
7843 # IGNORES SIGWINCH, waiting for `CSI 48;rows;cols;h;w t` on its input. The
7844 # daemon never sent one, and a resized session stayed painted at its old
7845 # size until nvim was restarted (found by hand, 2026-08-22, a drag-resized
7846 # window full of two overlaid screens).
7847 #
7848 # The witness needs no nvim: `cat -v` prints whatever arrives on its stdin,
7849 # so the report — INPUT to the app — lands on the grid as text. Two reports
7850 # are asserted, because they are two code paths: the one the mode-set
7851 # itself owes (the app sizes itself from it), and the one each resize owes.
7852 "$MUXD" run --sock "$SOCK51" --shell /bin/sh > "$OUT.inband.d" 2>&1 &
7853 D51PID=$!
7854 wait_sock "$SOCK51" "$OUT.inband.d" "in-band daemon never bound"
7855 set +e
7856 timeout 40 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.inband" --err "$OUT.inband.err" -- \
7857 "$MUX" --sock "$SOCK51" > "$OUT.inband.log" 2>&1 <<'EOF'
7858 expect \x1b[?1049h 15000
7859 settle 400 15000
7860 send printf '\\033[?2048h'; cat -v\n
7861 expect [48;30;100;0;0t 10000
7862 resize 120 40
7863 expect [48;40;120;0;0t 10000
7864 settle 500 15000
7865 send \x1c\x1c
7866 waitexit 10000
7867 EOF
7868 RC=$?
7869 set -e
7870 [ "$RC" -eq 0 ] || {
7871 echo "e2e FAIL: in-band size: the leg exited $RC:"; cat "$OUT.inband.log"; exit 1; }
7872 assert_stopped "$SOCK51" "$D51PID" "in-band size" "$OUT.inbstop"
7873 D51PID=""
7874 ok "a resize is reported in-band to an app that turned on mode 2048"
7875
7835 # --- an offerer that cannot answer is hung up on, and ssh falls through ---- 7876 # --- an offerer that cannot answer is hung up on, and ssh falls through ----
7836 # 7877 #
7837 # An agent_offer is a declaration, not a capability (decisions.md 7878 # An agent_offer is a declaration, not a capability (decisions.md
@@ -8043,14 +8084,19 @@ DPID=""
8043 # That shipped and was found by hand. This is the check that would have 8084 # That shipped and was found by hand. This is the check that would have
8044 # caught it. 8085 # caught it.
8045 # 8086 #
8046 # The 65th is the mute offerer, and no convergence point for the 58th's 8087 # The 65th is the in-band size report, and no convergence point: its
8088 # subject is what the DAEMON wrote to the app's stdin, read back off the
8089 # grid as the text `cat -v` made of it; the client's replica carries the
8090 # same rows and would add nothing.
8091 #
8092 # The 66th is the mute offerer, and no convergence point for the 58th's
8047 # reason: its subject is whether ssh in the session got an answer, and how 8093 # reason: its subject is whether ssh in the session got an answer, and how
8048 # fast, which is a side channel no grid carries. Last in the file because 8094 # fast, which is a side channel no grid carries. Last in the file because
8049 # it is the one leg that holds an ssh-agent under SIGSTOP, and a trap that 8095 # it is the one leg that holds an ssh-agent under SIGSTOP, and a trap that
8050 # has to CONT before it kills is cheaper to reason about with nothing 8096 # has to CONT before it kills is cheaper to reason about with nothing
8051 # after it. 8097 # after it.
8052 [ "$OK_COUNT" = "65" ] || { 8098 [ "$OK_COUNT" = "66" ] || {
8053 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 65 —" 8099 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 66 —"
8054 echo " a scenario was added (update the pin) or silently lost" 8100 echo " a scenario was added (update the pin) or silently lost"
8055 exit 1 8101 exit 1
8056 } 8102 }
@@ -8058,4 +8104,4 @@ DPID=""
8058 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 36" 8104 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 36"
8059 exit 1 8105 exit 1
8060 } 8106 }
8061 echo "e2e OK (64 scenarios, 36 convergence points)" 8107 echo "e2e OK (65 scenarios, 36 convergence points)"