8bf9f055
feat: the instruments learn session names, and the wire module owns the payload
a73x 2026-08-15 12:21
Commit message
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 | ||