7c8dbc8a
fix: a resize is reported in-band to an app that turned on mode 2048
a73x 2026-08-22 19:40
Commit message
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)" |