a73x

c8463a07

feat: sessions_reply says how many clients hold each session

a73x   2026-09-03 05:20

Commit message
feat: sessions_reply says how many clients hold each session

src/engine/protocol.zig
Old New
@@ -927,11 +927,57 @@ pub const sessions_meta_version_max = 32;
927 /// `sessions_text_max`. 927 /// `sessions_text_max`.
928 pub const sessions_meta_max = 928 pub const sessions_meta_max =
929 1 + sessions_meta_prefix.len + sessions_meta_version_max + sessions_meta_stale_word.len; 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 930 /// One `# holds NAME N` line per session, appended by a daemon that can
931 /// meta line. `listSessions` refuses an overlong payload as a transport 931 /// count: how many clients hold that session. The picker's session list
932 /// error, so a receiver sized to `sessions_text_max` alone would read a 932 /// shows it, and the end key's first press is judged against it. Spelled
933 /// full daemon that states its version as an unreachable box. 933 /// as a `#` line so `sessionsIter`, which yields only valid session names,
934 pub const sessions_reply_max = sessions_text_max + sessions_meta_max; 934 /// skips it on a client that predates it — exactly as it skips the meta
935 /// line — and a daemon that predates it sends none, which
936 /// `parseSessionsHolds` reads as unknown.
937 pub const sessions_holds_prefix = "# holds ";
938 /// `# holds NAME N\n` at its widest: N is a u8, so at most three digits.
939 pub const sessions_holds_line_max = sessions_holds_prefix.len + session_name_max + 1 + 3 + 1;
940 /// What a `sessions_reply` READER must have room for: every name, every
941 /// holds line, and the meta line. `listSessions` refuses an overlong
942 /// payload as a transport error, so a receiver sized to `sessions_text_max`
943 /// alone would read a full daemon that states its version as an
944 /// unreachable box.
945 pub const sessions_reply_max = sessions_text_max + sessions_max * sessions_holds_line_max + sessions_meta_max;
946
947 /// Append one holds line to the payload already in `buf[0..len]` and return
948 /// the new length. Same join rule as `appendSessionsMeta` — a separator only
949 /// when there is something to separate from — and the same tolerance for a
950 /// buffer that cannot take the line: the payload is returned unchanged
951 /// rather than truncated, so a reader sees the count as unknown instead of
952 /// reading half a line. `sessions_reply_max` is sized so a daemon never
953 /// reaches that branch.
954 pub fn appendSessionsHolds(buf: []u8, len: usize, name: []const u8, holds: u8) usize {
955 var w = len;
956 if (w != 0) {
957 if (w >= buf.len) return len;
958 buf[w] = '\n';
959 w += 1;
960 }
961 const line = std.fmt.bufPrint(buf[w..], "{s}{s} {d}", .{ sessions_holds_prefix, name, holds }) catch return len;
962 return w + line.len;
963 }
964
965 /// The reader's half, across the same trust boundary as `sessionsIter`: the
966 /// payload is a peer's bytes, so anything that is not this exact shape reads
967 /// as absent. Null is "this daemon did not say", never zero — an old daemon
968 /// sends no holds line at all, and a caller that painted null as 0 would
969 /// claim nobody holds a session it cannot count.
970 pub fn parseSessionsHolds(payload: []const u8, name: []const u8) ?u8 {
971 var lines = std.mem.splitScalar(u8, payload, '\n');
972 while (lines.next()) |line| {
973 if (!std.mem.startsWith(u8, line, sessions_holds_prefix)) continue;
974 const rest = line[sessions_holds_prefix.len..];
975 const sp = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse continue;
976 if (!std.mem.eql(u8, rest[0..sp], name)) continue;
977 return std.fmt.parseInt(u8, rest[sp + 1 ..], 10) catch continue;
978 }
979 return null;
980 }
935 981
936 pub const SessionsMeta = struct { version: []const u8, stale: bool }; 982 pub const SessionsMeta = struct { version: []const u8, stale: bool };
937 983
@@ -1910,6 +1956,40 @@ test "sessions meta: junk is absent, never a version" {
1910 try std.testing.expect(meta.stale); 1956 try std.testing.expect(meta.stale);
1911 } 1957 }
1912 1958
1959 test "sessions holds: a holds line rides beside the names, old readers skip it, and a name reads its own count" {
1960 var buf: [sessions_reply_max]u8 = undefined;
1961 @memcpy(buf[0..4], "0\nwk");
1962 var len: usize = 4;
1963 len = appendSessionsHolds(&buf, len, "0", 1);
1964 len = appendSessionsHolds(&buf, len, "wk", 0);
1965 const payload = buf[0..len];
1966 try std.testing.expectEqualStrings("0\nwk\n# holds 0 1\n# holds wk 0", payload);
1967
1968 // The iterator every old client walks yields the names and nothing else.
1969 var it = sessionsIter(payload);
1970 try std.testing.expectEqualStrings("0", it.next().?);
1971 try std.testing.expectEqualStrings("wk", it.next().?);
1972 try std.testing.expect(it.next() == null);
1973
1974 try std.testing.expectEqual(@as(?u8, 1), parseSessionsHolds(payload, "0"));
1975 try std.testing.expectEqual(@as(?u8, 0), parseSessionsHolds(payload, "wk"));
1976 // A name the daemon did not count, and an old daemon's payload with no
1977 // holds lines at all, both read as unknown rather than zero.
1978 try std.testing.expect(parseSessionsHolds(payload, "w") == null);
1979 try std.testing.expect(parseSessionsHolds("0\nwk", "0") == null);
1980 // The meta line and the holds lines coexist in either order.
1981 const with_meta = appendSessionsMeta(&buf, len, "0.0.1-18", false);
1982 try std.testing.expectEqual(@as(?u8, 1), parseSessionsHolds(buf[0..with_meta], "0"));
1983 try std.testing.expectEqualStrings("0.0.1-18", parseSessionsMeta(buf[0..with_meta]).?.version);
1984 }
1985
1986 test "sessions holds: sessions_reply_max holds every session's name, holds line and the meta line" {
1987 // The daemon writes names, then one holds line per session, then meta,
1988 // into ONE buffer of this size; the bound must cover the worst case.
1989 const worst_line = sessions_holds_prefix.len + session_name_max + 1 + 3;
1990 try std.testing.expect(sessions_reply_max >= sessions_text_max + sessions_max * (worst_line + 1) + sessions_meta_max);
1991 }
1992
1913 test "wireName: the default session is spelled as the empty tail" { 1993 test "wireName: the default session is spelled as the empty tail" {
1914 try std.testing.expectEqualStrings("", wireName("0")); 1994 try std.testing.expectEqualStrings("", wireName("0"));
1915 try std.testing.expectEqualStrings("", wireName(resolveName(""))); 1995 try std.testing.expectEqualStrings("", wireName(resolveName("")));
src/server/server.zig
Old New
@@ -1795,13 +1795,19 @@ pub const Server = struct {
1795 // activity, so it claims no grid. 1795 // activity, so it claims no grid.
1796 var buf: [proto.sessions_reply_max]u8 = undefined; 1796 var buf: [proto.sessions_reply_max]u8 = undefined;
1797 const names = self.sessions.text(buf[0..proto.sessions_text_max]); 1797 const names = self.sessions.text(buf[0..proto.sessions_text_max]);
1798 // A daemon with no version to state appends nothing, so a 1798 // The holds lines and the meta line ride the same gate: a
1799 // bare fixture's payload stays byte-identical to the old 1799 // daemon with no version to state appends nothing, so a bare
1800 // wire and the sibling exact-equality test keeps pinning it. 1800 // fixture's payload stays byte-identical to the old wire and
1801 const len = if (self.version.len != 0) 1801 // the sibling exact-equality test keeps pinning it.
1802 proto.appendSessionsMeta(&buf, names.len, self.version, selfImageStale()) 1802 var len = names.len;
1803 else 1803 if (self.version.len != 0) {
1804 names.len; 1804 for (self.sessions.table, 0..) |slot, si| {
1805 const s = slot orelse continue;
1806 const holds: u8 = @intCast(@min(self.clientsInSession(si), std.math.maxInt(u8)));
1807 len = proto.appendSessionsHolds(&buf, len, s.name(), holds);
1808 }
1809 len = proto.appendSessionsMeta(&buf, len, self.version, selfImageStale());
1810 }
1805 self.replyTo(p, .sessions_reply, buf[0..len]); 1811 self.replyTo(p, .sessions_reply, buf[0..len]);
1806 }, 1812 },
1807 .endpoint_req => { 1813 .endpoint_req => {
src/server/server_test_session.zig
Old New
@@ -1268,6 +1268,63 @@ test "Server: sessions_reply carries the daemon's own version behind the names"
1268 try std.testing.expect(!meta.stale); 1268 try std.testing.expect(!meta.stale);
1269 } 1269 }
1270 1270
1271 /// "Every client of this session has gone" as a `pumpUntil` context. The
1272 /// close of a socket is the peer's act; the daemon only learns about it on a
1273 /// pump, so a test that asked for the count straight after `close()` would be
1274 /// racing the reap.
1275 const SessionEmptied = struct {
1276 srv: *Server,
1277 si: usize,
1278
1279 fn reached(s: SessionEmptied) bool {
1280 return !s.srv.hasClientsIn(s.si);
1281 }
1282 };
1283
1284 test "Server: sessions_reply states how many clients hold each session" {
1285 const alloc = std.testing.allocator;
1286
1287 var td = try h.TestDaemon.init(alloc, "sesshold", .{
1288 .shell = "/bin/cat",
1289 .version = "0.0.1-test",
1290 });
1291 defer td.deinit();
1292
1293 // One held session and one nobody holds: a fixture where every session
1294 // had the same number of clients could not tell a real count from a
1295 // constant.
1296 const ca = try dial.dialAttach(td.sock_path, 80, 24);
1297 defer ca.close();
1298 (try awaitFrame(alloc, &td.srv, ca.handle, .snapshot, 400) orelse
1299 return error.NoSnapshotA).deinit(alloc);
1300 const cb = try dial.dialAttachNamed(td.sock_path, 80, 24, "wk");
1301 (try awaitFrame(alloc, &td.srv, cb.handle, .snapshot, 400) orelse
1302 return error.NoSnapshotB).deinit(alloc);
1303 cb.close();
1304 const wk = td.srv.sessions.find(proto.wireName("wk")) orelse return error.NoSession;
1305 try std.testing.expect(try h.pumpUntil(&td.srv, 2000, SessionEmptied{
1306 .srv = &td.srv,
1307 .si = wk,
1308 }, SessionEmptied.reached));
1309
1310 try proto.writeFrame(ca.handle, .sessions_req, "");
1311 const reply = (try awaitFrame(alloc, &td.srv, ca.handle, .sessions_reply, 400)) orelse
1312 return error.NoSessionsReply;
1313 defer reply.deinit(alloc);
1314
1315 try std.testing.expectEqual(@as(?u8, 1), proto.parseSessionsHolds(reply.payload, proto.default_session));
1316 try std.testing.expectEqual(@as(?u8, 0), proto.parseSessionsHolds(reply.payload, "wk"));
1317
1318 // The holds lines ride behind the names invisibly, exactly as the meta
1319 // line does, and the meta line still parses out from beside them.
1320 var names = proto.sessionsIter(reply.payload);
1321 try std.testing.expectEqualStrings(proto.default_session, names.next() orelse return error.NameLost);
1322 try std.testing.expectEqualStrings("wk", names.next() orelse return error.NameLost);
1323 try std.testing.expect(names.next() == null);
1324 const meta = proto.parseSessionsMeta(reply.payload) orelse return error.MetaAbsent;
1325 try std.testing.expectEqualStrings("0.0.1-test", meta.version);
1326 }
1327
1271 test "Server: an attached client's mismatched status tail is ignored" { 1328 test "Server: an attached client's mismatched status tail is ignored" {
1272 const alloc = std.testing.allocator; 1329 const alloc = std.testing.allocator;
1273 1330