a73x

2b544162

refactor: the wall shows sessions only — stripes go

a73x   2026-08-28 19:53

Commit message
refactor: the wall shows sessions only — stripes go

A stripe was a placeholder tile for a host with nothing live on it: a bar
reading `[unreachable]` or `[no sessions]` and no session behind it. A
hands-on demo said what the model could not: a wall of bars for machines
you are not using is a wall you have to read past to find the two shells
you came for. So a host with no tile contributes NOTHING, and the wall is
its live sessions and nothing else.

What goes with it: `Tile.stripe` and `Birth.stripe`, `stripeRule`,
`stripePaints`, `pumpStripe`, the `.unreachable` / `.empty` bar states and
their words, and every walk that had to step over a placeholder — `endAsk`,
`addSessionTile`, `endedTile`, `awaitDetach`, `ownedBy`, and the `c` chord's
branch that turned a stripe into a session. Hosts keep their pollers and
their lists: `Host.reachable`, `Host.list` and `pollHost` are untouched,
because a host that is up with nothing on it is still a fact — it just has
somewhere else to be said.

An empty wall is now a state you can sit in rather than a sub-second gap
before the first poll, which costs two things. Its one line names the way
out: `Ctrl-\ s` to pick a host (the key lands with the picker), `Ctrl-\ d`
to leave. And the keyboard grows a guard for `live == 0`: `tiles[shared.sel]`
is a slot that was never written when nothing has ever been born, and every
chord below reads one.

