a73x

6c535804

feat: a host's live sessions are its tiles — per-host poll, diff, stripe

a73x   2026-08-28 19:53

Commit message
feat: a host's live sessions are its tiles — per-host poll, diff, stripe

wallview.run takes HOSTS, not resolved tiles: one poller per host asks
sessions_req once a second, and planHostDiff turns each answer into
births and vanishes against the tiles that host already owns. A host with
no tile of its own is ONE stripe (unreachable, or up with no sessions),
never one dead tile per session it used to have.

The wall therefore starts empty but for the entry tile, and the layout
sidecar is restored once the hosts have reported (or 2s in, whichever is
first) over the tiles they turned out to have. The Ctrl-\ w fold has
nothing left to hydrate and is a plain re-cut.

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

build.zig
Old New
@@ -297,7 +297,7 @@ const mod_table = [_]ModSpec{
297 // typed-at session; the overlay machinery itself is interact's, shared — 297 // typed-at session; the overlay machinery itself is interact's, shared —
298 // and phase 3 promotes the tile into that core rather than growing a 298 // and phase 3 promotes the tile into that core rather than growing a
299 // second copy of it. 299 // second copy of it.
300 .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "paint", "select", "layout" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 300 .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "hosts", "handoff", "xdg", "sockpath", "proxy", "engine", "paint", "select", "layout" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
301 // ---- layer 5 ---- 301 // ---- layer 5 ----
302 // wall owns the spelling grammar and the state file, so argv is parsed 302 // wall owns the spelling grammar and the state file, so argv is parsed
303 // by the SAME rules the page's POST /tiles and the restored file are — 303 // by the SAME rules the page's POST /tiles and the restored file are —
@@ -311,7 +311,7 @@ const mod_table = [_]ModSpec{
311 // daemon downstream has to notice and refuse. Layer 5 since `mux wall` 311 // daemon downstream has to notice and refuse. Layer 5 since `mux wall`
312 // pulled in wallview (layer 4); wall rides along for the no-arg wall 312 // pulled in wallview (layer 4); wall rides along for the no-arg wall
313 // (the state file the browser hub builds). 313 // (the state file the browser hub builds).
314 .{ .name = "mux", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 314 .{ .name = "mux", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall", "hosts", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
315 }; 315 };
316 316
317 /// Comptime row lookup. Every hand-written module name in this file goes 317 /// Comptime row lookup. Every hand-written module name in this file goes
docscheck.budget
Old New
@@ -30,7 +30,7 @@ sockpath.zig 0
30 spawn.zig 0 30 spawn.zig 0
31 testtmp.zig 0 31 testtmp.zig 0
32 upgrade.zig 269 32 upgrade.zig 269
33 wallview.zig 1841 33 wallview.zig 1596
34 wall.zig 0 34 wall.zig 0
35 wasm_core.zig 0 35 wasm_core.zig 0
36 webhub_main.zig 0 36 webhub_main.zig 0
src/cli/mux_main.zig
Old New
@@ -19,6 +19,7 @@ const spawn = @import("spawn");
19 const handoff = @import("handoff"); 19 const handoff = @import("handoff");
20 const sockpath = @import("sockpath"); 20 const sockpath = @import("sockpath");
21 const wallview = @import("wallview"); 21 const wallview = @import("wallview");
22 const hosts = @import("hosts");
22 const wall = @import("wall"); 23 const wall = @import("wall");
23 const cliflags = @import("cliflags"); 24 const cliflags = @import("cliflags");
24 const TmpDir = @import("testtmp").TmpDir; 25 const TmpDir = @import("testtmp").TmpDir;
@@ -504,7 +505,6 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 {
504 const idle_ms = w_opts.quic_idle_ms.ms; 505 const idle_ms = w_opts.quic_idle_ms.ms;
505 var spellings = w_opts._argv.tiles; 506 var spellings = w_opts._argv.tiles;
506 507
507 var from_file = false;
508 if (spellings.items.len == 0) { 508 if (spellings.items.len == 0) {
509 const path = try wall.statePath(arena); 509 const path = try wall.statePath(arena);
510 const w = try wall.load(arena, path); 510 const w = try wall.load(arena, path);
@@ -513,38 +513,19 @@ fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 {
513 std.debug.print("mux: wall is empty: name targets, or add tiles in muxweb\n", .{}); 513 std.debug.print("mux: wall is empty: name targets, or add tiles in muxweb\n", .{});
514 return 2; 514 return 2;
515 } 515 }
516 from_file = true;
517 } 516 }
518 517
519 const resolved = try arena.alloc(wallview.Resolved, spellings.items.len); 518 // A wall line names a DAEMON now: no `#SESSION`, and no self-attach
520 const env_sock = std.posix.getenv(proto.sock_env); 519 // check here — the session this shell is inside is one name on a
521 const env_session = std.posix.getenv(proto.session_env); 520 // host's list, and the wall's own diff is what refuses to tile it.
522 for (resolved, spellings.items) |*r, s| { 521 const host_specs = try arena.alloc(wallview.HostSpec, spellings.items.len);
523 r.* = wallview.resolveSpelling(arena, s, xdg.pickKey(key, std.posix.getenv(xdg.key_env)), idle_ms) catch |err| { 522 for (host_specs, spellings.items) |*h, s| {
524 std.debug.print("mux: bad wall target '{s}': {s}\n", .{ s, @errorName(err) }); 523 h.* = wallview.resolveHost(arena, s, xdg.pickKey(key, std.posix.getenv(xdg.key_env)), idle_ms) catch |err| {
524 std.debug.print("mux: bad wall target '{s}': {s}\n", .{ s, hosts.reason(err) });
525 return 2; 525 return 2;
526 }; 526 };
527 // A tile is a read-only attach, but it is still an attach: a stripe
528 // of the session the wall is running in paints into the grid it is
529 // reading, which is the same feedback loop with a nicer name. The
530 // WHOLE wall goes, not just the tile — dropping one silently would
531 // be a wall that lies about which sessions it is showing.
532 const tile_sock = switch (r.target) {
533 .sock => |p| p,
534 else => null,
535 };
536 if (insideThisSession(env_sock, env_session, tile_sock, r.session)) {
537 std.debug.print("mux: wall target '{s}' is the session this shell is inside\n", .{s});
538 std.debug.print("{s}", .{self_attach_refusal});
539 return 2;
540 }
541 } 527 }
542 // The other door into the same program: `mux wall` opens on the wall, 528 return wallview.run(arena, host_specs, .{});
543 // `mux TARGET` opens focused on the tile it just attached to. It does
544 // not hydrate — it has already read the file, or was handed the
545 // spellings it must show and no others — and it does need a terminal,
546 // because there is nothing to cut stripes from without one.
547 return wallview.run(arena, resolved, .{ .hydrated = from_file });
548 } 529 }
549 530
550 /// `mux wall add|rm SPELLING...`: file operations only, neither verb dials. 531 /// `mux wall add|rm SPELLING...`: file operations only, neither verb dials.
src/wallview.zig
Old New
@@ -1,5 +1,6 @@
1 //! The CLI wall: N sessions in one terminal, the multiattach the browser hub 1 //! The CLI wall: N sessions in one terminal, the multiattach the browser hub
2 //! gives without a browser, over the same wall file and grammar (wall.zig). 2 //! gives without a browser. The wall lists daemons (hosts.zig); a host's
3 //! live sessions are its tiles, polled.
3 //! 4 //!
4 //! Every tile claims its rectangle: attach sends the rect, relayout resends 5 //! Every tile claims its rectangle: attach sends the rect, relayout resends
5 //! it, and focus is client-local and sends nothing on the wire. 6 //! it, and focus is client-local and sends nothing on the wire.
@@ -20,6 +21,7 @@ const std = @import("std");
20 const proto = @import("protocol"); 21 const proto = @import("protocol");
21 const client = @import("client"); 22 const client = @import("client");
22 const wall = @import("wall"); 23 const wall = @import("wall");
24 const hosts = @import("hosts");
23 const handoff = @import("handoff"); 25 const handoff = @import("handoff");
24 const xdg = @import("xdg"); 26 const xdg = @import("xdg");
25 const sockpath = @import("sockpath"); 27 const sockpath = @import("sockpath");
@@ -48,7 +50,7 @@ pub const Resolved = struct {
48 agent: bool = false, 50 agent: bool = false,
49 }; 51 };
50 52
51 pub const ResolveError = wall.ParseError || error{ MissingKey, SockPathTooLong, OutOfMemory }; 53 pub const ResolveError = wall.ParseError || hosts.ParseError || error{ MissingKey, SockPathTooLong, OutOfMemory };
52 54
53 /// Result slices borrow `spelling` or live in `alloc`; keep both alive. 55 /// Result slices borrow `spelling` or live in `alloc`; keep both alive.
54 pub fn resolveSpelling( 56 pub fn resolveSpelling(
@@ -95,6 +97,54 @@ pub fn resolveSpelling(
95 return .{ .target = target, .label = spelling, .session = p.session }; 97 return .{ .target = target, .label = spelling, .session = p.session };
96 } 98 }
97 99
100 /// A daemon on the wall: what to dial, and the line that named it. The
101 /// spelling is the stripe's label and the sidecar's key, so it is kept
102 /// verbatim rather than rebuilt.
103 pub const HostSpec = struct { spelling: []const u8, target: client.Target };
104
105 /// `resolveSpelling`'s target half: a wall line names a daemon, not a
106 /// session.
107 pub fn resolveHost(
108 alloc: std.mem.Allocator,
109 spelling: []const u8,
110 key: ?[]const u8,
111 idle_ms: u32,
112 ) ResolveError!HostSpec {
113 const target: client.Target = switch (try hosts.parse(spelling)) {
114 // Refused here, at usage altitude, not at a connect that fails
115 // with a truncated sun_path nobody typed.
116 .sock => |path| if (path.len > sockpath.max_sun_path)
117 return error.SockPathTooLong
118 else
119 .{ .sock = path },
120 .host => |h| blk: {
121 const r = try handoff.recipeFor(alloc, h);
122 break :blk .{ .hand = .{
123 .host = h,
124 .ssh_cmd = r.ssh_cmd,
125 .cache_path = r.cache_path,
126 .idle_ms = idle_ms,
127 } };
128 },
129 .quic => |hp| blk: {
130 const key_path = switch (xdg.resolveKeyPath(alloc, key) catch |err| switch (err) {
131 error.NoHome => return error.MissingKey,
132 else => |e| return e,
133 }) {
134 .given => |kp| kp,
135 .default => |kp| kp,
136 .missing => return error.MissingKey,
137 };
138 break :blk .{ .quic = .{
139 .host_port = hp,
140 .key_path = key_path,
141 .idle_ms = idle_ms,
142 } };
143 },
144 };
145 return .{ .spelling = spelling, .target = target };
146 }
147
98 /// The daemon's row floor plus the label-bar arithmetic: a one-tile wall 148 /// The daemon's row floor plus the label-bar arithmetic: a one-tile wall
99 /// draws no bar so its floor is `min_session_rows`; two or more tiles each 149 /// draws no bar so its floor is `min_session_rows`; two or more tiles each
100 /// lose a row to a bar, so each stripe must hold that floor PLUS the bar 150 /// lose a row to a bar, so each stripe must hold that floor PLUS the bar
@@ -189,6 +239,10 @@ const State = enum {
189 reconnecting, 239 reconnecting,
190 exited, 240 exited,
191 refused, 241 refused,
242 /// A host's stripe: down, or up with nothing to show. Neither is a
243 /// tile's own state — no session ever reaches them.
244 @"unreachable",
245 empty,
192 246
193 fn word(s: State) []const u8 { 247 fn word(s: State) []const u8 {
194 return switch (s) { 248 return switch (s) {
@@ -197,6 +251,8 @@ const State = enum {
197 .reconnecting => "reconnecting", 251 .reconnecting => "reconnecting",
198 .exited => "exited", 252 .exited => "exited",
199 .refused => "refused", 253 .refused => "refused",
254 .@"unreachable" => "unreachable",
255 .empty => "no sessions",
200 }; 256 };
201 } 257 }
202 }; 258 };
@@ -333,6 +389,15 @@ const Tile = struct {
333 /// This tile's place in the wall: what the focus and the `1`-`9` 389 /// This tile's place in the wall: what the focus and the `1`-`9`
334 /// jump are indices into. 390 /// jump are indices into.
335 idx: usize, 391 idx: usize,
392 /// Which host on the wall this tile belongs to, as an index into the
393 /// run's host table. Null for a tile no host poll owns, which the poll
394 /// therefore never vanishes.
395 host: ?usize = null,
396 /// A host's placeholder rather than a session: no transport, no attach,
397 /// and a label bar that says why the host has no tiles. It exists only
398 /// while its host has no real tile, so `n`, the diff and the end walk
399 /// all have to step over it.
400 stripe: bool = false,
336 /// This tile's interaction core, set by its OWN pump thread once the 401 /// This tile's interaction core, set by its OWN pump thread once the
337 /// core exists and dereferenced only inside that pump's paint-end hook. 402 /// core exists and dereferenced only inside that pump's paint-end hook.
338 /// No lifetime beyond the pump's scope: the core is freed before the 403 /// No lifetime beyond the pump's scope: the core is freed before the
@@ -1650,6 +1715,33 @@ fn pumpTile(t: *Tile) void {
1650 } 1715 }
1651 } 1716 }
1652 1717
1718 /// A stripe's pump: no transport, no attach, no replica. It paints the
1719 /// host's bar and waits to be told it is over — the wall's paint rules
1720 /// (one repaint per relayout, one thread per tile) hold for a placeholder
1721 /// exactly as they do for a session.
1722 fn pumpStripe(t: *Tile) void {
1723 defer {
1724 t.alive.store(false, .release);
1725 ringKeyboard(t.shared);
1726 std.posix.close(t.wake_r);
1727 std.posix.close(t.wake_w);
1728 }
1729 while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) {
1730 {
1731 t.shared.paint_mu.lock();
1732 defer t.shared.paint_mu.unlock();
1733 // Painted even where `label_rows` is 0: a wall of one stripe
1734 // has no other row to say the host is down on.
1735 if (t.shared.is_tty) paintLabelLocked(t);
1736 }
1737 var fds = [_]std.posix.pollfd{
1738 .{ .fd = t.wake_r, .events = std.posix.POLL.IN, .revents = 0 },
1739 };
1740 _ = std.posix.poll(&fds, 200) catch return;
1741 drainWake(t);
1742 }
1743 }
1744
1653 /// Move the focus to tile `next`. Client-local: decides which pump owns the 1745 /// Move the focus to tile `next`. Client-local: decides which pump owns the
1654 /// terminal's modes, not which session the daemon hears. The outgoing tile's 1746 /// terminal's modes, not which session the daemon hears. The outgoing tile's
1655 /// `session_release` is written HERE, under `paint_mu`, so the handover is 1747 /// `session_release` is written HERE, under `paint_mu`, so the handover is
@@ -2001,7 +2093,7 @@ fn saveLayoutTo(
2001 } 2093 }
2002 2094
2003 /// Resolves the sidecar path from env and delegates to `saveLayoutTo`. 2095 /// Resolves the sidecar path from env and delegates to `saveLayoutTo`.
2004 fn saveLayoutIfHydrated( 2096 fn saveSidecar(
2005 alloc: std.mem.Allocator, 2097 alloc: std.mem.Allocator,
2006 tiles: []Tile, 2098 tiles: []Tile,
2007 present: []const bool, 2099 present: []const bool,
@@ -2057,7 +2149,10 @@ fn initTile(t: *Tile, r: Resolved, s: layout.Rect, shared: *Shared, idx: usize)
2057 } 2149 }
2058 2150
2059 fn spawnPump(t: *Tile) void { 2151 fn spawnPump(t: *Tile) void {
2060 const th = std.Thread.spawn(.{}, pumpTile, .{t}) catch { 2152 const th = (if (t.stripe)
2153 std.Thread.spawn(.{}, pumpStripe, .{t})
2154 else
2155 std.Thread.spawn(.{}, pumpTile, .{t})) catch {
2061 // A tile with no thread is a tile nothing will ever paint — the 2156 // A tile with no thread is a tile nothing will ever paint — the
2062 // same hole `pumpTile`'s exit closes, reached without the pump 2157 // same hole `pumpTile`'s exit closes, reached without the pump
2063 // having run at all. Marked here so the keyboard paints its bar and 2158 // having run at all. Marked here so the keyboard paints its bar and
@@ -2110,6 +2205,10 @@ const Birth = struct {
2110 // Where a REFUSED attach hands the focus back; null for a tile no 2205 // Where a REFUSED attach hands the focus back; null for a tile no
2111 // tile made (the fold's). 2206 // tile made (the fold's).
2112 born_from: ?usize, 2207 born_from: ?usize,
2208 // Which host owns it. A chord-born tile inherits the focus's, or the
2209 // next poll of that host births a second tile for the same session.
2210 host: ?usize = null,
2211 stripe: bool = false,
2113 }; 2212 };
2114 2213
2115 /// Every road onto a running wall — chord, fold, prompt — one body. 2214 /// Every road onto a running wall — chord, fold, prompt — one body.
@@ -2130,7 +2229,12 @@ fn birthTile(
2130 // cannot see that a `.beside` cut spends columns. 2229 // cannot see that a `.beside` cut spends columns.
2131 const at = live.*; 2230 const at = live.*;
2132 switch (b.place) { 2231 switch (b.place) {
2133 .beside_focus => shared.tree.insert(@intCast(b.from), @intCast(at)) catch return null, 2232 .beside_focus => if (shared.tree.root == null)
2233 // A wall whose tiles all arrive from a host's list starts with
2234 // no tree at all; `insert` has no leaf to sit beside.
2235 shared.tree.addFirst(@intCast(at)) catch return null
2236 else
2237 shared.tree.insert(@intCast(b.from), @intCast(at)) catch return null,
2134 .right_of => shared.tree.splitRight(@intCast(b.from), @intCast(at)) catch return null, 2238 .right_of => shared.tree.splitRight(@intCast(b.from), @intCast(at)) catch return null,
2135 .below => shared.tree.splitBelow(@intCast(b.from), @intCast(at)) catch return null, 2239 .below => shared.tree.splitBelow(@intCast(b.from), @intCast(at)) catch return null,
2136 } 2240 }
@@ -2168,6 +2272,8 @@ fn birthTile(
2168 tiles[at].record = b.record; 2272 tiles[at].record = b.record;
2169 tiles[at].creates = b.creates; 2273 tiles[at].creates = b.creates;
2170 tiles[at].born_from = b.born_from; 2274 tiles[at].born_from = b.born_from;
2275 tiles[at].host = b.host;
2276 tiles[at].stripe = b.stripe;
2171 present[at] = true; 2277 present[at] = true;
2172 live.* += 1; 2278 live.* += 1;
2173 return at; 2279 return at;
@@ -2188,7 +2294,9 @@ fn addSessionTile(
2188 const target = tiles[from].r.target; 2294 const target = tiles[from].r.target;
2189 const want = proto.resolveName(name); 2295 const want = proto.resolveName(name);
2190 for (tiles[0..live.*], present[0..live.*], 0..) |*t, p, i| { 2296 for (tiles[0..live.*], present[0..live.*], 0..) |*t, p, i| {
2191 if (!p) continue; 2297 // A stripe carries its host's target and no session of its own, so
2298 // matching it would focus the placeholder instead of making a shell.
2299 if (!p or t.stripe) continue;
2192 if (!sameTarget(t.r.target, target)) continue; 2300 if (!sameTarget(t.r.target, target)) continue;
2193 if (std.mem.eql(u8, proto.resolveName(t.r.session), want)) return .{ .moved = i }; 2301 if (std.mem.eql(u8, proto.resolveName(t.r.session), want)) return .{ .moved = i };
2194 } 2302 }
@@ -2209,6 +2317,7 @@ fn addSessionTile(
2209 .creates = true, 2317 .creates = true,
2210 .record = true, 2318 .record = true,
2211 .born_from = from, 2319 .born_from = from,
2320 .host = tiles[from].host,
2212 }) orelse return .full; 2321 }) orelse return .full;
2213 spawnPump(&tiles[at]); 2322 spawnPump(&tiles[at]);
2214 return .{ .moved = at }; 2323 return .{ .moved = at };
@@ -2243,6 +2352,7 @@ fn addSpelledTile(
2243 present: []bool, 2352 present: []bool,
2244 live: *usize, 2353 live: *usize,
2245 shared: *Shared, 2354 shared: *Shared,
2355 host_table: []const HostSpec,
2246 from: usize, 2356 from: usize,
2247 spelling: []const u8, 2357 spelling: []const u8,
2248 key: ?[]const u8, 2358 key: ?[]const u8,
@@ -2282,6 +2392,10 @@ fn addSpelledTile(
2282 .creates = true, 2392 .creates = true,
2283 .record = true, 2393 .record = true,
2284 .born_from = from, 2394 .born_from = from,
2395 // A spelling that names a host already on the wall belongs to it,
2396 // or that host's next poll births a second tile for the session
2397 // this one just made.
2398 .host = hostOf(host_table, r.target),
2285 }) orelse return .full; 2399 }) orelse return .full;
2286 spawnPump(&tiles[at]); 2400 spawnPump(&tiles[at]);
2287 return .{ .moved = at }; 2401 return .{ .moved = at };
@@ -2398,7 +2512,9 @@ fn endedTile(tiles: []Tile, present: []const bool, shared: *Shared) ?usize {
2398 const z = shared.sel; 2512 const z = shared.sel;
2399 var hit: ?usize = null; 2513 var hit: ?usize = null;
2400 for (tiles, present, 0..) |*t, p, i| { 2514 for (tiles, present, 0..) |*t, p, i| {
2401 if (!p or t.end_seen or t.alive.load(.acquire)) continue; 2515 // A stripe never ends: its pump returns when the keyboard vanishes
2516 // it, and a wall that read that as a session ending would exit.
2517 if (!p or t.stripe or t.end_seen or t.alive.load(.acquire)) continue;
2402 const reason: EndReason = @enumFromInt(t.end.load(.acquire)); 2518 const reason: EndReason = @enumFromInt(t.end.load(.acquire));
2403 // A tile that only narrates (lost / refused / taken) needs no 2519 // A tile that only narrates (lost / refused / taken) needs no
2404 // keyboard action: its pump painted its bar before it died. 2520 // keyboard action: its pump painted its bar before it died.
@@ -2444,89 +2560,33 @@ fn awaitDetach(t: *Tile, shared: *Shared) void {
2444 } 2560 }
2445 } 2561 }
2446 2562
2447 /// Deduped by SPELLING: the line the entry attach just wrote is the one 2563 /// The saved layout over the tiles the hosts turned out to have; null
2448 /// already on screen. Best effort throughout. 2564 /// when nothing matched.
2449 fn hydrate( 2565 fn restoreSidecar(
2450 alloc: std.mem.Allocator,
2451 tiles: []Tile,
2452 present: []bool,
2453 live: *usize,
2454 shared: *Shared,
2455 key: ?[]const u8,
2456 idle_ms: u32,
2457 ) usize {
2458 const path = wall.statePath(alloc) catch return 0;
2459 defer alloc.free(path);
2460 const w = wall.load(alloc, path) catch return 0;
2461 const env_sock = std.posix.getenv(proto.sock_env);
2462 const env_session = std.posix.getenv(proto.session_env);
2463 var added: usize = 0;
2464 outer: for (w.targets.items) |spelling| {
2465 for (tiles[0..live.*], present[0..live.*]) |*t, p| {
2466 if (p and std.mem.eql(u8, t.r.label, spelling)) continue :outer;
2467 }
2468 if (live.* >= max_tiles) {
2469 setNotice(shared, "[saved wall truncated: no room for more tiles]");
2470 break;
2471 }
2472 const r = resolveSpelling(alloc, spelling, key, idle_ms) catch continue;
2473 if (showsSelf(r.target, r.session, env_sock, env_session)) continue;
2474 const at = birthTile(alloc, tiles, present, live, shared, .{
2475 .r = r,
2476 .from = if (live.* > 0) live.* - 1 else 0,
2477 .place = .beside_focus,
2478 .creates = client.hydratedCreates(r.target),
2479 .record = false,
2480 .born_from = null,
2481 }) orelse {
2482 // Said rather than silently done: a wall that quietly shows a
2483 // PREFIX of what the user recorded is a wall lying about what
2484 // it is. Raised on the refusal, never before it — asking a
2485 // second, cruder question first pre-refused lines that fit.
2486 // It lands on the next focus, the only channel a wall with no
2487 // status line of its own has.
2488 setNotice(shared, "[saved wall truncated: no room for more tiles]");
2489 break;
2490 };
2491 spawnPump(&tiles[at]);
2492 added += 1;
2493 }
2494 if (added > 0) {
2495 // The fold grew the wall; try the saved sidecar over the folded
2496 // set. The entry tile participates like any other — its label is
2497 // a wall line. Dense indices from the present-only resolved array
2498 // are remapped to real tile indices so relayout finds every leaf.
2499 restoreFoldedLayout(alloc, tiles, present, live.*, shared) orelse
2500 shared.tree.setRootOrient(rootOrient(shared.size));
2501 }
2502 return added;
2503 }
2504
2505 /// The Ctrl-\ w fold's restore. Null when no sidecar or no match — the
2506 /// caller falls back to the aspect heuristic.
2507 fn restoreFoldedLayout(
2508 alloc: std.mem.Allocator, 2566 alloc: std.mem.Allocator,
2509 tiles: []Tile, 2567 tiles: []Tile,
2510 present: []bool, 2568 present: []bool,
2511 live: usize, 2569 live: usize,
2512 shared: *Shared, 2570 shared: *Shared,
2571 focus_out: *?usize,
2513 ) ?void { 2572 ) ?void {
2514 const path = wall.layoutPath(alloc) catch return null; 2573 const path = wall.layoutPath(alloc) catch return null;
2515 defer alloc.free(path); 2574 defer alloc.free(path);
2516 const bytes = wall.loadLayout(alloc, path) orelse return null; 2575 const bytes = wall.loadLayout(alloc, path) orelse return null;
2517 defer alloc.free(bytes); 2576 defer alloc.free(bytes);
2518 return restoreFoldedLayoutFrom(alloc, tiles, present, live, shared, bytes); 2577 return restoreLayoutFrom(alloc, tiles, present, live, shared, bytes, focus_out);
2519 } 2578 }
2520 2579
2521 /// Pure of file I/O like `restoreLayout`: the chained id translations — 2580 /// Pure of file I/O like `restoreLayout`: the chained id translations —
2522 /// saved→dense, then dense→real — are what a test must reach. 2581 /// saved→dense, then dense→real — are what a test must reach.
2523 fn restoreFoldedLayoutFrom( 2582 fn restoreLayoutFrom(
2524 alloc: std.mem.Allocator, 2583 alloc: std.mem.Allocator,
2525 tiles: []Tile, 2584 tiles: []Tile,
2526 present: []bool, 2585 present: []bool,
2527 live: usize, 2586 live: usize,
2528 shared: *Shared, 2587 shared: *Shared,
2529 bytes: []const u8, 2588 bytes: []const u8,
2589 focus_out: *?usize,
2530 ) ?void { 2590 ) ?void {
2531 const n = presentCount(present[0..live]); 2591 const n = presentCount(present[0..live]);
2532 if (n == 0) return null; 2592 if (n == 0) return null;
@@ -2542,18 +2602,323 @@ fn restoreFoldedLayoutFrom(
2542 di += 1; 2602 di += 1;
2543 } 2603 }
2544 } 2604 }
2545 // The fold keeps the user's focus: the entry tile they are typing 2605 var dense_focus: ?u8 = null;
2546 // into stays focused, so a saved focus record is ignored here. 2606 if (restoreLayout(alloc, dense, shared, bytes, &dense_focus)) {
2547 var dummy_focus: ?u8 = null;
2548 if (restoreLayout(alloc, dense, shared, bytes, &dummy_focus)) {
2549 // The tree's leaf ids are dense indices into `dense`; remap them 2607 // The tree's leaf ids are dense indices into `dense`; remap them
2550 // to the real tile indices relayout reads. 2608 // to the real tile indices relayout reads, and the saved focus
2609 // with them.
2551 shared.tree.remapLeaves(remap); 2610 shared.tree.remapLeaves(remap);
2611 if (dense_focus) |d| {
2612 if (d < remap.len) {
2613 if (remap[d]) |real| focus_out.* = real;
2614 }
2615 }
2552 return {}; 2616 return {};
2553 } 2617 }
2554 return null; 2618 return null;
2555 } 2619 }
2556 2620
2621 /// A diff's plan, bounded by the wall's own capacity so a daemon with more
2622 /// sessions than the wall can hold is COUNTED rather than overrunning.
2623 fn Fixed(comptime T: type) type {
2624 return struct {
2625 items: [max_tiles]T = undefined,
2626 len: usize = 0,
2627 dropped: usize = 0,
2628
2629 fn append(self: *@This(), v: T) void {
2630 if (self.len == self.items.len) {
2631 self.dropped += 1;
2632 return;
2633 }
2634 self.items[self.len] = v;
2635 self.len += 1;
2636 }
2637
2638 fn get(self: *const @This(), i: usize) T {
2639 return self.items[i];
2640 }
2641 };
2642 }
2643
2644 const BirthNames = Fixed([]const u8);
2645 const TileIdxs = Fixed(usize);
2646
2647 /// A stripe carries its host's target and no session, so it is neither a
2648 /// session the diff can keep nor one it can drop.
2649 fn ownedBy(t: *const Tile, host: usize) bool {
2650 if (t.stripe) return false;
2651 const h = t.host orelse return false;
2652 return h == host;
2653 }
2654
2655 fn firstPresent(present: []const bool) ?usize {
2656 for (present, 0..) |p, i| {
2657 if (p) return i;
2658 }
2659 return null;
2660 }
2661
2662 /// Which host on the wall a target belongs to, so a tile spelled by hand
2663 /// joins the poll that owns it instead of being duplicated by it.
2664 fn hostOf(host_specs: []const HostSpec, target: client.Target) ?usize {
2665 for (host_specs, 0..) |h, i| {
2666 if (sameTarget(h.target, target)) return i;
2667 }
2668 return null;
2669 }
2670
2671 /// The session on this host that mux itself is running inside, if any.
2672 fn selfSession(target: client.Target, env_sock: ?[]const u8, env_session: ?[]const u8) ?[]const u8 {
2673 const es = env_session orelse return null;
2674 if (!showsSelf(target, es, env_sock, env_session)) return null;
2675 return proto.resolveName(es);
2676 }
2677
2678 /// The whole of "a host's live sessions are its tiles", per HOST so that
2679 /// two daemons may share a session name. Pure: the rule is testable
2680 /// without a daemon.
2681 fn planHostDiff(
2682 tiles: []const Tile,
2683 present: []const bool,
2684 live: usize,
2685 host: usize,
2686 list: []const u8,
2687 self_name: ?[]const u8,
2688 births: *BirthNames,
2689 vanish: *TileIdxs,
2690 ) void {
2691 var it = std.mem.splitScalar(u8, list, '\n');
2692 while (it.next()) |name| {
2693 if (name.len == 0) continue;
2694 if (self_name) |self| if (std.mem.eql(u8, self, name)) continue;
2695 var found = false;
2696 for (tiles[0..live], present[0..live]) |*t, p| {
2697 if (p and ownedBy(t, host) and std.mem.eql(u8, proto.resolveName(t.r.session), name)) {
2698 found = true;
2699 break;
2700 }
2701 }
2702 if (!found) births.append(name);
2703 }
2704 for (tiles[0..live], present[0..live], 0..) |*t, p, i| {
2705 if (!p or !ownedBy(t, host)) continue;
2706 var keep = false;
2707 var it2 = std.mem.splitScalar(u8, list, '\n');
2708 while (it2.next()) |name| {
2709 if (std.mem.eql(u8, proto.resolveName(t.r.session), name)) {
2710 keep = true;
2711 break;
2712 }
2713 }
2714 if (!keep) vanish.append(i);
2715 }
2716 }
2717
2718 /// One daemon on the wall, as the run knows it: the poller writes, the
2719 /// keyboard reads.
2720 const Host = struct {
2721 spec: HostSpec,
2722 shared: *Shared,
2723 idx: usize,
2724 /// Never born as a tile here: see `showsSelf`.
2725 self_name: ?[]const u8 = null,
2726 /// A chord that births asks for the next poll NOW rather than in a
2727 /// second — the wall must not lag the session the user just made.
2728 poke: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
2729 list_mu: std.Thread.Mutex = .{},
2730 list: [proto.sessions_text_max]u8 = undefined,
2731 list_len: usize = 0,
2732 /// News for the keyboard: a poll finished, well or badly.
2733 list_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
2734 reachable: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
2735 };
2736
2737 /// Polling, not a push: a subscription is a new daemon concept, and one
2738 /// small frame a second per host over a link that already carries deltas is
2739 /// not a cost worth designing around.
2740 const host_poll_ms: u64 = 1000;
2741
2742 fn pollHost(h: *Host) void {
2743 var out: [proto.sessions_text_max]u8 = undefined;
2744 while (h.shared.running.load(.acquire)) {
2745 // A connection of its own per poll: the observer idle deadline and
2746 // the redial backoff stay the pump's problem, and this thread owns
2747 // no transport between polls that a teardown would have to reach.
2748 const got = client.listSessions(std.heap.page_allocator, h.spec.target, &out, 2000) catch null;
2749 if (got) |list| {
2750 h.list_mu.lock();
2751 @memcpy(h.list[0..list.len], list);
2752 h.list_len = list.len;
2753 h.list_mu.unlock();
2754 h.reachable.store(true, .release);
2755 } else h.reachable.store(false, .release);
2756 h.list_ready.store(true, .release);
2757 ringKeyboard(h.shared);
2758 var slept: u64 = 0;
2759 while (slept < host_poll_ms and
2760 !h.poke.swap(false, .acq_rel) and
2761 h.shared.running.load(.acquire)) : (slept += 50)
2762 std.Thread.sleep(50 * std.time.ns_per_ms);
2763 }
2764 }
2765
2766 /// A host with no tile of its own is ONE stripe, never one dead tile per
2767 /// session it used to have. True when the wall changed shape.
2768 fn stripeRule(
2769 alloc: std.mem.Allocator,
2770 tiles: []Tile,
2771 present: []bool,
2772 live: *usize,
2773 shared: *Shared,
2774 host_table: []Host,
2775 hi: usize,
2776 reachable: bool,
2777 sel: *usize,
2778 ) bool {
2779 var real = false;
2780 var stripe_at: ?usize = null;
2781 for (tiles[0..live.*], present[0..live.*], 0..) |*t, p, i| {
2782 if (!p) continue;
2783 const h = t.host orelse continue;
2784 if (h != hi) continue;
2785 if (t.stripe) stripe_at = i else real = true;
2786 }
2787 const want: State = if (reachable) .empty else .@"unreachable";
2788 if (real) {
2789 const at = stripe_at orelse return false;
2790 present[at] = false;
2791 tiles[at].gone.store(true, .release);
2792 ring(&tiles[at]);
2793 shared.tree.remove(@intCast(at));
2794 if (sel.* == at) sel.* = stepPresent(present[0..live.*], at, true) orelse sel.*;
2795 return true;
2796 }
2797 if (stripe_at) |at| {
2798 shared.paint_mu.lock();
2799 const now = tiles[at].state;
2800 shared.paint_mu.unlock();
2801 if (now != want) paintLabel(&tiles[at], want);
2802 return false;
2803 }
2804 const from = if (sel.* < live.* and present[sel.*]) sel.* else (firstPresent(present[0..live.*]) orelse 0);
2805 const at = birthTile(alloc, tiles, present, live, shared, .{
2806 // The stripe wears the host's own line: it is the wall's word for
2807 // the machine, not for any session on it.
2808 .r = .{ .target = host_table[hi].spec.target, .label = host_table[hi].spec.spelling, .session = "" },
2809 .from = from,
2810 .place = .beside_focus,
2811 .creates = false,
2812 .record = false,
2813 .born_from = null,
2814 .host = hi,
2815 .stripe = true,
2816 }) orelse return false;
2817 tiles[at].state = want;
2818 spawnPump(&tiles[at]);
2819 return true;
2820 }
2821
2822 /// A chord that births asks its host for a list NOW: a session made by
2823 /// `c` or `:` must not wait out the poll interval to become a tile.
2824 fn pokeHost(host_table: []Host, t: *const Tile) void {
2825 const hi = t.host orelse return;
2826 if (hi < host_table.len) host_table[hi].poke.store(true, .release);
2827 }
2828
2829 /// One host's list, applied to the wall. The keyboard thread only: it is
2830 /// the single writer of the tile array and the layout tree.
2831 fn applyHostList(
2832 alloc: std.mem.Allocator,
2833 tiles: []Tile,
2834 present: []bool,
2835 live: *usize,
2836 shared: *Shared,
2837 host_table: []Host,
2838 hi: usize,
2839 ) void {
2840 const h = &host_table[hi];
2841 const reachable = h.reachable.load(.acquire);
2842 var list_buf: [proto.sessions_text_max]u8 = undefined;
2843 var list: []const u8 = "";
2844 if (reachable) {
2845 h.list_mu.lock();
2846 @memcpy(list_buf[0..h.list_len], h.list[0..h.list_len]);
2847 list = list_buf[0..h.list_len];
2848 h.list_mu.unlock();
2849 }
2850 var sel = shared.sel;
2851 var changed = false;
2852 // Only a list DRIVES the diff. A host that has gone quiet keeps its
2853 // tiles, which reconnect on their own; vanishing them on a failed poll
2854 // would tear a wall down over one dropped packet.
2855 if (reachable) {
2856 var births = BirthNames{};
2857 var vanish = TileIdxs{};
2858 planHostDiff(tiles[0..live.*], present[0..live.*], live.*, hi, list, h.self_name, &births, &vanish);
2859 for (vanish.items[0..vanish.len]) |v| {
2860 present[v] = false;
2861 tiles[v].gone.store(true, .release);
2862 ring(&tiles[v]);
2863 shared.tree.remove(@intCast(v));
2864 if (sel == v) sel = stepPresent(present[0..live.*], v, true) orelse sel;
2865 changed = true;
2866 }
2867 var unplaced = births.dropped;
2868 for (births.items[0..births.len]) |name| {
2869 // The tile's own copies: `name` points into the buffer this
2870 // poll's reply was read into, and a pump outlives that.
2871 const session = alloc.dupe(u8, name) catch {
2872 unplaced += 1;
2873 continue;
2874 };
2875 const label = tileLabel(alloc, h.spec.target, session) catch {
2876 unplaced += 1;
2877 continue;
2878 };
2879 const from = if (sel < live.* and present[sel]) sel else (firstPresent(present[0..live.*]) orelse 0);
2880 const at = birthTile(alloc, tiles, present, live, shared, .{
2881 // Joins, never creates: the daemon already has this session,
2882 // and a sized attach on a live one would resize somebody.
2883 .r = .{ .target = h.spec.target, .label = label, .session = session },
2884 .from = from,
2885 .place = .beside_focus,
2886 .creates = false,
2887 .record = false,
2888 .born_from = null,
2889 .host = hi,
2890 }) orelse {
2891 unplaced += 1;
2892 continue;
2893 };
2894 spawnPump(&tiles[at]);
2895 changed = true;
2896 }
2897 // Said out loud rather than dropped: a wall showing a PREFIX of a
2898 // daemon's sessions is a wall lying about what it is.
2899 if (unplaced > 0) {
2900 var buf: [48]u8 = undefined;
2901 setNotice(shared, std.fmt.bufPrint(&buf, "[+{d} not shown]", .{unplaced}) catch "[not shown]");
2902 }
2903 }
2904 if (stripeRule(alloc, tiles, present, live, shared, host_table, hi, reachable, &sel)) changed = true;
2905 if (changed) relayout(alloc, tiles[0..live.*], present[0..live.*], shared, sel);
2906 }
2907
2908 /// The host grammar's own spelling of a target, for a wall entered by
2909 /// `mux TARGET` rather than off the file: the stripe's label and the
2910 /// sidecar's key have to read like the line that would have named it.
2911 fn hostSpelling(alloc: std.mem.Allocator, target: client.Target) ![]const u8 {
2912 return switch (target) {
2913 .sock => |p| try std.fmt.allocPrint(alloc, "--sock {s}", .{p}),
2914 .hand => |h| try alloc.dupe(u8, h.host),
2915 .quic => |q| try std.fmt.allocPrint(alloc, "quic://{s}", .{q.host_port}),
2916 // `--via` has no form in that grammar — an arbitrary command is not
2917 // an address — so the label is honest and is not a spelling.
2918 .via => |c| try std.fmt.allocPrint(alloc, "--via {s}", .{c}),
2919 };
2920 }
2921
2557 /// The DIAL is on the main thread, before any wall: ssh can want the tty. 2922 /// The DIAL is on the main thread, before any wall: ssh can want the tty.
2558 /// The pump ADOPTS a link that is already up. 2923 /// The pump ADOPTS a link that is already up.
2559 pub fn runAttach( 2924 pub fn runAttach(
@@ -2575,27 +2940,24 @@ pub fn runAttach(
2575 std.debug.print("{s}", .{f.msg}); 2940 std.debug.print("{s}", .{f.msg});
2576 return f.exit; 2941 return f.exit;
2577 }; 2942 };
2578 // The tile is NOT recorded here. A dial that came up is not an attach 2943 // One host, the one that was just dialled: its other sessions arrive
2579 // that landed — the daemon can still refuse it — so the write waits for 2944 // as tiles from its own list, like any host's.
2580 // the first state, inside the pump (`client.recordOnState`). 2945 const host_specs = [_]HostSpec{.{
2581 const name = proto.resolveName(session_name); 2946 .spelling = try hostSpelling(alloc, target),
2582 const resolved = [_]Resolved{.{
2583 .target = target, 2947 .target = target,
2584 .label = try tileLabel(alloc, target, name),
2585 .session = name,
2586 .agent = agent,
2587 }}; 2948 }};
2588 return run(alloc, &resolved, .{ 2949 return run(alloc, &host_specs, .{
2589 .focus0 = true, 2950 .focus0 = true,
2590 .pre = transport, 2951 .pre = transport,
2591 .carry = carry.items, 2952 .carry = carry.items,
2592 .record0 = true, 2953 .entry_host = 0,
2593 .hydrate = true, 2954 .entry_session = proto.resolveName(session_name),
2955 .agent = agent,
2594 .key = key, 2956 .key = key,
2595 .idle_ms = idle_ms, 2957 .idle_ms = idle_ms,
2596 // A scripted `mux` has pipes on both ends and is still a wall of 2958 // A scripted `mux` has pipes on both ends and is still a wall of
2597 // one tile whose rect is the whole terminal. Only `mux wall` needs 2959 // one tile whose rect is the whole terminal. Only a wall of many
2598 // a terminal. 2960 // needs a terminal.
2599 .needs_tty = false, 2961 .needs_tty = false,
2600 }) catch |err| { 2962 }) catch |err| {
2601 transport.close(); 2963 transport.close();
@@ -2603,12 +2965,12 @@ pub fn runAttach(
2603 }; 2965 };
2604 } 2966 }
2605 2967
2606 /// How a wall is ENTERED. One program, two doors: `mux wall` opens on the 2968 /// How a wall is ENTERED. One program, two doors: `mux` opens on the wall
2607 /// wall itself, `mux [TARGET]` opens focused on the tile it just attached 2969 /// itself, `mux TARGET` opens focused on the tile it just attached to.
2608 /// to. Everything after the first paint is the same machinery. 2970 /// Everything after the first paint is the same machinery.
2609 pub const Entry = struct { 2971 pub const Entry = struct {
2610 /// Start focused on tile 0 rather than on the wall. `mux [TARGET]` 2972 /// Start focused on tile 0 rather than on the wall. `mux [TARGET]`
2611 /// opens here; `mux wall` does not (it already has every tile). 2973 /// opens here.
2612 focus0: bool = false, 2974 focus0: bool = false,
2613 /// Tile 0's link, already open. See `Tile.pre`. 2975 /// Tile 0's link, already open. See `Tile.pre`.
2614 pre: ?client.Transport = null, 2976 pre: ?client.Transport = null,
@@ -2617,41 +2979,30 @@ pub const Entry = struct {
2617 /// through the mailbox like everything else a user types, so they reach 2979 /// through the mailbox like everything else a user types, so they reach
2618 /// the session by the same path — prediction, mouse split and all. 2980 /// the session by the same path — prediction, mouse split and all.
2619 carry: []const u8 = "", 2981 carry: []const u8 = "",
2620 /// Whether tile 0's attach earns a wall line. See `Tile.record`. 2982 /// Which host on the wall tile 0 belongs to. Null (with no `pre`) is
2621 record0: bool = false, 2983 /// the plain wall: every tile arrives from a host's own list.
2622 /// Whether the SAVED wall's other tiles join this one on the first 2984 entry_host: ?usize = null,
2623 /// `Ctrl-\ w`. `mux TARGET` hydrates: `Ctrl-\ w` shows THE wall, which 2985 /// The session tile 0 opens on that host; empty is the default one.
2624 /// is the file's tiles plus the one you are standing on. `mux wall` 2986 entry_session: []const u8 = "",
2625 /// does not, because it already read the file (or was handed spellings 2987 /// Whether tile 0's attaches offer this client's ssh-agent (`mux -A`).
2626 /// on its command line, and a wall of what was asked for must not grow 2988 /// Per tile, not per wall: a tile the user never named must not hand a
2627 /// tiles nobody named). 2989 /// stranger's host the keys.
2628 /// 2990 agent: bool = false,
2629 /// Deferred to the first `Ctrl-\ w` rather than done at startup, and 2991 /// The key and idle timeout a `quic://` host is resolved with — the
2630 /// that is a decision rather than laziness: a bare `mux` that dialled 2992 /// ones this invocation was given.
2631 /// every tile in a long attach history before showing the session would
2632 /// have converged the code and broken the feel. Nothing is dialled
2633 /// until somebody asks to see the wall.
2634 hydrate: bool = false,
2635 /// The key and idle timeout a hydrated `quic://` tile is resolved
2636 /// with — the ones this invocation was given, so a wall restored on a
2637 /// `Ctrl-\ w` dials the same way the command line would have.
2638 key: ?[]const u8 = null, 2993 key: ?[]const u8 = null,
2639 idle_ms: u32 = client.quic_idle_ms_default, 2994 idle_ms: u32 = client.quic_idle_ms_default,
2640 /// Whether this wall insists on a terminal. `mux wall` does — there is 2995 /// Whether this wall insists on a terminal. A wall of many does — there
2641 /// nothing to cut stripes from without one, and it says so. A scripted 2996 /// is nothing to cut stripes from without one, and it says so. A
2642 /// `mux TARGET` on a pipe does not: it is a wall of one tile whose rect 2997 /// scripted `mux TARGET` on a pipe does not: it is a wall of one tile
2643 /// is the whole terminal, and it writes exactly what the plain client 2998 /// whose rect is the whole terminal, and it writes exactly what the
2644 /// wrote. 2999 /// plain client wrote.
2645 needs_tty: bool = true, 3000 needs_tty: bool = true,
2646 /// True when tiles came from the wall file (`mux wall` with no argv).
2647 /// An argv wall is an explicit view and saves nothing. `mux TARGET`
2648 /// flips this true on the first `Ctrl-\ w` fold.
2649 hydrated: bool = false,
2650 }; 3001 };
2651 3002
2652 /// Run the wall until it is left. Never returns on the success path — see 3003 /// Run the wall until it is left. Never returns on the success path — see
2653 /// the exit at the bottom. 3004 /// the exit at the bottom.
2654 pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !u8 { 3005 pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) !u8 {
2655 const stdin_fd = std.posix.STDIN_FILENO; 3006 const stdin_fd = std.posix.STDIN_FILENO;
2656 const stdout_fd = std.posix.STDOUT_FILENO; 3007 const stdout_fd = std.posix.STDOUT_FILENO;
2657 const measured = interact.ttySize(stdout_fd); 3008 const measured = interact.ttySize(stdout_fd);
@@ -2671,48 +3022,22 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2671 // screen at all. 3022 // screen at all.
2672 const is_tty = std.posix.isatty(stdin_fd); 3023 const is_tty = std.posix.isatty(stdin_fd);
2673 3024
2674 if (resolved.len > max_tiles) {
2675 std.debug.print("mux: too many tiles ({d}, max {d})\n", .{ resolved.len, max_tiles });
2676 return 2;
2677 }
2678 // Rects are cut for the tiles that exist NOW. A wall of one tile
2679 // has one, and the geometry is re-cut when the first Ctrl-\ w hydrates
2680 // the rest — `relayout` already does exactly that for `x`.
2681 var shared = Shared{ .out_fd = stdout_fd, .size = size, .is_tty = is_tty }; 3025 var shared = Shared{ .out_fd = stdout_fd, .size = size, .is_tty = is_tty };
2682 shared.tree = layout.Tree.init(alloc); 3026 shared.tree = layout.Tree.init(alloc);
2683 shared.flat_alloc = alloc; 3027 shared.flat_alloc = alloc;
2684 // A hydrated wall (no-argv `mux wall`) tries the saved sidecar first: 3028 // The wall starts with the tile the user asked for and NOTHING else:
2685 // the saved tree wins verbatim, root orientation and all. Only on a 3029 // every other tile arrives from a host's own list, so the sidecar is
2686 // false (no file, garbage, or zero matches) does the aspect heuristic 3030 // restored later, over the tiles the hosts turn out to have.
2687 // and the addFirst/insert loop build the default tree. 3031 const has_entry = entry.pre != null and entry.entry_host != null;
2688 var restored_focus: ?u8 = null; 3032 if (has_entry) shared.tree.addFirst(0) catch return 2;
2689 const restored = entry.hydrated and blk: {
2690 const path = wall.layoutPath(alloc) catch break :blk false;
2691 defer alloc.free(path);
2692 const bytes = wall.loadLayout(alloc, path) orelse break :blk false;
2693 defer alloc.free(bytes);
2694 break :blk restoreLayout(alloc, resolved, &shared, bytes, &restored_focus);
2695 };
2696 if (!restored) {
2697 var i: usize = 0;
2698 while (i < resolved.len) : (i += 1) {
2699 if (i == 0) {
2700 shared.tree.addFirst(@intCast(i)) catch return 2;
2701 } else {
2702 shared.tree.insert(@intCast(i - 1), @intCast(i)) catch return 2;
2703 }
2704 }
2705 if (resolved.len > 1)
2706 shared.tree.setRootOrient(rootOrient(size));
2707 }
2708 const init_flat = shared.tree.flatten( 3033 const init_flat = shared.tree.flatten(
2709 alloc, 3034 alloc,
2710 size.rows, 3035 size.rows,
2711 size.cols, 3036 size.cols,
2712 wallFloors(resolved.len), 3037 wallFloors(@intFromBool(has_entry)),
2713 null, 3038 null,
2714 ) catch { 3039 ) catch {
2715 std.debug.print("mux: terminal too small for {d} tiles\n", .{resolved.len}); 3040 std.debug.print("mux: terminal too small\n", .{});
2716 return 2; 3041 return 2;
2717 }; 3042 };
2718 shared.last_flat = init_flat; 3043 shared.last_flat = init_flat;
@@ -2774,13 +3099,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2774 const kb = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); 3099 const kb = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true });
2775 shared.kb_r = kb[0]; 3100 shared.kb_r = kb[0];
2776 shared.kb_w = kb[1]; 3101 shared.kb_w = kb[1];
2777 // The restored focus tile is armed from the first byte: its 3102 shared.sel = 0;
2778 // `claim_pending` is set BEFORE its pump starts, so the claim goes out
2779 // on the first pass and the attach frame already carries the terminal's
2780 // size. A hydrated wall with a surviving focus record lands on that
2781 // tile; everything else defaults to 0.
2782 const focus0: usize = if (restored) (restored_focus orelse 0) else 0;
2783 shared.sel = focus0;
2784 3103
2785 // Allocated at CAPACITY, not at length: the pump threads are detached 3104 // Allocated at CAPACITY, not at length: the pump threads are detached
2786 // and hold `*Tile` for the wall's whole life, so the array may never 3105 // and hold `*Tile` for the wall's whole life, so the array may never
@@ -2789,27 +3108,24 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2789 var live: usize = 0; 3108 var live: usize = 0;
2790 const present = try alloc.alloc(bool, max_tiles); 3109 const present = try alloc.alloc(bool, max_tiles);
2791 @memset(present, false); 3110 @memset(present, false);
2792 for (resolved, 0..) |r, i| { 3111 const env_sock = std.posix.getenv(proto.sock_env);
2793 const rect = init_flat.rectOf(@intCast(i)) orelse return 2; 3112 const env_session = std.posix.getenv(proto.session_env);
2794 try initTile(&tiles[live], r, rect, &shared, live); 3113 if (has_entry) {
2795 // An argv wall is a view of what was named and creates nothing; 3114 const hi = entry.entry_host.?;
2796 // only the SAVED wall restores, and only its local lines. 3115 const target = host_specs[hi].target;
2797 tiles[live].creates = entry.hydrated and client.hydratedCreates(r.target); 3116 const name = proto.resolveName(entry.entry_session);
2798 present[live] = true; 3117 const rect = init_flat.rectOf(0) orelse return 2;
2799 live += 1; 3118 try initTile(&tiles[0], .{
2800 } 3119 .target = target,
2801 // A one-tile wall owns every row and draws no label bar; two or more 3120 .label = try tileLabel(alloc, target, name),
2802 // tiles each lose their top row to one. Set before the pumps start so 3121 .session = name,
2803 // `viewRows` is right on the first attach. 3122 .agent = entry.agent,
2804 shared.label_rows = if (live > 1) 1 else 0; 3123 }, rect, &shared, 0);
2805 // The restored focus tile's pump claims the terminal on its first 3124 tiles[0].host = hi;
2806 // pass. Set before `spawnPump` so the claim is the first thing
2807 // the pump does after the dial.
2808 if (live > 0) tiles[focus0].claim_pending.store(true, .release);
2809 if (entry.focus0 and live > 0) {
2810 tiles[0].pre = entry.pre; 3125 tiles[0].pre = entry.pre;
2811 tiles[0].record = entry.record0; 3126 // `mux TARGET` is attach-or-create: the attach carries the rect,
2812 tiles[0].creates = entry.focus0; 3127 // which is what the daemon reads create-vs-join off.
3128 tiles[0].creates = true;
2813 // The entry tile keeps the plain client's cold-loss rule; see 3129 // The entry tile keeps the plain client's cold-loss rule; see
2814 // `Tile.retry_cold`. 3130 // `Tile.retry_cold`.
2815 tiles[0].retry_cold = false; 3131 tiles[0].retry_cold = false;
@@ -2819,8 +3135,37 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2819 const n = @min(entry.carry.len, tiles[0].in.len); 3135 const n = @min(entry.carry.len, tiles[0].in.len);
2820 @memcpy(tiles[0].in[0..n], entry.carry[0..n]); 3136 @memcpy(tiles[0].in[0..n], entry.carry[0..n]);
2821 tiles[0].in_len = n; 3137 tiles[0].in_len = n;
3138 present[0] = true;
3139 live = 1;
3140 // Its pump claims the terminal on its first pass. Set before
3141 // `spawnPump` so the claim is the first thing the pump does after
3142 // the dial, and the attach already carries the terminal's size.
3143 tiles[0].claim_pending.store(true, .release);
2822 } 3144 }
3145 // A one-tile wall owns every row and draws no label bar; two or more
3146 // tiles each lose their top row to one. Set before the pumps start so
3147 // `viewRows` is right on the first attach.
3148 shared.label_rows = if (live > 1) 1 else 0;
2823 for (tiles[0..live]) |*t| spawnPump(t); 3149 for (tiles[0..live]) |*t| spawnPump(t);
3150 // A wall with no tile yet paints its one line rather than nothing: a
3151 // blank terminal with no cursor reads as hung, and the hosts are up to
3152 // a poll away from having anything to show.
3153 if (live == 0) relayout(alloc, tiles[0..live], present[0..live], &shared, 0);
3154
3155 // One poller per host, all of them at once and none of them on this
3156 // thread: the user asked for one session and must not be held on
3157 // another host's ssh to see it.
3158 const host_table = try alloc.alloc(Host, host_specs.len);
3159 for (host_table, host_specs, 0..) |*h, spec, i| {
3160 h.* = .{
3161 .spec = spec,
3162 .shared = &shared,
3163 .idx = i,
3164 .self_name = selfSession(spec.target, env_sock, env_session),
3165 };
3166 const th = std.Thread.spawn(.{}, pollHost, .{h}) catch continue;
3167 th.detach();
3168 }
2824 3169
2825 // The wall is always "in": every non-chord byte goes to the focused 3170 // The wall is always "in": every non-chord byte goes to the focused
2826 // tile, whose own Core splits mouse reports and owns the drag. The 3171 // tile, whose own Core splits mouse reports and owns the drag. The
@@ -2850,13 +3195,22 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2850 // on the normal screen once the terminal is back. 3195 // on the normal screen once the terminal is back.
2851 var exit_code: u8 = 0; 3196 var exit_code: u8 = 0;
2852 var exit_msg: ?[]const u8 = null; 3197 var exit_msg: ?[]const u8 = null;
2853 // Whether the saved wall has already been folded in. One-shot: the 3198 // The sidecar is restored ONCE, over the tiles the hosts turned out to
2854 // first `Ctrl-\ w` shows THE wall, and every one after it shows the 3199 // have: at the latest 2s in, so a host that never answers cannot hold
2855 // same one rather than re-reading a file other clients are also 3200 // the user on an unlaid-out wall, and as soon as every host has
2856 // writing. 3201 // reported when they are quick.
2857 var hydrated = !entry.hydrate; 3202 const restore_due: i64 = std.time.milliTimestamp() + 2000;
2858 // Save on exit only if the wall came from the file or the fold grew it. 3203 var restore_tried = false;
2859 var save_hydrated = entry.hydrated; 3204 var restore_ok = false;
3205 // "Has this host answered" is the KEYBOARD's flag, not the poller's: it
3206 // is a list APPLIED that gives the wall a tile, and a poller flag read
3207 // between its own two stores would restore over a wall still missing
3208 // that host's sessions.
3209 const applied = try alloc.alloc(bool, host_specs.len);
3210 @memset(applied, false);
3211 // The aspect heuristic is the fallback for a wall with no saved tree,
3212 // and it decides once — the first time the wall holds two tiles.
3213 var oriented = false;
2860 // Stdin gone is not the wall gone. A piped `mux` whose script has run 3214 // Stdin gone is not the wall gone. A piped `mux` whose script has run
2861 // out still has a session on the far end, exactly as the plain client 3215 // out still has a session on the far end, exactly as the plain client
2862 // did — it ends when that session does. 3216 // did — it ends when that session does.
@@ -2917,6 +3271,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2917 .full => setNotice(&shared, "[no room on the wall for another tile]"), 3271 .full => setNotice(&shared, "[no room on the wall for another tile]"),
2918 .stay => {}, 3272 .stay => {},
2919 } 3273 }
3274 pokeHost(host_table, &tiles[z]);
2920 } 3275 }
2921 } 3276 }
2922 // Ends are read every pass, not only on the bell: two pumps dying 3277 // Ends are read every pass, not only on the bell: two pumps dying
@@ -2950,14 +3305,48 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2950 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, true, target); 3305 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, true, target);
2951 }, 3306 },
2952 .finish => |how| { 3307 .finish => |how| {
2953 if (save_hydrated) 3308 saveSidecar(alloc, tiles[0..live], present[0..live], &shared);
2954 saveLayoutIfHydrated(alloc, tiles[0..live], present[0..live], &shared);
2955 exit_code = how.code; 3309 exit_code = how.code;
2956 exit_msg = how.msg; 3310 exit_msg = how.msg;
2957 break :keys; 3311 break :keys;
2958 }, 3312 },
2959 } 3313 }
2960 } 3314 }
3315 // Ends first, lists second: a session that exited is its pump's
3316 // news and arrives at once, while a list is up to a poll behind.
3317 // Reading the list first would vanish the tile the exit code is on.
3318 for (0..host_table.len) |hi| {
3319 if (!host_table[hi].list_ready.swap(false, .acq_rel)) continue;
3320 applyHostList(alloc, tiles, present, &live, &shared, host_table, hi);
3321 applied[hi] = true;
3322 }
3323 if (!restore_tried) {
3324 var all_reported = true;
3325 for (applied) |a| {
3326 if (!a) {
3327 all_reported = false;
3328 break;
3329 }
3330 }
3331 if (all_reported or std.time.milliTimestamp() >= restore_due) {
3332 restore_tried = true;
3333 var saved_focus: ?usize = null;
3334 if (live > 0 and restoreSidecar(alloc, tiles, present, live, &shared, &saved_focus) != null) {
3335 restore_ok = true;
3336 // The entry tile is the one the user is already typing
3337 // into; a saved focus record must not move them off it.
3338 const to = if (has_entry) shared.sel else (saved_focus orelse shared.sel);
3339 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, true, to);
3340 }
3341 }
3342 }
3343 if (restore_tried and !restore_ok and !oriented and presentCount(present[0..live]) > 1) {
3344 oriented = true;
3345 shared.paint_mu.lock();
3346 shared.tree.setRootOrient(rootOrient(shared.size));
3347 shared.paint_mu.unlock();
3348 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel);
3349 }
2961 if (fds[0].revents == 0) continue; 3350 if (fds[0].revents == 0) continue;
2962 3351
2963 const n = std.posix.read(stdin_fd, &b) catch break; 3352 const n = std.posix.read(stdin_fd, &b) catch break;
@@ -3056,33 +3445,45 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
3056 tiles[z].detach_req.store(true, .release); 3445 tiles[z].detach_req.store(true, .release);
3057 ring(&tiles[z]); 3446 ring(&tiles[z]);
3058 awaitDetach(&tiles[z], &shared); 3447 awaitDetach(&tiles[z], &shared);
3059 if (save_hydrated) 3448 saveSidecar(alloc, tiles[0..live], present[0..live], &shared);
3060 saveLayoutIfHydrated(alloc, tiles[0..live], present[0..live], &shared);
3061 exit_code = 0; 3449 exit_code = 0;
3062 exit_msg = "mux: detached (session still running; run mux to reattach)"; 3450 exit_msg = "mux: detached (session still running; run mux to reattach)";
3063 break :keys; 3451 break :keys;
3064 }, 3452 },
3065 .wall => { 3453 .wall => {
3066 // `Ctrl-\ w` shows the wall: hydrate the saved tiles 3454 // Nothing left to fold in: every tile the wall will ever
3067 // (one-shot) and re-cut the stripes over everything that 3455 // have is already here or on its way from a host's list.
3068 // is present NOW. On a `mux wall` this is a no-op 3456 // `Ctrl-\ w` is a re-cut of the stripes, and an unzoom.
3069 // (already hydrated); on a `mux TARGET` it grows the one
3070 // tile into the full wall.
3071 if (!hydrated) {
3072 hydrated = true;
3073 save_hydrated = true;
3074 _ = hydrate(alloc, tiles, present, &live, &shared, entry.key, entry.idle_ms);
3075 }
3076 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel); 3457 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel);
3077 }, 3458 },
3078 .new_session => { 3459 .new_session, .split_right, .split_below => {
3079 // Ask the focused tile's daemon for a new session. The 3460 // Ask the focused tile's daemon for a new session. The
3080 // answer arrives on the pump's transport and is posted 3461 // answer arrives on the pump's transport and is posted
3081 // back to the keyboard (`postAnswer`), which moves the 3462 // back to the keyboard (`postAnswer`), which moves the
3082 // focus or grows the wall. 3463 // focus or grows the wall.
3083 tiles[z].pending_place = .beside_focus; 3464 tiles[z].pending_place = switch (cmd.action) {
3084 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.new), .release); 3465 .split_right => .right_of,
3085 ring(&tiles[z]); 3466 .split_below => .below,
3467 else => .beside_focus,
3468 };
3469 if (tiles[z].stripe) {
3470 // A stripe has no transport to ask with, so it names
3471 // the session `mux HOST` would have made and lets the
3472 // attach create it; the next poll dedups.
3473 const before = live;
3474 switch (addSessionTile(alloc, tiles, present, &live, &shared, z, proto.default_session, tiles[z].pending_place)) {
3475 .moved => |to| {
3476 last_focus = z;
3477 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, live > before, to);
3478 },
3479 .full => setNotice(&shared, "[no room on the wall for another tile]"),
3480 .stay => {},
3481 }
3482 } else {
3483 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.new), .release);
3484 ring(&tiles[z]);
3485 }
3486 pokeHost(host_table, &tiles[z]);
3086 }, 3487 },
3087 .next_session => { 3488 .next_session => {
3088 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.next), .release); 3489 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.next), .release);
@@ -3106,16 +3507,6 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
3106 } 3507 }
3107 } 3508 }
3108 }, 3509 },
3109 .split_right => {
3110 tiles[z].pending_place = .right_of;
3111 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.new), .release);
3112 ring(&tiles[z]);
3113 },
3114 .split_below => {
3115 tiles[z].pending_place = .below;
3116 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.new), .release);
3117 ring(&tiles[z]);
3118 },
3119 .fullscreen => { 3510 .fullscreen => {
3120 shared.fullscreen = !shared.fullscreen; 3511 shared.fullscreen = !shared.fullscreen;
3121 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel); 3512 relayout(alloc, tiles[0..live], present[0..live], &shared, shared.sel);
@@ -3140,7 +3531,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
3140 }, 3531 },
3141 .add_tile => |spelling| { 3532 .add_tile => |spelling| {
3142 const before = live; 3533 const before = live;
3143 switch (addSpelledTile(alloc, tiles, present, &live, &shared, z, spelling, entry.key, entry.idle_ms)) { 3534 switch (addSpelledTile(alloc, tiles, present, &live, &shared, host_specs, z, spelling, entry.key, entry.idle_ms)) {
3144 .moved => |to| { 3535 .moved => |to| {
3145 last_focus = z; 3536 last_focus = z;
3146 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, live > before, to); 3537 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, live > before, to);
@@ -3153,6 +3544,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
3153 // puts it in front of the eyes that earned it. 3544 // puts it in front of the eyes that earned it.
3154 .stay => showRefusal(tiles[0..live], &shared, z), 3545 .stay => showRefusal(tiles[0..live], &shared, z),
3155 } 3546 }
3547 pokeHost(host_table, &tiles[z]);
3156 }, 3548 },
3157 } 3549 }
3158 } 3550 }
@@ -3307,6 +3699,113 @@ test "birthTile: a prompt-born tile creates, records, and offers no agent" {
3307 try std.testing.expectEqual(@as(u16, 1), shared.label_rows); 3699 try std.testing.expectEqual(@as(u16, 1), shared.label_rows);
3308 } 3700 }
3309 3701
3702 /// Three tiles: two sessions on host 0 and a same-named one on host 1, so a
3703 /// diff that aliased by NAME alone would be caught.
3704 fn diffFixture(shared: *Shared) [3]Tile {
3705 const t0: client.Target = .{ .sock = "/tmp/h0.sock" };
3706 const t1: client.Target = .{ .sock = "/tmp/h1.sock" };
3707 const rect: layout.Rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 };
3708 return .{
3709 .{
3710 .r = .{ .target = t0, .label = "--sock /tmp/h0.sock#a", .session = "a" },
3711 .rect = rect,
3712 .shared = shared,
3713 .idx = 0,
3714 .wake_r = -1,
3715 .wake_w = -1,
3716 .host = 0,
3717 },
3718 .{
3719 .r = .{ .target = t0, .label = "--sock /tmp/h0.sock#b", .session = "b" },
3720 .rect = rect,
3721 .shared = shared,
3722 .idx = 1,
3723 .wake_r = -1,
3724 .wake_w = -1,
3725 .host = 0,
3726 },
3727 .{
3728 .r = .{ .target = t1, .label = "--sock /tmp/h1.sock#a", .session = "a" },
3729 .rect = rect,
3730 .shared = shared,
3731 .idx = 2,
3732 .wake_r = -1,
3733 .wake_w = -1,
3734 .host = 1,
3735 },
3736 };
3737 }
3738
3739 test "planHostDiff: names the daemon has and the wall does not are births, tiles it dropped vanish, and another host's same name is untouched" {
3740 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3741 var tiles = diffFixture(&shared);
3742 var present = [_]bool{ true, true, true };
3743
3744 var births = BirthNames{};
3745 var vanish = TileIdxs{};
3746 planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &births, &vanish);
3747 try std.testing.expectEqual(@as(usize, 1), births.len);
3748 try std.testing.expectEqualStrings("c", births.get(0));
3749 try std.testing.expectEqual(@as(usize, 1), vanish.len);
3750 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
3751
3752 // Host 1's "a" survives host 0's list saying nothing about it.
3753 births.len = 0;
3754 vanish.len = 0;
3755 planHostDiff(&tiles, &present, 3, 1, "a\n", null, &births, &vanish);
3756 try std.testing.expectEqual(@as(usize, 0), births.len + vanish.len);
3757
3758 // An empty list vanishes everything the host had.
3759 planHostDiff(&tiles, &present, 3, 0, "", null, &births, &vanish);
3760 try std.testing.expectEqual(@as(usize, 2), vanish.len);
3761 try std.testing.expectEqual(@as(usize, 0), births.len);
3762 }
3763
3764 test "planHostDiff: a vanished tile is not present, so the next list does not vanish it twice" {
3765 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3766 var tiles = diffFixture(&shared);
3767 var present = [_]bool{ true, false, true };
3768
3769 var births = BirthNames{};
3770 var vanish = TileIdxs{};
3771 planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
3772 try std.testing.expectEqual(@as(usize, 0), vanish.len);
3773 // ...and the name is not reborn either: the tile is gone, but the
3774 // daemon no longer lists it, so there is nothing to bring back.
3775 try std.testing.expectEqual(@as(usize, 0), births.len);
3776 }
3777
3778 test "planHostDiff: the session this shell is inside is never born as a tile" {
3779 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3780 var tiles = diffFixture(&shared);
3781 var present = [_]bool{ true, true, true };
3782
3783 var births = BirthNames{};
3784 var vanish = TileIdxs{};
3785 // The daemon has "a", "b" and "self"; "self" is the shell mux runs in,
3786 // so a tile of it would paint into the grid it is reading.
3787 planHostDiff(&tiles, &present, 3, 0, "a\nb\nself\n", "self", &births, &vanish);
3788 try std.testing.expectEqual(@as(usize, 0), births.len);
3789 try std.testing.expectEqual(@as(usize, 0), vanish.len);
3790 }
3791
3792 test "planHostDiff: a host's stripe is not one of its sessions — it neither dedups a birth nor vanishes" {
3793 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3794 var tiles = diffFixture(&shared);
3795 tiles[0].stripe = true;
3796 tiles[0].r.session = "a";
3797 var present = [_]bool{ true, false, true };
3798
3799 var births = BirthNames{};
3800 var vanish = TileIdxs{};
3801 planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
3802 // The stripe is host 0's placeholder, not its session "a": the name is
3803 // still owed a tile, and the stripe is not the diff's to remove.
3804 try std.testing.expectEqual(@as(usize, 1), births.len);
3805 try std.testing.expectEqualStrings("a", births.get(0));
3806 try std.testing.expectEqual(@as(usize, 0), vanish.len);
3807 }
3808
3310 test "addSpelledTile: a flag-like spelling is refused, as argv refuses it" { 3809 test "addSpelledTile: a flag-like spelling is refused, as argv refuses it" {
3311 const alloc = std.testing.allocator; 3810 const alloc = std.testing.allocator;
3312 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 3811 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
@@ -3327,7 +3826,7 @@ test "addSpelledTile: a flag-like spelling is refused, as argv refuses it" {
3327 // tile for a host named `-A` is nobody's intent at either mouth. The 3826 // tile for a host named `-A` is nobody's intent at either mouth. The
3328 // host part is unresolvable on purpose — a refusal that reached a dial 3827 // host part is unresolvable on purpose — a refusal that reached a dial
3329 // would be this assertion passing for the wrong reason. 3828 // would be this assertion passing for the wrong reason.
3330 const to = addSpelledTile(alloc, &tiles, &present, &live, &shared, 0, "-A nosuchhost.invalid", null, 30_000); 3829 const to = addSpelledTile(alloc, &tiles, &present, &live, &shared, &.{}, 0, "-A nosuchhost.invalid", null, 30_000);
3331 try std.testing.expect(to == .stay); 3830 try std.testing.expect(to == .stay);
3332 try std.testing.expectEqual(@as(usize, 1), live); 3831 try std.testing.expectEqual(@as(usize, 1), live);
3333 try std.testing.expect(!present[1]); 3832 try std.testing.expect(!present[1]);
@@ -5309,7 +5808,7 @@ test "restore: duplicate spellings pair positionally" {
5309 try std.testing.expect(t0.cols > t1.cols); 5808 try std.testing.expect(t0.cols > t1.cols);
5310 } 5809 }
5311 5810
5312 test "restoreFoldedLayout: dense sidecar indices land on the real tile indices" { 5811 test "restoreLayoutFrom: dense sidecar indices land on the real tile indices" {
5313 // Tile 1 was forgotten, so the dense array is 0,2,3 and the two index 5812 // Tile 1 was forgotten, so the dense array is 0,2,3 and the two index
5314 // spaces disagree. With no hole they coincide and the second remap is 5813 // spaces disagree. With no hole they coincide and the second remap is
5315 // untestable by construction — which is how a tile could be handed the 5814 // untestable by construction — which is how a tile could be handed the
@@ -5343,7 +5842,8 @@ test "restoreFoldedLayout: dense sidecar indices land on the real tile indices"
5343 // so each tile's width names which saved leaf it was given. 5842 // so each tile's width names which saved leaf it was given.
5344 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n" ++ 5843 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n" ++
5345 " leaf 20 --sock /tmp/x#c\n leaf 18 --sock /tmp/x#d\n"; 5844 " leaf 20 --sock /tmp/x#c\n leaf 18 --sock /tmp/x#d\n";
5346 restoreFoldedLayoutFrom(alloc, &tiles, &present, 4, &shared, bytes) orelse 5845 var restored_focus: ?usize = null;
5846 restoreLayoutFrom(alloc, &tiles, &present, 4, &shared, bytes, &restored_focus) orelse
5347 return error.TestUnexpectedResult; 5847 return error.TestUnexpectedResult;
5348 const flat = try shared.tree.flatten(alloc, 24, 80, wallFloors(3), null); 5848 const flat = try shared.tree.flatten(alloc, 24, 80, wallFloors(3), null);
5349 defer flat.deinit(alloc); 5849 defer flat.deinit(alloc);