e78769e9
refactor: one session poll loop, in client.zig
a73x 2026-08-29 12:14
Commit message
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. |