a500b0cd
feat: sessions_reply carries the daemon's version and a stale-image bit
a73x 2026-09-02 05:30
Commit message
src/cli/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -291,7 +291,7 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 291 | .host_port = q.host_port, | 291 | .host_port = q.host_port, |
| 292 | .key_path = key_path, | 292 | .key_path = key_path, |
| 293 | .idle_ms = q.idle_ms, | 293 | .idle_ms = q.idle_ms, |
| 294 | } }, q.session, q.key, q.idle_ms, q.agent); | 294 | } }, q.session, q.key, q.idle_ms, q.agent, build_options.version); |
| 295 | }, | 295 | }, |
| 296 | .host => |h| { | 296 | .host => |h| { |
| 297 | // The handoff recipe uses SSH to obtain coordinates and as a | 297 | // The handoff recipe uses SSH to obtain coordinates and as a |
| @@ -305,7 +305,7 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 305 | // Relay SSH stderr for this foreground attach; no alternate-screen | 305 | // Relay SSH stderr for this foreground attach; no alternate-screen |
| 306 | // UI is active yet. | 306 | // UI is active yet. |
| 307 | target.narrate = true; | 307 | target.narrate = true; |
| 308 | return wall.runAttach(alloc, .{ .hand = target }, h.session, null, h.idle_ms, h.agent); | 308 | return wall.runAttach(alloc, .{ .hand = target }, h.session, null, h.idle_ms, h.agent, build_options.version); |
| 309 | }, | 309 | }, |
| 310 | .attach => |t| { | 310 | .attach => |t| { |
| 311 | if (t.via) |cmd| return wall.runAttach( | 311 | if (t.via) |cmd| return wall.runAttach( |
| @@ -315,6 +315,7 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 315 | null, | 315 | null, |
| 316 | client.quic_idle_ms_default, | 316 | client.quic_idle_ms_default, |
| 317 | t.agent, | 317 | t.agent, |
| 318 | build_options.version, | ||
| 318 | ); | 319 | ); |
| 319 | const sock_path = if (t.sock) |s| | 320 | const sock_path = if (t.sock) |s| |
| 320 | try alloc.dupe(u8, s) | 321 | try alloc.dupe(u8, s) |
| @@ -363,6 +364,7 @@ fn attachLocal( | |||
| 363 | null, | 364 | null, |
| 364 | client.quic_idle_ms_default, | 365 | client.quic_idle_ms_default, |
| 365 | agent, | 366 | agent, |
| 367 | build_options.version, | ||
| 366 | ); | 368 | ); |
| 367 | } | 369 | } |
| 368 | 370 | ||
| @@ -430,7 +432,7 @@ fn wallOfHosts(alloc: std.mem.Allocator) !u8 { | |||
| 430 | return 2; | 432 | return 2; |
| 431 | }; | 433 | }; |
| 432 | } | 434 | } |
| 433 | return wall.run(arena, specs, .{ .key = key }); | 435 | return wall.run(arena, specs, .{ .key = key, .own_version = build_options.version }); |
| 434 | } | 436 | } |
| 435 | 437 | ||
| 436 | /// Timeout for a daemon's session-count reply. SSH setup is intentionally not | 438 | /// Timeout for a daemon's session-count reply. SSH setup is intentionally not |
| @@ -476,7 +478,7 @@ fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t) | |||
| 476 | printRow(out_fd, line, "\t[bad host: {s}]\n", .{hosts.reason(err)}); | 478 | printRow(out_fd, line, "\t[bad host: {s}]\n", .{hosts.reason(err)}); |
| 477 | continue; | 479 | continue; |
| 478 | }; | 480 | }; |
| 479 | var out: [proto.sessions_text_max]u8 = undefined; | 481 | var out: [proto.sessions_reply_max]u8 = undefined; |
| 480 | const list = client.listSessions(arena, spec.poll_target, &out, hosts_list_ms, null, null) catch |err| { | 482 | const list = client.listSessions(arena, spec.poll_target, &out, hosts_list_ms, null, null) catch |err| { |
| 481 | // Propagate local allocation failure rather than reporting the | 483 | // Propagate local allocation failure rather than reporting the |
| 482 | // remote daemon as unreachable. | 484 | // remote daemon as unreachable. |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -1218,7 +1218,7 @@ fn oomOrTransport(e: anyerror) error{ OutOfMemory, Transport } { | |||
| 1218 | pub fn listSessions( | 1218 | pub fn listSessions( |
| 1219 | alloc: std.mem.Allocator, | 1219 | alloc: std.mem.Allocator, |
| 1220 | target: Target, | 1220 | target: Target, |
| 1221 | out: *[proto.sessions_text_max]u8, | 1221 | out: *[proto.sessions_reply_max]u8, |
| 1222 | budget_ms: i64, | 1222 | budget_ms: i64, |
| 1223 | answered: ?*std.meta.Tag(Link), | 1223 | answered: ?*std.meta.Tag(Link), |
| 1224 | /// Where the dial's leavings go when it fails: the picker row quotes | 1224 | /// Where the dial's leavings go when it fails: the picker row quotes |
| @@ -1308,7 +1308,7 @@ pub fn pollDelayMs(link: std.meta.Tag(Link)) u64 { | |||
| 1308 | /// including riding out a blip, is the same on both fronts. | 1308 | /// including riding out a blip, is the same on both fronts. |
| 1309 | pub const SessionPoll = struct { | 1309 | pub const SessionPoll = struct { |
| 1310 | list_mu: std.Thread.Mutex = .{}, | 1310 | list_mu: std.Thread.Mutex = .{}, |
| 1311 | list: [proto.sessions_text_max]u8 = undefined, | 1311 | list: [proto.sessions_reply_max]u8 = undefined, |
| 1312 | list_len: usize = 0, | 1312 | list_len: usize = 0, |
| 1313 | /// News for the reader: a poll finished, well or badly. | 1313 | /// News for the reader: a poll finished, well or badly. |
| 1314 | list_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), | 1314 | list_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), |
| @@ -1319,12 +1319,19 @@ pub const SessionPoll = struct { | |||
| 1319 | /// A birth asks for the next poll NOW rather than in a second — no | 1319 | /// A birth asks for the next poll NOW rather than in a second — no |
| 1320 | /// wall may lag the session the user just made. | 1320 | /// wall may lag the session the user just made. |
| 1321 | poke: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), | 1321 | poke: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), |
| 1322 | /// The meta line off the last answer, parsed HERE once per cycle so no | ||
| 1323 | /// row or bar re-walks the payload. Under `list_mu` with the list it | ||
| 1324 | /// came from; `meta_present` false is an old daemon or no answer yet. | ||
| 1325 | meta_version: [proto.sessions_meta_version_max]u8 = undefined, | ||
| 1326 | meta_version_len: usize = 0, | ||
| 1327 | meta_stale: bool = false, | ||
| 1328 | meta_present: bool = false, | ||
| 1322 | 1329 | ||
| 1323 | /// The last answer, copied out from under `list_mu` into the caller's | 1330 | /// The last answer, copied out from under `list_mu` into the caller's |
| 1324 | /// buffer: `list` is this poller's own and is overwritten by the next | 1331 | /// buffer: `list` is this poller's own and is overwritten by the next |
| 1325 | /// poll, so a reader that kept a slice of it would read a half-written | 1332 | /// poll, so a reader that kept a slice of it would read a half-written |
| 1326 | /// list. | 1333 | /// list. |
| 1327 | pub fn snapshot(self: *SessionPoll, buf: *[proto.sessions_text_max]u8) []const u8 { | 1334 | pub fn snapshot(self: *SessionPoll, buf: *[proto.sessions_reply_max]u8) []const u8 { |
| 1328 | self.list_mu.lock(); | 1335 | self.list_mu.lock(); |
| 1329 | defer self.list_mu.unlock(); | 1336 | defer self.list_mu.unlock(); |
| 1330 | @memcpy(buf[0..self.list_len], self.list[0..self.list_len]); | 1337 | @memcpy(buf[0..self.list_len], self.list[0..self.list_len]); |
| @@ -1342,6 +1349,21 @@ pub const SessionPoll = struct { | |||
| 1342 | return buf[0..said.len]; | 1349 | return buf[0..said.len]; |
| 1343 | } | 1350 | } |
| 1344 | 1351 | ||
| 1352 | /// The daemon's word about itself off the last ANSWER, parsed once here | ||
| 1353 | /// so every row and bar shares one verdict: absent until a reply carries | ||
| 1354 | /// the line, absent again when a reply stops carrying it — last cycle's | ||
| 1355 | /// drift must not dress a daemon that no longer states any. | ||
| 1356 | pub fn metaSnapshot( | ||
| 1357 | self: *SessionPoll, | ||
| 1358 | buf: *[proto.sessions_meta_version_max]u8, | ||
| 1359 | ) ?proto.SessionsMeta { | ||
| 1360 | self.list_mu.lock(); | ||
| 1361 | defer self.list_mu.unlock(); | ||
| 1362 | if (!self.meta_present) return null; | ||
| 1363 | @memcpy(buf[0..self.meta_version_len], self.meta_version[0..self.meta_version_len]); | ||
| 1364 | return .{ .version = buf[0..self.meta_version_len], .stale = self.meta_stale }; | ||
| 1365 | } | ||
| 1366 | |||
| 1345 | /// Runtime hooks, not a comptime context: one compiled loop, two | 1367 | /// Runtime hooks, not a comptime context: one compiled loop, two |
| 1346 | /// fronts, and a test may hand it a counter. | 1368 | /// fronts, and a test may hand it a counter. |
| 1347 | pub const Hooks = struct { | 1369 | pub const Hooks = struct { |
| @@ -1352,7 +1374,7 @@ pub const SessionPoll = struct { | |||
| 1352 | 1374 | ||
| 1353 | /// Blocks until `keep` says stop. One thread per host. | 1375 | /// Blocks until `keep` says stop. One thread per host. |
| 1354 | pub fn run(self: *SessionPoll, target: Target, hooks: Hooks) void { | 1376 | pub fn run(self: *SessionPoll, target: Target, hooks: Hooks) void { |
| 1355 | var out: [proto.sessions_text_max]u8 = undefined; | 1377 | var out: [proto.sessions_reply_max]u8 = undefined; |
| 1356 | while (hooks.keep(hooks.ctx)) { | 1378 | while (hooks.keep(hooks.ctx)) { |
| 1357 | // A connection of its own per poll: the observer idle deadline | 1379 | // A connection of its own per poll: the observer idle deadline |
| 1358 | // and the redial backoff stay the pump's problem, and this | 1380 | // and the redial backoff stay the pump's problem, and this |
| @@ -1371,6 +1393,15 @@ pub const SessionPoll = struct { | |||
| 1371 | // A host that answered has no reason to give, and last | 1393 | // A host that answered has no reason to give, and last |
| 1372 | // cycle's would sit on a reachable row forever. | 1394 | // cycle's would sit on a reachable row forever. |
| 1373 | self.reason.clear(); | 1395 | self.reason.clear(); |
| 1396 | // The meta verdict follows the same rule as the reason: it | ||
| 1397 | // is the LAST answer's, so a reply without the line — an | ||
| 1398 | // old daemon, or one downgraded under us — clears it. | ||
| 1399 | if (proto.parseSessionsMeta(list)) |m| { | ||
| 1400 | @memcpy(self.meta_version[0..m.version.len], m.version); | ||
| 1401 | self.meta_version_len = m.version.len; | ||
| 1402 | self.meta_stale = m.stale; | ||
| 1403 | self.meta_present = true; | ||
| 1404 | } else self.meta_present = false; | ||
| 1374 | self.list_mu.unlock(); | 1405 | self.list_mu.unlock(); |
| 1375 | self.reachable.store(true, .release); | 1406 | self.reachable.store(true, .release); |
| 1376 | } else { | 1407 | } else { |
| @@ -1495,6 +1526,56 @@ test "SessionPoll: a failed poll keeps ssh's last line, and a good one clears it | |||
| 1495 | try std.testing.expectEqualStrings("", poll.reasonSnapshot(&said_buf)); | 1526 | try std.testing.expectEqualStrings("", poll.reasonSnapshot(&said_buf)); |
| 1496 | } | 1527 | } |
| 1497 | 1528 | ||
| 1529 | test "SessionPoll: the daemon's meta rides the poll, and a wordless reply clears it" { | ||
| 1530 | var tmp = try TmpDir.make(); | ||
| 1531 | defer tmp.cleanup(); | ||
| 1532 | const Ctx = struct { | ||
| 1533 | left: u32, | ||
| 1534 | fn keep(p: *anyopaque) bool { | ||
| 1535 | const self: *@This() = @ptrCast(@alignCast(p)); | ||
| 1536 | if (self.left == 0) return false; | ||
| 1537 | self.left -= 1; | ||
| 1538 | return true; | ||
| 1539 | } | ||
| 1540 | fn wake(_: *anyopaque) void {} | ||
| 1541 | }; | ||
| 1542 | var poll: SessionPoll = .{}; | ||
| 1543 | var vbuf: [proto.sessions_meta_version_max]u8 = undefined; | ||
| 1544 | |||
| 1545 | // A daemon that states a version and a replaced image, beside names the | ||
| 1546 | // wall still has to see whole. | ||
| 1547 | const sp = try std.fmt.allocPrint(std.testing.allocator, "{s}/meta.sock", .{tmp.path()}); | ||
| 1548 | defer std.testing.allocator.free(sp); | ||
| 1549 | const addr = try std.net.Address.initUnix(sp); | ||
| 1550 | var fake = ListFake{ | ||
| 1551 | .listener = try addr.listen(.{}), | ||
| 1552 | .reply = "0\nwork\n# mux 9.9.9-new stale", | ||
| 1553 | }; | ||
| 1554 | defer fake.listener.deinit(); | ||
| 1555 | const th = try std.Thread.spawn(.{}, ListFake.serve, .{&fake}); | ||
| 1556 | var ctx = Ctx{ .left = 1 }; | ||
| 1557 | poll.run(.{ .sock = sp }, .{ .ctx = &ctx, .keep = Ctx.keep, .wake = Ctx.wake }); | ||
| 1558 | th.join(); | ||
| 1559 | const meta = poll.metaSnapshot(&vbuf) orelse return error.MetaAbsent; | ||
| 1560 | try std.testing.expectEqualStrings("9.9.9-new", meta.version); | ||
| 1561 | try std.testing.expect(meta.stale); | ||
| 1562 | var lbuf: [proto.sessions_reply_max]u8 = undefined; | ||
| 1563 | try std.testing.expectEqualStrings("0\nwork\n# mux 9.9.9-new stale", poll.snapshot(&lbuf)); | ||
| 1564 | |||
| 1565 | // The SAME poller then hears an old daemon — no line at all. The verdict | ||
| 1566 | // leaves with the evidence, exactly as `reason` clears on an answer. | ||
| 1567 | const sp2 = try std.fmt.allocPrint(std.testing.allocator, "{s}/old.sock", .{tmp.path()}); | ||
| 1568 | defer std.testing.allocator.free(sp2); | ||
| 1569 | const addr2 = try std.net.Address.initUnix(sp2); | ||
| 1570 | var fake2 = ListFake{ .listener = try addr2.listen(.{}), .reply = "0\n" }; | ||
| 1571 | defer fake2.listener.deinit(); | ||
| 1572 | const th2 = try std.Thread.spawn(.{}, ListFake.serve, .{&fake2}); | ||
| 1573 | ctx = Ctx{ .left = 1 }; | ||
| 1574 | poll.run(.{ .sock = sp2 }, .{ .ctx = &ctx, .keep = Ctx.keep, .wake = Ctx.wake }); | ||
| 1575 | th2.join(); | ||
| 1576 | try std.testing.expect(poll.metaSnapshot(&vbuf) == null); | ||
| 1577 | } | ||
| 1578 | |||
| 1498 | test "reconnect backoff: 0 then 200 doubling to the 2s cap, never beyond" { | 1579 | test "reconnect backoff: 0 then 200 doubling to the 2s cap, never beyond" { |
| 1499 | try std.testing.expectEqual(@as(u64, 200), nextBackoffMs(0)); | 1580 | try std.testing.expectEqual(@as(u64, 200), nextBackoffMs(0)); |
| 1500 | try std.testing.expectEqual(@as(u64, 400), nextBackoffMs(200)); | 1581 | try std.testing.expectEqual(@as(u64, 400), nextBackoffMs(200)); |
| @@ -2892,7 +2973,7 @@ test "listSessions: answers with the daemon's whole list, and a socket nobody li | |||
| 2892 | defer fake.listener.deinit(); | 2973 | defer fake.listener.deinit(); |
| 2893 | const th = try std.Thread.spawn(.{}, ListFake.serve, .{&fake}); | 2974 | const th = try std.Thread.spawn(.{}, ListFake.serve, .{&fake}); |
| 2894 | 2975 | ||
| 2895 | var out: [proto.sessions_text_max]u8 = undefined; | 2976 | var out: [proto.sessions_reply_max]u8 = undefined; |
| 2896 | var link: std.meta.Tag(Link) = .quic; | 2977 | var link: std.meta.Tag(Link) = .quic; |
| 2897 | const list = try listSessions(alloc, .{ .sock = sp }, &out, 2000, &link, null); | 2978 | const list = try listSessions(alloc, .{ .sock = sp }, &out, 2000, &link, null); |
| 2898 | th.join(); | 2979 | th.join(); |
| @@ -2922,7 +3003,7 @@ test "listSessions: an allocation failure is not a host that is down" { | |||
| 2922 | defer fake.listener.deinit(); | 3003 | defer fake.listener.deinit(); |
| 2923 | const th = try std.Thread.spawn(.{}, ListFake.serve, .{&fake}); | 3004 | const th = try std.Thread.spawn(.{}, ListFake.serve, .{&fake}); |
| 2924 | 3005 | ||
| 2925 | var out: [proto.sessions_text_max]u8 = undefined; | 3006 | var out: [proto.sessions_reply_max]u8 = undefined; |
| 2926 | try std.testing.expectError( | 3007 | try std.testing.expectError( |
| 2927 | error.OutOfMemory, | 3008 | error.OutOfMemory, |
| 2928 | listSessions(std.testing.failing_allocator, .{ .sock = sp }, &out, 2000, null, null), | 3009 | listSessions(std.testing.failing_allocator, .{ .sock = sp }, &out, 2000, null, null), |
| @@ -2932,7 +3013,7 @@ test "listSessions: an allocation failure is not a host that is down" { | |||
| 2932 | 3013 | ||
| 2933 | test "listSessions: a poll that failed still reports the login it paid for" { | 3014 | test "listSessions: a poll that failed still reports the login it paid for" { |
| 2934 | const alloc = std.testing.allocator; | 3015 | const alloc = std.testing.allocator; |
| 2935 | var out: [proto.sessions_text_max]u8 = undefined; | 3016 | var out: [proto.sessions_reply_max]u8 = undefined; |
| 2936 | 3017 | ||
| 2937 | // An ssh that dies without an announce: the login is spent, the poll has | 3018 | // An ssh that dies without an announce: the login is spent, the poll has |
| 2938 | // nothing. Left at `.fd` the wall would ask again in a second, forever — | 3019 | // nothing. Left at `.fd` the wall would ask again in a second, forever — |
src/client/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -66,7 +66,7 @@ pub const HubHost = struct { | |||
| 66 | 66 | ||
| 67 | fn wake(p: *anyopaque) void { | 67 | fn wake(p: *anyopaque) void { |
| 68 | const self: *HubHost = @ptrCast(@alignCast(p)); | 68 | const self: *HubHost = @ptrCast(@alignCast(p)); |
| 69 | var buf: [proto.sessions_text_max]u8 = undefined; | 69 | var buf: [proto.sessions_reply_max]u8 = undefined; |
| 70 | const reachable = self.poll.reachable.load(.acquire); | 70 | const reachable = self.poll.reachable.load(.acquire); |
| 71 | self.hub.?.applyList(self.idx, if (reachable) self.poll.snapshot(&buf) else "", reachable); | 71 | self.hub.?.applyList(self.idx, if (reachable) self.poll.snapshot(&buf) else "", reachable); |
| 72 | } | 72 | } |
| @@ -285,7 +285,7 @@ pub const Hub = struct { | |||
| 285 | self.mutex.unlock(); | 285 | self.mutex.unlock(); |
| 286 | 286 | ||
| 287 | const h = &self.hosts[hi]; | 287 | const h = &self.hosts[hi]; |
| 288 | var list_buf: [proto.sessions_text_max]u8 = undefined; | 288 | var list_buf: [proto.sessions_reply_max]u8 = undefined; |
| 289 | var name_buf: [proto.session_name_max]u8 = undefined; | 289 | var name_buf: [proto.session_name_max]u8 = undefined; |
| 290 | const name = client.SessionName.of(client.nextFreeName(&name_buf, h.poll.snapshot(&list_buf))); | 290 | const name = client.SessionName.of(client.nextFreeName(&name_buf, h.poll.snapshot(&list_buf))); |
| 291 | try client.birthSession(self.alloc, h.spec.target, name.slice(), client.birth_cols, client.birth_rows); | 291 | try client.birthSession(self.alloc, h.spec.target, name.slice(), client.birth_cols, client.birth_rows); |
src/engine/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -908,6 +908,83 @@ pub const SessionsIter = struct { | |||
| 908 | } | 908 | } |
| 909 | }; | 909 | }; |
| 910 | 910 | ||
| 911 | /// The daemon's word about ITSELF, riding the same `sessions_reply`: one | ||
| 912 | /// trailing `# mux <version>[ stale]` line. Both '#' and the space are bytes | ||
| 913 | /// `validSessionName` refuses, so every names reader — all of them walk | ||
| 914 | /// `sessionsIter` — skips this line without knowing it exists. That is the | ||
| 915 | /// whole compatibility story: an old client ignores it by construction, and | ||
| 916 | /// an old daemon never sends it, which a new client reads as "unknown" and | ||
| 917 | /// paints as nothing. | ||
| 918 | pub const sessions_meta_prefix = "# mux "; | ||
| 919 | /// `stale` is the daemon reporting that the installed binary on its OWN box | ||
| 920 | /// was replaced under it (its /proc/self/exe reads deleted): the one drift a | ||
| 921 | /// version string cannot show, because two builds of one dev version spell | ||
| 922 | /// the same version. | ||
| 923 | pub const sessions_meta_stale_word = " stale"; | ||
| 924 | pub const sessions_meta_version_max = 32; | ||
| 925 | /// Worst-case growth of a names payload that gains the meta line: the | ||
| 926 | /// joining '\n' plus the line itself. Reply buffers add this to | ||
| 927 | /// `sessions_text_max`. | ||
| 928 | pub const sessions_meta_max = | ||
| 929 | 1 + sessions_meta_prefix.len + sessions_meta_version_max + sessions_meta_stale_word.len; | ||
| 930 | /// What a `sessions_reply` READER must have room for: every name plus the | ||
| 931 | /// meta line. `listSessions` refuses an overlong payload as a transport | ||
| 932 | /// error, so a receiver sized to `sessions_text_max` alone would read a | ||
| 933 | /// full daemon that states its version as an unreachable box. | ||
| 934 | pub const sessions_reply_max = sessions_text_max + sessions_meta_max; | ||
| 935 | |||
| 936 | pub const SessionsMeta = struct { version: []const u8, stale: bool }; | ||
| 937 | |||
| 938 | /// Append the meta line to the names already in `buf[0..names_len]` and | ||
| 939 | /// return the new payload length. The join rule lives HERE with the parser: | ||
| 940 | /// an empty table is only the line, never a leading separator, so "empty | ||
| 941 | /// payload = no sessions" stays true in spirit — zero names iterate out | ||
| 942 | /// either way. A version longer than `sessions_meta_version_max` is | ||
| 943 | /// truncated; the parser caps at the same bound, so the two ends agree. | ||
| 944 | pub fn appendSessionsMeta(buf: []u8, names_len: usize, version: []const u8, stale: bool) usize { | ||
| 945 | const ver = version[0..@min(version.len, sessions_meta_version_max)]; | ||
| 946 | var w = names_len; | ||
| 947 | if (names_len != 0) { | ||
| 948 | buf[w] = '\n'; | ||
| 949 | w += 1; | ||
| 950 | } | ||
| 951 | @memcpy(buf[w..][0..sessions_meta_prefix.len], sessions_meta_prefix); | ||
| 952 | w += sessions_meta_prefix.len; | ||
| 953 | @memcpy(buf[w..][0..ver.len], ver); | ||
| 954 | w += ver.len; | ||
| 955 | if (stale) { | ||
| 956 | @memcpy(buf[w..][0..sessions_meta_stale_word.len], sessions_meta_stale_word); | ||
| 957 | w += sessions_meta_stale_word.len; | ||
| 958 | } | ||
| 959 | return w; | ||
| 960 | } | ||
| 961 | |||
| 962 | /// The reader's half, across the same trust boundary as `sessionsIter`: the | ||
| 963 | /// payload is a peer's bytes, so a meta line is accepted only whole — the | ||
| 964 | /// prefix, a version of 1..=`sessions_meta_version_max` bytes every one of | ||
| 965 | /// which `validSessionName` would accept in a name, and either nothing or | ||
| 966 | /// exactly the stale word after it. Anything else is absent, not an error: | ||
| 967 | /// a wall paints nothing for a daemon it cannot read. | ||
| 968 | pub fn parseSessionsMeta(payload: []const u8) ?SessionsMeta { | ||
| 969 | var lines = std.mem.splitScalar(u8, payload, '\n'); | ||
| 970 | while (lines.next()) |line| { | ||
| 971 | if (!std.mem.startsWith(u8, line, sessions_meta_prefix)) continue; | ||
| 972 | var rest = line[sessions_meta_prefix.len..]; | ||
| 973 | var stale = false; | ||
| 974 | if (std.mem.endsWith(u8, rest, sessions_meta_stale_word)) { | ||
| 975 | stale = true; | ||
| 976 | rest = rest[0 .. rest.len - sessions_meta_stale_word.len]; | ||
| 977 | } | ||
| 978 | // `validSessionName` IS the version check: same byte set, and | ||
| 979 | // `sessions_meta_version_max` equals `session_name_max`, asserted | ||
| 980 | // below so a drift in either cap shows up as a build break. | ||
| 981 | comptime std.debug.assert(sessions_meta_version_max == session_name_max); | ||
| 982 | if (!validSessionName(rest)) continue; | ||
| 983 | return .{ .version = rest, .stale = stale }; | ||
| 984 | } | ||
| 985 | return null; | ||
| 986 | } | ||
| 987 | |||
| 911 | /// Whether a `sessions_reply` payload names this session. Never `indexOf`: | 988 | /// Whether a `sessions_reply` payload names this session. Never `indexOf`: |
| 912 | /// `w` is in `work` and neither is the other. | 989 | /// `w` is in `work` and neither is the other. |
| 913 | pub fn sessionsHas(payload: []const u8, name: []const u8) bool { | 990 | pub fn sessionsHas(payload: []const u8, name: []const u8) bool { |
| @@ -1772,6 +1849,67 @@ test "sessions_req/sessions_reply round-trip through writeFrame/readFrame" { | |||
| 1772 | try std.testing.expectEqualStrings("0\nwork\n2", rep.payload); | 1849 | try std.testing.expectEqualStrings("0\nwork\n2", rep.payload); |
| 1773 | } | 1850 | } |
| 1774 | 1851 | ||
| 1852 | test "sessions meta: the daemon's line rides behind the names and parses back" { | ||
| 1853 | var buf: [sessions_text_max + sessions_meta_max]u8 = undefined; | ||
| 1854 | const names = "0\nwork"; | ||
| 1855 | @memcpy(buf[0..names.len], names); | ||
| 1856 | const full = buf[0..appendSessionsMeta(&buf, names.len, "0.0.1-17", false)]; | ||
| 1857 | |||
| 1858 | const meta = parseSessionsMeta(full) orelse return error.MetaAbsent; | ||
| 1859 | try std.testing.expectEqualStrings("0.0.1-17", meta.version); | ||
| 1860 | try std.testing.expect(!meta.stale); | ||
| 1861 | |||
| 1862 | // The compatibility contract itself: a names reader sees exactly the | ||
| 1863 | // names, in order, and nothing of the line. Every reader in the tree | ||
| 1864 | // walks this iterator, so this one walk is every reader's test. | ||
| 1865 | var it = sessionsIter(full); | ||
| 1866 | try std.testing.expectEqualStrings("0", it.next() orelse return error.NameLost); | ||
| 1867 | try std.testing.expectEqualStrings("work", it.next() orelse return error.NameLost); | ||
| 1868 | try std.testing.expect(it.next() == null); | ||
| 1869 | try std.testing.expect(!sessionsHas(full, "mux")); | ||
| 1870 | } | ||
| 1871 | |||
| 1872 | test "sessions meta: stale survives, and an empty table is only the line" { | ||
| 1873 | var buf: [sessions_meta_max]u8 = undefined; | ||
| 1874 | const full = buf[0..appendSessionsMeta(&buf, 0, "0.0.1-17", true)]; | ||
| 1875 | |||
| 1876 | const meta = parseSessionsMeta(full) orelse return error.MetaAbsent; | ||
| 1877 | try std.testing.expect(meta.stale); | ||
| 1878 | try std.testing.expectEqualStrings("0.0.1-17", meta.version); | ||
| 1879 | // A daemon hosting nothing still answers "no sessions" to a names | ||
| 1880 | // reader: the payload is non-empty now, so emptiness of NAMES must come | ||
| 1881 | // from the iterator, never the byte count. | ||
| 1882 | var it = sessionsIter(full); | ||
| 1883 | try std.testing.expect(it.next() == null); | ||
| 1884 | // No leading separator on an empty table. | ||
| 1885 | try std.testing.expect(full[0] == '#'); | ||
| 1886 | } | ||
| 1887 | |||
| 1888 | test "sessions meta: junk is absent, never a version" { | ||
| 1889 | // No line at all, and lines broken every way a peer can break one: | ||
| 1890 | // empty version, a trailer that is not the stale word, bytes no name | ||
| 1891 | // may hold, and a version past the cap. | ||
| 1892 | const absent = [_][]const u8{ | ||
| 1893 | "0\nwork", | ||
| 1894 | "", | ||
| 1895 | "# mux ", | ||
| 1896 | "# mux 0.0.1-17 soon", | ||
| 1897 | "# mux 0.0.1-17 stale extra", | ||
| 1898 | "# mux bad version", | ||
| 1899 | "# mux \x01evil", | ||
| 1900 | "# mux " ++ "x" ** 33, | ||
| 1901 | "#mux 0.0.1-17", | ||
| 1902 | }; | ||
| 1903 | for (absent) |payload| | ||
| 1904 | try std.testing.expect(parseSessionsMeta(payload) == null); | ||
| 1905 | // A version at the cap, mid-payload, still parses: the line need not be | ||
| 1906 | // last for the reader, only for the writer. | ||
| 1907 | const capped = "a\n# mux " ++ "x" ** 32 ++ " stale" ++ "\nb"; | ||
| 1908 | const meta = parseSessionsMeta(capped) orelse return error.MetaAbsent; | ||
| 1909 | try std.testing.expectEqualStrings("x" ** 32, meta.version); | ||
| 1910 | try std.testing.expect(meta.stale); | ||
| 1911 | } | ||
| 1912 | |||
| 1775 | test "wireName: the default session is spelled as the empty tail" { | 1913 | test "wireName: the default session is spelled as the empty tail" { |
| 1776 | try std.testing.expectEqualStrings("", wireName("0")); | 1914 | try std.testing.expectEqualStrings("", wireName("0")); |
| 1777 | try std.testing.expectEqualStrings("", wireName(resolveName(""))); | 1915 | try std.testing.expectEqualStrings("", wireName(resolveName(""))); |
src/server/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -1793,8 +1793,16 @@ pub const Server = struct { | |||
| 1793 | // Daemon-global like `.stats_req`: the answer is the session | 1793 | // Daemon-global like `.stats_req`: the answer is the session |
| 1794 | // TABLE, so who asked cannot change it. An observation, not | 1794 | // TABLE, so who asked cannot change it. An observation, not |
| 1795 | // activity, so it claims no grid. | 1795 | // activity, so it claims no grid. |
| 1796 | var buf: SessionsBuf = undefined; | 1796 | var buf: [proto.sessions_reply_max]u8 = undefined; |
| 1797 | self.replyTo(p, .sessions_reply, self.sessions.text(&buf)); | 1797 | const names = self.sessions.text(buf[0..proto.sessions_text_max]); |
| 1798 | // A daemon with no version to state appends nothing, so a | ||
| 1799 | // bare fixture's payload stays byte-identical to the old | ||
| 1800 | // wire and the sibling exact-equality test keeps pinning it. | ||
| 1801 | const len = if (self.version.len != 0) | ||
| 1802 | proto.appendSessionsMeta(&buf, names.len, self.version, selfImageStale()) | ||
| 1803 | else | ||
| 1804 | names.len; | ||
| 1805 | self.replyTo(p, .sessions_reply, buf[0..len]); | ||
| 1798 | }, | 1806 | }, |
| 1799 | .endpoint_req => { | 1807 | .endpoint_req => { |
| 1800 | const payload = proto.encodeEndpointReply(self.endpointPort()); | 1808 | const payload = proto.encodeEndpointReply(self.endpointPort()); |
| @@ -3170,11 +3178,28 @@ pub const Server = struct { | |||
| 3170 | /// spell the size: a caller that copied an array literal slips the assert. | 3178 | /// spell the size: a caller that copied an array literal slips the assert. |
| 3171 | pub const SessionsBuf = [sessions_text_len]u8; | 3179 | pub const SessionsBuf = [sessions_text_len]u8; |
| 3172 | comptime { | 3180 | comptime { |
| 3173 | // The wall reads a reply into a buffer of its own; a daemon that | 3181 | // The wall reads a reply into a buffer of its own — sized |
| 3174 | // could say more than that would be truncated into a false list. | 3182 | // `proto.sessions_reply_max`, names plus the meta line — and a |
| 3183 | // daemon that could say more than that would be truncated into a | ||
| 3184 | // false list. Names alone must agree with the protocol's half. | ||
| 3175 | std.debug.assert(@sizeOf(SessionsBuf) == proto.sessions_text_max); | 3185 | std.debug.assert(@sizeOf(SessionsBuf) == proto.sessions_text_max); |
| 3176 | } | 3186 | } |
| 3177 | 3187 | ||
| 3188 | /// Whether the file this daemon was exec'd from has been replaced or | ||
| 3189 | /// removed since: `/proc/self/exe` keeps resolving THROUGH to the old | ||
| 3190 | /// image, so the daemon runs fine, but the kernel appends " (deleted)" | ||
| 3191 | /// to the link's text. That suffix is the whole staleness check — no | ||
| 3192 | /// version compare can catch it, because a rebuild of the same dev | ||
| 3193 | /// version spells the same string. Read fresh per ask: a rename lands | ||
| 3194 | /// under a running daemon at any moment, and one readlink per | ||
| 3195 | /// `sessions_req` is nothing. Unknown is reported not-stale — a wall | ||
| 3196 | /// must not dress a healthy box in a warning because /proc was coy. | ||
| 3197 | fn selfImageStale() bool { | ||
| 3198 | var buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 3199 | const p = std.posix.readlink("/proc/self/exe", &buf) catch return false; | ||
| 3200 | return std.mem.endsWith(u8, p, " (deleted)"); | ||
| 3201 | } | ||
| 3202 | |||
| 3178 | /// A gauge: an unattached QUIC handshake holds a slot, unobservably. | 3203 | /// A gauge: an unattached QUIC handshake holds a slot, unobservably. |
| 3179 | fn liveClients(self: *const Server) usize { | 3204 | fn liveClients(self: *const Server) usize { |
| 3180 | return countLive(&self.clients); | 3205 | return countLive(&self.clients); |
src/server/server_test_session.zig
| Old | New | ||
|---|---|---|---|
| @@ -1226,6 +1226,48 @@ test "Server: sessions_req answers every live name, whoever asks" { | |||
| 1226 | try std.testing.expectEqualStrings(proto.default_session ++ "\nwork", reply.payload); | 1226 | try std.testing.expectEqualStrings(proto.default_session ++ "\nwork", reply.payload); |
| 1227 | } | 1227 | } |
| 1228 | 1228 | ||
| 1229 | test "Server: sessions_reply carries the daemon's own version behind the names" { | ||
| 1230 | const alloc = std.testing.allocator; | ||
| 1231 | |||
| 1232 | var td = try h.TestDaemon.init(alloc, "sessmeta", .{ | ||
| 1233 | .shell = "/bin/cat", | ||
| 1234 | .version = "9.9.9-test", | ||
| 1235 | }); | ||
| 1236 | defer td.deinit(); | ||
| 1237 | |||
| 1238 | const ca = try dial.dialAttach(td.sock_path, 80, 24); | ||
| 1239 | defer ca.close(); | ||
| 1240 | const cb = try dial.dialAttachNamed(td.sock_path, 80, 24, "work"); | ||
| 1241 | defer cb.close(); | ||
| 1242 | const fa = (try awaitFrame(alloc, &td.srv, ca.handle, .snapshot, 400)) orelse | ||
| 1243 | return error.NoSnapshotA; | ||
| 1244 | fa.deinit(alloc); | ||
| 1245 | const fb = (try awaitFrame(alloc, &td.srv, cb.handle, .snapshot, 400)) orelse | ||
| 1246 | return error.NoSnapshotB; | ||
| 1247 | fb.deinit(alloc); | ||
| 1248 | |||
| 1249 | try proto.writeFrame(ca.handle, .sessions_req, ""); | ||
| 1250 | const reply = (try awaitFrame(alloc, &td.srv, ca.handle, .sessions_reply, 400)) orelse | ||
| 1251 | return error.NoSessionsReply; | ||
| 1252 | defer reply.deinit(alloc); | ||
| 1253 | |||
| 1254 | // The names still read as exactly the names — the sibling test above | ||
| 1255 | // pins a versionless daemon's payload byte-for-byte, so this one only | ||
| 1256 | // has to show the line rides BEHIND them, invisibly to the iterator. | ||
| 1257 | var it = proto.sessionsIter(reply.payload); | ||
| 1258 | try std.testing.expectEqualStrings(proto.default_session, it.next() orelse return error.NameLost); | ||
| 1259 | try std.testing.expectEqualStrings("work", it.next() orelse return error.NameLost); | ||
| 1260 | try std.testing.expect(it.next() == null); | ||
| 1261 | |||
| 1262 | const meta = proto.parseSessionsMeta(reply.payload) orelse return error.MetaAbsent; | ||
| 1263 | try std.testing.expectEqualStrings("9.9.9-test", meta.version); | ||
| 1264 | // This test binary is still the file it was exec'd from, and the server | ||
| 1265 | // must say so: stale=true here would mean the bit is invented rather | ||
| 1266 | // than read off /proc. The true side needs a replaced binary under a | ||
| 1267 | // running daemon, which is the e2e journey's to stage. | ||
| 1268 | try std.testing.expect(!meta.stale); | ||
| 1269 | } | ||
| 1270 | |||
| 1229 | test "Server: an attached client's mismatched status tail is ignored" { | 1271 | test "Server: an attached client's mismatched status tail is ignored" { |
| 1230 | const alloc = std.testing.allocator; | 1272 | const alloc = std.testing.allocator; |
| 1231 | 1273 | ||
src/tui/wall_host.zig
| Old | New | ||
|---|---|---|---|
| @@ -251,6 +251,12 @@ pub const Host = struct { | |||
| 251 | /// array beside the table, which the picker's `a` would index out of bounds. | 251 | /// array beside the table, which the picker's `a` would index out of bounds. |
| 252 | /// Keyboard-thread only, so no lock. | 252 | /// Keyboard-thread only, so no lock. |
| 253 | applied: bool = false, | 253 | applied: bool = false, |
| 254 | /// The drift word as last APPLIED to this host's tiles, so a poll that | ||
| 255 | /// repeats the same news asks for no repaint — one bump a second per | ||
| 256 | /// host is a flicker, not information. Keyboard-thread only, like | ||
| 257 | /// `applied`. | ||
| 258 | drift: [wv.drift_max]u8 = undefined, | ||
| 259 | drift_len: usize = 0, | ||
| 254 | /// 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 |
| 255 | /// 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 |
| 256 | /// and every tile's `host` indexes this array — so nothing compacts. | 262 | /// and every tile's `host` indexes this array — so nothing compacts. |
| @@ -303,6 +309,35 @@ pub fn applyReadyLists(w: Wall) bool { | |||
| 303 | return news; | 309 | return news; |
| 304 | } | 310 | } |
| 305 | 311 | ||
| 312 | /// The host's drift word, written onto every tile it owns. Written even | ||
| 313 | /// when unchanged — a tile born THIS cycle has not been dressed yet, and a | ||
| 314 | /// blind rewrite of at most `wv.drift_max` bytes per tile is cheaper than | ||
| 315 | /// remembering which tiles are new — but a REPAINT is asked for only on | ||
| 316 | /// change, so a poll that repeats the same news costs the screen nothing. | ||
| 317 | /// Only a reachable host's answer speaks: a silent box keeps its last word | ||
| 318 | /// the way its panes keep their rects. | ||
| 319 | fn applyDrift(w: Wall, hi: usize, list: []const u8) void { | ||
| 320 | const h = &w.hosts[hi]; | ||
| 321 | var word_buf: [wv.drift_max]u8 = undefined; | ||
| 322 | const word = wv.driftWord(&word_buf, w.shared.own_version, proto.parseSessionsMeta(list)); | ||
| 323 | const changed = !std.mem.eql(u8, word, h.drift[0..h.drift_len]); | ||
| 324 | @memcpy(h.drift[0..word.len], word); | ||
| 325 | h.drift_len = word.len; | ||
| 326 | w.shared.paint_mu.lock(); | ||
| 327 | defer w.shared.paint_mu.unlock(); | ||
| 328 | for (w.liveTiles(), w.livePresent()) |*t, p| { | ||
| 329 | if (!p or !ownedBy(t, hi)) continue; | ||
| 330 | @memcpy(t.drift[0..word.len], word); | ||
| 331 | t.drift_len = word.len; | ||
| 332 | } | ||
| 333 | if (!changed) return; | ||
| 334 | // The pumps redraw their bars on the bump within a poll slice; a | ||
| 335 | // pumpless pane — pending, gone — has only the keyboard's hand, the | ||
| 336 | // same one that paints it in `dressSilent`. | ||
| 337 | _ = w.shared.repaint_gen.fetchAdd(1, .release); | ||
| 338 | if (w.shared.labelRows() != 0) wv.paintDeadBarsLocked(w.liveTiles()); | ||
| 339 | } | ||
| 340 | |||
| 306 | /// A silent host DRESSES its saved panes rather than taking them: losing an | 341 | /// A silent host DRESSES its saved panes rather than taking them: losing an |
| 307 | /// eight-pane setup to one quiet box is worse than reading the word. | 342 | /// eight-pane setup to one quiet box is worse than reading the word. |
| 308 | fn dressSilent(w: Wall, hi: usize) void { | 343 | fn dressSilent(w: Wall, hi: usize) void { |
| @@ -324,7 +359,7 @@ fn dressSilent(w: Wall, hi: usize) void { | |||
| 324 | pub fn applyHostList(w: Wall, hi: usize) void { | 359 | pub fn applyHostList(w: Wall, hi: usize) void { |
| 325 | const h = &w.hosts[hi]; | 360 | const h = &w.hosts[hi]; |
| 326 | const reachable = h.poll.reachable.load(.acquire); | 361 | const reachable = h.poll.reachable.load(.acquire); |
| 327 | var list_buf: [proto.sessions_text_max]u8 = undefined; | 362 | var list_buf: [proto.sessions_reply_max]u8 = undefined; |
| 328 | var list: []const u8 = ""; | 363 | var list: []const u8 = ""; |
| 329 | if (reachable) list = h.poll.snapshot(&list_buf); | 364 | if (reachable) list = h.poll.snapshot(&list_buf); |
| 330 | // Whether the focus was on a real tile when this list arrived. An empty | 365 | // Whether the focus was on a real tile when this list arrived. An empty |
| @@ -394,6 +429,7 @@ pub fn applyHostList(w: Wall, hi: usize) void { | |||
| 394 | var buf: [48]u8 = undefined; | 429 | var buf: [48]u8 = undefined; |
| 395 | wv.setNoticeIdle(w.shared, std.fmt.bufPrint(&buf, "[+{d} not shown]", .{unplaced}) catch "[not shown]"); | 430 | wv.setNoticeIdle(w.shared, std.fmt.bufPrint(&buf, "[+{d} not shown]", .{unplaced}) catch "[not shown]"); |
| 396 | } | 431 | } |
| 432 | applyDrift(w, hi, list); | ||
| 397 | } else dressSilent(w, hi); | 433 | } else dressSilent(w, hi); |
| 398 | if ((!had_focus or w.shared.sel >= w.live.* or !w.present[w.shared.sel]) and | 434 | if ((!had_focus or w.shared.sel >= w.live.* or !w.present[w.shared.sel]) and |
| 399 | wv.presentCount(w.livePresent()) > 0) | 435 | wv.presentCount(w.livePresent()) > 0) |
src/tui/wall_picker.zig
| Old | New | ||
|---|---|---|---|
| @@ -77,9 +77,30 @@ pub fn hostState(buf: []u8, h: *Host) []const u8 { | |||
| 77 | var it = proto.sessionsIter(h.poll.list[0..h.poll.list_len]); | 77 | var it = proto.sessionsIter(h.poll.list[0..h.poll.list_len]); |
| 78 | while (it.next()) |_| n += 1; | 78 | while (it.next()) |_| n += 1; |
| 79 | h.poll.list_mu.unlock(); | 79 | h.poll.list_mu.unlock(); |
| 80 | if (n == 0) return "no sessions"; | 80 | var count_buf: [16]u8 = undefined; |
| 81 | if (n == 1) return "1 session"; | 81 | const count: []const u8 = switch (n) { |
| 82 | return std.fmt.bufPrint(buf, "{d} sessions", .{n}) catch "sessions"; | 82 | 0 => "no sessions", |
| 83 | 1 => "1 session", | ||
| 84 | else => std.fmt.bufPrint(&count_buf, "{d} sessions", .{n}) catch "sessions", | ||
| 85 | }; | ||
| 86 | if (h.drift_len == 0) { | ||
| 87 | // Verbatim only if it fits: `count` may live in this frame's | ||
| 88 | // buffer, and the caller keeps a slice of ITS buffer, not ours. | ||
| 89 | if (count.len > buf.len) return "sessions"; | ||
| 90 | @memcpy(buf[0..count.len], count); | ||
| 91 | return buf[0..count.len]; | ||
| 92 | } | ||
| 93 | // The drift word beside the count, hand-truncated like the reason | ||
| 94 | // above and for the same reason: shortening is the contract, and | ||
| 95 | // `bufPrint`'s failure would leave `buf` holding the stack's garbage. | ||
| 96 | var w: usize = 0; | ||
| 97 | for ([_][]const u8{ count, ", ", h.drift[0..h.drift_len] }) |part| { | ||
| 98 | const room = @min(part.len, buf.len - w); | ||
| 99 | @memcpy(buf[w..][0..room], part[0..room]); | ||
| 100 | w += room; | ||
| 101 | if (w == buf.len) break; | ||
| 102 | } | ||
| 103 | return buf[0..w]; | ||
| 83 | } | 104 | } |
| 84 | 105 | ||
| 85 | /// The row's fixed left column: ` N` or ` NN`, plus the marker. | 106 | /// The row's fixed left column: ` N` or ` NN`, plus the marker. |
| @@ -319,7 +340,7 @@ pub fn pickBirth(w: Wall, sel: usize) ?usize { | |||
| 319 | // off it every second and must not resurrect a stopped daemon. | 340 | // off it every second and must not resurrect a stopped daemon. |
| 320 | var target = h.spec.target; | 341 | var target = h.spec.target; |
| 321 | if (target == .hand) target.hand.asked = true; | 342 | if (target == .hand) target.hand.asked = true; |
| 322 | var list_buf: [proto.sessions_text_max]u8 = undefined; | 343 | var list_buf: [proto.sessions_reply_max]u8 = undefined; |
| 323 | const list = h.poll.snapshot(&list_buf); | 344 | const list = h.poll.snapshot(&list_buf); |
| 324 | // The daemon's own naming, off the daemon's own list: the name the | 345 | // The daemon's own naming, off the daemon's own list: the name the |
| 325 | // `c` chord would have landed on, reached without a pump to ask. | 346 | // `c` chord would have landed on, reached without a pump to ask. |
src/tui/wall_test_host.zig
| Old | New | ||
|---|---|---|---|
| @@ -171,7 +171,7 @@ test "planHostDiff: a name the wire grammar refuses never becomes a tile, howeve | |||
| 171 | var binds = TileIdxs{}; | 171 | var binds = TileIdxs{}; |
| 172 | var vanish = TileIdxs{}; | 172 | var vanish = TileIdxs{}; |
| 173 | var gones = TileIdxs{}; | 173 | var gones = TileIdxs{}; |
| 174 | // A whole reply may be `sessions_text_max`, so ONE "name" in it can be | 174 | // A whole reply may be `sessions_reply_max`, so ONE "name" in it can be |
| 175 | // 1056 bytes; `encodeAttachNamed` memcpys the birth's name into a | 175 | // 1056 bytes; `encodeAttachNamed` memcpys the birth's name into a |
| 176 | // 32-byte tail behind nothing but an assert. Peer bytes are filtered | 176 | // 32-byte tail behind nothing but an assert. Peer bytes are filtered |
| 177 | // where they become a tile, not asserted about at the wire. | 177 | // where they become a tile, not asserted about at the wire. |
| @@ -236,6 +236,53 @@ test "recordHost: a file it cannot write comes BACK — the wall may be on the a | |||
| 236 | try std.testing.expect(wall_host.recordHost(alloc, .{ .via = "ssh h mux d proxy" }, "x", bad) == null); | 236 | try std.testing.expect(wall_host.recordHost(alloc, .{ .via = "ssh h mux d proxy" }, "x", bad) == null); |
| 237 | } | 237 | } |
| 238 | 238 | ||
| 239 | test "applyHostList: drift dresses every tile of its host, and repaints only on change" { | ||
| 240 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | ||
| 241 | defer arena.deinit(); | ||
| 242 | const alloc = arena.allocator(); | ||
| 243 | var shared: Shared = undefined; | ||
| 244 | fixture.stoppedWall(alloc, &shared); | ||
| 245 | shared.own_version = "1.0"; | ||
| 246 | var tiles: [4]Tile = undefined; | ||
| 247 | var present = [_]bool{false} ** 4; | ||
| 248 | var live: usize = 0; | ||
| 249 | defer fixture.endPumps(tiles[0..live]); | ||
| 250 | // Two drifted hosts side by side — one behind us, one stale on its own | ||
| 251 | // box with TWO sessions, so "every tile of the host" is plural where it | ||
| 252 | // can be. | ||
| 253 | var table = [_]Host{ | ||
| 254 | fixture.testHost(&shared, "behind", "/tmp/behind.sock"), | ||
| 255 | fixture.testHost(&shared, "stale", "/tmp/stale.sock"), | ||
| 256 | }; | ||
| 257 | fixture.setList(&table[0], "a\n# mux 0.9"); | ||
| 258 | fixture.setList(&table[1], "b\nc\n# mux 1.0 stale"); | ||
| 259 | |||
| 260 | wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0); | ||
| 261 | wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 1); | ||
| 262 | try std.testing.expectEqual(@as(usize, 3), wv.presentCount(present[0..live])); | ||
| 263 | for (tiles[0..live], present[0..live]) |*t, p| { | ||
| 264 | if (!p) continue; | ||
| 265 | const want: []const u8 = if (t.host == @as(?usize, 0)) "daemon 0.9" else "daemon stale"; | ||
| 266 | try std.testing.expectEqualStrings(want, t.drift[0..t.drift_len]); | ||
| 267 | } | ||
| 268 | |||
| 269 | // The same lists again: same words, and NO repaint asked for — a wall | ||
| 270 | // repainted once a second per host would flicker for no news. | ||
| 271 | const gen = shared.repaint_gen.load(.acquire); | ||
| 272 | wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0); | ||
| 273 | wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 1); | ||
| 274 | try std.testing.expectEqual(gen, shared.repaint_gen.load(.acquire)); | ||
| 275 | |||
| 276 | // The behind daemon upgrades under us: its word leaves, and that IS news. | ||
| 277 | fixture.setList(&table[0], "a\n# mux 1.0"); | ||
| 278 | wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0); | ||
| 279 | for (tiles[0..live], present[0..live]) |*t, p| { | ||
| 280 | if (!p or t.host != @as(?usize, 0)) continue; | ||
| 281 | try std.testing.expectEqualStrings("", t.drift[0..t.drift_len]); | ||
| 282 | } | ||
| 283 | try std.testing.expect(shared.repaint_gen.load(.acquire) != gen); | ||
| 284 | } | ||
| 285 | |||
| 239 | test "applyHostList: a host with no live session gets no tile — the wall shows sessions only" { | 286 | test "applyHostList: a host with no live session gets no tile — the wall shows sessions only" { |
| 240 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | 287 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); |
| 241 | defer arena.deinit(); | 288 | defer arena.deinit(); |
src/tui/wall_test_picker.zig
| Old | New | ||
|---|---|---|---|
| @@ -559,6 +559,39 @@ test "hostState: the row counts the sessions the wall could show, not the runs i | |||
| 559 | try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&buf, &h)); | 559 | try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&buf, &h)); |
| 560 | } | 560 | } |
| 561 | 561 | ||
| 562 | test "hostState: a drifted daemon says so beside its count, within the row's buffer" { | ||
| 563 | var shared: Shared = undefined; | ||
| 564 | fixture.stoppedWall(std.testing.allocator, &shared); | ||
| 565 | defer shared.tree.deinit(); | ||
| 566 | var h = Host{ | ||
| 567 | .spec = .{ .spelling = "--sock /a", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } }, | ||
| 568 | .shared = &shared, | ||
| 569 | }; | ||
| 570 | h.applied = true; | ||
| 571 | const listed = "a\n"; | ||
| 572 | @memcpy(h.poll.list[0..listed.len], listed); | ||
| 573 | h.poll.list_len = listed.len; | ||
| 574 | // The keyboard's applied word, the same one the tiles wear: the row | ||
| 575 | // reads it rather than re-judging the payload, so popup and bar can | ||
| 576 | // never disagree about one host. | ||
| 577 | const word = "daemon 0.9 stale"; | ||
| 578 | @memcpy(h.drift[0..word.len], word); | ||
| 579 | h.drift_len = word.len; | ||
| 580 | |||
| 581 | var buf: [32]u8 = undefined; | ||
| 582 | try std.testing.expectEqualStrings("1 session, daemon 0.9 stale", wall_picker.hostState(&buf, &h)); | ||
| 583 | |||
| 584 | // The row's buffer is the bound, and truncation is the contract — | ||
| 585 | // never an overflow, never stack garbage. | ||
| 586 | var narrow: [12]u8 = undefined; | ||
| 587 | try std.testing.expectEqualStrings("1 session, d", wall_picker.hostState(&narrow, &h)); | ||
| 588 | |||
| 589 | // A dark box keeps its reason: drift is news about a daemon that IS | ||
| 590 | // answering, and `unreachable` outranks it. | ||
| 591 | h.poll.reachable.store(false, .release); | ||
| 592 | try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&buf, &h)); | ||
| 593 | } | ||
| 594 | |||
| 562 | test "hostState: an unreachable host says what ssh said, cut to the row's buffer" { | 595 | test "hostState: an unreachable host says what ssh said, cut to the row's buffer" { |
| 563 | // `unreachable` alone is a row the user cannot act on: a box that is | 596 | // `unreachable` alone is a row the user cannot act on: a box that is |
| 564 | // off, a key that was refused and a host name that does not resolve | 597 | // off, a key that was refused and a host name that does not resolve |
src/tui/wall_test_wall.zig
| Old | New | ||
|---|---|---|---|
| @@ -572,6 +572,28 @@ test "sendKeys: a chunk that does not fit is dropped whole, and says so" { | |||
| 572 | try std.testing.expect(!t.in_dropped.load(.acquire)); | 572 | try std.testing.expect(!t.in_dropped.load(.acquire)); |
| 573 | } | 573 | } |
| 574 | 574 | ||
| 575 | test "driftWord: says only what differs — version, staleness, both, or nothing" { | ||
| 576 | var buf: [wv.drift_max]u8 = undefined; | ||
| 577 | const same: proto.SessionsMeta = .{ .version = "1.0", .stale = false }; | ||
| 578 | const old: proto.SessionsMeta = .{ .version = "0.9", .stale = false }; | ||
| 579 | const same_stale: proto.SessionsMeta = .{ .version = "1.0", .stale = true }; | ||
| 580 | const old_stale: proto.SessionsMeta = .{ .version = "0.9", .stale = true }; | ||
| 581 | |||
| 582 | // The common case is silence: a daemon of our own version on its own | ||
| 583 | // living image, and a daemon too old to say anything at all. | ||
| 584 | try std.testing.expectEqualStrings("", wv.driftWord(&buf, "1.0", same)); | ||
| 585 | try std.testing.expectEqualStrings("", wv.driftWord(&buf, "1.0", null)); | ||
| 586 | |||
| 587 | try std.testing.expectEqualStrings("daemon 0.9", wv.driftWord(&buf, "1.0", old)); | ||
| 588 | try std.testing.expectEqualStrings("daemon stale", wv.driftWord(&buf, "1.0", same_stale)); | ||
| 589 | try std.testing.expectEqualStrings("daemon 0.9 stale", wv.driftWord(&buf, "1.0", old_stale)); | ||
| 590 | |||
| 591 | // A client that does not know its own version cannot judge anyone | ||
| 592 | // else's, but a replaced image is the daemon's own report and stands. | ||
| 593 | try std.testing.expectEqualStrings("", wv.driftWord(&buf, "", old)); | ||
| 594 | try std.testing.expectEqualStrings("daemon stale", wv.driftWord(&buf, "", old_stale)); | ||
| 595 | } | ||
| 596 | |||
| 575 | test "labelText: a dropped chunk is narrated beside the state that caused it" { | 597 | test "labelText: a dropped chunk is narrated beside the state that caused it" { |
| 576 | // The bar's own format, asserted through the same truncation path the | 598 | // The bar's own format, asserted through the same truncation path the |
| 577 | // states go through, because "reconnecting, input dropped" is now the | 599 | // states go through, because "reconnecting, input dropped" is now the |
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -105,6 +105,11 @@ pub const Shared = struct { | |||
| 105 | /// Whether there is a terminal here at all. Every WALL-level write is | 105 | /// Whether there is a terminal here at all. Every WALL-level write is |
| 106 | /// gated on it: on a pipe mux writes the grid and no terminal state. | 106 | /// gated on it: on a pipe mux writes the grid and no terminal state. |
| 107 | is_tty: bool, | 107 | is_tty: bool, |
| 108 | /// This client's own version, what a host's stated version is judged | ||
| 109 | /// against for the bar's drift word. Empty means unknown, which judges | ||
| 110 | /// nobody — see `driftWord`. Set once before any pump exists, read-only | ||
| 111 | /// after, so it rides `Shared` without a lock. | ||
| 112 | own_version: []const u8 = "", | ||
| 108 | /// The pumps' doorbell to the KEYBOARD — the mirror of `Tile.wake_w`. | 113 | /// The pumps' doorbell to the KEYBOARD — the mirror of `Tile.wake_w`. |
| 109 | /// Without it a shell that exits goes unnoticed until the next keystroke. | 114 | /// Without it a shell that exits goes unnoticed until the next keystroke. |
| 110 | kb_r: std.posix.fd_t = -1, | 115 | kb_r: std.posix.fd_t = -1, |
| @@ -329,6 +334,13 @@ pub const Tile = struct { | |||
| 329 | /// False for the ENTRY tile — a bad host or a typo'd command helps nobody | 334 | /// False for the ENTRY tile — a bad host or a typo'd command helps nobody |
| 330 | /// to retry. True for wall tiles: a wall is a thing you leave up. | 335 | /// to retry. True for wall tiles: a wall is a thing you leave up. |
| 331 | retry_cold: bool = true, | 336 | retry_cold: bool = true, |
| 337 | /// The host's version-drift word as the keyboard last applied it, empty | ||
| 338 | /// when there is none to say. A `paint_mu` field like `state`: written | ||
| 339 | /// by the keyboard in `wall_host.applyHostList`, read wherever the bar | ||
| 340 | /// is painted — the pump paints its own bar, and a pending pane's is | ||
| 341 | /// painted by the keyboard, so the word has to live where both look. | ||
| 342 | drift: [drift_max]u8 = undefined, | ||
| 343 | drift_len: usize = 0, | ||
| 332 | /// Why this tile's pump stopped, and with what code. Written BEFORE | 344 | /// Why this tile's pump stopped, and with what code. Written BEFORE |
| 333 | /// `alive` clears, so a keyboard that sees a dead tile can always read | 345 | /// `alive` clears, so a keyboard that sees a dead tile can always read |
| 334 | /// a reason for it. | 346 | /// a reason for it. |
| @@ -528,6 +540,33 @@ pub fn paintLabel(t: *Tile, state: State) void { | |||
| 528 | if (t.shared.labelRows() != 0) paintLabelLocked(t); | 540 | if (t.shared.labelRows() != 0) paintLabelLocked(t); |
| 529 | } | 541 | } |
| 530 | 542 | ||
| 543 | /// The longest drift word: "daemon " + a capped version + " stale". | ||
| 544 | pub const drift_max = "daemon ".len + proto.sessions_meta_version_max + " stale".len; | ||
| 545 | |||
| 546 | /// The bar's version-drift word, composed from what the host's daemon said | ||
| 547 | /// about itself and what this client is. Says only what DIFFERS: a version | ||
| 548 | /// unequal to our own, a replaced image, both, or — the common case — | ||
| 549 | /// nothing at all. An unknown own version withholds the version half | ||
| 550 | /// rather than calling every daemon drifted; staleness needs no comparison | ||
| 551 | /// and is said regardless. | ||
| 552 | pub fn driftWord( | ||
| 553 | buf: *[drift_max]u8, | ||
| 554 | own_version: []const u8, | ||
| 555 | meta: ?proto.SessionsMeta, | ||
| 556 | ) []const u8 { | ||
| 557 | const m = meta orelse return ""; | ||
| 558 | // Re-capped here rather than trusted: the parser bounds what it yields, | ||
| 559 | // but this function's contract is `drift_max`, not its caller's manners. | ||
| 560 | const ver = m.version[0..@min(m.version.len, proto.sessions_meta_version_max)]; | ||
| 561 | const differs = own_version.len != 0 and !std.mem.eql(u8, own_version, ver); | ||
| 562 | if (differs and m.stale) | ||
| 563 | return std.fmt.bufPrint(buf, "daemon {s} stale", .{ver}) catch ""; | ||
| 564 | if (differs) | ||
| 565 | return std.fmt.bufPrint(buf, "daemon {s}", .{ver}) catch ""; | ||
| 566 | if (m.stale) return "daemon stale"; | ||
| 567 | return ""; | ||
| 568 | } | ||
| 569 | |||
| 531 | /// Bounded by `cols` AND by `buf`: `cols` alone overran past 250 columns. | 570 | /// Bounded by `cols` AND by `buf`: `cols` alone overran past 250 columns. |
| 532 | pub fn labelText( | 571 | pub fn labelText( |
| 533 | buf: []u8, | 572 | buf: []u8, |
| @@ -567,12 +606,22 @@ pub fn paintLabelLocked(t: *Tile) void { | |||
| 567 | // Keystrokes the mailbox had no room for are said where the eye already | 606 | // Keystrokes the mailbox had no room for are said where the eye already |
| 568 | // is, beside the state that explains them: input is only ever dropped | 607 | // is, beside the state that explains them: input is only ever dropped |
| 569 | // by a pump that stopped reading, so the state word is the reason and | 608 | // by a pump that stopped reading, so the state word is the reason and |
| 570 | // this is the consequence. | 609 | // this is the consequence. The host's drift word rides the same bracket |
| 571 | var status_buf: [64]u8 = undefined; | 610 | // — one place a bar says everything that is wrong, last because it is |
| 572 | const status: []const u8 = if (t.in_dropped.load(.acquire)) | 611 | // the least urgent thing in it. |
| 573 | std.fmt.bufPrint(&status_buf, "{s}, input dropped", .{t.state.word()}) catch t.state.word() | 612 | var status_buf: [160]u8 = undefined; |
| 574 | else | 613 | const word = t.state.word(); |
| 575 | t.state.word(); | 614 | const drift = t.drift[0..t.drift_len]; |
| 615 | const dropped = t.in_dropped.load(.acquire); | ||
| 616 | const status: []const u8 = blk: { | ||
| 617 | if (dropped and drift.len != 0) | ||
| 618 | break :blk std.fmt.bufPrint(&status_buf, "{s}, input dropped, {s}", .{ word, drift }) catch word; | ||
| 619 | if (dropped) | ||
| 620 | break :blk std.fmt.bufPrint(&status_buf, "{s}, input dropped", .{word}) catch word; | ||
| 621 | if (drift.len != 0) | ||
| 622 | break :blk std.fmt.bufPrint(&status_buf, "{s}, {s}", .{ word, drift }) catch word; | ||
| 623 | break :blk word; | ||
| 624 | }; | ||
| 576 | var text_buf: [256]u8 = undefined; | 625 | var text_buf: [256]u8 = undefined; |
| 577 | const shown = labelText(&text_buf, t.rect.cols, marker, t.r.label, status); | 626 | const shown = labelText(&text_buf, t.rect.cols, marker, t.r.label, status); |
| 578 | var out: [1024]u8 = undefined; | 627 | var out: [1024]u8 = undefined; |
| @@ -1401,6 +1450,7 @@ pub fn runAttach( | |||
| 1401 | key: ?[]const u8, | 1450 | key: ?[]const u8, |
| 1402 | idle_ms: u32, | 1451 | idle_ms: u32, |
| 1403 | agent: bool, | 1452 | agent: bool, |
| 1453 | own_version: []const u8, | ||
| 1404 | ) !u8 { | 1454 | ) !u8 { |
| 1405 | // Anything typed while the first handshake is in flight belongs to the | 1455 | // Anything typed while the first handshake is in flight belongs to the |
| 1406 | // shell, so it is held rather than dropped — and handed to the tile's | 1456 | // shell, so it is held rather than dropped — and handed to the tile's |
| @@ -1459,6 +1509,7 @@ pub fn runAttach( | |||
| 1459 | .entry_host = 0, | 1509 | .entry_host = 0, |
| 1460 | .entry_session = proto.resolveName(session_name), | 1510 | .entry_session = proto.resolveName(session_name), |
| 1461 | .agent = agent, | 1511 | .agent = agent, |
| 1512 | .own_version = own_version, | ||
| 1462 | .key = key, | 1513 | .key = key, |
| 1463 | .idle_ms = idle_ms, | 1514 | .idle_ms = idle_ms, |
| 1464 | // A scripted `mux` has pipes on both ends and is still a wall of | 1515 | // A scripted `mux` has pipes on both ends and is still a wall of |
| @@ -1494,6 +1545,9 @@ pub const Entry = struct { | |||
| 1494 | /// Per tile, not per wall: a tile the user never named must not hand a | 1545 | /// Per tile, not per wall: a tile the user never named must not hand a |
| 1495 | /// stranger's host the keys. | 1546 | /// stranger's host the keys. |
| 1496 | agent: bool = false, | 1547 | agent: bool = false, |
| 1548 | /// This client's own version, handed in as data because only the cli | ||
| 1549 | /// modules import `build_options`. `Shared.own_version` is set from it. | ||
| 1550 | own_version: []const u8 = "", | ||
| 1497 | /// The key and idle timeout a `quic://` host is resolved with — the | 1551 | /// The key and idle timeout a `quic://` host is resolved with — the |
| 1498 | /// ones this invocation was given. | 1552 | /// ones this invocation was given. |
| 1499 | key: ?[]const u8 = null, | 1553 | key: ?[]const u8 = null, |
| @@ -1522,7 +1576,12 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1522 | // size nothing can paint at. | 1576 | // size nothing can paint at. |
| 1523 | const is_tty = std.posix.isatty(stdin_fd); | 1577 | const is_tty = std.posix.isatty(stdin_fd); |
| 1524 | 1578 | ||
| 1525 | var shared = Shared{ .out_fd = stdout_fd, .size = size, .is_tty = is_tty }; | 1579 | var shared = Shared{ |
| 1580 | .out_fd = stdout_fd, | ||
| 1581 | .size = size, | ||
| 1582 | .is_tty = is_tty, | ||
| 1583 | .own_version = entry.own_version, | ||
| 1584 | }; | ||
| 1526 | shared.tree = layout.Tree.init(alloc); | 1585 | shared.tree = layout.Tree.init(alloc); |
| 1527 | shared.flat_alloc = alloc; | 1586 | shared.flat_alloc = alloc; |
| 1528 | const env_sock = std.posix.getenv(proto.sock_env); | 1587 | const env_sock = std.posix.getenv(proto.sock_env); |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -170,8 +170,8 @@ done | |||
| 170 | # one of those and adds a convergence point would be pinning a fact every | 170 | # one of those and adds a convergence point would be pinning a fact every |
| 171 | # leg above already establishes. | 171 | # leg above already establishes. |
| 172 | 172 | ||
| 173 | [ "$OK_COUNT" = "105" ] || { | 173 | [ "$OK_COUNT" = "106" ] || { |
| 174 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 105 —" | 174 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 106 —" |
| 175 | echo " a scenario was added (update the pin) or silently lost" | 175 | echo " a scenario was added (update the pin) or silently lost" |
| 176 | exit 1 | 176 | exit 1 |
| 177 | } | 177 | } |
test/e2e_09_hosts.sh
| Old | New | ||
|---|---|---|---|
| @@ -1284,3 +1284,66 @@ RLAST=$(grep -a '^mux: ' "$OUT.rentry.err" | tail -1) | |||
| 1284 | echo " want 'mux: badhost@127.0.0.1 over ssh: $RSAID'" | 1284 | echo " want 'mux: badhost@127.0.0.1 over ssh: $RSAID'" |
| 1285 | cat "$OUT.rentry.err"; exit 1; } | 1285 | cat "$OUT.rentry.err"; exit 1; } |
| 1286 | ok "the entry dial relays ssh's stderr and fails in ssh's own words" | 1286 | ok "the entry dial relays ssh's stderr and fails in ssh's own words" |
| 1287 | |||
| 1288 | # ---- a replaced binary under a running daemon reaches the bar ------------ | ||
| 1289 | # `make install` swaps the file on disk and touches nothing that is | ||
| 1290 | # running: the daemon keeps serving the old image, every long-lived wall | ||
| 1291 | # keeps painting with it, and for months nothing anywhere said so — the | ||
| 1292 | # night of 2026-09-01 was a wall flooding its screen with a bug that had | ||
| 1293 | # been FIXED on disk for three days. The daemon now reads its own | ||
| 1294 | # /proc/self/exe per sessions_req and reports the kernel's ` (deleted)` | ||
| 1295 | # suffix as `stale`; the poll carries it; the bar wears it. | ||
| 1296 | # | ||
| 1297 | # Client and daemon here are the SAME build, so the version half of the | ||
| 1298 | # drift word stays silent and `daemon stale` is the whole of it — which | ||
| 1299 | # makes run 1 the other half of the claim: a live image, same version, | ||
| 1300 | # paints NOTHING. The needle is asserted with the state word beside it | ||
| 1301 | # (`[up, daemon stale]`), pinning where in the bar the word lands. | ||
| 1302 | SBSTATE="${TMPDIR:-/tmp}/mux-e2e-sb-state-$$" | ||
| 1303 | defer_rm "$SBSTATE" | ||
| 1304 | SBBIN="${TMPDIR:-/tmp}/mux-e2e-sb-bin-$$" | ||
| 1305 | defer_rm "$SBBIN" | ||
| 1306 | SBSOCK="${TMPDIR:-/tmp}/muxd-e2e-sb-$$.sock" | ||
| 1307 | defer_sock "$SBSOCK" | ||
| 1308 | mkdir -p "$SBBIN" "$SBSTATE/mux" | ||
| 1309 | cp "$MUX" "$SBBIN/mux" | ||
| 1310 | "$SBBIN/mux" d start --sock "$SBSOCK" --shell /bin/sh > "$OUT.sb.d" 2>&1 & | ||
| 1311 | defer_kill "$!" | ||
| 1312 | wait_sock "$SBSOCK" "$OUT.sb.d" "stale-bit daemon never bound" | ||
| 1313 | |||
| 1314 | # Run 1: the image on disk IS the image running — the bar says nothing. | ||
| 1315 | set +e | ||
| 1316 | XDG_STATE_HOME="$SBSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \ | ||
| 1317 | --out "$OUT.sb1" --err "$OUT.sb1.err" -- \ | ||
| 1318 | "$MUX" --sock "$SBSOCK" > "$OUT.sb1.pc" 2>&1 <<'EOF' | ||
| 1319 | expect [up] 15000 | ||
| 1320 | settle 2500 20000 | ||
| 1321 | send \x1cd | ||
| 1322 | waitexit 15000 | ||
| 1323 | EOF | ||
| 1324 | RC=$? | ||
| 1325 | set -e | ||
| 1326 | rc0 "stale-bit: the pre-replace wall leg" "$OUT.sb1.pc" "$OUT.sb1.err" | ||
| 1327 | grep -aq "daemon stale" "$OUT.sb1" && { | ||
| 1328 | echo "e2e FAIL: stale-bit: a daemon on its own living image was called stale" | ||
| 1329 | exit 1; } | ||
| 1330 | |||
| 1331 | # The install, by the same inode dance install(1) does: a new file RENAMED | ||
| 1332 | # over the old, so the running daemon's exe link goes ` (deleted)`. A | ||
| 1333 | # truncating copy would reuse the inode and prove nothing. | ||
| 1334 | cp "$MUX" "$SBBIN/mux.new" | ||
| 1335 | mv -f "$SBBIN/mux.new" "$SBBIN/mux" | ||
| 1336 | |||
| 1337 | # Run 2: the same daemon, one poll cycle later, wearing the word. | ||
| 1338 | set +e | ||
| 1339 | XDG_STATE_HOME="$SBSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \ | ||
| 1340 | --out "$OUT.sb2" --err "$OUT.sb2.err" -- \ | ||
| 1341 | "$MUX" --sock "$SBSOCK" > "$OUT.sb2.pc" 2>&1 <<'EOF' | ||
| 1342 | expect [up, daemon stale] 25000 | ||
| 1343 | send \x1cd | ||
| 1344 | waitexit 15000 | ||
| 1345 | EOF | ||
| 1346 | RC=$? | ||
| 1347 | set -e | ||
| 1348 | rc0 "stale-bit: the post-replace wall leg" "$OUT.sb2.pc" "$OUT.sb2.err" | ||
| 1349 | ok "a replaced binary under a running daemon reaches the bar as daemon stale" | ||