a73x

b585c470

feat: the wall zooms in place — promote the tile, do not spawn a client

a73x   2026-08-19 22:51

Commit message
feat: the wall zooms in place — promote the tile, do not spawn a client

`Enter` used to hand the terminal to a real `mux` child, because a tile
attaches 0x0 and a 0x0 client must never claim a grid. It now PROMOTES the
tile's existing connection instead: resize to this terminal's size, then
forward keystrokes down it. Latest-wins already makes a resize the legitimate
claim, and the resize goes out before the first keystroke can — so the client
that types is a full-size one and the passivity rule keeps no exception. The
daemon changes not at all: no new attach, no dial, no snapshot round trip.

Unzooming demotes, and demote is client-local: nothing goes on the wire. The
slot keeps its promoted size deliberately (applySize refuses sub-minimum, so
there is no 0x0 to hand back, and a size nobody types at claims nothing).
"An unzoomed tile claims nothing" is kept by the keyboard never queueing a
byte for a tile it is not zoomed into — `sendKeys` has exactly one caller.

Because every tile's replica was hot all along, moving the zoom is a local
repaint: `Ctrl-\ n`/`p` step the zoom between tiles and `Ctrl-\ l` skips back
to the last one, at zero round trips. `Ctrl-\ d` and `Ctrl-\ w` both unzoom.

Input leaves on the PUMP's thread, through a mailbox and a doorbell pipe: a
Transport has one owning thread (QUIC's service/writeFrame share state, and a
reconnect swaps the struct out under it). Mouse reporting is deliberately not
wired up — the wall never asks its terminal for mouse reports, so a zoomed
tile is keyboard-only until phase 3 converges the two input loops and it
comes for free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

