ef27667a
feat: mux wall — CLI multiattach, the web hub's wall in a terminal (POC)
a73x 2026-08-19 17:17
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -242,11 +242,11 @@ 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 | // sockpath is the sun_path bound only; the client binds no socket itself. | 245 | // The CLI wall (`mux wall`): passive multiattach stripes in one |
| 246 | // protocol is the session-name validator alone (validSessionName): a bad | 246 | // terminal. Same layer as webhub for the same reason — both sit on |
| 247 | // --session has to be a usage error here, at parse, not bytes some | 247 | // client's Transport and wall's grammar; neither may import the other, |
| 248 | // daemon downstream has to notice and refuse. | 248 | // which is why each carries its own spelling→Target resolution. |
| 249 | .{ .name = "mux", .path = "src/mux_main.zig", .layer = 3, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath" }, .quic_tests = true }, | 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 | // The daemon entrypoint loads the key and constructs the listener, so | 250 | // The daemon entrypoint loads the key and constructs the listener, so |
| 251 | // it needs quic/quic_server directly rather than through the server. | 251 | // it needs quic/quic_server directly rather than through the server. |
| 252 | // `muxd endpoint` prints the announce line handoff spells; sockpath is | 252 | // `muxd endpoint` prints the announce line handoff spells; sockpath is |
| @@ -261,6 +261,13 @@ const mod_table = [_]ModSpec{ | |||
| 261 | // handoff/protocol left with it; sockpath stays for the one startup | 261 | // handoff/protocol left with it; sockpath stays for the one startup |
| 262 | // message that names the sun_path bound. | 262 | // message that names the sun_path bound. |
| 263 | .{ .name = "webhub_main", .path = "src/webhub_main.zig", .layer = 4, .link_libc = true, .imports = &.{ "client", "webhub", "wall", "xdg", "sockpath" }, .quic_tests = true }, | 263 | .{ .name = "webhub_main", .path = "src/webhub_main.zig", .layer = 4, .link_libc = true, .imports = &.{ "client", "webhub", "wall", "xdg", "sockpath" }, .quic_tests = true }, |
| 264 | // sockpath is the sun_path bound only; the client binds no socket itself. | ||
| 265 | // protocol is the session-name validator alone (validSessionName): a bad | ||
| 266 | // --session has to be a usage error here, at parse, not bytes some | ||
| 267 | // daemon downstream has to notice and refuse. Layer 4 since `mux wall` | ||
| 268 | // pulled in wallview (layer 3); wall rides along for the no-arg wall | ||
| 269 | // (the state file the browser hub builds). | ||
| 270 | .{ .name = "mux", .path = "src/mux_main.zig", .layer = 4, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall" }, .quic_tests = true }, | ||
| 264 | }; | 271 | }; |
| 265 | 272 | ||
| 266 | /// Comptime row lookup. Every hand-written module name in this file goes | 273 | /// Comptime row lookup. Every hand-written module name in this file goes |
| @@ -400,12 +407,12 @@ fn shellGate(b: *std.Build, step: *std.Build.Step) void { | |||
| 400 | /// escape pins. mux and exe are executable roots but carry the argument | 407 | /// escape pins. mux and exe are executable roots but carry the argument |
| 401 | /// parsers — a test that is never built is not a test (decisions.md). | 408 | /// parsers — a test that is never built is not a test (decisions.md). |
| 402 | const test_order = [_][]const u8{ | 409 | const test_order = [_][]const u8{ |
| 403 | "script", "protocol", "client_core", "engine", "pty", "delta", | 410 | "script", "protocol", "client_core", "engine", "pty", "delta", |
| 404 | "cmd", "wall", "shellint", "replica", "keymap", "webhub", | 411 | "cmd", "wall", "shellint", "replica", "keymap", "webhub", |
| 405 | "sockpath", "muxa", "server", "client", "proxy", "mux", | 412 | "wallview", "sockpath", "muxa", "server", "client", "proxy", |
| 406 | "quic", "quic_server", "exe", "testtmp", "quic_client", "predict", | 413 | "mux", "quic", "quic_server", "exe", "testtmp", "quic_client", |
| 407 | "rawmode", "delaypipe", "xdg", "spawn", "handoff", "paint", | 414 | "predict", "rawmode", "delaypipe", "xdg", "spawn", "handoff", |
| 408 | "render", "ptyclient", "webhub_main", "wsclient", | 415 | "paint", "render", "ptyclient", "webhub_main", "wsclient", |
| 409 | }; | 416 | }; |
| 410 | 417 | ||
| 411 | comptime { | 418 | comptime { |
src/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -11,6 +11,8 @@ const xdg = @import("xdg"); | |||
| 11 | const spawn = @import("spawn"); | 11 | const spawn = @import("spawn"); |
| 12 | const handoff = @import("handoff"); | 12 | const handoff = @import("handoff"); |
| 13 | const sockpath = @import("sockpath"); | 13 | const sockpath = @import("sockpath"); |
| 14 | const wallview = @import("wallview"); | ||
| 15 | const wall = @import("wall"); | ||
| 14 | 16 | ||
| 15 | const usage = | 17 | const usage = |
| 16 | \\usage: mux [HOST | --sock PATH | --via CMD | quic://HOST[:PORT]] | 18 | \\usage: mux [HOST | --sock PATH | --via CMD | quic://HOST[:PORT]] |
| @@ -25,6 +27,11 @@ const usage = | |||
| 25 | \\ the default (`0`); NAME is printable ASCII, no space, no '#' or '/' | 27 | \\ the default (`0`); NAME is printable ASCII, no space, no '#' or '/' |
| 26 | \\ --version prints the version | 28 | \\ --version prints the version |
| 27 | \\ | 29 | \\ |
| 30 | \\ mux wall [SPELLING...] shows several sessions at once, read-only, | ||
| 31 | \\ one stripe each; `q` or Ctrl-\ leaves. SPELLING is the wall grammar | ||
| 32 | \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION], | ||
| 33 | \\ one argument per tile); with none, the muxweb wall file is shown. | ||
| 34 | \\ | ||
| 28 | ; | 35 | ; |
| 29 | 36 | ||
| 30 | /// What the command line asked for. A tagged union rather than a struct of | 37 | /// What the command line asked for. A tagged union rather than a struct of |
| @@ -156,6 +163,11 @@ pub fn main() !u8 { | |||
| 156 | const args = try std.process.argsAlloc(alloc); | 163 | const args = try std.process.argsAlloc(alloc); |
| 157 | defer std.process.argsFree(alloc, args); | 164 | defer std.process.argsFree(alloc, args); |
| 158 | 165 | ||
| 166 | // A subcommand, checked before the flag parse: `wall` is a different | ||
| 167 | // program (N passive tiles), not a transport spelling for one attach. | ||
| 168 | if (args.len > 1 and std.mem.eql(u8, args[1], "wall")) | ||
| 169 | return wallMain(alloc, args[2..]); | ||
| 170 | |||
| 159 | const parsed = parseArgs(args, std.posix.getenv(key_env)); | 171 | const parsed = parseArgs(args, std.posix.getenv(key_env)); |
| 160 | switch (parsed) { | 172 | switch (parsed) { |
| 161 | .version => { | 173 | .version => { |
| @@ -260,6 +272,59 @@ pub fn main() !u8 { | |||
| 260 | } | 272 | } |
| 261 | } | 273 | } |
| 262 | 274 | ||
| 275 | /// `mux wall`: gather spellings (argv, or the muxweb wall file when argv | ||
| 276 | /// names none), resolve each through the one grammar, hand the lot to | ||
| 277 | /// wallview.run. Resolution allocates into an arena because run() never | ||
| 278 | /// returns on the success path (it exits the process — see wallview.run); | ||
| 279 | /// only the early usage-error paths come back through the defers here. | ||
| 280 | fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 { | ||
| 281 | var arena_state = std.heap.ArenaAllocator.init(alloc); | ||
| 282 | defer arena_state.deinit(); | ||
| 283 | const arena = arena_state.allocator(); | ||
| 284 | |||
| 285 | var key: ?[]const u8 = null; | ||
| 286 | var idle_ms: u32 = client.quic_idle_ms_default; | ||
| 287 | var spellings: std.ArrayList([]const u8) = .empty; | ||
| 288 | |||
| 289 | var i: usize = 0; | ||
| 290 | while (i < args.len) : (i += 1) { | ||
| 291 | const a = args[i]; | ||
| 292 | if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) { | ||
| 293 | i += 1; | ||
| 294 | key = args[i]; | ||
| 295 | } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) { | ||
| 296 | i += 1; | ||
| 297 | const n = std.fmt.parseInt(u32, args[i], 10) catch 0; | ||
| 298 | if (n == 0) { | ||
| 299 | std.debug.print("{s}", .{usage}); | ||
| 300 | return 2; | ||
| 301 | } | ||
| 302 | idle_ms = n; | ||
| 303 | } else { | ||
| 304 | try spellings.append(arena, a); | ||
| 305 | } | ||
| 306 | } | ||
| 307 | |||
| 308 | if (spellings.items.len == 0) { | ||
| 309 | const path = try wall.statePath(arena); | ||
| 310 | const w = try wall.load(arena, path); | ||
| 311 | for (w.targets.items) |t| try spellings.append(arena, t); | ||
| 312 | if (spellings.items.len == 0) { | ||
| 313 | std.debug.print("mux: wall is empty: name targets, or add tiles in muxweb\n", .{}); | ||
| 314 | return 2; | ||
| 315 | } | ||
| 316 | } | ||
| 317 | |||
| 318 | const resolved = try arena.alloc(wallview.Resolved, spellings.items.len); | ||
| 319 | for (resolved, spellings.items) |*r, s| { | ||
| 320 | r.* = wallview.resolveSpelling(arena, s, xdg.pickKey(key, std.posix.getenv(key_env)), idle_ms) catch |err| { | ||
| 321 | std.debug.print("mux: bad wall target '{s}': {s}\n", .{ s, @errorName(err) }); | ||
| 322 | return 2; | ||
| 323 | }; | ||
| 324 | } | ||
| 325 | return wallview.run(arena, resolved); | ||
| 326 | } | ||
| 327 | |||
| 263 | /// Test helper: parseArgs takes what argsAlloc produces, so the tests have to | 328 | /// Test helper: parseArgs takes what argsAlloc produces, so the tests have to |
| 264 | /// speak the same type — a slice of sentinel-terminated strings. | 329 | /// speak the same type — a slice of sentinel-terminated strings. |
| 265 | fn parse(comptime argv: []const [:0]const u8) ParseResult { | 330 | fn parse(comptime argv: []const [:0]const u8) ParseResult { |
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,420 @@ | |||
| 1 | //! The CLI wall (POC): `mux wall` — one terminal showing N sessions at | ||
| 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 | ||
| 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 | ||
| 6 | //! the grid, never create a session — muxa's discipline), and no input is | ||
| 7 | //! forwarded. `q` or Ctrl-\ leaves. | ||
| 8 | //! | ||
| 9 | //! Layout is horizontal stripes, not a column grid: a stripe gets the full | ||
| 10 | //! terminal width, so long rows clip at the terminal's right edge (DECAWM | ||
| 11 | //! off) — the terminal does the clipping. A column grid would need VT-safe | ||
| 12 | //! truncation of dumpVtRow output, which nothing here does. Each stripe is | ||
| 13 | //! one inverse label bar plus the BOTTOM rows of that session's grid, | ||
| 14 | //! because prompts live at the bottom. | ||
| 15 | //! | ||
| 16 | //! That layout is computed ONCE, from the terminal's size at startup, and | ||
| 17 | //! there is no SIGWINCH handler: resizing the terminal while the wall is | ||
| 18 | //! up leaves the stripes at their old rows and the bars at their old | ||
| 19 | //! width, and it stays that way until the wall is closed and reopened. | ||
| 20 | //! A POC's accepted limit, not an oversight — a live re-layout has to | ||
| 21 | //! stop every pump, re-cut the stripes and repaint from the replicas, | ||
| 22 | //! and the wall is a thing you open, read and leave. | ||
| 23 | //! | ||
| 24 | //! One thread per tile, the hub's lesson verbatim (webhub.zig pumpTile): | ||
| 25 | //! a `.fd`/`.pipe` readFrame blocks, and bytes buffered in userspace are | ||
| 26 | //! invisible to poll — a single-threaded fair loop over N transports is | ||
| 27 | //! exactly the bug the hub already paid for. Stripe paints serialize on | ||
| 28 | //! one mutex; each paint is one synchronized-update write. | ||
| 29 | //! | ||
| 30 | //! Spelling → Target resolution mirrors the hub's resolveTile (webhub.zig) | ||
| 31 | //! rather than sharing it: both are layer 3, so neither may import the | ||
| 32 | //! other. Third copy wants a shared home below client — noted, not built. | ||
| 33 | const std = @import("std"); | ||
| 34 | const proto = @import("protocol"); | ||
| 35 | const client = @import("client"); | ||
| 36 | const wall = @import("wall"); | ||
| 37 | const handoff = @import("handoff"); | ||
| 38 | const xdg = @import("xdg"); | ||
| 39 | const sockpath = @import("sockpath"); | ||
| 40 | const proxy = @import("proxy"); | ||
| 41 | const Engine = @import("engine").Engine; | ||
| 42 | const Replica = @import("replica").Replica; | ||
| 43 | const paint = @import("paint"); | ||
| 44 | |||
| 45 | pub const Resolved = struct { | ||
| 46 | target: client.Target, | ||
| 47 | /// The user's spelling verbatim, `#NAME` included — the label bar | ||
| 48 | /// shows what was typed, not a rebuilt approximation of it. | ||
| 49 | label: []const u8, | ||
| 50 | session: []const u8, | ||
| 51 | }; | ||
| 52 | |||
| 53 | pub const ResolveError = wall.ParseError || error{ MissingKey, SockPathTooLong, OutOfMemory }; | ||
| 54 | |||
| 55 | /// Spelling → client.Target. Slices in the result borrow `spelling` or | ||
| 56 | /// live in `alloc`; the caller keeps both alive for the wall's lifetime. | ||
| 57 | pub fn resolveSpelling( | ||
| 58 | alloc: std.mem.Allocator, | ||
| 59 | spelling: []const u8, | ||
| 60 | key: ?[]const u8, | ||
| 61 | idle_ms: u32, | ||
| 62 | ) ResolveError!Resolved { | ||
| 63 | const p = try wall.parseSpelling(spelling); | ||
| 64 | const target: client.Target = switch (p.spec) { | ||
| 65 | // Refused here, at usage altitude, not at a connect that fails | ||
| 66 | // with a truncated sun_path nobody typed. | ||
| 67 | .sock => |path| if (path.len > sockpath.max_sun_path) | ||
| 68 | return error.SockPathTooLong | ||
| 69 | else | ||
| 70 | .{ .sock = path }, | ||
| 71 | .host => |h| blk: { | ||
| 72 | const r = try handoff.recipeFor(alloc, h); | ||
| 73 | break :blk .{ .hand = .{ | ||
| 74 | .host = h, | ||
| 75 | .ssh_cmd = r.ssh_cmd, | ||
| 76 | .cache_path = r.cache_path, | ||
| 77 | .idle_ms = idle_ms, | ||
| 78 | } }; | ||
| 79 | }, | ||
| 80 | .quic => |hp| blk: { | ||
| 81 | const key_path = switch (xdg.resolveKeyPath(alloc, key) catch |err| switch (err) { | ||
| 82 | // No HOME is no default key path: nothing to | ||
| 83 | // authenticate the dial with. | ||
| 84 | error.NoHome => return error.MissingKey, | ||
| 85 | else => |e| return e, | ||
| 86 | }) { | ||
| 87 | .given => |kp| kp, | ||
| 88 | .default => |kp| kp, | ||
| 89 | .missing => return error.MissingKey, | ||
| 90 | }; | ||
| 91 | break :blk .{ .quic = .{ | ||
| 92 | .host_port = hp, | ||
| 93 | .key_path = key_path, | ||
| 94 | .idle_ms = idle_ms, | ||
| 95 | } }; | ||
| 96 | }, | ||
| 97 | }; | ||
| 98 | return .{ .target = target, .label = spelling, .session = p.session }; | ||
| 99 | } | ||
| 100 | |||
| 101 | /// One stripe's place on the terminal: `top` is 0-based; the label bar is | ||
| 102 | /// terminal row top+1, content rows are top+2 .. top+rows (`rows` includes | ||
| 103 | /// the label bar). | ||
| 104 | pub const Stripe = struct { top: u16, rows: u16 }; | ||
| 105 | |||
| 106 | /// Split `tty_rows` into n stripes, remainder rows to the top stripes. A | ||
| 107 | /// stripe below 2 rows (label + one content row) shows nothing — refused. | ||
| 108 | pub fn layoutStripes( | ||
| 109 | alloc: std.mem.Allocator, | ||
| 110 | n: usize, | ||
| 111 | tty_rows: u16, | ||
| 112 | ) error{ TooSmall, OutOfMemory }![]Stripe { | ||
| 113 | if (n == 0 or tty_rows / @as(u16, @intCast(@min(n, 65535))) < 2) return error.TooSmall; | ||
| 114 | const nn: u16 = @intCast(n); | ||
| 115 | const per = tty_rows / nn; | ||
| 116 | const rem = tty_rows % nn; | ||
| 117 | const stripes = try alloc.alloc(Stripe, n); | ||
| 118 | var top: u16 = 0; | ||
| 119 | for (stripes, 0..) |*s, i| { | ||
| 120 | const h = per + @intFromBool(i < rem); | ||
| 121 | s.* = .{ .top = top, .rows = h }; | ||
| 122 | top += h; | ||
| 123 | } | ||
| 124 | return stripes; | ||
| 125 | } | ||
| 126 | |||
| 127 | const State = enum { | ||
| 128 | connecting, | ||
| 129 | up, | ||
| 130 | reconnecting, | ||
| 131 | exited, | ||
| 132 | refused, | ||
| 133 | |||
| 134 | fn word(s: State) []const u8 { | ||
| 135 | return switch (s) { | ||
| 136 | .connecting => "connecting", | ||
| 137 | .up => "up", | ||
| 138 | .reconnecting => "reconnecting", | ||
| 139 | .exited => "exited", | ||
| 140 | .refused => "refused", | ||
| 141 | }; | ||
| 142 | } | ||
| 143 | }; | ||
| 144 | |||
| 145 | const Shared = struct { | ||
| 146 | running: std.atomic.Value(bool) = std.atomic.Value(bool).init(true), | ||
| 147 | /// Serializes every write to the terminal. Held (and never released) | ||
| 148 | /// at teardown, so no stripe paints across the restore. | ||
| 149 | paint_mu: std.Thread.Mutex = .{}, | ||
| 150 | out_fd: std.posix.fd_t, | ||
| 151 | cols: u16, | ||
| 152 | }; | ||
| 153 | |||
| 154 | const Tile = struct { | ||
| 155 | r: Resolved, | ||
| 156 | stripe: Stripe, | ||
| 157 | shared: *Shared, | ||
| 158 | |||
| 159 | fn viewRows(t: *const Tile) u16 { | ||
| 160 | return t.stripe.rows - 1; | ||
| 161 | } | ||
| 162 | }; | ||
| 163 | |||
| 164 | /// The label bar: inverse, full width, `LABEL [state]`, truncated at the | ||
| 165 | /// terminal edge. Plain text only, so byte truncation cannot split an | ||
| 166 | /// escape sequence. | ||
| 167 | fn paintLabel(t: *Tile, state: State) void { | ||
| 168 | // The state survives truncation, the label doesn't: a wall whose bars | ||
| 169 | // read `...scratchpad` instead of `[reconnecting]` narrates nothing. | ||
| 170 | const suffix_len = state.word().len + 4; // " [" state "] "-ish: " [", "]" | ||
| 171 | const label_max = @as(usize, t.shared.cols) -| suffix_len -| 1; | ||
| 172 | const label = t.r.label[0..@min(t.r.label.len, label_max)]; | ||
| 173 | var text_buf: [256]u8 = undefined; | ||
| 174 | const text = std.fmt.bufPrint(&text_buf, " {s} [{s}]", .{ label, state.word() }) catch &text_buf; | ||
| 175 | const shown = text[0..@min(text.len, t.shared.cols)]; | ||
| 176 | var out: [1024]u8 = undefined; | ||
| 177 | var fbs = std.io.fixedBufferStream(&out); | ||
| 178 | const w = fbs.writer(); | ||
| 179 | w.print("\x1b[?2026h\x1b[{d};1H\x1b[7m{s}", .{ t.stripe.top + 1, shown }) catch return; | ||
| 180 | // Padded with spaces rather than \x1b[K: erase-to-EOL fills with the | ||
| 181 | // background color, not the reverse-video attribute, on most | ||
| 182 | // terminals — the bar would end where the text does. | ||
| 183 | var i: usize = shown.len; | ||
| 184 | while (i < t.shared.cols) : (i += 1) w.writeByte(' ') catch break; | ||
| 185 | w.writeAll("\x1b[0m\x1b[?2026l") catch return; | ||
| 186 | t.shared.paint_mu.lock(); | ||
| 187 | defer t.shared.paint_mu.unlock(); | ||
| 188 | proto.writeAllFd(t.shared.out_fd, fbs.getWritten()) catch {}; | ||
| 189 | } | ||
| 190 | |||
| 191 | fn paintTile(t: *Tile, alloc: std.mem.Allocator, eng: *Engine) void { | ||
| 192 | t.shared.paint_mu.lock(); | ||
| 193 | defer t.shared.paint_mu.unlock(); | ||
| 194 | paint.renderStripe(alloc, eng, t.stripe.top + 1, .{ | ||
| 195 | .cols = t.shared.cols, | ||
| 196 | .rows = t.viewRows(), | ||
| 197 | }, t.shared.out_fd) catch {}; | ||
| 198 | } | ||
| 199 | |||
| 200 | /// The one place a wall tile puts an attach on the wire: always 0x0 — | ||
| 201 | /// a wall tile never claims the grid and never creates a session. | ||
| 202 | fn sendAttach0(tr: *client.Transport, have_seq: u64, have_epoch: u64, session: []const u8) !void { | ||
| 203 | var buf: [proto.attach_max_len]u8 = undefined; | ||
| 204 | try tr.writeFrame(.attach, proto.encodeAttachNamed(&buf, 0, 0, have_seq, have_epoch, session)); | ||
| 205 | } | ||
| 206 | |||
| 207 | fn dial(alloc: std.mem.Allocator, t: *Tile, target: client.Target) ?client.Transport { | ||
| 208 | var backoff_ms: u64 = 0; | ||
| 209 | while (t.shared.running.load(.acquire)) { | ||
| 210 | if (client.Transport.open(alloc, target, null, -1)) |tr| return tr else |_| {} | ||
| 211 | backoff_ms = client.nextBackoffMs(backoff_ms); | ||
| 212 | // Sliced sleep so quit is never behind a full backoff. | ||
| 213 | var slept: u64 = 0; | ||
| 214 | while (slept < backoff_ms and t.shared.running.load(.acquire)) : (slept += 50) { | ||
| 215 | std.Thread.sleep(50 * std.time.ns_per_ms); | ||
| 216 | } | ||
| 217 | } | ||
| 218 | return null; | ||
| 219 | } | ||
| 220 | |||
| 221 | /// One tile's life: dial → 0x0 attach → replay frames into the replica → | ||
| 222 | /// repaint the stripe. Runs on its own thread (see module header). On | ||
| 223 | /// transport death: reconnect on the CLI's backoff schedule, quoting | ||
| 224 | /// have_seq/have_epoch, and M7's snapshot-vs-delta resolution does the | ||
| 225 | /// rest. Ends when `running` clears, the session exits, or the attach is | ||
| 226 | /// refused. | ||
| 227 | fn pumpTile(t: *Tile) void { | ||
| 228 | // Per-thread allocator: nothing allocated here crosses threads except | ||
| 229 | // painted bytes, which go out under the paint mutex. | ||
| 230 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 231 | defer _ = gpa.deinit(); | ||
| 232 | const alloc = gpa.allocator(); | ||
| 233 | |||
| 234 | var target = t.r.target; | ||
| 235 | // The stripe's label already narrates state; a fallback line on | ||
| 236 | // stderr would corrupt the alternate screen. Same as the hub's pump. | ||
| 237 | if (target == .hand) target.hand.report_fallback = false; | ||
| 238 | |||
| 239 | const eng = Engine.init(alloc, .{ .cols = 80, .rows = 24 }) catch return; | ||
| 240 | defer eng.deinit(); | ||
| 241 | var rep = Replica.init(alloc, eng); | ||
| 242 | |||
| 243 | paintLabel(t, .connecting); | ||
| 244 | var transport = dial(alloc, t, target) orelse return; | ||
| 245 | defer transport.close(); | ||
| 246 | sendAttach0(&transport, 0, 0, t.r.session) catch return; | ||
| 247 | |||
| 248 | var state: State = .connecting; | ||
| 249 | outer: while (t.shared.running.load(.acquire)) { | ||
| 250 | var fds = [_]std.posix.pollfd{ | ||
| 251 | .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 252 | }; | ||
| 253 | _ = std.posix.poll(&fds, transport.timeoutMs(100)) catch return; | ||
| 254 | transport.service(); | ||
| 255 | |||
| 256 | // The `.quic` disjunct is the hub's lesson verbatim: QUIC frames | ||
| 257 | // can arrive from the stream layer with the socket never going | ||
| 258 | // readable. | ||
| 259 | if (fds[0].revents != 0 or transport.link == .quic) frames: { | ||
| 260 | while (true) { | ||
| 261 | const incoming = transport.readFrame(alloc) catch return; | ||
| 262 | const frame = switch (incoming) { | ||
| 263 | .frame => |f| f, | ||
| 264 | .incomplete => break :frames, | ||
| 265 | .closed => { | ||
| 266 | transport.close(); | ||
| 267 | state = .reconnecting; | ||
| 268 | paintLabel(t, state); | ||
| 269 | transport = dial(alloc, t, target) orelse return; | ||
| 270 | rep.state_since_attach = false; | ||
| 271 | const have = rep.attachArgs(); | ||
| 272 | sendAttach0(&transport, have.have_seq, have.have_epoch, t.r.session) catch return; | ||
| 273 | continue :outer; | ||
| 274 | }, | ||
| 275 | }; | ||
| 276 | defer frame.deinit(alloc); | ||
| 277 | switch (frame.type) { | ||
| 278 | .snapshot, .delta => switch (rep.apply(frame.type, frame.payload) catch break :frames) { | ||
| 279 | .painted => { | ||
| 280 | if (state != .up) { | ||
| 281 | state = .up; | ||
| 282 | paintLabel(t, state); | ||
| 283 | } | ||
| 284 | paintTile(t, alloc, eng); | ||
| 285 | }, | ||
| 286 | // The replica is suspect, not the transport: | ||
| 287 | // re-attach quoting (0,0) explicitly — a quoted | ||
| 288 | // seq would invite the delta that cannot fix us. | ||
| 289 | .resync => { | ||
| 290 | rep.state_since_attach = false; | ||
| 291 | sendAttach0(&transport, 0, 0, t.r.session) catch return; | ||
| 292 | }, | ||
| 293 | }, | ||
| 294 | .exit_status => { | ||
| 295 | // Before any replay frame this is the refusal path | ||
| 296 | // (a 0x0 attach joins but never creates); after, | ||
| 297 | // the session really ended. | ||
| 298 | state = if (rep.state_since_attach) .exited else .refused; | ||
| 299 | paintLabel(t, state); | ||
| 300 | return; | ||
| 301 | }, | ||
| 302 | // Passive tile: modes, titles, clipboard, bells are | ||
| 303 | // the zoomed/interactive client's business. | ||
| 304 | else => {}, | ||
| 305 | } | ||
| 306 | // Only the socket link guarantees one readable event is | ||
| 307 | // one frame; QUIC may have buffered more. | ||
| 308 | if (transport.link != .quic) break :frames; | ||
| 309 | } | ||
| 310 | } | ||
| 311 | } | ||
| 312 | } | ||
| 313 | |||
| 314 | fn ttySize(fd: std.posix.fd_t) ?proto.Size { | ||
| 315 | if (!std.posix.isatty(fd)) return null; | ||
| 316 | var ws: std.posix.winsize = undefined; | ||
| 317 | if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null; | ||
| 318 | if (ws.col < 2 or ws.row < 2) return null; | ||
| 319 | return .{ .cols = ws.col, .rows = ws.row }; | ||
| 320 | } | ||
| 321 | |||
| 322 | /// Run the wall until `q` or Ctrl-\. Returns an exit code. | ||
| 323 | pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved) !u8 { | ||
| 324 | const stdin_fd = std.posix.STDIN_FILENO; | ||
| 325 | const stdout_fd = std.posix.STDOUT_FILENO; | ||
| 326 | const size = ttySize(stdout_fd) orelse { | ||
| 327 | std.debug.print("mux: wall needs a terminal\n", .{}); | ||
| 328 | return 2; | ||
| 329 | }; | ||
| 330 | const stripes = layoutStripes(alloc, resolved.len, size.rows) catch |err| switch (err) { | ||
| 331 | error.TooSmall => { | ||
| 332 | std.debug.print("mux: terminal too small for {d} tiles\n", .{resolved.len}); | ||
| 333 | return 2; | ||
| 334 | }, | ||
| 335 | else => |e| return e, | ||
| 336 | }; | ||
| 337 | defer alloc.free(stripes); | ||
| 338 | |||
| 339 | // A daemon that dies mid-write must surface as a write error on that | ||
| 340 | // tile's thread, not a process-fatal SIGPIPE. | ||
| 341 | proxy.ignoreSigpipe(); | ||
| 342 | |||
| 343 | const orig = try std.posix.tcgetattr(stdin_fd); | ||
| 344 | var raw = orig; | ||
| 345 | raw.lflag.ICANON = false; | ||
| 346 | raw.lflag.ECHO = false; | ||
| 347 | raw.lflag.ISIG = false; | ||
| 348 | raw.iflag.IXON = false; | ||
| 349 | raw.iflag.ICRNL = false; | ||
| 350 | try std.posix.tcsetattr(stdin_fd, .FLUSH, raw); | ||
| 351 | // Alternate screen, cursor hidden for the wall's whole lifetime, | ||
| 352 | // autowrap off (stripe clipping is the terminal's right edge). | ||
| 353 | proto.writeAllFd(stdout_fd, "\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[H\x1b[2J") catch {}; | ||
| 354 | |||
| 355 | var shared = Shared{ .out_fd = stdout_fd, .cols = size.cols }; | ||
| 356 | // Never freed: the pump threads are detached and hold pointers into | ||
| 357 | // this slice until the process ends — see the exit below. | ||
| 358 | const tiles = try alloc.alloc(Tile, resolved.len); | ||
| 359 | for (tiles, resolved, stripes) |*t, r, s| { | ||
| 360 | t.* = .{ .r = r, .stripe = s, .shared = &shared }; | ||
| 361 | } | ||
| 362 | for (tiles) |*t| { | ||
| 363 | const th = std.Thread.spawn(.{}, pumpTile, .{t}) catch continue; | ||
| 364 | th.detach(); | ||
| 365 | } | ||
| 366 | |||
| 367 | // The wall's whole input surface: quit. Everything else is deliberately | ||
| 368 | // swallowed — a passive wall must never forward keystrokes. | ||
| 369 | var b: [1]u8 = undefined; | ||
| 370 | while (true) { | ||
| 371 | const n = std.posix.read(stdin_fd, &b) catch break; | ||
| 372 | if (n == 0) break; | ||
| 373 | if (b[0] == 'q' or b[0] == 0x1c) break; | ||
| 374 | } | ||
| 375 | |||
| 376 | shared.running.store(false, .release); | ||
| 377 | // Taken and never released: no stripe paints across the restore. The | ||
| 378 | // pump threads are detached and die with the process; joining them | ||
| 379 | // could wait on a blocked readFrame forever. | ||
| 380 | shared.paint_mu.lock(); | ||
| 381 | proto.writeAllFd(stdout_fd, "\x1b[?7h\x1b[?25h\x1b[?1049l") catch {}; | ||
| 382 | std.posix.tcsetattr(stdin_fd, .FLUSH, orig) catch {}; | ||
| 383 | // exit(2), not return: returning would run the caller's frees and leak | ||
| 384 | // checks while detached pump threads still hold pointers into `tiles` | ||
| 385 | // and their own live transports — the window between here and process | ||
| 386 | // death must not contain a free. | ||
| 387 | std.posix.exit(0); | ||
| 388 | } | ||
| 389 | |||
| 390 | test "layoutStripes splits rows with the remainder at the top" { | ||
| 391 | const alloc = std.testing.allocator; | ||
| 392 | const s = try layoutStripes(alloc, 3, 25); | ||
| 393 | defer alloc.free(s); | ||
| 394 | try std.testing.expectEqual(@as(usize, 3), s.len); | ||
| 395 | try std.testing.expectEqual(Stripe{ .top = 0, .rows = 9 }, s[0]); | ||
| 396 | try std.testing.expectEqual(Stripe{ .top = 9, .rows = 8 }, s[1]); | ||
| 397 | try std.testing.expectEqual(Stripe{ .top = 17, .rows = 8 }, s[2]); | ||
| 398 | } | ||
| 399 | |||
| 400 | test "layoutStripes refuses a wall that cannot show a content row" { | ||
| 401 | const alloc = std.testing.allocator; | ||
| 402 | // 12 tiles over 23 rows: 1 row each — a label with no content. | ||
| 403 | try std.testing.expectError(error.TooSmall, layoutStripes(alloc, 12, 23)); | ||
| 404 | try std.testing.expectError(error.TooSmall, layoutStripes(alloc, 0, 24)); | ||
| 405 | } | ||
| 406 | |||
| 407 | test "resolveSpelling: --sock with a session, label verbatim" { | ||
| 408 | const alloc = std.testing.allocator; | ||
| 409 | const r = try resolveSpelling(alloc, "--sock /tmp/wall.sock#b", null, 30_000); | ||
| 410 | try std.testing.expect(r.target == .sock); | ||
| 411 | try std.testing.expectEqualStrings("/tmp/wall.sock", r.target.sock); | ||
| 412 | try std.testing.expectEqualStrings("b", r.session); | ||
| 413 | try std.testing.expectEqualStrings("--sock /tmp/wall.sock#b", r.label); | ||
| 414 | } | ||
| 415 | |||
| 416 | test "resolveSpelling refuses a sun_path-overflowing sock path" { | ||
| 417 | const alloc = std.testing.allocator; | ||
| 418 | const long = "--sock /" ++ "x" ** 200; | ||
| 419 | try std.testing.expectError(error.SockPathTooLong, resolveSpelling(alloc, long, null, 30_000)); | ||
| 420 | } | ||