a73x

ef27667a

feat: mux wall — CLI multiattach, the web hub's wall in a terminal (POC)

a73x   2026-08-19 17:17

Commit message
feat: mux wall — CLI multiattach, the web hub's wall in a terminal (POC)

wallview (layer 3, webhub's sibling): one thread per tile — the hub's
blocking-readFrame lesson verbatim — each dialing client.Transport,
attaching at 0x0 (join, never claim, never create: muxa's discipline),
replaying through the one Replica core, and painting its stripe under a
shared mutex. Reconnect on the CLI's backoff schedule, quoting
have_seq/have_epoch. Passive: no input is forwarded; q or Ctrl-\ leaves.

Targets are wall.zig spellings, one per argv; with none, the muxweb wall
file — the wall built in a browser is the wall the CLI shows. Spelling to
Target resolution mirrors the hub's resolveTile because both live at
layer 3 and neither may import the other; a shared home below client is
noted in the module header, not built.

mux moves to layer 4 to import wallview, the same step webhub_main took.

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 }