a73x

63b5cfa8

refactor: drop the dead end key, the hub's tracked ws fd, and two stale headers

a73x   2026-09-03 05:20

Commit message
refactor: drop the dead end key, the hub's tracked ws fd, and two stale headers

endKey/EndKey lost its production caller when Ctrl-\\ x became removePane;
intentForEnd and onEndReply stay, since the pump reads the arming back
through them. HubTile.ws_fd was registered and cleared and never read -
nothing tears a pane down under its pump any more. mux_main's header still
described the wall as every live session of every listed daemon, and two
comments still called the layout file a sidecar.

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

src/cli/mux_main.zig
Old New
@@ -2,10 +2,13 @@
2 //! uses the command's stdio without a shell, and a bare host uses the 2 //! uses the command's stdio without a shell, and a bare host uses the
3 //! SSH-to-QUIC handoff. 3 //! SSH-to-QUIC handoff.
4 //! 4 //!
5 //! Bare `mux` opens a wall containing every live session on each listed daemon. 5 //! Bare `mux` opens the wall the layout file names: one pane per leaf, in
6 //! Naming a transport opens the same wall focused on that daemon. This module 6 //! the tree they were arranged in, and nothing else — a session born by
7 //! handles argument validation, local daemon startup, and the `hosts` 7 //! anyone else on a listed daemon is on no wall until a picker adds it.
8 //! subcommand before handing connections to the wall. 8 //! Naming a transport opens the same wall zoomed on a pane for that
9 //! daemon's session `0`, adding it to both files if they did not have it.
10 //! This module handles argument validation, local daemon startup, and the
11 //! `hosts` subcommand before handing connections to the wall.
9 const std = @import("std"); 12 const std = @import("std");
10 const client = @import("client"); 13 const client = @import("client");
11 const proto = @import("term").protocol; 14 const proto = @import("term").protocol;
src/client/hosts.zig
Old New
@@ -201,7 +201,7 @@ pub fn statePathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home
201 return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "hosts"); 201 return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "hosts");
202 } 202 }
203 203
204 /// Beside the hosts file. What the sidecar HOLDS is `wall_layout`'s. 204 /// Beside the hosts file. What the layout file HOLDS is `wall_layout`'s.
205 pub fn layoutPath(alloc: std.mem.Allocator) ![]const u8 { 205 pub fn layoutPath(alloc: std.mem.Allocator) ![]const u8 {
206 return xdg.statePath(alloc, "layout"); 206 return xdg.statePath(alloc, "layout");
207 } 207 }
@@ -245,8 +245,8 @@ pub fn freeLines(alloc: std.mem.Allocator, lines: *std.ArrayList([]u8)) void {
245 } 245 }
246 246
247 /// One atomic writer for every state file mux keeps: the hosts file here 247 /// One atomic writer for every state file mux keeps: the hosts file here
248 /// and the layout sidecar in `wall_layout`, so there is one temp+rename 248 /// and the layout file in `wall_layout` and `layoutfile`, so there is one
249 /// idiom and not two. 249 /// temp+rename idiom and not three.
250 pub fn saveBytes(path: []const u8, bytes: []const u8) !void { 250 pub fn saveBytes(path: []const u8, bytes: []const u8) !void {
251 var write_buf: [4096]u8 = undefined; 251 var write_buf: [4096]u8 = undefined;
252 var af = try std.fs.cwd().atomicFile(path, .{ .make_path = true, .write_buffer = &write_buf }); 252 var af = try std.fs.cwd().atomicFile(path, .{ .make_path = true, .write_buffer = &write_buf });
src/client/webhub.zig
Old New
@@ -94,12 +94,6 @@ const HubTile = struct {
94 /// badge, never a removal, because only the layout file takes a pane 94 /// badge, never a removal, because only the layout file takes a pane
95 /// off the wall. 95 /// off the wall.
96 state: TileState = .connecting, 96 state: TileState = .connecting,
97 /// Registered while a pump owns a WS for this tile, FIRST pump wins.
98 /// Two browsers run two pumps on one tile, and the registration is what
99 /// keeps the second's release from clearing the first's fd. The pump
100 /// unregisters (under the hub mutex) BEFORE serveConn closes the fd, so
101 /// nothing here can ever name a number the kernel has recycled.
102 ws_fd: ?std.posix.fd_t = null,
103 /// One list's grace. The poll's connect and its pass through the 97 /// One list's grace. The poll's connect and its pass through the
104 /// daemon take milliseconds a daemon mid-answer can be descheduled 98 /// daemon take milliseconds a daemon mid-answer can be descheduled
105 /// for, and a wall that tore a tile down over one of those would 99 /// for, and a wall that tore a tile down over one of those would
@@ -429,12 +423,13 @@ pub const Hub = struct {
429 std.debug.print("mux web: tile {d}: {s}#{s}\n", .{ id, self.hosts[host_idx].spec.spelling, name }); 423 std.debug.print("mux web: tile {d}: {s}#{s}\n", .{ id, self.hosts[host_idx].spec.spelling, name });
430 } 424 }
431 425
432 /// UnknownId: an ended session. 404, and the page refetches. 426 /// UnknownId: an ended session. 404, and the page refetches. Nothing is
433 pub fn checkoutTile( 427 /// registered here: a pane is never torn down under its pump any more —
434 self: *Hub, 428 /// a session the daemon stops leaves the pane wearing `gone` and the
435 id: u32, 429 /// pump redials — so there is no wakeup that would need the socket, and
436 ws_fd: std.posix.fd_t, 430 /// two browsers on one tile are simply two pumps, each ended by its own
437 ) error{UnknownId}!Checkout { 431 /// WS read.
432 pub fn checkoutTile(self: *Hub, id: u32) error{UnknownId}!Checkout {
438 self.mutex.lock(); 433 self.mutex.lock();
439 defer self.mutex.unlock(); 434 defer self.mutex.unlock();
440 435
@@ -442,26 +437,7 @@ pub const Hub = struct {
442 // BORROWED: a host is fixed for the run and outlives every pump, 437 // BORROWED: a host is fixed for the run and outlives every pump,
443 // so there is nothing here to copy field by field — the three 438 // so there is nothing here to copy field by field — the three
444 // comptime field-count asserts a copy needed are gone with it. 439 // comptime field-count asserts a copy needed are gone with it.
445 const copy = Checkout{ .target = self.hosts[self.tiles.items[idx].host].spec.target }; 440 return .{ .target = self.hosts[self.tiles.items[idx].host].spec.target };
446 // FIRST registration wins: two browsers run two pumps per tile, and
447 // overwriting would let the untracked pump's release clear the tracked
448 // one's fd, so a vanishing would wake nobody. The second pump still
449 // serves its browser and its own WS read ends it.
450 if (self.tiles.items[idx].ws_fd == null) self.tiles.items[idx].ws_fd = ws_fd;
451 return copy;
452 }
453
454 /// Unregister only the fd this caller registered: a second pump
455 /// releasing must not clear the tracked pump's. Must run BEFORE the
456 /// caller closes the fd, or a concurrent vanishing could shutdown a
457 /// number the kernel has already recycled.
458 pub fn releaseTile(self: *Hub, id: u32, ws_fd: std.posix.fd_t) void {
459 self.mutex.lock();
460 defer self.mutex.unlock();
461 const idx = self.indexOf(id) orelse return;
462 if (self.tiles.items[idx].ws_fd) |fd| {
463 if (fd == ws_fd) self.tiles.items[idx].ws_fd = null;
464 }
465 } 441 }
466 442
467 /// The `+` on a tile: a new session on THAT tile's daemon, AND a new 443 /// The `+` on a tile: a new session on THAT tile's daemon, AND a new
@@ -1084,10 +1060,9 @@ pub fn serveConn(
1084 var pump_arena = std.heap.ArenaAllocator.init(alloc); 1060 var pump_arena = std.heap.ArenaAllocator.init(alloc);
1085 defer pump_arena.deinit(); 1061 defer pump_arena.deinit();
1086 // Checkout BEFORE the upgrade, so a missing id is answered in HTTP 1062 // Checkout BEFORE the upgrade, so a missing id is answered in HTTP
1087 // — a 404 the page can read, not a 101 and a silent close. One name 1063 // — a 404 the page can read, not a 101 and a silent close.
1088 // for the fd, so the release below names what checkout got.
1089 const ws_fd = stream.handle; 1064 const ws_fd = stream.handle;
1090 const checked = hub.checkoutTile(id, ws_fd) catch |err| switch (err) { 1065 const checked = hub.checkoutTile(id) catch |err| switch (err) {
1091 // Removed between the page's GET and this dial: the browser 1066 // Removed between the page's GET and this dial: the browser
1092 // refetches /tiles and stops asking for it. 1067 // refetches /tiles and stops asking for it.
1093 error.UnknownId => { 1068 error.UnknownId => {
@@ -1095,10 +1070,6 @@ pub fn serveConn(
1095 return; 1070 return;
1096 }, 1071 },
1097 }; 1072 };
1098 // Deferred, not called after `pumpTile`: a failed upgrade must
1099 // unregister too. Declared AFTER `defer stream.close()` so it runs
1100 // BEFORE it, or a vanishing shuts down a recycled fd.
1101 defer hub.releaseTile(id, ws_fd);
1102 var ws = req.respondWebSocket(.{ .key = key }) catch return; 1073 var ws = req.respondWebSocket(.{ .key = key }) catch return;
1103 ws.flush() catch return; 1074 ws.flush() catch return;
1104 pumpTile(alloc, &ws, ws_fd, checked.target); 1075 pumpTile(alloc, &ws, ws_fd, checked.target);
@@ -1716,7 +1687,7 @@ test "hub: a checkout borrows the host target; a gone pane is still the browser'
1716 var hub = try Hub.init(alloc, &specs, &[_]Leaf{.{ .host = 0, .session = "wghost" }}); 1687 var hub = try Hub.init(alloc, &specs, &[_]Leaf{.{ .host = 0, .session = "wghost" }});
1717 defer hub.deinit(); 1688 defer hub.deinit();
1718 1689
1719 const t = try hub.checkoutTile(0, 7); 1690 const t = try hub.checkoutTile(0);
1720 // The host's target verbatim — a host outlives every pump, so a copy 1691 // The host's target verbatim — a host outlives every pump, so a copy
1721 // would be a lifetime nobody needed to track. 1692 // would be a lifetime nobody needed to track.
1722 try std.testing.expectEqualStrings("/tmp/a", t.target.sock); 1693 try std.testing.expectEqualStrings("/tmp/a", t.target.sock);
@@ -1732,38 +1703,10 @@ test "hub: a checkout borrows the host target; a gone pane is still the browser'
1732 hub.applyList(0, "", true); 1703 hub.applyList(0, "", true);
1733 hub.applyList(0, "", true); 1704 hub.applyList(0, "", true);
1734 try std.testing.expectEqual(TileState.gone, hub.tiles.items[0].state); 1705 try std.testing.expectEqual(TileState.gone, hub.tiles.items[0].state);
1735 _ = try hub.checkoutTile(0, 7); 1706 _ = try hub.checkoutTile(0);
1736 hub.releaseTile(0, 7);
1737 // An id this hub never handed out is the 404 the page refetches on: a 1707 // An id this hub never handed out is the 404 the page refetches on: a
1738 // browser holds `/ws/<n>` across a hub restart that renumbered the wall. 1708 // browser holds `/ws/<n>` across a hub restart that renumbered the wall.
1739 try std.testing.expectError(error.UnknownId, hub.checkoutTile(99, 7)); 1709 try std.testing.expectError(error.UnknownId, hub.checkoutTile(99));
1740 }
1741
1742 test "hub: two pumps on one tile — the first fd stays tracked, the second's release spares it" {
1743 const alloc = std.testing.allocator;
1744 const specs = [_]client.HostSpec{
1745 .{ .spelling = "--sock /tmp/a", .target = .{ .sock = "/tmp/a" }, .poll_target = .{ .sock = "/tmp/a" } },
1746 };
1747 var hub = try Hub.init(alloc, &specs, &[_]Leaf{.{ .host = 0, .session = "0" }});
1748 defer hub.deinit();
1749
1750 var arena = std.heap.ArenaAllocator.init(alloc);
1751 defer arena.deinit();
1752
1753 // Two browsers on one wall: both pumps get a target and both serve.
1754 const first = try hub.checkoutTile(0, 7);
1755 const second = try hub.checkoutTile(0, 8);
1756 try std.testing.expectEqualStrings("/tmp/a", first.target.sock);
1757 try std.testing.expectEqualStrings("/tmp/a", second.target.sock);
1758
1759 // The first registration is the tracked one, and the untracked pump's
1760 // release must leave it alone — two releases on one tile must not make
1761 // the tracked pump's fd look free while it is still serving.
1762 try std.testing.expectEqual(@as(?std.posix.fd_t, 7), hub.tiles.items[0].ws_fd);
1763 hub.releaseTile(0, 8);
1764 try std.testing.expectEqual(@as(?std.posix.fd_t, 7), hub.tiles.items[0].ws_fd);
1765 hub.releaseTile(0, 7);
1766 try std.testing.expectEqual(@as(?std.posix.fd_t, null), hub.tiles.items[0].ws_fd);
1767 } 1710 }
1768 1711
1769 test "hub: a HOST tile is never the ask" { 1712 test "hub: a HOST tile is never the ask" {
@@ -1776,7 +1719,7 @@ test "hub: a HOST tile is never the ask" {
1776 var hub = try Hub.init(alloc, &.{spec}, &[_]Leaf{.{ .host = 0, .session = "0" }}); 1719 var hub = try Hub.init(alloc, &.{spec}, &[_]Leaf{.{ .host = 0, .session = "0" }});
1777 defer hub.deinit(); 1720 defer hub.deinit();
1778 1721
1779 const t = try hub.checkoutTile(0, 7); 1722 const t = try hub.checkoutTile(0);
1780 // Nobody is sitting in front of a browser tile: it may not start a 1723 // Nobody is sitting in front of a browser tile: it may not start a
1781 // daemon on a box whose owner just stopped one, and it may not print 1724 // daemon on a box whose owner just stopped one, and it may not print
1782 // an ssh-fallback line into a page that has no stderr. 1725 // an ssh-fallback line into a page that has no stderr.
src/tui/wall_test_host.zig
Old New
@@ -1058,21 +1058,6 @@ test "keysToFocused: Enter on an unreachable pane revives it AND starts the daem
1058 tiles[0].pump_done.store(true, .release); 1058 tiles[0].pump_done.store(true, .release);
1059 } 1059 }
1060 1060
1061 test "endKey: x on a gone pane drops the tile locally" {
1062 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
1063 defer arena.deinit();
1064 const alloc = arena.allocator();
1065 var shared: Shared = undefined;
1066 fixture.stoppedWall(alloc, &shared);
1067 var tiles: [2]Tile = undefined;
1068 try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0);
1069 tiles[0].state = .gone;
1070 // A PIN, green from the day it was written: `.drop` already covers
1071 // `!alive`, and a gone pane has no pump. The spec now depends on that
1072 // coincidence, so it is asserted rather than assumed.
1073 try std.testing.expectEqual(wv.EndKey.drop, wv.endKey(&tiles[0], 0));
1074 }
1075
1076 test "planHostDiff: a session the daemon has and the wall does not is nobody's business: no birth, no tile" { 1061 test "planHostDiff: a session the daemon has and the wall does not is nobody's business: no birth, no tile" {
1077 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 1062 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
1078 var tiles = fixture.diffFixture(&shared); 1063 var tiles = fixture.diffFixture(&shared);
src/tui/wall_test_wall.zig
Old New
@@ -9,7 +9,6 @@ const wall_layout = @import("wall_layout.zig");
9 const wall_pump = @import("wall_pump.zig"); 9 const wall_pump = @import("wall_pump.zig");
10 const wv = @import("wallview.zig"); 10 const wv = @import("wallview.zig");
11 const EndAction = wv.EndAction; 11 const EndAction = wv.EndAction;
12 const EndKey = wv.EndKey;
13 const EndReason = wv.EndReason; 12 const EndReason = wv.EndReason;
14 const Resolved = wall_host.Resolved; 13 const Resolved = wall_host.Resolved;
15 const Shared = wv.Shared; 14 const Shared = wv.Shared;
@@ -137,50 +136,6 @@ test "setNoticeIdle: a standing condition waits — a refusal the user just earn
137 try std.testing.expectEqualStrings("[+2 not shown]", wv.takeNotice(&shared, &buf)); 136 try std.testing.expectEqualStrings("[+2 not shown]", wv.takeNotice(&shared, &buf));
138 } 137 }
139 138
140 test "endKey: x on a tile that never came up closes the TILE — there is no session to end" {
141 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
142 var t = Tile{
143 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" },
144 .rect = .{ .top = 0, .left = 0, .rows = 4, .cols = 80 },
145 .shared = &shared,
146 .idx = 0,
147 .wake_r = -1,
148 .wake_w = -1,
149 };
150 t.alive.store(true, .release);
151
152 // Parked on its first dial: alive, so the old rule stored an ask that
153 // `dial` never reads — no effect and no sentence, and the session ended
154 // under the user if the box ever came back.
155 t.state = .connecting;
156 try std.testing.expectEqual(EndKey.drop, wv.endKey(&t, 0));
157
158 // Once it has been up, the DAEMON owns the ending, reconnect or not.
159 t.ever_up.store(true, .release);
160 t.state = .reconnecting;
161 try std.testing.expectEqual(EndKey{ .ask = .end }, wv.endKey(&t, 0));
162 t.state = .up;
163 try std.testing.expectEqual(EndKey{ .ask = .end }, wv.endKey(&t, 0));
164
165 // The armed second press forces; the client never decides that itself.
166 t.end_armed_until.store(1000, .release);
167 try std.testing.expectEqual(EndKey{ .ask = .end_force }, wv.endKey(&t, 999));
168 try std.testing.expectEqual(EndKey{ .ask = .end }, wv.endKey(&t, 1000));
169
170 // A pump that has ended has nothing to ASK over, so the tile goes —
171 // whatever it once was. Without this a tile parked on a host no poll
172 // can log into (a declined prompt, a box that is down) sits on the wall
173 // for the wall's life with no key that removes it: `ringLive` is false,
174 // so the ask reaches nobody, and the list that would vanish it never
175 // comes.
176 t.alive.store(false, .release);
177 try std.testing.expectEqual(EndKey.drop, wv.endKey(&t, 0));
178 // ...including one that never came up at all, which is the same answer
179 // by the other road.
180 t.ever_up.store(false, .release);
181 try std.testing.expectEqual(EndKey.drop, wv.endKey(&t, 0));
182 }
183
184 test "x on a tile whose daemon refused arms a second press, and the window closes after 3 s" { 139 test "x on a tile whose daemon refused arms a second press, and the window closes after 3 s" {
185 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 140 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
186 defer shared.tree.deinit(); 141 defer shared.tree.deinit();
@@ -1303,24 +1258,6 @@ test "State.waiting has a word of its own, and truncation drops the label before
1303 if (bar.len > 30) return error.WaitingBarOverranTheTerminal; 1258 if (bar.len > 30) return error.WaitingBarOverranTheTerminal;
1304 } 1259 }
1305 1260
1306 test "endKey: x on a pane that has not bound yet closes it locally, silent host or not" {
1307 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
1308 var tiles = fixture.diffFixture(&shared);
1309 tiles[1].pending = true;
1310 tiles[1].alive.store(false, .release);
1311 tiles[1].state = .waiting;
1312 // A pending pane names no session to end, so `x` takes the TILE. The
1313 // pane the user closed stays closed — no list puts one back — and its
1314 // session, if the host turns out to have one, is the picker's to open
1315 // again; a pane on a silent host is otherwise unremovable for the
1316 // wall's life, which is what makes `unreachable` a place to press a key.
1317 if (wv.endKey(&tiles[1], 0) != .drop) return error.WaitingPaneRefusedTheKeyThatCloseIt;
1318 tiles[1].state = .@"unreachable";
1319 if (wv.endKey(&tiles[1], 0) != .drop) return error.SilentHostsPaneCannotBeClosed;
1320 tiles[1].pending = false;
1321 try std.testing.expectEqual(EndKey.drop, wv.endKey(&tiles[1], 0));
1322 }
1323
1324 test "removePane: the pane leaves the wall, its pump is told to detach, and nothing is asked to end" { 1261 test "removePane: the pane leaves the wall, its pump is told to detach, and nothing is asked to end" {
1325 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = false }; 1262 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = false };
1326 defer shared.tree.deinit(); 1263 defer shared.tree.deinit();
src/tui/wallview.zig
Old New
@@ -530,31 +530,9 @@ fn stepWhere(present: []const bool, tiles: ?[]const Tile, sel: usize, forward: b
530 /// the screen and not one the user has since walked away from. 530 /// the screen and not one the user has since walked away from.
531 pub const end_arm_ms: i64 = 3000; 531 pub const end_arm_ms: i64 = 3000;
532 532
533 /// What `Ctrl-\ x` has to act on. 533 /// Which `x` this is. The wall's own `Ctrl-\ x` removes a pane and asks
534 pub const EndKey = union(enum) { 534 /// the daemon nothing (`removePane`); this is the picker's end key, whose
535 /// The daemon owns the two-step; the pump carries this. 535 /// arming the pump reads back through `onEndReply`.
536 ask: client.SwitchIntent,
537 /// No session yet: the TILE goes, locally.
538 drop,
539 /// Nothing to do, and a sentence owed.
540 none,
541 };
542
543 /// Whether `Ctrl-\ x` has anything to ask, and what.
544 pub fn endKey(t: *Tile, now: i64) EndKey {
545 // An ask stored on an ended pump goes nowhere, so the TILE goes instead.
546 // Nothing is asked of the daemon: the session keeps running there, and
547 // no list puts it back on this wall — the picker's session rows are the
548 // way back onto one (`wall_picker.pickAdd`). A pending pane has no pump.
549 if (!t.alive.load(.acquire)) return .drop;
550 // A pump on its FIRST dial is parked in `dial`, which polls `removed` and
551 // never `ask`: the key would be swallowed until the box came back, then
552 // end the session. Birthing onto an `unreachable` row is a designed path.
553 if (!t.ever_up.load(.acquire) and awaitingSession(t) != null) return .drop;
554 return .{ .ask = intentForEnd(t, now) };
555 }
556
557 /// Which `x` this is.
558 pub fn intentForEnd(t: *const Tile, now: i64) client.SwitchIntent { 536 pub fn intentForEnd(t: *const Tile, now: i64) client.SwitchIntent {
559 // The client never DECIDES to force: the daemon is the only side that 537 // The client never DECIDES to force: the daemon is the only side that
560 // knows who else is attached, so this only remembers being told to ask 538 // knows who else is attached, so this only remembers being told to ask