c8463a07
feat: sessions_reply says how many clients hold each session
a73x 2026-09-03 05:20
Commit message
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 | ||