a73x

95bebe20

refactor: the wall's daemons and their poller are wall_host.zig

a73x   2026-08-28 20:47

Commit message
refactor: the wall's daemons and their poller are wall_host.zig

The hosts file's lines, the table that holds them, the once-a-second poll
and the births and vanishings its answer makes: one file, reached from
wallview.zig. A move, not a rewrite — every body is the bytes it was, with
the cross-file names qualified and `HostSpec`/`resolveHost` re-exported
so mux_main still names the module's face.

docscheck.budget
Old New
@@ -48,3 +48,4 @@ server_test_agent.zig 0
48 server_test_upgrade.zig 0 48 server_test_upgrade.zig 0
49 server_agent.zig 0 49 server_agent.zig 0
50 server_sessions.zig 0 50 server_sessions.zig 0
51 wall_host.zig 0
src/tui/wall_host.zig
Old New
@@ -0,0 +1,573 @@
1 //! The wall's daemons: the hosts file's lines resolved to targets, the
2 //! table that holds them, and the poller that asks each one for its live
3 //! sessions once a second. A host contributes tiles, never a tile of its
4 //! own — `applyHostList` is where a poll's answer becomes births and
5 //! vanishings on the wall the root owns.
6 const std = @import("std");
7 const proto = @import("protocol");
8 const client = @import("client");
9 const wall = @import("wall");
10 const hosts = @import("hosts");
11 const handoff = @import("handoff");
12 const xdg = @import("xdg");
13 const sockpath = @import("sockpath");
14 const wv = @import("wallview.zig");
15 const Shared = wv.Shared;
16 const Tile = wv.Tile;
17
18 pub const Resolved = struct {
19 target: client.Target,
20 /// The user's spelling verbatim, `#NAME` included — the label bar
21 /// shows what was typed, not a rebuilt approximation of it.
22 label: []const u8,
23 session: []const u8,
24 /// Whether this tile's attaches offer this client's ssh-agent (`mux
25 /// -A`). Per tile rather than per wall: a tile the user never named —
26 /// one a host's poll turned up — must not hand a stranger's host the
27 /// keys, even while an `-A` tile is on the same wall.
28 agent: bool = false,
29 };
30
31 pub const ResolveError = hosts.ParseError || error{ MissingKey, SockPathTooLong, OutOfMemory };
32
33 /// A daemon on the wall: what to dial, and the line that named it. The
34 /// spelling is the sidecar's key and what `mux hosts` prints back, so it
35 /// is kept verbatim rather than rebuilt.
36 pub const HostSpec = struct { spelling: []const u8, target: client.Target, poll_target: client.Target };
37
38 /// A host line names a daemon, not a session; this is its target.
39 pub fn resolveHost(
40 alloc: std.mem.Allocator,
41 spelling: []const u8,
42 key: ?[]const u8,
43 idle_ms: u32,
44 ) ResolveError!HostSpec {
45 const target: client.Target = switch (try hosts.parse(spelling)) {
46 // Refused here, at usage altitude, not at a connect that fails
47 // with a truncated sun_path nobody typed.
48 .sock => |path| if (path.len > sockpath.max_sun_path)
49 return error.SockPathTooLong
50 else
51 .{ .sock = path },
52 .host => |h| blk: {
53 const r = try handoff.recipeFor(alloc, h, false);
54 break :blk .{
55 .hand = .{
56 .host = h,
57 .ssh_cmd = r.ssh_cmd,
58 .start_cmd = r.start_cmd,
59 .cache_path = r.cache_path,
60 .idle_ms = idle_ms,
61 // A host line is a listing, not an attach anyone waited
62 // for. The POLLER runs off this spec once a second: an
63 // asked copy would print the fallback line onto the
64 // wall's alternate screen every cycle, and would start
65 // a daemon on a box whose owner just stopped one.
66 .asked = false,
67 },
68 };
69 },
70 .quic => |hp| blk: {
71 const key_path = switch (xdg.resolveKeyPath(alloc, key) catch |err| switch (err) {
72 error.NoHome => return error.MissingKey,
73 else => |e| return e,
74 }) {
75 .given => |kp| kp,
76 .default => |kp| kp,
77 .missing => return error.MissingKey,
78 };
79 break :blk .{ .quic = .{
80 .host_port = hp,
81 .key_path = key_path,
82 .idle_ms = idle_ms,
83 } };
84 },
85 };
86 return .{ .spelling = spelling, .target = target, .poll_target = try pollTargetFor(alloc, target) };
87 }
88
89 /// What `HostSpec.poll_target` is: the same daemon, dialled by a recipe
90 /// nobody is sitting in front of.
91 pub fn pollTargetFor(alloc: std.mem.Allocator, target: client.Target) !client.Target {
92 // Only `hand` can ask a terminal for anything, so only `hand` needs a
93 // second recipe: a poll runs under a wall that owns the screen, where
94 // an ssh password prompt goes to /dev/tty under the panes and a
95 // fallback line goes onto the alternate screen.
96 const h = switch (target) {
97 .hand => |hd| hd,
98 else => return target,
99 };
100 const r = try handoff.recipeFor(alloc, h.host, true);
101 return .{ .hand = .{
102 .host = h.host,
103 .ssh_cmd = r.ssh_cmd,
104 .start_cmd = r.start_cmd,
105 .cache_path = r.cache_path,
106 .idle_ms = h.idle_ms,
107 .asked = false,
108 } };
109 }
110
111 /// One wording for every spelling the wall will not take.
112 fn badHost(shared: *Shared, e: anyerror) void {
113 // `hosts.reason` rather than the error name: the grammar's own sentence
114 // is the one that tells a user what to type instead.
115 var buf: [128]u8 = undefined;
116 const text = std.fmt.bufPrint(&buf, "[bad host: {s}]", .{hosts.reason(e)}) catch "[bad host]";
117 wv.setNotice(shared, text);
118 }
119
120 /// The sentence for host lines the wall has no room for; null when they all
121 /// fit. A wall silently missing a machine the user WROTE DOWN is the lie the
122 /// hosts file exists to stop telling — the session count's own argument.
123 pub fn hostsOverCapacity(buf: []u8, listed: usize) ?[]const u8 {
124 const over = listed -| wv.max_tiles;
125 if (over == 0) return null;
126 return std.fmt.bufPrint(buf, "[+{d} host{s} in the file not shown]", .{
127 over,
128 if (over == 1) "" else "s",
129 }) catch "[hosts in the file not shown]";
130 }
131
132 /// The lowest slot a forgotten host has finished leaving. Both halves, for
133 /// `freeSlot`'s reason: `forgotten` is the ask, `poller_done` is the answer.
134 fn freeHostSlot(host_table: []const Host) ?usize {
135 for (host_table, 0..) |*h, i| {
136 if (h.forgotten.load(.acquire) and h.poller_done.load(.acquire)) return i;
137 }
138 return null;
139 }
140
141 /// What the picker's `a` did. `listed` carries the row the spelling is
142 /// ALREADY on: an unchanged popup and an unmoved selection is the prompt
143 /// answering a user who typed a real host with nothing at all.
144 pub const AddHost = union(enum) { added: usize, listed: usize, refused };
145
146 /// The picker's `a`, answered: the spelling names a DAEMON. Every arm sets
147 /// the sentence it is; the caller only polls what it just made.
148 pub fn addHost(
149 alloc: std.mem.Allocator,
150 shared: *Shared,
151 host_table: []Host,
152 hosts_live: *usize,
153 spelling: []const u8,
154 key: ?[]const u8,
155 idle_ms: u32,
156 path: ?[]const u8,
157 ) AddHost {
158 // No tile is born here: the host's first poll is what turns its
159 // sessions into tiles, so the prompt cannot conjure a session that is
160 // not there. Twice is once — a second poller on the same daemon would
161 // tile every one of its sessions again.
162 for (host_table[0..hosts_live.*], 0..) |*h, hi| {
163 // A forgotten host is not on the wall, so its slot must not refuse
164 // the spelling back: `x` then `a` on the same host is one of the
165 // two things the picker is for.
166 if (h.forgotten.load(.acquire)) continue;
167 if (std.mem.eql(u8, h.spec.spelling, spelling)) {
168 wv.setNotice(shared, "[that host is already on the wall]");
169 return .{ .listed = hi };
170 }
171 }
172 // The prompt is `mux hosts add` typed from inside, so it refuses what
173 // that refuses: `-A box` is a mistyped flag, not a host. Ahead of the
174 // dupe, so the answer to a typo allocates nothing.
175 if (wall.flagLike(spelling)) {
176 badHost(shared, error.FlagLikeTarget);
177 return .refused;
178 }
179 // Ahead of the dupe and the resolve, both of which allocate: a wall
180 // that is full is full whatever the spelling turns out to mean, and a
181 // prompt the user keeps re-opening must not leak a copy per refusal.
182 //
183 // A forgotten slot whose poller has left is free — `a` and `x` are what
184 // the picker is FOR, and a table that only grew spent the wall after 32
185 // of them however few rows were showing.
186 const reuse = freeHostSlot(host_table[0..hosts_live.*]);
187 if (reuse == null and hosts_live.* >= host_table.len) {
188 wv.setNotice(shared, "[no room on the wall for another host]");
189 return .refused;
190 }
191 // The filter's buffer is the next read's; the table keeps this copy.
192 const own = alloc.dupe(u8, spelling) catch {
193 wv.setNotice(shared, "[could not add that host]");
194 return .refused;
195 };
196 const spec = resolveHost(alloc, own, key, idle_ms) catch |err| {
197 alloc.free(own);
198 badHost(shared, err);
199 return .refused;
200 };
201 // The file before the table: a host the user is looking at and a host
202 // they get back next time are the same host, and `mux hosts rm` is the
203 // only way out of either.
204 // The host is this wall's either way: a file that will not take the
205 // line costs the user the NEXT wall, not this one.
206 if (path) |p| if (recordHost(alloc, spec.target, spec.spelling, p)) |err| {
207 var nb: [128]u8 = undefined;
208 wv.setNotice(shared, std.fmt.bufPrint(
209 &nb,
210 "[hosts file not updated: {s}]",
211 .{hosts.reason(err)},
212 ) catch "[hosts file not updated]");
213 };
214 const at = reuse orelse hosts_live.*;
215 // The departed spec is not freed: `run`'s allocator is an arena that
216 // never returns, exactly as the tile labels beside it are not freed.
217 host_table[at] = .{
218 .spec = spec,
219 .shared = shared,
220 .self_name = wv.selfSession(spec.target, std.posix.getenv(proto.sock_env), std.posix.getenv(proto.session_env)),
221 };
222 if (reuse == null) hosts_live.* += 1;
223 return .{ .added = at };
224 }
225
226 /// A diff's plan, bounded by the wall's own capacity so a daemon with more
227 /// sessions than the wall can hold is COUNTED rather than overrunning.
228 fn Fixed(comptime T: type) type {
229 return struct {
230 items: [wv.max_tiles]T = undefined,
231 len: usize = 0,
232 dropped: usize = 0,
233
234 fn append(self: *@This(), v: T) void {
235 if (self.len == self.items.len) {
236 self.dropped += 1;
237 return;
238 }
239 self.items[self.len] = v;
240 self.len += 1;
241 }
242
243 pub fn get(self: *const @This(), i: usize) T {
244 return self.items[i];
245 }
246 };
247 }
248
249 pub const BirthNames = Fixed([]const u8);
250 pub const TileIdxs = Fixed(usize);
251
252 /// Only a host's own sessions are its list's to keep or to drop.
253 pub fn ownedBy(t: *const Tile, host: usize) bool {
254 const h = t.host orelse return false;
255 return h == host;
256 }
257
258 /// The whole of "a host's live sessions are its tiles", per HOST so that
259 /// two daemons may share a session name. Pure: the rule is testable
260 /// without a daemon.
261 pub fn planHostDiff(
262 tiles: []Tile,
263 present: []const bool,
264 live: usize,
265 host: usize,
266 list: []const u8,
267 self_name: ?[]const u8,
268 births: *BirthNames,
269 vanish: *TileIdxs,
270 ) void {
271 var it = std.mem.splitScalar(u8, list, '\n');
272 while (it.next()) |name| {
273 if (name.len == 0) continue;
274 // The trust boundary: a peer's reply is bounded only in TOTAL, so
275 // one "name" in it can be 1056 bytes of anything. `encodeAttachNamed`
276 // and `encodeEndReq` memcpy a birth's name into a 32-byte tail
277 // behind an assert, which states a bug rather than filtering input.
278 if (!proto.validSessionName(name)) continue;
279 if (self_name) |self| if (std.mem.eql(u8, self, name)) continue;
280 var found = false;
281 for (tiles[0..live], present[0..live]) |*t, p| {
282 if (p and ownedBy(t, host) and std.mem.eql(u8, proto.resolveName(t.r.session), name)) {
283 found = true;
284 break;
285 }
286 }
287 if (!found) births.append(name);
288 }
289 for (tiles[0..live], present[0..live], 0..) |*t, p, i| {
290 if (!p or !ownedBy(t, host)) continue;
291 // A tile whose CREATING attach has not landed names a session the
292 // daemon does not have yet, not one it dropped — and `pokeHost`
293 // asks for this list at exactly that moment.
294 if (t.creates and !t.ever_up.load(.acquire)) continue;
295 var keep = false;
296 var it2 = std.mem.splitScalar(u8, list, '\n');
297 while (it2.next()) |name| {
298 if (std.mem.eql(u8, proto.resolveName(t.r.session), name)) {
299 keep = true;
300 break;
301 }
302 }
303 if (keep) {
304 t.missed_once = false;
305 continue;
306 }
307 // A LIVE pump gets one list's grace. The daemon drains a session's
308 // exit_status before it clears the slot, but the poll's connect and
309 // its pass through the daemon take milliseconds the pump can be
310 // descheduled for — and on a wall of one, vanishing the tile first
311 // loses the shell's exit code, because `endedTile` skips a tile that
312 // is no longer present and mux stays up on an empty wall. A dead
313 // pump has no code left to lose, so it goes at once.
314 if (t.alive.load(.acquire) and !t.missed_once) {
315 t.missed_once = true;
316 continue;
317 }
318 vanish.append(i);
319 }
320 }
321
322 /// One daemon on the wall, as the run knows it: the poller writes, the
323 /// keyboard reads.
324 pub const Host = struct {
325 spec: HostSpec,
326 shared: *Shared,
327 /// Never born as a tile here: see `showsSelf`.
328 self_name: ?[]const u8 = null,
329 /// A chord that births asks for the next poll NOW rather than in a
330 /// second — the wall must not lag the session the user just made.
331 poke: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
332 list_mu: std.Thread.Mutex = .{},
333 list: [proto.sessions_text_max]u8 = undefined,
334 list_len: usize = 0,
335 /// News for the keyboard: a poll finished, well or badly.
336 list_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
337 /// Whether a list of this host's has reached the WALL. Here rather than
338 /// in an array beside the table, because the picker's `a` grows the
339 /// table and an array sized when the wall opened is one index out of
340 /// bounds per added host. Keyboard-thread only, so no lock: a poller flag read
341 /// between its own two stores would restore over a wall still missing
342 /// that host's sessions.
343 applied: bool = false,
344 reachable: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
345 /// Forgotten in the picker: off the file, off the rows, and its poller
346 /// exits for good. The SLOT stays — a poller thread holds this pointer
347 /// and every tile's `host` indexes this array — so nothing compacts.
348 forgotten: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
349 /// `Tile.pump_done`'s twin, and `addHost` needs both halves for the
350 /// same reason `freeSlot` does: a forgotten slot rewritten while its
351 /// poller still holds the pointer is a thread dialling a replaced spec.
352 poller_done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
353 };
354
355 /// Polling, not a push: a subscription is a new daemon concept, and one
356 /// small frame a second per host over a link that already carries deltas is
357 /// not a cost worth designing around.
358 const host_poll_ms: u64 = 1000;
359
360 /// How long before this host is asked again, given the link that answered.
361 pub fn pollDelayMs(link: std.meta.Tag(client.Link)) u64 {
362 // A pipe link cost a whole sshd login: the cached QUIC coordinates were
363 // dead or blocked, so this cycle spawned `ssh`, read the announce and
364 // killed it. A second of that, forever, is a remote auth log the wall
365 // wrote — the list is worth a tenth of the freshness.
366 return if (link == .pipe) host_poll_ms * 10 else host_poll_ms;
367 }
368
369 pub fn pollHost(h: *Host) void {
370 var out: [proto.sessions_text_max]u8 = undefined;
371 while (h.shared.running.load(.acquire) and !h.forgotten.load(.acquire)) {
372 // A connection of its own per poll: the observer idle deadline and
373 // the redial backoff stay the pump's problem, and this thread owns
374 // no transport between polls that a teardown would have to reach.
375 var link: std.meta.Tag(client.Link) = .fd;
376 const got = client.listSessions(std.heap.page_allocator, h.spec.poll_target, &out, 2000, &link) catch null;
377 if (got) |list| {
378 h.list_mu.lock();
379 @memcpy(h.list[0..list.len], list);
380 h.list_len = list.len;
381 h.list_mu.unlock();
382 h.reachable.store(true, .release);
383 } else h.reachable.store(false, .release);
384 h.list_ready.store(true, .release);
385 wv.ringKeyboard(h.shared);
386 var slept: u64 = 0;
387 const wait = pollDelayMs(link);
388 // A chord that births still pokes through it, so the stretched wait
389 // costs a user's own action nothing.
390 while (slept < wait and
391 !h.poke.swap(false, .acq_rel) and
392 !h.forgotten.load(.acquire) and
393 h.shared.running.load(.acquire)) : (slept += 50)
394 std.Thread.sleep(50 * std.time.ns_per_ms);
395 }
396 // Last: past here nothing reads `h`, which is what lets `addHost` take
397 // the slot back.
398 h.poller_done.store(true, .release);
399 }
400
401 /// A chord that births asks its host for a list NOW: a session made by
402 /// `c` or `:` must not wait out the poll interval to become a tile.
403 pub fn pokeHost(host_table: []Host, t: *const Tile) void {
404 const hi = t.host orelse return;
405 if (hi < host_table.len) host_table[hi].poke.store(true, .release);
406 }
407
408 /// Every host with news, applied to the wall. True when any reported.
409 pub fn applyReadyLists(
410 alloc: std.mem.Allocator,
411 tiles: []Tile,
412 present: []bool,
413 live: *usize,
414 shared: *Shared,
415 host_table: []Host,
416 ) bool {
417 var news = false;
418 for (host_table, 0..) |*h, hi| {
419 if (!h.list_ready.swap(false, .acq_rel)) continue;
420 news = true;
421 // A poll already in flight when the host was forgotten still lands.
422 // Applying it would re-birth the tiles the forget just took off the
423 // wall, one poll later.
424 if (h.forgotten.load(.acquire)) continue;
425 applyHostList(alloc, tiles, present, live, shared, host_table, hi);
426 h.applied = true;
427 }
428 return news;
429 }
430
431 /// One host's list, applied to the wall. The keyboard thread only: it is
432 /// the single writer of the tile array and the layout tree.
433 pub fn applyHostList(
434 alloc: std.mem.Allocator,
435 tiles: []Tile,
436 present: []bool,
437 live: *usize,
438 shared: *Shared,
439 host_table: []Host,
440 hi: usize,
441 ) void {
442 const h = &host_table[hi];
443 const reachable = h.reachable.load(.acquire);
444 var list_buf: [proto.sessions_text_max]u8 = undefined;
445 var list: []const u8 = "";
446 if (reachable) {
447 h.list_mu.lock();
448 @memcpy(list_buf[0..h.list_len], h.list[0..h.list_len]);
449 list = list_buf[0..h.list_len];
450 h.list_mu.unlock();
451 }
452 // Whether the focus was on a real tile when this list arrived. An empty
453 // wall has none, and a vanish can take the one there was — either way
454 // the wall owes the tile it ends up with a `setFocus`, which is the only
455 // thing that arms a claim.
456 const had_focus = shared.sel < live.* and present[shared.sel];
457 var changed = false;
458 // Only a list DRIVES the diff. A host that has gone quiet keeps its
459 // tiles, which reconnect on their own; vanishing them on a failed poll
460 // would tear a wall down over one dropped packet.
461 if (reachable) {
462 var births = BirthNames{};
463 var vanish = TileIdxs{};
464 planHostDiff(tiles[0..live.*], present[0..live.*], live.*, hi, list, h.self_name, &births, &vanish);
465 for (vanish.items[0..vanish.len]) |v| {
466 wv.vanishTile(tiles[0..live.*], present[0..live.*], shared, v, null);
467 changed = true;
468 }
469 var placed: usize = 0;
470 // The tile the NEXT birth sits beside: the focus for the first, then
471 // the one just born. `insert` puts a new leaf immediately after its
472 // anchor, so anchoring every birth at the focus would lay a list of
473 // {b, c} out as c, b — a wall reading back-to-front against the
474 // order its daemon reported, and against the digits the chords use.
475 var anchor = wv.anchorTile(present[0..live.*], shared.sel);
476 // Stops at the FIRST refusal rather than retrying each name: the
477 // wall refuses for a reason that holds for the whole list (no slot,
478 // no room to cut), and this list comes back every second — a
479 // per-name retry is an insert, a flatten and an undo per name per
480 // poll, forever.
481 while (placed < births.len) : (placed += 1) {
482 const at = wv.birthTile(alloc, tiles, present, live, shared, .{
483 // Joins, never creates: the daemon already has this session,
484 // and a sized attach on a live one would resize somebody.
485 // The name is this poll's reply buffer until the wall takes
486 // the tile — see `Birth.borrowed`.
487 .r = .{ .target = h.spec.target, .label = "", .session = births.get(placed) },
488 .from = anchor,
489 .place = .beside_focus,
490 .creates = false,
491 .born_from = null,
492 .host = hi,
493 .borrowed = true,
494 }) orelse break;
495 anchor = at;
496 wv.spawnPump(&tiles[at]);
497 changed = true;
498 }
499 const unplaced = births.dropped + (births.len - placed);
500 // Said out loud rather than dropped: a wall showing a PREFIX of a
501 // daemon's sessions is a wall lying about what it is.
502 if (unplaced > 0) {
503 var buf: [48]u8 = undefined;
504 wv.setNoticeIdle(shared, std.fmt.bufPrint(&buf, "[+{d} not shown]", .{unplaced}) catch "[not shown]");
505 }
506 }
507 if ((!had_focus or shared.sel >= live.* or !present[shared.sel]) and
508 wv.presentCount(present[0..live.*]) > 0)
509 wv.setFocus(tiles[0..live.*], shared, wv.firstPresent(present[0..live.*]) orelse 0);
510 if (changed) wv.relayout(alloc, tiles[0..live.*], present[0..live.*], shared, shared.sel);
511 }
512
513 /// The host grammar's own spelling of a target, for a wall entered by
514 /// `mux TARGET` rather than off the file: the sidecar's key and the line
515 /// `mux hosts` prints have to read like the one that would have named it.
516 pub fn hostSpelling(alloc: std.mem.Allocator, target: client.Target) ![]const u8 {
517 return switch (target) {
518 .sock => |p| try std.fmt.allocPrint(alloc, "--sock {s}", .{p}),
519 .hand => |h| try alloc.dupe(u8, h.host),
520 .quic => |q| try std.fmt.allocPrint(alloc, "quic://{s}", .{q.host_port}),
521 // `--via` has no form in that grammar — an arbitrary command is not
522 // an address — so the label is honest and is not a spelling.
523 .via => |c| try std.fmt.allocPrint(alloc, "--via {s}", .{c}),
524 };
525 }
526
527 /// The hosts file is what the user asked to SEE, not what answered.
528 pub fn recordHost(
529 alloc: std.mem.Allocator,
530 target: client.Target,
531 spelling: []const u8,
532 path: []const u8,
533 ) ?anyerror {
534 // Both doors — `mux HOST` and the picker's `a` — write the line on the
535 // user's word: a daemon that never answers is a host the file still remembers,
536 // rather than a line missing from it.
537 //
538 // The failure comes BACK rather than being printed, because where it may
539 // be SAID differs by door: stderr before the wall takes the screen, a
540 // notice after.
541 //
542 // `--via` has no form in the host grammar — an arbitrary command is not
543 // an address — so an attach over one records nothing, and silently.
544 if (target == .via) return null;
545 _ = hosts.record(alloc, path, spelling) catch |err| return err;
546 return null;
547 }
548
549 /// The rest of the file, after the host the user named.
550 pub fn otherHosts(
551 alloc: std.mem.Allocator,
552 specs: *std.ArrayList(HostSpec),
553 first: []const u8,
554 path: []const u8,
555 key: ?[]const u8,
556 idle_ms: u32,
557 ) void {
558 const h = hosts.load(alloc, path) catch |err| {
559 std.debug.print("mux: hosts file ignored ({s}): {s}\n", .{ path, hosts.reason(err) });
560 return;
561 };
562 for (h.lines.items) |line| {
563 if (std.mem.eql(u8, line, first)) continue;
564 const spec = resolveHost(alloc, line, key, idle_ms) catch |err| {
565 // Said and skipped, not refused: what was asked for here is a
566 // session, and it is already open. Only bare `mux`, where the
567 // wall itself is the ask, turns a bad line into an exit code.
568 std.debug.print("mux: bad host '{s}': {s}\n", .{ line, hosts.reason(err) });
569 continue;
570 };
571 specs.append(alloc, spec) catch return;
572 }
573 }
src/tui/wallview.zig
Old New
@@ -36,99 +36,17 @@ const select = @import("select");
36 const interact = @import("interact"); 36 const interact = @import("interact");
37 const layout = @import("layout"); 37 const layout = @import("layout");
38 const TmpDir = @import("testtmp").TmpDir; 38 const TmpDir = @import("testtmp").TmpDir;
39 39 const wall_host = @import("wall_host.zig");
40 pub const Resolved = struct { 40 const AddHost = wall_host.AddHost;
41 target: client.Target, 41 const BirthNames = wall_host.BirthNames;
42 /// The user's spelling verbatim, `#NAME` included — the label bar 42 const Host = wall_host.Host;
43 /// shows what was typed, not a rebuilt approximation of it. 43 const Resolved = wall_host.Resolved;
44 label: []const u8, 44 const TileIdxs = wall_host.TileIdxs;
45 session: []const u8, 45
46 /// Whether this tile's attaches offer this client's ssh-agent (`mux 46 // `wallview` is this module's face: mux_main reaches these through
47 /// -A`). Per tile rather than per wall: a tile the user never named — 47 // the root, wherever inside the module they now live.
48 /// one a host's poll turned up — must not hand a stranger's host the 48 pub const HostSpec = wall_host.HostSpec;
49 /// keys, even while an `-A` tile is on the same wall. 49 pub const resolveHost = wall_host.resolveHost;
50 agent: bool = false,
51 };
52
53 pub const ResolveError = hosts.ParseError || error{ MissingKey, SockPathTooLong, OutOfMemory };
54
55 /// A daemon on the wall: what to dial, and the line that named it. The
56 /// spelling is the sidecar's key and what `mux hosts` prints back, so it
57 /// is kept verbatim rather than rebuilt.
58 pub const HostSpec = struct { spelling: []const u8, target: client.Target, poll_target: client.Target };
59
60 /// A host line names a daemon, not a session; this is its target.
61 pub fn resolveHost(
62 alloc: std.mem.Allocator,
63 spelling: []const u8,
64 key: ?[]const u8,
65 idle_ms: u32,
66 ) ResolveError!HostSpec {
67 const target: client.Target = switch (try hosts.parse(spelling)) {
68 // Refused here, at usage altitude, not at a connect that fails
69 // with a truncated sun_path nobody typed.
70 .sock => |path| if (path.len > sockpath.max_sun_path)
71 return error.SockPathTooLong
72 else
73 .{ .sock = path },
74 .host => |h| blk: {
75 const r = try handoff.recipeFor(alloc, h, false);
76 break :blk .{
77 .hand = .{
78 .host = h,
79 .ssh_cmd = r.ssh_cmd,
80 .start_cmd = r.start_cmd,
81 .cache_path = r.cache_path,
82 .idle_ms = idle_ms,
83 // A host line is a listing, not an attach anyone waited
84 // for. The POLLER runs off this spec once a second: an
85 // asked copy would print the fallback line onto the
86 // wall's alternate screen every cycle, and would start
87 // a daemon on a box whose owner just stopped one.
88 .asked = false,
89 },
90 };
91 },
92 .quic => |hp| blk: {
93 const key_path = switch (xdg.resolveKeyPath(alloc, key) catch |err| switch (err) {
94 error.NoHome => return error.MissingKey,
95 else => |e| return e,
96 }) {
97 .given => |kp| kp,
98 .default => |kp| kp,
99 .missing => return error.MissingKey,
100 };
101 break :blk .{ .quic = .{
102 .host_port = hp,
103 .key_path = key_path,
104 .idle_ms = idle_ms,
105 } };
106 },
107 };
108 return .{ .spelling = spelling, .target = target, .poll_target = try pollTargetFor(alloc, target) };
109 }
110
111 /// What `HostSpec.poll_target` is: the same daemon, dialled by a recipe
112 /// nobody is sitting in front of.
113 fn pollTargetFor(alloc: std.mem.Allocator, target: client.Target) !client.Target {
114 // Only `hand` can ask a terminal for anything, so only `hand` needs a
115 // second recipe: a poll runs under a wall that owns the screen, where
116 // an ssh password prompt goes to /dev/tty under the panes and a
117 // fallback line goes onto the alternate screen.
118 const h = switch (target) {
119 .hand => |hd| hd,
120 else => return target,
121 };
122 const r = try handoff.recipeFor(alloc, h.host, true);
123 return .{ .hand = .{
124 .host = h.host,
125 .ssh_cmd = r.ssh_cmd,
126 .start_cmd = r.start_cmd,
127 .cache_path = r.cache_path,
128 .idle_ms = h.idle_ms,
129 .asked = false,
130 } };
131 }
132 50
133 /// Whether this process has a screen to cut stripes on. 51 /// Whether this process has a screen to cut stripes on.
134 fn headless(out_fd: std.posix.fd_t) bool { 52 fn headless(out_fd: std.posix.fd_t) bool {
@@ -269,7 +187,7 @@ pub const max_tiles: usize = 32;
269 /// loss has no label word of its own and an exit carries a code. 187 /// loss has no label word of its own and an exit carries a code.
270 pub const EndReason = enum(u8) { none, exited, taken, refused, lost, no_thread }; 188 pub const EndReason = enum(u8) { none, exited, taken, refused, lost, no_thread };
271 189
272 const Shared = struct { 190 pub const Shared = struct {
273 running: std.atomic.Value(bool) = std.atomic.Value(bool).init(true), 191 running: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
274 /// Serializes every write to the terminal. Held (and never released) 192 /// Serializes every write to the terminal. Held (and never released)
275 /// at teardown, so no stripe paints across the restore, and held 193 /// at teardown, so no stripe paints across the restore, and held
@@ -379,7 +297,7 @@ const Shared = struct {
379 /// Absolute rows are counted from the oldest row the DAEMON still retains 297 /// Absolute rows are counted from the oldest row the DAEMON still retains
380 /// (`protocol.SelectionReply`), so a grid row alone names nothing: it has 298 /// (`protocol.SelectionReply`), so a grid row alone names nothing: it has
381 /// to be read against the history count the same frame carried. 299 /// to be read against the history count the same frame carried.
382 const Tile = struct { 300 pub const Tile = struct {
383 r: Resolved, 301 r: Resolved,
384 rect: layout.Rect, 302 rect: layout.Rect,
385 shared: *Shared, 303 shared: *Shared,
@@ -969,7 +887,7 @@ fn ringLive(t: *const Tile) bool {
969 // is a cheaper owner than the alternative is to get right. 887 // is a cheaper owner than the alternative is to get right.
970 888
971 /// `ring` in the other direction: never blocks, never reports. 889 /// `ring` in the other direction: never blocks, never reports.
972 fn ringKeyboard(shared: *const Shared) void { 890 pub fn ringKeyboard(shared: *const Shared) void {
973 _ = std.posix.write(shared.kb_w, "\x00") catch {}; 891 _ = std.posix.write(shared.kb_w, "\x00") catch {};
974 } 892 }
975 893
@@ -993,7 +911,7 @@ fn publishStats(shared: *Shared, c: interact.PredictCounters) void {
993 /// Leave a sentence for whichever tile owns the terminal next. Keyboard 911 /// Leave a sentence for whichever tile owns the terminal next. Keyboard
994 /// thread only, and only for a focus it is about to move — the pump that 912 /// thread only, and only for a focus it is about to move — the pump that
995 /// lands there paints it as a banner on its claim. 913 /// lands there paints it as a banner on its claim.
996 fn setNotice(shared: *Shared, text: []const u8) void { 914 pub fn setNotice(shared: *Shared, text: []const u8) void {
997 shared.paint_mu.lock(); 915 shared.paint_mu.lock();
998 defer shared.paint_mu.unlock(); 916 defer shared.paint_mu.unlock();
999 const n = @min(text.len, shared.notice.len); 917 const n = @min(text.len, shared.notice.len);
@@ -1003,7 +921,7 @@ fn setNotice(shared: *Shared, text: []const u8) void {
1003 921
1004 /// A standing condition, into an EMPTY slot only: one notice, re-derived 922 /// A standing condition, into an EMPTY slot only: one notice, re-derived
1005 /// every second per host, would erase a sentence the user just earned. 923 /// every second per host, would erase a sentence the user just earned.
1006 fn setNoticeIdle(shared: *Shared, text: []const u8) void { 924 pub fn setNoticeIdle(shared: *Shared, text: []const u8) void {
1007 shared.paint_mu.lock(); 925 shared.paint_mu.lock();
1008 // Dropped between the two: both writers are the keyboard thread, so 926 // Dropped between the two: both writers are the keyboard thread, so
1009 // nothing can take the slot in the gap. 927 // nothing can take the slot in the gap.
@@ -1924,7 +1842,7 @@ fn pumpTile(t: *Tile) void {
1924 /// (forgets the focused tile). So the outgoing session's modes come off 1842 /// (forgets the focused tile). So the outgoing session's modes come off
1925 /// here, ahead of the focus moving. The claim and release are doorbells; 1843 /// here, ahead of the focus moving. The claim and release are doorbells;
1926 /// no screen clear — every tile paints its own rect. 1844 /// no screen clear — every tile paints its own rect.
1927 fn setFocus(tiles: []Tile, shared: *Shared, next: usize) void { 1845 pub fn setFocus(tiles: []Tile, shared: *Shared, next: usize) void {
1928 const prev = shared.sel; 1846 const prev = shared.sel;
1929 shared.paint_mu.lock(); 1847 shared.paint_mu.lock();
1930 defer shared.paint_mu.unlock(); 1848 defer shared.paint_mu.unlock();
@@ -2018,7 +1936,7 @@ fn paintRailsLocked(shared: *Shared, flat: layout.Flat) void {
2018 1936
2019 /// One `paint_mu` hold: no window where a pump paints rows that just 1937 /// One `paint_mu` hold: no window where a pump paints rows that just
2020 /// changed owner. 1938 /// changed owner.
2021 fn relayout( 1939 pub fn relayout(
2022 alloc: std.mem.Allocator, 1940 alloc: std.mem.Allocator,
2023 tiles: []Tile, 1941 tiles: []Tile,
2024 present: []const bool, 1942 present: []const bool,
@@ -2104,7 +2022,7 @@ fn relayout(
2104 /// come here, which is why the focus hand-off can only be written once. 2022 /// come here, which is why the focus hand-off can only be written once.
2105 /// `to` overrides where a focused tile's focus goes; null takes the next 2023 /// `to` overrides where a focused tile's focus goes; null takes the next
2106 /// present tile. The caller re-cuts. 2024 /// present tile. The caller re-cuts.
2107 fn vanishTile(tiles: []Tile, present: []bool, shared: *Shared, i: usize, to: ?usize) void { 2025 pub fn vanishTile(tiles: []Tile, present: []bool, shared: *Shared, i: usize, to: ?usize) void {
2108 present[i] = false; 2026 present[i] = false;
2109 tiles[i].gone.store(true, .release); 2027 tiles[i].gone.store(true, .release);
2110 ring(&tiles[i]); 2028 ring(&tiles[i]);
@@ -2297,7 +2215,7 @@ fn sameTarget(a: client.Target, b: client.Target) bool {
2297 }; 2215 };
2298 } 2216 }
2299 2217
2300 fn presentCount(present: []const bool) usize { 2218 pub fn presentCount(present: []const bool) usize {
2301 var n: usize = 0; 2219 var n: usize = 0;
2302 for (present) |p| { 2220 for (present) |p| {
2303 if (p) n += 1; 2221 if (p) n += 1;
@@ -2345,7 +2263,7 @@ fn initTile(t: *Tile, r: Resolved, s: layout.Rect, shared: *Shared, idx: usize,
2345 }; 2263 };
2346 } 2264 }
2347 2265
2348 fn spawnPump(t: *Tile) void { 2266 pub fn spawnPump(t: *Tile) void {
2349 const th = std.Thread.spawn(.{}, pumpTile, .{t}) catch { 2267 const th = std.Thread.spawn(.{}, pumpTile, .{t}) catch {
2350 // A tile with no thread is a tile nothing will ever paint — the 2268 // A tile with no thread is a tile nothing will ever paint — the
2351 // same hole `pumpTile`'s exit closes, reached without the pump 2269 // same hole `pumpTile`'s exit closes, reached without the pump
@@ -2422,7 +2340,7 @@ const Birth = struct {
2422 2340
2423 /// Every road onto a running wall — chord, fold, prompt — one body. 2341 /// Every road onto a running wall — chord, fold, prompt — one body.
2424 /// Null: nothing was added. 2342 /// Null: nothing was added.
2425 fn birthTile( 2343 pub fn birthTile(
2426 alloc: std.mem.Allocator, 2344 alloc: std.mem.Allocator,
2427 tiles: []Tile, 2345 tiles: []Tile,
2428 present: []bool, 2346 present: []bool,
@@ -2609,121 +2527,6 @@ fn closeNotice(tiles: []Tile, present: []const bool, shared: *Shared) void {
2609 if (pending) showRefusal(tiles, shared, z) else _ = ringLive(&tiles[z]); 2527 if (pending) showRefusal(tiles, shared, z) else _ = ringLive(&tiles[z]);
2610 } 2528 }
2611 2529
2612 /// One wording for every spelling the wall will not take.
2613 fn badHost(shared: *Shared, e: anyerror) void {
2614 // `hosts.reason` rather than the error name: the grammar's own sentence
2615 // is the one that tells a user what to type instead.
2616 var buf: [128]u8 = undefined;
2617 const text = std.fmt.bufPrint(&buf, "[bad host: {s}]", .{hosts.reason(e)}) catch "[bad host]";
2618 setNotice(shared, text);
2619 }
2620
2621 /// The sentence for host lines the wall has no room for; null when they all
2622 /// fit. A wall silently missing a machine the user WROTE DOWN is the lie the
2623 /// hosts file exists to stop telling — the session count's own argument.
2624 fn hostsOverCapacity(buf: []u8, listed: usize) ?[]const u8 {
2625 const over = listed -| max_tiles;
2626 if (over == 0) return null;
2627 return std.fmt.bufPrint(buf, "[+{d} host{s} in the file not shown]", .{
2628 over,
2629 if (over == 1) "" else "s",
2630 }) catch "[hosts in the file not shown]";
2631 }
2632
2633 /// The lowest slot a forgotten host has finished leaving. Both halves, for
2634 /// `freeSlot`'s reason: `forgotten` is the ask, `poller_done` is the answer.
2635 fn freeHostSlot(host_table: []const Host) ?usize {
2636 for (host_table, 0..) |*h, i| {
2637 if (h.forgotten.load(.acquire) and h.poller_done.load(.acquire)) return i;
2638 }
2639 return null;
2640 }
2641
2642 /// What the picker's `a` did. `listed` carries the row the spelling is
2643 /// ALREADY on: an unchanged popup and an unmoved selection is the prompt
2644 /// answering a user who typed a real host with nothing at all.
2645 const AddHost = union(enum) { added: usize, listed: usize, refused };
2646
2647 /// The picker's `a`, answered: the spelling names a DAEMON. Every arm sets
2648 /// the sentence it is; the caller only polls what it just made.
2649 fn addHost(
2650 alloc: std.mem.Allocator,
2651 shared: *Shared,
2652 host_table: []Host,
2653 hosts_live: *usize,
2654 spelling: []const u8,
2655 key: ?[]const u8,
2656 idle_ms: u32,
2657 path: ?[]const u8,
2658 ) AddHost {
2659 // No tile is born here: the host's first poll is what turns its
2660 // sessions into tiles, so the prompt cannot conjure a session that is
2661 // not there. Twice is once — a second poller on the same daemon would
2662 // tile every one of its sessions again.
2663 for (host_table[0..hosts_live.*], 0..) |*h, hi| {
2664 // A forgotten host is not on the wall, so its slot must not refuse
2665 // the spelling back: `x` then `a` on the same host is one of the
2666 // two things the picker is for.
2667 if (h.forgotten.load(.acquire)) continue;
2668 if (std.mem.eql(u8, h.spec.spelling, spelling)) {
2669 setNotice(shared, "[that host is already on the wall]");
2670 return .{ .listed = hi };
2671 }
2672 }
2673 // The prompt is `mux hosts add` typed from inside, so it refuses what
2674 // that refuses: `-A box` is a mistyped flag, not a host. Ahead of the
2675 // dupe, so the answer to a typo allocates nothing.
2676 if (wall.flagLike(spelling)) {
2677 badHost(shared, error.FlagLikeTarget);
2678 return .refused;
2679 }
2680 // Ahead of the dupe and the resolve, both of which allocate: a wall
2681 // that is full is full whatever the spelling turns out to mean, and a
2682 // prompt the user keeps re-opening must not leak a copy per refusal.
2683 //
2684 // A forgotten slot whose poller has left is free — `a` and `x` are what
2685 // the picker is FOR, and a table that only grew spent the wall after 32
2686 // of them however few rows were showing.
2687 const reuse = freeHostSlot(host_table[0..hosts_live.*]);
2688 if (reuse == null and hosts_live.* >= host_table.len) {
2689 setNotice(shared, "[no room on the wall for another host]");
2690 return .refused;
2691 }
2692 // The filter's buffer is the next read's; the table keeps this copy.
2693 const own = alloc.dupe(u8, spelling) catch {
2694 setNotice(shared, "[could not add that host]");
2695 return .refused;
2696 };
2697 const spec = resolveHost(alloc, own, key, idle_ms) catch |err| {
2698 alloc.free(own);
2699 badHost(shared, err);
2700 return .refused;
2701 };
2702 // The file before the table: a host the user is looking at and a host
2703 // they get back next time are the same host, and `mux hosts rm` is the
2704 // only way out of either.
2705 // The host is this wall's either way: a file that will not take the
2706 // line costs the user the NEXT wall, not this one.
2707 if (path) |p| if (recordHost(alloc, spec.target, spec.spelling, p)) |err| {
2708 var nb: [128]u8 = undefined;
2709 setNotice(shared, std.fmt.bufPrint(
2710 &nb,
2711 "[hosts file not updated: {s}]",
2712 .{hosts.reason(err)},
2713 ) catch "[hosts file not updated]");
2714 };
2715 const at = reuse orelse hosts_live.*;
2716 // The departed spec is not freed: `run`'s allocator is an arena that
2717 // never returns, exactly as the tile labels beside it are not freed.
2718 host_table[at] = .{
2719 .spec = spec,
2720 .shared = shared,
2721 .self_name = selfSession(spec.target, std.posix.getenv(proto.sock_env), std.posix.getenv(proto.session_env)),
2722 };
2723 if (reuse == null) hosts_live.* += 1;
2724 return .{ .added = at };
2725 }
2726
2727 /// How a tile spells itself on the wall: the wall grammar's own form, which 2530 /// How a tile spells itself on the wall: the wall grammar's own form, which
2728 /// is what keys the layout sidecar's leaves. 2531 /// is what keys the layout sidecar's leaves.
2729 /// 2532 ///
@@ -3000,46 +2803,14 @@ fn restoreLayoutFrom(
3000 return null; 2803 return null;
3001 } 2804 }
3002 2805
3003 /// A diff's plan, bounded by the wall's own capacity so a daemon with more
3004 /// sessions than the wall can hold is COUNTED rather than overrunning.
3005 fn Fixed(comptime T: type) type {
3006 return struct {
3007 items: [max_tiles]T = undefined,
3008 len: usize = 0,
3009 dropped: usize = 0,
3010
3011 fn append(self: *@This(), v: T) void {
3012 if (self.len == self.items.len) {
3013 self.dropped += 1;
3014 return;
3015 }
3016 self.items[self.len] = v;
3017 self.len += 1;
3018 }
3019
3020 fn get(self: *const @This(), i: usize) T {
3021 return self.items[i];
3022 }
3023 };
3024 }
3025
3026 const BirthNames = Fixed([]const u8);
3027 const TileIdxs = Fixed(usize);
3028
3029 /// Only a host's own sessions are its list's to keep or to drop.
3030 fn ownedBy(t: *const Tile, host: usize) bool {
3031 const h = t.host orelse return false;
3032 return h == host;
3033 }
3034
3035 /// The leaf a birth sits beside: `birthTile` inserts against a LEAF, and 2806 /// The leaf a birth sits beside: `birthTile` inserts against a LEAF, and
3036 /// the focus can be a hole. 2807 /// the focus can be a hole.
3037 fn anchorTile(present: []const bool, sel: usize) usize { 2808 pub fn anchorTile(present: []const bool, sel: usize) usize {
3038 if (sel < present.len and present[sel]) return sel; 2809 if (sel < present.len and present[sel]) return sel;
3039 return firstPresent(present) orelse 0; 2810 return firstPresent(present) orelse 0;
3040 } 2811 }
3041 2812
3042 fn firstPresent(present: []const bool) ?usize { 2813 pub fn firstPresent(present: []const bool) ?usize {
3043 for (present, 0..) |p, i| { 2814 for (present, 0..) |p, i| {
3044 if (p) return i; 2815 if (p) return i;
3045 } 2816 }
@@ -3047,114 +2818,12 @@ fn firstPresent(present: []const bool) ?usize {
3047 } 2818 }
3048 2819
3049 /// The session on this host that mux itself is running inside, if any. 2820 /// The session on this host that mux itself is running inside, if any.
3050 fn selfSession(target: client.Target, env_sock: ?[]const u8, env_session: ?[]const u8) ?[]const u8 { 2821 pub fn selfSession(target: client.Target, env_sock: ?[]const u8, env_session: ?[]const u8) ?[]const u8 {
3051 const es = env_session orelse return null; 2822 const es = env_session orelse return null;
3052 if (!showsSelf(target, es, env_sock, env_session)) return null; 2823 if (!showsSelf(target, es, env_sock, env_session)) return null;
3053 return proto.resolveName(es); 2824 return proto.resolveName(es);
3054 } 2825 }
3055 2826
3056 /// The whole of "a host's live sessions are its tiles", per HOST so that
3057 /// two daemons may share a session name. Pure: the rule is testable
3058 /// without a daemon.
3059 fn planHostDiff(
3060 tiles: []Tile,
3061 present: []const bool,
3062 live: usize,
3063 host: usize,
3064 list: []const u8,
3065 self_name: ?[]const u8,
3066 births: *BirthNames,
3067 vanish: *TileIdxs,
3068 ) void {
3069 var it = std.mem.splitScalar(u8, list, '\n');
3070 while (it.next()) |name| {
3071 if (name.len == 0) continue;
3072 // The trust boundary: a peer's reply is bounded only in TOTAL, so
3073 // one "name" in it can be 1056 bytes of anything. `encodeAttachNamed`
3074 // and `encodeEndReq` memcpy a birth's name into a 32-byte tail
3075 // behind an assert, which states a bug rather than filtering input.
3076 if (!proto.validSessionName(name)) continue;
3077 if (self_name) |self| if (std.mem.eql(u8, self, name)) continue;
3078 var found = false;
3079 for (tiles[0..live], present[0..live]) |*t, p| {
3080 if (p and ownedBy(t, host) and std.mem.eql(u8, proto.resolveName(t.r.session), name)) {
3081 found = true;
3082 break;
3083 }
3084 }
3085 if (!found) births.append(name);
3086 }
3087 for (tiles[0..live], present[0..live], 0..) |*t, p, i| {
3088 if (!p or !ownedBy(t, host)) continue;
3089 // A tile whose CREATING attach has not landed names a session the
3090 // daemon does not have yet, not one it dropped — and `pokeHost`
3091 // asks for this list at exactly that moment.
3092 if (t.creates and !t.ever_up.load(.acquire)) continue;
3093 var keep = false;
3094 var it2 = std.mem.splitScalar(u8, list, '\n');
3095 while (it2.next()) |name| {
3096 if (std.mem.eql(u8, proto.resolveName(t.r.session), name)) {
3097 keep = true;
3098 break;
3099 }
3100 }
3101 if (keep) {
3102 t.missed_once = false;
3103 continue;
3104 }
3105 // A LIVE pump gets one list's grace. The daemon drains a session's
3106 // exit_status before it clears the slot, but the poll's connect and
3107 // its pass through the daemon take milliseconds the pump can be
3108 // descheduled for — and on a wall of one, vanishing the tile first
3109 // loses the shell's exit code, because `endedTile` skips a tile that
3110 // is no longer present and mux stays up on an empty wall. A dead
3111 // pump has no code left to lose, so it goes at once.
3112 if (t.alive.load(.acquire) and !t.missed_once) {
3113 t.missed_once = true;
3114 continue;
3115 }
3116 vanish.append(i);
3117 }
3118 }
3119
3120 /// One daemon on the wall, as the run knows it: the poller writes, the
3121 /// keyboard reads.
3122 const Host = struct {
3123 spec: HostSpec,
3124 shared: *Shared,
3125 /// Never born as a tile here: see `showsSelf`.
3126 self_name: ?[]const u8 = null,
3127 /// A chord that births asks for the next poll NOW rather than in a
3128 /// second — the wall must not lag the session the user just made.
3129 poke: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
3130 list_mu: std.Thread.Mutex = .{},
3131 list: [proto.sessions_text_max]u8 = undefined,
3132 list_len: usize = 0,
3133 /// News for the keyboard: a poll finished, well or badly.
3134 list_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
3135 /// Whether a list of this host's has reached the WALL. Here rather than
3136 /// in an array beside the table, because the picker's `a` grows the
3137 /// table and an array sized when the wall opened is one index out of
3138 /// bounds per added host. Keyboard-thread only, so no lock: a poller flag read
3139 /// between its own two stores would restore over a wall still missing
3140 /// that host's sessions.
3141 applied: bool = false,
3142 reachable: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
3143 /// Forgotten in the picker: off the file, off the rows, and its poller
3144 /// exits for good. The SLOT stays — a poller thread holds this pointer
3145 /// and every tile's `host` indexes this array — so nothing compacts.
3146 forgotten: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
3147 /// `Tile.pump_done`'s twin, and `addHost` needs both halves for the
3148 /// same reason `freeSlot` does: a forgotten slot rewritten while its
3149 /// poller still holds the pointer is a thread dialling a replaced spec.
3150 poller_done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
3151 };
3152
3153 /// Polling, not a push: a subscription is a new daemon concept, and one
3154 /// small frame a second per host over a link that already carries deltas is
3155 /// not a cost worth designing around.
3156 const host_poll_ms: u64 = 1000;
3157
3158 /// The widest row the picker draws. A spelling past it is cut, never 2827 /// The widest row the picker draws. A spelling past it is cut, never
3159 /// wrapped: a host list that reflows is one a digit cannot address. 2828 /// wrapped: a host list that reflows is one a digit cannot address.
3160 const picker_row_max: usize = 128; 2829 const picker_row_max: usize = 128;
@@ -3423,7 +3092,7 @@ fn pickForget(
3423 } 3092 }
3424 h.forgotten.store(true, .release); 3093 h.forgotten.store(true, .release);
3425 for (0..live) |i| { 3094 for (0..live) |i| {
3426 if (present[i] and ownedBy(&tiles[i], sel)) 3095 if (present[i] and wall_host.ownedBy(&tiles[i], sel))
3427 vanishTile(tiles[0..live], present[0..live], shared, i, null); 3096 vanishTile(tiles[0..live], present[0..live], shared, i, null);
3428 } 3097 }
3429 var buf: [96]u8 = undefined; 3098 var buf: [96]u8 = undefined;
@@ -3631,221 +3300,6 @@ fn pickerRows(body: *PickerBody, host_table: []Host, sel: usize, cols: u16) void
3631 } 3300 }
3632 } 3301 }
3633 3302
3634 /// How long before this host is asked again, given the link that answered.
3635 fn pollDelayMs(link: std.meta.Tag(client.Link)) u64 {
3636 // A pipe link cost a whole sshd login: the cached QUIC coordinates were
3637 // dead or blocked, so this cycle spawned `ssh`, read the announce and
3638 // killed it. A second of that, forever, is a remote auth log the wall
3639 // wrote — the list is worth a tenth of the freshness.
3640 return if (link == .pipe) host_poll_ms * 10 else host_poll_ms;
3641 }
3642
3643 fn pollHost(h: *Host) void {
3644 var out: [proto.sessions_text_max]u8 = undefined;
3645 while (h.shared.running.load(.acquire) and !h.forgotten.load(.acquire)) {
3646 // A connection of its own per poll: the observer idle deadline and
3647 // the redial backoff stay the pump's problem, and this thread owns
3648 // no transport between polls that a teardown would have to reach.
3649 var link: std.meta.Tag(client.Link) = .fd;
3650 const got = client.listSessions(std.heap.page_allocator, h.spec.poll_target, &out, 2000, &link) catch null;
3651 if (got) |list| {
3652 h.list_mu.lock();
3653 @memcpy(h.list[0..list.len], list);
3654 h.list_len = list.len;
3655 h.list_mu.unlock();
3656 h.reachable.store(true, .release);
3657 } else h.reachable.store(false, .release);
3658 h.list_ready.store(true, .release);
3659 ringKeyboard(h.shared);
3660 var slept: u64 = 0;
3661 const wait = pollDelayMs(link);
3662 // A chord that births still pokes through it, so the stretched wait
3663 // costs a user's own action nothing.
3664 while (slept < wait and
3665 !h.poke.swap(false, .acq_rel) and
3666 !h.forgotten.load(.acquire) and
3667 h.shared.running.load(.acquire)) : (slept += 50)
3668 std.Thread.sleep(50 * std.time.ns_per_ms);
3669 }
3670 // Last: past here nothing reads `h`, which is what lets `addHost` take
3671 // the slot back.
3672 h.poller_done.store(true, .release);
3673 }
3674
3675 /// A chord that births asks its host for a list NOW: a session made by
3676 /// `c` or `:` must not wait out the poll interval to become a tile.
3677 fn pokeHost(host_table: []Host, t: *const Tile) void {
3678 const hi = t.host orelse return;
3679 if (hi < host_table.len) host_table[hi].poke.store(true, .release);
3680 }
3681
3682 /// Every host with news, applied to the wall. True when any reported.
3683 fn applyReadyLists(
3684 alloc: std.mem.Allocator,
3685 tiles: []Tile,
3686 present: []bool,
3687 live: *usize,
3688 shared: *Shared,
3689 host_table: []Host,
3690 ) bool {
3691 var news = false;
3692 for (host_table, 0..) |*h, hi| {
3693 if (!h.list_ready.swap(false, .acq_rel)) continue;
3694 news = true;
3695 // A poll already in flight when the host was forgotten still lands.
3696 // Applying it would re-birth the tiles the forget just took off the
3697 // wall, one poll later.
3698 if (h.forgotten.load(.acquire)) continue;
3699 applyHostList(alloc, tiles, present, live, shared, host_table, hi);
3700 h.applied = true;
3701 }
3702 return news;
3703 }
3704
3705 /// One host's list, applied to the wall. The keyboard thread only: it is
3706 /// the single writer of the tile array and the layout tree.
3707 fn applyHostList(
3708 alloc: std.mem.Allocator,
3709 tiles: []Tile,
3710 present: []bool,
3711 live: *usize,
3712 shared: *Shared,
3713 host_table: []Host,
3714 hi: usize,
3715 ) void {
3716 const h = &host_table[hi];
3717 const reachable = h.reachable.load(.acquire);
3718 var list_buf: [proto.sessions_text_max]u8 = undefined;
3719 var list: []const u8 = "";
3720 if (reachable) {
3721 h.list_mu.lock();
3722 @memcpy(list_buf[0..h.list_len], h.list[0..h.list_len]);
3723 list = list_buf[0..h.list_len];
3724 h.list_mu.unlock();
3725 }
3726 // Whether the focus was on a real tile when this list arrived. An empty
3727 // wall has none, and a vanish can take the one there was — either way
3728 // the wall owes the tile it ends up with a `setFocus`, which is the only
3729 // thing that arms a claim.
3730 const had_focus = shared.sel < live.* and present[shared.sel];
3731 var changed = false;
3732 // Only a list DRIVES the diff. A host that has gone quiet keeps its
3733 // tiles, which reconnect on their own; vanishing them on a failed poll
3734 // would tear a wall down over one dropped packet.
3735 if (reachable) {
3736 var births = BirthNames{};
3737 var vanish = TileIdxs{};
3738 planHostDiff(tiles[0..live.*], present[0..live.*], live.*, hi, list, h.self_name, &births, &vanish);
3739 for (vanish.items[0..vanish.len]) |v| {
3740 vanishTile(tiles[0..live.*], present[0..live.*], shared, v, null);
3741 changed = true;
3742 }
3743 var placed: usize = 0;
3744 // The tile the NEXT birth sits beside: the focus for the first, then
3745 // the one just born. `insert` puts a new leaf immediately after its
3746 // anchor, so anchoring every birth at the focus would lay a list of
3747 // {b, c} out as c, b — a wall reading back-to-front against the
3748 // order its daemon reported, and against the digits the chords use.
3749 var anchor = anchorTile(present[0..live.*], shared.sel);
3750 // Stops at the FIRST refusal rather than retrying each name: the
3751 // wall refuses for a reason that holds for the whole list (no slot,
3752 // no room to cut), and this list comes back every second — a
3753 // per-name retry is an insert, a flatten and an undo per name per
3754 // poll, forever.
3755 while (placed < births.len) : (placed += 1) {
3756 const at = birthTile(alloc, tiles, present, live, shared, .{
3757 // Joins, never creates: the daemon already has this session,
3758 // and a sized attach on a live one would resize somebody.
3759 // The name is this poll's reply buffer until the wall takes
3760 // the tile — see `Birth.borrowed`.
3761 .r = .{ .target = h.spec.target, .label = "", .session = births.get(placed) },
3762 .from = anchor,
3763 .place = .beside_focus,
3764 .creates = false,
3765 .born_from = null,
3766 .host = hi,
3767 .borrowed = true,
3768 }) orelse break;
3769 anchor = at;
3770 spawnPump(&tiles[at]);
3771 changed = true;
3772 }
3773 const unplaced = births.dropped + (births.len - placed);
3774 // Said out loud rather than dropped: a wall showing a PREFIX of a
3775 // daemon's sessions is a wall lying about what it is.
3776 if (unplaced > 0) {
3777 var buf: [48]u8 = undefined;
3778 setNoticeIdle(shared, std.fmt.bufPrint(&buf, "[+{d} not shown]", .{unplaced}) catch "[not shown]");
3779 }
3780 }
3781 if ((!had_focus or shared.sel >= live.* or !present[shared.sel]) and
3782 presentCount(present[0..live.*]) > 0)
3783 setFocus(tiles[0..live.*], shared, firstPresent(present[0..live.*]) orelse 0);
3784 if (changed) relayout(alloc, tiles[0..live.*], present[0..live.*], shared, shared.sel);
3785 }
3786
3787 /// The host grammar's own spelling of a target, for a wall entered by
3788 /// `mux TARGET` rather than off the file: the sidecar's key and the line
3789 /// `mux hosts` prints have to read like the one that would have named it.
3790 fn hostSpelling(alloc: std.mem.Allocator, target: client.Target) ![]const u8 {
3791 return switch (target) {
3792 .sock => |p| try std.fmt.allocPrint(alloc, "--sock {s}", .{p}),
3793 .hand => |h| try alloc.dupe(u8, h.host),
3794 .quic => |q| try std.fmt.allocPrint(alloc, "quic://{s}", .{q.host_port}),
3795 // `--via` has no form in that grammar — an arbitrary command is not
3796 // an address — so the label is honest and is not a spelling.
3797 .via => |c| try std.fmt.allocPrint(alloc, "--via {s}", .{c}),
3798 };
3799 }
3800
3801 /// The hosts file is what the user asked to SEE, not what answered.
3802 fn recordHost(
3803 alloc: std.mem.Allocator,
3804 target: client.Target,
3805 spelling: []const u8,
3806 path: []const u8,
3807 ) ?anyerror {
3808 // Both doors — `mux HOST` and the picker's `a` — write the line on the
3809 // user's word: a daemon that never answers is a host the file still remembers,
3810 // rather than a line missing from it.
3811 //
3812 // The failure comes BACK rather than being printed, because where it may
3813 // be SAID differs by door: stderr before the wall takes the screen, a
3814 // notice after.
3815 //
3816 // `--via` has no form in the host grammar — an arbitrary command is not
3817 // an address — so an attach over one records nothing, and silently.
3818 if (target == .via) return null;
3819 _ = hosts.record(alloc, path, spelling) catch |err| return err;
3820 return null;
3821 }
3822
3823 /// The rest of the file, after the host the user named.
3824 fn otherHosts(
3825 alloc: std.mem.Allocator,
3826 specs: *std.ArrayList(HostSpec),
3827 first: []const u8,
3828 path: []const u8,
3829 key: ?[]const u8,
3830 idle_ms: u32,
3831 ) void {
3832 const h = hosts.load(alloc, path) catch |err| {
3833 std.debug.print("mux: hosts file ignored ({s}): {s}\n", .{ path, hosts.reason(err) });
3834 return;
3835 };
3836 for (h.lines.items) |line| {
3837 if (std.mem.eql(u8, line, first)) continue;
3838 const spec = resolveHost(alloc, line, key, idle_ms) catch |err| {
3839 // Said and skipped, not refused: what was asked for here is a
3840 // session, and it is already open. Only bare `mux`, where the
3841 // wall itself is the ask, turns a bad line into an exit code.
3842 std.debug.print("mux: bad host '{s}': {s}\n", .{ line, hosts.reason(err) });
3843 continue;
3844 };
3845 specs.append(alloc, spec) catch return;
3846 }
3847 }
3848
3849 /// The DIAL is on the main thread, before any wall: ssh can want the tty. 3303 /// The DIAL is on the main thread, before any wall: ssh can want the tty.
3850 /// The pump ADOPTS a link that is already up. 3304 /// The pump ADOPTS a link that is already up.
3851 pub fn runAttach( 3305 pub fn runAttach(
@@ -3880,7 +3334,7 @@ pub fn runAttach(
3880 var arena_state = std.heap.ArenaAllocator.init(alloc); 3334 var arena_state = std.heap.ArenaAllocator.init(alloc);
3881 defer arena_state.deinit(); 3335 defer arena_state.deinit();
3882 const arena = arena_state.allocator(); 3336 const arena = arena_state.allocator();
3883 const spelling = try hostSpelling(arena, target); 3337 const spelling = try wall_host.hostSpelling(arena, target);
3884 // The ask is SPENT: the open above is the dial the user waited for, 3338 // The ask is SPENT: the open above is the dial the user waited for,
3885 // and everything downstream of this spec — the poller, the tiles the 3339 // and everything downstream of this spec — the poller, the tiles the
3886 // host's own list births, the entry tile's reconnects — is the wall 3340 // host's own list births, the entry tile's reconnects — is the wall
@@ -3895,14 +3349,14 @@ pub fn runAttach(
3895 // can prompt, which is worse, but a wall that refuses to open over 3349 // can prompt, which is worse, but a wall that refuses to open over
3896 // one allocation is worse still. Unasked either way — a poll that 3350 // one allocation is worse still. Unasked either way — a poll that
3897 // could start a daemon is not a lesser evil, it is the bug. 3351 // could start a daemon is not a lesser evil, it is the bug.
3898 .poll_target = pollTargetFor(arena, spec_target) catch spec_target, 3352 .poll_target = wall_host.pollTargetFor(arena, spec_target) catch spec_target,
3899 }); 3353 });
3900 if (hosts.statePath(arena) catch null) |path| { 3354 if (hosts.statePath(arena) catch null) |path| {
3901 // stderr, not a notice: this runs before `run` takes the screen. 3355 // stderr, not a notice: this runs before `run` takes the screen.
3902 if (recordHost(arena, target, spelling, path)) |err| 3356 if (wall_host.recordHost(arena, target, spelling, path)) |err|
3903 std.debug.print("mux: hosts file not updated ({s}): {s}\n", .{ path, hosts.reason(err) }); 3357 std.debug.print("mux: hosts file not updated ({s}): {s}\n", .{ path, hosts.reason(err) });
3904 if (!headless(std.posix.STDOUT_FILENO)) 3358 if (!headless(std.posix.STDOUT_FILENO))
3905 otherHosts(arena, &specs, spelling, path, key, idle_ms); 3359 wall_host.otherHosts(arena, &specs, spelling, path, key, idle_ms);
3906 } 3360 }
3907 return run(alloc, specs.items, .{ 3361 return run(alloc, specs.items, .{
3908 .focus0 = true, 3362 .focus0 = true,
@@ -4124,7 +3578,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
4124 const host_table = try alloc.alloc(Host, max_tiles); 3578 const host_table = try alloc.alloc(Host, max_tiles);
4125 var hosts_live: usize = @min(host_specs.len, max_tiles); 3579 var hosts_live: usize = @min(host_specs.len, max_tiles);
4126 var over_buf: [64]u8 = undefined; 3580 var over_buf: [64]u8 = undefined;
4127 if (hostsOverCapacity(&over_buf, host_specs.len)) |said| setNotice(&shared, said); 3581 if (wall_host.hostsOverCapacity(&over_buf, host_specs.len)) |said| setNotice(&shared, said);
4128 for (host_table[0..hosts_live], host_specs[0..hosts_live]) |*h, spec| { 3582 for (host_table[0..hosts_live], host_specs[0..hosts_live]) |*h, spec| {
4129 h.* = .{ 3583 h.* = .{
4130 .spec = spec, 3584 .spec = spec,
@@ -4142,7 +3596,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
4142 h.poller_done.store(true, .release); 3596 h.poller_done.store(true, .release);
4143 continue; 3597 continue;
4144 } 3598 }
4145 const th = std.Thread.spawn(.{}, pollHost, .{h}) catch { 3599 const th = std.Thread.spawn(.{}, wall_host.pollHost, .{h}) catch {
4146 h.poller_done.store(true, .release); 3600 h.poller_done.store(true, .release);
4147 continue; 3601 continue;
4148 }; 3602 };
@@ -4262,7 +3716,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
4262 .full => setNotice(&shared, "[no room on the wall for another tile]"), 3716 .full => setNotice(&shared, "[no room on the wall for another tile]"),
4263 .stay => {}, 3717 .stay => {},
4264 } 3718 }
4265 pokeHost(host_table[0..hosts_live], &tiles[z]); 3719 wall_host.pokeHost(host_table[0..hosts_live], &tiles[z]);
4266 } 3720 }
4267 } 3721 }
4268 // Ends are read every pass, not only on the bell: two pumps dying 3722 // Ends are read every pass, not only on the bell: two pumps dying
@@ -4308,7 +3762,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
4308 // Ends first, lists second: a session that exited is its pump's 3762 // Ends first, lists second: a session that exited is its pump's
4309 // news and arrives at once, while a list is up to a poll behind. 3763 // news and arrives at once, while a list is up to a poll behind.
4310 // Reading the list first would vanish the tile the exit code is on. 3764 // Reading the list first would vanish the tile the exit code is on.
4311 const host_news = applyReadyLists(alloc, tiles, present, &live, &shared, host_table[0..hosts_live]); 3765 const host_news = wall_host.applyReadyLists(alloc, tiles, present, &live, &shared, host_table[0..hosts_live]);
4312 if (!restore_tried) { 3766 if (!restore_tried) {
4313 var all_reported = true; 3767 var all_reported = true;
4314 for (host_table[0..opening_hosts]) |*h| { 3768 for (host_table[0..opening_hosts]) |*h| {
@@ -4435,7 +3889,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
4435 hosts_path, 3889 hosts_path,
4436 ), 3890 ),
4437 .add_tile => |spelling| { 3891 .add_tile => |spelling| {
4438 switch (addHost( 3892 switch (wall_host.addHost(
4439 alloc, 3893 alloc,
4440 &shared, 3894 &shared,
4441 host_table, 3895 host_table,
@@ -4446,7 +3900,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
4446 hosts_path, 3900 hosts_path,
4447 )) { 3901 )) {
4448 .added => |hi| { 3902 .added => |hi| {
4449 const th = std.Thread.spawn(.{}, pollHost, .{&host_table[hi]}) catch null; 3903 const th = std.Thread.spawn(.{}, wall_host.pollHost, .{&host_table[hi]}) catch null;
4450 if (th) |handle| handle.detach(); 3904 if (th) |handle| handle.detach();
4451 // The row the user just made is the row they meant. 3905 // The row the user just made is the row they meant.
4452 picker_sel = hi; 3906 picker_sel = hi;
@@ -4605,7 +4059,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
4605 }; 4059 };
4606 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.new), .release); 4060 tiles[z].ask.store(@intFromEnum(client.SwitchIntent.new), .release);
4607 if (ringLive(&tiles[z])) { 4061 if (ringLive(&tiles[z])) {
4608 pokeHost(host_table[0..hosts_live], &tiles[z]); 4062 wall_host.pokeHost(host_table[0..hosts_live], &tiles[z]);
4609 } else { 4063 } else {
4610 setNotice(&shared, "[no live session here - Ctrl-\\ s picks a host]"); 4064 setNotice(&shared, "[no live session here - Ctrl-\\ s picks a host]");
4611 showRefusal(tiles[0..live], &shared, z); 4065 showRefusal(tiles[0..live], &shared, z);
@@ -4866,25 +4320,25 @@ test "planHostDiff: names the daemon has and the wall does not are births, tiles
4866 var vanish = TileIdxs{}; 4320 var vanish = TileIdxs{};
4867 // The birth is immediate; the vanish waits for a second list to agree 4321 // The birth is immediate; the vanish waits for a second list to agree
4868 // (see the grace test below), so this list is asked twice. 4322 // (see the grace test below), so this list is asked twice.
4869 planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &births, &vanish); 4323 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &births, &vanish);
4870 try std.testing.expectEqual(@as(usize, 1), births.len); 4324 try std.testing.expectEqual(@as(usize, 1), births.len);
4871 try std.testing.expectEqualStrings("c", births.get(0)); 4325 try std.testing.expectEqualStrings("c", births.get(0));
4872 births.len = 0; 4326 births.len = 0;
4873 planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &births, &vanish); 4327 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &births, &vanish);
4874 try std.testing.expectEqual(@as(usize, 1), vanish.len); 4328 try std.testing.expectEqual(@as(usize, 1), vanish.len);
4875 try std.testing.expectEqual(@as(usize, 1), vanish.get(0)); 4329 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
4876 4330
4877 // Host 1's "a" survives host 0's list saying nothing about it. 4331 // Host 1's "a" survives host 0's list saying nothing about it.
4878 births.len = 0; 4332 births.len = 0;
4879 vanish.len = 0; 4333 vanish.len = 0;
4880 planHostDiff(&tiles, &present, 3, 1, "a\n", null, &births, &vanish); 4334 wall_host.planHostDiff(&tiles, &present, 3, 1, "a\n", null, &births, &vanish);
4881 try std.testing.expectEqual(@as(usize, 0), births.len + vanish.len); 4335 try std.testing.expectEqual(@as(usize, 0), births.len + vanish.len);
4882 4336
4883 // An empty list vanishes everything the host had, once every tile has 4337 // An empty list vanishes everything the host had, once every tile has
4884 // spent the grace. 4338 // spent the grace.
4885 planHostDiff(&tiles, &present, 3, 0, "", null, &births, &vanish); 4339 wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &vanish);
4886 vanish.len = 0; 4340 vanish.len = 0;
4887 planHostDiff(&tiles, &present, 3, 0, "", null, &births, &vanish); 4341 wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &births, &vanish);
4888 try std.testing.expectEqual(@as(usize, 2), vanish.len); 4342 try std.testing.expectEqual(@as(usize, 2), vanish.len);
4889 try std.testing.expectEqual(@as(usize, 0), births.len); 4343 try std.testing.expectEqual(@as(usize, 0), births.len);
4890 } 4344 }
@@ -4900,19 +4354,19 @@ test "planHostDiff: a live pump's tile is not vanished by ONE list that lacks it
4900 // the name while the pump has not yet read its `exit_status`. Vanishing 4354 // the name while the pump has not yet read its `exit_status`. Vanishing
4901 // on that list loses the exit code — on a wall of one, `endedTile` skips 4355 // on that list loses the exit code — on a wall of one, `endedTile` skips
4902 // a tile that is no longer present and mux never leaves. 4356 // a tile that is no longer present and mux never leaves.
4903 planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 4357 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
4904 try std.testing.expectEqual(@as(usize, 0), vanish.len); 4358 try std.testing.expectEqual(@as(usize, 0), vanish.len);
4905 try std.testing.expect(tiles[1].missed_once); 4359 try std.testing.expect(tiles[1].missed_once);
4906 4360
4907 // A list that names it again forgives it: a tile does not accumulate 4361 // A list that names it again forgives it: a tile does not accumulate
4908 // misses across the seconds it is legitimately live. 4362 // misses across the seconds it is legitimately live.
4909 planHostDiff(&tiles, &present, 3, 0, "a\nb\n", null, &births, &vanish); 4363 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\n", null, &births, &vanish);
4910 try std.testing.expectEqual(@as(usize, 0), vanish.len); 4364 try std.testing.expectEqual(@as(usize, 0), vanish.len);
4911 try std.testing.expect(!tiles[1].missed_once); 4365 try std.testing.expect(!tiles[1].missed_once);
4912 4366
4913 // Two consecutive lists without it, and it goes. 4367 // Two consecutive lists without it, and it goes.
4914 planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 4368 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
4915 planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 4369 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
4916 try std.testing.expectEqual(@as(usize, 1), vanish.len); 4370 try std.testing.expectEqual(@as(usize, 1), vanish.len);
4917 try std.testing.expectEqual(@as(usize, 1), vanish.get(0)); 4371 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
4918 4372
@@ -4921,7 +4375,7 @@ test "planHostDiff: a live pump's tile is not vanished by ONE list that lacks it
4921 tiles[0].alive.store(false, .release); 4375 tiles[0].alive.store(false, .release);
4922 vanish.len = 0; 4376 vanish.len = 0;
4923 const only0 = [_]bool{ true, false, false }; 4377 const only0 = [_]bool{ true, false, false };
4924 planHostDiff(&tiles, &only0, 3, 0, "", null, &births, &vanish); 4378 wall_host.planHostDiff(&tiles, &only0, 3, 0, "", null, &births, &vanish);
4925 try std.testing.expectEqual(@as(usize, 1), vanish.len); 4379 try std.testing.expectEqual(@as(usize, 1), vanish.len);
4926 try std.testing.expectEqual(@as(usize, 0), vanish.get(0)); 4380 try std.testing.expectEqual(@as(usize, 0), vanish.get(0));
4927 } 4381 }
@@ -4933,7 +4387,7 @@ test "planHostDiff: a vanished tile is not present, so the next list does not va
4933 4387
4934 var births = BirthNames{}; 4388 var births = BirthNames{};
4935 var vanish = TileIdxs{}; 4389 var vanish = TileIdxs{};
4936 planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 4390 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
4937 try std.testing.expectEqual(@as(usize, 0), vanish.len); 4391 try std.testing.expectEqual(@as(usize, 0), vanish.len);
4938 // ...and the name is not reborn either: the tile is gone, but the 4392 // ...and the name is not reborn either: the tile is gone, but the
4939 // daemon no longer lists it, so there is nothing to bring back. 4393 // daemon no longer lists it, so there is nothing to bring back.
@@ -4949,7 +4403,7 @@ test "planHostDiff: the session this shell is inside is never born as a tile" {
4949 var vanish = TileIdxs{}; 4403 var vanish = TileIdxs{};
4950 // The daemon has "a", "b" and "self"; "self" is the shell mux runs in, 4404 // The daemon has "a", "b" and "self"; "self" is the shell mux runs in,
4951 // so a tile of it would paint into the grid it is reading. 4405 // so a tile of it would paint into the grid it is reading.
4952 planHostDiff(&tiles, &present, 3, 0, "a\nb\nself\n", "self", &births, &vanish); 4406 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\nself\n", "self", &births, &vanish);
4953 try std.testing.expectEqual(@as(usize, 0), births.len); 4407 try std.testing.expectEqual(@as(usize, 0), births.len);
4954 try std.testing.expectEqual(@as(usize, 0), vanish.len); 4408 try std.testing.expectEqual(@as(usize, 0), vanish.len);
4955 } 4409 }
@@ -4967,7 +4421,7 @@ test "planHostDiff: a name the wire grammar refuses never becomes a tile, howeve
4967 // where they become a tile, not asserted about at the wire. 4421 // where they become a tile, not asserted about at the wire.
4968 const over_long = "x" ** (proto.session_name_max + 1); 4422 const over_long = "x" ** (proto.session_name_max + 1);
4969 const list = "a\n" ++ over_long ++ "\nhas space\n\x1b[2J\nc\n"; 4423 const list = "a\n" ++ over_long ++ "\nhas space\n\x1b[2J\nc\n";
4970 planHostDiff(&tiles, &present, 3, 0, list, null, &births, &vanish); 4424 wall_host.planHostDiff(&tiles, &present, 3, 0, list, null, &births, &vanish);
4971 try std.testing.expectEqual(@as(usize, 1), births.len); 4425 try std.testing.expectEqual(@as(usize, 1), births.len);
4972 try std.testing.expectEqualStrings("c", births.get(0)); 4426 try std.testing.expectEqualStrings("c", births.get(0));
4973 } 4427 }
@@ -5031,7 +4485,7 @@ test "applyReadyLists: a host added after the wall opened gets its sessions, and
5031 setList(&table[2], "late\n"); 4485 setList(&table[2], "late\n");
5032 table[2].list_ready.store(true, .release); 4486 table[2].list_ready.store(true, .release);
5033 4487
5034 _ = applyReadyLists(alloc, &tiles, &present, &live, &shared, table[0..3]); 4488 _ = wall_host.applyReadyLists(alloc, &tiles, &present, &live, &shared, table[0..3]);
5035 4489
5036 // The added host's session is a tile, and only the host that reported is 4490 // The added host's session is a tile, and only the host that reported is
5037 // marked: the two that opened the wall are still owed a first list. 4491 // marked: the two that opened the wall are still owed a first list.
@@ -5685,11 +5139,11 @@ test "recordHost: a file it cannot write comes BACK — the wall may be on the a
5685 try wall.saveBytes(blocker, "x"); 5139 try wall.saveBytes(blocker, "x");
5686 const bad = try std.fmt.bufPrint(&bad_buf, "{s}/notadir/hosts", .{tmp.path()}); 5140 const bad = try std.fmt.bufPrint(&bad_buf, "{s}/notadir/hosts", .{tmp.path()});
5687 const ok = try std.fmt.bufPrint(&ok_buf, "{s}/hosts", .{tmp.path()}); 5141 const ok = try std.fmt.bufPrint(&ok_buf, "{s}/hosts", .{tmp.path()});
5688 try std.testing.expect(recordHost(alloc, .{ .sock = "/a" }, "--sock /a", bad) != null); 5142 try std.testing.expect(wall_host.recordHost(alloc, .{ .sock = "/a" }, "--sock /a", bad) != null);
5689 try std.testing.expect(recordHost(alloc, .{ .sock = "/a" }, "--sock /a", ok) == null); 5143 try std.testing.expect(wall_host.recordHost(alloc, .{ .sock = "/a" }, "--sock /a", ok) == null);
5690 // `--via` has no form in the host grammar, and writing nothing down for 5144 // `--via` has no form in the host grammar, and writing nothing down for
5691 // it is not a failure to report. 5145 // it is not a failure to report.
5692 try std.testing.expect(recordHost(alloc, .{ .via = "ssh h muxd proxy" }, "x", bad) == null); 5146 try std.testing.expect(wall_host.recordHost(alloc, .{ .via = "ssh h muxd proxy" }, "x", bad) == null);
5693 } 5147 }
5694 5148
5695 test "applyHostList: a host with no live session gets no tile — the wall shows sessions only" { 5149 test "applyHostList: a host with no live session gets no tile — the wall shows sessions only" {
@@ -5713,8 +5167,8 @@ test "applyHostList: a host with no live session gets no tile — the wall shows
5713 5167
5714 // Twice, because a placeholder that is born once is still a placeholder. 5168 // Twice, because a placeholder that is born once is still a placeholder.
5715 for (0..2) |_| { 5169 for (0..2) |_| {
5716 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0); 5170 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
5717 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 1); 5171 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 1);
5718 } 5172 }
5719 5173
5720 try std.testing.expectEqual(@as(usize, 0), live); 5174 try std.testing.expectEqual(@as(usize, 0), live);
@@ -5722,7 +5176,7 @@ test "applyHostList: a host with no live session gets no tile — the wall shows
5722 5176
5723 // A session on the empty host, and the wall has exactly the one tile. 5177 // A session on the empty host, and the wall has exactly the one tile.
5724 setList(&table[1], "a\n"); 5178 setList(&table[1], "a\n");
5725 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 1); 5179 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 1);
5726 5180
5727 try std.testing.expectEqual(@as(usize, 1), presentCount(present[0..live])); 5181 try std.testing.expectEqual(@as(usize, 1), presentCount(present[0..live]));
5728 try std.testing.expectEqualStrings("a", tiles[0].r.session); 5182 try std.testing.expectEqualStrings("a", tiles[0].r.session);
@@ -5743,7 +5197,7 @@ test "applyHostList: a live tile survives one list that lost its session, and go
5743 // grace is per tile and not a wall-wide pause. 5197 // grace is per tile and not a wall-wide pause.
5744 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")}; 5198 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")};
5745 setList(&table[0], "a\nb\n"); 5199 setList(&table[0], "a\nb\n");
5746 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0); 5200 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
5747 try std.testing.expectEqual(@as(usize, 2), live); 5201 try std.testing.expectEqual(@as(usize, 2), live);
5748 5202
5749 // The wall's pumps are stopped in this fixture, so the liveness the rule 5203 // The wall's pumps are stopped in this fixture, so the liveness the rule
@@ -5751,13 +5205,13 @@ test "applyHostList: a live tile survives one list that lost its session, and go
5751 tiles[0].alive.store(true, .release); 5205 tiles[0].alive.store(true, .release);
5752 tiles[1].alive.store(true, .release); 5206 tiles[1].alive.store(true, .release);
5753 setList(&table[0], "a\n"); 5207 setList(&table[0], "a\n");
5754 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0); 5208 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
5755 // Still there: this list may have overtaken an `exit_status` the pump 5209 // Still there: this list may have overtaken an `exit_status` the pump
5756 // has not read yet, and that code is the run's own on a wall of one. 5210 // has not read yet, and that code is the run's own on a wall of one.
5757 try std.testing.expect(present[1]); 5211 try std.testing.expect(present[1]);
5758 try std.testing.expect(present[0]); 5212 try std.testing.expect(present[0]);
5759 5213
5760 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0); 5214 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
5761 try std.testing.expect(!present[1]); 5215 try std.testing.expect(!present[1]);
5762 try std.testing.expect(present[0]); 5216 try std.testing.expect(present[0]);
5763 } 5217 }
@@ -5775,7 +5229,7 @@ test "applyHostList: two sessions on an empty wall are two tiles, the first focu
5775 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")}; 5229 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")};
5776 setList(&table[0], "a\nb\n"); 5230 setList(&table[0], "a\nb\n");
5777 5231
5778 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0); 5232 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
5779 5233
5780 // The first birth has no leaf to sit beside — an empty tree takes it as 5234 // The first birth has no leaf to sit beside — an empty tree takes it as
5781 // its root, and the second inserts against it. 5235 // its root, and the second inserts against it.
@@ -5805,7 +5259,7 @@ test "applyHostList: one list's tiles are laid out in the order the daemon repor
5805 // its two siblings. 5259 // its two siblings.
5806 setList(&table[0], "a\nb\nc\n"); 5260 setList(&table[0], "a\nb\nc\n");
5807 5261
5808 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0); 5262 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
5809 5263
5810 try std.testing.expectEqual(@as(usize, 3), live); 5264 try std.testing.expectEqual(@as(usize, 3), live);
5811 // Down the screen in the daemon's own order. The tile ARRAY is in that 5265 // Down the screen in the daemon's own order. The tile ARRAY is in that
@@ -5853,7 +5307,7 @@ test "applyHostList: sessions past the wall's capacity are counted in the notice
5853 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")}; 5307 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")};
5854 setList(&table[0], text.items); 5308 setList(&table[0], text.items);
5855 5309
5856 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0); 5310 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
5857 5311
5858 try std.testing.expectEqual(@as(usize, max_tiles), live); 5312 try std.testing.expectEqual(@as(usize, max_tiles), live);
5859 var buf: [96]u8 = undefined; 5313 var buf: [96]u8 = undefined;
@@ -5885,7 +5339,7 @@ test "applyHostList: a wall too thin for a second pane takes what fits and retai
5885 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")}; 5339 var table = [_]Host{testHost(&shared, "box", "/tmp/box.sock")};
5886 setList(&table[0], "a\nb\n"); 5340 setList(&table[0], "a\nb\n");
5887 5341
5888 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0); 5342 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
5889 5343
5890 try std.testing.expectEqual(@as(usize, 1), live); 5344 try std.testing.expectEqual(@as(usize, 1), live);
5891 try std.testing.expectEqualStrings("a", tiles[0].r.session); 5345 try std.testing.expectEqualStrings("a", tiles[0].r.session);
@@ -5919,7 +5373,7 @@ test "applyHostList: when a host's last session goes the wall empties, and nothi
5919 // The daemon has nothing left: a restart, or another client ended it. 5373 // The daemon has nothing left: a restart, or another client ended it.
5920 setList(&table[0], ""); 5374 setList(&table[0], "");
5921 5375
5922 applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0); 5376 wall_host.applyHostList(alloc, &tiles, &present, &live, &shared, &table, 0);
5923 5377
5924 // The host is up with nothing on it, which the wall says by showing 5378 // The host is up with nothing on it, which the wall says by showing
5925 // nothing: no placeholder tile takes the gone session's place. 5379 // nothing: no placeholder tile takes the gone session's place.
@@ -5938,15 +5392,15 @@ test "planHostDiff: a creating tile whose attach has not landed yet is not vanis
5938 5392
5939 var births = BirthNames{}; 5393 var births = BirthNames{};
5940 var vanish = TileIdxs{}; 5394 var vanish = TileIdxs{};
5941 planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 5395 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
5942 try std.testing.expectEqual(@as(usize, 0), vanish.len); 5396 try std.testing.expectEqual(@as(usize, 0), vanish.len);
5943 5397
5944 // ...and once its session exists, the same list does drop it — after 5398 // ...and once its session exists, the same list does drop it — after
5945 // the one list of grace a live pump gets: a session the daemon HAD and 5399 // the one list of grace a live pump gets: a session the daemon HAD and
5946 // no longer lists is gone. 5400 // no longer lists is gone.
5947 tiles[1].ever_up.store(true, .release); 5401 tiles[1].ever_up.store(true, .release);
5948 planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 5402 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
5949 planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish); 5403 wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &births, &vanish);
5950 try std.testing.expectEqual(@as(usize, 1), vanish.len); 5404 try std.testing.expectEqual(@as(usize, 1), vanish.len);
5951 try std.testing.expectEqual(@as(usize, 1), vanish.get(0)); 5405 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
5952 } 5406 }
@@ -5969,15 +5423,15 @@ test "setNoticeIdle: a standing condition waits — a refusal the user just earn
5969 test "hostsOverCapacity: a hosts file past the wall's room is counted out loud, not cut in silence" { 5423 test "hostsOverCapacity: a hosts file past the wall's room is counted out loud, not cut in silence" {
5970 var buf: [64]u8 = undefined; 5424 var buf: [64]u8 = undefined;
5971 // Exactly full is not over: the boundary is where a silent cut starts. 5425 // Exactly full is not over: the boundary is where a silent cut starts.
5972 try std.testing.expectEqual(@as(?[]const u8, null), hostsOverCapacity(&buf, max_tiles)); 5426 try std.testing.expectEqual(@as(?[]const u8, null), wall_host.hostsOverCapacity(&buf, max_tiles));
5973 try std.testing.expectEqual(@as(?[]const u8, null), hostsOverCapacity(&buf, 0)); 5427 try std.testing.expectEqual(@as(?[]const u8, null), wall_host.hostsOverCapacity(&buf, 0));
5974 try std.testing.expectEqualStrings( 5428 try std.testing.expectEqualStrings(
5975 "[+1 host in the file not shown]", 5429 "[+1 host in the file not shown]",
5976 hostsOverCapacity(&buf, max_tiles + 1) orelse "", 5430 wall_host.hostsOverCapacity(&buf, max_tiles + 1) orelse "",
5977 ); 5431 );
5978 try std.testing.expectEqualStrings( 5432 try std.testing.expectEqualStrings(
5979 "[+9 hosts in the file not shown]", 5433 "[+9 hosts in the file not shown]",
5980 hostsOverCapacity(&buf, max_tiles + 9) orelse "", 5434 wall_host.hostsOverCapacity(&buf, max_tiles + 9) orelse "",
5981 ); 5435 );
5982 } 5436 }
5983 5437
@@ -6109,19 +5563,19 @@ test "addHost: a forgotten host's slot comes back, so a and x cannot fill the ta
6109 table[1] = .{ .spec = .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }, .shared = &shared }; 5563 table[1] = .{ .spec = .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }, .shared = &shared };
6110 var n: usize = 2; 5564 var n: usize = 2;
6111 var buf: [96]u8 = undefined; 5565 var buf: [96]u8 = undefined;
6112 try std.testing.expect(addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path) == .refused); 5566 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path) == .refused);
6113 try std.testing.expectEqualStrings("[no room on the wall for another host]", takeNotice(&shared, &buf)); 5567 try std.testing.expectEqualStrings("[no room on the wall for another host]", takeNotice(&shared, &buf));
6114 5568
6115 // `x` asks the poller to leave; until it has, the slot is still its own. 5569 // `x` asks the poller to leave; until it has, the slot is still its own.
6116 // A spec overwritten under a live poller is a thread dialling a target 5570 // A spec overwritten under a live poller is a thread dialling a target
6117 // that has been replaced. 5571 // that has been replaced.
6118 table[0].forgotten.store(true, .release); 5572 table[0].forgotten.store(true, .release);
6119 try std.testing.expect(addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path) == .refused); 5573 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path) == .refused);
6120 try std.testing.expectEqualStrings("[no room on the wall for another host]", takeNotice(&shared, &buf)); 5574 try std.testing.expectEqualStrings("[no room on the wall for another host]", takeNotice(&shared, &buf));
6121 5575
6122 // The poller's last act, and the slot is the wall's again. 5576 // The poller's last act, and the slot is the wall's again.
6123 table[0].poller_done.store(true, .release); 5577 table[0].poller_done.store(true, .release);
6124 const at = switch (addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path)) { 5578 const at = switch (wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path)) {
6125 .added => |i| i, 5579 .added => |i| i,
6126 else => return error.HostWasNotAdded, 5580 else => return error.HostWasNotAdded,
6127 }; 5581 };
@@ -6153,19 +5607,19 @@ test "addHost: the prompt adds a DAEMON — a flag, a session and a host already
6153 5607
6154 // The typo `mux hosts add -A host` dies at, typed at the prompt 5608 // The typo `mux hosts add -A host` dies at, typed at the prompt
6155 // instead: a host named `-A` is nobody's intent at either mouth. 5609 // instead: a host named `-A` is nobody's intent at either mouth.
6156 try std.testing.expect(addHost(alloc, &shared, &table, &n, "-A nosuchhost.invalid", null, 30_000, path) == .refused); 5610 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "-A nosuchhost.invalid", null, 30_000, path) == .refused);
6157 try std.testing.expectEqual(@as(usize, 2), n); 5611 try std.testing.expectEqual(@as(usize, 2), n);
6158 try std.testing.expectEqualStrings("[bad host: FlagLikeTarget]", takeNotice(&shared, &buf)); 5612 try std.testing.expectEqualStrings("[bad host: FlagLikeTarget]", takeNotice(&shared, &buf));
6159 5613
6160 // `#` is the old wall grammar's session split. The prompt names a 5614 // `#` is the old wall grammar's session split. The prompt names a
6161 // daemon now, and the refusal says which half was one too many. 5615 // daemon now, and the refusal says which half was one too many.
6162 try std.testing.expect(addHost(alloc, &shared, &table, &n, "--sock /c#work", null, 30_000, path) == .refused); 5616 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /c#work", null, 30_000, path) == .refused);
6163 try std.testing.expectEqual(@as(usize, 2), n); 5617 try std.testing.expectEqual(@as(usize, 2), n);
6164 try std.testing.expect(std.mem.indexOf(u8, takeNotice(&shared, &buf), "daemons") != null); 5618 try std.testing.expect(std.mem.indexOf(u8, takeNotice(&shared, &buf), "daemons") != null);
6165 5619
6166 // A real one: it lands in the table AND in the file, because the wall 5620 // A real one: it lands in the table AND in the file, because the wall
6167 // the user is looking at and the wall they get back are the same wall. 5621 // the user is looking at and the wall they get back are the same wall.
6168 const at = switch (addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path)) { 5622 const at = switch (wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path)) {
6169 .added => |i| i, 5623 .added => |i| i,
6170 else => return error.HostWasNotAdded, 5624 else => return error.HostWasNotAdded,
6171 }; 5625 };
@@ -6185,14 +5639,14 @@ test "addHost: the prompt adds a DAEMON — a flag, a session and a host already
6185 // selection is the prompt answering a real host with nothing at all. 5639 // selection is the prompt answering a real host with nothing at all.
6186 try std.testing.expectEqual( 5640 try std.testing.expectEqual(
6187 AddHost{ .listed = 2 }, 5641 AddHost{ .listed = 2 },
6188 addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path), 5642 wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path),
6189 ); 5643 );
6190 try std.testing.expectEqual(@as(usize, 3), n); 5644 try std.testing.expectEqual(@as(usize, 3), n);
6191 try std.testing.expectEqualStrings("[that host is already on the wall]", takeNotice(&shared, &buf)); 5645 try std.testing.expectEqualStrings("[that host is already on the wall]", takeNotice(&shared, &buf));
6192 5646
6193 // Full is said out loud, not swallowed. 5647 // Full is said out loud, not swallowed.
6194 n = table.len; 5648 n = table.len;
6195 try std.testing.expect(addHost(alloc, &shared, &table, &n, "--sock /d", null, 30_000, path) == .refused); 5649 try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /d", null, 30_000, path) == .refused);
6196 try std.testing.expectEqualStrings("[no room on the wall for another host]", takeNotice(&shared, &buf)); 5650 try std.testing.expectEqualStrings("[no room on the wall for another host]", takeNotice(&shared, &buf));
6197 } 5651 }
6198 5652
@@ -6677,9 +6131,9 @@ test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cann
6677 test "pollDelayMs: a poll that cost an ssh login is asked ten times less often" { 6131 test "pollDelayMs: a poll that cost an ssh login is asked ten times less often" {
6678 // The whole point of the number: a pipe-answered poll spawned `ssh`, 6132 // The whole point of the number: a pipe-answered poll spawned `ssh`,
6679 // read the announce and killed it — a remote auth log line per cycle. 6133 // read the announce and killed it — a remote auth log line per cycle.
6680 try std.testing.expectEqual(@as(u64, 10_000), pollDelayMs(.pipe)); 6134 try std.testing.expectEqual(@as(u64, 10_000), wall_host.pollDelayMs(.pipe));
6681 try std.testing.expectEqual(@as(u64, 1_000), pollDelayMs(.fd)); 6135 try std.testing.expectEqual(@as(u64, 1_000), wall_host.pollDelayMs(.fd));
6682 try std.testing.expectEqual(@as(u64, 1_000), pollDelayMs(.quic)); 6136 try std.testing.expectEqual(@as(u64, 1_000), wall_host.pollDelayMs(.quic));
6683 } 6137 }
6684 6138
6685 test "endAction: on a terminal the last tile's exit leaves an empty wall, not an exit" { 6139 test "endAction: on a terminal the last tile's exit leaves an empty wall, not an exit" {
@@ -8621,7 +8075,7 @@ test "otherHosts: the dialled host is not tiled twice, and the rest follow in fi
8621 const arena = arena_state.allocator(); 8075 const arena = arena_state.allocator();
8622 var specs: std.ArrayList(HostSpec) = .empty; 8076 var specs: std.ArrayList(HostSpec) = .empty;
8623 try specs.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }); 8077 try specs.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } });
8624 otherHosts(arena, &specs, "--sock /b", path, null, 0); 8078 wall_host.otherHosts(arena, &specs, "--sock /b", path, null, 0);
8625 8079
8626 try std.testing.expectEqual(@as(usize, 2), specs.items.len); 8080 try std.testing.expectEqual(@as(usize, 2), specs.items.len);
8627 try std.testing.expectEqualStrings("--sock /b", specs.items[0].spelling); 8081 try std.testing.expectEqualStrings("--sock /b", specs.items[0].spelling);
@@ -8632,6 +8086,6 @@ test "otherHosts: the dialled host is not tiled twice, and the rest follow in fi
8632 try wall.saveBytes(path, "--sock /a\nbox#old\n"); 8086 try wall.saveBytes(path, "--sock /a\nbox#old\n");
8633 var one: std.ArrayList(HostSpec) = .empty; 8087 var one: std.ArrayList(HostSpec) = .empty;
8634 try one.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }); 8088 try one.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } });
8635 otherHosts(arena, &one, "--sock /b", path, null, 0); 8089 wall_host.otherHosts(arena, &one, "--sock /b", path, null, 0);
8636 try std.testing.expectEqual(@as(usize, 1), one.items.len); 8090 try std.testing.expectEqual(@as(usize, 1), one.items.len);
8637 } 8091 }