a73x

e78769e9

refactor: one session poll loop, in client.zig

a73x   2026-08-29 12:14

Commit message
refactor: one session poll loop, in client.zig

The CLI wall polled each daemon in wall_host.pollHost; the browser hub
needs the same loop and may not import tui. The loop, its 50 ms slices,
`pollDelayMs` and the host row it runs off (`HostSpec`, `resolveHost`,
`pollTargetFor`) move to client.zig as `SessionPoll`; the wall keeps only
its own answers to "keep going" and "wake the keyboard".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

src/client/client.zig
Old New
@@ -1263,11 +1263,182 @@ pub fn listSessions(
1263 } 1263 }
1264 } 1264 }
1265 1265
1266 /// A daemon on a wall: what to dial, and the line that named it. The
1267 /// spelling is the layout sidecar's key and what `mux hosts` prints back,
1268 /// so it is kept verbatim rather than rebuilt.
1269 pub const HostSpec = struct { spelling: []const u8, target: Target, poll_target: Target };
1270
1271 pub const HostResolveError = hosts.ParseError || SpecError;
1272
1273 /// A host line names a daemon, not a session; this is its target.
1274 pub fn resolveHost(
1275 alloc: std.mem.Allocator,
1276 spelling: []const u8,
1277 key: ?[]const u8,
1278 idle_ms: u32,
1279 ) HostResolveError!HostSpec {
1280 // A host line is a listing, not an attach anyone waited for. The
1281 // POLLER runs off this spec once a second: an asked copy would print
1282 // the fallback line onto the wall's alternate screen every cycle, and
1283 // would start a daemon on a box whose owner just stopped one.
1284 const target = try Target.fromSpec(alloc, try hosts.parse(spelling), key, idle_ms, false);
1285 return .{ .spelling = spelling, .target = target, .poll_target = try pollTargetFor(alloc, target) };
1286 }
1287
1288 /// What `HostSpec.poll_target` is: the same daemon, dialled by a recipe
1289 /// nobody is sitting in front of.
1290 pub fn pollTargetFor(alloc: std.mem.Allocator, target: Target) !Target {
1291 // Only `hand` can ask a terminal for anything, so only `hand` needs a
1292 // second recipe: a poll runs under a wall that owns the screen, where
1293 // an ssh password prompt goes to /dev/tty under the panes and a
1294 // fallback line goes onto the alternate screen.
1295 const h = switch (target) {
1296 .hand => |hd| hd,
1297 else => return target,
1298 };
1299 const r = try handoff.recipeFor(alloc, h.host, true);
1300 return .{ .hand = HandoffTarget.fromRecipe(h.host, r, h.idle_ms, false) };
1301 }
1302
1303 /// Polling, not a push: a subscription is a new daemon concept, and one
1304 /// small frame a second per host over a link that already carries deltas is
1305 /// not a cost worth designing around.
1306 const host_poll_ms: u64 = 1000;
1307
1308 /// How long before this host is asked again, given the link that answered.
1309 pub fn pollDelayMs(link: std.meta.Tag(Link)) u64 {
1310 // A pipe link cost a whole sshd login: the cached QUIC coordinates were
1311 // dead or blocked, so this cycle spawned `ssh`, read the announce and
1312 // killed it. A second of that, forever, is a remote auth log the wall
1313 // wrote — the list is worth a tenth of the freshness.
1314 return if (link == .pipe) host_poll_ms * 10 else host_poll_ms;
1315 }
1316
1317 /// ONE host's session poll, for every front that shows a wall: the CLI
1318 /// wall's `wall_host.Host` and the browser hub's host row both run this
1319 /// loop, so a tile born in a terminal and a tile born in a browser come
1320 /// from the same question asked the same way.
1321 ///
1322 /// Whether to keep going and how to wake the reader are the caller's —
1323 /// the wall answers `shared.running and !forgotten` and rings the keyboard;
1324 /// the hub answers "the hub is serving" and applies the list. Everything
1325 /// else, including riding out a blip, is the same on both fronts.
1326 pub const SessionPoll = struct {
1327 list_mu: std.Thread.Mutex = .{},
1328 list: [proto.sessions_text_max]u8 = undefined,
1329 list_len: usize = 0,
1330 /// News for the reader: a poll finished, well or badly.
1331 list_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
1332 reachable: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
1333 /// A birth asks for the next poll NOW rather than in a second — no
1334 /// wall may lag the session the user just made.
1335 poke: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
1336
1337 /// The last answer, copied out from under `list_mu` into the caller's
1338 /// buffer: `list` is this poller's own and is overwritten by the next
1339 /// poll, so a reader that kept a slice of it would read a half-written
1340 /// list.
1341 pub fn snapshot(self: *SessionPoll, buf: *[proto.sessions_text_max]u8) []const u8 {
1342 self.list_mu.lock();
1343 defer self.list_mu.unlock();
1344 @memcpy(buf[0..self.list_len], self.list[0..self.list_len]);
1345 return buf[0..self.list_len];
1346 }
1347
1348 /// Runtime hooks, not a comptime context: one compiled loop, two
1349 /// fronts, and a test may hand it a counter.
1350 pub const Hooks = struct {
1351 ctx: *anyopaque,
1352 keep: *const fn (*anyopaque) bool,
1353 wake: *const fn (*anyopaque) void,
1354 };
1355
1356 /// Blocks until `keep` says stop. One thread per host.
1357 pub fn run(self: *SessionPoll, target: Target, hooks: Hooks) void {
1358 var out: [proto.sessions_text_max]u8 = undefined;
1359 while (hooks.keep(hooks.ctx)) {
1360 // A connection of its own per poll: the observer idle deadline
1361 // and the redial backoff stay the pump's problem, and this
1362 // thread owns no transport between polls that a teardown would
1363 // have to reach.
1364 var link: std.meta.Tag(Link) = .fd;
1365 const got = listSessions(std.heap.page_allocator, target, &out, 2000, &link) catch null;
1366 if (got) |list| {
1367 self.list_mu.lock();
1368 @memcpy(self.list[0..list.len], list);
1369 self.list_len = list.len;
1370 self.list_mu.unlock();
1371 self.reachable.store(true, .release);
1372 } else self.reachable.store(false, .release);
1373 self.list_ready.store(true, .release);
1374 hooks.wake(hooks.ctx);
1375 var slept: u64 = 0;
1376 const wait = pollDelayMs(link);
1377 // Sliced so a poke or a stop is felt in 50 ms, not in a poll
1378 // interval — a chord that births still pokes through the
1379 // stretched `.pipe` wait, so a user's own action costs nothing.
1380 while (slept < wait and
1381 !self.poke.swap(false, .acq_rel) and
1382 hooks.keep(hooks.ctx)) : (slept += 50)
1383 std.Thread.sleep(50 * std.time.ns_per_ms);
1384 }
1385 }
1386 };
1387
1266 /// Zero first — a link that just died usually reconnects now. No retry cap. 1388 /// Zero first — a link that just died usually reconnects now. No retry cap.
1267 pub fn nextBackoffMs(prev: u64) u64 { 1389 pub fn nextBackoffMs(prev: u64) u64 {
1268 return if (prev == 0) 200 else @min(prev * 2, 2000); 1390 return if (prev == 0) 200 else @min(prev * 2, 2000);
1269 } 1391 }
1270 1392
1393 test "pollDelayMs: a poll that cost an ssh login is asked ten times less often" {
1394 // A pipe link is an sshd login per cycle — a remote auth log the wall
1395 // writes. Every other link is a connect, and stays at the second.
1396 try std.testing.expectEqual(@as(u64, 10_000), pollDelayMs(.pipe));
1397 try std.testing.expectEqual(@as(u64, 1_000), pollDelayMs(.fd));
1398 try std.testing.expectEqual(@as(u64, 1_000), pollDelayMs(.quic));
1399 }
1400
1401 test "SessionPoll.run: a keep that says stop is felt within one sleep slice, not one poll interval" {
1402 // The wall's teardown and the picker's `x` both end a poller by
1403 // answering `keep` with false. A loop that only re-read it once per
1404 // `pollDelayMs` would hold the wall's exit for a second per host — so
1405 // the 50 ms slice is the claim, and the socket nobody serves is what
1406 // makes the poll itself fail fast enough to reach the sleep.
1407 var tmp = try TmpDir.make();
1408 defer tmp.cleanup();
1409 const path = try std.fmt.allocPrint(std.testing.allocator, "{s}/nobody.sock", .{tmp.path()});
1410 defer std.testing.allocator.free(path);
1411
1412 const Ctx = struct {
1413 left: u32 = 3,
1414 woke: u32 = 0,
1415 fn keep(p: *anyopaque) bool {
1416 const self: *@This() = @ptrCast(@alignCast(p));
1417 if (self.left == 0) return false;
1418 self.left -= 1;
1419 return true;
1420 }
1421 fn wake(p: *anyopaque) void {
1422 const self: *@This() = @ptrCast(@alignCast(p));
1423 self.woke += 1;
1424 }
1425 };
1426 var ctx = Ctx{};
1427 var poll: SessionPoll = .{};
1428 const start = std.time.milliTimestamp();
1429 poll.run(.{ .sock = path }, .{ .ctx = &ctx, .keep = Ctx.keep, .wake = Ctx.wake });
1430 const elapsed = std.time.milliTimestamp() - start;
1431
1432 // One poll per pass, and a wake after each — the keyboard hears about a
1433 // failed poll, or a host that went quiet would never be repainted.
1434 try std.testing.expectEqual(@as(u32, 1), ctx.woke);
1435 try std.testing.expect(!poll.reachable.load(.acquire));
1436 try std.testing.expect(poll.list_ready.load(.acquire));
1437 // Two of the three `keep`s are spent inside the sleep, so the run is
1438 // slices long, nowhere near `pollDelayMs`.
1439 try std.testing.expect(elapsed < 1_000);
1440 }
1441
1271 test "reconnect backoff: 0 then 200 doubling to the 2s cap, never beyond" { 1442 test "reconnect backoff: 0 then 200 doubling to the 2s cap, never beyond" {
1272 try std.testing.expectEqual(@as(u64, 200), nextBackoffMs(0)); 1443 try std.testing.expectEqual(@as(u64, 200), nextBackoffMs(0));
1273 try std.testing.expectEqual(@as(u64, 400), nextBackoffMs(200)); 1444 try std.testing.expectEqual(@as(u64, 400), nextBackoffMs(200));
src/tui/wall_host.zig
Old New
@@ -27,42 +27,7 @@ pub const Resolved = struct {
27 agent: bool = false, 27 agent: bool = false,
28 }; 28 };
29 29
30 pub const ResolveError = hosts.ParseError || client.SpecError; 30 const HostSpec = client.HostSpec;
31
32 /// A daemon on the wall: what to dial, and the line that named it. The
33 /// spelling is the sidecar's key and what `mux hosts` prints back, so it
34 /// is kept verbatim rather than rebuilt.
35 pub const HostSpec = struct { spelling: []const u8, target: client.Target, poll_target: client.Target };
36
37 /// A host line names a daemon, not a session; this is its target.
38 pub fn resolveHost(
39 alloc: std.mem.Allocator,
40 spelling: []const u8,
41 key: ?[]const u8,
42 idle_ms: u32,
43 ) ResolveError!HostSpec {
44 // A host line is a listing, not an attach anyone waited for. The
45 // POLLER runs off this spec once a second: an asked copy would print
46 // the fallback line onto the wall's alternate screen every cycle, and
47 // would start a daemon on a box whose owner just stopped one.
48 const target = try client.Target.fromSpec(alloc, try hosts.parse(spelling), key, idle_ms, false);
49 return .{ .spelling = spelling, .target = target, .poll_target = try pollTargetFor(alloc, target) };
50 }
51
52 /// What `HostSpec.poll_target` is: the same daemon, dialled by a recipe
53 /// nobody is sitting in front of.
54 pub fn pollTargetFor(alloc: std.mem.Allocator, target: client.Target) !client.Target {
55 // Only `hand` can ask a terminal for anything, so only `hand` needs a
56 // second recipe: a poll runs under a wall that owns the screen, where
57 // an ssh password prompt goes to /dev/tty under the panes and a
58 // fallback line goes onto the alternate screen.
59 const h = switch (target) {
60 .hand => |hd| hd,
61 else => return target,
62 };
63 const r = try handoff.recipeFor(alloc, h.host, true);
64 return .{ .hand = client.HandoffTarget.fromRecipe(h.host, r, h.idle_ms, false) };
65 }
66 31
67 /// One wording for every spelling the wall will not take. 32 /// One wording for every spelling the wall will not take.
68 fn badHost(shared: *Shared, e: anyerror) void { 33 fn badHost(shared: *Shared, e: anyerror) void {
@@ -149,7 +114,7 @@ pub fn addHost(
149 wv.setNotice(shared, "[could not add that host]"); 114 wv.setNotice(shared, "[could not add that host]");
150 return .refused; 115 return .refused;
151 }; 116 };
152 const spec = resolveHost(alloc, own, key, idle_ms) catch |err| { 117 const spec = client.resolveHost(alloc, own, key, idle_ms) catch |err| {
153 alloc.free(own); 118 alloc.free(own);
154 badHost(shared, err); 119 badHost(shared, err);
155 return .refused; 120 return .refused;
@@ -282,14 +247,9 @@ pub const Host = struct {
282 shared: *Shared, 247 shared: *Shared,
283 /// Never born as a tile here: see `showsSelf`. 248 /// Never born as a tile here: see `showsSelf`.
284 self_name: ?[]const u8 = null, 249 self_name: ?[]const u8 = null,
285 /// A chord that births asks for the next poll NOW rather than in a 250 /// The session poll itself, shared with the browser hub: its list, its
286 /// second — the wall must not lag the session the user just made. 251 /// `reachable`, its `poke` and its 50 ms slices.
287 poke: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), 252 poll: client.SessionPoll = .{},
288 list_mu: std.Thread.Mutex = .{},
289 list: [proto.sessions_text_max]u8 = undefined,
290 list_len: usize = 0,
291 /// News for the keyboard: a poll finished, well or badly.
292 list_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
293 /// Whether a list of this host's has reached the WALL. Here rather than 253 /// Whether a list of this host's has reached the WALL. Here rather than
294 /// in an array beside the table, because the picker's `a` grows the 254 /// in an array beside the table, because the picker's `a` grows the
295 /// table and an array sized when the wall opened is one index out of 255 /// table and an array sized when the wall opened is one index out of
@@ -297,7 +257,6 @@ pub const Host = struct {
297 /// between its own two stores would restore over a wall still missing 257 /// between its own two stores would restore over a wall still missing
298 /// that host's sessions. 258 /// that host's sessions.
299 applied: bool = false, 259 applied: bool = false,
300 reachable: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
301 /// Forgotten in the picker: off the file, off the rows, and its poller 260 /// Forgotten in the picker: off the file, off the rows, and its poller
302 /// exits for good. The SLOT stays — a poller thread holds this pointer 261 /// exits for good. The SLOT stays — a poller thread holds this pointer
303 /// and every tile's `host` indexes this array — so nothing compacts. 262 /// and every tile's `host` indexes this array — so nothing compacts.
@@ -307,59 +266,21 @@ pub const Host = struct {
307 /// poller still holds the pointer is a thread dialling a replaced spec. 266 /// poller still holds the pointer is a thread dialling a replaced spec.
308 poller_done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), 267 poller_done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
309 268
310 /// The poller's last answer, copied out from under `list_mu` into the 269 /// Answering `keep` for `client.SessionPoll.run`: the wall is up and
311 /// caller's buffer: `list` is the poller's own and is overwritten by 270 /// the picker has not forgotten this host.
312 /// the next poll, so a reader that kept a slice of it would read a 271 fn keep(p: *anyopaque) bool {
313 /// half-written list. 272 const self: *Host = @ptrCast(@alignCast(p));
314 pub fn snapshotList(self: *Host, buf: *[proto.sessions_text_max]u8) []const u8 { 273 return self.shared.running.load(.acquire) and !self.forgotten.load(.acquire);
315 self.list_mu.lock();
316 defer self.list_mu.unlock();
317 @memcpy(buf[0..self.list_len], self.list[0..self.list_len]);
318 return buf[0..self.list_len];
319 } 274 }
320 };
321
322 /// Polling, not a push: a subscription is a new daemon concept, and one
323 /// small frame a second per host over a link that already carries deltas is
324 /// not a cost worth designing around.
325 const host_poll_ms: u64 = 1000;
326 275
327 /// How long before this host is asked again, given the link that answered. 276 fn wake(p: *anyopaque) void {
328 pub fn pollDelayMs(link: std.meta.Tag(client.Link)) u64 { 277 const self: *Host = @ptrCast(@alignCast(p));
329 // A pipe link cost a whole sshd login: the cached QUIC coordinates were 278 wv.ringKeyboard(self.shared);
330 // dead or blocked, so this cycle spawned `ssh`, read the announce and 279 }
331 // killed it. A second of that, forever, is a remote auth log the wall 280 };
332 // wrote — the list is worth a tenth of the freshness.
333 return if (link == .pipe) host_poll_ms * 10 else host_poll_ms;
334 }
335 281
336 pub fn pollHost(h: *Host) void { 282 pub fn pollHost(h: *Host) void {
337 var out: [proto.sessions_text_max]u8 = undefined; 283 h.poll.run(h.spec.poll_target, .{ .ctx = h, .keep = Host.keep, .wake = Host.wake });
338 while (h.shared.running.load(.acquire) and !h.forgotten.load(.acquire)) {
339 // A connection of its own per poll: the observer idle deadline and
340 // the redial backoff stay the pump's problem, and this thread owns
341 // no transport between polls that a teardown would have to reach.
342 var link: std.meta.Tag(client.Link) = .fd;
343 const got = client.listSessions(std.heap.page_allocator, h.spec.poll_target, &out, 2000, &link) catch null;
344 if (got) |list| {
345 h.list_mu.lock();
346 @memcpy(h.list[0..list.len], list);
347 h.list_len = list.len;
348 h.list_mu.unlock();
349 h.reachable.store(true, .release);
350 } else h.reachable.store(false, .release);
351 h.list_ready.store(true, .release);
352 wv.ringKeyboard(h.shared);
353 var slept: u64 = 0;
354 const wait = pollDelayMs(link);
355 // A chord that births still pokes through it, so the stretched wait
356 // costs a user's own action nothing.
357 while (slept < wait and
358 !h.poke.swap(false, .acq_rel) and
359 !h.forgotten.load(.acquire) and
360 h.shared.running.load(.acquire)) : (slept += 50)
361 std.Thread.sleep(50 * std.time.ns_per_ms);
362 }
363 // Last: past here nothing reads `h`, which is what lets `addHost` take 284 // Last: past here nothing reads `h`, which is what lets `addHost` take
364 // the slot back. 285 // the slot back.
365 h.poller_done.store(true, .release); 286 h.poller_done.store(true, .release);
@@ -369,14 +290,14 @@ pub fn pollHost(h: *Host) void {
369 /// `c` or `:` must not wait out the poll interval to become a tile. 290 /// `c` or `:` must not wait out the poll interval to become a tile.
370 pub fn pokeHost(host_table: []Host, t: *const Tile) void { 291 pub fn pokeHost(host_table: []Host, t: *const Tile) void {
371 const hi = t.host orelse return; 292 const hi = t.host orelse return;
372 if (hi < host_table.len) host_table[hi].poke.store(true, .release); 293 if (hi < host_table.len) host_table[hi].poll.poke.store(true, .release);
373 } 294 }
374 295
375 /// Every host with news, applied to the wall. True when any reported. 296 /// Every host with news, applied to the wall. True when any reported.
376 pub fn applyReadyLists(w: Wall) bool { 297 pub fn applyReadyLists(w: Wall) bool {
377 var news = false; 298 var news = false;
378 for (w.hosts, 0..) |*h, hi| { 299 for (w.hosts, 0..) |*h, hi| {
379 if (!h.list_ready.swap(false, .acq_rel)) continue; 300 if (!h.poll.list_ready.swap(false, .acq_rel)) continue;
380 news = true; 301 news = true;
381 // A poll already in flight when the host was forgotten still lands. 302 // A poll already in flight when the host was forgotten still lands.
382 // Applying it would re-birth the tiles the forget just took off the 303 // Applying it would re-birth the tiles the forget just took off the
@@ -392,10 +313,10 @@ pub fn applyReadyLists(w: Wall) bool {
392 /// the single writer of the tile array and the layout tree. 313 /// the single writer of the tile array and the layout tree.
393 pub fn applyHostList(w: Wall, hi: usize) void { 314 pub fn applyHostList(w: Wall, hi: usize) void {
394 const h = &w.hosts[hi]; 315 const h = &w.hosts[hi];
395 const reachable = h.reachable.load(.acquire); 316 const reachable = h.poll.reachable.load(.acquire);
396 var list_buf: [proto.sessions_text_max]u8 = undefined; 317 var list_buf: [proto.sessions_text_max]u8 = undefined;
397 var list: []const u8 = ""; 318 var list: []const u8 = "";
398 if (reachable) list = h.snapshotList(&list_buf); 319 if (reachable) list = h.poll.snapshot(&list_buf);
399 // Whether the focus was on a real tile when this list arrived. An empty 320 // Whether the focus was on a real tile when this list arrived. An empty
400 // wall has none, and a vanish can take the one there was — either way 321 // wall has none, and a vanish can take the one there was — either way
401 // the wall owes the tile it ends up with a `setFocus`, which is the only 322 // the wall owes the tile it ends up with a `setFocus`, which is the only
@@ -508,7 +429,7 @@ pub fn otherHosts(
508 }; 429 };
509 for (h.lines.items) |line| { 430 for (h.lines.items) |line| {
510 if (std.mem.eql(u8, line, first)) continue; 431 if (std.mem.eql(u8, line, first)) continue;
511 const spec = resolveHost(alloc, line, key, idle_ms) catch |err| { 432 const spec = client.resolveHost(alloc, line, key, idle_ms) catch |err| {
512 // Said and skipped, not refused: what was asked for here is a 433 // Said and skipped, not refused: what was asked for here is a
513 // session, and it is already open. Only bare `mux`, where the 434 // session, and it is already open. Only bare `mux`, where the
514 // wall itself is the ask, turns a bad line into an exit code. 435 // wall itself is the ask, turns a bad line into an exit code.
src/tui/wall_picker.zig
Old New
@@ -53,17 +53,17 @@ pub fn hostState(buf: []u8, h: *Host) []const u8 {
53 // so a host that has never reported is `connecting` — not `no 53 // so a host that has never reported is `connecting` — not `no
54 // sessions`, which would send the user to birth on a dead machine. 54 // sessions`, which would send the user to birth on a dead machine.
55 if (!h.applied) return "connecting"; 55 if (!h.applied) return "connecting";
56 if (!h.reachable.load(.acquire)) return "unreachable"; 56 if (!h.poll.reachable.load(.acquire)) return "unreachable";
57 var n: usize = 0; 57 var n: usize = 0;
58 h.list_mu.lock(); 58 h.poll.list_mu.lock();
59 var it = std.mem.splitScalar(u8, h.list[0..h.list_len], '\n'); 59 var it = std.mem.splitScalar(u8, h.poll.list[0..h.poll.list_len], '\n');
60 while (it.next()) |name| { 60 while (it.next()) |name| {
61 // The same filter `planHostDiff` births through, so the count a row 61 // The same filter `planHostDiff` births through, so the count a row
62 // advertises is the number of tiles that host can actually put on 62 // advertises is the number of tiles that host can actually put on
63 // the wall. 63 // the wall.
64 if (proto.validSessionName(name)) n += 1; 64 if (proto.validSessionName(name)) n += 1;
65 } 65 }
66 h.list_mu.unlock(); 66 h.poll.list_mu.unlock();
67 if (n == 0) return "no sessions"; 67 if (n == 0) return "no sessions";
68 if (n == 1) return "1 session"; 68 if (n == 1) return "1 session";
69 return std.fmt.bufPrint(buf, "{d} sessions", .{n}) catch "sessions"; 69 return std.fmt.bufPrint(buf, "{d} sessions", .{n}) catch "sessions";
@@ -200,7 +200,7 @@ pub fn pickBirth(w: Wall, sel: usize) ?usize {
200 target.hand.quiet = true; 200 target.hand.quiet = true;
201 } 201 }
202 var list_buf: [proto.sessions_text_max]u8 = undefined; 202 var list_buf: [proto.sessions_text_max]u8 = undefined;
203 const list = h.snapshotList(&list_buf); 203 const list = h.poll.snapshot(&list_buf);
204 // The daemon's own naming, off the daemon's own list: the name the 204 // The daemon's own naming, off the daemon's own list: the name the
205 // `c` chord would have landed on, reached without a pump to ask. 205 // `c` chord would have landed on, reached without a pump to ask.
206 var name_buf: [proto.session_name_max]u8 = undefined; 206 var name_buf: [proto.session_name_max]u8 = undefined;
@@ -233,7 +233,7 @@ pub fn pickBirth(w: Wall, sel: usize) ?usize {
233 wv.spawnPump(&w.tiles[at]); 233 wv.spawnPump(&w.tiles[at]);
234 // The new session must not wait out a poll to be confirmed by the list 234 // The new session must not wait out a poll to be confirmed by the list
235 // that will also stop the diff from vanishing it. 235 // that will also stop the diff from vanishing it.
236 h.poke.store(true, .release); 236 h.poll.poke.store(true, .release);
237 return at; 237 return at;
238 } 238 }
239 239
src/tui/wall_test_harness.zig
Old New
@@ -115,9 +115,9 @@ pub fn testHost(shared: *Shared, spelling: []const u8, sock: []const u8) Host {
115 } 115 }
116 116
117 pub fn setList(h: *Host, list: []const u8) void { 117 pub fn setList(h: *Host, list: []const u8) void {
118 @memcpy(h.list[0..list.len], list); 118 @memcpy(h.poll.list[0..list.len], list);
119 h.list_len = list.len; 119 h.poll.list_len = list.len;
120 h.reachable.store(true, .release); 120 h.poll.reachable.store(true, .release);
121 } 121 }
122 122
123 // A picker painted into a pipe, drained. Non-blocking on both ends so a 123 // A picker painted into a pipe, drained. Non-blocking on both ends so a
src/tui/wall_test_host.zig
Old New
@@ -1,7 +1,7 @@
1 //! The hosts file, the host table and the poller (wall_host.zig). 1 //! The hosts file, the host table and the poller (wall_host.zig).
2 const std = @import("std"); 2 const std = @import("std");
3 const proto = @import("protocol"); 3 const proto = @import("protocol");
4 const wall = @import("wall"); 4 const client = @import("client");
5 const hosts = @import("hosts"); 5 const hosts = @import("hosts");
6 const TmpDir = @import("testtmp").TmpDir; 6 const TmpDir = @import("testtmp").TmpDir;
7 const fixture = @import("wall_test_harness.zig"); 7 const fixture = @import("wall_test_harness.zig");
@@ -10,7 +10,7 @@ const wv = @import("wallview.zig");
10 const AddHost = wall_host.AddHost; 10 const AddHost = wall_host.AddHost;
11 const BirthNames = wall_host.BirthNames; 11 const BirthNames = wall_host.BirthNames;
12 const Host = wall_host.Host; 12 const Host = wall_host.Host;
13 const HostSpec = wall_host.HostSpec; 13 const HostSpec = client.HostSpec;
14 const Shared = wv.Shared; 14 const Shared = wv.Shared;
15 const Tile = wv.Tile; 15 const Tile = wv.Tile;
16 const TileIdxs = wall_host.TileIdxs; 16 const TileIdxs = wall_host.TileIdxs;
@@ -149,7 +149,7 @@ test "applyReadyLists: a host added after the wall opened gets its sessions, and
149 fixture.testHost(&shared, "--sock /tmp/added.sock", "/tmp/added.sock"), 149 fixture.testHost(&shared, "--sock /tmp/added.sock", "/tmp/added.sock"),
150 }; 150 };
151 fixture.setList(&table[2], "late\n"); 151 fixture.setList(&table[2], "late\n");
152 table[2].list_ready.store(true, .release); 152 table[2].poll.list_ready.store(true, .release);
153 153
154 _ = wall_host.applyReadyLists(fixture.wallOf(alloc, &tiles, &present, &live, &shared, table[0..3])); 154 _ = wall_host.applyReadyLists(fixture.wallOf(alloc, &tiles, &present, &live, &shared, table[0..3]));
155 155
@@ -200,7 +200,7 @@ test "applyHostList: a host with no live session gets no tile — the wall shows
200 fixture.testHost(&shared, "down", "/tmp/nobody.sock"), 200 fixture.testHost(&shared, "down", "/tmp/nobody.sock"),
201 fixture.testHost(&shared, "empty", "/tmp/empty.sock"), 201 fixture.testHost(&shared, "empty", "/tmp/empty.sock"),
202 }; 202 };
203 table[0].reachable.store(false, .release); 203 table[0].poll.reachable.store(false, .release);
204 fixture.setList(&table[1], ""); 204 fixture.setList(&table[1], "");
205 205
206 // Twice, because a placeholder that is born once is still a placeholder. 206 // Twice, because a placeholder that is born once is still a placeholder.
@@ -571,7 +571,7 @@ test "resolveHost refuses a sun_path-overflowing sock path" {
571 // Refused at usage altitude, not at a connect that fails with a 571 // Refused at usage altitude, not at a connect that fails with a
572 // truncated sun_path nobody typed. 572 // truncated sun_path nobody typed.
573 const long = "--sock /" ++ "x" ** 200; 573 const long = "--sock /" ++ "x" ** 200;
574 try std.testing.expectError(error.SockPathTooLong, wall_host.resolveHost(alloc, long, null, 30_000)); 574 try std.testing.expectError(error.SockPathTooLong, client.resolveHost(alloc, long, null, 30_000));
575 } 575 }
576 576
577 test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cannot narrate" { 577 test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cannot narrate" {
@@ -582,7 +582,7 @@ test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cann
582 // Two spellings, because the poll target is built per host and a shared 582 // Two spellings, because the poll target is built per host and a shared
583 // one would point every stripe at the first host's ssh argv. 583 // one would point every stripe at the first host's ssh argv.
584 for ([_][]const u8{ "box", "user@gate" }) |spelling| { 584 for ([_][]const u8{ "box", "user@gate" }) |spelling| {
585 const spec = try wall_host.resolveHost(alloc, spelling, null, 30_000); 585 const spec = try client.resolveHost(alloc, spelling, null, 30_000);
586 try std.testing.expect(hasWord(spec.poll_target.hand.ssh_argv, "BatchMode=yes")); 586 try std.testing.expect(hasWord(spec.poll_target.hand.ssh_argv, "BatchMode=yes"));
587 try std.testing.expectEqualStrings(spelling, spec.poll_target.hand.host); 587 try std.testing.expectEqualStrings(spelling, spec.poll_target.hand.host);
588 // The attach the user sees must still be able to ask for a password. 588 // The attach the user sees must still be able to ask for a password.
@@ -594,18 +594,10 @@ test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cann
594 } 594 }
595 595
596 // A transport that cannot prompt has one target, not a copy of one. 596 // A transport that cannot prompt has one target, not a copy of one.
597 const s = try wall_host.resolveHost(alloc, "--sock /tmp/a.sock", null, 30_000); 597 const s = try client.resolveHost(alloc, "--sock /tmp/a.sock", null, 30_000);
598 try std.testing.expectEqualStrings(s.target.sock, s.poll_target.sock); 598 try std.testing.expectEqualStrings(s.target.sock, s.poll_target.sock);
599 } 599 }
600 600
601 test "pollDelayMs: a poll that cost an ssh login is asked ten times less often" {
602 // The whole point of the number: a pipe-answered poll spawned `ssh`,
603 // read the announce and killed it — a remote auth log line per cycle.
604 try std.testing.expectEqual(@as(u64, 10_000), wall_host.pollDelayMs(.pipe));
605 try std.testing.expectEqual(@as(u64, 1_000), wall_host.pollDelayMs(.fd));
606 try std.testing.expectEqual(@as(u64, 1_000), wall_host.pollDelayMs(.quic));
607 }
608
609 test "otherHosts: the dialled host is not tiled twice, and the rest follow in file order" { 601 test "otherHosts: the dialled host is not tiled twice, and the rest follow in file order" {
610 const alloc = std.testing.allocator; 602 const alloc = std.testing.allocator;
611 var tmp = try TmpDir.make(); 603 var tmp = try TmpDir.make();
src/tui/wall_test_picker.zig
Old New
@@ -332,7 +332,7 @@ test "pickerRows: every host says what its poller last answered" {
332 fixture.setList(&table[0], "0\nwork\ndev\n"); 332 fixture.setList(&table[0], "0\nwork\ndev\n");
333 table[0].applied = true; 333 table[0].applied = true;
334 fixture.setList(&table[1], ""); 334 fixture.setList(&table[1], "");
335 table[1].reachable.store(false, .release); 335 table[1].poll.reachable.store(false, .release);
336 table[1].applied = true; 336 table[1].applied = true;
337 fixture.setList(&table[2], ""); 337 fixture.setList(&table[2], "");
338 table[2].applied = true; 338 table[2].applied = true;
@@ -425,11 +425,11 @@ test "pickBirth: Enter is an ask — the tile it births may start a daemon, the
425 // see a birth that reaches for the wrong row. 425 // see a birth that reaches for the wrong row.
426 var table = [_]Host{ 426 var table = [_]Host{
427 .{ 427 .{
428 .spec = try wall_host.resolveHost(alloc, "alpha", null, client.quic_idle_ms_default), 428 .spec = try client.resolveHost(alloc, "alpha", null, client.quic_idle_ms_default),
429 .shared = &shared, 429 .shared = &shared,
430 }, 430 },
431 .{ 431 .{
432 .spec = try wall_host.resolveHost(alloc, "beta", null, client.quic_idle_ms_default), 432 .spec = try client.resolveHost(alloc, "beta", null, client.quic_idle_ms_default),
433 .shared = &shared, 433 .shared = &shared,
434 }, 434 },
435 }; 435 };
@@ -509,18 +509,18 @@ test "hostState: the row counts the sessions the wall could show, not the runs i
509 509
510 h.applied = true; 510 h.applied = true;
511 const listed = "a\nb\nc\n"; 511 const listed = "a\nb\nc\n";
512 @memcpy(h.list[0..listed.len], listed); 512 @memcpy(h.poll.list[0..listed.len], listed);
513 h.list_len = listed.len; 513 h.poll.list_len = listed.len;
514 try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h)); 514 try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h));
515 515
516 // A row saying `4 sessions` beside three tiles is the row lying about 516 // A row saying `4 sessions` beside three tiles is the row lying about
517 // the wall: `planHostDiff` births through `validSessionName`, so the 517 // the wall: `planHostDiff` births through `validSessionName`, so the
518 // count reads the reply through the same filter. 518 // count reads the reply through the same filter.
519 const with_junk = "a\nb\n" ++ ("x" ** (proto.session_name_max + 1)) ++ "\nc\n"; 519 const with_junk = "a\nb\n" ++ ("x" ** (proto.session_name_max + 1)) ++ "\nc\n";
520 @memcpy(h.list[0..with_junk.len], with_junk); 520 @memcpy(h.poll.list[0..with_junk.len], with_junk);
521 h.list_len = with_junk.len; 521 h.poll.list_len = with_junk.len;
522 try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h)); 522 try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h));
523 523
524 h.reachable.store(false, .release); 524 h.poll.reachable.store(false, .release);
525 try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&buf, &h)); 525 try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&buf, &h));
526 } 526 }
src/tui/wallview.zig
Old New
@@ -43,8 +43,8 @@ const Resolved = wall_host.Resolved;
43 43
44 // `wallview` is this module's face: mux_main reaches these through 44 // `wallview` is this module's face: mux_main reaches these through
45 // the root, wherever inside the module they now live. 45 // the root, wherever inside the module they now live.
46 pub const HostSpec = wall_host.HostSpec; 46 pub const HostSpec = client.HostSpec;
47 pub const resolveHost = wall_host.resolveHost; 47 pub const resolveHost = client.resolveHost;
48 48
49 /// Whether this process has a screen to cut stripes on. 49 /// Whether this process has a screen to cut stripes on.
50 fn headless(out_fd: std.posix.fd_t) bool { 50 fn headless(out_fd: std.posix.fd_t) bool {
@@ -1415,7 +1415,7 @@ pub fn runAttach(
1415 // can prompt, which is worse, but a wall that refuses to open over 1415 // can prompt, which is worse, but a wall that refuses to open over
1416 // one allocation is worse still. Unasked either way — a poll that 1416 // one allocation is worse still. Unasked either way — a poll that
1417 // could start a daemon is not a lesser evil, it is the bug. 1417 // could start a daemon is not a lesser evil, it is the bug.
1418 .poll_target = wall_host.pollTargetFor(arena, spec_target) catch spec_target, 1418 .poll_target = client.pollTargetFor(arena, spec_target) catch spec_target,
1419 }); 1419 });
1420 if (hosts.statePath(arena) catch null) |path| { 1420 if (hosts.statePath(arena) catch null) |path| {
1421 // stderr, not a notice: this runs before `run` takes the screen. 1421 // stderr, not a notice: this runs before `run` takes the screen.