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
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 |