49996784
feat: latest-wins follows input activity, matching the cited tmux semantics
a73x 2026-08-08 14:08
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -39,7 +39,8 @@ frame-agnostic byte pump that contains no protocol knowledge at all. | |||
| 39 | WAN link (see the script header for the variables it needs). | 39 | WAN link (see the script header for the variables it needs). |
| 40 | 40 | ||
| 41 | Multiple mux clients may attach to one session; the grid follows the most | 41 | Multiple mux clients may attach to one session; the grid follows the most |
| 42 | recent attacher/resize (latest wins). | 42 | recently active client — typing, attaching, or resizing claims it (latest |
| 43 | wins). | ||
| 43 | 44 | ||
| 44 | Detach, or kill the client outright — the session survives and `mux` | 45 | Detach, or kill the client outright — the session survives and `mux` |
| 45 | resumes it from a state snapshot, including TUI screens and the primary | 46 | resumes it from a state snapshot, including TUI screens and the primary |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -169,8 +169,23 @@ | |||
| 169 | ## 2026-08-07 (M5) | 169 | ## 2026-08-07 (M5) |
| 170 | 170 | ||
| 171 | - **Resize policy: latest wins.** The authoritative grid follows the most | 171 | - **Resize policy: latest wins.** The authoritative grid follows the most |
| 172 | recent attach or resize event from any client (modern tmux | 172 | recently *active* client: attaching, resizing, or typing claims it |
| 173 | `window-size latest`). Rejected: smallest-wins (punishes the larger | 173 | (modern tmux `window-size latest`). **Amended post-M6 (2026-08-07):** |
| 174 | until then the triggers were attach and resize only, and real two-client | ||
| 175 | use showed the tmux comparison overpromised — tmux's "latest" follows | ||
| 176 | the active client, so typing at a console it had not resized left our | ||
| 177 | grid at the other client's size. Input is now a trigger, applying the | ||
| 178 | size and broadcasting down the same discontinuity path a resize takes: | ||
| 179 | one broadcast on the first keystroke after a switch, nothing on the | ||
| 180 | keystrokes after that, since the sizes then already match. A size the | ||
| 181 | daemon refuses (degenerate, or an engine resize that fails) is neither | ||
| 182 | recorded as that client's size nor broadcast — the grid never moved, so | ||
| 183 | there is nothing to repaint anyone for, and a client whose size was | ||
| 184 | never accepted claims nothing when it types. Scroll | ||
| 185 | fetches are deliberately excluded from "activity" — paging history from | ||
| 186 | a small terminal must not yank the grid away from the client actually | ||
| 187 | working; nor are stats/dump/detach, which are not somebody using the | ||
| 188 | terminal. Rejected: smallest-wins (punishes the larger | ||
| 174 | screen for the smaller one's presence — the handoff calls the result | 189 | screen for the smaller one's presence — the handoff calls the result |
| 175 | "widely disliked"); per-client reflow (needs a per-client engine or | 190 | "widely disliked"); per-client reflow (needs a per-client engine or |
| 176 | reflow pass, contradicting the single-authoritative-grid architecture — | 191 | reflow pass, contradicting the single-authoritative-grid architecture — |
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,8 @@ | |||
| 1 | //! muxd's daemon core: one session (engine + pty), one listener, up to | 1 | //! muxd's daemon core: one session (engine + pty), one listener, up to |
| 2 | //! max_clients attached interactive clients plus a few dump-only observer | 2 | //! max_clients attached interactive clients plus a few dump-only observer |
| 3 | //! connections. Every state update is broadcast to all attached clients; | 3 | //! connections. Every state update is broadcast to all attached clients; |
| 4 | //! the grid follows the most recent attach or resize (latest wins). | 4 | //! the grid follows the most recently active client — typing, attaching or |
| 5 | //! resizing claims it (latest wins). | ||
| 5 | //! Single-threaded; pumpOnce is one poll iteration so tests can drive the | 6 | //! Single-threaded; pumpOnce is one poll iteration so tests can drive the |
| 6 | //! loop. | 7 | //! loop. |
| 7 | const std = @import("std"); | 8 | const std = @import("std"); |
| @@ -203,6 +204,13 @@ pub fn installSignalHandlers() void { | |||
| 203 | /// still owes it. | 204 | /// still owes it. |
| 204 | const ClientSlot = struct { | 205 | const ClientSlot = struct { |
| 205 | fd: std.posix.fd_t, | 206 | fd: std.posix.fd_t, |
| 207 | /// The grid size this client last asked for *and got*: written only | ||
| 208 | /// after an applySize that succeeded, so it is never some other | ||
| 209 | /// client's size. 0x0 until the first accepted attach/resize, which | ||
| 210 | /// claimGrid reads as "makes no claim". Latest-wins re-reads this on | ||
| 211 | /// every keystroke — see the `.input` arm. | ||
| 212 | cols: u16 = 0, | ||
| 213 | rows: u16 = 0, | ||
| 206 | /// Frames queued but not yet accepted by the kernel. Bounded by | 214 | /// Frames queued but not yet accepted by the kernel. Bounded by |
| 207 | /// Server.pending_cap; a peer that stops reading gets dropped, never | 215 | /// Server.pending_cap; a peer that stops reading gets dropped, never |
| 208 | /// waited on — one slow WAN client must not stall the session. | 216 | /// waited on — one slow WAN client must not stall the session. |
| @@ -585,17 +593,38 @@ pub const Server = struct { | |||
| 585 | // resync path isn't worth carrying — snapshot it (which, | 593 | // resync path isn't worth carrying — snapshot it (which, |
| 586 | // like any resize, everyone else hears about too). | 594 | // like any resize, everyone else hears about too). |
| 587 | const req = proto.decodeAttach(frame.payload) catch return; | 595 | const req = proto.decodeAttach(frame.payload) catch return; |
| 588 | self.applySize(req.cols, req.rows); | 596 | if (self.applySize(req.cols, req.rows)) self.recordSize(i); |
| 589 | self.resyncSnapshot(); | 597 | self.resyncSnapshot(); |
| 590 | }, | 598 | }, |
| 591 | .resize => { | 599 | .resize => { |
| 592 | // Latest wins: whoever resized last sets the grid, and the | 600 | // Latest wins: whoever resized last sets the grid, and the |
| 593 | // broadcast snapshot tells every other client about it. | 601 | // broadcast snapshot tells every other client about it. |
| 594 | const sz = proto.decodeSize(frame.payload) catch return; | 602 | const sz = proto.decodeSize(frame.payload) catch return; |
| 595 | self.applySize(sz.cols, sz.rows); | 603 | if (self.applySize(sz.cols, sz.rows)) self.recordSize(i); |
| 596 | self.resyncSnapshot(); | 604 | self.resyncSnapshot(); |
| 597 | }, | 605 | }, |
| 598 | .input => proto.writeAllFd(self.pty.master, frame.payload) catch self.dropClient(i), | 606 | .input => { |
| 607 | // Latest wins follows *activity*, which includes typing: | ||
| 608 | // the console you are typing at claims the grid, exactly as | ||
| 609 | // attaching or resizing from it would. Costs one broadcast | ||
| 610 | // per switch between differently-sized clients — the first | ||
| 611 | // keystroke moves the grid, and every keystroke after that | ||
| 612 | // finds the sizes already equal and does nothing. | ||
| 613 | // | ||
| 614 | // The other client-initiated frames are deliberately not | ||
| 615 | // activity: .fetch_scrollback would let paging history from | ||
| 616 | // a small terminal yank the grid away from the client that | ||
| 617 | // is actually working, and .stats_req/.debug_dump/.detach | ||
| 618 | // are not somebody using the terminal at all. | ||
| 619 | // | ||
| 620 | // Claimed before the bytes go out, so the shell reacts to | ||
| 621 | // this keystroke at the size the typist is watching. The | ||
| 622 | // input is forwarded even if the broadcast dropped this | ||
| 623 | // client — the bytes are already ours, and dropClient on an | ||
| 624 | // empty slot is a no-op. | ||
| 625 | self.claimGrid(i); | ||
| 626 | proto.writeAllFd(self.pty.master, frame.payload) catch self.dropClient(i); | ||
| 627 | }, | ||
| 599 | .stats_req => { | 628 | .stats_req => { |
| 600 | var buf: [stats_text_len]u8 = undefined; | 629 | var buf: [stats_text_len]u8 = undefined; |
| 601 | const text = self.statsText(&buf) catch { | 630 | const text = self.statsText(&buf) catch { |
| @@ -660,11 +689,18 @@ pub const Server = struct { | |||
| 660 | self.observers[i] = null; // promote without closing | 689 | self.observers[i] = null; // promote without closing |
| 661 | self.clients[slot] = .{ .fd = fd }; | 690 | self.clients[slot] = .{ .fd = fd }; |
| 662 | const size_changed = (sz.cols != self.colsNow() or sz.rows != self.rowsNow()); | 691 | const size_changed = (sz.cols != self.colsNow() or sz.rows != self.rowsNow()); |
| 663 | self.applySize(sz.cols, sz.rows); | 692 | const applied = self.applySize(sz.cols, sz.rows); |
| 693 | // The slot's size is what latest-wins re-reads when this | ||
| 694 | // client types, so it is set here and kept current by the | ||
| 695 | // .resize/.attach arms — but only when the grid really went | ||
| 696 | // there. A refused attach must leave the slot at 0x0. | ||
| 697 | if (applied) self.recordSize(slot); | ||
| 664 | // Latest wins: a size change broadcasts, repainting every | 698 | // Latest wins: a size change broadcasts, repainting every |
| 665 | // client at the new attacher's size. A same-size join is | 699 | // client at the new attacher's size. A same-size join is |
| 666 | // the joiner's business alone — see sendResync. | 700 | // the joiner's business alone — see sendResync. So is a |
| 667 | self.sendResync(slot, sz.have_seq, sz.have_epoch, size_changed); | 701 | // refused one: the size differs but the grid never moved, |
| 702 | // so there is nothing to repaint anyone else for. | ||
| 703 | self.sendResync(slot, sz.have_seq, sz.have_epoch, size_changed and applied); | ||
| 668 | }, | 704 | }, |
| 669 | .debug_dump => self.replyDumpObserver(fd, frame.payload) catch self.dropObserver(i), | 705 | .debug_dump => self.replyDumpObserver(fd, frame.payload) catch self.dropObserver(i), |
| 670 | .stats_req => self.replyStatsObserver(fd) catch self.dropObserver(i), | 706 | .stats_req => self.replyStatsObserver(fd) catch self.dropObserver(i), |
| @@ -692,12 +728,44 @@ pub const Server = struct { | |||
| 692 | try proto.writeFrame(fd, .dump_reply, dump); | 728 | try proto.writeFrame(fd, .dump_reply, dump); |
| 693 | } | 729 | } |
| 694 | 730 | ||
| 695 | fn applySize(self: *Server, cols: u16, rows: u16) void { | 731 | /// Take the grid to `cols` x `rows`. Returns whether the grid is now |
| 732 | /// that size — false means the request was refused and nothing moved, | ||
| 733 | /// which is what keeps a refused size out of a client's slot. | ||
| 734 | fn applySize(self: *Server, cols: u16, rows: u16) bool { | ||
| 696 | // A degenerate size (0x0 pty, buggy client) would trip engine | 735 | // A degenerate size (0x0 pty, buggy client) would trip engine |
| 697 | // asserts; keep the current grid instead. | 736 | // asserts; keep the current grid instead. |
| 698 | if (cols < 2 or rows < 2) return; | 737 | if (cols < 2 or rows < 2) return false; |
| 699 | self.eng.resize(cols, rows) catch return; | 738 | self.eng.resize(cols, rows) catch return false; |
| 700 | self.pty.resize(cols, rows) catch {}; | 739 | self.pty.resize(cols, rows) catch {}; |
| 740 | return true; | ||
| 741 | } | ||
| 742 | |||
| 743 | /// Record the grid as client `i`'s size. ONLY call this after an | ||
| 744 | /// applySize that returned true: the grid is then the size this client | ||
| 745 | /// asked for, and recording it is recording the client's own size. Call | ||
| 746 | /// it after a refusal and you record whatever size the grid happens to | ||
| 747 | /// hold — some other client's — which this client would then claim as | ||
| 748 | /// its own the moment it typed. | ||
| 749 | fn recordSize(self: *Server, i: usize) void { | ||
| 750 | if (self.clients[i] == null) return; | ||
| 751 | self.clients[i].?.cols = self.colsNow(); | ||
| 752 | self.clients[i].?.rows = self.rowsNow(); | ||
| 753 | } | ||
| 754 | |||
| 755 | /// Latest wins on activity: bring the grid to client `i`'s size if it | ||
| 756 | /// isn't there already, and tell everyone. Same discontinuity path a | ||
| 757 | /// resize takes, because that is exactly what this is — the difference | ||
| 758 | /// is only what triggered it. | ||
| 759 | fn claimGrid(self: *Server, i: usize) void { | ||
| 760 | if (self.clients[i] == null) return; | ||
| 761 | const slot = &self.clients[i].?; | ||
| 762 | if (slot.cols == self.colsNow() and slot.rows == self.rowsNow()) return; | ||
| 763 | // A client that has never had a size accepted is still 0x0 and | ||
| 764 | // claims nothing — this is what stops a degenerate attacher from | ||
| 765 | // broadcasting a repaint on every keystroke for a resize that | ||
| 766 | // cannot happen. Two compares and a refusal, once per keystroke. | ||
| 767 | if (!self.applySize(slot.cols, slot.rows)) return; | ||
| 768 | self.resyncSnapshot(); | ||
| 701 | } | 769 | } |
| 702 | 770 | ||
| 703 | /// Counterfactual: what M2 would have sent for this one update as a | 771 | /// Counterfactual: what M2 would have sent for this one update as a |
| @@ -1928,6 +1996,211 @@ test "Server: latest attacher's size wins; earlier client is resnapshotted at th | |||
| 1928 | try std.testing.expectEqual(@as(u16, 30), @as(u16, @intCast(srv.eng.term.rows))); | 1996 | try std.testing.expectEqual(@as(u16, 30), @as(u16, @intCast(srv.eng.term.rows))); |
| 1929 | } | 1997 | } |
| 1930 | 1998 | ||
| 1999 | /// Test helper: read `fd` until a snapshot arrives whose prefix reports | ||
| 2000 | /// `cols` x `rows`, discarding everything else. Lenient about what precedes | ||
| 2001 | /// it because a client's own join snapshot and the shell's deltas share the | ||
| 2002 | /// stream with the broadcast under test. | ||
| 2003 | fn awaitSnapshotSize( | ||
| 2004 | alloc: std.mem.Allocator, | ||
| 2005 | fd: std.posix.fd_t, | ||
| 2006 | cols: u16, | ||
| 2007 | rows: u16, | ||
| 2008 | timeout_ms: u64, | ||
| 2009 | ) !bool { | ||
| 2010 | var deadline_ms = timeout_ms; | ||
| 2011 | while (deadline_ms > 0) { | ||
| 2012 | var pfd = [_]std.posix.pollfd{ | ||
| 2013 | .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 2014 | }; | ||
| 2015 | const ready = try std.posix.poll(&pfd, 100); | ||
| 2016 | deadline_ms -|= 100; | ||
| 2017 | if (ready == 0) continue; | ||
| 2018 | const frame = (try proto.readFrame(alloc, fd)) orelse return false; | ||
| 2019 | defer frame.deinit(alloc); | ||
| 2020 | if (frame.type != .snapshot) continue; | ||
| 2021 | const p = try proto.readSnapshotPrefix(frame.payload); | ||
| 2022 | if (p.cols == cols and p.rows == rows) return true; | ||
| 2023 | } | ||
| 2024 | return false; | ||
| 2025 | } | ||
| 2026 | |||
| 2027 | test "Server: typing claims the grid for the typist (latest-wins on input)" { | ||
| 2028 | const alloc = std.testing.allocator; | ||
| 2029 | |||
| 2030 | var tmp = std.testing.tmpDir(.{}); | ||
| 2031 | defer tmp.cleanup(); | ||
| 2032 | var path_buf: [256]u8 = undefined; | ||
| 2033 | const dir_path = try tmp.dir.realpath(".", &path_buf); | ||
| 2034 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/typing.sock", .{dir_path}); | ||
| 2035 | defer alloc.free(sock_path); | ||
| 2036 | |||
| 2037 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" }); | ||
| 2038 | defer srv.deinit(); | ||
| 2039 | |||
| 2040 | var stop = std.atomic.Value(bool).init(false); | ||
| 2041 | const th = try std.Thread.spawn(.{}, serverThread, .{ &srv, &stop }); | ||
| 2042 | defer th.join(); | ||
| 2043 | defer stop.store(true, .release); | ||
| 2044 | |||
| 2045 | // A joins first and, being the only client, sets the grid: attach-wins | ||
| 2046 | // is unchanged by any of this. | ||
| 2047 | const a = try std.net.connectUnixSocket(sock_path); | ||
| 2048 | defer a.close(); | ||
| 2049 | try proto.writeFrame(a.handle, .attach, &proto.encodeAttach(100, 30, 0, 0)); | ||
| 2050 | try std.testing.expect(try awaitSnapshotSize(alloc, a.handle, 100, 30, 10_000)); | ||
| 2051 | |||
| 2052 | // B joins at a different size and takes the grid, which A is told about. | ||
| 2053 | const b = try std.net.connectUnixSocket(sock_path); | ||
| 2054 | defer b.close(); | ||
| 2055 | try proto.writeFrame(b.handle, .attach, &proto.encodeAttach(80, 24, 0, 0)); | ||
| 2056 | try std.testing.expect(try awaitSnapshotSize(alloc, a.handle, 80, 24, 10_000)); | ||
| 2057 | try std.testing.expect(try awaitSnapshotSize(alloc, b.handle, 80, 24, 10_000)); | ||
| 2058 | |||
| 2059 | // Quiet on both sides, so what follows is unambiguously the typing's | ||
| 2060 | // doing and not a frame still in flight from the join. | ||
| 2061 | _ = try drainHeld(alloc, a.handle, 10_000); | ||
| 2062 | _ = try drainHeld(alloc, b.handle, 10_000); | ||
| 2063 | |||
| 2064 | // The point of the test: A types without resizing or re-attaching, and | ||
| 2065 | // the grid moves back to A's size for everyone. | ||
| 2066 | try proto.writeFrame(a.handle, .input, "echo from-a\n"); | ||
| 2067 | try std.testing.expect(try awaitSnapshotSize(alloc, a.handle, 100, 30, 10_000)); | ||
| 2068 | try std.testing.expect(try awaitSnapshotSize(alloc, b.handle, 100, 30, 10_000)); | ||
| 2069 | |||
| 2070 | _ = try drainHeld(alloc, a.handle, 10_000); | ||
| 2071 | _ = try drainHeld(alloc, b.handle, 10_000); | ||
| 2072 | |||
| 2073 | // ...and it switches back when B types, so this is "most recent", not | ||
| 2074 | // "first" or "largest". | ||
| 2075 | try proto.writeFrame(b.handle, .input, "echo from-b\n"); | ||
| 2076 | try std.testing.expect(try awaitSnapshotSize(alloc, a.handle, 80, 24, 10_000)); | ||
| 2077 | try std.testing.expect(try awaitSnapshotSize(alloc, b.handle, 80, 24, 10_000)); | ||
| 2078 | |||
| 2079 | _ = try drainHeld(alloc, a.handle, 10_000); | ||
| 2080 | _ = try drainHeld(alloc, b.handle, 10_000); | ||
| 2081 | |||
| 2082 | // A second keystroke from the same client finds the grid already its | ||
| 2083 | // size and must claim nothing: a snapshot here would mean every | ||
| 2084 | // keystroke of an ordinary session pays for a full repaint. | ||
| 2085 | try proto.writeFrame(b.handle, .input, "echo again-from-b\n"); | ||
| 2086 | const first = try firstStateFrame(alloc, b.handle, 10_000); | ||
| 2087 | try std.testing.expect(first != null); | ||
| 2088 | try std.testing.expectEqual(proto.MsgType.delta, first.?.type); | ||
| 2089 | } | ||
| 2090 | |||
| 2091 | /// Test helper: watch `fd` until `marker` shows up in a replica built from | ||
| 2092 | /// what it sends, and fail if any of it is a snapshot. The replica starts | ||
| 2093 | /// blank on purpose — a delta carries every row it changed, so the row the | ||
| 2094 | /// marker lands on arrives whole. | ||
| 2095 | fn awaitMarkerWithoutSnapshot( | ||
| 2096 | alloc: std.mem.Allocator, | ||
| 2097 | fd: std.posix.fd_t, | ||
| 2098 | cols: u16, | ||
| 2099 | rows: u16, | ||
| 2100 | marker: []const u8, | ||
| 2101 | timeout_ms: u64, | ||
| 2102 | ) !bool { | ||
| 2103 | var replica = try Engine.init(alloc, .{ .cols = cols, .rows = rows }); | ||
| 2104 | defer replica.deinit(); | ||
| 2105 | var deadline_ms = timeout_ms; | ||
| 2106 | while (deadline_ms > 0) { | ||
| 2107 | var pfd = [_]std.posix.pollfd{ | ||
| 2108 | .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 2109 | }; | ||
| 2110 | const ready = try std.posix.poll(&pfd, 100); | ||
| 2111 | deadline_ms -|= 100; | ||
| 2112 | if (ready == 0) continue; | ||
| 2113 | const frame = (try proto.readFrame(alloc, fd)) orelse return false; | ||
| 2114 | defer frame.deinit(alloc); | ||
| 2115 | if (frame.type == .snapshot) return error.UnexpectedSnapshotBroadcast; | ||
| 2116 | if (frame.type != .delta) continue; | ||
| 2117 | try applyFrame(alloc, replica, frame); | ||
| 2118 | const plain = try replica.dumpPlain(alloc); | ||
| 2119 | defer alloc.free(plain); | ||
| 2120 | if (std.mem.indexOf(u8, plain, marker) != null) return true; | ||
| 2121 | } | ||
| 2122 | return false; | ||
| 2123 | } | ||
| 2124 | |||
| 2125 | test "Server: a size the grid refuses never becomes a claim" { | ||
| 2126 | const alloc = std.testing.allocator; | ||
| 2127 | |||
| 2128 | var tmp = std.testing.tmpDir(.{}); | ||
| 2129 | defer tmp.cleanup(); | ||
| 2130 | var path_buf: [256]u8 = undefined; | ||
| 2131 | const dir_path = try tmp.dir.realpath(".", &path_buf); | ||
| 2132 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/refused.sock", .{dir_path}); | ||
| 2133 | defer alloc.free(sock_path); | ||
| 2134 | |||
| 2135 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" }); | ||
| 2136 | defer srv.deinit(); | ||
| 2137 | |||
| 2138 | var stop = std.atomic.Value(bool).init(false); | ||
| 2139 | const th = try std.Thread.spawn(.{}, serverThread, .{ &srv, &stop }); | ||
| 2140 | // Stopped and joined explicitly below so the last assertion can read | ||
| 2141 | // server state without racing the daemon thread. | ||
| 2142 | var joined = false; | ||
| 2143 | defer if (!joined) { | ||
| 2144 | stop.store(true, .release); | ||
| 2145 | th.join(); | ||
| 2146 | }; | ||
| 2147 | |||
| 2148 | const a = try std.net.connectUnixSocket(sock_path); | ||
| 2149 | defer a.close(); | ||
| 2150 | try proto.writeFrame(a.handle, .attach, &proto.encodeAttach(80, 24, 0, 0)); | ||
| 2151 | try std.testing.expect(try awaitSnapshotSize(alloc, a.handle, 80, 24, 10_000)); | ||
| 2152 | _ = try drainHeld(alloc, a.handle, 10_000); | ||
| 2153 | |||
| 2154 | // D asks for a size applySize will not have (1x1 trips engine asserts). | ||
| 2155 | // It still joins and is still served — it is only its *size* that is | ||
| 2156 | // refused — but the grid must not move, and A must not be repainted for | ||
| 2157 | // a resize that never happened. | ||
| 2158 | const d = try std.net.connectUnixSocket(sock_path); | ||
| 2159 | defer d.close(); | ||
| 2160 | try proto.writeFrame(d.handle, .attach, &proto.encodeAttach(1, 1, 0, 0)); | ||
| 2161 | try std.testing.expect(try awaitSnapshotSize(alloc, d.handle, 80, 24, 10_000)); | ||
| 2162 | |||
| 2163 | // D types. Its slot holds no accepted size, so this claims nothing: A | ||
| 2164 | // sees the output as deltas and no snapshot at all. That the marker | ||
| 2165 | // arrives at all is the other half — a refused size must not cost the | ||
| 2166 | // client its keystrokes. | ||
| 2167 | try proto.writeFrame(d.handle, .input, "echo typed-by-refused\n"); | ||
| 2168 | try std.testing.expect(try awaitMarkerWithoutSnapshot( | ||
| 2169 | alloc, | ||
| 2170 | a.handle, | ||
| 2171 | 80, | ||
| 2172 | 24, | ||
| 2173 | "typed-by-refused", | ||
| 2174 | 10_000, | ||
| 2175 | )); | ||
| 2176 | |||
| 2177 | // The reviewer's repro: a real client moves the grid, and then D types | ||
| 2178 | // again. If D's slot had been stamped with the grid it found at attach | ||
| 2179 | // (80x24), this keystroke would drag everyone back to a size neither | ||
| 2180 | // client asked for. | ||
| 2181 | try proto.writeFrame(a.handle, .resize, &proto.encodeSize(100, 30)); | ||
| 2182 | try std.testing.expect(try awaitSnapshotSize(alloc, a.handle, 100, 30, 10_000)); | ||
| 2183 | _ = try drainHeld(alloc, a.handle, 10_000); | ||
| 2184 | _ = try drainHeld(alloc, d.handle, 10_000); | ||
| 2185 | |||
| 2186 | try proto.writeFrame(d.handle, .input, "echo typed-again\n"); | ||
| 2187 | try std.testing.expect(try awaitMarkerWithoutSnapshot( | ||
| 2188 | alloc, | ||
| 2189 | a.handle, | ||
| 2190 | 100, | ||
| 2191 | 30, | ||
| 2192 | "typed-again", | ||
| 2193 | 10_000, | ||
| 2194 | )); | ||
| 2195 | |||
| 2196 | stop.store(true, .release); | ||
| 2197 | th.join(); | ||
| 2198 | joined = true; | ||
| 2199 | // The grid is where the only client with an accepted size put it. | ||
| 2200 | try std.testing.expectEqual(@as(u16, 100), @as(u16, @intCast(srv.eng.term.cols))); | ||
| 2201 | try std.testing.expectEqual(@as(u16, 30), @as(u16, @intCast(srv.eng.term.rows))); | ||
| 2202 | } | ||
| 2203 | |||
| 1931 | test "Server: scrollback fetch is per-client and independent" { | 2204 | test "Server: scrollback fetch is per-client and independent" { |
| 1932 | const alloc = std.testing.allocator; | 2205 | const alloc = std.testing.allocator; |
| 1933 | 2206 | ||