2b544162
refactor: the wall shows sessions only — stripes go
a73x 2026-08-28 19:53
Commit message
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 ------------------ |