a73x

49996784

feat: latest-wins follows input activity, matching the cited tmux semantics

a73x   2026-08-08 14:08

Commit message
feat: latest-wins follows input activity, matching the cited tmux semantics

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