build.zig
Old New
@@ -242,11 +242,14 @@ const mod_table = [_]ModSpec{
242 // sockpath is the sun_path bound a `--sock` tile is refused against — 242 // sockpath is the sun_path bound a `--sock` tile is refused against —
243 // the check argv used to make before the Hub owned resolution. 243 // the check argv used to make before the Hub owned resolution.
244 .{ .name = "webhub", .path = "src/webhub.zig", .layer = 3, .imports = &.{ "protocol", "client", "wall", "handoff", "xdg", "sockpath" }, .quic_tests = true }, 244 .{ .name = "webhub", .path = "src/webhub.zig", .layer = 3, .imports = &.{ "protocol", "client", "wall", "handoff", "xdg", "sockpath" }, .quic_tests = true },
245 // The CLI wall (`mux wall`): passive multiattach stripes in one 245 // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of
246 // terminal. Same layer as webhub for the same reason — both sit on 246 // which can be ZOOMED — promoted to the terminal's size and typed
247 // through. Same layer as webhub for the same reason — both sit on
247 // client's Transport and wall's grammar; neither may import the other, 248 // client's Transport and wall's grammar; neither may import the other,
248 // which is why each carries its own spelling→Target resolution. 249 // which is why each carries its own spelling→Target resolution.
249 .{ .name = "wallview", .path = "src/wallview.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "client", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "replica", "paint" }, .quic_tests = true }, 250 // `predict` is here because a zoomed tile speculates like any other
251 // typed-at session; the overlay machinery itself is client's, shared.
252 .{ .name = "wallview", .path = "src/wallview.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "client", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "replica", "paint", "predict" }, .quic_tests = true },
250 // The daemon entrypoint loads the key and constructs the listener, so 253 // The daemon entrypoint loads the key and constructs the listener, so
251 // it needs quic/quic_server directly rather than through the server. 254 // it needs quic/quic_server directly rather than through the server.
252 // `muxd endpoint` prints the announce line handoff spells; sockpath is 255 // `muxd endpoint` prints the announce line handoff spells; sockpath is
src/server.zig
Old New
@@ -103,6 +103,23 @@ const Stats = struct {
103 /// What the same updates would have cost as full snapshots (M2 model): 103 /// What the same updates would have cost as full snapshots (M2 model):
104 /// measured, not estimated — dumpState length at each delta send. 104 /// measured, not estimated — dumpState length at each delta send.
105 snapshot_equiv_bytes: u64 = 0, 105 snapshot_equiv_bytes: u64 = 0,
106 /// Every `.attach` this daemon ACCEPTED, cumulative and monotonic.
107 ///
108 /// A counter, deliberately, where `clients=` is a gauge. The gauge
109 /// answers "who is watching right now" and cannot answer "did anyone
110 /// attach at all since I last looked": a client that attaches and
111 /// leaves between two samples is invisible to it, and so is one that
112 /// closes as another opens. The CLI wall's in-place zoom is exactly
113 /// the claim that needs the second question answered — it promotes a
114 /// connection it already holds, so the honest proof is that this
115 /// number did not move across a zoom, a skip and an unzoom.
116 ///
117 /// Counted where an attach SUCCEEDS (a session resolved and the client
118 /// is seated), not where the frame arrives: a refusal attached nobody.
119 /// Both arms count, because a socket client's first attach promotes an
120 /// observer and every later one — a QUIC first attach included —
121 /// arrives on an established connection.
122 attaches: u64 = 0,
106 }; 123 };
107 124
108 var shutdown_flag = std.atomic.Value(bool).init(false); 125 var shutdown_flag = std.atomic.Value(bool).init(false);
@@ -1690,6 +1707,9 @@ pub const Server = struct {
1690 _ = self.queueFrame(i, .exit_status, &.{1}); 1707 _ = self.queueFrame(i, .exit_status, &.{1});
1691 return; 1708 return;
1692 }; 1709 };
1710 // Past the refusal, so the counter never grows for an
1711 // attach that attached nobody. See Stats.attaches.
1712 self.stats.attaches += 1;
1693 if (self.clients[i]) |*c| { 1713 if (self.clients[i]) |*c| {
1694 // An await's since_seq is a watermark in the OLD 1714 // An await's since_seq is a watermark in the OLD
1695 // session's tracker, and seq series are per-session — 1715 // session's tracker, and seq series are per-session —
@@ -2084,6 +2104,9 @@ pub const Server = struct {
2084 self.dropObserver(i); 2104 self.dropObserver(i);
2085 return; 2105 return;
2086 }; 2106 };
2107 // Past both refusals above, so the counter only ever grows
2108 // for an attach that seated somebody. See Stats.attaches.
2109 self.stats.attaches += 1;
2087 self.observers[i] = null; // promote without closing 2110 self.observers[i] = null; // promote without closing
2088 self.clients[slot] = .{ .sink = .{ .socket = fd }, .session = si }; 2111 self.clients[slot] = .{ .sink = .{ .socket = fd }, .session = si };
2089 const size_changed = (sz.cols != self.colsNow(si) or sz.rows != self.rowsNow(si)); 2112 const size_changed = (sz.cols != self.colsNow(si) or sz.rows != self.rowsNow(si));
@@ -2820,22 +2843,25 @@ pub const Server = struct {
2820 // per live session up to max_sessions, and 256 stopped being enough 2843 // per live session up to max_sessions, and 256 stopped being enough
2821 // headroom for that plus the longest legal names. 2844 // headroom for that plus the longest legal names.
2822 // 2845 //
2823 // The arithmetic the 512 answers to: the main line's five u64 counters 2846 // The arithmetic the 576 answers to: the main line's six u64 counters
2824 // (snapshots, snapshot_bytes, deltas, delta_bytes, snapshot_equiv_bytes) 2847 // (snapshots, snapshot_bytes, deltas, delta_bytes, snapshot_equiv_bytes,
2825 // plus the two gauges (clients, sessions), each with its "name=" label 2848 // attaches) plus the two gauges (clients, sessions), each with its
2826 // and a generous 20 digits for a u64 at its widest, comes to roughly 2849 // "name=" label and a generous 20 digits for a u64 at its widest, comes
2827 // 190 bytes. Each per-session segment is 44 bytes before the name: 23 2850 // to roughly 220 bytes. `attaches` is what took it from 190 — and the
2851 // comptime assert below is what SAID so, failing the build the moment
2852 // the field was added rather than truncating somebody's stats reply at
2853 // four sessions. Each per-session segment is 44 bytes before the name: 23
2828 // of literal text (" session " + " clients=" + " seq=", 9 + 9 + 5) and 2854 // of literal text (" session " + " clients=" + " seq=", 9 + 9 + 5) and
2829 // 21 of digits — one for clients, which max_clients bounds at 8, and 20 2855 // 21 of digits — one for clients, which max_clients bounds at 8, and 20
2830 // for seq at a u64's widest — plus up to session_name_max (32) bytes of 2856 // for seq at a u64's widest — plus up to session_name_max (32) bytes of
2831 // the name itself, so ~76 bytes 2857 // the name itself, so ~76 bytes
2832 // a session. Four sessions (max_sessions): 190 + 4*76 = 494 of 512 — 2858 // a session. Four sessions (max_sessions): 220 + 4*76 = 524 of 576 —
2833 // headroom, not a coincidence, and pinned below so a future 2859 // headroom, not a coincidence, and pinned below so a future
2834 // max_sessions bump fails the build instead of silently truncating 2860 // max_sessions bump (or another main-line field) fails the build
2835 // whoever asks for stats. 2861 // instead of silently truncating whoever asks for stats.
2836 pub const stats_text_len = 512; 2862 pub const stats_text_len = 576;
2837 comptime { 2863 comptime {
2838 std.debug.assert(stats_text_len >= 190 + max_sessions * (44 + proto.session_name_max)); 2864 std.debug.assert(stats_text_len >= 220 + max_sessions * (44 + proto.session_name_max));
2839 } 2865 }
2840 2866
2841 /// Every live name plus one separator each — one more separator than a 2867 /// Every live name plus one separator each — one more separator than a
@@ -2920,12 +2946,12 @@ pub const Server = struct {
2920 var w: std.Io.Writer = .fixed(buf); 2946 var w: std.Io.Writer = .fixed(buf);
2921 try w.print( 2947 try w.print(
2922 "snapshots={d} snapshot_bytes={d} deltas={d} delta_bytes={d}" ++ 2948 "snapshots={d} snapshot_bytes={d} deltas={d} delta_bytes={d}" ++
2923 " snapshot_equiv_bytes={d} clients={d} sessions={d}", 2949 " snapshot_equiv_bytes={d} clients={d} attaches={d} sessions={d}",
2924 .{ 2950 .{
2925 self.stats.snapshots, self.stats.snapshot_bytes, 2951 self.stats.snapshots, self.stats.snapshot_bytes,
2926 self.stats.deltas, self.stats.delta_bytes, 2952 self.stats.deltas, self.stats.delta_bytes,
2927 self.stats.snapshot_equiv_bytes, self.liveClients(), 2953 self.stats.snapshot_equiv_bytes, self.liveClients(),
2928 self.liveSessions(), 2954 self.stats.attaches, self.liveSessions(),
2929 }, 2955 },
2930 ); 2956 );
2931 for (self.sessions, 0..) |slot, si| { 2957 for (self.sessions, 0..) |slot, si| {
@@ -5912,6 +5938,23 @@ test "Server: stats reports live client slots, and the number comes down again"
5912 srv.clients[3] = null; 5938 srv.clients[3] = null;
5913 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "clients=0") != null); 5939 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "clients=0") != null);
5914 5940
5941 // ...and `attaches=` is the counter beside it, which is the whole
5942 // reason both exist. Occupancy went 0 → 2 → 0 above WITHOUT an attach
5943 // frame ever arriving, so the counter is still 0 here: what it answers
5944 // is "did anyone attach since I last looked", which no gauge can, and
5945 // that is what the CLI wall's zoom is proved against. It only ever
5946 // goes up.
5947 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "attaches=0") != null);
5948 srv.stats.attaches += 1;
5949 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "attaches=1") != null);
5950 srv.clients[0] = .{ .sink = .{ .socket = -1 } };
5951 // A second client seating itself does not move it either — only the
5952 // attach handler does, and a gauge moving is not an attach.
5953 const both = try srv.statsText(&buf);
5954 try std.testing.expect(std.mem.indexOf(u8, both, "clients=1") != null);
5955 try std.testing.expect(std.mem.indexOf(u8, both, "attaches=1") != null);
5956 srv.clients[0] = null;
5957
5915 // The fields the harnesses parse are still where they were: appended, 5958 // The fields the harnesses parse are still where they were: appended,
5916 // never reordered. The old leading `seq=` was one session's tracker 5959 // never reordered. The old leading `seq=` was one session's tracker
5917 // and moved into the per-session tail (M18); the main line now starts 5960 // and moved into the per-session tail (M18); the main line now starts
src/wallview.zig
Old New
@@ -2,17 +2,40 @@
2 //! once, the same multiattach the browser hub gives, without a browser. 2 //! once, the same multiattach the browser hub gives, without a browser.
3 //! Targets are the wall grammar's spellings (wall.zig), so `mux wall` with 3 //! Targets are the wall grammar's spellings (wall.zig), so `mux wall` with
4 //! no arguments shows the SAME wall the browser built — one state file, 4 //! no arguments shows the SAME wall the browser built — one state file,
5 //! one grammar. Tiles are passive: every attach is 0x0 (join, never claim 5 //! one grammar. Every tile attaches at 0x0 (join, never claim the grid,
6 //! the grid, never create a session — muxa's discipline), and no input is 6 //! never create a session — muxa's discipline). `q` or Ctrl-\ leaves.
7 //! forwarded. `q` or Ctrl-\ leaves.
8 //! 7 //!
9 //! One stripe is SELECTED (`j`/`k` or `n`/`p` to move, `1`-`9` to jump); 8 //! One stripe is SELECTED (`j`/`k` or `n`/`p` to move, `1`-`9` to jump);
10 //! its label bar carries a `> ` marker. `Enter` ZOOMS it: the wall hands 9 //! its label bar carries a `> ` marker. `Enter` ZOOMS it, in place.
11 //! the terminal to a real `mux` child on that tile's session and takes it 10 //!
12 //! back when the child detaches. Typing through a tile is not on offer — 11 //! ZOOM IS A LENS, NOT A MODE. An unzoomed tile claims nothing; zooming
13 //! a tile's attach is 0x0 and a 0x0 client must never claim the grid, so 12 //! PROMOTES that tile's existing connection and unzooming DEMOTES it.
14 //! interaction claims it the legitimate way, with an attach of its own. 13 //!
15 //! The wall stays an observer; the zoom is a client. 14 //! * Promote resizes the tile's attach from 0x0 to this terminal's size
15 //! and starts forwarding keystrokes down it. Latest-wins already makes
16 //! a resize the legitimate claim on a grid, and the resize goes out
17 //! BEFORE the first keystroke — so the client that types is a
18 //! full-size one and the passivity rule keeps no exception. The daemon
19 //! changes not at all: no new attach, no dial, no snapshot round trip.
20 //! The replica was hot the whole time the tile was a stripe, so the
21 //! zoom paints full-screen from it immediately. Moving the zoom
22 //! between tiles is therefore a local repaint, at zero round trips.
23 //! * Demote is CLIENT-LOCAL and sends nothing at all. The slot keeps its
24 //! promoted size at the daemon deliberately: `applySize` refuses
25 //! sub-minimum resizes, so there is no 0x0 to hand back, and a size
26 //! nobody types at claims nothing under latest-wins. "Claims nothing"
27 //! is kept by the wall NEVER queueing a byte for a tile it is not
28 //! zoomed into — not by the slot reading 0x0. The session keeps its
29 //! full-size grid (no resize storm) and the stripe crops it, which is
30 //! what stripes already do.
31 //!
32 //! Zoomed, the terminal belongs to the session: `Ctrl-\` is its command
33 //! prefix, read out of client.zig's PrefixFilter so there is one chord
34 //! table and not a twin. `d` and `w` both unzoom (`d` is the muscle memory
35 //! the child-spawn era left behind, `w` is where the model is going),
36 //! `n`/`p` move the zoom to the next/previous TILE, `l` skips back to the
37 //! last tile zoomed — and unzooms when there is none. Everything else the
38 //! prefix takes is swallowed, exactly as in a client.
16 //! 39 //!
17 //! Layout is horizontal stripes, not a column grid: a stripe gets the full 40 //! Layout is horizontal stripes, not a column grid: a stripe gets the full
18 //! terminal width, so long rows clip at the terminal's right edge (DECAWM 41 //! terminal width, so long rows clip at the terminal's right edge (DECAWM
@@ -35,6 +58,13 @@
35 //! exactly the bug the hub already paid for. Stripe paints serialize on 58 //! exactly the bug the hub already paid for. Stripe paints serialize on
36 //! one mutex; each paint is one synchronized-update write. 59 //! one mutex; each paint is one synchronized-update write.
37 //! 60 //!
61 //! That thread also OWNS its transport, both directions. The keyboard runs
62 //! on the main thread and hands typed bytes to the pump through a mailbox
63 //! and a doorbell pipe rather than writing them itself: a `Transport` is
64 //! single-threaded (QUIC's `service()` and `writeFrame` share state, and a
65 //! reconnect swaps the whole struct out from under the pump), so one
66 //! thread per transport is a rule and not a preference.
67 //!
38 //! Spelling → Target resolution mirrors the hub's resolveTile (webhub.zig) 68 //! Spelling → Target resolution mirrors the hub's resolveTile (webhub.zig)
39 //! rather than sharing it: both are layer 3, so neither may import the 69 //! rather than sharing it: both are layer 3, so neither may import the
40 //! other. Third copy wants a shared home below client — noted, not built. 70 //! other. Third copy wants a shared home below client — noted, not built.
@@ -49,6 +79,7 @@ const proxy = @import("proxy");
49 const Engine = @import("engine").Engine; 79 const Engine = @import("engine").Engine;
50 const Replica = @import("replica").Replica; 80 const Replica = @import("replica").Replica;
51 const paint = @import("paint"); 81 const paint = @import("paint");
82 const predict = @import("predict");
52 83
53 pub const Resolved = struct { 84 pub const Resolved = struct {
54 target: client.Target, 85 target: client.Target,
@@ -150,27 +181,42 @@ const State = enum {
150 } 181 }
151 }; 182 };
152 183
184 /// `Shared.zoom` when the wall is zoomed OUT — no tile owns the terminal.
185 /// A sentinel rather than an optional so the field can be atomic: every
186 /// pump reads it on every paint decision.
187 const no_zoom: usize = std.math.maxInt(usize);
188
189 /// The mailbox: how many typed bytes can wait for a pump to put them on
190 /// the wire. One stdin read's worth, which is all a keyboard can produce
191 /// between two turns of a pump's poll loop.
192 const mailbox_max = 4096;
193
153 const Shared = struct { 194 const Shared = struct {
154 running: std.atomic.Value(bool) = std.atomic.Value(bool).init(true), 195 running: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
155 /// Serializes every write to the terminal. Held (and never released) 196 /// Serializes every write to the terminal. Held (and never released)
156 /// at teardown, so no stripe paints across the restore, and held for a 197 /// at teardown, so no stripe paints across the restore, and held
157 /// zoomed child's whole life, so no stripe paints over the child. 198 /// across a zoom move, so no stripe paints into the gap between the
199 /// screen being cleared and the terminal changing hands.
158 paint_mu: std.Thread.Mutex = .{}, 200 paint_mu: std.Thread.Mutex = .{},
159 out_fd: std.posix.fd_t, 201 out_fd: std.posix.fd_t,
160 cols: u16, 202 /// The terminal, measured once at startup (there is no SIGWINCH
203 /// handler — see the module header). Stripes are cut from it and a
204 /// promoted tile claims exactly it.
205 size: proto.Size,
161 /// The selected tile's index. Under `paint_mu` rather than atomic 206 /// The selected tile's index. Under `paint_mu` rather than atomic
162 /// because it is read while a label bar is being drawn: a pump 207 /// because it is read while a label bar is being drawn: a pump
163 /// repainting on a frame must draw the selection the keyboard has, 208 /// repainting on a frame must draw the selection the keyboard has,
164 /// not the one it had when the frame arrived. 209 /// not the one it had when the frame arrived.
165 sel: usize = 0, 210 sel: usize = 0,
166 /// What a zoom that did not work out has to say, shown in place of the 211 /// Which tile the terminal currently belongs to, or `no_zoom` for the
167 /// selected tile's state word — the alternate screen would eat a line 212 /// wall. Written only by the keyboard loop, and only under `paint_mu`
168 /// written to stderr. Under `paint_mu`; cleared by the next key. 213 /// — a pump deciding what it may draw must not see the zoom move
169 notice: ?[]const u8 = null, 214 /// between that decision and the bytes going out.
215 zoom: std.atomic.Value(usize) = std.atomic.Value(usize).init(no_zoom),
170 /// Bumped when the terminal's contents are no longer anybody's paint — 216 /// Bumped when the terminal's contents are no longer anybody's paint —
171 /// a zoomed child owned the screen. Pumps compare it against what they 217 /// a zoom owned the screen, or has just given it back. Pumps compare
172 /// last painted at, which is the only thing that repaints a stripe 218 /// it against what they last painted at, which is the only thing that
173 /// whose session sent no new frame while the child was up. 219 /// repaints a stripe whose session sent no new frame meanwhile.
174 repaint_gen: std.atomic.Value(u64) = std.atomic.Value(u64).init(0), 220 repaint_gen: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
175 }; 221 };
176 222
@@ -185,18 +231,49 @@ const Tile = struct {
185 /// here (under `paint_mu`) because the KEYBOARD repaints this bar too, 231 /// here (under `paint_mu`) because the KEYBOARD repaints this bar too,
186 /// when the selection moves, and it has no other way to know it. 232 /// when the selection moves, and it has no other way to know it.
187 state: State = .connecting, 233 state: State = .connecting,
234 /// Whether this tile's pump thread is still running.
235 ///
236 /// Only pumps answer `repaint_gen`, so a tile whose pump has ENDED — a
237 /// refused attach, a session that exited — has nobody to redraw it
238 /// after a screen clear. That was invisible while a clear happened once
239 /// per zoom; phase 1 clears on every `n`/`p`/`l` too, so a dead stripe
240 /// would vanish for the wall's whole remaining life, and zooming one
241 /// would paint an entirely blank terminal with no cursor and no way
242 /// out that the screen admits to. The keyboard reads this and paints
243 /// for the dead, which is the only paint it ever does on a tile's
244 /// behalf.
245 ///
246 /// Deliberately ONLY for dead pumps. A tile that is merely
247 /// `[reconnecting]` still has a thread that will repaint its hot
248 /// replica within a poll timeout, and letting the keyboard draw over
249 /// that would replace something true with something stale.
250 alive: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
251 /// The doorbell. The keyboard writes one byte here to wake this tile's
252 /// pump; the bytes themselves carry nothing, the mailbox does. Both
253 /// ends are non-blocking, which is what makes a bell that is already
254 /// ringing free to ring again.
255 wake_r: std.posix.fd_t = -1,
256 wake_w: std.posix.fd_t = -1,
257 /// Bytes typed at this tile while it was zoomed, waiting for its pump.
258 /// The keyboard is the only writer and it writes ONLY while this tile
259 /// is the zoom — which is the entire enforcement of "an unzoomed tile
260 /// claims nothing". The pump therefore sends whatever it finds here
261 /// without re-checking the zoom: bytes typed at a session belong to
262 /// that session even if the zoom has moved on since.
263 in_mu: std.Thread.Mutex = .{},
264 in: [mailbox_max]u8 = undefined,
265 in_len: usize = 0,
266 /// A chunk this tile's mailbox had no room for, and so never sent.
267 /// Sticky until the pump next gets input out, which is the moment the
268 /// news stops being current; the label bar narrates it meanwhile, so
269 /// keystrokes never vanish without the wall admitting to it.
270 in_dropped: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
188 271
189 fn viewRows(t: *const Tile) u16 { 272 fn viewRows(t: *const Tile) u16 {
190 return t.stripe.rows - 1; 273 return t.stripe.rows - 1;
191 } 274 }
192 }; 275 };
193 276
194 /// What a failed zoom puts in the label bar. Static strings: formatting a
195 /// message under `paint_mu` with an allocator in hand is more machinery
196 /// than "it did not start" deserves.
197 const zoom_failed = "zoom failed";
198 const zoom_nonzero = "zoom exited nonzero";
199
200 /// Where a key takes the selection, or null for "not a selection key" — 277 /// Where a key takes the selection, or null for "not a selection key" —
201 /// including a digit past the last tile, which is swallowed rather than 278 /// including a digit past the last tile, which is swallowed rather than
202 /// clamped: a jump to a tile that is not there should do nothing, not 279 /// clamped: a jump to a tile that is not there should do nothing, not
@@ -217,64 +294,35 @@ pub fn selectKey(sel: usize, n: usize, key: u8) ?usize {
217 }; 294 };
218 } 295 }
219 296
220 /// The command line a zoom spawns: this same binary, dialling the tile's 297 /// What this tile may put on the terminal right now. Caller holds
221 /// target the way the user could have typed it. Pure and pinned rather 298 /// `paint_mu`, which is what makes the answer stable long enough to act on
222 /// than built inline at the spawn, for `wallSpelling`'s reason 299 /// — the zoom moves under that same lock.
223 /// (client.zig): one place turns a `Target` back into an argv. 300 const PaintMode = enum {
224 pub fn zoomArgv( 301 /// The wall: this tile's stripe, at its own rows.
225 arena: std.mem.Allocator, 302 stripe,
226 exe: []const u8, 303 /// This tile IS the zoom: the whole terminal, from its replica.
227 r: Resolved, 304 full,
228 ) error{OutOfMemory}![]const []const u8 { 305 /// Some other tile is the zoom. Painting here would be the wall
229 var argv: std.ArrayList([]const u8) = .empty; 306 /// talking over the session the user is typing at.
230 try argv.append(arena, exe); 307 none,
231 switch (r.target) { 308 };
232 // Two argv elements here, unlike the wall grammar's one-string 309
233 // `--sock PATH#NAME`: this is `mux`'s own flag parse, not the 310 fn paintModeLocked(t: *const Tile) PaintMode {
234 // wall's spelling. 311 const z = t.shared.zoom.load(.acquire);
235 .sock => |path| { 312 if (z == no_zoom) return .stripe;
236 try argv.append(arena, "--sock"); 313 return if (z == t.idx) .full else .none;
237 try argv.append(arena, path);
238 },
239 // The bare-HOST form: the child re-runs the ssh→QUIC handoff from
240 // the host word, exactly as the tile did.
241 .hand => |h| try argv.append(arena, h.host),
242 .quic => |q| {
243 try argv.append(arena, try std.fmt.allocPrint(arena, "quic://{s}", .{q.host_port}));
244 // The child cannot inherit a key it was never told about —
245 // showWall's lesson (client.zig), and the tile already
246 // resolved which key path this dial authenticates with.
247 try argv.append(arena, "--key");
248 try argv.append(arena, q.key_path);
249 },
250 // Unreachable by grammar, not by luck — and the grammar lives in
251 // ANOTHER module, so the guarantee is worth naming: this holds
252 // exactly as long as `parseSpelling` in src/wall.zig keeps making
253 // sock, host and quic specs and nothing else. Teach the wall
254 // grammar a `--via` spelling and a tile can hold a target this
255 // arm cannot spell as `mux` argv, and the zoom panics. Left a
256 // comment rather than a runtime check because the wall grammar
257 // and this switch are edited together or not at all.
258 .via => unreachable,
259 }
260 // "" is the wire-compatible default session, and `mux` spells the
261 // default by OMITTING the flag — `--session ""` is a usage error there
262 // (mux_main.zig refuses a name validSessionName will not take).
263 if (r.session.len > 0) {
264 try argv.append(arena, "--session");
265 try argv.append(arena, r.session);
266 }
267 return argv.items;
268 } 314 }
269 315
270 /// Narrate a new state on this tile's bar. The state is remembered on the 316 /// Narrate a new state on this tile's bar. The state is remembered on the
271 /// tile because the keyboard repaints the same bar when the selection 317 /// tile because the keyboard repaints the same bar when the selection
272 /// moves, with no frame in hand to tell it what to say. 318 /// moves, with no frame in hand to tell it what to say — and because while
319 /// a zoom is up there are no bars at all, so the state has to survive
320 /// somewhere until one comes back.
273 fn paintLabel(t: *Tile, state: State) void { 321 fn paintLabel(t: *Tile, state: State) void {
274 t.shared.paint_mu.lock(); 322 t.shared.paint_mu.lock();
275 defer t.shared.paint_mu.unlock(); 323 defer t.shared.paint_mu.unlock();
276 t.state = state; 324 t.state = state;
277 paintLabelLocked(t); 325 if (paintModeLocked(t) == .stripe) paintLabelLocked(t);
278 } 326 }
279 327
280 /// One bar's text, byte-truncated to fit BOTH the terminal and `buf`. 328 /// One bar's text, byte-truncated to fit BOTH the terminal and `buf`.
@@ -307,21 +355,25 @@ pub fn labelText(
307 } 355 }
308 356
309 /// The label bar: inverse, full width, `> LABEL [state]`, truncated at the 357 /// The label bar: inverse, full width, `> LABEL [state]`, truncated at the
310 /// terminal edge. Caller holds `paint_mu` — the marker and the notice are 358 /// terminal edge. Caller holds `paint_mu` — the marker and the state are
311 /// shared state, and this is also what writes the bytes out. 359 /// shared state, and this is also what writes the bytes out. Only called
360 /// in `.stripe` mode: a zoomed tile is a session, not a stripe with a bar.
312 fn paintLabelLocked(t: *Tile) void { 361 fn paintLabelLocked(t: *Tile) void {
313 // ASCII, not an arrow glyph: this bar is byte-truncated, greppable in 362 // ASCII, not an arrow glyph: this bar is byte-truncated, greppable in
314 // a capture, and must not depend on a font. The unselected marker is 363 // a capture, and must not depend on a font. The unselected marker is
315 // the same width, so labels do not shift as the selection moves. 364 // the same width, so labels do not shift as the selection moves.
316 const marker: []const u8 = if (t.shared.sel == t.idx) "> " else " "; 365 const marker: []const u8 = if (t.shared.sel == t.idx) "> " else " ";
317 // A zoom that failed says so where the eye already is, in the place 366 // Keystrokes the mailbox had no room for are said where the eye already
318 // the state word would have been. 367 // is, beside the state that explains them: input is only ever dropped
319 const status = if (t.shared.sel == t.idx and t.shared.notice != null) 368 // by a pump that stopped reading, so the state word is the reason and
320 t.shared.notice.? 369 // this is the consequence.
370 var status_buf: [64]u8 = undefined;
371 const status: []const u8 = if (t.in_dropped.load(.acquire))
372 std.fmt.bufPrint(&status_buf, "{s}, input dropped", .{t.state.word()}) catch t.state.word()
321 else 373 else
322 t.state.word(); 374 t.state.word();
323 var text_buf: [256]u8 = undefined; 375 var text_buf: [256]u8 = undefined;
324 const shown = labelText(&text_buf, t.shared.cols, marker, t.r.label, status); 376 const shown = labelText(&text_buf, t.shared.size.cols, marker, t.r.label, status);
325 var out: [1024]u8 = undefined; 377 var out: [1024]u8 = undefined;
326 var fbs = std.io.fixedBufferStream(&out); 378 var fbs = std.io.fixedBufferStream(&out);
327 const w = fbs.writer(); 379 const w = fbs.writer();
@@ -330,7 +382,7 @@ fn paintLabelLocked(t: *Tile) void {
330 // background color, not the reverse-video attribute, on most 382 // background color, not the reverse-video attribute, on most
331 // terminals — the bar would end where the text does. 383 // terminals — the bar would end where the text does.
332 var i: usize = shown.len; 384 var i: usize = shown.len;
333 while (i < t.shared.cols) : (i += 1) w.writeByte(' ') catch break; 385 while (i < t.shared.size.cols) : (i += 1) w.writeByte(' ') catch break;
334 w.writeAll("\x1b[0m\x1b[?2026l") catch return; 386 w.writeAll("\x1b[0m\x1b[?2026l") catch return;
335 proto.writeAllFd(t.shared.out_fd, fbs.getWritten()) catch {}; 387 proto.writeAllFd(t.shared.out_fd, fbs.getWritten()) catch {};
336 } 388 }
@@ -344,26 +396,134 @@ fn moveSelection(tiles: []Tile, shared: *Shared, next: usize) void {
344 defer shared.paint_mu.unlock(); 396 defer shared.paint_mu.unlock();
345 const prev = shared.sel; 397 const prev = shared.sel;
346 shared.sel = next; 398 shared.sel = next;
347 // Any deliberate key means the last zoom's complaint has been read.
348 shared.notice = null;
349 if (prev != next and prev < tiles.len) paintLabelLocked(&tiles[prev]); 399 if (prev != next and prev < tiles.len) paintLabelLocked(&tiles[prev]);
350 paintLabelLocked(&tiles[next]); 400 paintLabelLocked(&tiles[next]);
351 } 401 }
352 402
353 fn paintTile(t: *Tile, alloc: std.mem.Allocator, eng: *Engine) void { 403 /// Draw this tile's session — as a stripe on the wall, or full-screen while
404 /// it is the zoom, or not at all while some OTHER tile is.
405 ///
406 /// Returns whether anything actually reached the terminal, because the
407 /// repaint generation must only be recorded by a pump that satisfied it: a
408 /// tile hidden behind another's zoom has not, and its stripe would stay
409 /// blank when the zoom ends.
410 fn paintTile(t: *Tile, alloc: std.mem.Allocator, eng: *Engine, overlay: *predict.Overlay) bool {
354 t.shared.paint_mu.lock(); 411 t.shared.paint_mu.lock();
355 defer t.shared.paint_mu.unlock(); 412 defer t.shared.paint_mu.unlock();
356 paint.renderStripe(alloc, eng, t.stripe.top + 1, .{ 413 switch (paintModeLocked(t)) {
357 .cols = t.shared.cols, 414 .none => return false,
358 .rows = t.viewRows(), 415 .stripe => paint.renderStripe(alloc, eng, t.stripe.top + 1, .{
359 }, t.shared.out_fd) catch {}; 416 .cols = t.shared.size.cols,
417 .rows = t.viewRows(),
418 }, t.shared.out_fd) catch {},
419 .full => {
420 paint.renderClipped(alloc, eng, t.shared.size, t.shared.out_fd) catch {};
421 // The overlay goes back on top after every authoritative paint,
422 // client.zig's reason verbatim: the rows just drawn have
423 // overwritten predictions that are still outstanding, and one
424 // frame of flicker is exactly what prediction exists to avoid.
425 client.paintOverlay(alloc, overlay, eng.cursorPos(), t.shared.size, t.shared.out_fd);
426 },
427 }
428 return true;
360 } 429 }
361 430
362 /// The one place a wall tile puts an attach on the wire: always 0x0 — 431 /// The one place a wall tile puts an attach on the wire. 0x0 while
363 /// a wall tile never claims the grid and never creates a session. 432 /// unzoomed — a stripe never claims the grid and never creates a session —
364 fn sendAttach0(tr: *client.Transport, have_seq: u64, have_epoch: u64, session: []const u8) !void { 433 /// and this terminal's size while zoomed, because a reconnect under a zoom
434 /// must come back claiming what the zoom claimed, not hand the grid away.
435 fn sendAttach(t: *Tile, tr: *client.Transport, have_seq: u64, have_epoch: u64) !void {
436 const size: proto.Size = if (t.shared.zoom.load(.acquire) == t.idx)
437 t.shared.size
438 else
439 .{ .cols = 0, .rows = 0 };
365 var buf: [proto.attach_max_len]u8 = undefined; 440 var buf: [proto.attach_max_len]u8 = undefined;
366 try tr.writeFrame(.attach, proto.encodeAttachNamed(&buf, 0, 0, have_seq, have_epoch, session)); 441 try tr.writeFrame(.attach, proto.encodeAttachNamed(
442 &buf,
443 size.cols,
444 size.rows,
445 have_seq,
446 have_epoch,
447 t.r.session,
448 ));
449 }
450
451 /// One byte on this tile's doorbell. Never blocks and never reports: a bell
452 /// that is already ringing needs no second ring, which is precisely what a
453 /// full pipe means here.
454 fn ring(t: *const Tile) void {
455 _ = std.posix.write(t.wake_w, "\x00") catch {};
456 }
457
458 /// Hand typed bytes to this tile's pump.
459 ///
460 /// Called ONLY from the keyboard loop and ONLY while `t` is the zoomed
461 /// tile. That restriction is not an optimisation — it is the whole
462 /// enforcement of "an unzoomed tile claims nothing". There is no other
463 /// caller and there must not be one.
464 ///
465 /// Mouse reporting is deliberately NOT part of this in phase 1: the wall
466 /// never asks its terminal for mouse reports, so nothing arriving here can
467 /// be one, and the client's wheel/scrollback machinery is not wired up. A
468 /// zoomed tile is keyboard-only until phase 3 converges the two input
469 /// loops and it comes for free.
470 ///
471 /// A chunk that does not fit is dropped WHOLE. The obvious alternative —
472 /// copy what fits — splices: the head of one read lands in the mailbox, the
473 /// middle of the stream is lost, and the next read appends to it, so a
474 /// shell can be handed a command nobody typed (the front of a paste, then
475 /// bytes from somewhere else, then a newline that runs the result). Losing
476 /// a whole read is a keystroke that did not arrive, which is a thing users
477 /// understand and retry; a spliced one is a command they never wrote.
478 /// Blocking instead is not on offer either — a full mailbox means the pump
479 /// is not reading (a dead session, a dial in progress) and a wedged
480 /// keyboard would take the whole wall down with it.
481 fn sendKeys(t: *Tile, keys: []const u8) void {
482 {
483 t.in_mu.lock();
484 defer t.in_mu.unlock();
485 if (keys.len > t.in.len - t.in_len) {
486 // Narrated rather than silent: input vanishing with no
487 // explanation is the worst version of this. Atomic rather than
488 // guarded by `in_mu`, so the label bar can read it under
489 // `paint_mu` without the two locks ever having to nest.
490 t.in_dropped.store(true, .release);
491 } else {
492 @memcpy(t.in[t.in_len..][0..keys.len], keys);
493 t.in_len += keys.len;
494 }
495 }
496 ring(t);
497 }
498
499 /// Take everything the keyboard left, in order. Pump thread only.
500 ///
501 /// Whole-mailbox chunking, which has one visible consequence worth naming
502 /// before somebody reads `MUX_PREDICT_STATS` from a wall and panics:
503 /// `offerKeystroke` speculates only on a chunk of exactly one byte, so two
504 /// keystrokes that arrive between one poll and the next are handed over
505 /// together and BOTH counted as suppressed. A zoomed tile therefore
506 /// predicts a little less than a plain client, whose stdin read is its own
507 /// chunk boundary. Left as it is deliberately: the alternative is offering
508 /// the chunk byte by byte, which would speculate against a replica that
509 /// has not seen the earlier bytes yet — a guess about a screen that does
510 /// not exist, which is exactly what predict.zig refuses to make.
511 fn takeKeys(t: *Tile, out: []u8) []u8 {
512 t.in_mu.lock();
513 defer t.in_mu.unlock();
514 const n = @min(out.len, t.in_len);
515 @memcpy(out[0..n], t.in[0..n]);
516 std.mem.copyForwards(u8, t.in[0 .. t.in_len - n], t.in[n..t.in_len]);
517 t.in_len -= n;
518 return out[0..n];
519 }
520
521 /// Empty the doorbell so the next ring is visible to poll.
522 fn drainWake(t: *const Tile) void {
523 var sink: [64]u8 = undefined;
524 while (std.posix.read(t.wake_r, &sink)) |n| {
525 if (n < sink.len) break;
526 } else |_| {}
367 } 527 }
368 528
369 fn dial(alloc: std.mem.Allocator, t: *Tile, target: client.Target) ?client.Transport { 529 fn dial(alloc: std.mem.Allocator, t: *Tile, target: client.Target) ?client.Transport {
@@ -380,13 +540,25 @@ fn dial(alloc: std.mem.Allocator, t: *Tile, target: client.Target) ?client.Trans
380 return null; 540 return null;
381 } 541 }
382 542
383 /// One tile's life: dial → 0x0 attach → replay frames into the replica → 543 /// One tile's life: dial → attach → replay frames into the replica →
384 /// repaint the stripe. Runs on its own thread (see module header). On 544 /// repaint. Runs on its own thread (see module header). On transport
385 /// transport death: reconnect on the CLI's backoff schedule, quoting 545 /// death: reconnect on the CLI's backoff schedule, quoting
386 /// have_seq/have_epoch, and M7's snapshot-vs-delta resolution does the 546 /// have_seq/have_epoch, and M7's snapshot-vs-delta resolution does the
387 /// rest. Ends when `running` clears, the session exits, or the attach is 547 /// rest. Ends when `running` clears, the session exits, or the attach is
388 /// refused. 548 /// refused.
549 ///
550 /// This thread is also the tile's WRITER: the promote resize and every
551 /// keystroke the keyboard queued go out from here, because a Transport has
552 /// exactly one owning thread (module header).
389 fn pumpTile(t: *Tile) void { 553 fn pumpTile(t: *Tile) void {
554 // FIRST defer, so it runs LAST: every `return` below — a refused
555 // attach, an exited session, a dial the quit interrupted, an engine
556 // that would not initialise — is this tile going quiet for good, and
557 // the keyboard needs to know which tiles it has to paint for. Declared
558 // before the allocator's own defer so nothing can end this thread
559 // without it running.
560 defer t.alive.store(false, .release);
561
390 // Per-thread allocator: nothing allocated here crosses threads except 562 // Per-thread allocator: nothing allocated here crosses threads except
391 // painted bytes, which go out under the paint mutex. 563 // painted bytes, which go out under the paint mutex.
392 var gpa: std.heap.DebugAllocator(.{}) = .init; 564 var gpa: std.heap.DebugAllocator(.{}) = .init;
@@ -402,32 +574,127 @@ fn pumpTile(t: *Tile) void {
402 defer eng.deinit(); 574 defer eng.deinit();
403 var rep = Replica.init(alloc, eng); 575 var rep = Replica.init(alloc, eng);
404 576
577 // Prediction for the zoomed tile — the client's overlay, not a second
578 // one (client.zig owns offerKeystroke/reconcileOverlay/paintOverlay).
579 // It stays here while the tile is a stripe rather than being built at
580 // promote time, because the thing that decides whether a keystroke may
581 // be speculated at all is the pty's line discipline, and that arrives
582 // in `.pty_mode` frames long before anyone zooms.
583 var overlay = predict.Overlay.init(alloc, t.shared.size.cols, t.shared.size.rows);
584 defer overlay.deinit();
585
405 paintLabel(t, .connecting); 586 paintLabel(t, .connecting);
406 var transport = dial(alloc, t, target) orelse return; 587 var transport = dial(alloc, t, target) orelse return;
407 defer transport.close(); 588 defer transport.close();
408 sendAttach0(&transport, 0, 0, t.r.session) catch return; 589 sendAttach(t, &transport, 0, 0) catch return;
409 590
410 var state: State = .connecting; 591 var state: State = .connecting;
592 // Whether this tile has already claimed the grid for the zoom it is
593 // in. Compared against the zoom on every turn: the EDGE is what sends
594 // the resize, so a zoom that comes back to this tile re-asserts the
595 // claim and one that stays put costs nothing.
596 var promoted = false;
411 // What this stripe's paint is worth: while it matches the wall's 597 // What this stripe's paint is worth: while it matches the wall's
412 // generation the terminal still holds what this thread drew. 598 // generation the terminal still holds what this thread drew.
413 var painted_gen = t.shared.repaint_gen.load(.acquire); 599 var painted_gen = t.shared.repaint_gen.load(.acquire);
414 outer: while (t.shared.running.load(.acquire)) { 600 outer: while (t.shared.running.load(.acquire)) {
415 var fds = [_]std.posix.pollfd{ 601 var fds = [_]std.posix.pollfd{
416 .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, 602 .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
603 .{ .fd = t.wake_r, .events = std.posix.POLL.IN, .revents = 0 },
417 }; 604 };
418 _ = std.posix.poll(&fds, transport.timeoutMs(100)) catch return; 605 _ = std.posix.poll(&fds, transport.timeoutMs(100)) catch return;
419 transport.service(); 606 transport.service();
607 if (fds[1].revents != 0) drainWake(t);
420 608
421 // A zoomed child wrote over every stripe, and a quiet session 609 const zoomed = t.shared.zoom.load(.acquire) == t.idx;
422 // sends nothing to trigger a repaint. The replica is current — the 610 if (zoomed != promoted) {
611 promoted = zoomed;
612 if (zoomed) {
613 // PROMOTE. The claim goes out before the first keystroke
614 // can, which is what keeps the passivity rule
615 // exception-free: the client that types this session is a
616 // full-size one, claiming under latest-wins like any
617 // other. No attach, no dial — the same connection, resized.
618 overlay.setResizePending(true);
619 transport.writeFrame(
620 .resize,
621 &proto.encodeSize(t.shared.size.cols, t.shared.size.rows),
622 ) catch {
623 transport.close();
624 state = .reconnecting;
625 paintLabel(t, state);
626 transport = dial(alloc, t, target) orelse return;
627 rep.state_since_attach = false;
628 const have = rep.attachArgs();
629 sendAttach(t, &transport, have.have_seq, have.have_epoch) catch return;
630 continue :outer;
631 };
632 // The replica has been hot the whole time this was a
633 // stripe, so the zoom paints from it NOW rather than
634 // waiting for the daemon's answering snapshot. That is the
635 // headline: moving the zoom costs a local repaint.
636 _ = paintTile(t, alloc, eng, &overlay);
637 } else {
638 // DEMOTE. Nothing goes on the wire, deliberately — see the
639 // module header. Only the speculation is dropped, because
640 // it describes a screen this terminal no longer shows.
641 overlay.flush();
642 }
643 }
644
645 // Whatever the keyboard left is sent whatever the zoom is doing
646 // now: it was typed AT this session while this tile was the zoom,
647 // and the keyboard is the only writer of that mailbox.
648 var keys_buf: [mailbox_max]u8 = undefined;
649 const keys = takeKeys(t, &keys_buf);
650 if (keys.len > 0) {
651 if (zoomed) {
652 // Speculate under `paint_mu`, and only while this tile
653 // still owns the terminal: an overlay glyph painted onto
654 // another tile's zoom would be graffiti. The prediction is
655 // an OVERLAY — it never enters the replica.
656 t.shared.paint_mu.lock();
657 if (paintModeLocked(t) == .full) {
658 client.offerKeystroke(alloc, &overlay, eng, keys, t.shared.size, t.shared.out_fd);
659 }
660 t.shared.paint_mu.unlock();
661 }
662 transport.writeFrame(.input, keys) catch {
663 transport.close();
664 state = .reconnecting;
665 paintLabel(t, state);
666 transport = dial(alloc, t, target) orelse return;
667 rep.state_since_attach = false;
668 const have = rep.attachArgs();
669 sendAttach(t, &transport, have.have_seq, have.have_epoch) catch return;
670 continue :outer;
671 };
672 // Input is moving again, so "input dropped" has stopped being
673 // news. Repainted rather than merely cleared, because the bar
674 // is still carrying the old sentence until something draws
675 // over it — and only in `.stripe` mode, which paintLabel
676 // already decides.
677 if (t.in_dropped.swap(false, .acq_rel)) paintLabel(t, state);
678 }
679
680 // A prediction the daemon never answered must not sit on the
681 // screen forever, and only the clock can say so — no frame will.
682 if (zoomed and overlay.expire(std.time.milliTimestamp()) == .contradicted) {
683 _ = paintTile(t, alloc, eng, &overlay);
684 }
685
686 // A zoom wrote over every stripe, and a quiet session sends
687 // nothing to trigger a repaint. The replica is current — the
423 // SCREEN is not — so the generation is what puts it back. Checked 688 // SCREEN is not — so the generation is what puts it back. Checked
424 // on the poll timeout, so a stripe comes back within ~100ms of the 689 // on the poll timeout, so a stripe comes back within ~100ms of the
425 // child leaving whether or not its session ever speaks again. 690 // zoom leaving whether or not its session ever speaks again.
426 const gen = t.shared.repaint_gen.load(.acquire); 691 const gen = t.shared.repaint_gen.load(.acquire);
427 if (gen != painted_gen) { 692 if (gen != painted_gen) {
428 painted_gen = gen;
429 paintLabel(t, state); 693 paintLabel(t, state);
430 paintTile(t, alloc, eng); 694 // Recorded only if the paint landed: a tile hidden behind
695 // another tile's zoom has not satisfied this generation, and
696 // must repaint when the zoom gives the terminal back.
697 if (paintTile(t, alloc, eng, &overlay)) painted_gen = gen;
431 } 698 }
432 699
433 // The `.quic` disjunct is the hub's lesson verbatim: QUIC frames 700 // The `.quic` disjunct is the hub's lesson verbatim: QUIC frames
@@ -446,7 +713,7 @@ fn pumpTile(t: *Tile) void {
446 transport = dial(alloc, t, target) orelse return; 713 transport = dial(alloc, t, target) orelse return;
447 rep.state_since_attach = false; 714 rep.state_since_attach = false;
448 const have = rep.attachArgs(); 715 const have = rep.attachArgs();
449 sendAttach0(&transport, have.have_seq, have.have_epoch, t.r.session) catch return; 716 sendAttach(t, &transport, have.have_seq, have.have_epoch) catch return;
450 continue :outer; 717 continue :outer;
451 }, 718 },
452 }; 719 };
@@ -458,14 +725,36 @@ fn pumpTile(t: *Tile) void {
458 state = .up; 725 state = .up;
459 paintLabel(t, state); 726 paintLabel(t, state);
460 } 727 }
461 paintTile(t, alloc, eng); 728 // The overlay is judged against the replica the
729 // frame has just been fed into, which is the
730 // only authority there is. A snapshot resolves
731 // the promote's resize; a delta gets the
732 // ordinary verdict, which is discarded here
733 // because the repaint below is unconditional
734 // and total — a stripe and a zoom both redraw
735 // from the replica, not from the delta.
736 if (frame.type == .snapshot) {
737 overlay.setGrid(rep.grid.cols, rep.grid.rows);
738 overlay.setResizePending(false);
739 overlay.flush();
740 overlay.noteSeq(rep.last_seq);
741 } else {
742 _ = client.reconcileOverlay(
743 alloc,
744 &overlay,
745 eng,
746 rep.last_seq,
747 std.time.milliTimestamp(),
748 );
749 }
750 _ = paintTile(t, alloc, eng, &overlay);
462 }, 751 },
463 // The replica is suspect, not the transport: 752 // The replica is suspect, not the transport:
464 // re-attach quoting (0,0) explicitly — a quoted 753 // re-attach quoting (0,0) explicitly — a quoted
465 // seq would invite the delta that cannot fix us. 754 // seq would invite the delta that cannot fix us.
466 .resync => { 755 .resync => {
467 rep.state_since_attach = false; 756 rep.state_since_attach = false;
468 sendAttach0(&transport, 0, 0, t.r.session) catch return; 757 sendAttach(t, &transport, 0, 0) catch return;
469 }, 758 },
470 }, 759 },
471 .exit_status => { 760 .exit_status => {
@@ -476,8 +765,18 @@ fn pumpTile(t: *Tile) void {
476 paintLabel(t, state); 765 paintLabel(t, state);
477 return; 766 return;
478 }, 767 },
479 // Passive tile: modes, titles, clipboard, bells are 768 // The one mode a stripe is NOT passive about. What the
480 // the zoomed/interactive client's business. 769 // pty's line discipline is doing is the entire gate on
770 // speculation — a password prompt must never be
771 // predicted — so the overlay is told even while
772 // unzoomed, and a promote starts from the truth rather
773 // than from `.never`. Titles, clipboard and bells stay
774 // the interactive client's business.
775 .pty_mode => {
776 if (proto.decodePtyMode(frame.payload)) |flags| {
777 overlay.setMode(flags);
778 } else |_| {}
779 },
481 else => {}, 780 else => {},
482 } 781 }
483 // Only the socket link guarantees one readable event is 782 // Only the socket link guarantees one readable event is
@@ -488,96 +787,141 @@ fn pumpTile(t: *Tile) void {
488 } 787 }
489 } 788 }
490 789
491 /// `Enter`: hand this terminal to a REAL `mux` on the selected tile's 790 /// Where a chord takes the zoom.
492 /// session, and take it back when that client detaches (`Ctrl-\ d`, the 791 pub const ZoomMove = union(enum) {
493 /// child's own prefix layer — the wall is not in the loop while it runs). 792 /// Swallowed: the zoom stays where it is.
793 stay,
794 /// Back to the wall.
795 out,
796 /// Onto this tile — possibly the one already zoomed, which re-asserts
797 /// the claim and costs one repaint.
798 to: usize,
799 };
800
801 /// The zoomed tile's chord table, resolved against the wall. Pure, so what
802 /// a chord MEANS can be asserted without a terminal, two daemons and a
803 /// pty: `selectKey`'s reason, one layer up.
494 /// 804 ///
495 /// A spawn rather than "forward keystrokes to the tile": a tile attaches 805 /// `d` and `w` both leave. `d` is the muscle memory the child-spawn zoom
496 /// 0x0 so it can never claim the grid, and a client that types is exactly 806 /// left behind — that child was a real client and `Ctrl-\ d` detached it —
497 /// a client that must claim it (latest wins). So interaction takes the 807 /// and `w` is where the model is going, since a wall is what unzooming
498 /// ordinary route — a full-size attach of its own — and the wall stays an 808 /// shows. During phases 1-2 a plain client's `Ctrl-\ w` still SPAWNS a
499 /// observer. It is also `Ctrl-\ w`'s bargain read backwards: that spawns a 809 /// wall while a zoomed tile's `w` leaves one; that coexistence is
500 /// wall from a client, this spawns a client from a wall. 810 /// deliberate and phase 3 is what collapses it.
501 /// 811 ///
502 /// `paint_mu` is held for the child's whole life, and that is stronger than 812 /// `last` is the tile the zoom last came from, or null when it has not
503 /// "no stripe paints over the child": a pump blocks ON the mutex at the 813 /// moved yet. `Ctrl-\ l` with nothing to go back to unzooms rather than
504 /// first frame it would paint and stops reading its socket there until the 814 /// guessing — the spec's chosen fallback, and the same answer a tile that
505 /// child is gone. The pumps do NOT keep replicating behind the zoom — each 815 /// has since been forgotten will get once `x` exists. So does `l` aimed at
506 /// one stalls a frame in. The wall still comes back current, by the 816 /// the tile already zoomed, which tmux's `prefix-l` treats the same way:
507 /// generation bump below plus the backlog every pump drains once released. 817 /// "go where I was" cannot mean "stay here", and re-zooming in place would
818 /// clear the screen and repaint it to no visible effect.
819 pub fn zoomChord(
820 action: client.PrefixFilter.Action,
821 cur: usize,
822 n: usize,
823 last: ?usize,
824 ) ZoomMove {
825 return switch (action) {
826 .detach, .wall => .out,
827 .next_session => if (selectKey(cur, n, 'n')) |i| .{ .to = i } else .stay,
828 .prev_session => if (selectKey(cur, n, 'p')) |i| .{ .to = i } else .stay,
829 .last_session => blk: {
830 const back = last orelse break :blk .out;
831 break :blk if (back < n and back != cur) .{ .to = back } else .out;
832 },
833 // `c` (create a session and zoom it) is phase 2's, when the wall
834 // learns to grow a tile. Swallowed here exactly as the client
835 // swallows a key it has no meaning for.
836 .none, .new_session => .stay,
837 };
838 }
839
840 /// Move the zoom: to a tile, or to `no_zoom` for the wall.
508 /// 841 ///
509 /// The tail of that is a daemon decision, deliberately not fixed here. A 842 /// The whole transition happens under one hold of `paint_mu`, which is what
510 /// tile that stops reading accumulates queue on the daemon side, and past 843 /// makes it atomic as far as the pumps are concerned: between the screen
511 /// `pending_cap` (8 MiB, server.zig) the daemon drops it — so a chatty 844 /// being cleared and the terminal changing hands there is no window in
512 /// session under a long zoom loses its tile, which then reconnects (for 845 /// which a stripe can paint. The pumps do the rest themselves — the tile
513 /// `.hand`/`.quic` that re-runs the whole handshake) and re-attaches from a 846 /// that is now the zoom sends its resize and paints full-screen from its
514 /// snapshot. It self-heals, at the cost of a stripe that says 847 /// already-hot replica, and the tiles that are not stay quiet until the
515 /// `[reconnecting]` for a moment. Draining without painting would avoid it 848 /// terminal comes back to the wall.
516 /// and costs a second buffering path; the cap is what bounds the daemon's 849 ///
517 /// memory and is not worth trading away for it. 850 /// Nothing here goes on the wire, in either direction. A promote's resize
518 fn zoom( 851 /// is the promoted PUMP's to send (one thread owns a transport); a demote
519 alloc: std.mem.Allocator, 852 /// has nothing to send at all, which is the point.
520 t: *Tile, 853 ///
521 raw: std.posix.termios, 854 /// The one thing it DOES paint is for the dead. Only pumps answer
522 orig: std.posix.termios, 855 /// `repaint_gen`, so a tile whose pump has ended has nobody to redraw it
523 stdin_fd: std.posix.fd_t, 856 /// after the clear above — and phase 1 clears the screen on every zoom
524 ) void { 857 /// move, not once per zoom, so what used to be a corner case is now every
525 const shared = t.shared; 858 /// `n`/`p`/`l`. Both symptoms are the same hole: a dead tile's stripe never
859 /// comes back to the wall, and zooming a dead tile paints an entirely blank
860 /// terminal that admits to nothing. See `Tile.alive`.
861 fn setZoom(tiles: []Tile, shared: *Shared, next: usize) void {
526 shared.paint_mu.lock(); 862 shared.paint_mu.lock();
527 defer shared.paint_mu.unlock(); 863 defer shared.paint_mu.unlock();
528 shared.notice = null; 864 shared.zoom.store(next, .release);
529 865 if (next != no_zoom) {
530 // Exactly the restore the wall's own teardown does: a child must start 866 // The selection follows the zoom, so leaving one puts the marker
531 // from a sane tty, not from raw mode inside somebody's alternate 867 // where the user just was and `Enter` goes straight back in.
532 // screen — it is about to set up its own. 868 shared.sel = next;
533 proto.writeAllFd(shared.out_fd, "\x1b[?7h\x1b[?25h\x1b[?1049l") catch {}; 869 // The cursor belongs to the session now; `renderClipped` ends with
534 std.posix.tcsetattr(stdin_fd, .FLUSH, orig) catch {}; 870 // the sync bracket that shows it.
535 defer { 871 proto.writeAllFd(shared.out_fd, "\x1b[H\x1b[2J") catch {};
536 std.posix.tcsetattr(stdin_fd, .FLUSH, raw) catch {}; 872 // Zooming a tile whose pump has ended: nobody will paint this
537 proto.writeAllFd(shared.out_fd, "\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[H\x1b[2J") catch {}; 873 // screen, ever. One line, at the top, saying whose session it was,
538 // Every stripe is now blank screen over a current replica. One 874 // what became of it, and the way out — the alternative measured in
539 // bump tells the pumps so; without it a quiet session's stripe 875 // review was a blank terminal with no cursor and no hint.
540 // would stay empty until it next said something. 876 if (!tiles[next].alive.load(.acquire)) paintDeadZoomLocked(&tiles[next]);
541 _ = shared.repaint_gen.fetchAdd(1, .release); 877 } else {
878 // ...and the wall keeps it hidden again.
879 proto.writeAllFd(shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {};
542 } 880 }
543 881 // Every stripe is now blank screen over a current replica. One bump
544 var arena_state = std.heap.ArenaAllocator.init(alloc); 882 // tells the pumps so; without it a quiet session's stripe would stay
545 defer arena_state.deinit(); 883 // empty until it next said something. The doorbell makes that
546 const arena = arena_state.allocator(); 884 // immediate rather than one poll timeout away — the skip between two
547 var exe_buf: [std.fs.max_path_bytes]u8 = undefined; 885 // tiles must feel like a repaint, because that is all it is.
548 const exe = std.fs.selfExePath(&exe_buf) catch { 886 _ = shared.repaint_gen.fetchAdd(1, .release);
549 shared.notice = zoom_failed; 887 for (tiles) |*t| ring(t);
550 return; 888 // ...except for the tiles that have no pump left to hear the bell.
551 }; 889 // Their bars are the keyboard's to redraw, and only in `.stripe` mode:
552 const argv = zoomArgv(arena, exe, t.r) catch { 890 // while a zoom holds the terminal there are no bars at all.
553 shared.notice = zoom_failed; 891 if (next == no_zoom) {
554 return; 892 for (tiles) |*t| {
555 }; 893 if (!t.alive.load(.acquire)) paintLabelLocked(t);
556 894 }
557 var child = std.process.Child.init(argv, alloc);
558 // All three inherited: for as long as it runs, the child IS the
559 // user's session on this terminal.
560 child.stdin_behavior = .Inherit;
561 child.stdout_behavior = .Inherit;
562 child.stderr_behavior = .Inherit;
563 child.spawn() catch {
564 shared.notice = zoom_failed;
565 return;
566 };
567 // Said on the label bar, never propagated: a zoom that failed — a
568 // refused attach, a daemon that went away — is not a reason to end
569 // the wall the user is still standing in.
570 switch (child.wait() catch {
571 shared.notice = zoom_failed;
572 return;
573 }) {
574 .Exited => |code| if (code != 0) {
575 shared.notice = zoom_nonzero;
576 },
577 else => shared.notice = zoom_nonzero,
578 } 895 }
579 } 896 }
580 897
898 /// The whole screen a dead tile's zoom gets: its label, its last state, and
899 /// the chord back. Caller holds `paint_mu`.
900 ///
901 /// Row 1 rather than anywhere prettier because there is nothing else on the
902 /// screen to lay it out against — the replica this tile would have painted
903 /// from is exactly what does not exist. ASCII and plain text, `labelText`'s
904 /// reasons: greppable in a capture, no font dependency, safe to truncate by
905 /// byte.
906 fn paintDeadZoomLocked(t: *Tile) void {
907 var status_buf: [64]u8 = undefined;
908 const status = std.fmt.bufPrint(
909 &status_buf,
910 "{s} - Ctrl-\\ w for the wall",
911 .{t.state.word()},
912 ) catch t.state.word();
913 var text_buf: [256]u8 = undefined;
914 const shown = labelText(&text_buf, t.shared.size.cols, "", t.r.label, status);
915 var out: [512]u8 = undefined;
916 var fbs = std.io.fixedBufferStream(&out);
917 const w = fbs.writer();
918 // Cursor shown and parked at the end of the line: a terminal with no
919 // cursor at all reads as hung, which is the impression this exists to
920 // prevent.
921 w.print("\x1b[1;1H{s}\x1b[?25h", .{shown}) catch return;
922 proto.writeAllFd(t.shared.out_fd, fbs.getWritten()) catch {};
923 }
924
581 fn ttySize(fd: std.posix.fd_t) ?proto.Size { 925 fn ttySize(fd: std.posix.fd_t) ?proto.Size {
582 if (!std.posix.isatty(fd)) return null; 926 if (!std.posix.isatty(fd)) return null;
583 var ws: std.posix.winsize = undefined; 927 var ws: std.posix.winsize = undefined;
@@ -619,39 +963,92 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved) !u8 {
619 // autowrap off (stripe clipping is the terminal's right edge). 963 // autowrap off (stripe clipping is the terminal's right edge).
620 proto.writeAllFd(stdout_fd, "\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[H\x1b[2J") catch {}; 964 proto.writeAllFd(stdout_fd, "\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[H\x1b[2J") catch {};
621 965
622 var shared = Shared{ .out_fd = stdout_fd, .cols = size.cols }; 966 var shared = Shared{ .out_fd = stdout_fd, .size = size };
623 // Never freed: the pump threads are detached and hold pointers into 967 // Never freed: the pump threads are detached and hold pointers into
624 // this slice until the process ends — see the exit below. 968 // this slice until the process ends — see the exit below.
625 const tiles = try alloc.alloc(Tile, resolved.len); 969 const tiles = try alloc.alloc(Tile, resolved.len);
626 for (tiles, resolved, stripes, 0..) |*t, r, s, i| { 970 for (tiles, resolved, stripes, 0..) |*t, r, s, i| {
627 t.* = .{ .r = r, .stripe = s, .shared = &shared, .idx = i }; 971 // The doorbell, before the pump that polls it exists. Non-blocking
972 // at both ends: the writer must never wedge the keyboard and the
973 // reader must never wedge the pump.
974 const wake = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true });
975 t.* = .{
976 .r = r,
977 .stripe = s,
978 .shared = &shared,
979 .idx = i,
980 .wake_r = wake[0],
981 .wake_w = wake[1],
982 };
628 } 983 }
629 for (tiles) |*t| { 984 for (tiles) |*t| {
630 const th = std.Thread.spawn(.{}, pumpTile, .{t}) catch continue; 985 const th = std.Thread.spawn(.{}, pumpTile, .{t}) catch {
986 // A tile with no thread is a tile nothing will ever paint —
987 // the same hole `pumpTile`'s exit closes, reached without the
988 // pump having run at all. Marked here so the keyboard paints
989 // its bar and narrates a zoom into it. `.connecting` would be
990 // a lie: nobody is going to.
991 t.state = .refused;
992 t.alive.store(false, .release);
993 continue;
994 };
631 th.detach(); 995 th.detach();
632 } 996 }
633 997
634 // The wall's whole input surface: move the selection, zoom into it, 998 // The wall's input surface has two halves, and which one a byte lands
635 // quit. Everything else is still deliberately swallowed — no keystroke 999 // in is the whole model: zoomed OUT the wall reads the keys (nothing
636 // reaches a tile, because the only way to type at a session here is 1000 // reaches a session — that is what makes an unzoomed tile claim
637 // the zoom, which spawns a client to do it. 1001 // nothing); zoomed IN the terminal belongs to the session and only the
638 var b: [1]u8 = undefined; 1002 // `Ctrl-\` chord layer is held back.
639 while (true) { 1003 var prefix: client.PrefixFilter = .{};
1004 // Where `Ctrl-\ l` goes back to. Keyboard-thread state: no pump reads
1005 // it, and no lock guards it, because nothing else writes it.
1006 var last_zoom: ?usize = null;
1007 var b: [mailbox_max]u8 = undefined;
1008 keys: while (true) {
640 const n = std.posix.read(stdin_fd, &b) catch break; 1009 const n = std.posix.read(stdin_fd, &b) catch break;
641 if (n == 0) break; 1010 if (n == 0) break;
642 const key = b[0]; 1011
643 if (key == 'q' or key == 0x1c) break; 1012 const z = shared.zoom.load(.acquire);
644 // Both spellings of Enter: ICRNL is off, so a Return arrives as 1013 if (z != no_zoom) {
645 // CR, but a script or a paste can just as easily send LF. 1014 const cmd = prefix.feed(b[0..n]);
646 // `sel` is read here without the mutex on purpose: this loop is 1015 // What preceded the chord was typed AT the zoomed session, and
647 // its only writer (moveSelection takes the lock to write it, so 1016 // it is queued BEFORE the zoom moves — so `Ctrl-\ n` cannot
648 // the pumps never read it half-written), and a value only this 1017 // deliver the tail of a word to the tile it is jumping to.
649 // thread can change is not one it can read stale. 1018 if (cmd.forward.len > 0) sendKeys(&tiles[z], cmd.forward);
650 if (key == '\r' or key == '\n') { 1019 switch (zoomChord(cmd.action, z, tiles.len, last_zoom)) {
651 zoom(alloc, &tiles[shared.sel], raw, orig, stdin_fd); 1020 .stay => {},
1021 .out => {
1022 last_zoom = z;
1023 setZoom(tiles, &shared, no_zoom);
1024 },
1025 .to => |next| {
1026 last_zoom = z;
1027 setZoom(tiles, &shared, next);
1028 },
1029 }
652 continue; 1030 continue;
653 } 1031 }
654 if (selectKey(shared.sel, tiles.len, key)) |next| moveSelection(tiles, &shared, next); 1032
1033 for (b[0..n]) |key| {
1034 if (key == 'q' or key == 0x1c) break :keys;
1035 // Both spellings of Enter: ICRNL is off, so a Return arrives
1036 // as CR, but a script or a paste can just as easily send LF.
1037 // `sel` is read here without the mutex on purpose: this loop
1038 // is its only writer (moveSelection and setZoom take the lock
1039 // to write it, so the pumps never read it half-written), and a
1040 // value only this thread can change is not one it can read
1041 // stale.
1042 if (key == '\r' or key == '\n') {
1043 setZoom(tiles, &shared, shared.sel);
1044 // The rest of this read was typed at the WALL, before the
1045 // terminal changed hands — it is not the session's input.
1046 // The prefix layer starts clean for the same reason.
1047 prefix = .{};
1048 continue :keys;
1049 }
1050 if (selectKey(shared.sel, tiles.len, key)) |next| moveSelection(tiles, &shared, next);
1051 }
655 } 1052 }
656 1053
657 shared.running.store(false, .release); 1054 shared.running.store(false, .release);
@@ -712,10 +1109,11 @@ test "labelText: the state word survives truncation at every width" {
712 try std.testing.expect(std.mem.endsWith(u8, wide, " [reconnecting]")); 1109 try std.testing.expect(std.mem.endsWith(u8, wide, " [reconnecting]"));
713 try std.testing.expect(wide.len <= buf.len); 1110 try std.testing.expect(wide.len <= buf.len);
714 1111
715 // The longest thing a bar ever says is a zoom's complaint, not a state. 1112 // Wider than `buf` with the longest status word a bar can carry: the
716 const notice = labelText(&buf, 400, " ", long, zoom_nonzero); 1113 // bound that matters is `buf`'s, and it is not `cols`'.
717 try std.testing.expect(std.mem.endsWith(u8, notice, " [" ++ zoom_nonzero ++ "]")); 1114 const widest = labelText(&buf, 400, " ", long, State.reconnecting.word());
718 try std.testing.expect(notice.len <= buf.len); 1115 try std.testing.expect(std.mem.endsWith(u8, widest, " [reconnecting]"));
1116 try std.testing.expect(widest.len <= buf.len);
719 } 1117 }
720 1118
721 test "selectKey: j/k and n/p wrap at both ends" { 1119 test "selectKey: j/k and n/p wrap at both ends" {
@@ -742,57 +1140,116 @@ test "selectKey: digits jump 1-based, and a digit past the wall is ignored" {
742 try std.testing.expectEqual(@as(?usize, null), selectKey(0, 0, 'j')); 1140 try std.testing.expectEqual(@as(?usize, null), selectKey(0, 0, 'j'));
743 } 1141 }
744 1142
745 test "zoomArgv: a sock tile zooms as `mux --sock PATH --session NAME`" { 1143 test "zoomChord: d and w both leave, n/p move the zoom, c is swallowed" {
746 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); 1144 // Both spellings of "give the wall back". `d` is the muscle memory the
747 defer arena_state.deinit(); 1145 // child-spawn zoom left behind; `w` is where the model is going.
748 const arena = arena_state.allocator(); 1146 try std.testing.expectEqual(ZoomMove.out, zoomChord(.detach, 1, 3, null));
749 const r = try resolveSpelling(arena, "--sock /tmp/wall.sock#b", null, 30_000); 1147 try std.testing.expectEqual(ZoomMove.out, zoomChord(.wall, 1, 3, null));
750 const argv = try zoomArgv(arena, "/usr/bin/mux", r); 1148 // The zoom moves by the same motion the selection does, wrapping.
751 try std.testing.expectEqualDeep( 1149 try std.testing.expectEqual(ZoomMove{ .to = 2 }, zoomChord(.next_session, 1, 3, null));
752 @as([]const []const u8, &.{ "/usr/bin/mux", "--sock", "/tmp/wall.sock", "--session", "b" }), 1150 try std.testing.expectEqual(ZoomMove{ .to = 0 }, zoomChord(.next_session, 2, 3, null));
753 argv, 1151 try std.testing.expectEqual(ZoomMove{ .to = 2 }, zoomChord(.prev_session, 0, 3, null));
754 ); 1152 // `c` creates in the client and will create here in phase 2; today it
755 } 1153 // is swallowed rather than half-implemented.
756 1154 try std.testing.expectEqual(ZoomMove.stay, zoomChord(.new_session, 1, 3, null));
757 test "zoomArgv: the default session is spelled by omitting --session" { 1155 try std.testing.expectEqual(ZoomMove.stay, zoomChord(.none, 1, 3, null));
758 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); 1156 // A one-tile wall has nowhere to skip to, and says so by not moving.
759 defer arena_state.deinit(); 1157 try std.testing.expectEqual(ZoomMove{ .to = 0 }, zoomChord(.next_session, 0, 1, null));
760 const arena = arena_state.allocator(); 1158 }
761 const argv = try zoomArgv(arena, "mux", .{ 1159
762 .target = .{ .sock = "/tmp/w.sock" }, 1160 test "zoomChord: `l` goes back, and unzooms when there is nowhere to go" {
763 .label = "--sock /tmp/w.sock", 1161 try std.testing.expectEqual(ZoomMove{ .to = 0 }, zoomChord(.last_session, 2, 3, 0));
764 .session = "", 1162 // The spec's chosen fallback: nowhere remembered means the wall, not a
765 }); 1163 // guess. Once `x` can forget a tile (phase 2) an index past the wall's
766 try std.testing.expectEqualDeep( 1164 // end takes the same route.
767 @as([]const []const u8, &.{ "mux", "--sock", "/tmp/w.sock" }), 1165 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 2, 3, null));
768 argv, 1166 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 1, 2, 7));
769 ); 1167 // "Go where I was" pointed at where you ARE is not "stay here": tmux's
770 } 1168 // prefix-l answers it the same way, and re-zooming in place would
771 1169 // clear the screen and repaint it to no visible effect.
772 test "zoomArgv: a host tile is a bare word, a quic tile carries its key" { 1170 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 1, 3, 1));
773 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); 1171 // A one-tile wall is that case always, so `l` there is simply "out".
774 defer arena_state.deinit(); 1172 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 0, 1, 0));
775 const arena = arena_state.allocator(); 1173 }
776 const host = try zoomArgv(arena, "mux", .{ 1174
777 .target = .{ .hand = .{ .host = "box", .ssh_cmd = "ssh box", .cache_path = "/c" } }, 1175 test "zoomChord: the chords come out of the client's own table" {
778 .label = "box#a", 1176 // Not a twin table: the bytes are filtered by client.PrefixFilter and
779 .session = "a", 1177 // only their MEANING is decided here. Fed as a real client would feed
780 }); 1178 // it — split across reads, because a read boundary is not a chord
781 try std.testing.expectEqualDeep( 1179 // boundary — so a drift in either half fails here.
782 @as([]const []const u8, &.{ "mux", "box", "--session", "a" }), 1180 var f: client.PrefixFilter = .{};
783 host, 1181 var first = "vi\x1c".*;
784 ); 1182 const a = f.feed(&first);
785 // The key the tile resolved rides along: the child cannot inherit one 1183 try std.testing.expectEqualStrings("vi", a.forward);
786 // it was never told about. 1184 try std.testing.expectEqual(ZoomMove.stay, zoomChord(a.action, 0, 2, null));
787 const q = try zoomArgv(arena, "mux", .{ 1185 var second = "n".*;
788 .target = .{ .quic = .{ .host_port = "box:8787", .key_path = "/k" } }, 1186 const b = f.feed(&second);
789 .label = "quic://box:8787", 1187 try std.testing.expectEqualStrings("", b.forward);
790 .session = "", 1188 try std.testing.expectEqual(ZoomMove{ .to = 1 }, zoomChord(b.action, 0, 2, null));
791 }); 1189
792 try std.testing.expectEqualDeep( 1190 // A doubled prefix is the client's second spelling of detach, and it
793 @as([]const []const u8, &.{ "mux", "quic://box:8787", "--key", "/k" }), 1191 // unzooms here for the same reason `d` does.
794 q, 1192 var dbl = "\x1c\x1c".*;
795 ); 1193 try std.testing.expectEqual(ZoomMove.out, zoomChord(f.feed(&dbl).action, 1, 2, null));
1194 // An unknown command key is swallowed with its prefix, and swallowing
1195 // it must not move the zoom.
1196 var unk = "a\x1czb".*;
1197 const u = f.feed(&unk);
1198 try std.testing.expectEqualStrings("ab", u.forward);
1199 try std.testing.expectEqual(ZoomMove.stay, zoomChord(u.action, 1, 2, 0));
1200 }
1201
1202 // The splice this refuses is the one that matters: keep the head of a chunk
1203 // that does not fit, drop the middle of the stream, and the NEXT chunk
1204 // appends to the head — handing a shell a command nobody typed. All-or-
1205 // nothing means the mailbox only ever holds whole reads, in order.
1206 test "sendKeys: a chunk that does not fit is dropped whole, and says so" {
1207 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 } };
1208 var t = Tile{
1209 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
1210 .stripe = .{ .top = 0, .rows = 4 },
1211 .shared = &shared,
1212 .idx = 0,
1213 // -1 both ends: `ring` writes to the doorbell and ignores the
1214 // failure, which is exactly what it promises to do.
1215 .wake_r = -1,
1216 .wake_w = -1,
1217 };
1218
1219 const head = "abc";
1220 sendKeys(&t, head);
1221 try std.testing.expectEqual(@as(usize, 3), t.in_len);
1222 try std.testing.expect(!t.in_dropped.load(.acquire));
1223
1224 // One byte more than the room left. The old code copied what fit.
1225 const too_big = [_]u8{'z'} ** (mailbox_max - 2);
1226 sendKeys(&t, &too_big);
1227 try std.testing.expectEqual(@as(usize, 3), t.in_len);
1228 try std.testing.expect(t.in_dropped.load(.acquire));
1229
1230 // ...and the mailbox still holds exactly what was typed before it, so
1231 // the next chunk cannot splice onto a half-delivered one.
1232 sendKeys(&t, "def");
1233 var out: [mailbox_max]u8 = undefined;
1234 try std.testing.expectEqualStrings("abcdef", takeKeys(&t, &out));
1235
1236 // Exactly filling it is not overflow — the bound is `>`, not `>=`.
1237 const exact = [_]u8{'q'} ** mailbox_max;
1238 t.in_dropped.store(false, .release);
1239 sendKeys(&t, &exact);
1240 try std.testing.expectEqual(@as(usize, mailbox_max), t.in_len);
1241 try std.testing.expect(!t.in_dropped.load(.acquire));
1242 }
1243
1244 test "labelText: a dropped chunk is narrated beside the state that caused it" {
1245 // The bar's own format, asserted through the same truncation path the
1246 // states go through, because "reconnecting, input dropped" is now the
1247 // longest thing a bar can say and `buf`'s bound is what it tests.
1248 var buf: [256]u8 = undefined;
1249 const long = "x" ** 400;
1250 const said = labelText(&buf, 300, "> ", long, "reconnecting, input dropped");
1251 try std.testing.expect(std.mem.endsWith(u8, said, " [reconnecting, input dropped]"));
1252 try std.testing.expect(said.len <= buf.len);
796 } 1253 }
797 1254
798 test "resolveSpelling refuses a sun_path-overflowing sock path" { 1255 test "resolveSpelling refuses a sun_path-overflowing sock path" {