4d48bfad
feat: mux d stop, dump and stats take --quic for a daemon whose socket is gone
a73x 2026-09-05 19:04
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -740,6 +740,8 @@ has none. | |||
| 740 | mux d start [-d] # the daemon: in this shell, or -d to fork one and wait for it | 740 | mux d start [-d] # the daemon: in this shell, or -d to fork one and wait for it |
| 741 | mux d dump [--vt] # debug: print the authoritative grid (what the screen *should* be) | 741 | mux d dump [--vt] # debug: print the authoritative grid (what the screen *should* be) |
| 742 | mux d stats # live sessions by name + clients each; wire stats (deltas vs snapshot bytes) | 742 | mux d stats # live sessions by name + clients each; wire stats (deltas vs snapshot bytes) |
| 743 | mux d stop # ask the daemon to exit; dump, stats and stop also take | ||
| 744 | # --quic HOST[:PORT] [--key FILE], for a daemon whose socket file is gone | ||
| 743 | mux d upgrade # exec a newly installed binary in place; every session keeps running | 745 | mux d upgrade # exec a newly installed binary in place; every session keeps running |
| 744 | mux d upgrade HOST # push THIS binary onto HOST over ssh, then upgrade its daemon the same way | 746 | mux d upgrade HOST # push THIS binary onto HOST over ssh, then upgrade its daemon the same way |
| 745 | make bench # typing-workload bytes-per-keystroke measurement | 747 | make bench # typing-workload bytes-per-keystroke measurement |
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -322,7 +322,7 @@ const mod_table = [_]ModSpec{ | |||
| 322 | // fourth copy of connect-write-poll-read here. `testtmp` is the keygen | 322 | // fourth copy of connect-write-poll-read here. `testtmp` is the keygen |
| 323 | // round-trip's: it needs a directory to generate into, which the daemon | 323 | // round-trip's: it needs a directory to generate into, which the daemon |
| 324 | // never touches. | 324 | // never touches. |
| 325 | .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags", "dial", "server_os", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 325 | .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags", "dial", "link", "server_os", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 326 | // Opt-in rows: no default artifact or test names them, so machines | 326 | // Opt-in rows: no default artifact or test names them, so machines |
| 327 | // without the viewer's system libraries keep all existing gates. | 327 | // without the viewer's system libraries keep all existing gates. |
| 328 | .{ .name = "native_core", .path = "src/gui/native_core.zig", .link_libc = true, .imports = &.{ "client", "term" }, .opt_in = true }, | 328 | .{ .name = "native_core", .path = "src/gui/native_core.zig", .link_libc = true, .imports = &.{ "client", "term" }, .opt_in = true }, |
docs/superpowers/specs/2026-09-05-daemon-socket-lifecycle-design.md
| Old | New | ||
|---|---|---|---|
| @@ -58,7 +58,7 @@ would have dated the second daemon's birth against the first one's loss. | |||
| 58 | ### 2. Watching the path and taking it back (145807a2, option a) | 58 | ### 2. Watching the path and taking it back (145807a2, option a) |
| 59 | 59 | ||
| 60 | `Server.pumpOnce` gains a once-a-second stat of `sock_path` against | 60 | `Server.pumpOnce` gains a once-a-second stat of `sock_path` against |
| 61 | `bound.path_id` (`sock_watch_ms`, same shape as the agent relay's timers). | 61 | `bound.path_id` (`Server.sock_watch_interval_ms`, same shape as the agent relay's timers). |
| 62 | Cost: one `fstatat` per second per daemon. | 62 | Cost: one `fstatat` per second per daemon. |
| 63 | 63 | ||
| 64 | On loss: | 64 | On loss: |
| @@ -82,7 +82,7 @@ a re-bound fd is just the current `bound.fd`, so nothing there changes. | |||
| 82 | `upgrade_req` is refused while a re-bind is pending? No pending state | 82 | `upgrade_req` is refused while a re-bind is pending? No pending state |
| 83 | exists: the re-bind is synchronous inside one tick. | 83 | exists: the re-bind is synchronous inside one tick. |
| 84 | 84 | ||
| 85 | ### 3. Admin verbs by QUIC (145807a2, option b) — pending the user's call | 85 | ### 3. Admin verbs by QUIC (145807a2, option b) — taken, 2026-09-05 |
| 86 | 86 | ||
| 87 | `mux d stop|dump|stats --quic HOST:PORT [--key PATH]` for a daemon whose | 87 | `mux d stop|dump|stats --quic HOST:PORT [--key PATH]` for a daemon whose |
| 88 | path is gone AND whose slot the successor now holds. Client-side only: | 88 | path is gone AND whose slot the successor now holds. Client-side only: |
| @@ -109,6 +109,9 @@ Not in scope: an admin socket of its own, a signal handler. | |||
| 109 | daemon, `sleep 2`, `mux d stats --sock PATH` answers, and the log carries | 109 | daemon, `sleep 2`, `mux d stats --sock PATH` answers, and the log carries |
| 110 | the loss line and the re-bound line in that order; `mux d stop` then | 110 | the loss line and the re-bound line in that order; `mux d stop` then |
| 111 | unlinks and logs `unlinked`. | 111 | unlinks and logs `unlinked`. |
| 112 | - Log-line unit test: the trail is asserted through `std.testing` capture | 112 | - Log lines: as built, `logSocket` writes stderr like the rest of the |
| 113 | of a writer, so the daemon's `socketLog` takes a writer like every other | 113 | daemon's diagnostics (fd 2, never fd 1, so the fd-1 wedge rule is not in |
| 114 | stdout-adjacent path in this repo (the fd-1 wedge rule). | 114 | play) and the trail is graded off the real log by the e2e leg in the boot |
| 115 | group; the unit test grades the watch's STATE (`sock_watch.lost`, | ||
| 116 | `sock_watch.refused`, the bound inode), not the wording. The wall's | ||
| 117 | auto-start note has a unit test of its own on `xdg.appendLogLineTo`. | ||
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -20,15 +20,16 @@ const sockpath = @import("sockpath"); | |||
| 20 | const upgrade = @import("daemon").upgrade; | 20 | const upgrade = @import("daemon").upgrade; |
| 21 | const cliflags = @import("cliflags"); | 21 | const cliflags = @import("cliflags"); |
| 22 | const dial = @import("dial"); | 22 | const dial = @import("dial"); |
| 23 | const link_mod = @import("link"); | ||
| 23 | 24 | ||
| 24 | const usage = | 25 | const usage = |
| 25 | \\usage: | 26 | \\usage: |
| 26 | \\ mux d start [-d] [--sock PATH] [--shell PATH] [--cols N] [--rows N] | 27 | \\ mux d start [-d] [--sock PATH] [--shell PATH] [--cols N] [--rows N] |
| 27 | \\ [--quic HOST[:PORT] --key FILE] [--quic-idle-ms N] | 28 | \\ [--quic HOST[:PORT] --key FILE] [--quic-idle-ms N] |
| 28 | \\ (-d comes first; forks it off and waits, no-op if up) | 29 | \\ (-d comes first; forks it off and waits, no-op if up) |
| 29 | \\ mux d dump [--vt] [--session NAME] [--sock PATH] | 30 | \\ mux d dump [--vt] [--session NAME] [--sock PATH | --quic HOST[:PORT] [--key FILE]] |
| 30 | \\ mux d stats [--sock PATH] | 31 | \\ mux d stats [--sock PATH | --quic HOST[:PORT] [--key FILE]] |
| 31 | \\ mux d stop [--sock PATH] (ask the daemon on PATH to exit) | 32 | \\ mux d stop [--sock PATH | --quic HOST[:PORT] [--key FILE]] (ask the daemon to exit) |
| 32 | \\ mux d proxy [--sock PATH] (byte pump: stdio <-> session socket) | 33 | \\ mux d proxy [--sock PATH] (byte pump: stdio <-> session socket) |
| 33 | \\ mux d endpoint [--sock PATH] [--start] (announce QUIC port+key, then proxy) | 34 | \\ mux d endpoint [--sock PATH] [--start] (announce QUIC port+key, then proxy) |
| 34 | \\ mux d keygen (write a fresh key to ~/.config/mux/key) | 35 | \\ mux d keygen (write a fresh key to ~/.config/mux/key) |
| @@ -182,10 +183,22 @@ const UsageResponse = union(enum) { | |||
| 182 | /// locally while HOST is honored remotely would leave the user | 183 | /// locally while HOST is honored remotely would leave the user |
| 183 | /// believing both. | 184 | /// believing both. |
| 184 | sock_with_host, | 185 | sock_with_host, |
| 186 | /// `stop|dump|stats --sock PATH --quic HOST:PORT`: two daemons named | ||
| 187 | /// for one question. `start` takes both, because a daemon can listen | ||
| 188 | /// on both; the admin verbs ask one thing of one daemon. | ||
| 189 | sock_with_quic, | ||
| 185 | }; | 190 | }; |
| 186 | 191 | ||
| 187 | const DaemonInvocation = union(enum) { command: DaemonArguments, usage: UsageResponse }; | 192 | const DaemonInvocation = union(enum) { command: DaemonArguments, usage: UsageResponse }; |
| 188 | 193 | ||
| 194 | /// The verbs that may reach a daemon over QUIC instead of its socket path: | ||
| 195 | /// one frame in, one frame out, which `handleDaemonVerb` already serves on a | ||
| 196 | /// QUIC client slot. `upgrade` stays on the socket — its request is served | ||
| 197 | /// to observers only, and the manifest it carries is local to the box. | ||
| 198 | fn quicAdmin(cmd: DaemonCommand) bool { | ||
| 199 | return cmd == .stop or cmd == .dump or cmd == .stats; | ||
| 200 | } | ||
| 201 | |||
| 189 | fn parseArgs(args: []const [:0]const u8) DaemonInvocation { | 202 | fn parseArgs(args: []const [:0]const u8) DaemonInvocation { |
| 190 | if (args.len < 2) return .{ .usage = .no_command }; | 203 | if (args.len < 2) return .{ .usage = .no_command }; |
| 191 | const spec = specForName(args[1]) orelse return .{ .usage = .{ .unknown_command = args[1] } }; | 204 | const spec = specForName(args[1]) orelse return .{ .usage = .{ .unknown_command = args[1] } }; |
| @@ -230,6 +243,12 @@ fn parseArgs(args: []const [:0]const u8) DaemonInvocation { | |||
| 230 | if (o.key != null and o.quic == null) return .{ .usage = .key_without_quic }; | 243 | if (o.key != null and o.quic == null) return .{ .usage = .key_without_quic }; |
| 231 | 244 | ||
| 232 | if (o._host != null and o.sock != null) return .{ .usage = .sock_with_host }; | 245 | if (o._host != null and o.sock != null) return .{ .usage = .sock_with_host }; |
| 246 | if (quicAdmin(spec.cmd) and o.quic != null and o.sock != null) return .{ .usage = .sock_with_quic }; | ||
| 247 | // `--quic` is a bind for `start` and a door for the admin verbs. Every | ||
| 248 | // other verb is served on the socket alone, and a flag it parsed and | ||
| 249 | // dropped would run `mux d upgrade --quic HOST` against the socket's | ||
| 250 | // daemon while the user believed HOST was being upgraded. | ||
| 251 | if (o.quic != null and spec.cmd != .start and !quicAdmin(spec.cmd)) return .{ .usage = .{ .unknown_arg = "--quic" } }; | ||
| 233 | 252 | ||
| 234 | return .{ .command = o }; | 253 | return .{ .command = o }; |
| 235 | } | 254 | } |
| @@ -255,6 +274,10 @@ fn usageExit(u: UsageResponse) u8 { | |||
| 255 | "mux d: upgrade HOST uses that box's own default socket; --sock only names a local one\n", | 274 | "mux d: upgrade HOST uses that box's own default socket; --sock only names a local one\n", |
| 256 | .{}, | 275 | .{}, |
| 257 | ), | 276 | ), |
| 277 | .sock_with_quic => std.debug.print( | ||
| 278 | "mux d: --sock and --quic name two daemons; ask one of them\n", | ||
| 279 | .{}, | ||
| 280 | ), | ||
| 258 | } | 281 | } |
| 259 | return usageCode(u); | 282 | return usageCode(u); |
| 260 | } | 283 | } |
| @@ -284,7 +307,11 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 284 | // A remote upgrade names a HOST, and the socket that matters is that | 307 | // A remote upgrade names a HOST, and the socket that matters is that |
| 285 | // box's own default; resolving a local one here would refuse the verb on | 308 | // box's own default; resolving a local one here would refuse the verb on |
| 286 | // a machine with no XDG_RUNTIME_DIR for no reason it could name. | 309 | // a machine with no XDG_RUNTIME_DIR for no reason it could name. |
| 287 | const uses_socket = specForCmd(o._cmd).uses_socket and o.resume_fd == null and o._host == null; | 310 | // An admin verb aimed at `--quic` resolves no socket path either: the |
| 311 | // daemon it is for may have none, and the box asking may have no | ||
| 312 | // runtime dir. | ||
| 313 | const uses_socket = specForCmd(o._cmd).uses_socket and o.resume_fd == null and o._host == null and | ||
| 314 | !(quicAdmin(o._cmd) and o.quic != null); | ||
| 288 | const sock_path = if (o.sock) |s| | 315 | const sock_path = if (o.sock) |s| |
| 289 | try alloc.dupe(u8, s) | 316 | try alloc.dupe(u8, s) |
| 290 | else if (!uses_socket) | 317 | else if (!uses_socket) |
| @@ -307,9 +334,9 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 307 | resumeRun(alloc, o, fd) | 334 | resumeRun(alloc, o, fd) |
| 308 | else | 335 | else |
| 309 | startCmd(alloc, o, sock_path, args[2..]), | 336 | startCmd(alloc, o, sock_path, args[2..]), |
| 310 | .dump => return dump(alloc, sock_path, o.vt, if (o.session) |n| n.name else ""), | 337 | .dump => return dump(alloc, adminTarget(o, sock_path), o.vt, if (o.session) |n| n.name else ""), |
| 311 | .stats => return stats(alloc, sock_path), | 338 | .stats => return stats(alloc, adminTarget(o, sock_path)), |
| 312 | .stop => return stopCmd(alloc, sock_path), | 339 | .stop => return stopCmd(alloc, adminTarget(o, sock_path)), |
| 313 | .upgrade => return if (o._host) |h| | 340 | .upgrade => return if (o._host) |h| |
| 314 | remoteUpgradeCmd(alloc, h, o.allow_same_version) | 341 | remoteUpgradeCmd(alloc, h, o.allow_same_version) |
| 315 | else | 342 | else |
| @@ -609,52 +636,206 @@ fn run(alloc: std.mem.Allocator, o: DaemonArguments, sock_path: []const u8) !u8 | |||
| 609 | return try srv.run(); | 636 | return try srv.run(); |
| 610 | } | 637 | } |
| 611 | 638 | ||
| 612 | /// Perform an unbounded one-shot query. A connected daemon that stops replying | 639 | /// Where an admin verb sends its one frame. `sock` is the path every verb |
| 613 | /// remains a visible hang rather than being reported like an absent socket. | 640 | /// has always taken. `quic` is the second door, for the daemon the first |
| 641 | /// cannot reach: one whose socket file was deleted and whose path a | ||
| 642 | /// successor now holds, so the re-bind in `Server.watchSockPath` is | ||
| 643 | /// refused and the daemon is still holding sessions nobody can stop, | ||
| 644 | /// dump or count (issue 145807a2). `mux a` has dialled daemons this way | ||
| 645 | /// since QUIC existed; the daemon serves these verbs on a client slot | ||
| 646 | /// exactly as on an observer, so this is the client learning the spelling. | ||
| 647 | const AdminTarget = union(enum) { | ||
| 648 | sock: []const u8, | ||
| 649 | quic: struct { host_port: []const u8, key: ?[]const u8 }, | ||
| 650 | }; | ||
| 651 | |||
| 652 | fn adminTarget(o: DaemonArguments, sock_path: []const u8) AdminTarget { | ||
| 653 | if (o.quic) |hp| return .{ .quic = .{ .host_port = hp, .key = o.key } }; | ||
| 654 | return .{ .sock = sock_path }; | ||
| 655 | } | ||
| 656 | |||
| 657 | /// The one bound QUIC asks live under. A unix socket says "nothing | ||
| 658 | /// listening" as an errno in a microsecond, so `oneShotQuery` on a path | ||
| 659 | /// may wait forever on a daemon that answered the connect; UDP has no | ||
| 660 | /// such errno — an unanswered port is silence — so every QUIC ask is | ||
| 661 | /// bounded, and the silence is reported as what it might be. | ||
| 662 | const admin_quic_deadline_ms: u32 = 5000; | ||
| 663 | |||
| 664 | /// One deadline for the whole ask, minted by the caller and spent by the | ||
| 665 | /// handshake and the reply together: two full budgets in a row made "5 s" | ||
| 666 | /// a ten-second wait on a port that took the handshake and nothing else. | ||
| 667 | fn adminDeadline() i64 { | ||
| 668 | return std.time.milliTimestamp() + admin_quic_deadline_ms; | ||
| 669 | } | ||
| 670 | |||
| 671 | fn msLeft(deadline: i64) u32 { | ||
| 672 | const left = deadline - std.time.milliTimestamp(); | ||
| 673 | return if (left <= 0) 0 else @intCast(@min(left, admin_quic_deadline_ms)); | ||
| 674 | } | ||
| 675 | |||
| 676 | /// Dial a daemon's QUIC arm for one admin verb: key by the same three-way | ||
| 677 | /// rule `mux a --quic` and `mux d start --quic` use (`--key`, then | ||
| 678 | /// `MUX_KEY_FILE`, then the default path), handshake within the deadline. | ||
| 679 | /// Every refusal is printed here in the verb's voice and answered with | ||
| 680 | /// null; the caller's only job is the frame. | ||
| 681 | fn openAdminQuic(alloc: std.mem.Allocator, verb: []const u8, q: anytype, deadline: i64) ?link_mod.Link { | ||
| 682 | const res = xdg.resolveKeyPath(alloc, xdg.pickKey(q.key, std.posix.getenv(xdg.key_env))) catch |e| { | ||
| 683 | std.debug.print("mux d {s}: quic: cannot resolve a key path: {s}\n", .{ verb, @errorName(e) }); | ||
| 684 | return null; | ||
| 685 | }; | ||
| 686 | defer res.deinit(alloc); | ||
| 687 | const key_path = switch (res) { | ||
| 688 | .given, .default => |p| p, | ||
| 689 | .missing => |p| { | ||
| 690 | std.debug.print( | ||
| 691 | "mux d {s}: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen` (default {s})\n", | ||
| 692 | .{ verb, p }, | ||
| 693 | ); | ||
| 694 | return null; | ||
| 695 | }, | ||
| 696 | }; | ||
| 697 | const key = quic.Key.load(key_path) catch |e| { | ||
| 698 | var buf: [quic.key_refusal_len]u8 = undefined; | ||
| 699 | std.debug.print("mux d {s}: {s}\n", .{ verb, quic.keyRefusalBody(&buf, e, key_path) }); | ||
| 700 | return null; | ||
| 701 | }; | ||
| 702 | const addr = quic.parseAddr(alloc, q.host_port) catch |e| { | ||
| 703 | std.debug.print("mux d {s}: --quic wants HOST[:PORT], got {s}: {s}\n", .{ verb, q.host_port, @errorName(e) }); | ||
| 704 | return null; | ||
| 705 | }; | ||
| 706 | const cl = quic.Client.connect(alloc, addr, key, quic.default_idle_ms) catch |e| { | ||
| 707 | std.debug.print("mux d {s}: cannot dial quic://{s}: {s}\n", .{ verb, q.host_port, @errorName(e) }); | ||
| 708 | return null; | ||
| 709 | }; | ||
| 710 | while (!cl.isReady()) { | ||
| 711 | cl.pump(); | ||
| 712 | if (cl.dead or std.time.milliTimestamp() >= deadline) { | ||
| 713 | cl.deinit(); | ||
| 714 | std.debug.print( | ||
| 715 | "mux d {s}: quic://{s} did not answer in {d}s (nothing there, or not this key)\n", | ||
| 716 | .{ verb, q.host_port, admin_quic_deadline_ms / 1000 }, | ||
| 717 | ); | ||
| 718 | return null; | ||
| 719 | } | ||
| 720 | var fds = [_]std.posix.pollfd{ | ||
| 721 | .{ .fd = cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 722 | }; | ||
| 723 | _ = std.posix.poll(&fds, cl.timeoutMs(50)) catch {}; | ||
| 724 | } | ||
| 725 | return .{ .quic = .{ .cl = cl, .alloc = alloc } }; | ||
| 726 | } | ||
| 727 | |||
| 728 | /// Perform a one-shot query. On a socket it is unbounded: a connected | ||
| 729 | /// daemon that stops replying remains a visible hang rather than being | ||
| 730 | /// reported like an absent socket. Over QUIC it is bounded, for the reason | ||
| 731 | /// on `admin_quic_deadline_ms`. | ||
| 614 | fn oneShotQuery( | 732 | fn oneShotQuery( |
| 615 | alloc: std.mem.Allocator, | 733 | alloc: std.mem.Allocator, |
| 616 | sock_path: []const u8, | 734 | target: AdminTarget, |
| 617 | verb: []const u8, | 735 | verb: []const u8, |
| 618 | req: proto.MsgType, | 736 | req: proto.MsgType, |
| 619 | req_payload: []const u8, | 737 | req_payload: []const u8, |
| 620 | want: proto.MsgType, | 738 | want: proto.MsgType, |
| 621 | ) !u8 { | 739 | ) !u8 { |
| 622 | const frame = (dial.ask(alloc, sock_path, req, req_payload, want, null) catch |e| switch (e) { | 740 | const frame = switch (target) { |
| 623 | error.NoDaemon => { | 741 | .sock => |sock_path| (dial.ask(alloc, sock_path, req, req_payload, want, null) catch |e| switch (e) { |
| 624 | std.debug.print( | 742 | error.NoDaemon => { |
| 625 | "mux d {s}: nothing listening on {s} (`mux d start -d` starts one)\n", | 743 | std.debug.print( |
| 626 | .{ verb, sock_path }, | 744 | "mux d {s}: nothing listening on {s} (`mux d start -d` starts one)\n", |
| 627 | ); | 745 | .{ verb, sock_path }, |
| 628 | return 1; | 746 | ); |
| 747 | return 1; | ||
| 748 | }, | ||
| 749 | // A reply this side could not read, or a daemon that hung up over the | ||
| 750 | // request: both stay errors, so a broken daemon never reads as an | ||
| 751 | // absent one. | ||
| 752 | else => return e, | ||
| 753 | }) orelse return 1, | ||
| 754 | .quic => |q| blk: { | ||
| 755 | const deadline = adminDeadline(); | ||
| 756 | var l = openAdminQuic(alloc, verb, q, deadline) orelse return 1; | ||
| 757 | // The goodbye: a QUIC connection holds one of the daemon's client | ||
| 758 | // slots until CONNECTION_CLOSE or its idle timer, and close() | ||
| 759 | // writes the former. | ||
| 760 | defer l.close(); | ||
| 761 | l.sendFrame(req, req_payload) catch |e| { | ||
| 762 | std.debug.print("mux d {s}: quic: could not send the request: {s}\n", .{ verb, @errorName(e) }); | ||
| 763 | return 1; | ||
| 764 | }; | ||
| 765 | const got = l.awaitFrame(alloc, want, msLeft(deadline), .{}) catch |e| switch (e) { | ||
| 766 | error.Closed => null, | ||
| 767 | else => return e, | ||
| 768 | }; | ||
| 769 | break :blk got orelse { | ||
| 770 | std.debug.print("mux d {s}: quic://{s} took the request and did not answer\n", .{ verb, q.host_port }); | ||
| 771 | return 1; | ||
| 772 | }; | ||
| 629 | }, | 773 | }, |
| 630 | // A reply this side could not read, or a daemon that hung up over the | 774 | }; |
| 631 | // request: both stay errors, so a broken daemon never reads as an | ||
| 632 | // absent one. | ||
| 633 | else => return e, | ||
| 634 | }) orelse return 1; | ||
| 635 | defer frame.deinit(alloc); | 775 | defer frame.deinit(alloc); |
| 636 | try proto.writeAllFd(std.posix.STDOUT_FILENO, frame.payload); | 776 | try proto.writeAllFd(std.posix.STDOUT_FILENO, frame.payload); |
| 637 | try proto.writeAllFd(std.posix.STDOUT_FILENO, "\n"); | 777 | try proto.writeAllFd(std.posix.STDOUT_FILENO, "\n"); |
| 638 | return 0; | 778 | return 0; |
| 639 | } | 779 | } |
| 640 | 780 | ||
| 641 | fn dump(alloc: std.mem.Allocator, sock_path: []const u8, vt_mode: bool, session: []const u8) !u8 { | 781 | fn dump(alloc: std.mem.Allocator, target: AdminTarget, vt_mode: bool, session: []const u8) !u8 { |
| 642 | // vt byte ++ session-name tail, built by the wire module — empty is the | 782 | // vt byte ++ session-name tail, built by the wire module — empty is the |
| 643 | // wire's own default spelling, so a bare `mux d dump` sends exactly the | 783 | // wire's own default spelling, so a bare `mux d dump` sends exactly the |
| 644 | // one-byte payload that predates session names. | 784 | // one-byte payload that predates session names. |
| 645 | var buf: [proto.debug_dump_max_len]u8 = undefined; | 785 | var buf: [proto.debug_dump_max_len]u8 = undefined; |
| 646 | const payload = proto.encodeDebugDumpNamed(&buf, vt_mode, session); | 786 | const payload = proto.encodeDebugDumpNamed(&buf, vt_mode, session); |
| 647 | return oneShotQuery(alloc, sock_path, "dump", .debug_dump, payload, .dump_reply); | 787 | return oneShotQuery(alloc, target, "dump", .debug_dump, payload, .dump_reply); |
| 648 | } | 788 | } |
| 649 | 789 | ||
| 650 | fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | 790 | fn stats(alloc: std.mem.Allocator, target: AdminTarget) !u8 { |
| 651 | return oneShotQuery(alloc, sock_path, "stats", .stats_req, "", .stats_reply); | 791 | return oneShotQuery(alloc, target, "stats", .stats_req, "", .stats_reply); |
| 652 | } | 792 | } |
| 653 | 793 | ||
| 654 | /// Request daemon shutdown, then wait for the peer process to exit rather than | 794 | /// Request daemon shutdown, then wait for the peer process to exit rather than |
| 655 | /// only for socket unlink. Both an already-absent daemon and a completed stop | 795 | /// only for socket unlink. Both an already-absent daemon and a completed stop |
| 656 | /// return zero, making the command idempotent for scripts. | 796 | /// return zero, making the command idempotent for scripts. |
| 657 | fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | 797 | /// |
| 798 | /// Over QUIC there is no pid to wait on and no errno that says "absent", so | ||
| 799 | /// the verdict is the daemon's own goodbye: `Server.deinit` tears every | ||
| 800 | /// client slot down before it frees anything else, and a QUIC slot's | ||
| 801 | /// teardown is a CONNECTION_CLOSE to us. A port that never answered is a | ||
| 802 | /// refusal (rc 1), not a no-op — the silence may be a firewall or the wrong | ||
| 803 | /// key, and a script that read it as "already stopped" would be lied to. | ||
| 804 | /// The goodbye is read off `quic.Client.dead`, which a lost packet stream | ||
| 805 | /// or an idle expiry also sets, so the message says what was seen — the | ||
| 806 | /// connection ended after the request was delivered — and does not claim | ||
| 807 | /// the pid is gone; `mux d stats --quic` afterwards is the check. | ||
| 808 | fn stopCmd(alloc: std.mem.Allocator, target: AdminTarget) !u8 { | ||
| 809 | const sock_path = switch (target) { | ||
| 810 | .sock => |p| p, | ||
| 811 | .quic => |q| { | ||
| 812 | const deadline = adminDeadline(); | ||
| 813 | var l = openAdminQuic(alloc, "stop", q, deadline) orelse return 1; | ||
| 814 | defer l.close(); | ||
| 815 | l.sendFrame(.stop_req, "") catch |e| { | ||
| 816 | std.debug.print("mux d stop: quic: could not deliver the stop request: {s}\n", .{@errorName(e)}); | ||
| 817 | return 1; | ||
| 818 | }; | ||
| 819 | // Nothing is ever wanted: the loop ends on the daemon's close. | ||
| 820 | // `stop_req` as the wanted type is one the daemon never sends. | ||
| 821 | const got = l.awaitFrame(alloc, .stop_req, msLeft(deadline), .{}) catch |e| switch (e) { | ||
| 822 | error.Closed => { | ||
| 823 | std.debug.print( | ||
| 824 | "mux d: stopped (quic://{s} ended the connection after taking the request)\n", | ||
| 825 | .{q.host_port}, | ||
| 826 | ); | ||
| 827 | return 0; | ||
| 828 | }, | ||
| 829 | else => return e, | ||
| 830 | }; | ||
| 831 | if (got) |f| f.deinit(alloc); | ||
| 832 | std.debug.print( | ||
| 833 | "mux d stop: quic://{s} took the request and is still connected after {d}s\n", | ||
| 834 | .{ q.host_port, admin_quic_deadline_ms / 1000 }, | ||
| 835 | ); | ||
| 836 | return 1; | ||
| 837 | }, | ||
| 838 | }; | ||
| 658 | const stream = dial.dial(sock_path) catch { | 839 | const stream = dial.dial(sock_path) catch { |
| 659 | std.debug.print("mux d stop: nothing listening on {s}\n", .{sock_path}); | 840 | std.debug.print("mux d stop: nothing listening on {s}\n", .{sock_path}); |
| 660 | return 0; | 841 | return 0; |
| @@ -666,30 +847,31 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 666 | const peer = peerPid(stream.handle); | 847 | const peer = peerPid(stream.handle); |
| 667 | stream.close(); | 848 | stream.close(); |
| 668 | 849 | ||
| 669 | // Probe before checking the deadline so the final interval is observed. | 850 | // The pid is the verdict whenever the kernel names one. The path is |
| 670 | // Connection refusal indicates that the shutdown unlink has completed; | 851 | // not: a daemon that lost its path to a successor watches that path |
| 671 | // a wedged event loop can still accept through the socket backlog. | 852 | // and re-binds it within a second of the successor's unlink |
| 853 | // (`Server.watchSockPath`), so "the socket still answers" can mean the | ||
| 854 | // OTHER daemon answered — and a stop that graded the path reported a | ||
| 855 | // clean exit as a failure. Process exit is also what a supervisor | ||
| 856 | // wants: the unlink happens before shell reaping and directory | ||
| 857 | // cleanup, so a stop that returned on the unlink returned early. | ||
| 858 | if (peer) |pid| return waitPidGone(alloc, pid, sock_path, asked); | ||
| 859 | |||
| 860 | // No pid (a kernel that does not expose the peer): the socket's | ||
| 861 | // silence is all there is. Probe before checking the deadline so the | ||
| 862 | // final interval is observed; a wedged event loop can still accept | ||
| 863 | // through the backlog, so silence here means the unlink ran. | ||
| 672 | const stop_deadline_ms: i64 = 2000; | 864 | const stop_deadline_ms: i64 = 2000; |
| 673 | const t0 = std.time.milliTimestamp(); | 865 | const t0 = std.time.milliTimestamp(); |
| 674 | while (true) { | 866 | while (true) { |
| 675 | if (!sockpath.answers(sock_path)) return waitPidGone(peer, sock_path); | 867 | if (!sockpath.answers(sock_path)) { |
| 868 | std.debug.print("mux d: stopped\n", .{}); | ||
| 869 | return 0; | ||
| 870 | } | ||
| 676 | if (std.time.milliTimestamp() - t0 >= stop_deadline_ms) break; | 871 | if (std.time.milliTimestamp() - t0 >= stop_deadline_ms) break; |
| 677 | std.Thread.sleep(50 * std.time.ns_per_ms); | 872 | std.Thread.sleep(50 * std.time.ns_per_ms); |
| 678 | } | 873 | } |
| 679 | const secs = @divTrunc(stop_deadline_ms, 1000); | 874 | return stillRunning(alloc, sock_path, asked, @divTrunc(stop_deadline_ms, 1000)); |
| 680 | if (!asked) { | ||
| 681 | std.debug.print( | ||
| 682 | "mux d stop: could not deliver the stop request to {s}, and it is still answering after {d}s\n", | ||
| 683 | .{ sock_path, secs }, | ||
| 684 | ); | ||
| 685 | return 1; | ||
| 686 | } | ||
| 687 | var hint: [log_hint_len]u8 = undefined; | ||
| 688 | std.debug.print( | ||
| 689 | "mux d stop: {s} still answering after {d}s{s}\n", | ||
| 690 | .{ sock_path, secs, logHint(alloc, &hint) }, | ||
| 691 | ); | ||
| 692 | return 1; | ||
| 693 | } | 875 | } |
| 694 | 876 | ||
| 695 | /// The peer's pid, or null when the kernel cannot expose it; callers then | 877 | /// The peer's pid, or null when the kernel cannot expose it; callers then |
| @@ -699,26 +881,15 @@ fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t { | |||
| 699 | return cred.pid; | 881 | return cred.pid; |
| 700 | } | 882 | } |
| 701 | 883 | ||
| 702 | /// Wait for the peer process after its socket disappears. Socket unlink occurs | 884 | /// Wait for the daemon's process to leave. Only a live process answers |
| 703 | /// before shell reaping and directory cleanup, so process exit is the reliable | 885 | /// signal 0 with success: ESRCH is the answer wanted, and EPERM means the |
| 704 | /// completion signal for supervisors. | 886 | /// pid was reused by someone else's process, so the daemon is just as gone. |
| 705 | fn waitPidGone(peer: ?std.posix.pid_t, sock_path: []const u8) u8 { | 887 | fn waitPidGone(alloc: std.mem.Allocator, pid: std.posix.pid_t, sock_path: []const u8, asked: bool) u8 { |
| 706 | const pid = peer orelse { | 888 | const gone_deadline_ms: i64 = 5000; |
| 707 | std.debug.print("mux d: stopped\n", .{}); | ||
| 708 | return 0; | ||
| 709 | }; | ||
| 710 | const gone_deadline_ms: i64 = 3000; | ||
| 711 | const t0 = std.time.milliTimestamp(); | 889 | const t0 = std.time.milliTimestamp(); |
| 712 | // Only a live process answers signal 0 with success. ESRCH is the | ||
| 713 | // answer wanted; EPERM means the pid was reused by someone else's | ||
| 714 | // process, and the daemon is just as gone. | ||
| 715 | while (std.posix.kill(pid, 0)) |_| { | 890 | while (std.posix.kill(pid, 0)) |_| { |
| 716 | if (std.time.milliTimestamp() - t0 >= gone_deadline_ms) { | 891 | if (std.time.milliTimestamp() - t0 >= gone_deadline_ms) { |
| 717 | std.debug.print( | 892 | return stillRunning(alloc, sock_path, asked, @divTrunc(gone_deadline_ms, 1000)); |
| 718 | "mux d stop: {s} is closed, but pid {d} is still running {d}s later\n", | ||
| 719 | .{ sock_path, pid, @divTrunc(gone_deadline_ms, 1000) }, | ||
| 720 | ); | ||
| 721 | return 1; | ||
| 722 | } | 893 | } |
| 723 | std.Thread.sleep(20 * std.time.ns_per_ms); | 894 | std.Thread.sleep(20 * std.time.ns_per_ms); |
| 724 | } else |_| {} | 895 | } else |_| {} |
| @@ -726,6 +897,25 @@ fn waitPidGone(peer: ?std.posix.pid_t, sock_path: []const u8) u8 { | |||
| 726 | return 0; | 897 | return 0; |
| 727 | } | 898 | } |
| 728 | 899 | ||
| 900 | /// The stop's failure line. Whether the request was delivered decides | ||
| 901 | /// whether the log is worth pointing at: a daemon that never read the | ||
| 902 | /// frame has nothing to say about it there. | ||
| 903 | fn stillRunning(alloc: std.mem.Allocator, sock_path: []const u8, asked: bool, secs: i64) u8 { | ||
| 904 | if (!asked) { | ||
| 905 | std.debug.print( | ||
| 906 | "mux d stop: could not deliver the stop request to {s}, and it is still running after {d}s\n", | ||
| 907 | .{ sock_path, secs }, | ||
| 908 | ); | ||
| 909 | return 1; | ||
| 910 | } | ||
| 911 | var hint: [log_hint_len]u8 = undefined; | ||
| 912 | std.debug.print( | ||
| 913 | "mux d stop: the daemon on {s} is still running after {d}s{s}\n", | ||
| 914 | .{ sock_path, secs, logHint(alloc, &hint) }, | ||
| 915 | ); | ||
| 916 | return 1; | ||
| 917 | } | ||
| 918 | |||
| 729 | /// What the remote-upgrade preflight learned. One ssh run answers everything: | 919 | /// What the remote-upgrade preflight learned. One ssh run answers everything: |
| 730 | /// `uname -m && command -v mux && mux d endpoint` — the bare endpoint verb, | 920 | /// `uname -m && command -v mux && mux d endpoint` — the bare endpoint verb, |
| 731 | /// which never starts a daemon — and the LINE COUNT is the diagnosis, because | 921 | /// which never starts a daemon — and the LINE COUNT is the diagnosis, because |
| @@ -1771,6 +1961,35 @@ test "parseArgs: stop is a command and takes --sock" { | |||
| 1771 | try std.testing.expectEqualStrings("/tmp/x.sock", s.command.sock.?); | 1961 | try std.testing.expectEqualStrings("/tmp/x.sock", s.command.sock.?); |
| 1772 | } | 1962 | } |
| 1773 | 1963 | ||
| 1964 | test "parseArgs: stop, dump and stats take --quic as the other door, and never both doors" { | ||
| 1965 | // The path-less daemon's door (145807a2): the same `--quic HOST:PORT | ||
| 1966 | // [--key]` mux a spells, on the three verbs the daemon serves to a | ||
| 1967 | // QUIC client slot. | ||
| 1968 | const q = parse(&.{ "d", "stop", "--quic", "127.0.0.1:4433", "--key", "/k" }); | ||
| 1969 | try std.testing.expect(q == .command); | ||
| 1970 | try std.testing.expect(q.command._cmd == .stop); | ||
| 1971 | try std.testing.expectEqualStrings("127.0.0.1:4433", q.command.quic.?); | ||
| 1972 | try std.testing.expectEqualStrings("/k", q.command.key.?); | ||
| 1973 | try std.testing.expect(parse(&.{ "d", "stats", "--quic", "box" }) == .command); | ||
| 1974 | try std.testing.expect(parse(&.{ "d", "dump", "--session", "a", "--quic", "box:1" }) == .command); | ||
| 1975 | |||
| 1976 | // Two daemons named for one question is a refusal, not a preference. | ||
| 1977 | try std.testing.expect(parse(&.{ "d", "stop", "--sock", "/x", "--quic", "box" }).usage == .sock_with_quic); | ||
| 1978 | try std.testing.expect(parse(&.{ "d", "stats", "--quic", "box", "--sock", "/x" }).usage == .sock_with_quic); | ||
| 1979 | // `start` listens on both, so both stay legal there. | ||
| 1980 | try std.testing.expect(parse(&.{ "d", "start", "--sock", "/x", "--quic", "0.0.0.0:1" }) == .command); | ||
| 1981 | // And the key rule is unchanged: --key still wants --quic. | ||
| 1982 | try std.testing.expect(parse(&.{ "d", "stop", "--key", "/k" }).usage == .key_without_quic); | ||
| 1983 | |||
| 1984 | // A verb with no QUIC door refuses the flag rather than dropping it: | ||
| 1985 | // `upgrade --quic HOST` used to upgrade the socket's daemon in silence. | ||
| 1986 | inline for (.{ "upgrade", "proxy", "endpoint" }) |verb| { | ||
| 1987 | const r = parse(&.{ "d", verb, "--quic", "box" }); | ||
| 1988 | try std.testing.expect(r == .usage); | ||
| 1989 | try std.testing.expectEqualStrings("--quic", r.usage.unknown_arg); | ||
| 1990 | } | ||
| 1991 | } | ||
| 1992 | |||
| 1774 | test "parseArgs: endpoint is a command and takes --sock" { | 1993 | test "parseArgs: endpoint is a command and takes --sock" { |
| 1775 | const r = parse(&.{ "d", "endpoint" }); | 1994 | const r = parse(&.{ "d", "endpoint" }); |
| 1776 | try std.testing.expect(r == .command); | 1995 | try std.testing.expect(r == .command); |
| @@ -2032,11 +2251,11 @@ test "oneShotQuery: a socket nobody serves is exit 1" { | |||
| 2032 | // therefore return one for an absent socket. | 2251 | // therefore return one for an absent socket. |
| 2033 | try std.testing.expectEqual( | 2252 | try std.testing.expectEqual( |
| 2034 | @as(u8, 1), | 2253 | @as(u8, 1), |
| 2035 | try oneShotQuery(std.testing.allocator, sock, "dump", .debug_dump, "", .dump_reply), | 2254 | try oneShotQuery(std.testing.allocator, .{ .sock = sock }, "dump", .debug_dump, "", .dump_reply), |
| 2036 | ); | 2255 | ); |
| 2037 | try std.testing.expectEqual( | 2256 | try std.testing.expectEqual( |
| 2038 | @as(u8, 1), | 2257 | @as(u8, 1), |
| 2039 | try oneShotQuery(std.testing.allocator, sock, "stats", .stats_req, "", .stats_reply), | 2258 | try oneShotQuery(std.testing.allocator, .{ .sock = sock }, "stats", .stats_req, "", .stats_reply), |
| 2040 | ); | 2259 | ); |
| 2041 | } | 2260 | } |
| 2042 | 2261 | ||
| @@ -2046,7 +2265,7 @@ test "stopCmd: a socket path with nothing on it is exit 0, not a failure" { | |||
| 2046 | defer tmp.cleanup(); | 2265 | defer tmp.cleanup(); |
| 2047 | var buf: [280]u8 = undefined; | 2266 | var buf: [280]u8 = undefined; |
| 2048 | const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()}); | 2267 | const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()}); |
| 2049 | try std.testing.expectEqual(@as(u8, 0), try stopCmd(std.testing.allocator, sock)); | 2268 | try std.testing.expectEqual(@as(u8, 0), try stopCmd(std.testing.allocator, .{ .sock = sock })); |
| 2050 | } | 2269 | } |
| 2051 | 2270 | ||
| 2052 | test "peerPid: the kernel names the peer" { | 2271 | test "peerPid: the kernel names the peer" { |
| @@ -2079,7 +2298,7 @@ test "waitPidGone: returns only once the OS has no such process" { | |||
| 2079 | _ = try child.wait(); | 2298 | _ = try child.wait(); |
| 2080 | const pid = try std.fmt.parseInt(std.posix.pid_t, std.mem.trim(u8, buf[0..n], "\n "), 10); | 2299 | const pid = try std.fmt.parseInt(std.posix.pid_t, std.mem.trim(u8, buf[0..n], "\n "), 10); |
| 2081 | try std.posix.kill(pid, 0); // alive when we start, or the wait proves nothing | 2300 | try std.posix.kill(pid, 0); // alive when we start, or the wait proves nothing |
| 2082 | try std.testing.expectEqual(@as(u8, 0), waitPidGone(pid, "(test)")); | 2301 | try std.testing.expectEqual(@as(u8, 0), waitPidGone(std.testing.allocator, pid, "(test)", true)); |
| 2083 | try std.testing.expectError(error.ProcessNotFound, std.posix.kill(pid, 0)); | 2302 | try std.testing.expectError(error.ProcessNotFound, std.posix.kill(pid, 0)); |
| 2084 | } | 2303 | } |
| 2085 | 2304 | ||
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -176,8 +176,8 @@ done | |||
| 176 | # one of those and adds a convergence point would be pinning a fact every | 176 | # one of those and adds a convergence point would be pinning a fact every |
| 177 | # leg above already establishes. | 177 | # leg above already establishes. |
| 178 | 178 | ||
| 179 | [ "$OK_COUNT" = "115" ] || { | 179 | [ "$OK_COUNT" = "117" ] || { |
| 180 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 115 —" | 180 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 117 —" |
| 181 | echo " a scenario was added (update the pin) or silently lost" | 181 | echo " a scenario was added (update the pin) or silently lost" |
| 182 | exit 1 | 182 | exit 1 |
| 183 | } | 183 | } |
test/e2e_05_session.sh
| Old | New | ||
|---|---|---|---|
| @@ -402,6 +402,53 @@ if dump_session "$SOCK23" b | grep -q "x18a-pin"; then | |||
| 402 | dump_session "$SOCK23" b; exit 1 | 402 | dump_session "$SOCK23" b; exit 1 |
| 403 | fi | 403 | fi |
| 404 | 404 | ||
| 405 | assert_stopped "$SOCK23" "$D20PID" "M18 quic multi-session" "$OUT.m18qstop" | ||
| 406 | D20PID="" | ||
| 407 | ok "quic: two dials are two sessions, and one name is one session on either transport" | 405 | ok "quic: two dials are two sessions, and one name is one session on either transport" |
| 406 | |||
| 407 | # --- the admin verbs by QUIC: the path-less daemon's door (145807a2) ------ | ||
| 408 | # | ||
| 409 | # The incident's end state: the daemon's socket file is gone and (in the | ||
| 410 | # real one) a successor holds the path, so the re-bind is refused and | ||
| 411 | # `--sock` reaches the wrong daemon or none. This daemon has a QUIC arm, | ||
| 412 | # and the verbs the daemon serves on a client slot — stats, dump, stop — | ||
| 413 | # now take `--quic HOST:PORT --key` as `mux a` always has. Graded with | ||
| 414 | # the file really deleted: the daemon takes it back within a second (the | ||
| 415 | # boot group pins that), so every ask below is made in the window where | ||
| 416 | # only QUIC can reach it, or after, and either way through UDP alone. | ||
| 417 | rm "$SOCK23" | ||
| 418 | "$MUX" d stats --quic "127.0.0.1:$QPORT5" --key "$M18KEY" > "$OUT.m18qstats" 2>&1 || { | ||
| 419 | echo "e2e FAIL: mux d stats --quic failed"; cat "$OUT.m18qstats"; exit 1; } | ||
| 420 | grep -q 'sessions=3' "$OUT.m18qstats" || { | ||
| 421 | echo "e2e FAIL: stats over quic did not count the three sessions"; cat "$OUT.m18qstats"; exit 1; } | ||
| 422 | "$MUX" d dump --quic "127.0.0.1:$QPORT5" --key "$M18KEY" --session a > "$OUT.m18qdump" 2>&1 || { | ||
| 423 | echo "e2e FAIL: mux d dump --quic failed"; cat "$OUT.m18qdump"; exit 1; } | ||
| 424 | grep -q 'q18a-pin' "$OUT.m18qdump" || { | ||
| 425 | echo "e2e FAIL: dump over quic did not carry session a's marker"; cat "$OUT.m18qdump"; exit 1; } | ||
| 426 | # Both doors at once is a refusal at parse, rc 2, and no frame is sent. | ||
| 427 | set +e | ||
| 428 | "$MUX" d stats --sock "$SOCK23" --quic "127.0.0.1:$QPORT5" > /dev/null 2> "$OUT.m18qboth" | ||
| 429 | _rc=$? | ||
| 430 | set -e | ||
| 431 | [ "$_rc" = "2" ] || { echo "e2e FAIL: --sock with --quic exited $_rc, want 2"; cat "$OUT.m18qboth"; exit 1; } | ||
| 432 | grep -q 'name two daemons' "$OUT.m18qboth" || { | ||
| 433 | echo "e2e FAIL: --sock with --quic did not say why"; cat "$OUT.m18qboth"; exit 1; } | ||
| 434 | # The stop, and the OS says it took: the pid goes, and whatever socket file | ||
| 435 | # the re-bind put back goes with it (deinit's unlink), so no leftover | ||
| 436 | # names a daemon that is not there. | ||
| 437 | set +e | ||
| 438 | "$MUX" d stop --quic "127.0.0.1:$QPORT5" --key "$M18KEY" 2> "$OUT.m18qstop" | ||
| 439 | _rc=$? | ||
| 440 | set -e | ||
| 441 | [ "$_rc" = "0" ] || { echo "e2e FAIL: mux d stop --quic exited $_rc, want 0"; cat "$OUT.m18qstop"; exit 1; } | ||
| 442 | grep -q '^mux d: stopped' "$OUT.m18qstop" || { | ||
| 443 | echo "e2e FAIL: stop over quic did not report stopped"; cat "$OUT.m18qstop"; exit 1; } | ||
| 444 | wait_pid_gone "$D20PID" "stop over quic reported stopped" | ||
| 445 | [ ! -S "$SOCK23" ] || { echo "e2e FAIL: stop over quic left $SOCK23"; ls -l "$SOCK23"; exit 1; } | ||
| 446 | # And a stopped port is a refusal, not a silent no-op: a script must not | ||
| 447 | # read a firewall or a wrong key as "already stopped". | ||
| 448 | set +e | ||
| 449 | "$MUX" d stop --quic "127.0.0.1:$QPORT5" --key "$M18KEY" 2> "$OUT.m18qstop2" | ||
| 450 | _rc=$? | ||
| 451 | set -e | ||
| 452 | [ "$_rc" = "1" ] || { echo "e2e FAIL: stop on a dead quic port exited $_rc, want 1"; cat "$OUT.m18qstop2"; exit 1; } | ||
| 453 | D20PID="" | ||
| 454 | ok "quic: stats, dump and stop reach a daemon whose socket file is gone (145807a2)" | ||