6c535804
feat: a host's live sessions are its tiles — per-host poll, diff, stripe
a73x 2026-08-28 19:53
Commit message
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); |