b585c470
feat: the wall zooms in place — promote the tile, do not spawn a client
a73x 2026-08-19 22:51
Commit message
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" { |