CLAUDE.md
Old New
@@ -98,8 +98,9 @@ real pty), `wsclient` (browser stand-in), `rawmode`, `delaypipe`, `render`.
98 Tiles come from each host's own `sessions_req` on a fresh side 98 Tiles come from each host's own `sessions_req` on a fresh side
99 connection, polled once a second — never from the file — so a birth by 99 connection, polled once a second — never from the file — so a birth by
100 anyone appears and an exit disappears, and `n`/`p`/digits walk the one 100 anyone appears and an exit disappears, and `n`/`p`/digits walk the one
101 list. A host contributes ONE stripe only when it has no tiles; tiles ride 101 list. A host with no live session contributes NOTHING — the wall shows
102 out a blip rather than vanishing. `--via` and `muxa` record no host. 102 sessions and nothing else, and a wall with none says it is empty; tiles
103 ride out a blip rather than vanishing. `--via` and `muxa` record no host.
103 Nothing re-creates a session: a daemon restart heals to what that daemon 104 Nothing re-creates a session: a daemon restart heals to what that daemon
104 now has, which for a fresh `muxd` is session `0`. 105 now has, which for a fresh `muxd` is session `0`.
105 `hosts.load` is strict — a bad line refuses `mux` with rc 2 and prints 106 `hosts.load` is strict — a bad line refuses `mux` with rc 2 and prints
README.md
Old New
@@ -374,38 +374,36 @@ absent sockets). `mux hosts rm` reads the file verbatim too, so a line
374 you hand-edited into something no longer legal can be typed back byte for 374 you hand-edited into something no longer legal can be typed back byte for
375 byte and removed instead of wedging every command that touches the wall. 375 byte and removed instead of wedging every command that touches the wall.
376 376
377 A daemon that is not answering is **one** stripe — `--sock PATH 377 A daemon that is not answering contributes **nothing** to the wall: not one
378 [unreachable]` — and not one dead tile per session it used to have. It keeps 378 dead tile per session it used to have, and no placeholder of its own either.
379 redialling, forever and on purpose: a wall is a thing you leave up, and a 379 Its line stays in the file and its poller keeps redialling, forever and on
380 laptop that wakes an hour later should find its sessions. The cost is that a 380 purpose — a wall is a thing you leave up, and a laptop that wakes an hour
381 daemon that is never coming back looks exactly like one whose network is 381 later should find its sessions back on it. The cost is that a daemon that is
382 merely down. Nothing times it out; `mux hosts rm SPELLING` is how a line 382 never coming back looks exactly like one whose network is merely down.
383 leaves. Your own daemon is the exception that needs no stripe: a listed 383 Nothing times it out; `mux hosts rm SPELLING` is how a line leaves. Your own
384 daemon is the exception that is started rather than waited for: a listed
384 local `--sock <default>` that nothing is serving is started for you, because 385 local `--sock <default>` that nothing is serving is started for you, because
385 it dies on every reboot while its line lives on. 386 it dies on every reboot while its line lives on.
386 387
387 A host that answers with an EMPTY list is a stripe reading `[no sessions]` 388 A host that answers with an EMPTY list contributes nothing either. You will
388 instead. You will not see it from your own machine — `muxd` exits when its 389 not see that from your own machine — `muxd` exits when its last session
389 last session ends, so a local daemon that answers at all has at least 390 ends, so a local daemon that answers at all has at least session `0` — but a
390 session `0` — but a remote daemon something else is holding open can sit 391 remote daemon something else is holding open can sit there with nothing to
391 there with nothing to show. 392 show. A wall on which no host has a live session is empty, and says so on
392 393 one line rather than showing a blank screen that reads as hung.
393 **A daemon too old to be asked reads as `[unreachable]` too.** Listing a 394
394 daemon's live sessions is a new question on the wire, and a daemon from 395 **A daemon too old to be asked shows nothing too.** Listing a daemon's live
395 before this change answers it with the silence it answers every unknown 396 sessions is a new question on the wire, and a daemon from before this change
396 frame with. It is up, `mux --sock PATH` still attaches to it, and the wall 397 answers it with the silence it answers every unknown frame with. It is up,
397 still shows it as a stripe that never heals, so today `muxd upgrade` (or a 398 `mux --sock PATH` still attaches to it, and none of its sessions reach the
398 restart) on every box you put on the wall is what clears it. Whether that 399 wall, so today `muxd upgrade` (or a restart) on every box you put on the
399 stays the answer is undecided: the alternative is to tell the two apart — 400 wall is what fills it in.
400 `[upgrade muxd]` when the dial succeeded and only the list timed out,
401 `[unreachable]` when nothing answered at all — which costs a word and a
402 branch and is not built until someone asks for it.
403 401
404 Restarting a daemon re-creates **nothing**. The old wall would have brought 402 Restarting a daemon re-creates **nothing**. The old wall would have brought
405 your sessions back from the file; this one cannot, because the file has no 403 your sessions back from the file; this one cannot, because the file has no
406 session in it to bring back. The stripe goes `[unreachable]`, then heals to 404 session in it to bring back. A host's tiles leave with it and the wall heals
407 whatever the daemon has when it returns — which, for a daemon someone just 405 to whatever that daemon has when it returns — which, for a daemon someone
408 started, is one session called `0`. 406 just started, is one session called `0`.
409 407
410 `mux hosts add`/`rm` are file operations only: neither dials, and `rm` never 408 `mux hosts add`/`rm` are file operations only: neither dials, and `rm` never
411 touches the session — the shells on that machine go on running and `mux 409 touches the session — the shells on that machine go on running and `mux
src/wallview.zig
Old New
@@ -53,8 +53,8 @@ pub const Resolved = struct {
53 pub const ResolveError = hosts.ParseError || error{ MissingKey, SockPathTooLong, OutOfMemory }; 53 pub const ResolveError = hosts.ParseError || error{ MissingKey, SockPathTooLong, OutOfMemory };
54 54
55 /// A daemon on the wall: what to dial, and the line that named it. The 55 /// A daemon on the wall: what to dial, and the line that named it. The
56 /// spelling is the stripe's label and the sidecar's key, so it is kept 56 /// spelling is the sidecar's key and what `mux hosts` prints back, so it
57 /// verbatim rather than rebuilt. 57 /// is kept verbatim rather than rebuilt.
58 pub const HostSpec = struct { spelling: []const u8, target: client.Target, poll_target: client.Target }; 58 pub const HostSpec = struct { spelling: []const u8, target: client.Target, poll_target: client.Target };
59 59
60 /// A host line names a daemon, not a session; this is its target. 60 /// A host line names a daemon, not a session; this is its target.
@@ -231,10 +231,6 @@ const State = enum {
231 reconnecting, 231 reconnecting,
232 exited, 232 exited,
233 refused, 233 refused,
234 /// A host's stripe: down, or up with nothing to show. Neither is a
235 /// tile's own state — no session ever reaches them.
236 @"unreachable",
237 empty,
238 234
239 fn word(s: State) []const u8 { 235 fn word(s: State) []const u8 {
240 return switch (s) { 236 return switch (s) {
@@ -243,8 +239,6 @@ const State = enum {
243 .reconnecting => "reconnecting", 239 .reconnecting => "reconnecting",
244 .exited => "exited", 240 .exited => "exited",
245 .refused => "refused", 241 .refused => "refused",
246 .@"unreachable" => "unreachable",
247 .empty => "no sessions",
248 }; 242 };
249 } 243 }
250 }; 244 };
@@ -393,11 +387,6 @@ const Tile = struct {
393 /// One list already failed to name this tile's session while its pump 387 /// One list already failed to name this tile's session while its pump
394 /// was still alive. Keyboard-thread only, like the diff that sets it. 388 /// was still alive. Keyboard-thread only, like the diff that sets it.
395 missed_once: bool = false, 389 missed_once: bool = false,
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,
401 /// This tile's interaction core, set by its OWN pump thread once the 390 /// This tile's interaction core, set by its OWN pump thread once the
402 /// core exists and dereferenced only inside that pump's paint-end hook. 391 /// core exists and dereferenced only inside that pump's paint-end hook.
403 /// No lifetime beyond the pump's scope: the core is freed before the 392 /// No lifetime beyond the pump's scope: the core is freed before the
@@ -586,11 +575,10 @@ const end_arm_ms: i64 = 3000;
586 575
587 /// Whether `Ctrl-\ x` has anything to ask, and what. 576 /// Whether `Ctrl-\ x` has anything to ask, and what.
588 fn endAsk(t: *const Tile, now: i64) ?client.SwitchIntent { 577 fn endAsk(t: *const Tile, now: i64) ?client.SwitchIntent {
589 // Null is a tile there is no asking over. A stripe stands for a host 578 // Null is a tile there is no asking over: a pump that has ended is the
590 // with nothing live on it, and a pump that has ended is the only thread 579 // only thread that would ever swap `ask` out again, so an ask stored on
591 // that would ever swap `ask` out again — so an ask stored on either is 580 // one is a keystroke that goes nowhere for the rest of the wall's life.
592 // a keystroke that goes nowhere for the rest of the wall's life. 581 if (!t.alive.load(.acquire)) return null;
593 if (t.stripe or !t.alive.load(.acquire)) return null;
594 return intentForEnd(t, now); 582 return intentForEnd(t, now);
595 } 583 }
596 584
@@ -1778,41 +1766,6 @@ fn pumpTile(t: *Tile) void {
1778 } 1766 }
1779 } 1767 }
1780 1768
1781 /// Not `label_rows`: a wall of ONE stripe sets that to 0. Caller holds
1782 /// `paint_mu`.
1783 fn stripePaints(shared: *const Shared, idx: usize) bool {
1784 return shared.is_tty and (!shared.fullscreen or shared.sel == idx);
1785 }
1786
1787 /// A stripe's pump: no transport, no attach, no replica. It paints the
1788 /// host's bar and waits to be told it is over — the wall's paint rules
1789 /// (one repaint per relayout, one thread per tile) hold for a placeholder
1790 /// exactly as they do for a session.
1791 fn pumpStripe(t: *Tile) void {
1792 defer {
1793 t.alive.store(false, .release);
1794 ringKeyboard(t.shared);
1795 }
1796 // On `repaint_gen`, like every pump: the screen's contents are the
1797 // relayout's until it says otherwise, and a bar redrawn on a timer
1798 // races the tile that owns the rows it is aimed at.
1799 var painted: ?u64 = null;
1800 while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) {
1801 const gen = t.shared.repaint_gen.load(.acquire);
1802 if (painted == null or painted.? != gen) {
1803 painted = gen;
1804 t.shared.paint_mu.lock();
1805 defer t.shared.paint_mu.unlock();
1806 if (stripePaints(t.shared, t.idx)) paintLabelLocked(t);
1807 }
1808 var fds = [_]std.posix.pollfd{
1809 .{ .fd = t.wake_r, .events = std.posix.POLL.IN, .revents = 0 },
1810 };
1811 _ = std.posix.poll(&fds, 200) catch return;
1812 drainWake(t);
1813 }
1814 }
1815
1816 /// Move the focus to tile `next`. Client-local: decides which pump owns the 1769 /// Move the focus to tile `next`. Client-local: decides which pump owns the
1817 /// terminal's modes, not which session the daemon hears. The outgoing tile's 1770 /// terminal's modes, not which session the daemon hears. The outgoing tile's
1818 /// `session_release` is written HERE, under `paint_mu`, so the handover is 1771 /// `session_release` is written HERE, under `paint_mu`, so the handover is
@@ -1884,7 +1837,7 @@ fn paintEmptyWallLocked(shared: *Shared) void {
1884 shared.size.cols, 1837 shared.size.cols,
1885 "", 1838 "",
1886 "the wall is empty", 1839 "the wall is empty",
1887 "nothing left to show - Ctrl-\\ d to leave", 1840 "Ctrl-\\ s to pick a host - Ctrl-\\ d to leave",
1888 ); 1841 );
1889 var out: [512]u8 = undefined; 1842 var out: [512]u8 = undefined;
1890 var fbs = std.io.fixedBufferStream(&out); 1843 var fbs = std.io.fixedBufferStream(&out);
@@ -1990,9 +1943,8 @@ fn relayout(
1990 } 1943 }
1991 1944
1992 /// A tile leaves the wall: off the `present` roll, off the tree, and its 1945 /// A tile leaves the wall: off the `present` roll, off the tree, and its
1993 /// pump told to return. The ONE owner — the chord, the poll's diff and the 1946 /// pump told to return. The ONE owner — the chord and the poll's diff both
1994 /// stripe rule all come here, which is why the focus hand-off can only be 1947 /// come here, which is why the focus hand-off can only be written once. `to` overrides where a focused tile's focus goes; null
1995 /// written once. `to` overrides where a focused tile's focus goes; null
1996 /// takes the next present tile. The caller re-cuts. 1948 /// takes the next present tile. The caller re-cuts.
1997 fn vanishTile(tiles: []Tile, present: []bool, shared: *Shared, i: usize, to: ?usize) void { 1949 fn vanishTile(tiles: []Tile, present: []bool, shared: *Shared, i: usize, to: ?usize) void {
1998 present[i] = false; 1950 present[i] = false;
@@ -2004,8 +1956,9 @@ fn vanishTile(tiles: []Tile, present: []bool, shared: *Shared, i: usize, to: ?us
2004 // mouse modes, no prediction — until the user moves focus away and back. 1956 // mouse modes, no prediction — until the user moves focus away and back.
2005 if (shared.sel == i) { 1957 if (shared.sel == i) {
2006 // Nothing left to step to is not a reason to focus the tile that 1958 // Nothing left to step to is not a reason to focus the tile that
2007 // just went: `sel` stays where it is and the caller — a stripe born 1959 // just went: `sel` stays where it is, and the caller puts it on a
2008 // in its place — is what puts the focus somewhere real. 1960 // real tile as soon as there is one (`applyHostList`). An empty
1961 // wall has none, and paints its own line instead.
2009 if (to orelse stepPresent(present, i, true)) |next| setFocus(tiles, shared, next); 1962 if (to orelse stepPresent(present, i, true)) |next| setFocus(tiles, shared, next);
2010 } 1963 }
2011 } 1964 }
@@ -2213,10 +2166,7 @@ fn initTile(t: *Tile, r: Resolved, s: layout.Rect, shared: *Shared, idx: usize)
2213 } 2166 }
2214 2167
2215 fn spawnPump(t: *Tile) void { 2168 fn spawnPump(t: *Tile) void {
2216 const th = (if (t.stripe) 2169 const th = std.Thread.spawn(.{}, pumpTile, .{t}) catch {
2217 std.Thread.spawn(.{}, pumpStripe, .{t})
2218 else
2219 std.Thread.spawn(.{}, pumpTile, .{t})) catch {
2220 // A tile with no thread is a tile nothing will ever paint — the 2170 // A tile with no thread is a tile nothing will ever paint — the
2221 // same hole `pumpTile`'s exit closes, reached without the pump 2171 // same hole `pumpTile`'s exit closes, reached without the pump
2222 // having run at all. Marked here so the keyboard paints its bar and 2172 // having run at all. Marked here so the keyboard paints its bar and
@@ -2247,7 +2197,7 @@ const FocusTo = union(enum) {
2247 /// Onto this tile: one that was already on the wall (a hot replica and 2197 /// Onto this tile: one that was already on the wall (a hot replica and
2248 /// a local repaint — zero round trips) or one just added for it. 2198 /// a local repaint — zero round trips) or one just added for it.
2249 moved: usize, 2199 moved: usize,
2250 /// The wall cannot hold another stripe. Said rather than silently done, 2200 /// The wall cannot hold another tile. Said rather than silently done,
2251 /// because the alternative — a roaming connection that claims full size 2201 /// because the alternative — a roaming connection that claims full size
2252 /// without earning a tile — is a rule this spec states elsewhere. 2202 /// without earning a tile — is a rule this spec states elsewhere.
2253 full, 2203 full,
@@ -2271,7 +2221,6 @@ const Birth = struct {
2271 // Which host owns it. A chord-born tile inherits the focus's, or the 2221 // Which host owns it. A chord-born tile inherits the focus's, or the
2272 // next poll of that host births a second tile for the same session. 2222 // next poll of that host births a second tile for the same session.
2273 host: ?usize = null, 2223 host: ?usize = null,
2274 stripe: bool = false,
2275 // Whether `r.session` is borrowed and the tile's own copies are owed. 2224 // Whether `r.session` is borrowed and the tile's own copies are owed.
2276 // The poll finds a name in the buffer it is about to reuse and must not 2225 // The poll finds a name in the buffer it is about to reuse and must not
2277 // pay for a copy — nor leave one behind — when the wall refuses the 2226 // pay for a copy — nor leave one behind — when the wall refuses the
@@ -2353,7 +2302,6 @@ fn birthTile(
2353 tiles[at].creates = b.creates; 2302 tiles[at].creates = b.creates;
2354 tiles[at].born_from = b.born_from; 2303 tiles[at].born_from = b.born_from;
2355 tiles[at].host = b.host; 2304 tiles[at].host = b.host;
2356 tiles[at].stripe = b.stripe;
2357 present[at] = true; 2305 present[at] = true;
2358 live.* += 1; 2306 live.* += 1;
2359 return at; 2307 return at;
@@ -2374,9 +2322,7 @@ fn addSessionTile(
2374 const target = tiles[from].r.target; 2322 const target = tiles[from].r.target;
2375 const want = proto.resolveName(name); 2323 const want = proto.resolveName(name);
2376 for (tiles[0..live.*], present[0..live.*], 0..) |*t, p, i| { 2324 for (tiles[0..live.*], present[0..live.*], 0..) |*t, p, i| {
2377 // A stripe carries its host's target and no session of its own, so 2325 if (!p) continue;
2378 // matching it would focus the placeholder instead of making a shell.
2379 if (!p or t.stripe) continue;
2380 if (!sameTarget(t.r.target, target)) continue; 2326 if (!sameTarget(t.r.target, target)) continue;
2381 if (std.mem.eql(u8, proto.resolveName(t.r.session), want)) return .{ .moved = i }; 2327 if (std.mem.eql(u8, proto.resolveName(t.r.session), want)) return .{ .moved = i };
2382 } 2328 }
@@ -2622,9 +2568,7 @@ fn endedTile(tiles: []Tile, present: []const bool, shared: *Shared) ?usize {
2622 const z = shared.sel; 2568 const z = shared.sel;
2623 var hit: ?usize = null; 2569 var hit: ?usize = null;
2624 for (tiles, present, 0..) |*t, p, i| { 2570 for (tiles, present, 0..) |*t, p, i| {
2625 // A stripe never ends: its pump returns when the keyboard vanishes 2571 if (!p or t.end_seen or t.alive.load(.acquire)) continue;
2626 // it, and a wall that read that as a session ending would exit.
2627 if (!p or t.stripe or t.end_seen or t.alive.load(.acquire)) continue;
2628 const reason: EndReason = @enumFromInt(t.end.load(.acquire)); 2572 const reason: EndReason = @enumFromInt(t.end.load(.acquire));
2629 // A tile that only narrates (lost / refused / taken) needs no 2573 // A tile that only narrates (lost / refused / taken) needs no
2630 // keyboard action: its pump painted its bar before it died. 2574 // keyboard action: its pump painted its bar before it died.
@@ -2658,10 +2602,6 @@ fn awaitingSession(t: *Tile) ?State {
2658 /// corpse for the session. Bounded so a wedged pump cannot hold the terminal 2602 /// corpse for the session. Bounded so a wedged pump cannot hold the terminal
2659 /// hostage; the process exit closes the socket either way. 2603 /// hostage; the process exit closes the socket either way.
2660 fn awaitDetach(t: *Tile, shared: *Shared) void { 2604 fn awaitDetach(t: *Tile, shared: *Shared) void {
2661 // A stripe holds no slot: it stands for a host with nothing live on it
2662 // and has no transport to write the frame with, so waiting on its ack
2663 // is 400ms of a wall that has already said goodbye.
2664 if (t.stripe) return;
2665 const deadline = std.time.milliTimestamp() + 400; 2605 const deadline = std.time.milliTimestamp() + 400;
2666 while (!t.detach_ack.load(.acquire)) { 2606 while (!t.detach_ack.load(.acquire)) {
2667 const left = deadline - std.time.milliTimestamp(); 2607 const left = deadline - std.time.milliTimestamp();
@@ -2768,10 +2708,8 @@ fn Fixed(comptime T: type) type {
2768 const BirthNames = Fixed([]const u8); 2708 const BirthNames = Fixed([]const u8);
2769 const TileIdxs = Fixed(usize); 2709 const TileIdxs = Fixed(usize);
2770 2710
2771 /// A stripe carries its host's target and no session, so it is neither a 2711 /// Only a host's own sessions are its list's to keep or to drop.
2772 /// session the diff can keep nor one it can drop.
2773 fn ownedBy(t: *const Tile, host: usize) bool { 2712 fn ownedBy(t: *const Tile, host: usize) bool {
2774 if (t.stripe) return false;
2775 const h = t.host orelse return false; 2713 const h = t.host orelse return false;
2776 return h == host; 2714 return h == host;
2777 } 2715 }
@@ -2846,8 +2784,8 @@ fn planHostDiff(
2846 // its pass through the daemon take milliseconds the pump can be 2784 // its pass through the daemon take milliseconds the pump can be
2847 // descheduled for — and on a wall of one, vanishing the tile first 2785 // descheduled for — and on a wall of one, vanishing the tile first
2848 // loses the shell's exit code, because `endedTile` skips a tile that 2786 // loses the shell's exit code, because `endedTile` skips a tile that
2849 // is no longer present and mux stays up as an `[unreachable]` 2787 // is no longer present and mux stays up on an empty wall. A dead
2850 // stripe. A dead pump has no code left to lose, so it goes at once. 2788 // pump has no code left to lose, so it goes at once.
2851 if (t.alive.load(.acquire) and !t.missed_once) { 2789 if (t.alive.load(.acquire) and !t.missed_once) {
2852 t.missed_once = true; 2790 t.missed_once = true;
2853 continue; 2791 continue;
@@ -2924,59 +2862,6 @@ fn pollHost(h: *Host) void {
2924 } 2862 }
2925 } 2863 }
2926 2864
2927 /// A host with no tile of its own is ONE stripe, never one dead tile per
2928 /// session it used to have. True when the wall changed shape.
2929 fn stripeRule(
2930 alloc: std.mem.Allocator,
2931 tiles: []Tile,
2932 present: []bool,
2933 live: *usize,
2934 shared: *Shared,
2935 host_table: []Host,
2936 hi: usize,
2937 reachable: bool,
2938 ) bool {
2939 var real = false;
2940 var stripe_at: ?usize = null;
2941 for (tiles[0..live.*], present[0..live.*], 0..) |*t, p, i| {
2942 if (!p) continue;
2943 const h = t.host orelse continue;
2944 if (h != hi) continue;
2945 if (t.stripe) stripe_at = i else real = true;
2946 }
2947 const want: State = if (reachable) .empty else .@"unreachable";
2948 if (real) {
2949 const at = stripe_at orelse return false;
2950 vanishTile(tiles[0..live.*], present[0..live.*], shared, at, null);
2951 return true;
2952 }
2953 if (stripe_at) |at| {
2954 shared.paint_mu.lock();
2955 const now = tiles[at].state;
2956 shared.paint_mu.unlock();
2957 if (now == want) return false;
2958 paintLabel(&tiles[at], want);
2959 // A re-cut, not just a bar: a stripe repaints on `repaint_gen` like
2960 // every pump, and only the caller's relayout bumps it.
2961 return true;
2962 }
2963 const from = anchorTile(present[0..live.*], shared.sel);
2964 const at = birthTile(alloc, tiles, present, live, shared, .{
2965 // The stripe wears the host's own line: it is the wall's word for
2966 // the machine, not for any session on it.
2967 .r = .{ .target = host_table[hi].spec.target, .label = host_table[hi].spec.spelling, .session = "" },
2968 .from = from,
2969 .place = .beside_focus,
2970 .creates = false,
2971 .born_from = null,
2972 .host = hi,
2973 .stripe = true,
2974 }) orelse return false;
2975 tiles[at].state = want;
2976 spawnPump(&tiles[at]);
2977 return true;
2978 }
2979
2980 /// A chord that births asks its host for a list NOW: a session made by 2865 /// A chord that births asks its host for a list NOW: a session made by
2981 /// `c` or `:` must not wait out the poll interval to become a tile. 2866 /// `c` or `:` must not wait out the poll interval to become a tile.
2982 fn pokeHost(host_table: []Host, t: *const Tile) void { 2867 fn pokeHost(host_table: []Host, t: *const Tile) void {
@@ -3077,7 +2962,6 @@ fn applyHostList(
3077 setNotice(shared, std.fmt.bufPrint(&buf, "[+{d} not shown]", .{unplaced}) catch "[not shown]"); 2962 setNotice(shared, std.fmt.bufPrint(&buf, "[+{d} not shown]", .{unplaced}) catch "[not shown]");
3078 } 2963 }
3079 } 2964 }
3080 if (stripeRule(alloc, tiles, present, live, shared, host_table, hi, reachable)) changed = true;
3081 if ((!had_focus or shared.sel >= live.* or !present[shared.sel]) and 2965 if ((!had_focus or shared.sel >= live.* or !present[shared.sel]) and
3082 presentCount(present[0..live.*]) > 0) 2966 presentCount(present[0..live.*]) > 0)
3083 setFocus(tiles[0..live.*], shared, firstPresent(present[0..live.*]) orelse 0); 2967 setFocus(tiles[0..live.*], shared, firstPresent(present[0..live.*]) orelse 0);
@@ -3085,8 +2969,8 @@ fn applyHostList(
3085 } 2969 }
3086 2970
3087 /// The host grammar's own spelling of a target, for a wall entered by 2971 /// The host grammar's own spelling of a target, for a wall entered by
3088 /// `mux TARGET` rather than off the file: the stripe's label and the 2972 /// `mux TARGET` rather than off the file: the sidecar's key and the line
3089 /// sidecar's key have to read like the line that would have named it. 2973 /// `mux hosts` prints have to read like the one that would have named it.
3090 fn hostSpelling(alloc: std.mem.Allocator, target: client.Target) ![]const u8 { 2974 fn hostSpelling(alloc: std.mem.Allocator, target: client.Target) ![]const u8 {
3091 return switch (target) { 2975 return switch (target) {
3092 .sock => |p| try std.fmt.allocPrint(alloc, "--sock {s}", .{p}), 2976 .sock => |p| try std.fmt.allocPrint(alloc, "--sock {s}", .{p}),
@@ -3106,8 +2990,8 @@ fn recordHost(
3106 path: []const u8, 2990 path: []const u8,
3107 ) ?anyerror { 2991 ) ?anyerror {
3108 // Both doors — `mux HOST` and `Ctrl-\ :` — write the line on the user's 2992 // Both doors — `mux HOST` and `Ctrl-\ :` — write the line on the user's
3109 // word, and a daemon that never answers is a stripe on the wall rather 2993 // word: a daemon that never answers is a host the file still remembers,
3110 // than a line missing from the file. 2994 // rather than a line missing from it.
3111 // 2995 //
3112 // The failure comes BACK rather than being printed, because where it may 2996 // The failure comes BACK rather than being printed, because where it may
3113 // be SAID differs by door: stderr before the wall takes the screen, a 2997 // be SAID differs by door: stderr before the wall takes the screen, a
@@ -3619,6 +3503,18 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3619 3503
3620 const z = shared.sel; 3504 const z = shared.sel;
3621 const cmd = input.prefix.feed(b[0..n]); 3505 const cmd = input.prefix.feed(b[0..n]);
3506 // A wall with no tile at all — every host with nothing live on it,
3507 // or the first poll still out — has no `tiles[z]` to command: that
3508 // slot has never been written, and every branch below reads one.
3509 // Leaving is all there is to do, and the empty screen says so.
3510 if (live == 0) {
3511 if (cmd.action == .detach) {
3512 exit_code = 0;
3513 exit_msg = "mux: left the wall";
3514 break :keys;
3515 }
3516 continue;
3517 }
3622 // A BARE Ctrl-\ while there is no session to command is the 3518 // A BARE Ctrl-\ while there is no session to command is the
3623 // user giving up on the wait. Judged after the filter rather 3519 // user giving up on the wait. Judged after the filter rather
3624 // than by scanning the raw bytes, because the two readings of 3520 // than by scanning the raw bytes, because the two readings of
@@ -3726,23 +3622,8 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3726 .split_below => .below, 3622 .split_below => .below,
3727 else => .beside_focus, 3623 else => .beside_focus,
3728 }; 3624 };
3729 if (tiles[z].stripe) { 3625 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.new), .release);
3730 // A stripe has no transport to ask with, so it names 3626 ring(&tiles[z]);
3731 // the session `mux HOST` would have made and lets the
3732 // attach create it; the next poll dedups.
3733 const before = live;
3734 switch (addSessionTile(alloc, tiles, present, &live, &shared, z, proto.default_session, tiles[z].pending_place)) {
3735 .moved => |to| {
3736 last_focus = z;
3737 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, live > before, to);
3738 },
3739 .full => setNotice(&shared, "[no room on the wall for another tile]"),
3740 .stay => {},
3741 }
3742 } else {
3743 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.new), .release);
3744 ring(&tiles[z]);
3745 }
3746 pokeHost(host_table[0..hosts_live], &tiles[z]); 3627 pokeHost(host_table[0..hosts_live], &tiles[z]);
3747 }, 3628 },
3748 .next_session, .prev_session => { 3629 .next_session, .prev_session => {
@@ -4091,23 +3972,6 @@ test "planHostDiff: the session this shell is inside is never born as a tile" {
4091 try std.testing.expectEqual(@as(usize, 0), vanish.len); 3972 try std.testing.expectEqual(@as(usize, 0), vanish.len);
4092 } 3973 }
4093 3974
4094 test "planHostDiff: a host's stripe is not one of its sessions — it neither dedups a birth nor vanishes" {
4095 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4096 var tiles = diffFixture(&shared);
4097 tiles[0].stripe = true;
4098 tiles[0].r.session = "a";
4099 var present = [_]bool{ true, false, true };
4100
4101 var births = BirthNames{};
4102 var vanish = TileIdxs{};
4103 planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
4104 // The stripe is host 0's placeholder, not its session "a": the name is
4105 // still owed a tile, and the stripe is not the diff's to remove.
4106 try std.testing.expectEqual(@as(usize, 1), births.len);
4107 try std.testing.expectEqualStrings("a", births.get(0));
4108 try std.testing.expectEqual(@as(usize, 0), vanish.len);
4109 }
4110
4111 /// The keyboard-thread fixture: `running` stopped, so a pump a diff spawns 3975 /// The keyboard-thread fixture: `running` stopped, so a pump a diff spawns
4112 /// returns at its first check rather than dialling a socket no test is 3976 /// returns at its first check rather than dialling a socket no test is
4113 /// listening on. 3977 /// listening on.
@@ -4179,7 +4043,7 @@ test "applyReadyLists: a host added after the wall opened gets its sessions, and
4179 try std.testing.expectEqualStrings("late", tiles[0].r.session); 4043 try std.testing.expectEqualStrings("late", tiles[0].r.session);
4180 } 4044 }
4181 4045
4182 test "endAsk: x asks only a tile with a link — a stripe and a dead pump get a sentence instead" { 4046 test "endAsk: x asks only a tile with a live pump — a dead one gets a sentence instead" {
4183 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 4047 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
4184 defer shared.tree.deinit(); 4048 defer shared.tree.deinit();
4185 var t: Tile = .{ 4049 var t: Tile = .{
@@ -4196,12 +4060,6 @@ test "endAsk: x asks only a tile with a link — a stripe and a dead pump get a
4196 t.end_armed_until.store(4000, .release); 4060 t.end_armed_until.store(4000, .release);
4197 try std.testing.expectEqual(client.SwitchIntent.end_force, endAsk(&t, 2500).?); 4061 try std.testing.expectEqual(client.SwitchIntent.end_force, endAsk(&t, 2500).?);
4198 4062
4199 // A stripe stands for a host with nothing live on it: no session to end
4200 // and no transport to ask over.
4201 t.stripe = true;
4202 try std.testing.expect(endAsk(&t, 2500) == null);
4203 t.stripe = false;
4204
4205 // A pump that has ENDED is the silent case: the only thread that swaps 4063 // A pump that has ENDED is the silent case: the only thread that swaps
4206 // `ask` out again is that pump's loop, so an ask stored here is a 4064 // `ask` out again is that pump's loop, so an ask stored here is a
4207 // keystroke that goes nowhere for the rest of the wall's life. 4065 // keystroke that goes nowhere for the rest of the wall's life.
@@ -4231,7 +4089,7 @@ test "recordHost: a file it cannot write comes BACK — the wall may be on the a
4231 try std.testing.expect(recordHost(alloc, .{ .via = "ssh h muxd proxy" }, "x", bad) == null); 4089 try std.testing.expect(recordHost(alloc, .{ .via = "ssh h muxd proxy" }, "x", bad) == null);
4232 } 4090 }
4233 4091
4234 test "applyHostList: a host that is down is ONE stripe, and a second failed poll does not add a second" { 4092 test "applyHostList: a host with no live session gets no tile — the wall shows sessions only" {
4235 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 4093 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
4236 defer arena.deinit(); 4094 defer arena.deinit();
4237 const alloc = arena.allocator(); 4095 const alloc = arena.allocator();
@@ -4241,53 +4099,32 @@ test "applyHostList: a host that is down is ONE stripe, and a second failed poll
4241 var present = [_]bool{false} ** 4; 4099 var present = [_]bool{false} ** 4;
4242 var live: usize = 0; 4100 var live: usize = 0;
4243 defer endPumps(tiles[0..live]); 4101 defer endPumps(tiles[0..live]);
4244 var table = [_]Host{testHost(&shared, "box", "/tmp/nobody.sock")}; 4102 // The two ways a host has nothing to show, side by side: one that never
4103 // answered and one that answered with an empty list.
4104 var table = [_]Host{
4105 testHost(&shared, "down", "/tmp/nobody.sock"),
4106 testHost(&shared, "empty", "/tmp/empty.sock"),
4107 };
4108 table[1].idx = 1;
4245 table[0].reachable.store(false, .release); 4109 table[0].reachable.store(false, .release);
4110 setList(&table[1], "");
4246 4111
4247 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0); 4112 // Twice, because a placeholder that is born once is still a placeholder.
4248 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0); 4113 for (0..2) |_| {
4114 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
4115 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 1);
4116 }
4249 4117
4250 try std.testing.expectEqual(@as(usize, 1), live); 4118 try std.testing.expectEqual(@as(usize, 0), live);
4251 try std.testing.expect(present[0]); 4119 try std.testing.expectEqual(@as(usize, 0), presentCount(present[0..]));
4252 try std.testing.expect(tiles[0].stripe);
4253 try std.testing.expectEqual(State.@"unreachable", tiles[0].state);
4254 // The stripe wears the host's line, not any session's spelling.
4255 try std.testing.expectEqualStrings("box", tiles[0].r.label);
4256 // The wall had no tile, so the first one it got has the focus AND the
4257 // claim: `setFocus` is the only thing that arms one.
4258 try std.testing.expectEqual(@as(usize, 0), shared.sel);
4259 try std.testing.expect(tiles[0].claim_pending.load(.acquire));
4260 }
4261 4120
4262 test "applyHostList: a host's first session takes its stripe's place" { 4121 // A session on the empty host, and the wall has exactly the one tile.
4263 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 4122 setList(&table[1], "a\n");
4264 defer arena.deinit(); 4123 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 1);
4265 const alloc = arena.allocator();
4266 var shared: Shared = undefined;
4267 stoppedWall(alloc, &shared);
4268 var tiles: [4]Tile = undefined;
4269 var present = [_]bool{false} ** 4;
4270 var live: usize = 0;
4271 defer endPumps(tiles[0..live]);
4272 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")};
4273 table[0].reachable.store(false, .release);
4274 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
4275 try std.testing.expect(tiles[0].stripe);
4276
4277 setList(&table[0], "a\n");
4278 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
4279 4124
4280 // The stripe stands for a host with NO tile; one arrived, so it goes. 4125 try std.testing.expectEqual(@as(usize, 1), presentCount(present[0..live]));
4281 try std.testing.expect(!present[0]); 4126 try std.testing.expectEqualStrings("a", tiles[0].r.session);
4282 try std.testing.expect(present[1]); 4127 try std.testing.expectEqual(@as(?usize, 1), tiles[0].host);
4283 try std.testing.expect(!tiles[1].stripe);
4284 try std.testing.expectEqualStrings("a", tiles[1].r.session);
4285 try std.testing.expectEqual(@as(?usize, 0), tiles[1].host);
4286 // Joins, never creates: the daemon already has this session.
4287 try std.testing.expect(!tiles[1].creates);
4288 // The focus followed the tile that left, through `setFocus`.
4289 try std.testing.expectEqual(@as(usize, 1), shared.sel);
4290 try std.testing.expect(tiles[1].claim_pending.load(.acquire));
4291 } 4128 }
4292 4129
4293 test "applyHostList: a live tile survives one list that lost its session, and goes on the next" { 4130 test "applyHostList: a live tile survives one list that lost its session, and goes on the next" {
@@ -4346,9 +4183,6 @@ test "applyHostList: two sessions on an empty wall are two tiles, the first focu
4346 try std.testing.expectEqualStrings("b", tiles[1].r.session); 4183 try std.testing.expectEqualStrings("b", tiles[1].r.session);
4347 try std.testing.expectEqual(@as(usize, 0), shared.sel); 4184 try std.testing.expectEqual(@as(usize, 0), shared.sel);
4348 try std.testing.expect(tiles[0].claim_pending.load(.acquire)); 4185 try std.testing.expect(tiles[0].claim_pending.load(.acquire));
4349 // No stripe: the host has tiles of its own.
4350 try std.testing.expect(!tiles[0].stripe);
4351 try std.testing.expect(!tiles[1].stripe);
4352 } 4186 }
4353 4187
4354 test "applyHostList: one list's tiles are laid out in the order the daemon reported them" { 4188 test "applyHostList: one list's tiles are laid out in the order the daemon reported them" {
@@ -4457,7 +4291,7 @@ test "applyHostList: a wall too thin for a second pane takes what fits and retai
4457 try std.testing.expectEqualStrings("[+1 not shown]", takeNotice(&shared, &buf)); 4291 try std.testing.expectEqualStrings("[+1 not shown]", takeNotice(&shared, &buf));
4458 } 4292 }
4459 4293
4460 test "applyHostList: when a host's last session goes, the stripe that replaces it takes the focus" { 4294 test "applyHostList: when a host's last session goes the wall empties, and nothing stands in for the host" {
4461 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 4295 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
4462 defer arena.deinit(); 4296 defer arena.deinit();
4463 const alloc = arena.allocator(); 4297 const alloc = arena.allocator();
@@ -4485,30 +4319,10 @@ test "applyHostList: when a host's last session goes, the stripe that replaces i
4485 4319
4486 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0); 4320 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
4487 4321
4322 // The host is up with nothing on it, which the wall says by showing
4323 // nothing: no placeholder tile takes the gone session's place.
4488 try std.testing.expect(!present[0]); 4324 try std.testing.expect(!present[0]);
4489 try std.testing.expect(present[1]); 4325 try std.testing.expectEqual(@as(usize, 0), presentCount(present[0..]));
4490 try std.testing.expect(tiles[1].stripe);
4491 // The focus may not sit on a hole: every chord reads `tiles[shared.sel]`
4492 // without a `present` guard, so a stranded `sel` acts on a gone tile.
4493 try std.testing.expectEqual(@as(usize, 1), shared.sel);
4494 try std.testing.expect(present[shared.sel]);
4495 try std.testing.expect(tiles[1].claim_pending.load(.acquire));
4496 }
4497
4498 test "stripePaints: a stripe paints unless another tile owns the whole screen" {
4499 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
4500 try std.testing.expect(stripePaints(&shared, 1));
4501 shared.fullscreen = true;
4502 shared.sel = 0;
4503 // Fullscreened OUT: the stripe keeps a stale rect inside the focused
4504 // tile's rows, and a bar drawn there lands in that tile's grid.
4505 try std.testing.expect(!stripePaints(&shared, 1));
4506 // Fullscreened IN: the stripe owes the screen its only line, or the
4507 // terminal is blank and reads as hung.
4508 try std.testing.expect(stripePaints(&shared, 0));
4509 // No terminal, no bar: a scripted wall writes what the plain client did.
4510 shared.is_tty = false;
4511 try std.testing.expect(!stripePaints(&shared, 0));
4512 } 4326 }
4513 4327
4514 test "planHostDiff: a creating tile whose attach has not landed yet is not vanished by the list that raced it" { 4328 test "planHostDiff: a creating tile whose attach has not landed yet is not vanished by the list that raced it" {
test/e2e_09_hosts.sh
Old New
@@ -272,7 +272,7 @@ set -e
272 # Four bars, each naming its own daemon. 272 # Four bars, each naming its own daemon.
273 for _hl in "$SOCKH1#0" "$SOCKH1#b" "$SOCKH2#0" "$SOCKH2#c"; do 273 for _hl in "$SOCKH1#0" "$SOCKH1#b" "$SOCKH2#0" "$SOCKH2#c"; do
274 grep -q -- "--sock $_hl \[up\]" "$OUT.hwcap" || { 274 grep -q -- "--sock $_hl \[up\]" "$OUT.hwcap" || {
275 echo "e2e FAIL: hosts: '--sock $_hl' never became a stripe on the wall:" 275 echo "e2e FAIL: hosts: '--sock $_hl' never became a tile on the wall:"
276 cat "$OUT.hwpc"; exit 1; } 276 cat "$OUT.hwpc"; exit 1; }
277 done 277 done
278 # The walk, read off the daemons. One holder each, and not the same one: 278 # The walk, read off the daemons. One holder each, and not the same one:
@@ -419,8 +419,11 @@ ok "x refuses while others are attached, then ends; the other client sees the ex
419 # 419 #
420 # Two states of one host, each with its own witness: 420 # Two states of one host, each with its own witness:
421 # 421 #
422 # * down: ONE stripe, `[unreachable]`, and not one dead tile per session 422 # * down: NOTHING. The wall shows live sessions and nothing else, so a
423 # it used to have. 423 # daemon that is not answering contributes no bar of its own and not
424 # one dead tile per session it used to have. Daemon 1 is up throughout
425 # and its tiles are what the leg waits on, so "the poll has not landed
426 # yet" and "daemon 2 has no bar" cannot be confused.
424 # * back: its own default session and nothing else. `c`, which was live 427 # * back: its own default session and nothing else. `c`, which was live
425 # when the daemon died, is not there — asked of `muxd stats` on the 428 # when the daemon died, is not there — asked of `muxd stats` on the
426 # real daemon, not of the wall that would be reporting on its own 429 # real daemon, not of the wall that would be reporting on its own
@@ -429,8 +432,8 @@ assert_stopped "$SOCKH2" "$DH2PID" "hosts restart" "$OUT.h2stop"
429 DH2PID="" 432 DH2PID=""
430 set +e 433 set +e
431 XDG_STATE_HOME="$HSTATE" timeout 60 "$PTYCLIENT" --cols 80 --rows 44 \ 434 XDG_STATE_HOME="$HSTATE" timeout 60 "$PTYCLIENT" --cols 80 --rows 44 \
432 --out "$OUT.hdcap" --err "$OUT.hdcap.err" -- "$MUX" > "$OUT.hdpc" 2>&1 <<'EOF' 435 --out "$OUT.hdcap" --err "$OUT.hdcap.err" -- "$MUX" > "$OUT.hdpc" 2>&1 <<EOF
433 expect [unreachable] 25000 436 expect --sock $SOCKH1#b [up] 25000
434 settle 1000 25000 437 settle 1000 25000
435 send \x1cd 438 send \x1cd
436 waitexit 15000 439 waitexit 15000
@@ -439,13 +442,16 @@ RC=$?
439 set -e 442 set -e
440 [ "$RC" -eq 0 ] || { 443 [ "$RC" -eq 0 ] || {
441 echo "e2e FAIL: hosts restart: the down-host wall exited $RC:"; cat "$OUT.hdpc"; exit 1; } 444 echo "e2e FAIL: hosts restart: the down-host wall exited $RC:"; cat "$OUT.hdpc"; exit 1; }
442 grep -q -- "--sock $SOCKH2 \[unreachable\]" "$OUT.hdcap" || { 445 # The down daemon's spelling, ANYWHERE on the screen: a placeholder bar
443 echo "e2e FAIL: hosts restart: a down daemon is not one stripe naming itself:" 446 # would carry `--sock $SOCKH2` and a dead tile `--sock $SOCKH2#c`, so one
447 # count of the host's own path catches both. The `expect` above is the
448 # control: it waited on daemon 1's bar, so a capture this grep finds
449 # nothing in is a PAINTED screen and not a blank one.
450 HDN=$(grep -c -- "--sock $SOCKH2" "$OUT.hdcap" || true)
451 [ "$HDN" -eq 0 ] || {
452 echo "e2e FAIL: hosts restart: a down daemon named itself on the wall,"
453 echo " which shows live sessions and nothing else:"
444 cat "$OUT.hdpc"; exit 1; } 454 cat "$OUT.hdpc"; exit 1; }
445 if grep -q -- "--sock $SOCKH2#c" "$OUT.hdcap"; then
446 echo "e2e FAIL: hosts restart: a down daemon still shows the sessions it had:"
447 cat "$OUT.hdpc"; exit 1
448 fi
449 455
450 start_daemon "$SOCKH2" "$OUT.h2b.d" "hosts daemon 2 never came back" --shell /bin/sh 456 start_daemon "$SOCKH2" "$OUT.h2b.d" "hosts daemon 2 never came back" --shell /bin/sh
451 DH2PID=$DPID 457 DH2PID=$DPID
@@ -472,11 +478,11 @@ if grep -q -- "--sock $SOCKH2#c" "$OUT.hrcap"; then
472 cat "$OUT.hrpc"; exit 1 478 cat "$OUT.hrpc"; exit 1
473 fi 479 fi
474 # The daemon's own account of it, which is the one that matters: a session 480 # The daemon's own account of it, which is the one that matters: a session
475 # the wall re-created would be here whether or not it ever got a stripe. 481 # the wall re-created would be here whether or not it ever got a tile.
476 [ "$("$MUXD" stats --sock "$SOCKH2" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p')" = "1" ] || { 482 [ "$("$MUXD" stats --sock "$SOCKH2" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p')" = "1" ] || {
477 echo "e2e FAIL: hosts restart: the reborn daemon holds more than its own default session:" 483 echo "e2e FAIL: hosts restart: the reborn daemon holds more than its own default session:"
478 "$MUXD" stats --sock "$SOCKH2"; exit 1; } 484 "$MUXD" stats --sock "$SOCKH2"; exit 1; }
479 ok "a daemon that goes down is one unreachable stripe, and comes back re-creating nothing" 485 ok "a daemon that goes down leaves no bar behind, and comes back re-creating nothing"
480 486
481 # ---- hosts rm takes a daemon off the wall and ends nothing -------------- 487 # ---- hosts rm takes a daemon off the wall and ends nothing --------------
482 # 488 #
test/e2e_lib.sh
Old New
@@ -95,9 +95,9 @@ $E2E_SOCK"
95 # A wall is the hosts FILE now: every `mux TARGET` records the daemon it 95 # A wall is the hosts FILE now: every `mux TARGET` records the daemon it
96 # attached to, and every `mux` on a TERMINAL opens on every line it finds. 96 # attached to, and every `mux` on a TERMINAL opens on every line it finds.
97 # The suite's shared state home is therefore every daemon the suite has 97 # The suite's shared state home is therefore every daemon the suite has
98 # ever attached to — most of them stopped — so a leg that read it would 98 # ever attached to — so a leg that read it would open on its own tile plus
99 # open on its own tile plus a screenful of `[unreachable]` stripes, and 99 # a tile for every session every daemon still running holds, and the
100 # the geometry every pty leg asserts would be somebody else's. 100 # geometry every pty leg asserts would be somebody else's.
101 # 101 #
102 # Per LEG and not per group: a group's earlier legs leave their own daemons 102 # Per LEG and not per group: a group's earlier legs leave their own daemons
103 # running while the later ones attach, so a wall opened on a home the leg 103 # running while the later ones attach, so a wall opened on a home the leg
test/xversion.sh
Old New
@@ -599,12 +599,14 @@ else
599 pass "x against a daemon with no end_req arm banners and leaves the session running" 599 pass "x against a daemon with no end_req arm banners and leaves the session running"
600 fi 600 fi
601 601
602 # ---- leg 10: an old daemon has no session list, so the wall stripes it -- 602 # ---- leg 10: an old daemon has no session list, so the wall skips it ---
603 # The other half of the same silence, and the one a user meets first. 603 # The other half of the same silence, and the one a user meets first.
604 # Listing a daemon's live sessions on a bare OBSERVER connection is new 604 # Listing a daemon's live sessions on a bare OBSERVER connection is new
605 # here — `sessions_req` existed, but only on the attached client's path — so 605 # here — `sessions_req` existed, but only on the attached client's path — so
606 # an old daemon answers the wall's poll with nothing and the wall paints it 606 # an old daemon answers the wall's poll with nothing, so none of its
607 # `[unreachable]` forever, while `mux --sock` still attaches to it happily. 607 # sessions ever reach the wall, while `mux --sock` still attaches to it
608 # happily. `mux hosts` is where that shows: it says `[unreachable]` for the
609 # same silence, and it is what this leg asks.
608 # That is a real cost of taking this version and it is documented 610 # That is a real cost of taking this version and it is documented
609 # (decisions.md, 2026-08-28); pinned here so the sentence cannot drift. 611 # (decisions.md, 2026-08-28); pinned here so the sentence cannot drift.
610 # 612 #
@@ -620,11 +622,11 @@ RC=$?
620 if [ "$RC" -ne 0 ]; then 622 if [ "$RC" -ne 0 ]; then
621 fail "mux hosts exited $RC [$(tr -d '\n' < "$TMP/hosts.out")]" 623 fail "mux hosts exited $RC [$(tr -d '\n' < "$TMP/hosts.out")]"
622 elif ! grep -q -- "--sock $SOCK_NEW [0-9]" "$TMP/hosts.out"; then 624 elif ! grep -q -- "--sock $SOCK_NEW [0-9]" "$TMP/hosts.out"; then
623 fail "mux hosts did not count the NEW daemon's sessions — the old one below stripes for no reason [$(cat "$TMP/hosts.out")]" 625 fail "mux hosts did not count the NEW daemon's sessions — the old one below reads unreachable for no reason [$(cat "$TMP/hosts.out")]"
624 elif ! grep -qF -- "--sock $SOCK_OLD [unreachable]" "$TMP/hosts.out"; then 626 elif ! grep -qF -- "--sock $SOCK_OLD [unreachable]" "$TMP/hosts.out"; then
625 fail "an old daemon answered the wall's session list — decisions.md says it cannot [$(cat "$TMP/hosts.out")]" 627 fail "an old daemon answered the wall's session list — decisions.md says it cannot [$(cat "$TMP/hosts.out")]"
626 else 628 else
627 pass "a daemon with no observer sessions_req reads as [unreachable] on the wall, as documented" 629 pass "a daemon with no observer sessions_req reads as [unreachable] to mux hosts, as documented"
628 fi 630 fi
629 631
630 # ---- teardown, by the sanctioned verb, then OBSERVED ------------------ 632 # ---- teardown, by the sanctioned verb, then OBSERVED ------------------