a73x

8bf9f055

feat: the instruments learn session names, and the wire module owns the payload

a73x   2026-08-15 12:21

Commit message
feat: the instruments learn session names, and the wire module owns the payload

`muxd dump --session NAME`, `muxa --session NAME` on every verb, and
`muxd stats` naming every live session. status_req and await_req grow the
same name tail attach already carries — one pattern across every
session-scoped verb, which is what lets an agent address one session of
several WITHOUT attaching to it. An unknown name is answered in words
(`muxd: no such session: NAME`), never a hang and never a fabricated
"connection lost".

The `vt byte ++ name tail` payload and the ""→default rule live in
protocol.zig rather than in the binaries that send them: two copies of a
wire layout is two places for it to drift, and "empty means the default
session" is a fact of the protocol, not a convention five call sites
remember.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

build.zig
Old New
@@ -669,7 +669,7 @@ pub fn build(b: *std.Build) void {
669 e2e_step.dependOn(&e2e.step); 669 e2e_step.dependOn(&e2e.step);
670 670
671 // The agent surface gets a step of its own rather than a place in e2e: 671 // The agent surface gets a step of its own rather than a place in e2e:
672 // its nine scenarios spend ~51s mostly waiting on real idle timeouts and 672 // its ten scenarios spend ~51s mostly waiting on real idle timeouts and
673 // a 20s quiet await, which is a cost the paint-and-convergence suite 673 // a 20s quiet await, which is a cost the paint-and-convergence suite
674 // should not have to carry on every run. A step is what makes it a gate 674 // should not have to carry on every run. A step is what makes it a gate
675 // at all — M7's rule, paid for twice: an end-to-end property that only 675 // at all — M7's rule, paid for twice: an end-to-end property that only
src/main.zig
Old New
@@ -17,7 +17,7 @@ const usage =
17 \\usage: 17 \\usage:
18 \\ muxd run [--sock PATH] [--shell PATH] [--cols N] [--rows N] 18 \\ muxd run [--sock PATH] [--shell PATH] [--cols N] [--rows N]
19 \\ [--quic HOST[:PORT] --key FILE] [--quic-idle-ms N] 19 \\ [--quic HOST[:PORT] --key FILE] [--quic-idle-ms N]
20 \\ muxd dump [--vt] [--sock PATH] 20 \\ muxd dump [--vt] [--session NAME] [--sock PATH]
21 \\ muxd stats [--sock PATH] 21 \\ muxd stats [--sock PATH]
22 \\ muxd stop [--sock PATH] (ask the daemon on PATH to exit) 22 \\ muxd stop [--sock PATH] (ask the daemon on PATH to exit)
23 \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket) 23 \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket)
@@ -145,6 +145,10 @@ const Opts = struct {
145 /// reading that makes it sensible. 145 /// reading that makes it sensible.
146 key: ?[]const u8 = null, 146 key: ?[]const u8 = null,
147 quic_idle_ms: u32 = default_quic_idle_ms, 147 quic_idle_ms: u32 = default_quic_idle_ms,
148 /// The session `dump` names. Empty is the wire's own default spelling
149 /// — no tail at all — so a caller that never passes `--session` builds
150 /// byte-identical payloads to before this flag existed.
151 session: []const u8 = "",
148 }; 152 };
149 153
150 /// A refusal, carrying whatever `main` needs to print one line about it. 154 /// A refusal, carrying whatever `main` needs to print one line about it.
@@ -158,6 +162,9 @@ const Usage = union(enum) {
158 /// The flag whose value would not parse as the number it wants. 162 /// The flag whose value would not parse as the number it wants.
159 bad_number: []const u8, 163 bad_number: []const u8,
160 key_without_quic, 164 key_without_quic,
165 /// The name itself, not the flag: `muxd: bad session name: {s}` names
166 /// what was typed, which is the actionable half.
167 bad_session_name: []const u8,
161 }; 168 };
162 169
163 const ParseResult = union(enum) { ok: Opts, err: Usage }; 170 const ParseResult = union(enum) { ok: Opts, err: Usage };
@@ -192,7 +199,8 @@ fn parseArgs(args: []const [:0]const u8) ParseResult {
192 std.mem.eql(u8, a, "--rows") or 199 std.mem.eql(u8, a, "--rows") or
193 std.mem.eql(u8, a, "--quic") or 200 std.mem.eql(u8, a, "--quic") or
194 std.mem.eql(u8, a, "--key") or 201 std.mem.eql(u8, a, "--key") or
195 std.mem.eql(u8, a, "--quic-idle-ms"); 202 std.mem.eql(u8, a, "--quic-idle-ms") or
203 std.mem.eql(u8, a, "--session");
196 if (!takes_value) return .{ .err = .{ .unknown_arg = a } }; 204 if (!takes_value) return .{ .err = .{ .unknown_arg = a } };
197 if (i + 1 >= args.len) return .{ .err = .{ .missing_value = a } }; 205 if (i + 1 >= args.len) return .{ .err = .{ .missing_value = a } };
198 i += 1; 206 i += 1;
@@ -205,6 +213,12 @@ fn parseArgs(args: []const [:0]const u8) ParseResult {
205 o.quic = v; 213 o.quic = v;
206 } else if (std.mem.eql(u8, a, "--key")) { 214 } else if (std.mem.eql(u8, a, "--key")) {
207 o.key = v; 215 o.key = v;
216 } else if (std.mem.eql(u8, a, "--session")) {
217 // Refused here rather than carried to the wire as a payload
218 // nothing could ever look up — the same "check before it
219 // becomes a frame" the socket-path length guard follows.
220 if (!proto.validSessionName(v)) return .{ .err = .{ .bad_session_name = v } };
221 o.session = v;
208 } else if (std.mem.eql(u8, a, "--cols")) { 222 } else if (std.mem.eql(u8, a, "--cols")) {
209 o.cols = std.fmt.parseInt(u16, v, 10) catch return .{ .err = .{ .bad_number = a } }; 223 o.cols = std.fmt.parseInt(u16, v, 10) catch return .{ .err = .{ .bad_number = a } };
210 } else if (std.mem.eql(u8, a, "--rows")) { 224 } else if (std.mem.eql(u8, a, "--rows")) {
@@ -240,6 +254,7 @@ fn usageExit(u: Usage) u8 {
240 "muxd: --key without --quic has nothing to listen on; name both or neither\n", 254 "muxd: --key without --quic has nothing to listen on; name both or neither\n",
241 .{}, 255 .{},
242 ), 256 ),
257 .bad_session_name => |n| std.debug.print("muxd: bad session name: {s}\n{s}", .{ n, usage }),
243 } 258 }
244 return 2; 259 return 2;
245 } 260 }
@@ -327,7 +342,7 @@ pub fn main() !u8 {
327 .keygen => return keygen(alloc), 342 .keygen => return keygen(alloc),
328 .start => return startCmd(alloc, sock_path, args[2..]), 343 .start => return startCmd(alloc, sock_path, args[2..]),
329 .run => return run(alloc, o, sock_path), 344 .run => return run(alloc, o, sock_path),
330 .dump => return dump(alloc, sock_path, o.vt), 345 .dump => return dump(alloc, sock_path, o.vt, o.session),
331 .stats => return stats(alloc, sock_path), 346 .stats => return stats(alloc, sock_path),
332 .stop => return stopCmd(alloc, sock_path), 347 .stop => return stopCmd(alloc, sock_path),
333 .proxy => { 348 .proxy => {
@@ -530,9 +545,13 @@ fn oneShotQuery(
530 return 1; 545 return 1;
531 } 546 }
532 547
533 fn dump(alloc: std.mem.Allocator, sock_path: []const u8, vt_mode: bool) !u8 { 548 fn dump(alloc: std.mem.Allocator, sock_path: []const u8, vt_mode: bool, session: []const u8) !u8 {
534 const payload = [_]u8{if (vt_mode) 1 else 0}; 549 // vt byte ++ session-name tail, built by the wire module — empty is the
535 return oneShotQuery(alloc, sock_path, "dump", .debug_dump, &payload, .dump_reply); 550 // wire's own default spelling, so a bare `muxd dump` sends exactly the
551 // pre-M18 one-byte payload.
552 var buf: [proto.debug_dump_max_len]u8 = undefined;
553 const payload = proto.encodeDebugDumpNamed(&buf, vt_mode, session);
554 return oneShotQuery(alloc, sock_path, "dump", .debug_dump, payload, .dump_reply);
536 } 555 }
537 556
538 fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { 557 fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
@@ -961,6 +980,8 @@ test "parseArgs: subcommands and their existing flags" {
961 try std.testing.expect(d.ok.cmd == .dump); 980 try std.testing.expect(d.ok.cmd == .dump);
962 try std.testing.expect(d.ok.vt); 981 try std.testing.expect(d.ok.vt);
963 try std.testing.expectEqualStrings("/tmp/x.sock", d.ok.sock.?); 982 try std.testing.expectEqualStrings("/tmp/x.sock", d.ok.sock.?);
983 // No --session named: the wire's own default spelling, empty.
984 try std.testing.expectEqualStrings("", d.ok.session);
964 985
965 const g = parse(&.{ "muxd", "run", "--cols", "120", "--rows", "40", "--shell", "/bin/dash" }); 986 const g = parse(&.{ "muxd", "run", "--cols", "120", "--rows", "40", "--shell", "/bin/dash" });
966 try std.testing.expectEqual(@as(u16, 120), g.ok.cols); 987 try std.testing.expectEqual(@as(u16, 120), g.ok.cols);
@@ -972,6 +993,18 @@ test "parseArgs: subcommands and their existing flags" {
972 try std.testing.expect(parse(&.{ "muxd", "run", "--wat" }).err == .unknown_arg); 993 try std.testing.expect(parse(&.{ "muxd", "run", "--wat" }).err == .unknown_arg);
973 } 994 }
974 995
996 test "parse: dump --session rides into the payload" {
997 const d = parse(&.{ "muxd", "dump", "--session", "b", "--sock", "/tmp/x.sock" });
998 try std.testing.expect(d == .ok);
999 try std.testing.expectEqualStrings("b", d.ok.session);
1000
1001 // A name no tool could ever address is refused at parse — usage on
1002 // stderr, never carried to the wire as a payload nothing can look up.
1003 const bad = parse(&.{ "muxd", "dump", "--session", "has space" });
1004 try std.testing.expect(bad.err == .bad_session_name);
1005 try std.testing.expectEqualStrings("has space", bad.err.bad_session_name);
1006 }
1007
975 test "parseArgs: --key without --quic is refused; --quic alone defers to main" { 1008 test "parseArgs: --key without --quic is refused; --quic alone defers to main" {
976 const both = parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433", "--key", "/k" }); 1009 const both = parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433", "--key", "/k" });
977 try std.testing.expect(both == .ok); 1010 try std.testing.expect(both == .ok);
@@ -1025,7 +1058,7 @@ test "parseArgs: --quic-idle-ms defaults, parses, and refuses nonsense" {
1025 test "parseArgs: a value-taking flag at the end of argv names itself" { 1058 test "parseArgs: a value-taking flag at the end of argv names itself" {
1026 // This used to report "unknown argument: --quic", which blames the flag 1059 // This used to report "unknown argument: --quic", which blames the flag
1027 // rather than the missing value. 1060 // rather than the missing value.
1028 inline for (.{ "--sock", "--shell", "--cols", "--rows", "--quic", "--key", "--quic-idle-ms" }) |flag| { 1061 inline for (.{ "--sock", "--shell", "--cols", "--rows", "--quic", "--key", "--quic-idle-ms", "--session" }) |flag| {
1029 const r = parse(&.{ "muxd", "run", flag }); 1062 const r = parse(&.{ "muxd", "run", flag });
1030 try std.testing.expect(r.err == .missing_value); 1063 try std.testing.expect(r.err == .missing_value);
1031 try std.testing.expectEqualStrings(flag, r.err.missing_value); 1064 try std.testing.expectEqualStrings(flag, r.err.missing_value);
src/muxa.zig
Old New
@@ -23,7 +23,7 @@ const xdg = @import("xdg");
23 23
24 const usage = 24 const usage =
25 \\usage: muxa <verb> [--sock PATH | --quic HOST[:PORT] [--key PATH]] 25 \\usage: muxa <verb> [--sock PATH | --quic HOST[:PORT] [--key PATH]]
26 \\ [--settle MS] [--timeout MS] [--vt] [args] 26 \\ [--settle MS] [--timeout MS] [--vt] [--session NAME] [args]
27 \\verbs: 27 \\verbs:
28 \\ status session snapshot as JSON 28 \\ status session snapshot as JSON
29 \\ capture current grid as text (--vt for styled) 29 \\ capture current grid as text (--vt for styled)
@@ -51,6 +51,13 @@ const Opts = struct {
51 // would turn every await into an unbounded wait. 51 // would turn every await into an unbounded wait.
52 timeout_ms: u32 = 30_000, 52 timeout_ms: u32 = 30_000,
53 vt: bool = false, 53 vt: bool = false,
54 /// Which session every verb this invocation makes asks about — the
55 /// attach it opens with AND every ask that follows carry the same
56 /// name, which is what keeps the daemon's attached-tail equality rule
57 /// (server.zig) from ever seeing a mismatch out of this binary. Empty
58 /// is the wire's own default spelling, so a bare `muxa status` builds
59 /// byte-identical frames to before this flag existed.
60 session: []const u8 = "",
54 arg: ?[]const u8 = null, 61 arg: ?[]const u8 = null,
55 }; 62 };
56 63
@@ -93,6 +100,13 @@ fn parseArgs(args: []const [:0]const u8) ?Opts {
93 o.timeout_ms = std.fmt.parseInt(u32, args[i], 10) catch return null; 100 o.timeout_ms = std.fmt.parseInt(u32, args[i], 10) catch return null;
94 } else if (std.mem.eql(u8, a, "--vt")) { 101 } else if (std.mem.eql(u8, a, "--vt")) {
95 o.vt = true; 102 o.vt = true;
103 } else if (std.mem.eql(u8, a, "--session")) {
104 i += 1;
105 if (i >= args.len) return null;
106 // Refused here rather than carried to the wire as a payload
107 // nothing could ever look up: usage exit (2), not a frame.
108 if (!proto.validSessionName(args[i])) return null;
109 o.session = args[i];
96 } else if (o.arg == null and a.len > 0 and a[0] != '-') { 110 } else if (o.arg == null and a.len > 0 and a[0] != '-') {
97 o.arg = a; 111 o.arg = a;
98 } else return null; 112 } else return null;
@@ -206,6 +220,26 @@ test "parseArgs: -- hands the rest to the verb, flags and all" {
206 try std.testing.expectEqual(@as(?Opts, null), parseArgs(&two)); 220 try std.testing.expectEqual(@as(?Opts, null), parseArgs(&two));
207 } 221 }
208 222
223 test "muxa: --session rides every verb; a bad name is usage, not wire bytes" {
224 const a = [_][:0]const u8{ "muxa", "status", "--session", "b" };
225 const o = parseArgs(&a).?;
226 try std.testing.expectEqualStrings("b", o.session);
227
228 // No --session named: the wire's own default spelling, empty.
229 const bare = [_][:0]const u8{ "muxa", "status" };
230 try std.testing.expectEqualStrings("", parseArgs(&bare).?.session);
231
232 // A name no tool could ever address is refused at parse (the usage
233 // exit, 2) rather than reaching a daemon as a payload nothing can
234 // look up.
235 const bad = [_][:0]const u8{ "muxa", "status", "--session", "has space" };
236 try std.testing.expectEqual(@as(?Opts, null), parseArgs(&bad));
237
238 // Dangling like every other value-taking flag.
239 const dangling = [_][:0]const u8{ "muxa", "status", "--session" };
240 try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling));
241 }
242
209 test "parseArgs: --quic and --key, and the pairs that make no sense" { 243 test "parseArgs: --quic and --key, and the pairs that make no sense" {
210 const q = [_][:0]const u8{ "muxa", "status", "--quic", "10.0.0.2:4433" }; 244 const q = [_][:0]const u8{ "muxa", "status", "--quic", "10.0.0.2:4433" };
211 const oq = parseArgs(&q).?; 245 const oq = parseArgs(&q).?;
@@ -1033,9 +1067,9 @@ pub fn main() !u8 {
1033 /// else that distinguishes them, which is the property `--quic` is selling. 1067 /// else that distinguishes them, which is the property `--quic` is selling.
1034 fn dispatch(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 { 1068 fn dispatch(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 {
1035 return switch (o.verb) { 1069 return switch (o.verb) {
1036 .status => verbStatus(alloc, conn, deadline), 1070 .status => verbStatus(alloc, conn, o.session, deadline),
1037 .capture => verbCapture(alloc, conn, o.vt, deadline), 1071 .capture => verbCapture(alloc, conn, o.vt, o.session, deadline),
1038 .send => verbSend(alloc, conn, o.arg, deadline), 1072 .send => verbSend(alloc, conn, o.arg, o.session, deadline),
1039 // The one thing `run` needs that `await` does not, checked here so 1073 // The one thing `run` needs that `await` does not, checked here so
1040 // the shared pipeline below can read `cmdline == null` as "this is 1074 // the shared pipeline below can read `cmdline == null` as "this is
1041 // an await" rather than as "a run that was spelled wrong". 1075 // an await" rather than as "a run that was spelled wrong".
@@ -1097,8 +1131,12 @@ fn openQuicConn(
1097 return .{ .conn = conn }; 1131 return .{ .conn = conn };
1098 } 1132 }
1099 1133
1100 fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) !u8 { 1134 fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, session: []const u8, deadline: i64) !u8 {
1101 conn.sendFrame(.status_req, "", deadline) catch |e| return fail("status: send failed", @errorName(e)); 1135 // status_req's WHOLE payload is the name — this connection never
1136 // attaches (see attachZero's callers; `status` is not one of them), so
1137 // there is no slot for the daemon to fall back to and the tail is the
1138 // only word this ask gets to say.
1139 conn.sendFrame(.status_req, session, deadline) catch |e| return fail("status: send failed", @errorName(e));
1102 const frame = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) { 1140 const frame = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) {
1103 error.SessionExited => return failSessionEnded(conn.session_exit), 1141 error.SessionExited => return failSessionEnded(conn.session_exit),
1104 else => return fail("status: no reply", @errorName(e)), 1142 else => return fail("status: no reply", @errorName(e)),
@@ -1180,9 +1218,12 @@ test "printStatus spells a pending exit code as JSON null" {
1180 ); 1218 );
1181 } 1219 }
1182 1220
1183 fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, deadline: i64) !u8 { 1221 fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, session: []const u8, deadline: i64) !u8 {
1184 const payload = [_]u8{if (vt) 1 else 0}; 1222 // vt byte ++ session-name tail, the same shape muxd's own `dump` sends
1185 conn.sendFrame(.debug_dump, &payload, deadline) catch |e| return fail("capture: send failed", @errorName(e)); 1223 // — and built by the same encoder, so it cannot drift from it.
1224 var buf: [proto.debug_dump_max_len]u8 = undefined;
1225 const payload = proto.encodeDebugDumpNamed(&buf, vt, session);
1226 conn.sendFrame(.debug_dump, payload, deadline) catch |e| return fail("capture: send failed", @errorName(e));
1186 const frame = conn.awaitFrame(.dump_reply, deadline) catch |e| switch (e) { 1227 const frame = conn.awaitFrame(.dump_reply, deadline) catch |e| switch (e) {
1187 error.SessionExited => return failSessionEnded(conn.session_exit), 1228 error.SessionExited => return failSessionEnded(conn.session_exit),
1188 else => return fail("capture: no reply", @errorName(e)), 1229 else => return fail("capture: no reply", @errorName(e)),
@@ -1201,16 +1242,22 @@ fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, deadline: i64) !
1201 /// Join the session claiming NO grid. applySize refuses anything under 2, 1242 /// Join the session claiming NO grid. applySize refuses anything under 2,
1202 /// so the slot stays 0x0 and makes no claim in claimGrid: the human's 1243 /// so the slot stays 0x0 and makes no claim in claimGrid: the human's
1203 /// terminal must never be resized because an agent connected. 1244 /// terminal must never be resized because an agent connected.
1204 fn attachZero(conn: *Conn, deadline: i64) !void { 1245 ///
1205 try conn.sendFrame(.attach, &proto.encodeAttach(0, 0, 0, 0), deadline); 1246 /// `name` is joins-only by construction, not by a separate check here: a
1247 /// 0x0 attach can never create (resolveSession demands a real size), so
1248 /// naming a session that does not exist is simply refused — this binary
1249 /// never spawns a shell by asking about one.
1250 fn attachZero(conn: *Conn, name: []const u8, deadline: i64) !void {
1251 var buf: [proto.attach_max_len]u8 = undefined;
1252 try conn.sendFrame(.attach, proto.encodeAttachNamed(&buf, 0, 0, 0, 0, name), deadline);
1206 } 1253 }
1207 1254
1208 fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: ?[]const u8, deadline: i64) !u8 { 1255 fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: ?[]const u8, session: []const u8, deadline: i64) !u8 {
1209 const spec = arg orelse return fail("send: needs BYTES", ""); 1256 const spec = arg orelse return fail("send: needs BYTES", "");
1210 const bytes = decodeEscapes(alloc, spec) catch |e| return fail("send: bad escape", @errorName(e)); 1257 const bytes = decodeEscapes(alloc, spec) catch |e| return fail("send: bad escape", @errorName(e));
1211 defer alloc.free(bytes); 1258 defer alloc.free(bytes);
1212 1259
1213 attachZero(conn, deadline) catch |e| return fail("send: attach failed", @errorName(e)); 1260 attachZero(conn, session, deadline) catch |e| return fail("send: attach failed", @errorName(e));
1214 conn.sendFrame(.input, bytes, deadline) catch |e| return fail("send: input failed", @errorName(e)); 1261 conn.sendFrame(.input, bytes, deadline) catch |e| return fail("send: input failed", @errorName(e));
1215 1262
1216 // Write-and-close LOSES the input, and not as a rare race: attaching 1263 // Write-and-close LOSES the input, and not as a rare race: attaching
@@ -1226,7 +1273,11 @@ fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: ?[]const u8, deadline: i
1226 // the pushes on the way, which is what keeps the socket drained enough 1273 // the pushes on the way, which is what keeps the socket drained enough
1227 // for that flush to succeed. Nothing is done with the reply — its 1274 // for that flush to succeed. Nothing is done with the reply — its
1228 // arrival is the whole content. 1275 // arrival is the whole content.
1229 conn.sendFrame(.status_req, "", deadline) catch |e| return fail("send: ack request failed", @errorName(e)); 1276 //
1277 // The same `session` as the attach above, not "" — the daemon's
1278 // attached-tail rule (server.zig) answers only a tail that names the
1279 // slot's own session, and this connection attached to `session`.
1280 conn.sendFrame(.status_req, session, deadline) catch |e| return fail("send: ack request failed", @errorName(e));
1230 const ack = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) { 1281 const ack = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) {
1231 // The bytes we sent ended the session (`exit\n`). Reported as the 1282 // The bytes we sent ended the session (`exit\n`). Reported as the
1232 // session's death rather than as "sent", because this verb's answer 1283 // session's death rather than as "sent", because this verb's answer
@@ -1301,11 +1352,13 @@ fn doAwait(
1301 since_seq: u64, 1352 since_seq: u64,
1302 deadline: i64, 1353 deadline: i64,
1303 ) !proto.AwaitReply { 1354 ) !proto.AwaitReply {
1304 try conn.sendFrame(.await_req, &proto.encodeAwaitReq(.{ 1355 var buf: [proto.await_req_max_len]u8 = undefined;
1356 const payload = proto.encodeAwaitReqNamed(&buf, .{
1305 .since_seq = since_seq, 1357 .since_seq = since_seq,
1306 .settle_ms = o.settle_ms, 1358 .settle_ms = o.settle_ms,
1307 .timeout_ms = o.timeout_ms, 1359 .timeout_ms = o.timeout_ms,
1308 }), deadline); 1360 }, o.session);
1361 try conn.sendFrame(.await_req, payload, deadline);
1309 const frame = try conn.awaitFrame(.await_reply, deadline); 1362 const frame = try conn.awaitFrame(.await_reply, deadline);
1310 defer frame.deinit(alloc); 1363 defer frame.deinit(alloc);
1311 return try proto.decodeAwaitReply(frame.payload); 1364 return try proto.decodeAwaitReply(frame.payload);
@@ -1378,7 +1431,7 @@ fn awaitReissuing(
1378 // daemon that lost our connection lost the client slot with 1431 // daemon that lost our connection lost the client slot with
1379 // it, so an await_req arriving unattached asks about nothing. 1432 // it, so an await_req arriving unattached asks about nothing.
1380 // A failure here is still the reconnect failing. 1433 // A failure here is still the reconnect failing.
1381 attachZero(conn, deadline) catch |reattach| { 1434 attachZero(conn, o.session, deadline) catch |reattach| {
1382 conn.reconnect_failure = @errorName(reattach); 1435 conn.reconnect_failure = @errorName(reattach);
1383 return e; 1436 return e;
1384 }; 1437 };
@@ -1413,8 +1466,10 @@ fn waitFailDetail(buf: []u8, conn: *const Conn, e: anyerror) []const u8 {
1413 /// The session's RETURN WATERMARK: the seq of the last command return, 0 if 1466 /// The session's RETURN WATERMARK: the seq of the last command return, 0 if
1414 /// none. Handed straight to `since_seq`, where it means "only a return 1467 /// none. Handed straight to `since_seq`, where it means "only a return
1415 /// newer than this may answer me". 1468 /// newer than this may answer me".
1416 fn currentSeq(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) !u64 { 1469 fn currentSeq(alloc: std.mem.Allocator, conn: *Conn, session: []const u8, deadline: i64) !u64 {
1417 try conn.sendFrame(.status_req, "", deadline); 1470 // Same session as the attach that precedes this call — the attached-
1471 // tail equality rule (server.zig) demands it.
1472 try conn.sendFrame(.status_req, session, deadline);
1418 const frame = try conn.awaitFrame(.status_reply, deadline); 1473 const frame = try conn.awaitFrame(.status_reply, deadline);
1419 defer frame.deinit(alloc); 1474 defer frame.deinit(alloc);
1420 const s = try proto.decodeStatusReply(frame.payload); 1475 const s = try proto.decodeStatusReply(frame.payload);
@@ -1602,14 +1657,14 @@ fn awaitVerb(
1602 const who = if (cmdline == null) "await" else "run"; 1657 const who = if (cmdline == null) "await" else "run";
1603 const started = std.time.milliTimestamp(); 1658 const started = std.time.milliTimestamp();
1604 1659
1605 attachZero(conn, deadline) catch |e| return failAs(who, "attach failed", @errorName(e)); 1660 attachZero(conn, o.session, deadline) catch |e| return failAs(who, "attach failed", @errorName(e));
1606 1661
1607 // BEFORE the input, not after: the watermark has to be the one this 1662 // BEFORE the input, not after: the watermark has to be the one this
1608 // command must beat. Read afterwards, a command fast enough to return 1663 // command must beat. Read afterwards, a command fast enough to return
1609 // between the two would have already moved the seq past a value we 1664 // between the two would have already moved the seq past a value we
1610 // never recorded, and the await would sit waiting for a return that 1665 // never recorded, and the await would sit waiting for a return that
1611 // had happened. 1666 // had happened.
1612 const since = currentSeq(alloc, conn, deadline) catch |e| switch (e) { 1667 const since = currentSeq(alloc, conn, o.session, deadline) catch |e| switch (e) {
1613 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), 1668 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)),
1614 else => return failAs(who, "status failed", @errorName(e)), 1669 else => return failAs(who, "status failed", @errorName(e)),
1615 }; 1670 };
src/protocol.zig
Old New
@@ -318,9 +318,28 @@ pub const AwaitReq = struct {
318 318
319 pub const await_req_len = 16; 319 pub const await_req_len = 16;
320 320
321 /// Writes only the fixed 16 bytes — a caller that sets `.name` on `r` and 321 /// Writes only the fixed 16 bytes: the pre-M18 payload, which is what an
322 /// calls this instead of `encodeAwaitReqNamed` finds it silently dropped. 322 /// empty name means on the wire.
323 ///
324 /// The name must be empty, and that is asserted rather than documented.
325 /// This used to carry a comment admitting that a caller who set `.name`
326 /// and called this instead of `encodeAwaitReqNamed` would find it silently
327 /// dropped — a known way to send the wrong bytes, written down and left
328 /// live. Dropping `AwaitReq.name`'s default was considered and does not
329 /// fix it: it forces fourteen literals to say `.name = ""` and still lets
330 /// `.name = "b"` reach this function. The assert is what makes the misuse
331 /// impossible to hold wrong quietly — it fires at the call site, in the
332 /// build modes the tests and the daemon run under.
323 pub fn encodeAwaitReq(r: AwaitReq) [await_req_len]u8 { 333 pub fn encodeAwaitReq(r: AwaitReq) [await_req_len]u8 {
334 std.debug.assert(r.name.len == 0);
335 return awaitReqFixed(r);
336 }
337
338 /// The fixed 16 bytes alone, with no opinion about `r.name`. Both encoders
339 /// go through it: the named one writes the tail from its OWN parameter, so
340 /// delegating to `encodeAwaitReq` would have meant tripping that
341 /// function's assert on any caller that also set the field.
342 fn awaitReqFixed(r: AwaitReq) [await_req_len]u8 {
324 var buf: [await_req_len]u8 = undefined; 343 var buf: [await_req_len]u8 = undefined;
325 std.mem.writeInt(u64, buf[0..8], r.since_seq, .little); 344 std.mem.writeInt(u64, buf[0..8], r.since_seq, .little);
326 std.mem.writeInt(u32, buf[8..12], r.settle_ms, .little); 345 std.mem.writeInt(u32, buf[8..12], r.settle_ms, .little);
@@ -453,8 +472,21 @@ pub const attach_len = 20;
453 pub const session_name_max = 32; 472 pub const session_name_max = 32;
454 pub const attach_max_len = attach_len + session_name_max; 473 pub const attach_max_len = attach_len + session_name_max;
455 pub const await_req_max_len = await_req_len + session_name_max; 474 pub const await_req_max_len = await_req_len + session_name_max;
475 /// debug_dump's fixed part is the single vt-mode byte.
476 pub const debug_dump_len = 1;
477 pub const debug_dump_max_len = debug_dump_len + session_name_max;
456 pub const default_session = "0"; 478 pub const default_session = "0";
457 479
480 /// The wire name a session-scoped verb actually means: an empty tail is
481 /// the default session's spelling, and every reader of one has to say so.
482 /// Spelled here rather than at each call site because "empty means
483 /// default" is a fact about the WIRE, not a convention five call sites in
484 /// the daemon happen to remember in the same way — and five copies is
485 /// five chances for one of them to be updated alone.
486 pub fn resolveName(wire_name: []const u8) []const u8 {
487 return if (wire_name.len == 0) default_session else wire_name;
488 }
489
458 /// A name a user may spell: printable ASCII, no space; '#' is muxweb's 490 /// A name a user may spell: printable ASCII, no space; '#' is muxweb's
459 /// TARGET separator (a name holding one could never be addressed) and '/' 491 /// TARGET separator (a name holding one could never be addressed) and '/'
460 /// is reserved. The empty string is valid ON THE WIRE (it means default) 492 /// is reserved. The empty string is valid ON THE WIRE (it means default)
@@ -491,13 +523,30 @@ pub fn encodeAttachNamed(
491 return buf[0 .. attach_len + name.len]; 523 return buf[0 .. attach_len + name.len];
492 } 524 }
493 525
526 /// `debug_dump`'s payload: the vt-mode byte, then the session-name tail.
527 /// The third verb to take this shape, and the third place it was being
528 /// hand-assembled — `muxd dump` and `muxa capture` had byte-identical
529 /// copies. Wire layout belongs to the wire module: two binaries knowing
530 /// the dump payload's shape is two binaries that can disagree about it.
531 /// An empty name writes exactly the pre-M18 one-byte payload.
532 pub fn encodeDebugDumpNamed(
533 buf: *[debug_dump_max_len]u8,
534 vt: bool,
535 name: []const u8,
536 ) []const u8 {
537 std.debug.assert(name.len <= session_name_max);
538 buf[0] = if (vt) 1 else 0;
539 @memcpy(buf[1..][0..name.len], name);
540 return buf[0 .. 1 + name.len];
541 }
542
494 pub fn encodeAwaitReqNamed( 543 pub fn encodeAwaitReqNamed(
495 buf: *[await_req_max_len]u8, 544 buf: *[await_req_max_len]u8,
496 r: AwaitReq, 545 r: AwaitReq,
497 name: []const u8, 546 name: []const u8,
498 ) []const u8 { 547 ) []const u8 {
499 std.debug.assert(name.len <= session_name_max); 548 std.debug.assert(name.len <= session_name_max);
500 @memcpy(buf[0..await_req_len], &encodeAwaitReq(r)); 549 @memcpy(buf[0..await_req_len], &awaitReqFixed(r));
501 @memcpy(buf[await_req_len..][0..name.len], name); 550 @memcpy(buf[await_req_len..][0..name.len], name);
502 return buf[0 .. await_req_len + name.len]; 551 return buf[0 .. await_req_len + name.len];
503 } 552 }
src/server.zig
Old New
@@ -1488,12 +1488,12 @@ pub const Server = struct {
1488 proto.writeAllFd(self.ses(si).pty.master, frame.payload) catch self.dropClient(i); 1488 proto.writeAllFd(self.ses(si).pty.master, frame.payload) catch self.dropClient(i);
1489 }, 1489 },
1490 .stats_req => { 1490 .stats_req => {
1491 // Daemon-global counters, but the seq column reads one 1491 // Daemon-global, and the same text for whoever asks: an
1492 // session's tracker: an attached client reports its own, 1492 // attached client's own session no longer picks out a
1493 // a bare connection the default's. 1493 // single seq column, since every live session gets its own
1494 const si = self.clients[i].?.session orelse 0; 1494 // line now (see statsText).
1495 var buf: [stats_text_len]u8 = undefined; 1495 var buf: [stats_text_len]u8 = undefined;
1496 const text = self.statsText(si, &buf) catch { 1496 const text = self.statsText(&buf) catch {
1497 self.dropClient(i); 1497 self.dropClient(i);
1498 return; 1498 return;
1499 }; 1499 };
@@ -1532,8 +1532,13 @@ pub const Server = struct {
1532 _ = self.queueFrame(i, .endpoint_reply, &payload); 1532 _ = self.queueFrame(i, .endpoint_reply, &payload);
1533 }, 1533 },
1534 .debug_dump => { 1534 .debug_dump => {
1535 const si = self.clients[i].?.session orelse return; 1535 // Unlike status_req/await_req below, dump answers whatever
1536 const dump = self.buildDump(si, frame.payload) catch { 1536 // the payload's tail names — it is a read against a name,
1537 // not a question about this connection's own session, and
1538 // an attached client is free to peek at another live
1539 // session. See buildDump for the resolution and the
1540 // in-words unknown-name reply.
1541 const dump = self.buildDump(frame.payload) catch {
1537 self.dropClient(i); 1542 self.dropClient(i);
1538 return; 1543 return;
1539 }; 1544 };
@@ -1541,16 +1546,47 @@ pub const Server = struct {
1541 _ = self.queueFrame(i, .dump_reply, dump); 1546 _ = self.queueFrame(i, .dump_reply, dump);
1542 }, 1547 },
1543 .status_req => { 1548 .status_req => {
1544 // The slot's session, and its name tail ignored for now: 1549 const si = self.clients[i].?.session orelse {
1545 // Task 5 is where attached status/await/dump learn tails. 1550 // A session-less slot (promoted, never attached — the
1546 const si = self.clients[i].?.session orelse return; 1551 // shape a QUIC connection has between handshake and its
1552 // first attach) has no session of its own to compare a
1553 // tail against, so the tail IS the question — same as
1554 // an observer's status_req (serviceObserver's arm),
1555 // which only a unix connection can ever reach. Mirrors
1556 // debug_dump above for the same reason: this is a
1557 // one-shot ask against a name, not an attach, so the
1558 // resolved index is used and never stored on the slot.
1559 const found = self.findSession(frame.payload) orelse {
1560 const name = if (frame.payload.len == 0) proto.default_session else frame.payload;
1561 // Same wording as the observer arm: one stderr line
1562 // an operator can grep for, since status_reply is a
1563 // fixed binary layout with no room for words.
1564 std.debug.print("muxd: status_req for unknown session: {s}\n", .{name});
1565 self.dropClient(i);
1566 return;
1567 };
1568 const payload = proto.encodeStatusReply(self.buildStatusReply(found));
1569 _ = self.queueFrame(i, .status_reply, &payload);
1570 return;
1571 };
1572 // One rule, no aliasing: a non-empty tail must name the
1573 // slot's OWN session or the frame is ignored outright —
1574 // never answered against the tail instead. The client asked
1575 // two different questions at once ("what is my slot
1576 // attached to" via the attach it already made, and "what is
1577 // session X" via this tail), and there is no reading where
1578 // both win; a mismatch gets silence, not a second opinion.
1579 if (frame.payload.len != 0 and !std.mem.eql(u8, frame.payload, self.ses(si).name())) return;
1547 const payload = proto.encodeStatusReply(self.buildStatusReply(si)); 1580 const payload = proto.encodeStatusReply(self.buildStatusReply(si));
1548 _ = self.queueFrame(i, .status_reply, &payload); 1581 _ = self.queueFrame(i, .status_reply, &payload);
1549 }, 1582 },
1550 .await_req => { 1583 .await_req => {
1551 const req = proto.decodeAwaitReq(frame.payload) catch return; 1584 const req = proto.decodeAwaitReq(frame.payload) catch return;
1552 if (self.clients[i] == null) return; 1585 if (self.clients[i] == null) return;
1553 if (self.clients[i].?.session == null) return; 1586 const si = self.clients[i].?.session orelse return;
1587 // Same one rule as status_req's, on the tail decodeAwaitReq
1588 // already borrowed out as `.name`.
1589 if (req.name.len != 0 and !std.mem.eql(u8, req.name, self.ses(si).name())) return;
1554 self.clients[i].?.await_state = .{ 1590 self.clients[i].?.await_state = .{
1555 .since_seq = req.since_seq, 1591 .since_seq = req.since_seq,
1556 .settle_ms = req.settle_ms, 1592 .settle_ms = req.settle_ms,
@@ -1706,12 +1742,6 @@ pub const Server = struct {
1706 }; 1742 };
1707 defer frame.deinit(self.alloc); 1743 defer frame.deinit(self.alloc);
1708 1744
1709 // The observer one-shots (dump, stats, status) carry no slot to
1710 // hold a session, so until Task 5 threads their name tails they ask
1711 // about the default session — slot 0, where init pins it and where
1712 // it stays, since nothing removes a session yet.
1713 const default_si: usize = 0;
1714
1715 switch (frame.type) { 1745 switch (frame.type) {
1716 .attach => { 1746 .attach => {
1717 const sz = proto.decodeAttach(frame.payload) catch { 1747 const sz = proto.decodeAttach(frame.payload) catch {
@@ -1756,8 +1786,8 @@ pub const Server = struct {
1756 self.sendResync(si, slot, sz.have_seq, sz.have_epoch, size_changed and applied); 1786 self.sendResync(si, slot, sz.have_seq, sz.have_epoch, size_changed and applied);
1757 self.sendCmdStateTo(si, slot); 1787 self.sendCmdStateTo(si, slot);
1758 }, 1788 },
1759 .debug_dump => self.replyDumpObserver(default_si, fd, frame.payload) catch self.dropObserver(i), 1789 .debug_dump => self.replyDumpObserver(fd, frame.payload) catch self.dropObserver(i),
1760 .stats_req => self.replyStatsObserver(default_si, fd) catch self.dropObserver(i), 1790 .stats_req => self.replyStatsObserver(fd) catch self.dropObserver(i),
1761 .detach => self.dropObserver(i), 1791 .detach => self.dropObserver(i),
1762 // Where `muxd stop` actually lands, since it never attaches. 1792 // Where `muxd stop` actually lands, since it never attaches.
1763 // Same signal path as the client arm. 1793 // Same signal path as the client arm.
@@ -1771,16 +1801,46 @@ pub const Server = struct {
1771 // ever attaching. Blocking reply for the same reason the stats 1801 // ever attaching. Blocking reply for the same reason the stats
1772 // and endpoint arms use one — an observer has no send queue. 1802 // and endpoint arms use one — an observer has no send queue.
1773 .status_req => { 1803 .status_req => {
1774 const payload = proto.encodeStatusReply(self.buildStatusReply(default_si)); 1804 // The whole payload is the name; empty means the default
1805 // session. Unlike an attached client's status_req, there is
1806 // no slot to fall back to and nothing here to compare the
1807 // tail against — the tail IS the question.
1808 const si = self.findSession(frame.payload) orelse {
1809 const name = if (frame.payload.len == 0) proto.default_session else frame.payload;
1810 // status_reply is StatusReply, a fixed binary layout
1811 // with no room for words — unlike dump_reply, which is
1812 // free-form bytes and can just say so. The only honest
1813 // answer left is to end the connection, with the reason
1814 // on stderr where an operator (not the wire) reads it.
1815 std.debug.print("muxd: status_req for unknown session: {s}\n", .{name});
1816 self.dropObserver(i);
1817 return;
1818 };
1819 const payload = proto.encodeStatusReply(self.buildStatusReply(si));
1775 proto.writeFrame(fd, .status_reply, &payload) catch self.dropObserver(i); 1820 proto.writeFrame(fd, .status_reply, &payload) catch self.dropObserver(i);
1776 }, 1821 },
1777 else => {}, 1822 else => {},
1778 } 1823 }
1779 } 1824 }
1780 1825
1826 /// `payload` is 1 byte (0 = plain, 1 = vt) ++ an optional session-name
1827 /// tail, same pattern as attach/status_req/await_req — empty names the
1828 /// default. Resolved via `findSession`, never `resolveSession`: a dump
1829 /// is a read, and a read that could spawn a shell would make `muxd dump
1830 /// --session typo` a way to accidentally stand one up. An unknown name
1831 /// answers IN WORDS rather than as a dropped connection — the wire
1832 /// carries no separate channel for "no", and `dump_reply` is the only
1833 /// frame this verb ever gets, so the words have to travel inside it.
1834 /// The name in the message is the RESOLVED one (after ""→default), so
1835 /// it names what was actually looked up, not what was typed.
1781 /// Caller owns the result. 1836 /// Caller owns the result.
1782 fn buildDump(self: *Server, si: usize, payload: []const u8) ![]const u8 { 1837 fn buildDump(self: *Server, payload: []const u8) ![]const u8 {
1783 const want_vt = payload.len >= 1 and payload[0] == 1; 1838 const want_vt = payload.len >= 1 and payload[0] == 1;
1839 const wire_name = if (payload.len >= 2) payload[1..] else "";
1840 const si = self.findSession(wire_name) orelse {
1841 const name = if (wire_name.len == 0) proto.default_session else wire_name;
1842 return std.fmt.allocPrint(self.alloc, "muxd: no such session: {s}\n", .{name});
1843 };
1784 return if (want_vt) 1844 return if (want_vt)
1785 try self.ses(si).eng.dumpVt(self.alloc) 1845 try self.ses(si).eng.dumpVt(self.alloc)
1786 else 1846 else
@@ -1793,8 +1853,8 @@ pub const Server = struct {
1793 // rather than for a reply at all. So there is no slow-peer hazard to 1853 // rather than for a reply at all. So there is no slow-peer hazard to
1794 // queue around — and no slot to queue into, since an observer has no 1854 // queue around — and no slot to queue into, since an observer has no
1795 // ClientSlot. 1855 // ClientSlot.
1796 fn replyDumpObserver(self: *Server, si: usize, fd: std.posix.fd_t, payload: []const u8) !void { 1856 fn replyDumpObserver(self: *Server, fd: std.posix.fd_t, payload: []const u8) !void {
1797 const dump = try self.buildDump(si, payload); 1857 const dump = try self.buildDump(payload);
1798 defer self.alloc.free(dump); 1858 defer self.alloc.free(dump);
1799 try proto.writeFrame(fd, .dump_reply, dump); 1859 try proto.writeFrame(fd, .dump_reply, dump);
1800 } 1860 }
@@ -2140,12 +2200,31 @@ pub const Server = struct {
2140 /// pending_cap is what bounds it: no client can be more than one cap 2200 /// pending_cap is what bounds it: no client can be more than one cap
2141 /// behind before it is dropped. The bench measures a live single client 2201 /// behind before it is dropped. The bench measures a live single client
2142 /// whose queue drains every pump, so the ratio it reports is unaffected. 2202 /// whose queue drains every pump, so the ratio it reports is unaffected.
2143 fn replyStatsObserver(self: *Server, si: usize, fd: std.posix.fd_t) !void { 2203 fn replyStatsObserver(self: *Server, fd: std.posix.fd_t) !void {
2144 var buf: [stats_text_len]u8 = undefined; 2204 var buf: [stats_text_len]u8 = undefined;
2145 try proto.writeFrame(fd, .stats_reply, try self.statsText(si, &buf)); 2205 try proto.writeFrame(fd, .stats_reply, try self.statsText(&buf));
2146 } 2206 }
2147 2207
2148 pub const stats_text_len = 256; 2208 // 256 was sized for the single-session text; the per-session tail
2209 // (M18) can add several "session NAME clients=N seq=N" segments, one
2210 // per live session up to max_sessions, and 256 stopped being enough
2211 // headroom for that plus the longest legal names.
2212 //
2213 // The arithmetic the 512 answers to: the main line's five u64 counters
2214 // (snapshots, snapshot_bytes, deltas, delta_bytes, snapshot_equiv_bytes)
2215 // plus the two gauges (clients, sessions), each with its "name=" label
2216 // and a generous 20 digits for a u64 at its widest, comes to roughly
2217 // 190 bytes. Each per-session segment is its literal text — " session "
2218 // + " clients=" + " seq=" plus two more 20-digit numbers, about 44
2219 // bytes — plus up to session_name_max (32) bytes of name, so ~76 bytes
2220 // a session. Four sessions (max_sessions): 190 + 4*76 = 494 of 512 —
2221 // headroom, not a coincidence, and pinned below so a future
2222 // max_sessions bump fails the build instead of silently truncating
2223 // whoever asks for stats.
2224 pub const stats_text_len = 512;
2225 comptime {
2226 std.debug.assert(stats_text_len >= 190 + max_sessions * (44 + proto.session_name_max));
2227 }
2149 2228
2150 /// How many client slots are occupied right now. 2229 /// How many client slots are occupied right now.
2151 /// 2230 ///
@@ -2162,20 +2241,66 @@ pub const Server = struct {
2162 return n; 2241 return n;
2163 } 2242 }
2164 2243
2165 fn statsText(self: *const Server, si: usize, buf: []u8) ![]const u8 { 2244 /// How many live sessions there are right now — the count `sessions=`
2166 // Appended, never reordered: the bench harness and the e2e tests 2245 /// on the stats main line, and the number of per-session tail segments
2167 // split on these key=value pairs. 2246 /// to expect after it.
2168 return std.fmt.bufPrint( 2247 fn liveSessions(self: *const Server) usize {
2169 buf, 2248 var n: usize = 0;
2170 "seq={d} snapshots={d} snapshot_bytes={d} deltas={d} delta_bytes={d}" ++ 2249 for (self.sessions) |slot| {
2171 " snapshot_equiv_bytes={d} clients={d}", 2250 if (slot != null) n += 1;
2251 }
2252 return n;
2253 }
2254
2255 /// Client slots attached to session `si` specifically, as opposed to
2256 /// `liveClients`'s daemon-wide count — the number the per-session
2257 /// stats segment reports, so it answers "who is watching THIS shell"
2258 /// rather than "how many sockets are open at all".
2259 fn clientsInSession(self: *const Server, si: usize) usize {
2260 var n: usize = 0;
2261 for (0..max_clients) |i| {
2262 if (self.inSession(i, si)) n += 1;
2263 }
2264 return n;
2265 }
2266
2267 /// Text, but machine-parsed: the bench harness and the e2e tests split
2268 /// on these key=value pairs. Renaming or reordering fields breaks them.
2269 ///
2270 /// Byte counters are accrued when a frame is ACCEPTED INTO A CLIENT'S
2271 /// QUEUE, not when the kernel takes it — "sent" is now a small lie, and
2272 /// pending_cap is what bounds it: no client can be more than one cap
2273 /// behind before it is dropped. The bench measures a live single client
2274 /// whose queue drains every pump, so the ratio it reports is unaffected.
2275 ///
2276 /// Daemon-global now (M18): the old leading `seq=` field was ONE
2277 /// session's tracker, which had no honest answer once there could be
2278 /// more than one — so it moved off the main line entirely. The main
2279 /// line keeps the truly global counters plus `sessions=N`, and every
2280 /// live session gets its own appended `session NAME clients=N seq=N`
2281 /// segment, walked in slot order. Still one line, space-separated, same
2282 /// as before: nothing here introduces a newline for a caller to trip
2283 /// on, and the fields that were always parsed by name (`snapshots=`,
2284 /// `clients=`) keep meaning what they always meant on the main line.
2285 fn statsText(self: *const Server, buf: []u8) ![]const u8 {
2286 var w: std.Io.Writer = .fixed(buf);
2287 try w.print(
2288 "snapshots={d} snapshot_bytes={d} deltas={d} delta_bytes={d}" ++
2289 " snapshot_equiv_bytes={d} clients={d} sessions={d}",
2172 .{ 2290 .{
2173 self.sessions[si].?.tracker.seq, self.stats.snapshots, 2291 self.stats.snapshots, self.stats.snapshot_bytes,
2174 self.stats.snapshot_bytes, self.stats.deltas, 2292 self.stats.deltas, self.stats.delta_bytes,
2175 self.stats.delta_bytes, self.stats.snapshot_equiv_bytes, 2293 self.stats.snapshot_equiv_bytes, self.liveClients(),
2176 self.liveClients(), 2294 self.liveSessions(),
2177 }, 2295 },
2178 ); 2296 );
2297 for (self.sessions, 0..) |slot, si| {
2298 const s = slot orelse continue;
2299 try w.print(" session {s} clients={d} seq={d}", .{
2300 s.name(), self.clientsInSession(si), s.tracker.seq,
2301 });
2302 }
2303 return w.buffered();
2179 } 2304 }
2180 }; 2305 };
2181 2306
@@ -4920,7 +5045,10 @@ test "Server: stats reports live client slots, and the number comes down again"
4920 defer srv.deinit(); 5045 defer srv.deinit();
4921 5046
4922 var buf: [Server.stats_text_len]u8 = undefined; 5047 var buf: [Server.stats_text_len]u8 = undefined;
4923 try std.testing.expect(std.mem.endsWith(u8, try srv.statsText(0, &buf), "clients=0")); 5048 // indexOf rather than endsWith (M18): the text no longer ends on the
5049 // global gauge — a per-session tail follows it — so the assertion has
5050 // to name the field it means rather than lean on it being last.
5051 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "clients=0") != null);
4924 5052
4925 // Slots filled directly: what is under test is the gauge, not the 5053 // Slots filled directly: what is under test is the gauge, not the
4926 // machinery that fills them. Emptied by defer so that a failed 5054 // machinery that fills them. Emptied by defer so that a failed
@@ -4932,21 +5060,28 @@ test "Server: stats reports live client slots, and the number comes down again"
4932 } 5060 }
4933 srv.clients[0] = .{ .sink = .{ .socket = -1 } }; 5061 srv.clients[0] = .{ .sink = .{ .socket = -1 } };
4934 srv.clients[3] = .{ .sink = .{ .socket = -1 } }; 5062 srv.clients[3] = .{ .sink = .{ .socket = -1 } };
4935 try std.testing.expect(std.mem.endsWith(u8, try srv.statsText(0, &buf), "clients=2")); 5063 // Filled with no session (session: null, the promoted-but-unattached
5064 // shape), so this raises the daemon-wide gauge on the main line
5065 // without moving the default session's own per-session count — that
5066 // is the boundary the next assertion pins.
5067 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "clients=2") != null);
4936 5068
4937 // A gauge, not a counter: the whole reason for adding it is watching 5069 // A gauge, not a counter: the whole reason for adding it is watching
4938 // occupancy clear, so it has to be able to go down. 5070 // occupancy clear, so it has to be able to go down.
4939 srv.clients[0] = null; 5071 srv.clients[0] = null;
4940 try std.testing.expect(std.mem.endsWith(u8, try srv.statsText(0, &buf), "clients=1")); 5072 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "clients=1") != null);
4941 srv.clients[3] = null; 5073 srv.clients[3] = null;
4942 try std.testing.expect(std.mem.endsWith(u8, try srv.statsText(0, &buf), "clients=0")); 5074 try std.testing.expect(std.mem.indexOf(u8, try srv.statsText(&buf), "clients=0") != null);
4943 5075
4944 // The fields the harnesses parse are still where they were: appended, 5076 // The fields the harnesses parse are still where they were: appended,
4945 // never reordered. 5077 // never reordered. The old leading `seq=` was one session's tracker
4946 const text = try srv.statsText(0, &buf); 5078 // and moved into the per-session tail (M18); the main line now starts
4947 try std.testing.expect(std.mem.startsWith(u8, text, "seq=")); 5079 // with the counters that were always daemon-global.
4948 try std.testing.expect(std.mem.indexOf(u8, text, " snapshots=") != null); 5080 const text = try srv.statsText(&buf);
5081 try std.testing.expect(std.mem.startsWith(u8, text, "snapshots="));
4949 try std.testing.expect(std.mem.indexOf(u8, text, " snapshot_equiv_bytes=") != null); 5082 try std.testing.expect(std.mem.indexOf(u8, text, " snapshot_equiv_bytes=") != null);
5083 try std.testing.expect(std.mem.indexOf(u8, text, " sessions=1") != null);
5084 try std.testing.expect(std.mem.indexOf(u8, text, " session 0 clients=0 seq=0") != null);
4950 } 5085 }
4951 5086
4952 test "Server: stop_req from a bare connection requests shutdown; run returns 130" { 5087 test "Server: stop_req from a bare connection requests shutdown; run returns 130" {
@@ -6717,6 +6852,287 @@ test "Server: a dead name re-attaches as a fresh session with a new epoch" {
6717 try std.testing.expect(srv.findSession("a") != null); 6852 try std.testing.expect(srv.findSession("a") != null);
6718 } 6853 }
6719 6854
6855 // ---------------------------------------------------------------------------
6856 // Task 5: every out-of-band instrument learns a session name — dump, the
6857 // observer's status_req, stats, and the attached-client tail-match rule for
6858 // status_req/await_req.
6859 // ---------------------------------------------------------------------------
6860
6861 test "Server: dump names a session; an unknown name answers in words" {
6862 const alloc = std.testing.allocator;
6863
6864 var tmp = try TmpDir.make();
6865 defer tmp.cleanup();
6866 const sock_path = try std.fmt.allocPrint(alloc, "{s}/dumpnames.sock", .{tmp.path()});
6867 defer alloc.free(sock_path);
6868
6869 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
6870 defer srv.deinit();
6871
6872 const ca = try std.net.connectUnixSocket(sock_path);
6873 defer ca.close();
6874 try attachNamed(ca.handle, 80, 24, "a");
6875 const cb = try std.net.connectUnixSocket(sock_path);
6876 defer cb.close();
6877 try attachNamed(cb.handle, 80, 24, "b");
6878
6879 try proto.writeFrame(ca.handle, .input, "MARKER-ALPHA\n");
6880 try proto.writeFrame(cb.handle, .input, "MARKER-BETA\n");
6881
6882 var rep_a = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
6883 defer rep_a.deinit();
6884 var rep_b = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
6885 defer rep_b.deinit();
6886 try std.testing.expect(
6887 try pumpUntilReplicaSees(alloc, &srv, ca.handle, rep_a, "MARKER-ALPHA", 400),
6888 );
6889 try std.testing.expect(
6890 try pumpUntilReplicaSees(alloc, &srv, cb.handle, rep_b, "MARKER-BETA", 400),
6891 );
6892
6893 // Asked from a's own connection, for b: dump answers the payload's
6894 // tail, never the asker's own session.
6895 try proto.writeFrame(ca.handle, .debug_dump, &[_]u8{ 1, 'b' });
6896 const reply = (try awaitFrame(alloc, &srv, ca.handle, .dump_reply, 400)) orelse
6897 return error.NoDumpReply;
6898 defer reply.deinit(alloc);
6899 try std.testing.expect(std.mem.indexOf(u8, reply.payload, "MARKER-BETA") != null);
6900 try std.testing.expect(std.mem.indexOf(u8, reply.payload, "MARKER-ALPHA") == null);
6901
6902 // An unknown name is a dump_reply in words, not a fake connection loss
6903 // — the only channel this verb has for "no" is the reply itself.
6904 try proto.writeFrame(ca.handle, .debug_dump, &[_]u8{ 1, 'z' });
6905 const bad = (try awaitFrame(alloc, &srv, ca.handle, .dump_reply, 400)) orelse
6906 return error.NoDumpReplyForBadName;
6907 defer bad.deinit(alloc);
6908 try std.testing.expectEqualStrings("muxd: no such session: z\n", bad.payload);
6909 }
6910
6911 test "Server: an observer's status_req names a session by tail" {
6912 const alloc = std.testing.allocator;
6913
6914 var tmp = try TmpDir.make();
6915 defer tmp.cleanup();
6916 const dir_path = tmp.path();
6917 const sock_path = try std.fmt.allocPrint(alloc, "{s}/obsstatus.sock", .{dir_path});
6918 defer alloc.free(sock_path);
6919
6920 // The one-mark-on-command scripted shell, same shape as the OSC 133
6921 // test: it answers to being told, not on its own, so a running push
6922 // against b is unambiguously b's and never a's idle default.
6923 try tmp.dir.writeFile(.{
6924 .sub_path = "marks.sh",
6925 .data =
6926 \\#!/bin/sh
6927 \\read -r start
6928 \\printf '\033]133;C\007'
6929 \\read -r stop
6930 \\
6931 ,
6932 .flags = .{ .mode = 0o755 },
6933 });
6934 const script = try std.fmt.allocPrintSentinel(alloc, "{s}/marks.sh", .{dir_path}, 0);
6935 defer alloc.free(script);
6936
6937 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script });
6938 defer srv.deinit();
6939
6940 const a = try std.net.connectUnixSocket(sock_path);
6941 defer a.close();
6942 try attachNamed(a.handle, 80, 24, "a");
6943 const b = try std.net.connectUnixSocket(sock_path);
6944 defer b.close();
6945 try attachNamed(b.handle, 80, 24, "b");
6946 const fa = (try awaitFrame(alloc, &srv, a.handle, .snapshot, 400)) orelse
6947 return error.NoSnapshotA;
6948 fa.deinit(alloc);
6949 const fb = (try awaitFrame(alloc, &srv, b.handle, .snapshot, 400)) orelse
6950 return error.NoSnapshotB;
6951 fb.deinit(alloc);
6952
6953 // Drive b into its C — a's shell is never told to run anything, so a
6954 // stays at its idle default and the two sessions read differently.
6955 try proto.writeFrame(b.handle, .input, "go\n");
6956 const push = (try awaitFrame(alloc, &srv, b.handle, .cmd_state, 500)) orelse
6957 return error.NoRunningPush;
6958 push.deinit(alloc);
6959
6960 // A bare connection — an observer, never attached — asks about b by
6961 // name alone.
6962 const obs = try std.net.connectUnixSocket(sock_path);
6963 defer obs.close();
6964 try proto.writeFrame(obs.handle, .status_req, "b");
6965 const reply = (try awaitFrame(alloc, &srv, obs.handle, .status_reply, 400)) orelse
6966 return error.NoStatusReply;
6967 defer reply.deinit(alloc);
6968 const st = try proto.decodeStatusReply(reply.payload);
6969 try std.testing.expectEqual(proto.CmdPhase.running, st.cmd.phase);
6970 try std.testing.expectEqual(proto.Mechanism.marks, st.cmd.mechanism);
6971
6972 // An unknown name drops the observer outright: status_reply is a fixed
6973 // binary layout with no room for words, so the only honest answer is
6974 // to end the connection — and nothing must precede that close.
6975 try proto.writeFrame(obs.handle, .status_req, "z");
6976 var saw_bytes = false;
6977 var saw_eof = false;
6978 var iters: usize = 0;
6979 while (iters < 400 and !saw_eof) : (iters += 1) {
6980 _ = try srv.pumpOnce(5);
6981 var pfd = [_]std.posix.pollfd{
6982 .{ .fd = obs.handle, .events = std.posix.POLL.IN, .revents = 0 },
6983 };
6984 if ((std.posix.poll(&pfd, 1) catch 0) == 0) continue;
6985 var drain: [64]u8 = undefined;
6986 const n = try std.posix.read(obs.handle, &drain);
6987 if (n == 0) saw_eof = true else saw_bytes = true;
6988 }
6989 try std.testing.expect(saw_eof);
6990 try std.testing.expect(!saw_bytes);
6991 }
6992
6993 test "Server: stats names every live session" {
6994 const alloc = std.testing.allocator;
6995
6996 var tmp = try TmpDir.make();
6997 defer tmp.cleanup();
6998 const sock_path = try std.fmt.allocPrint(alloc, "{s}/statsnames.sock", .{tmp.path()});
6999 defer alloc.free(sock_path);
7000
7001 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
7002 defer srv.deinit();
7003
7004 // The default session (bare attach) plus one named session: two live
7005 // sessions total, both of which must show up by name.
7006 const ca = try std.net.connectUnixSocket(sock_path);
7007 defer ca.close();
7008 try proto.writeFrame(ca.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
7009 const cb = try std.net.connectUnixSocket(sock_path);
7010 defer cb.close();
7011 try attachNamed(cb.handle, 80, 24, "b");
7012 const fa = (try awaitFrame(alloc, &srv, ca.handle, .snapshot, 400)) orelse
7013 return error.NoSnapshotA;
7014 fa.deinit(alloc);
7015 const fb = (try awaitFrame(alloc, &srv, cb.handle, .snapshot, 400)) orelse
7016 return error.NoSnapshotB;
7017 fb.deinit(alloc);
7018
7019 var buf: [Server.stats_text_len]u8 = undefined;
7020 const text = try srv.statsText(&buf);
7021 try std.testing.expect(std.mem.indexOf(u8, text, "sessions=2") != null);
7022 try std.testing.expect(std.mem.indexOf(u8, text, "session " ++ proto.default_session ++ " ") != null);
7023 try std.testing.expect(std.mem.indexOf(u8, text, "session b ") != null);
7024 }
7025
7026 test "Server: an attached client's mismatched status tail is ignored" {
7027 const alloc = std.testing.allocator;
7028
7029 var tmp = try TmpDir.make();
7030 defer tmp.cleanup();
7031 const sock_path = try std.fmt.allocPrint(alloc, "{s}/mismatch.sock", .{tmp.path()});
7032 defer alloc.free(sock_path);
7033
7034 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
7035 defer srv.deinit();
7036
7037 const ca = try std.net.connectUnixSocket(sock_path);
7038 defer ca.close();
7039 try attachNamed(ca.handle, 80, 24, "a");
7040 const cb = try std.net.connectUnixSocket(sock_path);
7041 defer cb.close();
7042 try attachNamed(cb.handle, 80, 24, "b");
7043 const fa = (try awaitFrame(alloc, &srv, ca.handle, .snapshot, 400)) orelse
7044 return error.NoSnapshotA;
7045 fa.deinit(alloc);
7046 const fb = (try awaitFrame(alloc, &srv, cb.handle, .snapshot, 400)) orelse
7047 return error.NoSnapshotB;
7048 fb.deinit(alloc);
7049
7050 // A tail naming a DIFFERENT session than the one this connection
7051 // attached to gets silence, never an answer about the tail instead:
7052 // bounded negative probe, since the absence is what is under test.
7053 try proto.writeFrame(ca.handle, .status_req, "b");
7054 if (try awaitFrame(alloc, &srv, ca.handle, .status_reply, 30)) |leak| {
7055 leak.deinit(alloc);
7056 return error.MismatchedTailAnswered;
7057 }
7058
7059 // The empty tail is the "no opinion" spelling and always matches: the
7060 // reply arrives, and it is a's own state, not b's.
7061 try proto.writeFrame(ca.handle, .status_req, "");
7062 const ok = (try awaitFrame(alloc, &srv, ca.handle, .status_reply, 400)) orelse
7063 return error.NoReplyForEmptyTail;
7064 ok.deinit(alloc);
7065 }
7066
7067 test "Server: a session-less slot's status_req resolves the tail like an observer's" {
7068 const alloc = std.testing.allocator;
7069
7070 var tmp = try TmpDir.make();
7071 defer tmp.cleanup();
7072 const dir_path = tmp.path();
7073 const sock_path = try std.fmt.allocPrint(alloc, "{s}/quicstatus.sock", .{dir_path});
7074 defer alloc.free(sock_path);
7075
7076 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
7077 defer srv.deinit();
7078
7079 // Stand up session "b" for real, over an ordinary attach.
7080 const cb = try std.net.connectUnixSocket(sock_path);
7081 defer cb.close();
7082 try attachNamed(cb.handle, 80, 24, "b");
7083 const fb = (try awaitFrame(alloc, &srv, cb.handle, .snapshot, 400)) orelse
7084 return error.NoSnapshotB;
7085 fb.deinit(alloc);
7086
7087 // The shape a QUIC connection has between handshake and its first
7088 // attach: a live client slot with no session, reached through
7089 // pushInbound/handleFrame exactly as a QUIC datagram would be — not
7090 // serviceObserver, which only a unix connection can ever land in. This
7091 // is the gap the review found: handleFrame's status_req arm used to
7092 // `orelse return` a session-less slot outright, so `muxa status --quic`
7093 // against a live daemon just timed out.
7094 // Slot 1, not 0: cb's own attach already claimed slot 0 (freeClientSlot
7095 // hands out the first free index), and stomping it would leak its
7096 // queued snapshot rather than exercise anything about this arm.
7097 const c = try connectedPair(dir_path, "quicstatus-c");
7098 defer std.posix.close(c.peer);
7099 srv.clients[1] = .{ .sink = .{ .socket = c.daemon } };
7100
7101 var frame: std.ArrayList(u8) = .empty;
7102 defer frame.deinit(alloc);
7103 try proto.appendFrame(&frame, alloc, .status_req, "b");
7104 srv.pushInbound(1, frame.items);
7105
7106 // queueFrame flushes synchronously (see its doc comment), so the reply
7107 // is already on the wire — no pump needed, same as the other
7108 // pushInbound-driven tests above.
7109 var got: ?proto.Frame = null;
7110 {
7111 var pfd = [_]std.posix.pollfd{
7112 .{ .fd = c.peer, .events = std.posix.POLL.IN, .revents = 0 },
7113 };
7114 if ((try std.posix.poll(&pfd, 50)) != 0) got = try proto.readFrame(alloc, c.peer);
7115 }
7116 const reply = got orelse return error.NoStatusReplyForSessionlessSlot;
7117 defer reply.deinit(alloc);
7118 try std.testing.expectEqual(proto.MsgType.status_reply, reply.type);
7119 _ = try proto.decodeStatusReply(reply.payload);
7120
7121 // A one-shot ask, not an attach: the slot still holds no session.
7122 try std.testing.expect(srv.clients[1] != null);
7123 try std.testing.expect(srv.clients[1].?.session == null);
7124
7125 // An unknown name drops the CLIENT — dropClient is the QUIC-shaped
7126 // analogue of dropObserver's drop, and no status_reply precedes it.
7127 frame.clearRetainingCapacity();
7128 try proto.appendFrame(&frame, alloc, .status_req, "zz");
7129 srv.pushInbound(1, frame.items);
7130 try std.testing.expect(srv.clients[1] == null);
7131
7132 var drain: [8]u8 = undefined;
7133 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(c.peer, &drain));
7134 }
7135
6720 // Forces semantic analysis of every pub decl under `zig build test`, so an 7136 // Forces semantic analysis of every pub decl under `zig build test`, so an
6721 // unreferenced decl must at least compile (the silent-module-loss hazard, 7137 // unreferenced decl must at least compile (the silent-module-loss hazard,
6722 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 7138 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test/agent.sh
Old New
@@ -881,11 +881,48 @@ scen_blackhole() {
881 } 881 }
882 run_scenario "quic: a blackholed destination fails on --timeout, not on the idle ceiling" scen_blackhole 882 run_scenario "quic: a blackholed destination fails on --timeout, not on the idle ceiling" scen_blackhole
883 883
884 # --- 10: a live daemon actually answers muxa status --quic -----------------
885 # The blackhole probe above proves muxa REFUSES a daemon it cannot reach —
886 # on its own that is consistent with two very different bugs: "refuses dead
887 # daemons" (the claim) and "refuses every daemon, QUIC included" (what an
888 # M18 review found: handleFrame's status_req arm dropped a session-less
889 # slot outright via `orelse return`, so a QUIC connection — promoted to a
890 # client slot at handshake, before any attach gives it a session — never
891 # got an answer, and `muxa status --quic` against a perfectly live daemon
892 # just sat until its own --timeout). This is the other half of that pair:
893 # the same verb, against a daemon that DOES answer, must actually get a
894 # reply. SOCK_QUIET/PORT_QUIET are free again — scenario 7 already stopped
895 # that daemon and leakchecked it — so they are reused rather than adding a
896 # fourth port this suite has to pick and sweep.
897 scen_quic_status_live() {
898 quic_gate || return $?
899 start_quic PORT_QUIET D_QUIET "$TMP/quiet2.log" "$SOCK_QUIET" --shell /bin/sh \
900 --key "$KEY" || return 1
901
902 timeout 20 "$MUXA" status --quic "127.0.0.1:$PORT_QUIET" --key "$KEY" --timeout 5000 >"$TMP/q6" 2>&1
903 _rc=$?
904 [ "$_rc" -eq 0 ] || why "status exited $_rc against a live daemon [$(tr -d '\n' < "$TMP/q6")]" || return 1
905 # Not just "exit 0" — a real StatusReply decoded into the JSON shape
906 # muxa's own contract promises, which a silently-empty object could
907 # still slip past a bare exit-code check.
908 _cols=$(jget "$TMP/q6" cols)
909 case "$_cols" in
910 ''|'<missing>'|'<unparseable>'|'<not-an-object>')
911 why "no cols in the status reply [$(tr -d '\n' < "$TMP/q6")]" || return 1 ;;
912 esac
913
914 "$MUXD" stop --sock "$SOCK_QUIET" >/dev/null 2>&1
915 leakcheck "$D_QUIET" quiet2 || return 1
916 D_QUIET=""
917 return 0
918 }
919 run_scenario "quic: muxa status succeeds against a live daemon (not just refuses dead ones)" scen_quic_status_live
920
884 # The count, pinned against a literal for e2e.sh's reason: a scenario that 921 # The count, pinned against a literal for e2e.sh's reason: a scenario that
885 # silently stops running is the failure mode no assertion inside it can catch. 922 # silently stops running is the failure mode no assertion inside it can catch.
886 TOTAL=$((PASSES + FAILS + SKIPS)) 923 TOTAL=$((PASSES + FAILS + SKIPS))
887 if [ "$TOTAL" -ne 9 ]; then 924 if [ "$TOTAL" -ne 10 ]; then
888 echo "agent FAIL: $TOTAL scenarios reported, want 9 — one did not run" 925 echo "agent FAIL: $TOTAL scenarios reported, want 10 — one did not run"
889 FAILS=$((FAILS + 1)) 926 FAILS=$((FAILS + 1))
890 fi 927 fi
891 928