a73x

a500b0cd

feat: sessions_reply carries the daemon's version and a stale-image bit

a73x   2026-09-02 05:30

Commit message
feat: sessions_reply carries the daemon's version and a stale-image bit

One trailing '# mux <version>[ stale]' line, invisible to every names
reader because sessionsIter refuses '#' and space in a name — an old
client skips it by construction, an old daemon never sends it, and a
new client reads silence as unknown. stale is the daemon's own report
that the installed binary on its box was replaced under it, the one
drift two builds of the same dev version cannot spell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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"