cafa56ed
refactor: the four entrypoints move to src/cli/
a73x 2026-08-27 05:50
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -256,7 +256,7 @@ const mod_table = [_]ModSpec{ | |||
| 256 | // renders: `quic_client` for the remote arm and `xdg` for the one | 256 | // renders: `quic_client` for the remote arm and `xdg` for the one |
| 257 | // key-resolution rule all three binaries obey. Deliberately still no | 257 | // key-resolution rule all three binaries obey. Deliberately still no |
| 258 | // engine and no replica — muxa has nothing to draw. | 258 | // engine and no replica — muxa has nothing to draw. |
| 259 | .{ .name = "muxa", .path = "src/muxa.zig", .layer = 2, .link_libc = true, .imports = &.{ "protocol", "sockpath", "quic_client", "quic", "xdg" }, .quic_tests = true }, | 259 | .{ .name = "muxa", .path = "src/cli/muxa.zig", .layer = 2, .link_libc = true, .imports = &.{ "protocol", "sockpath", "quic_client", "quic", "xdg" }, .quic_tests = true }, |
| 260 | .{ .name = "wsclient", .path = "test/wsclient.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "replica", "protocol", "script" } }, | 260 | .{ .name = "wsclient", .path = "test/wsclient.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "replica", "protocol", "script" } }, |
| 261 | // ---- layer 3 ---- | 261 | // ---- layer 3 ---- |
| 262 | // Dialling, and what a chord means. The client is the only thing that | 262 | // Dialling, and what a chord means. The client is the only thing that |
| @@ -275,7 +275,7 @@ const mod_table = [_]ModSpec{ | |||
| 275 | // the sun_path bound, checked before any verb acts on the path; and the | 275 | // the sun_path bound, checked before any verb acts on the path; and the |
| 276 | // keygen round-trip test needs a directory to generate into, which the | 276 | // keygen round-trip test needs a directory to generate into, which the |
| 277 | // daemon itself never touches. | 277 | // daemon itself never touches. |
| 278 | .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath", "upgrade", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 278 | .{ .name = "exe", .path = "src/cli/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath", "upgrade", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 279 | // ---- layer 4 ---- | 279 | // ---- layer 4 ---- |
| 280 | // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route | 280 | // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route |
| 281 | // table, WS endpoint naming. Assets are injected (the exe root | 281 | // table, WS endpoint naming. Assets are injected (the exe root |
| @@ -299,14 +299,14 @@ const mod_table = [_]ModSpec{ | |||
| 299 | // one grammar, not three. Resolution itself now lives in the Hub, so | 299 | // one grammar, not three. Resolution itself now lives in the Hub, so |
| 300 | // handoff/protocol left with it; sockpath stays for the one startup | 300 | // handoff/protocol left with it; sockpath stays for the one startup |
| 301 | // message that names the sun_path bound. | 301 | // message that names the sun_path bound. |
| 302 | .{ .name = "webhub_main", .path = "src/webhub_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "webhub", "wall", "xdg", "sockpath" }, .quic_tests = true }, | 302 | .{ .name = "webhub_main", .path = "src/cli/webhub_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "webhub", "wall", "xdg", "sockpath" }, .quic_tests = true }, |
| 303 | // sockpath is the sun_path bound only; the client binds no socket itself. | 303 | // sockpath is the sun_path bound only; the client binds no socket itself. |
| 304 | // protocol is the session-name validator alone (validSessionName): a bad | 304 | // protocol is the session-name validator alone (validSessionName): a bad |
| 305 | // --session has to be a usage error here, at parse, not bytes some | 305 | // --session has to be a usage error here, at parse, not bytes some |
| 306 | // daemon downstream has to notice and refuse. Layer 5 since `mux wall` | 306 | // daemon downstream has to notice and refuse. Layer 5 since `mux wall` |
| 307 | // pulled in wallview (layer 4); wall rides along for the no-arg wall | 307 | // pulled in wallview (layer 4); wall rides along for the no-arg wall |
| 308 | // (the state file the browser hub builds). | 308 | // (the state file the browser hub builds). |
| 309 | .{ .name = "mux", .path = "src/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 309 | .{ .name = "mux", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 310 | }; | 310 | }; |
| 311 | 311 | ||
| 312 | /// Comptime row lookup. Every hand-written module name in this file goes | 312 | /// Comptime row lookup. Every hand-written module name in this file goes |
| @@ -567,7 +567,7 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui | |||
| 567 | zigFilesIn(b, "src/cli", &indexed); | 567 | zigFilesIn(b, "src/cli", &indexed); |
| 568 | zigFilesIn(b, "test", &indexed); | 568 | zigFilesIn(b, "test", &indexed); |
| 569 | zigFilesIn(b, "tools", &indexed); | 569 | zigFilesIn(b, "tools", &indexed); |
| 570 | // build.zig is cited by name in src/main.zig's comments and is a real | 570 | // build.zig is cited by name in src/cli/main.zig's comments and is a real |
| 571 | // file of this repo, so it belongs in the corpus even though it is not | 571 | // file of this repo, so it belongs in the corpus even though it is not |
| 572 | // under src/ or test/. | 572 | // under src/ or test/. |
| 573 | indexed.append(b.allocator, "build.zig") catch @panic("OOM"); | 573 | indexed.append(b.allocator, "build.zig") catch @panic("OOM"); |
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,1825 @@ | |||
| 1 | //! muxd — daemon entrypoint. `run` hosts the session; `dump` prints the | ||
| 2 | //! authoritative grid over the protocol (debug aid, also used by e2e); | ||
| 3 | //! `proxy` exposes the session socket over stdio for `mux --via`. | ||
| 4 | const std = @import("std"); | ||
| 5 | const Server = @import("server").Server; | ||
| 6 | const proto = @import("protocol"); | ||
| 7 | const proxy = @import("proxy"); | ||
| 8 | const quic = @import("quic"); | ||
| 9 | const quic_server = @import("quic_server"); | ||
| 10 | const build_options = @import("build_options"); | ||
| 11 | const xdg = @import("xdg"); | ||
| 12 | const spawn = @import("spawn"); | ||
| 13 | const handoff = @import("handoff"); | ||
| 14 | const sockpath = @import("sockpath"); | ||
| 15 | const upgrade = @import("upgrade"); | ||
| 16 | const cliflags = @import("cliflags"); | ||
| 17 | |||
| 18 | const usage = | ||
| 19 | \\usage: | ||
| 20 | \\ muxd run [--sock PATH] [--shell PATH] [--cols N] [--rows N] | ||
| 21 | \\ [--quic HOST[:PORT] --key FILE] [--quic-idle-ms N] | ||
| 22 | \\ muxd dump [--vt] [--session NAME] [--sock PATH] | ||
| 23 | \\ muxd stats [--sock PATH] | ||
| 24 | \\ muxd stop [--sock PATH] (ask the daemon on PATH to exit) | ||
| 25 | \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket) | ||
| 26 | \\ muxd endpoint [--sock PATH] (proxy that first announces QUIC port+key) | ||
| 27 | \\ muxd keygen (write a fresh key to ~/.config/mux/key) | ||
| 28 | \\ muxd start [run's flags] (spawn a daemon detached; no-op if one is up) | ||
| 29 | \\ muxd upgrade [--sock PATH] (exec THIS binary over the daemon; sessions live) | ||
| 30 | \\ [--allow-same-version] (strictly newer, unless this; the e2e leg's) | ||
| 31 | \\ muxd --version | ||
| 32 | \\ muxd --help | ||
| 33 | \\ | ||
| 34 | ; | ||
| 35 | |||
| 36 | /// `--key` beats `MUX_KEY_FILE` beats the default path — more specific | ||
| 37 | /// intent sits higher. Split out so the order is testable without a | ||
| 38 | /// daemon. | ||
| 39 | fn pickKey(flag: ?[]const u8, env: ?[]const u8, default_if_present: ?[]const u8) ?[]const u8 { | ||
| 40 | return flag orelse env orelse default_if_present; | ||
| 41 | } | ||
| 42 | |||
| 43 | /// MUX_KEY_FILE, with "set but empty" read as unset — an empty path | ||
| 44 | /// could only be a mistake, and Key.load would blame "". | ||
| 45 | fn envKey() ?[]const u8 { | ||
| 46 | const v = std.posix.getenv("MUX_KEY_FILE") orelse return null; | ||
| 47 | return if (v.len == 0) null else v; | ||
| 48 | } | ||
| 49 | |||
| 50 | /// The daemon's name for the shared default; see `quic.default_idle_ms` | ||
| 51 | /// for what the number means and why it lives there. | ||
| 52 | const default_quic_idle_ms: u32 = quic.default_idle_ms; | ||
| 53 | |||
| 54 | const Cmd = enum { run, dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade }; | ||
| 55 | |||
| 56 | /// One row per verb. Adding a subcommand used to mean editing the usage | ||
| 57 | /// literal, the Cmd enum, a name→Cmd if/else chain, keygen's hand-rolled | ||
| 58 | /// no-flags exception, and main's uses_socket switch — of which only the | ||
| 59 | /// dispatch switch was compiler-checked. Everything except the dispatch body | ||
| 60 | /// and the prose now reads off this table, and the two legs a table cannot | ||
| 61 | /// check itself have their own: the comptime block below (every Cmd has | ||
| 62 | /// exactly one row) and "usage names every subcommand" (every row is in the | ||
| 63 | /// prose, on its own line). | ||
| 64 | const Spec = struct { | ||
| 65 | name: []const u8, | ||
| 66 | cmd: Cmd, | ||
| 67 | /// Whether the resolved socket path is this verb's business — the | ||
| 68 | /// length guard in `main` reads this, and answering `false` means a | ||
| 69 | /// doomed path cannot refuse a command that never touches a socket. | ||
| 70 | uses_socket: bool, | ||
| 71 | /// What the verb does with the words after its name. `all` runs the | ||
| 72 | /// shared flag loop; `none` refuses any argument at all; `ignored` | ||
| 73 | /// returns before the loop, so nothing after the name is read. | ||
| 74 | /// | ||
| 75 | /// `ignored` is a contract, not an oversight: trailing arguments are | ||
| 76 | /// ACCEPTED and vanish silently. `muxd --version` with an over-long | ||
| 77 | /// `--sock` exits 0 today, pinned in e2e.sh ("--version refused over | ||
| 78 | /// sock length"). Narrowing it to `none` would turn that into a | ||
| 79 | /// refusal. | ||
| 80 | flags: enum { none, all, ignored }, | ||
| 81 | }; | ||
| 82 | |||
| 83 | const specs = [_]Spec{ | ||
| 84 | // First as the odd one out — the lookup is exact-match, so position | ||
| 85 | // carries no meaning. Spelled as a flag because that is what everyone | ||
| 86 | // types, but it is a command: it names what the process does instead of | ||
| 87 | // configuring one, and answers from the binary alone. | ||
| 88 | .{ .name = "--version", .cmd = .version, .uses_socket = false, .flags = .ignored }, | ||
| 89 | // Spelled as a flag for the same reason, and `.ignored` for a second: | ||
| 90 | // asking for the usage must never be refused over the words next to it. | ||
| 91 | .{ .name = "--help", .cmd = .help, .uses_socket = false, .flags = .ignored }, | ||
| 92 | .{ .name = "run", .cmd = .run, .uses_socket = true, .flags = .all }, | ||
| 93 | .{ .name = "dump", .cmd = .dump, .uses_socket = true, .flags = .all }, | ||
| 94 | .{ .name = "stats", .cmd = .stats, .uses_socket = true, .flags = .all }, | ||
| 95 | .{ .name = "proxy", .cmd = .proxy, .uses_socket = true, .flags = .all }, | ||
| 96 | .{ .name = "endpoint", .cmd = .endpoint, .uses_socket = true, .flags = .all }, | ||
| 97 | // keygen configures nothing: its one output is the default path, and a | ||
| 98 | // flag here would be a request this command cannot honor. | ||
| 99 | .{ .name = "keygen", .cmd = .keygen, .uses_socket = false, .flags = .none }, | ||
| 100 | .{ .name = "start", .cmd = .start, .uses_socket = true, .flags = .all }, | ||
| 101 | .{ .name = "stop", .cmd = .stop, .uses_socket = true, .flags = .all }, | ||
| 102 | .{ .name = "upgrade", .cmd = .upgrade, .uses_socket = true, .flags = .all }, | ||
| 103 | }; | ||
| 104 | |||
| 105 | comptime { | ||
| 106 | for (std.enums.values(Cmd)) |c| { | ||
| 107 | var rows = 0; | ||
| 108 | for (specs) |s| { | ||
| 109 | if (s.cmd == c) rows += 1; | ||
| 110 | } | ||
| 111 | // Two rows for one Cmd is as wrong as none, and quieter: the second | ||
| 112 | // is unreachable through `specForName` only if its name is dead, and | ||
| 113 | // `specForCmd` would answer with whichever came first. | ||
| 114 | if (rows == 0) @compileError("Cmd has no row in specs: " ++ @tagName(c)); | ||
| 115 | if (rows > 1) @compileError("Cmd has more than one row in specs: " ++ @tagName(c)); | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | /// No prefix matching: `ru` is a typo, and guessing which verb it | ||
| 120 | /// meant is how a typo becomes a daemon. | ||
| 121 | fn specForName(name: []const u8) ?Spec { | ||
| 122 | for (specs) |s| { | ||
| 123 | if (std.mem.eql(u8, name, s.name)) return s; | ||
| 124 | } | ||
| 125 | return null; | ||
| 126 | } | ||
| 127 | |||
| 128 | /// Unreachable is honest because the comptime check fails the build | ||
| 129 | /// for a Cmd with no row. | ||
| 130 | fn specForCmd(cmd: Cmd) Spec { | ||
| 131 | for (specs) |s| { | ||
| 132 | if (s.cmd == cmd) return s; | ||
| 133 | } | ||
| 134 | unreachable; | ||
| 135 | } | ||
| 136 | |||
| 137 | /// Everything the command line can say, once. Parsed away from `main` so it | ||
| 138 | /// can be tested without a process to exit from — the same reason | ||
| 139 | /// mux_main.zig's parser is its own function. | ||
| 140 | const Opts = struct { | ||
| 141 | /// Not a flag, and the leading underscore is what says so: cliflags.parse | ||
| 142 | /// skips it, the verb having been settled by the row above. | ||
| 143 | _cmd: Cmd, | ||
| 144 | sock: ?[]const u8 = null, | ||
| 145 | shell: ?[]const u8 = null, | ||
| 146 | cols: u16 = 80, | ||
| 147 | rows: u16 = 24, | ||
| 148 | vt: bool = false, | ||
| 149 | quic: ?[]const u8 = null, | ||
| 150 | /// Null does NOT mean "no key": it means the command line named none, | ||
| 151 | /// and `run` still has MUX_KEY_FILE and the default path to try. Only | ||
| 152 | /// `--key` without `--quic` is settled here, because that one has no | ||
| 153 | /// reading that makes it sensible. | ||
| 154 | key: ?[]const u8 = null, | ||
| 155 | /// u32 rather than u64 so that an absurd value is a parse failure rather | ||
| 156 | /// than an overflow where it is multiplied out to nanoseconds. | ||
| 157 | quic_idle_ms: u32 = default_quic_idle_ms, | ||
| 158 | /// The session `dump` names. Optional so that only a name that was TYPED | ||
| 159 | /// is validated: `""` is the wire's own default spelling — no tail at | ||
| 160 | /// all — and would pass a check written against the empty string. | ||
| 161 | session: ?[]const u8 = null, | ||
| 162 | /// The inherited manifest descriptor an upgrade exec'd us with. Not a | ||
| 163 | /// user flag: the old daemon writes it into our argv. Its presence is | ||
| 164 | /// what makes `run` an ADOPTION rather than a start, and it is also | ||
| 165 | /// what excuses this process from resolving a socket path — the | ||
| 166 | /// manifest names the socket the inherited listener is already bound to. | ||
| 167 | resume_fd: ?std.posix.fd_t = null, | ||
| 168 | /// The old daemon's dry run: parse the manifest to the end and exit 0 | ||
| 169 | /// having adopted nothing. A candidate that cannot read the manifest | ||
| 170 | /// must fail HERE, in a child, while the old daemon is still serving. | ||
| 171 | check: bool = false, | ||
| 172 | /// Test-only: abort adoption after the named section (`daemon`, | ||
| 173 | /// `session`) so the rollback exec has something to trigger it. The | ||
| 174 | /// rollback leg is the only thing that can prove a daemon survives a | ||
| 175 | /// candidate that reads the manifest and then cannot use it. | ||
| 176 | resume_fail_at: ?[]const u8 = null, | ||
| 177 | /// `upgrade`'s one exception to the strictly-newer rule. It exists for | ||
| 178 | /// the e2e leg, which has only one binary to upgrade with. | ||
| 179 | allow_same_version: bool = false, | ||
| 180 | }; | ||
| 181 | |||
| 182 | // The three flags left out are written into argv by the OLD daemon on an | ||
| 183 | // upgrade and never typed by a hand, so the prose does not offer them. | ||
| 184 | comptime { | ||
| 185 | cliflags.assertDocumented(Opts, usage, &.{ "resume_fd", "check", "resume_fail_at" }); | ||
| 186 | } | ||
| 187 | |||
| 188 | /// What `main` prints instead of running the command. Every case but `help` | ||
| 189 | /// is a refusal; none of them is a daemon bug, so none gets a stack trace. | ||
| 190 | const Usage = union(enum) { | ||
| 191 | no_command, | ||
| 192 | /// `--help` on a subcommand. The answer the user asked for, so it is the | ||
| 193 | /// one case `usageExit` exits 0 on. | ||
| 194 | help, | ||
| 195 | unknown_command: []const u8, | ||
| 196 | unknown_arg: []const u8, | ||
| 197 | /// A flag at the end of argv with nothing left to consume. | ||
| 198 | missing_value: []const u8, | ||
| 199 | /// The flag whose value would not parse as the number it wants. | ||
| 200 | bad_number: []const u8, | ||
| 201 | key_without_quic, | ||
| 202 | /// The name itself, not the flag: `muxd: bad session name: {s}` names | ||
| 203 | /// what was typed, which is the actionable half. | ||
| 204 | bad_session_name: []const u8, | ||
| 205 | }; | ||
| 206 | |||
| 207 | const ParseResult = union(enum) { ok: Opts, err: Usage }; | ||
| 208 | |||
| 209 | fn parseArgs(args: []const [:0]const u8) ParseResult { | ||
| 210 | if (args.len < 2) return .{ .err = .no_command }; | ||
| 211 | const spec = specForName(args[1]) orelse return .{ .err = .{ .unknown_command = args[1] } }; | ||
| 212 | |||
| 213 | // The one place a verb's flag class is enforced; which class each verb | ||
| 214 | // is in is stated once, in its row. | ||
| 215 | switch (spec.flags) { | ||
| 216 | .ignored => return .{ .ok = .{ ._cmd = spec.cmd } }, | ||
| 217 | .none => if (args.len > 2) return .{ .err = .{ .unknown_arg = args[2] } }, | ||
| 218 | .all => {}, | ||
| 219 | } | ||
| 220 | |||
| 221 | var o: Opts = .{ ._cmd = spec.cmd }; | ||
| 222 | switch (cliflags.parse(Opts, &o, args[2..])) { | ||
| 223 | .ok => {}, | ||
| 224 | .help => return .{ .err = .help }, | ||
| 225 | .unknown_arg => |a| return .{ .err = .{ .unknown_arg = a } }, | ||
| 226 | .missing_value => |f| return .{ .err = .{ .missing_value = f } }, | ||
| 227 | .bad_number => |f| return .{ .err = .{ .bad_number = f } }, | ||
| 228 | } | ||
| 229 | |||
| 230 | // Refused here rather than carried to the wire as a payload nothing could | ||
| 231 | // ever look up — the same "check before it becomes a frame" the socket | ||
| 232 | // path length guard follows. | ||
| 233 | if (o.session) |name| { | ||
| 234 | if (!proto.validSessionName(name)) return .{ .err = .{ .bad_session_name = name } }; | ||
| 235 | } | ||
| 236 | |||
| 237 | // Zero is refused because ngtcp2 reads it as "no idle timeout", the | ||
| 238 | // opposite of what the flag says. | ||
| 239 | if (o.quic_idle_ms == 0) return .{ .err = .{ .bad_number = "--quic-idle-ms" } }; | ||
| 240 | |||
| 241 | // A key with nowhere to listen is a mistake parse can see the whole of. | ||
| 242 | // The mirror case is NOT one: `--quic` with no `--key` may still be | ||
| 243 | // answered by MUX_KEY_FILE or the default key path, neither of which | ||
| 244 | // parse is allowed to look at, so it defers to `run`. | ||
| 245 | if (o.key != null and o.quic == null) return .{ .err = .key_without_quic }; | ||
| 246 | |||
| 247 | return .{ .ok = o }; | ||
| 248 | } | ||
| 249 | |||
| 250 | /// The code alone: a test can ask it without a process. | ||
| 251 | fn usageCode(u: Usage) u8 { | ||
| 252 | return if (u == .help) 0 else 2; | ||
| 253 | } | ||
| 254 | |||
| 255 | fn usageExit(u: Usage) u8 { | ||
| 256 | switch (u) { | ||
| 257 | // stdout, unlike every refusal below: a usage someone asked for is | ||
| 258 | // output, and they may well have piped it into a pager. | ||
| 259 | .help => { | ||
| 260 | _ = std.posix.write(std.posix.STDOUT_FILENO, usage) catch {}; | ||
| 261 | }, | ||
| 262 | .no_command => std.debug.print("{s}", .{usage}), | ||
| 263 | .unknown_command => std.debug.print("{s}", .{usage}), | ||
| 264 | .unknown_arg => |a| std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }), | ||
| 265 | .missing_value => |f| std.debug.print("muxd: {s} needs a value\n{s}", .{ f, usage }), | ||
| 266 | .bad_number => |f| std.debug.print("muxd: {s} needs a positive number\n{s}", .{ f, usage }), | ||
| 267 | .key_without_quic => std.debug.print( | ||
| 268 | "muxd: --key without --quic has nothing to listen on; name both or neither\n", | ||
| 269 | .{}, | ||
| 270 | ), | ||
| 271 | .bad_session_name => |n| std.debug.print("muxd: bad session name: {s}\n{s}", .{ n, usage }), | ||
| 272 | } | ||
| 273 | return usageCode(u); | ||
| 274 | } | ||
| 275 | |||
| 276 | /// `HOST[:PORT]` where HOST is a literal address — `127.0.0.1:4433`, | ||
| 277 | /// `0.0.0.0:4433`, `[::]:4433`. An omitted port means `quic.default_port`; | ||
| 278 | /// an empty one (`127.0.0.1:`) is still a mistake and still refused. | ||
| 279 | /// Deliberately no DNS: this is the address to bind, and a name resolving | ||
| 280 | /// to several is a question, not an answer. | ||
| 281 | fn splitHostPort(s: []const u8) !struct { host: []const u8, port: u16 } { | ||
| 282 | if (s.len > 0 and s[0] == '[') { | ||
| 283 | const close = std.mem.indexOfScalar(u8, s, ']') orelse return error.MalformedAddress; | ||
| 284 | if (close + 1 == s.len) return .{ .host = s[1..close], .port = quic.default_port }; | ||
| 285 | if (s[close + 1] != ':') return error.MalformedAddress; | ||
| 286 | return .{ .host = s[1..close], .port = try parsePort(s[close + 2 ..]) }; | ||
| 287 | } | ||
| 288 | const colon = std.mem.lastIndexOfScalar(u8, s, ':') orelse | ||
| 289 | return .{ .host = s, .port = quic.default_port }; | ||
| 290 | // An unbracketed IPv6 literal carries colons of its own, and splitting | ||
| 291 | // on the last one would quietly take its final group as a port: | ||
| 292 | // `fe80::1:4433` reads equally well as host `fe80::1` port 4433 and as | ||
| 293 | // host `fe80::1:4433` with the port left off. Brackets are how that | ||
| 294 | // ambiguity is spelled out, so without them it is refused rather than | ||
| 295 | // guessed at. | ||
| 296 | if (std.mem.indexOfScalar(u8, s[0..colon], ':') != null) return error.MalformedAddress; | ||
| 297 | return .{ .host = s[0..colon], .port = try parsePort(s[colon + 1 ..]) }; | ||
| 298 | } | ||
| 299 | |||
| 300 | fn parsePort(s: []const u8) !u16 { | ||
| 301 | return std.fmt.parseInt(u16, s, 10) catch error.MalformedAddress; | ||
| 302 | } | ||
| 303 | |||
| 304 | fn parseBindAddr(s: []const u8) !std.net.Address { | ||
| 305 | const hp = try splitHostPort(s); | ||
| 306 | return std.net.Address.parseIp(hp.host, hp.port); | ||
| 307 | } | ||
| 308 | |||
| 309 | pub fn main() !u8 { | ||
| 310 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 311 | defer if (gpa.deinit() == .leak) | ||
| 312 | std.debug.print("muxd: LEAK: allocations outlived deinit\n", .{}); | ||
| 313 | const alloc = gpa.allocator(); | ||
| 314 | |||
| 315 | const args = try std.process.argsAlloc(alloc); | ||
| 316 | defer std.process.argsFree(alloc, args); | ||
| 317 | |||
| 318 | const o = switch (parseArgs(args)) { | ||
| 319 | .err => |u| return usageExit(u), | ||
| 320 | .ok => |o| o, | ||
| 321 | }; | ||
| 322 | |||
| 323 | // The same row that exempts `--version` and `keygen` from the sun_path | ||
| 324 | // bound below exempts them from HAVING a path: the default can now | ||
| 325 | // refuse (sockpath.defaultSockPath), and a version string must never | ||
| 326 | // fail on the environment. So a verb that touches no socket gets no | ||
| 327 | // path rather than a path it must first survive resolving. | ||
| 328 | // A resuming `run` is exempt from the socket path entirely: the exec | ||
| 329 | // that started it passed only `--resume-fd`, and the path it must serve | ||
| 330 | // is in the manifest, under a listener that is already bound to it. | ||
| 331 | // Resolving the default here would refuse an upgrade on any daemon | ||
| 332 | // started with `--sock` outside XDG_RUNTIME_DIR. | ||
| 333 | const uses_socket = specForCmd(o._cmd).uses_socket and o.resume_fd == null; | ||
| 334 | const sock_path = if (o.sock) |s| | ||
| 335 | try alloc.dupe(u8, s) | ||
| 336 | else if (!uses_socket) | ||
| 337 | try alloc.dupe(u8, "") | ||
| 338 | else | ||
| 339 | sockpath.defaultSockPath(alloc) catch |err| switch (err) { | ||
| 340 | // Named in this binary's own words, like the sun_path bound | ||
| 341 | // below: sockpath owns the rule, each binary owns the wording. | ||
| 342 | error.NoRuntimeDir => { | ||
| 343 | std.debug.print( | ||
| 344 | "muxd: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n", | ||
| 345 | .{}, | ||
| 346 | ); | ||
| 347 | return 1; | ||
| 348 | }, | ||
| 349 | else => |e| return e, | ||
| 350 | }; | ||
| 351 | defer alloc.free(sock_path); | ||
| 352 | |||
| 353 | // The sun_path bound (sockpath.max_sun_path). Checked here, once, | ||
| 354 | // before any command acts: the alternative is a spawned daemon that can | ||
| 355 | // never answer and a 2s timeout story about a path doomed at parse. | ||
| 356 | // `--version` and `keygen` are dispatched from the switch below, i.e. | ||
| 357 | // after this point, so they are exempted by their rows rather than by | ||
| 358 | // order — neither touches the socket, and neither should be refused | ||
| 359 | // over it. Which verbs those are is their rows' business, not this | ||
| 360 | // line's. | ||
| 361 | if (uses_socket and sock_path.len > sockpath.max_sun_path) { | ||
| 362 | std.debug.print( | ||
| 363 | "muxd: socket path too long ({d} bytes, max {d}): {s}\n", | ||
| 364 | .{ sock_path.len, sockpath.max_sun_path, sock_path }, | ||
| 365 | ); | ||
| 366 | return 1; | ||
| 367 | } | ||
| 368 | |||
| 369 | switch (o._cmd) { | ||
| 370 | // The socket path resolved above is unused here and unchecked (see | ||
| 371 | // the length guard above): asking a binary its version must work | ||
| 372 | // with no daemon and no runtime dir. | ||
| 373 | .version => { | ||
| 374 | var vbuf: [64]u8 = undefined; | ||
| 375 | const s = std.fmt.bufPrint(&vbuf, "muxd {s}\n", .{build_options.version}) catch unreachable; | ||
| 376 | _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {}; | ||
| 377 | return 0; | ||
| 378 | }, | ||
| 379 | .help => return usageExit(.help), | ||
| 380 | .keygen => return keygen(alloc), | ||
| 381 | .start => return startCmd(alloc, sock_path, args[2..]), | ||
| 382 | .run => return if (o.resume_fd) |fd| resumeRun(alloc, o, fd) else run(alloc, o, sock_path), | ||
| 383 | .dump => return dump(alloc, sock_path, o.vt, o.session orelse ""), | ||
| 384 | .stats => return stats(alloc, sock_path), | ||
| 385 | .stop => return stopCmd(alloc, sock_path), | ||
| 386 | .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version), | ||
| 387 | .proxy => { | ||
| 388 | // Attach auto-start: the user asked for a session, not a | ||
| 389 | // daemon. Same helper and deadline as `muxd start`. Unlike it, a | ||
| 390 | // daemon that was already there is silent: the user asked for a | ||
| 391 | // session and is about to get one. | ||
| 392 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 393 | const exe = std.fs.selfExePath(&exe_buf) catch { | ||
| 394 | std.debug.print("muxd proxy: cannot find own binary via /proc/self/exe\n", .{}); | ||
| 395 | return 1; | ||
| 396 | }; | ||
| 397 | if (!try spawn.ensureForAttach(alloc, exe, sock_path, "muxd proxy")) return 1; | ||
| 398 | return proxy.run(sock_path); | ||
| 399 | }, | ||
| 400 | .endpoint => return endpointCmd(alloc, sock_path), | ||
| 401 | } | ||
| 402 | } | ||
| 403 | |||
| 404 | /// Opt-IN, `=1` alone: the shim costs a zsh user `~/.zshenv` and a | ||
| 405 | /// bash user's DEBUG trap. | ||
| 406 | fn shellIntegrationEnabled(env: ?[]const u8) bool { | ||
| 407 | return std.mem.eql(u8, env orelse "", "1"); | ||
| 408 | } | ||
| 409 | |||
| 410 | /// Bigger than any manifest a full session table can produce (one replayed | ||
| 411 | /// viewport each) and small enough that a descriptor that is not a manifest | ||
| 412 | /// cannot make this process eat the machine. | ||
| 413 | const manifest_read_max = 64 * 1024 * 1024; | ||
| 414 | |||
| 415 | /// Set on the environment the rollback exec builds, and read at the top of | ||
| 416 | /// the next adoption. Two binaries that both refuse one manifest would | ||
| 417 | /// otherwise trade the sessions forever, exec'ing each other at full speed. | ||
| 418 | /// A VARIABLE rather than a flag because the binary being exec'd back is by | ||
| 419 | /// definition older than this one: an unknown variable is ignored by any | ||
| 420 | /// muxd, an unknown flag is fatal usage. | ||
| 421 | const rollback_marker = "MUX_UPGRADE_ROLLBACK"; | ||
| 422 | |||
| 423 | /// The environment's spelling of `--resume-fail-at`. The flag alone cannot | ||
| 424 | /// be driven by a real upgrade: the exec builds a fixed argv | ||
| 425 | /// (`run --resume-fd N`), so an e2e leg has no way to put a word in it. The | ||
| 426 | /// daemon's ENVIRONMENT crosses the exec untouched, which is how the leg | ||
| 427 | /// arms the abort — on the daemon it starts, before any upgrade. | ||
| 428 | const fail_at_env = "MUX_RESUME_FAIL_AT"; | ||
| 429 | |||
| 430 | /// The flag beats the environment, as `--key` beats `MUX_KEY_FILE`: more | ||
| 431 | /// specific intent sits higher. | ||
| 432 | fn failAtFrom(flag: ?[]const u8, env: ?[]const u8) []const u8 { | ||
| 433 | return flag orelse env orelse ""; | ||
| 434 | } | ||
| 435 | |||
| 436 | /// What the rollback exec keeps from this process's environment. | ||
| 437 | fn rollbackKeepsEnv(entry: []const u8) bool { | ||
| 438 | // The abort that caused the rollback must not be inherited by the | ||
| 439 | // binary being rolled back TO: it would abort at the same section, | ||
| 440 | // find the marker, give up, and take every shell with it. | ||
| 441 | return !std.mem.startsWith(u8, entry, fail_at_env ++ "="); | ||
| 442 | } | ||
| 443 | |||
| 444 | /// libc's, because std.c does not declare it and the marker must not | ||
| 445 | /// outlive the exec that set it — a daemon carrying it would refuse to roll | ||
| 446 | /// back the NEXT upgrade, and would hand it to every shell it spawns. | ||
| 447 | extern "c" fn unsetenv(name: [*:0]const u8) c_int; | ||
| 448 | |||
| 449 | /// Hand the sessions back to the binary that wrote the manifest. | ||
| 450 | fn rollback( | ||
| 451 | alloc: std.mem.Allocator, | ||
| 452 | writer_path: []const u8, | ||
| 453 | resume_fd: std.posix.fd_t, | ||
| 454 | section: []const u8, | ||
| 455 | ) u8 { | ||
| 456 | // Adoption failed before the pump started, so nothing has changed: | ||
| 457 | // every descriptor is still open and still inherited, and the manifest | ||
| 458 | // names the binary that opened them. | ||
| 459 | std.debug.print( | ||
| 460 | "muxd run: adoption failed at {s}; exec'ing {s} back\n", | ||
| 461 | .{ section, writer_path }, | ||
| 462 | ); | ||
| 463 | if (std.posix.getenv(rollback_marker) != null) { | ||
| 464 | std.debug.print( | ||
| 465 | "muxd run: this IS the rollback ({s} refused the manifest it wrote); giving up\n", | ||
| 466 | .{writer_path}, | ||
| 467 | ); | ||
| 468 | return 1; | ||
| 469 | } | ||
| 470 | |||
| 471 | // The manifest was read to EOF and the descriptor is the one being | ||
| 472 | // passed on: the next binary starts where the writer left it. | ||
| 473 | var file = std.fs.File{ .handle = resume_fd }; | ||
| 474 | file.seekTo(0) catch {}; | ||
| 475 | |||
| 476 | var fd_buf: [12]u8 = undefined; | ||
| 477 | const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{resume_fd}) catch return 1; | ||
| 478 | const argv = [_:null]?[*:0]const u8{ "muxd", "run", "--resume-fd", fd_str.ptr }; | ||
| 479 | const path_z = alloc.dupeZ(u8, writer_path) catch return 1; | ||
| 480 | const envp = rollbackEnvp(alloc) catch return 1; | ||
| 481 | |||
| 482 | // Only a failed exec ends the process, and then the shells get SIGHUP | ||
| 483 | // as the pty masters close — exactly today's `stop` + `run` outcome, | ||
| 484 | // not a worse one. | ||
| 485 | const exec_err = std.posix.execveZ(path_z.ptr, &argv, envp); | ||
| 486 | alloc.free(path_z); | ||
| 487 | alloc.free(std.mem.span(envp)); | ||
| 488 | std.debug.print( | ||
| 489 | "muxd run: rollback exec of {s} failed: {s}\n", | ||
| 490 | .{ writer_path, @errorName(exec_err) }, | ||
| 491 | ); | ||
| 492 | return 1; | ||
| 493 | } | ||
| 494 | |||
| 495 | /// This process's environment plus the marker. Leaked deliberately: the | ||
| 496 | /// only thing that reads it is the execve on the next line. | ||
| 497 | fn rollbackEnvp(alloc: std.mem.Allocator) ![*:null]const ?[*:0]const u8 { | ||
| 498 | var n: usize = 0; | ||
| 499 | while (std.c.environ[n] != null) n += 1; | ||
| 500 | const envp = try alloc.allocSentinel(?[*:0]const u8, n + 1, null); | ||
| 501 | var kept: usize = 0; | ||
| 502 | for (0..n) |i| { | ||
| 503 | const entry = std.c.environ[i].?; | ||
| 504 | if (!rollbackKeepsEnv(std.mem.span(entry))) continue; | ||
| 505 | envp[kept] = entry; | ||
| 506 | kept += 1; | ||
| 507 | } | ||
| 508 | envp[kept] = rollback_marker ++ "=1"; | ||
| 509 | kept += 1; | ||
| 510 | // The dropped entries leave a tail of undefined pointers between the | ||
| 511 | // last kept one and the sentinel; execve reads to the first null. | ||
| 512 | for (envp[kept..]) |*slot| slot.* = null; | ||
| 513 | return envp.ptr; | ||
| 514 | } | ||
| 515 | |||
| 516 | /// `muxd run --resume-fd N`: the argv an upgrading daemon exec'd this binary | ||
| 517 | /// with. Same pid, same children, same descriptors — the manifest names | ||
| 518 | /// which ones. It is read from the descriptor and never from a path: the | ||
| 519 | /// memfd is anonymous memory, and the QUIC key inside it must not touch disk. | ||
| 520 | fn resumeRun(alloc: std.mem.Allocator, o: Opts, resume_fd: std.posix.fd_t) !u8 { | ||
| 521 | // The writer left the offset at the end of what it wrote, and a child | ||
| 522 | // shares the file description with it, so the rewind is ours to do. | ||
| 523 | var file = std.fs.File{ .handle = resume_fd }; | ||
| 524 | file.seekTo(0) catch |err| { | ||
| 525 | std.debug.print("muxd run: --resume-fd {d} does not seek ({t})\n", .{ resume_fd, err }); | ||
| 526 | return 1; | ||
| 527 | }; | ||
| 528 | const bytes = file.readToEndAlloc(alloc, manifest_read_max) catch |err| { | ||
| 529 | std.debug.print("muxd run: cannot read the manifest on fd {d} ({t})\n", .{ resume_fd, err }); | ||
| 530 | return 1; | ||
| 531 | }; | ||
| 532 | defer alloc.free(bytes); | ||
| 533 | |||
| 534 | var parsed = upgrade.parseManifest(alloc, bytes) catch |err| { | ||
| 535 | std.debug.print("muxd run: manifest on fd {d} is not one ({t})\n", .{ resume_fd, err }); | ||
| 536 | return 1; | ||
| 537 | }; | ||
| 538 | defer parsed.deinit(); | ||
| 539 | |||
| 540 | // `--check` is the old daemon's dry run and this is the whole of it: the | ||
| 541 | // candidate proves it can read the manifest to the end, exits 0, and | ||
| 542 | // adopts nothing — the daemon that wrote it is still serving. Before | ||
| 543 | // the abort flag, which is about adoption and so has nothing to abort | ||
| 544 | // here: a probe that rolled back would exec the old binary out of a | ||
| 545 | // CHILD of the daemon that is still running. | ||
| 546 | if (o.check) return 0; | ||
| 547 | |||
| 548 | const fail_at = failAtFrom(o.resume_fail_at, std.posix.getenv(fail_at_env)); | ||
| 549 | if (std.mem.eql(u8, fail_at, "daemon")) | ||
| 550 | return rollback(alloc, parsed.daemon.writer_path, resume_fd, "daemon (--resume-fail-at)"); | ||
| 551 | |||
| 552 | var srv = Server.initFromManifest(alloc, &parsed, build_options.version) catch |err| { | ||
| 553 | var reason: [64]u8 = undefined; | ||
| 554 | return rollback(alloc, parsed.daemon.writer_path, resume_fd, std.fmt.bufPrint(&reason, "sessions ({t})", .{err}) catch "sessions"); | ||
| 555 | }; | ||
| 556 | // Before the teardown defer, never after: a rollback must leave every | ||
| 557 | // shell, socket and directory exactly as it found them, and deinit is | ||
| 558 | // the demolition list. | ||
| 559 | if (std.mem.eql(u8, fail_at, "session")) | ||
| 560 | return rollback(alloc, parsed.daemon.writer_path, resume_fd, "session (--resume-fail-at)"); | ||
| 561 | defer srv.deinit(); | ||
| 562 | |||
| 563 | // The marker's job ended the moment this image started serving. | ||
| 564 | _ = unsetenv(rollback_marker); | ||
| 565 | |||
| 566 | @import("server").installSignalHandlers(); | ||
| 567 | return try srv.run(); | ||
| 568 | } | ||
| 569 | |||
| 570 | fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 { | ||
| 571 | // Address and key are settled before anything binds: a mistyped address | ||
| 572 | // or an unreadable key must not first leave a session socket and a live | ||
| 573 | // shell behind. Same discipline as the key loader's own refusals. | ||
| 574 | var quic_bind: ?std.net.Address = null; | ||
| 575 | var quic_key: quic.Key = undefined; | ||
| 576 | if (o.quic) |hostport| { | ||
| 577 | quic_bind = parseBindAddr(hostport) catch { | ||
| 578 | std.debug.print( | ||
| 579 | "muxd: --quic wants HOST:PORT with a literal address, got {s}\n", | ||
| 580 | .{hostport}, | ||
| 581 | ); | ||
| 582 | return 1; | ||
| 583 | }; | ||
| 584 | // --key, then MUX_KEY_FILE, then the default path if it exists. | ||
| 585 | // The default is only reached for by a daemon that was asked for | ||
| 586 | // QUIC, so an absent one is a message rather than a silent skip — | ||
| 587 | // and the message can always name the path because it is resolved | ||
| 588 | // whether or not it is the one chosen. | ||
| 589 | const dflt = try xdg.keyPath(alloc); | ||
| 590 | defer alloc.free(dflt); | ||
| 591 | const dflt_if_present: ?[]const u8 = | ||
| 592 | if (std.fs.cwd().access(dflt, .{})) |_| dflt else |_| null; | ||
| 593 | const key_path = pickKey(o.key, envKey(), dflt_if_present) orelse { | ||
| 594 | std.debug.print( | ||
| 595 | "muxd: no key: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n", | ||
| 596 | .{dflt}, | ||
| 597 | ); | ||
| 598 | return 2; | ||
| 599 | }; | ||
| 600 | quic_key = quic.Key.load(key_path) catch |err| switch (err) { | ||
| 601 | // The three the user can act on, in quic.zig's words — the one | ||
| 602 | // owner of them, because `muxd endpoint`, the daemon's | ||
| 603 | // `endpoint_req` and the client print the same sentences. | ||
| 604 | // | ||
| 605 | // Anything else still propagates rather than being flattened | ||
| 606 | // into a refusal line: `run` is a foreground start that may | ||
| 607 | // fail, so the error goes up. The announce paths cannot do | ||
| 608 | // that — they must stay on ssh — which is why their catch-all | ||
| 609 | // prints the body's fourth sentence and this one does not. | ||
| 610 | error.KeyFileMissing, | ||
| 611 | error.KeyFilePermissive, | ||
| 612 | error.KeyFileMalformed, | ||
| 613 | => { | ||
| 614 | var buf: [quic.key_refusal_len]u8 = undefined; | ||
| 615 | std.debug.print("muxd: {s}\n", .{quic.keyRefusalBody(&buf, err, key_path)}); | ||
| 616 | return 1; | ||
| 617 | }, | ||
| 618 | else => return err, | ||
| 619 | }; | ||
| 620 | } | ||
| 621 | |||
| 622 | // The UDP socket is bound BEFORE the session socket, so a port that is | ||
| 623 | // already taken costs nothing: no shell has been started and no socket | ||
| 624 | // left on disk. It is the same discipline as loading the key first, one | ||
| 625 | // syscall further along. | ||
| 626 | var listener: ?*quic_server.Listener = null; | ||
| 627 | if (quic_bind) |addr| { | ||
| 628 | listener = quic_server.Listener.bind(alloc, addr, quic_key, o.quic_idle_ms) catch |err| switch (err) { | ||
| 629 | // The QUIC edition of "a daemon is already running", refused for | ||
| 630 | // the same reason: the listener sets no SO_REUSEADDR, so rather | ||
| 631 | // than silently splitting a port's datagrams with the daemon | ||
| 632 | // already there, the second one says so and stops. | ||
| 633 | error.AddressInUse => { | ||
| 634 | std.debug.print( | ||
| 635 | "muxd: a daemon is already listening on udp {s}\n", | ||
| 636 | .{o.quic.?}, | ||
| 637 | ); | ||
| 638 | return 1; | ||
| 639 | }, | ||
| 640 | else => { | ||
| 641 | std.debug.print( | ||
| 642 | "muxd: cannot listen on udp {s}: {s}\n", | ||
| 643 | .{ o.quic.?, @errorName(err) }, | ||
| 644 | ); | ||
| 645 | return 1; | ||
| 646 | }, | ||
| 647 | }; | ||
| 648 | } | ||
| 649 | // Registered before the server's, so it runs after it: a client slot | ||
| 650 | // backed by QUIC closes through the listener, and the server must finish | ||
| 651 | // tearing its slots down before the listener is freed. | ||
| 652 | defer if (listener) |l| l.deinit(); | ||
| 653 | |||
| 654 | const shell_z: [:0]const u8 = if (o.shell) |s| | ||
| 655 | try alloc.dupeZ(u8, s) | ||
| 656 | else | ||
| 657 | try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); | ||
| 658 | defer alloc.free(shell_z); | ||
| 659 | |||
| 660 | // Read from the DAEMON's environment, necessarily: muxd forks the | ||
| 661 | // session shell, so by the time anyone could pass a flag through a | ||
| 662 | // client the shell has been running for a while. | ||
| 663 | const shell_integration = shellIntegrationEnabled( | ||
| 664 | std.posix.getenv("MUX_SHELL_INTEGRATION"), | ||
| 665 | ); | ||
| 666 | |||
| 667 | var srv = Server.init(alloc, .{ | ||
| 668 | .sock_path = sock_path, | ||
| 669 | .shell = shell_z, | ||
| 670 | .cols = o.cols, | ||
| 671 | .rows = o.rows, | ||
| 672 | .shell_integration = shell_integration, | ||
| 673 | .version = build_options.version, | ||
| 674 | }) catch |err| switch (err) { | ||
| 675 | // All of these mean "that path is not ours to take", and all | ||
| 676 | // are ordinary operator mistakes rather than daemon bugs: say | ||
| 677 | // so in one line and exit, no stack trace. | ||
| 678 | // | ||
| 679 | // AddressInUse is the same situation found one syscall later: | ||
| 680 | // two daemons starting at once can both see an empty path and | ||
| 681 | // both try to bind it. The loser has simply lost a dead heat, | ||
| 682 | // and telling it "a daemon is already running" is exactly | ||
| 683 | // right — by the time it reads the message, one is. | ||
| 684 | error.DaemonAlreadyRunning, error.AddressInUse => { | ||
| 685 | std.debug.print("muxd: a daemon is already running on {s}\n", .{sock_path}); | ||
| 686 | return 1; | ||
| 687 | }, | ||
| 688 | error.SockPathNotASocket => { | ||
| 689 | std.debug.print( | ||
| 690 | "muxd: {s} exists and is not a socket (move it, or name another with --sock)\n", | ||
| 691 | .{sock_path}, | ||
| 692 | ); | ||
| 693 | return 1; | ||
| 694 | }, | ||
| 695 | else => return err, | ||
| 696 | }; | ||
| 697 | |||
| 698 | defer srv.deinit(); | ||
| 699 | |||
| 700 | if (listener) |l| { | ||
| 701 | l.setHandler(srv.quicHandler()); | ||
| 702 | srv.attachQuic(l); | ||
| 703 | } | ||
| 704 | |||
| 705 | @import("server").installSignalHandlers(); | ||
| 706 | return try srv.run(); | ||
| 707 | } | ||
| 708 | |||
| 709 | /// `askEndpointPort` is not folded in here: it waits under a deadline | ||
| 710 | /// for a daemon too old for `endpoint_req`. This one blocks, so a | ||
| 711 | /// wedge shows. | ||
| 712 | fn oneShotQuery( | ||
| 713 | alloc: std.mem.Allocator, | ||
| 714 | sock_path: []const u8, | ||
| 715 | verb: []const u8, | ||
| 716 | req: proto.MsgType, | ||
| 717 | req_payload: []const u8, | ||
| 718 | want: proto.MsgType, | ||
| 719 | ) !u8 { | ||
| 720 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 721 | std.debug.print( | ||
| 722 | "muxd {s}: nothing listening on {s} (`muxd start` starts a daemon)\n", | ||
| 723 | .{ verb, sock_path }, | ||
| 724 | ); | ||
| 725 | return 1; | ||
| 726 | }; | ||
| 727 | defer stream.close(); | ||
| 728 | |||
| 729 | try proto.writeFrame(stream.handle, req, req_payload); | ||
| 730 | while (try proto.readFrame(alloc, stream.handle)) |frame| { | ||
| 731 | defer frame.deinit(alloc); | ||
| 732 | if (frame.type != want) continue; | ||
| 733 | try proto.writeAllFd(std.posix.STDOUT_FILENO, frame.payload); | ||
| 734 | try proto.writeAllFd(std.posix.STDOUT_FILENO, "\n"); | ||
| 735 | return 0; | ||
| 736 | } | ||
| 737 | return 1; | ||
| 738 | } | ||
| 739 | |||
| 740 | fn dump(alloc: std.mem.Allocator, sock_path: []const u8, vt_mode: bool, session: []const u8) !u8 { | ||
| 741 | // vt byte ++ session-name tail, built by the wire module — empty is the | ||
| 742 | // wire's own default spelling, so a bare `muxd dump` sends exactly the | ||
| 743 | // one-byte payload that predates session names. | ||
| 744 | var buf: [proto.debug_dump_max_len]u8 = undefined; | ||
| 745 | const payload = proto.encodeDebugDumpNamed(&buf, vt_mode, session); | ||
| 746 | return oneShotQuery(alloc, sock_path, "dump", .debug_dump, payload, .dump_reply); | ||
| 747 | } | ||
| 748 | |||
| 749 | fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | ||
| 750 | return oneShotQuery(alloc, sock_path, "stats", .stats_req, "", .stats_reply); | ||
| 751 | } | ||
| 752 | |||
| 753 | /// Ask the daemon on `sock_path` to exit, then wait until the PROCESS is | ||
| 754 | /// gone, not just the path. Exit 0 covers both "stopped" and "nothing | ||
| 755 | /// there" — the state the user asked for is the state they got, which is | ||
| 756 | /// what makes the verb safe to script (`muxd start`'s re-runnability, | ||
| 757 | /// mirrored). | ||
| 758 | /// | ||
| 759 | /// Prefixes split the way `start`'s do: `muxd stop:` for a refusal or a | ||
| 760 | /// report about this command, plain `muxd:` for a lifecycle verdict. | ||
| 761 | fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | ||
| 762 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 763 | std.debug.print("muxd stop: nothing listening on {s}\n", .{sock_path}); | ||
| 764 | return 0; | ||
| 765 | }; | ||
| 766 | // A daemon that dies between connect and write reached the asked-for | ||
| 767 | // state on its own; the poll below confirms it either way. But whether | ||
| 768 | // the frame LANDED is kept: a daemon can accept a connection and close | ||
| 769 | // it before the request arrives, and a log holds nothing about a | ||
| 770 | // request it never saw — pointing there would send the reader to an | ||
| 771 | // empty page. | ||
| 772 | const asked = if (proto.writeFrame(stream.handle, .stop_req, "")) |_| true else |_| false; | ||
| 773 | const peer = peerPid(stream.handle); | ||
| 774 | stream.close(); | ||
| 775 | |||
| 776 | // Probe-first, deadline-second — ensureDaemon's poll shape (spawn.zig), | ||
| 777 | // so the final window before the deadline is still probed and the | ||
| 778 | // failure line is never printed about an interval nobody checked. | ||
| 779 | // spawn.probe is the connect-refusal test: a live listener's backlog | ||
| 780 | // accepts even when its event loop is wedged (up to backlog depth — | ||
| 781 | // past ~128 pending connects AF_UNIX blocks rather than refuses, so | ||
| 782 | // this loop would wait, not lie), and only the shutdown unlink can | ||
| 783 | // produce a refusal, which makes it the true signal. | ||
| 784 | const stop_deadline_ms: i64 = 2000; | ||
| 785 | const t0 = std.time.milliTimestamp(); | ||
| 786 | while (true) { | ||
| 787 | if (!spawn.probe(sock_path)) return waitPidGone(peer, sock_path); | ||
| 788 | if (std.time.milliTimestamp() - t0 >= stop_deadline_ms) break; | ||
| 789 | std.Thread.sleep(50 * std.time.ns_per_ms); | ||
| 790 | } | ||
| 791 | const secs = @divTrunc(stop_deadline_ms, 1000); | ||
| 792 | if (!asked) { | ||
| 793 | std.debug.print( | ||
| 794 | "muxd stop: could not deliver the stop request to {s}, and it is still answering after {d}s\n", | ||
| 795 | .{ sock_path, secs }, | ||
| 796 | ); | ||
| 797 | return 1; | ||
| 798 | } | ||
| 799 | var hint: [log_hint_len]u8 = undefined; | ||
| 800 | std.debug.print( | ||
| 801 | "muxd stop: {s} still answering after {d}s{s}\n", | ||
| 802 | .{ sock_path, secs, logHint(alloc, &hint) }, | ||
| 803 | ); | ||
| 804 | return 1; | ||
| 805 | } | ||
| 806 | |||
| 807 | /// The daemon's pid from the kernel, not from the daemon: `stop`'s promise | ||
| 808 | /// is about a process, and only the OS can vouch for one. Null when the | ||
| 809 | /// kernel cannot name the peer (another pid namespace reports 0), and then | ||
| 810 | /// the socket's silence is all there is to wait on. | ||
| 811 | fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t { | ||
| 812 | const Ucred = extern struct { pid: std.posix.pid_t, uid: std.posix.uid_t, gid: std.posix.gid_t }; | ||
| 813 | var cred: Ucred = undefined; | ||
| 814 | std.posix.getsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, std.mem.asBytes(&cred)) catch return null; | ||
| 815 | return if (cred.pid > 0) cred.pid else null; | ||
| 816 | } | ||
| 817 | |||
| 818 | /// A socket gone quiet is the unlink, and the unlink is the FIRST thing a | ||
| 819 | /// stopping daemon does; reaping its shells (one TERM grace when one | ||
| 820 | /// ignores it) and deleting its dirs come after. "stopped" said at the | ||
| 821 | /// unlink handed a scripted `muxd start`, or a supervisor's "is it down", | ||
| 822 | /// a daemon still running. The bound is the reap's own grace with room to | ||
| 823 | /// spare — a daemon still here after it is wedged in teardown, and that | ||
| 824 | /// is a report, not a wait. | ||
| 825 | fn waitPidGone(peer: ?std.posix.pid_t, sock_path: []const u8) u8 { | ||
| 826 | const pid = peer orelse { | ||
| 827 | std.debug.print("muxd: stopped\n", .{}); | ||
| 828 | return 0; | ||
| 829 | }; | ||
| 830 | const gone_deadline_ms: i64 = 3000; | ||
| 831 | const t0 = std.time.milliTimestamp(); | ||
| 832 | // Only a live process answers signal 0 with success. ESRCH is the | ||
| 833 | // answer wanted; EPERM means the pid was reused by someone else's | ||
| 834 | // process, and the daemon is just as gone. | ||
| 835 | while (std.posix.kill(pid, 0)) |_| { | ||
| 836 | if (std.time.milliTimestamp() - t0 >= gone_deadline_ms) { | ||
| 837 | std.debug.print( | ||
| 838 | "muxd stop: {s} is closed, but pid {d} is still running {d}s later\n", | ||
| 839 | .{ sock_path, pid, @divTrunc(gone_deadline_ms, 1000) }, | ||
| 840 | ); | ||
| 841 | return 1; | ||
| 842 | } | ||
| 843 | std.Thread.sleep(20 * std.time.ns_per_ms); | ||
| 844 | } else |_| {} | ||
| 845 | std.debug.print("muxd: stopped\n", .{}); | ||
| 846 | return 0; | ||
| 847 | } | ||
| 848 | |||
| 849 | /// Ask the daemon on `sock_path` to become THIS binary. The new binary is | ||
| 850 | /// the one that asks: it knows its own version and its own path, and the | ||
| 851 | /// daemon is the one that decides. | ||
| 852 | /// | ||
| 853 | /// Prefixes split as `stop`'s do: `muxd upgrade:` for a refusal or a report | ||
| 854 | /// about this command, plain `muxd:` for the lifecycle verdict. | ||
| 855 | fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 { | ||
| 856 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 857 | const exe = std.fs.selfExePath(&exe_buf) catch { | ||
| 858 | std.debug.print("muxd upgrade: cannot find own binary via /proc/self/exe\n", .{}); | ||
| 859 | return 1; | ||
| 860 | }; | ||
| 861 | |||
| 862 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 863 | std.debug.print("muxd upgrade: nothing listening on {s}\n", .{sock_path}); | ||
| 864 | return 1; | ||
| 865 | }; | ||
| 866 | defer stream.close(); | ||
| 867 | |||
| 868 | var buf: [std.fs.max_path_bytes + 64]u8 = undefined; | ||
| 869 | const payload = proto.encodeUpgradeReq(&buf, .{ | ||
| 870 | .allow_same_version = allow_same, | ||
| 871 | .version = build_options.version, | ||
| 872 | .path = exe, | ||
| 873 | }) catch { | ||
| 874 | std.debug.print("muxd upgrade: cannot name {s} in a request\n", .{exe}); | ||
| 875 | return 1; | ||
| 876 | }; | ||
| 877 | proto.writeFrame(stream.handle, .upgrade_req, payload) catch { | ||
| 878 | std.debug.print("muxd upgrade: {s} closed before the request landed\n", .{sock_path}); | ||
| 879 | return 1; | ||
| 880 | }; | ||
| 881 | |||
| 882 | // Bounded, because a daemon older than this feature drops an unknown | ||
| 883 | // frame without a word: the expiry is a diagnosis, not a timeout. | ||
| 884 | const deadline_ms: i64 = 5000; | ||
| 885 | const t0 = std.time.milliTimestamp(); | ||
| 886 | while (true) { | ||
| 887 | const left = deadline_ms - (std.time.milliTimestamp() - t0); | ||
| 888 | if (left <= 0) break; | ||
| 889 | var pfd = [_]std.posix.pollfd{ | ||
| 890 | .{ .fd = stream.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 891 | }; | ||
| 892 | if ((std.posix.poll(&pfd, @intCast(left)) catch break) == 0) break; | ||
| 893 | // EOF: an older daemon that drops the connection over a frame it | ||
| 894 | // cannot read reaches the same conclusion as silence does. | ||
| 895 | const frame = (proto.readFrame(alloc, stream.handle) catch break) orelse break; | ||
| 896 | defer frame.deinit(alloc); | ||
| 897 | if (frame.type != .upgrade_reply or frame.payload.len == 0) continue; | ||
| 898 | if (frame.payload[0] != 0) { | ||
| 899 | // The daemon's words, verbatim: it is the side that knows which | ||
| 900 | // check failed, and paraphrasing here would lose the versions. | ||
| 901 | std.debug.print("muxd upgrade: refused: {s}\n", .{frame.payload[1..]}); | ||
| 902 | return 1; | ||
| 903 | } | ||
| 904 | std.debug.print("muxd: upgraded to {s}\n", .{build_options.version}); | ||
| 905 | return confirmServing(alloc, sock_path); | ||
| 906 | } | ||
| 907 | std.debug.print( | ||
| 908 | "muxd upgrade: no reply: this daemon predates upgrade — stop and run\n", | ||
| 909 | .{}, | ||
| 910 | ); | ||
| 911 | return 1; | ||
| 912 | } | ||
| 913 | |||
| 914 | /// The socket answered, and the answer came from the new image. | ||
| 915 | fn confirmServing(alloc: std.mem.Allocator, sock_path: []const u8) u8 { | ||
| 916 | // Not `spawn.probe`: the listener fd crosses the exec, so a connect | ||
| 917 | // succeeds throughout the handover — it is served out of the backlog by | ||
| 918 | // whichever image accepts it. Only an ANSWERED frame says the new one | ||
| 919 | // is pumping. | ||
| 920 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 921 | std.debug.print("muxd upgrade: {s} stopped answering after the exec\n", .{sock_path}); | ||
| 922 | return 1; | ||
| 923 | }; | ||
| 924 | defer stream.close(); | ||
| 925 | proto.writeFrame(stream.handle, .stats_req, "") catch return 1; | ||
| 926 | |||
| 927 | const deadline_ms: i64 = 5000; | ||
| 928 | var pfd = [_]std.posix.pollfd{ | ||
| 929 | .{ .fd = stream.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 930 | }; | ||
| 931 | if ((std.posix.poll(&pfd, deadline_ms) catch 0) > 0) { | ||
| 932 | if (proto.readFrame(alloc, stream.handle) catch null) |frame| { | ||
| 933 | defer frame.deinit(alloc); | ||
| 934 | if (frame.type == .stats_reply) return 0; | ||
| 935 | } | ||
| 936 | } | ||
| 937 | const secs = @divTrunc(deadline_ms, 1000); | ||
| 938 | var hint: [log_hint_len]u8 = undefined; | ||
| 939 | std.debug.print( | ||
| 940 | "muxd upgrade: exec'd, but {s} has not answered in {d}s{s}\n", | ||
| 941 | .{ sock_path, secs, logHint(alloc, &hint) }, | ||
| 942 | ); | ||
| 943 | return 1; | ||
| 944 | } | ||
| 945 | |||
| 946 | const log_hint_len = std.fs.max_path_bytes + 64; | ||
| 947 | |||
| 948 | /// The "where the rest of the story is" clause, or "" when there is no | ||
| 949 | /// path to name. `stopCmd` and `reportNoListener` are read by someone | ||
| 950 | /// who is not at that box. | ||
| 951 | /// | ||
| 952 | /// Only when the path resolves: an absent HOME (a container, a systemd | ||
| 953 | /// unit) must not replace the finding that matters with an error trace. | ||
| 954 | /// And the hedge stays in the words: a foreground `muxd run` logs to | ||
| 955 | /// its own stderr, so naming the xdg path unconditionally would guess. | ||
| 956 | fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 { | ||
| 957 | const log = xdg.logPath(alloc) catch return ""; | ||
| 958 | defer alloc.free(log); | ||
| 959 | // A path too long for the buffer is dropped rather than clipped: half | ||
| 960 | // a path is worse than none, and `log_hint_len` clears PATH_MAX, so | ||
| 961 | // the only paths it drops are ones nothing could have opened anyway. | ||
| 962 | return std.fmt.bufPrint( | ||
| 963 | buf, | ||
| 964 | " (if it was started detached, its log is {s})", | ||
| 965 | .{log}, | ||
| 966 | ) catch ""; | ||
| 967 | } | ||
| 968 | |||
| 969 | /// `muxd proxy` with a one-line preamble: ensure a daemon, ensure a key, | ||
| 970 | /// ask the daemon for its QUIC port, print `endpoint <port> <hex-key>` (or | ||
| 971 | /// `endpoint none`) as the FIRST bytes on stdout, then become exactly the | ||
| 972 | /// proxy byte pump. This is what `mux HOST` runs over ssh. | ||
| 973 | /// | ||
| 974 | /// The announce is mandatory in both directions. The client blocks on one | ||
| 975 | /// newline-terminated line, and the daemon side of the frame protocol | ||
| 976 | /// sends nothing unprompted — so silence here is indistinguishable from a | ||
| 977 | /// slow ssh and would hang the attach rather than degrade it. | ||
| 978 | /// | ||
| 979 | /// The two kinds of failure therefore end differently. A SOFT one — no | ||
| 980 | /// usable key, no listener — announces `endpoint none` and pumps anyway: | ||
| 981 | /// the ssh session is real and carries the whole session. A HARD one — no | ||
| 982 | /// daemon to pump to, a stdout that will not take the announce — exits, | ||
| 983 | /// and the client reads EOF on the pipe, which `handoff.readLine` already | ||
| 984 | /// tells apart from a line (`UnterminatedLine`). Announcing none and THEN | ||
| 985 | /// exiting is the one dishonest option available: it would tell the client | ||
| 986 | /// it has a working ssh session at the moment that session goes away. | ||
| 987 | /// | ||
| 988 | /// stdout carries the announce and then frames, nothing else: every | ||
| 989 | /// human-facing word here goes to stderr, which ssh already carries to the | ||
| 990 | /// user's terminal. That includes `ensureForAttach`'s progress, whose | ||
| 991 | /// Progress is pinned to STDERR_FILENO in spawn.zig — a stray stdout byte | ||
| 992 | /// ahead of the announce would land in the middle of the client's parse. | ||
| 993 | /// | ||
| 994 | /// The pump that follows keeps speaking in its own name: a socket that | ||
| 995 | /// disappears between the ask and the attach is reported by proxy.zig as | ||
| 996 | /// `muxd proxy: cannot connect to …`. That is deliberate — reusing | ||
| 997 | /// `proxy.run` is the whole design, and the line names the code that | ||
| 998 | /// failed rather than the verb that was typed. | ||
| 999 | fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | ||
| 1000 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 1001 | const exe = std.fs.selfExePath(&exe_buf) catch { | ||
| 1002 | std.debug.print("muxd endpoint: cannot find own binary via /proc/self/exe\n", .{}); | ||
| 1003 | return 1; | ||
| 1004 | }; | ||
| 1005 | if (!try spawn.ensureForAttach(alloc, exe, sock_path, "muxd endpoint")) return 1; | ||
| 1006 | |||
| 1007 | // The announce goes out on the same stdout the pump is about to use, | ||
| 1008 | // so it wants the same EPIPE-not-SIGPIPE treatment — and it wants it | ||
| 1009 | // from proxy.zig's installer rather than from a std default this file | ||
| 1010 | // would be leaning on. AFTER the auto-start above, never before: the | ||
| 1011 | // ignore is SIG_IGN, which survives exec, so installing it first would | ||
| 1012 | // hand the spawned daemon an inherited disposition it never chose. | ||
| 1013 | proxy.ignoreSigpipe(); | ||
| 1014 | |||
| 1015 | // Key first, then the ask, and the order is load-bearing: the daemon's | ||
| 1016 | // lazy bind takes the default key path only if the file already exists | ||
| 1017 | // and never creates one (server.zig endpointPortFrom). Creating it here | ||
| 1018 | // first is what lets a first-ever attach to a fresh box produce | ||
| 1019 | // coordinates instead of announcing none and being told to run keygen. | ||
| 1020 | const key = announceKey(alloc); | ||
| 1021 | const port: u16 = if (key == null) 0 else askEndpointPort(alloc, sock_path); | ||
| 1022 | |||
| 1023 | var line_buf: [handoff.announce_max_len]u8 = undefined; | ||
| 1024 | const line: []const u8 = blk: { | ||
| 1025 | const k = key orelse break :blk handoff.announce_none; | ||
| 1026 | // 0 is `endpoint_reply`'s "could not" — no key the daemon could | ||
| 1027 | // load, no bind, no reply — and turning it into the negative | ||
| 1028 | // announce is this caller's job. formatAnnounce refuses port 0 | ||
| 1029 | // rather than doing it quietly, so an omission here would fail on | ||
| 1030 | // this line instead of arriving on another machine as a parse error | ||
| 1031 | // about a message we wrote. | ||
| 1032 | if (port == 0) { | ||
| 1033 | reportNoListener(alloc, sock_path); | ||
| 1034 | break :blk handoff.announce_none; | ||
| 1035 | } | ||
| 1036 | // Unreachable in fact — port 0 is gone by here, and line_buf is | ||
| 1037 | // sized by the same constant that bounds the grammar — but written | ||
| 1038 | // as a fallback rather than `unreachable` because of what the two | ||
| 1039 | // do if that ever stops being true. A panic on the remote box takes | ||
| 1040 | // down a session ssh was about to carry perfectly well, to report a | ||
| 1041 | // bug about a preamble; announcing none costs the user QUIC and | ||
| 1042 | // nothing else, which is what every other unhappy path here does. | ||
| 1043 | break :blk handoff.formatAnnounce(&line_buf, .{ .port = port, .key = k.bytes }) catch | ||
| 1044 | handoff.announce_none; | ||
| 1045 | }; | ||
| 1046 | proto.writeAllFd(std.posix.STDOUT_FILENO, line) catch |err| { | ||
| 1047 | // stdout is the pipe the pump is about to need, so there is no | ||
| 1048 | // session left to fall back to — only a line about why. The error | ||
| 1049 | // is named rather than guessed at: EPIPE (the ssh client gave up | ||
| 1050 | // first) is the likely one, but a full disk under a redirect and a | ||
| 1051 | // closed fd reach here too, and they want different reactions. | ||
| 1052 | std.debug.print( | ||
| 1053 | "muxd endpoint: cannot write the announce to stdout: {s}\n", | ||
| 1054 | .{@errorName(err)}, | ||
| 1055 | ); | ||
| 1056 | return 1; | ||
| 1057 | }; | ||
| 1058 | |||
| 1059 | return proxy.run(sock_path); | ||
| 1060 | } | ||
| 1061 | |||
| 1062 | /// The daemon is up and answering but has no QUIC listener: no key it | ||
| 1063 | /// could load, a bind that failed, or a binary too old to know the | ||
| 1064 | /// verb. The reason went to the daemon's log, on a box the reader is | ||
| 1065 | /// not sitting at, so the line says where the rest is. | ||
| 1066 | fn reportNoListener(alloc: std.mem.Allocator, sock_path: []const u8) void { | ||
| 1067 | var hint: [log_hint_len]u8 = undefined; | ||
| 1068 | std.debug.print( | ||
| 1069 | "muxd endpoint: the daemon on {s} produced no QUIC listener; staying on ssh{s}\n", | ||
| 1070 | .{ sock_path, logHint(alloc, &hint) }, | ||
| 1071 | ); | ||
| 1072 | } | ||
| 1073 | |||
| 1074 | /// The key `muxd endpoint` announces, or null with exactly one stderr line | ||
| 1075 | /// saying why not. The reading of the environment and the deciding live in | ||
| 1076 | /// `announceKeyFrom` below; this half owns the words. | ||
| 1077 | fn announceKey(alloc: std.mem.Allocator) ?quic.Key { | ||
| 1078 | // Resolved whether or not it is the one chosen, so the no-HOME case can | ||
| 1079 | // be told apart from the have-a-path cases below. | ||
| 1080 | const dflt: ?[]const u8 = xdg.keyPath(alloc) catch null; | ||
| 1081 | defer if (dflt) |p| alloc.free(p); | ||
| 1082 | |||
| 1083 | switch (announceKeyFrom(envKey(), dflt)) { | ||
| 1084 | .key => |k| return k, | ||
| 1085 | .no_path => std.debug.print( | ||
| 1086 | "muxd endpoint: no HOME to resolve a key path; staying on ssh\n", | ||
| 1087 | .{}, | ||
| 1088 | ), | ||
| 1089 | .create_failed => |f| std.debug.print( | ||
| 1090 | "muxd endpoint: cannot create {s}: {s}; staying on ssh\n", | ||
| 1091 | .{ f.path, @errorName(f.err) }, | ||
| 1092 | ), | ||
| 1093 | .load_failed => |f| reportKeyRefusal(f.path, f.err), | ||
| 1094 | } | ||
| 1095 | return null; | ||
| 1096 | } | ||
| 1097 | |||
| 1098 | /// What the key resolution decided and why, separated from the printing of | ||
| 1099 | /// it. `pickKey` above exists for the same reason and the daemon's | ||
| 1100 | /// `endpointPortFrom` is this decision's other half: the two must agree on | ||
| 1101 | /// which file "the key" names, and an order that quietly inverted would | ||
| 1102 | /// otherwise show up only as a client authenticating to nothing. | ||
| 1103 | const KeyResult = union(enum) { | ||
| 1104 | key: quic.Key, | ||
| 1105 | /// No MUX_KEY_FILE and no HOME to build a default under: there is not | ||
| 1106 | /// even a path to try. | ||
| 1107 | no_path, | ||
| 1108 | /// The default key was absent and could not be created. Kept apart | ||
| 1109 | /// from `load_failed` because it is the cause and the load's | ||
| 1110 | /// `KeyFileMissing` would only be its symptom. | ||
| 1111 | create_failed: struct { path: []const u8, err: anyerror }, | ||
| 1112 | load_failed: struct { path: []const u8, err: anyerror }, | ||
| 1113 | }; | ||
| 1114 | |||
| 1115 | /// MUX_KEY_FILE, then the default path — the same order the daemon's lazy | ||
| 1116 | /// bind uses, because the announce hands a client the key it will | ||
| 1117 | /// authenticate WITH against a listener holding whatever the daemon | ||
| 1118 | /// loaded. Two spellings of "the key" would attach to nothing. | ||
| 1119 | /// | ||
| 1120 | /// Only the default is created when absent: that is the mosh-server move, | ||
| 1121 | /// and it is what lets a first attach to a fresh box produce coordinates | ||
| 1122 | /// at all. A MUX_KEY_FILE that is set but missing names a file the user | ||
| 1123 | /// manages, and writing one there would be a credential appearing where | ||
| 1124 | /// nobody asked for it — so that path is loaded, never created. | ||
| 1125 | /// | ||
| 1126 | /// Environment handed in and nothing printed, so the order and the | ||
| 1127 | /// create-vs-load distinction are testable: the `*From` discipline xdg.zig | ||
| 1128 | /// set and server.zig's `endpointPortFrom` follows. | ||
| 1129 | fn announceKeyFrom(env: ?[]const u8, dflt: ?[]const u8) KeyResult { | ||
| 1130 | if (env) |p| return if (quic.Key.load(p)) |k| | ||
| 1131 | .{ .key = k } | ||
| 1132 | else |err| | ||
| 1133 | .{ .load_failed = .{ .path = p, .err = err } }; | ||
| 1134 | |||
| 1135 | const path = dflt orelse return .no_path; | ||
| 1136 | // KeyExists is the ordinary case and no news: the key is already there | ||
| 1137 | // and the load below is what wanted it. Any OTHER create failure is | ||
| 1138 | // kept, because if the load then fails too it is the create that holds | ||
| 1139 | // the reason — an unwritable config directory, a full disk — while the | ||
| 1140 | // load can only report the file's absence, which is its symptom. | ||
| 1141 | const create_failed: ?anyerror = if (xdg.writeNewKey(path)) |_| | ||
| 1142 | null | ||
| 1143 | else |err| if (err == error.KeyExists) null else err; | ||
| 1144 | |||
| 1145 | return if (quic.Key.load(path)) |k| | ||
| 1146 | .{ .key = k } | ||
| 1147 | else |load_err| if (create_failed) |err| | ||
| 1148 | .{ .create_failed = .{ .path = path, .err = err } } | ||
| 1149 | else | ||
| 1150 | .{ .load_failed = .{ .path = path, .err = load_err } }; | ||
| 1151 | } | ||
| 1152 | |||
| 1153 | /// One line naming the reason: it rides ssh's stderr to someone who is | ||
| 1154 | /// not on that box. The words are `quic.keyRefusalBody`'s, so a refusal | ||
| 1155 | /// reads the same however the daemon was asked. | ||
| 1156 | fn reportKeyRefusal(path: []const u8, err: anyerror) void { | ||
| 1157 | var buf: [quic.key_refusal_len]u8 = undefined; | ||
| 1158 | std.debug.print( | ||
| 1159 | "muxd endpoint: {s}; staying on ssh\n", | ||
| 1160 | .{quic.keyRefusalBody(&buf, err, path)}, | ||
| 1161 | ); | ||
| 1162 | } | ||
| 1163 | |||
| 1164 | /// One observer round-trip: `endpoint_req`, then a bounded wait for the | ||
| 1165 | /// reply. 0 is every failure — nothing listening, no reply, a reply that | ||
| 1166 | /// is not one — because the caller does the same thing with all of them. | ||
| 1167 | /// | ||
| 1168 | /// The bound is what converts an old daemon's silence into the | ||
| 1169 | /// announce-none path instead of a hang: `MsgType` is non-exhaustive, so a | ||
| 1170 | /// binary from before the verb existed simply ignores it. It reuses | ||
| 1171 | /// `spawn.start_deadline_ms` rather than naming a number of its own — the | ||
| 1172 | /// same "how long can a local daemon reasonably take" the ensure above | ||
| 1173 | /// already waited on. | ||
| 1174 | /// | ||
| 1175 | /// The connect is outside the loop, so the ordinary failure — nothing on | ||
| 1176 | /// the socket — is a refusal in microseconds and never touches the budget. | ||
| 1177 | fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 { | ||
| 1178 | const stream = std.net.connectUnixSocket(sock_path) catch return 0; | ||
| 1179 | defer stream.close(); | ||
| 1180 | proto.writeFrame(stream.handle, .endpoint_req, "") catch return 0; | ||
| 1181 | |||
| 1182 | const deadline = std.time.milliTimestamp() + spawn.start_deadline_ms; | ||
| 1183 | while (std.time.milliTimestamp() < deadline) { | ||
| 1184 | var fds = [_]std.posix.pollfd{ | ||
| 1185 | .{ .fd = stream.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 1186 | }; | ||
| 1187 | const remaining: i32 = @intCast(@max(1, deadline - std.time.milliTimestamp())); | ||
| 1188 | _ = std.posix.poll(&fds, remaining) catch return 0; | ||
| 1189 | if (fds[0].revents == 0) continue; | ||
| 1190 | // Blocking, and bounded only by the poll that said bytes are here: | ||
| 1191 | // a daemon that wrote half a seven-byte frame and then stopped | ||
| 1192 | // would hold this past the deadline. That daemon is this same | ||
| 1193 | // binary on the same box, writing that frame in one call — the | ||
| 1194 | // hazard is worth naming and not worth a state machine. | ||
| 1195 | const f = (proto.readFrame(alloc, stream.handle) catch return 0) orelse return 0; | ||
| 1196 | defer f.deinit(alloc); | ||
| 1197 | if (f.type != .endpoint_reply) continue; | ||
| 1198 | // A reply of the wrong length is one we do not understand, which | ||
| 1199 | // lands in the same place as no reply at all. | ||
| 1200 | return proto.decodeEndpointReply(f.payload) catch 0; | ||
| 1201 | } | ||
| 1202 | return 0; | ||
| 1203 | } | ||
| 1204 | |||
| 1205 | /// `muxd start` = ensureDaemon under an explicit flag. Everything after | ||
| 1206 | /// `start` is forwarded to `run` verbatim — no re-serialization, so a flag | ||
| 1207 | /// that parses here behaves identically there. parseArgs has already | ||
| 1208 | /// validated the flags in THIS process; what it cannot validate (a bad | ||
| 1209 | /// bind address, a missing key file) surfaces in the daemon's log, which | ||
| 1210 | /// the failure path names. | ||
| 1211 | fn startCmd(alloc: std.mem.Allocator, sock_path: []const u8, forwarded: []const [:0]const u8) !u8 { | ||
| 1212 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 1213 | const exe = std.fs.selfExePath(&exe_buf) catch { | ||
| 1214 | std.debug.print("muxd: cannot find own binary via /proc/self/exe\n", .{}); | ||
| 1215 | return 1; | ||
| 1216 | }; | ||
| 1217 | const progress: spawn.Progress = .{ | ||
| 1218 | .fd = std.posix.STDERR_FILENO, | ||
| 1219 | .prefix = "muxd", | ||
| 1220 | .tty = std.posix.isatty(std.posix.STDERR_FILENO), | ||
| 1221 | }; | ||
| 1222 | // Default log path: the xdg one is the whole point for a real daemon. | ||
| 1223 | // Truncating, and this is the only caller that does: `start` is the one | ||
| 1224 | // verb whose user asked for a (re)start, so the log they go on to read | ||
| 1225 | // must be about the daemon they just started. | ||
| 1226 | const r = spawn.ensureDaemon(alloc, exe, forwarded, sock_path, progress, spawn.start_deadline_ms, .{ | ||
| 1227 | .truncate = true, | ||
| 1228 | }) catch |err| switch (err) { | ||
| 1229 | // The failure line, with the log path, was already printed by | ||
| 1230 | // Progress — a second line here would say the same thing worse. | ||
| 1231 | error.NeverAnswered => return 1, | ||
| 1232 | error.BinaryNotFound, error.SpawnFailed => { | ||
| 1233 | std.debug.print("muxd: could not spawn {s}: {s}\n", .{ exe, @errorName(err) }); | ||
| 1234 | return 1; | ||
| 1235 | }, | ||
| 1236 | }; | ||
| 1237 | if (r == .already_running) { | ||
| 1238 | std.debug.print( | ||
| 1239 | "muxd: already running on {s} (stop it first with `muxd stop --sock {s}` if you meant different flags)\n", | ||
| 1240 | .{ sock_path, sock_path }, | ||
| 1241 | ); | ||
| 1242 | } | ||
| 1243 | return 0; | ||
| 1244 | } | ||
| 1245 | |||
| 1246 | fn keygen(alloc: std.mem.Allocator) !u8 { | ||
| 1247 | const path = try xdg.keyPath(alloc); | ||
| 1248 | defer alloc.free(path); | ||
| 1249 | xdg.writeNewKey(path) catch |err| switch (err) { | ||
| 1250 | error.KeyExists => { | ||
| 1251 | std.debug.print( | ||
| 1252 | "muxd keygen: {s} already exists; rotation is `rm` + `keygen`, deliberately\n", | ||
| 1253 | .{path}, | ||
| 1254 | ); | ||
| 1255 | return 1; | ||
| 1256 | }, | ||
| 1257 | else => |e| return e, | ||
| 1258 | }; | ||
| 1259 | var buf: [std.fs.max_path_bytes + 1]u8 = undefined; | ||
| 1260 | const line = std.fmt.bufPrint(&buf, "{s}\n", .{path}) catch unreachable; | ||
| 1261 | _ = std.posix.write(std.posix.STDOUT_FILENO, line) catch {}; | ||
| 1262 | return 0; | ||
| 1263 | } | ||
| 1264 | |||
| 1265 | // --------------------------------------------------------------------------- | ||
| 1266 | // Tests. These run because `exe_mod` was added to build.zig's test loop in | ||
| 1267 | // the same commit; before that, a test written here would have compiled and | ||
| 1268 | // silently never executed (the hazard recorded in decisions.md, which cost | ||
| 1269 | // mux_main.zig five invisible tests). | ||
| 1270 | // --------------------------------------------------------------------------- | ||
| 1271 | |||
| 1272 | /// The tests must speak argsAlloc's type: a slice of | ||
| 1273 | /// sentinel-terminated strings. | ||
| 1274 | fn parse(comptime argv: []const [:0]const u8) ParseResult { | ||
| 1275 | return parseArgs(argv); | ||
| 1276 | } | ||
| 1277 | |||
| 1278 | test "parseArgs: subcommands and their existing flags" { | ||
| 1279 | const r = parse(&.{ "muxd", "run" }); | ||
| 1280 | try std.testing.expect(r == .ok); | ||
| 1281 | try std.testing.expect(r.ok._cmd == .run); | ||
| 1282 | try std.testing.expect(r.ok.sock == null); | ||
| 1283 | try std.testing.expectEqual(@as(u16, 80), r.ok.cols); | ||
| 1284 | try std.testing.expectEqual(@as(u16, 24), r.ok.rows); | ||
| 1285 | |||
| 1286 | const d = parse(&.{ "muxd", "dump", "--vt", "--sock", "/tmp/x.sock" }); | ||
| 1287 | try std.testing.expect(d.ok._cmd == .dump); | ||
| 1288 | try std.testing.expect(d.ok.vt); | ||
| 1289 | try std.testing.expectEqualStrings("/tmp/x.sock", d.ok.sock.?); | ||
| 1290 | // No --session named: nothing to validate, and `dump` spells the absence | ||
| 1291 | // on the wire as the empty tail. | ||
| 1292 | try std.testing.expect(d.ok.session == null); | ||
| 1293 | |||
| 1294 | const g = parse(&.{ "muxd", "run", "--cols", "120", "--rows", "40", "--shell", "/bin/dash" }); | ||
| 1295 | try std.testing.expectEqual(@as(u16, 120), g.ok.cols); | ||
| 1296 | try std.testing.expectEqual(@as(u16, 40), g.ok.rows); | ||
| 1297 | try std.testing.expectEqualStrings("/bin/dash", g.ok.shell.?); | ||
| 1298 | |||
| 1299 | try std.testing.expect(parse(&.{"muxd"}).err == .no_command); | ||
| 1300 | try std.testing.expect(parse(&.{ "muxd", "wat" }).err == .unknown_command); | ||
| 1301 | try std.testing.expect(parse(&.{ "muxd", "run", "--wat" }).err == .unknown_arg); | ||
| 1302 | } | ||
| 1303 | |||
| 1304 | test "parse: dump --session rides into the payload" { | ||
| 1305 | const d = parse(&.{ "muxd", "dump", "--session", "b", "--sock", "/tmp/x.sock" }); | ||
| 1306 | try std.testing.expect(d == .ok); | ||
| 1307 | try std.testing.expectEqualStrings("b", d.ok.session.?); | ||
| 1308 | |||
| 1309 | // A name no tool could ever address is refused at parse — usage on | ||
| 1310 | // stderr, never carried to the wire as a payload nothing can look up. | ||
| 1311 | const bad = parse(&.{ "muxd", "dump", "--session", "has space" }); | ||
| 1312 | try std.testing.expect(bad.err == .bad_session_name); | ||
| 1313 | try std.testing.expectEqualStrings("has space", bad.err.bad_session_name); | ||
| 1314 | } | ||
| 1315 | |||
| 1316 | test "parseArgs: --key without --quic is refused; --quic alone defers to main" { | ||
| 1317 | const both = parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433", "--key", "/k" }); | ||
| 1318 | try std.testing.expect(both == .ok); | ||
| 1319 | try std.testing.expectEqualStrings("0.0.0.0:4433", both.ok.quic.?); | ||
| 1320 | try std.testing.expectEqualStrings("/k", both.ok.key.?); | ||
| 1321 | |||
| 1322 | // --quic without --key is no longer a parse error: main resolves | ||
| 1323 | // MUX_KEY_FILE and the default path, and parse cannot see either. | ||
| 1324 | const deferred = parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433" }); | ||
| 1325 | try std.testing.expect(deferred == .ok); | ||
| 1326 | try std.testing.expect(deferred.ok.key == null); | ||
| 1327 | |||
| 1328 | // A key with nowhere to listen is still a mistake with no reading that | ||
| 1329 | // makes it sensible, and parse can see the whole of it. | ||
| 1330 | try std.testing.expect(parse(&.{ "muxd", "run", "--key", "/k" }).err == .key_without_quic); | ||
| 1331 | |||
| 1332 | // Neither is the ordinary case and must stay silent. | ||
| 1333 | const neither = parse(&.{ "muxd", "run" }); | ||
| 1334 | try std.testing.expect(neither.ok.quic == null); | ||
| 1335 | try std.testing.expect(neither.ok.key == null); | ||
| 1336 | } | ||
| 1337 | |||
| 1338 | test "parseArgs: --quic-idle-ms defaults, parses, and refuses nonsense" { | ||
| 1339 | const dflt = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k" }); | ||
| 1340 | // Spelled out rather than written `default_quic_idle_ms`: asserting | ||
| 1341 | // against the same constant the parser reads would hold for any value, | ||
| 1342 | // so it could never catch the number changing. | ||
| 1343 | try std.testing.expectEqual(@as(u32, 15_000), dflt.ok.quic_idle_ms); | ||
| 1344 | |||
| 1345 | const set = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k", "--quic-idle-ms", "2500" }); | ||
| 1346 | try std.testing.expectEqual(@as(u32, 2500), set.ok.quic_idle_ms); | ||
| 1347 | |||
| 1348 | // Zero means "no idle timeout" to ngtcp2 — the opposite of what anyone | ||
| 1349 | // typing a timeout of zero is asking for, so it is refused rather than | ||
| 1350 | // silently inverted. | ||
| 1351 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "0" }).err == .bad_number); | ||
| 1352 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "soon" }).err == .bad_number); | ||
| 1353 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "-5" }).err == .bad_number); | ||
| 1354 | // Wider than u32: refused at the parse rather than overflowing where it | ||
| 1355 | // is multiplied out to nanoseconds. | ||
| 1356 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "99999999999" }).err == .bad_number); | ||
| 1357 | // The idle flag alone does not turn QUIC on, and must not smuggle the | ||
| 1358 | // both-or-neither rule past the check. | ||
| 1359 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "2500" }) == .ok); | ||
| 1360 | |||
| 1361 | // Same treatment for the numbers that were already here. | ||
| 1362 | try std.testing.expect(parse(&.{ "muxd", "run", "--cols", "wide" }).err == .bad_number); | ||
| 1363 | try std.testing.expect(parse(&.{ "muxd", "run", "--rows", "99999" }).err == .bad_number); | ||
| 1364 | } | ||
| 1365 | |||
| 1366 | test "parseArgs: a value-taking flag at the end of argv names itself" { | ||
| 1367 | // This used to report "unknown argument: --quic", which blames the flag | ||
| 1368 | // rather than the missing value. | ||
| 1369 | inline for (.{ "--sock", "--shell", "--cols", "--rows", "--quic", "--key", "--quic-idle-ms", "--session", "--resume-fd", "--resume-fail-at" }) |flag| { | ||
| 1370 | const r = parse(&.{ "muxd", "run", flag }); | ||
| 1371 | try std.testing.expect(r.err == .missing_value); | ||
| 1372 | try std.testing.expectEqualStrings(flag, r.err.missing_value); | ||
| 1373 | } | ||
| 1374 | } | ||
| 1375 | |||
| 1376 | // The leg the table cannot check itself: `usage` is hand-tuned prose, so a | ||
| 1377 | // verb added as a row and forgotten in the text would ship undocumented. | ||
| 1378 | // This pins the CROSS-CHECK, not the wording — the text stays free to say | ||
| 1379 | // whatever it likes about a subcommand as long as it lists it. | ||
| 1380 | // | ||
| 1381 | // Anchored to the command position rather than to a bare name, because the | ||
| 1382 | // prose says these words in passing: `endpoint`'s parenthetical contains | ||
| 1383 | // "proxy", so deleting the whole `muxd proxy` line still leaves the word in | ||
| 1384 | // the text, and an unanchored search would call that documented. | ||
| 1385 | // `inline for` is what makes `s.name` comptime, so the anchor can be built | ||
| 1386 | // with `++`. | ||
| 1387 | test "usage names every subcommand" { | ||
| 1388 | inline for (specs) |s| { | ||
| 1389 | const named = std.mem.indexOf(u8, usage, "\n muxd " ++ s.name) != null; | ||
| 1390 | // expect() alone would print only "expected true", which does not | ||
| 1391 | // say which verb went missing. | ||
| 1392 | if (!named) std.debug.print("usage never names the subcommand `{s}`\n", .{s.name}); | ||
| 1393 | try std.testing.expect(named); | ||
| 1394 | } | ||
| 1395 | } | ||
| 1396 | |||
| 1397 | test "splitHostPort: literal addresses, bracketed and not" { | ||
| 1398 | const v4 = try splitHostPort("127.0.0.1:4433"); | ||
| 1399 | try std.testing.expectEqualStrings("127.0.0.1", v4.host); | ||
| 1400 | try std.testing.expectEqual(@as(u16, 4433), v4.port); | ||
| 1401 | |||
| 1402 | const v6 = try splitHostPort("[::1]:4433"); | ||
| 1403 | try std.testing.expectEqualStrings("::1", v6.host); | ||
| 1404 | try std.testing.expectEqual(@as(u16, 4433), v6.port); | ||
| 1405 | |||
| 1406 | const any6 = try splitHostPort("[::]:1"); | ||
| 1407 | try std.testing.expectEqualStrings("::", any6.host); | ||
| 1408 | try std.testing.expectEqual(@as(u16, 1), any6.port); | ||
| 1409 | |||
| 1410 | // No port names the default. 4433 is mux's convention; an explicit | ||
| 1411 | // port always wins. | ||
| 1412 | // | ||
| 1413 | // The number is spelled out rather than written `quic.default_port`: | ||
| 1414 | // comparing the parse's answer against the same constant the parse | ||
| 1415 | // reads holds for ANY value, so it would pin the wiring and say | ||
| 1416 | // nothing about the port — and 4433 is the half both ends of a | ||
| 1417 | // connection have to agree on. | ||
| 1418 | const dflt = try splitHostPort("127.0.0.1"); | ||
| 1419 | try std.testing.expectEqualStrings("127.0.0.1", dflt.host); | ||
| 1420 | try std.testing.expectEqual(@as(u16, 4433), dflt.port); | ||
| 1421 | |||
| 1422 | const dflt6 = try splitHostPort("[::1]"); | ||
| 1423 | try std.testing.expectEqualStrings("::1", dflt6.host); | ||
| 1424 | try std.testing.expectEqual(@as(u16, 4433), dflt6.port); | ||
| 1425 | |||
| 1426 | try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:")); | ||
| 1427 | try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:99999")); | ||
| 1428 | try std.testing.expectError(error.MalformedAddress, splitHostPort("[::1]4433")); | ||
| 1429 | |||
| 1430 | // An IPv6 literal without brackets is ambiguous about where the address | ||
| 1431 | // stops, so it is refused instead of being read either way. | ||
| 1432 | try std.testing.expectError(error.MalformedAddress, splitHostPort("::1:4433")); | ||
| 1433 | try std.testing.expectError(error.MalformedAddress, splitHostPort("fe80::1:4433")); | ||
| 1434 | } | ||
| 1435 | |||
| 1436 | test "parseBindAddr: a hostname is refused, not resolved" { | ||
| 1437 | const a = try parseBindAddr("127.0.0.1:4433"); | ||
| 1438 | try std.testing.expectEqual(@as(u16, 4433), a.getPort()); | ||
| 1439 | |||
| 1440 | const six = try parseBindAddr("[::1]:4433"); | ||
| 1441 | try std.testing.expectEqual(@as(u16, 4433), six.getPort()); | ||
| 1442 | try std.testing.expect(six.any.family == std.posix.AF.INET6); | ||
| 1443 | |||
| 1444 | // No DNS at bind time, deliberately: this is the address to bind, and a | ||
| 1445 | // name resolving to several is a question rather than an answer. | ||
| 1446 | try std.testing.expect(std.meta.isError(parseBindAddr("localhost:4433"))); | ||
| 1447 | } | ||
| 1448 | |||
| 1449 | test "keygen: a generated key loads through quic.Key.load" { | ||
| 1450 | const testtmp = @import("testtmp"); | ||
| 1451 | var tmp = try testtmp.TmpDir.make(); | ||
| 1452 | defer tmp.cleanup(); | ||
| 1453 | |||
| 1454 | var buf: [128]u8 = undefined; | ||
| 1455 | const path = try std.fmt.bufPrint(&buf, "{s}/key", .{tmp.path()}); | ||
| 1456 | try xdg.writeNewKey(path); | ||
| 1457 | _ = try quic.Key.load(path); | ||
| 1458 | } | ||
| 1459 | |||
| 1460 | test "parseArgs: --version is a command, not a flag on one" { | ||
| 1461 | const r = parse(&.{ "muxd", "--version" }); | ||
| 1462 | try std.testing.expect(r == .ok); | ||
| 1463 | try std.testing.expect(r.ok._cmd == .version); | ||
| 1464 | } | ||
| 1465 | |||
| 1466 | test "parseArgs: --help is a command, and a flag on one, and both exit 0 on stdout" { | ||
| 1467 | const bare = parse(&.{ "muxd", "--help" }); | ||
| 1468 | try std.testing.expect(bare == .ok); | ||
| 1469 | try std.testing.expect(bare.ok._cmd == .help); | ||
| 1470 | |||
| 1471 | // On a subcommand it is an outcome of the flag parse rather than a row, | ||
| 1472 | // and it must outrank the grammar: `--sock` here is still waiting for a | ||
| 1473 | // value, and asking for the usage is not a way to mistype one. | ||
| 1474 | try std.testing.expect(parse(&.{ "muxd", "run", "--help" }).err == .help); | ||
| 1475 | try std.testing.expect(parse(&.{ "muxd", "dump", "-h", "--sock", "/x" }).err == .help); | ||
| 1476 | try std.testing.expect(parse(&.{ "muxd", "run", "--sock", "--help" }).err == .help); | ||
| 1477 | |||
| 1478 | // The code, asked of `usageCode` rather than of `usageExit`: the latter | ||
| 1479 | // writes the usage to STDOUT, which under `zig build test` is the build | ||
| 1480 | // runner's own IPC channel, and the step hangs forever. 0 is the whole | ||
| 1481 | // difference between an answer and a refusal, so a refusal is asserted | ||
| 1482 | // beside it. | ||
| 1483 | try std.testing.expectEqual(@as(u8, 0), usageCode(.help)); | ||
| 1484 | try std.testing.expectEqual(@as(u8, 2), usageCode(.no_command)); | ||
| 1485 | } | ||
| 1486 | |||
| 1487 | test "parseArgs: keygen takes no flags" { | ||
| 1488 | const r = parse(&.{ "muxd", "keygen" }); | ||
| 1489 | try std.testing.expect(r == .ok); | ||
| 1490 | try std.testing.expect(r.ok._cmd == .keygen); | ||
| 1491 | try std.testing.expect(parse(&.{ "muxd", "keygen", "--sock", "/x" }).err == .unknown_arg); | ||
| 1492 | } | ||
| 1493 | |||
| 1494 | test "pickKey: --key beats MUX_KEY_FILE beats the default path" { | ||
| 1495 | try std.testing.expectEqualStrings("/flag", pickKey("/flag", "/env", "/dflt").?); | ||
| 1496 | try std.testing.expectEqualStrings("/env", pickKey(null, "/env", "/dflt").?); | ||
| 1497 | try std.testing.expectEqualStrings("/dflt", pickKey(null, null, "/dflt").?); | ||
| 1498 | // Nothing named anywhere is the triad-message case, not a silent skip. | ||
| 1499 | try std.testing.expect(pickKey(null, null, null) == null); | ||
| 1500 | } | ||
| 1501 | |||
| 1502 | test "parseArgs: start takes run's flags" { | ||
| 1503 | const r = parse(&.{ "muxd", "start", "--sock", "/tmp/x.sock", "--cols", "100" }); | ||
| 1504 | try std.testing.expect(r == .ok); | ||
| 1505 | try std.testing.expect(r.ok._cmd == .start); | ||
| 1506 | try std.testing.expectEqualStrings("/tmp/x.sock", r.ok.sock.?); | ||
| 1507 | try std.testing.expectEqual(@as(u16, 100), r.ok.cols); | ||
| 1508 | } | ||
| 1509 | |||
| 1510 | test "parseArgs: stop is a command and takes --sock" { | ||
| 1511 | const r = parse(&.{ "muxd", "stop" }); | ||
| 1512 | try std.testing.expect(r == .ok); | ||
| 1513 | try std.testing.expect(r.ok._cmd == .stop); | ||
| 1514 | try std.testing.expect(r.ok.sock == null); | ||
| 1515 | |||
| 1516 | const s = parse(&.{ "muxd", "stop", "--sock", "/tmp/x.sock" }); | ||
| 1517 | try std.testing.expect(s.ok._cmd == .stop); | ||
| 1518 | try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); | ||
| 1519 | } | ||
| 1520 | |||
| 1521 | test "parseArgs: endpoint is a command and takes --sock" { | ||
| 1522 | const r = parse(&.{ "muxd", "endpoint" }); | ||
| 1523 | try std.testing.expect(r == .ok); | ||
| 1524 | try std.testing.expect(r.ok._cmd == .endpoint); | ||
| 1525 | try std.testing.expect(r.ok.sock == null); | ||
| 1526 | |||
| 1527 | const s = parse(&.{ "muxd", "endpoint", "--sock", "/tmp/x.sock" }); | ||
| 1528 | try std.testing.expect(s.ok._cmd == .endpoint); | ||
| 1529 | try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); | ||
| 1530 | |||
| 1531 | // An unknown flag is refused, as it is for every command: a client of | ||
| 1532 | // some later version asking this binary for something it cannot do | ||
| 1533 | // gets a legible refusal rather than silence. | ||
| 1534 | // | ||
| 1535 | // Only UNKNOWN, though. The value-taking flags share one loop, so | ||
| 1536 | // `endpoint --cols 100` parses and is then ignored — `--sock` is the | ||
| 1537 | // only one this command reads. `keygen` is the sole verb that narrows | ||
| 1538 | // its own surface, and widening that rule to `proxy` and `endpoint` | ||
| 1539 | // together is its own change, not this one's. | ||
| 1540 | try std.testing.expect(parse(&.{ "muxd", "endpoint", "--quiet" }).err == .unknown_arg); | ||
| 1541 | try std.testing.expect(parse(&.{ "muxd", "endpoint", "--cols", "100" }) == .ok); | ||
| 1542 | const missing = parse(&.{ "muxd", "endpoint", "--sock" }); | ||
| 1543 | try std.testing.expect(missing.err == .missing_value); | ||
| 1544 | try std.testing.expectEqualStrings("--sock", missing.err.missing_value); | ||
| 1545 | } | ||
| 1546 | |||
| 1547 | test "parseArgs: run --resume-fd N --check is the old daemon's dry run" { | ||
| 1548 | const r = parse(&.{ "muxd", "run", "--resume-fd", "7", "--check" }); | ||
| 1549 | try std.testing.expect(r == .ok); | ||
| 1550 | try std.testing.expect(r.ok._cmd == .run); | ||
| 1551 | try std.testing.expectEqual(@as(std.posix.fd_t, 7), r.ok.resume_fd.?); | ||
| 1552 | try std.testing.expect(r.ok.check); | ||
| 1553 | |||
| 1554 | // A number, like --cols: an fd that is not one would be read as a | ||
| 1555 | // descriptor the daemon never passed. | ||
| 1556 | try std.testing.expect(parse(&.{ "muxd", "run", "--resume-fd", "x" }).err == .bad_number); | ||
| 1557 | |||
| 1558 | const f = parse(&.{ "muxd", "run", "--resume-fd", "3", "--resume-fail-at", "session" }); | ||
| 1559 | try std.testing.expectEqualStrings("session", f.ok.resume_fail_at.?); | ||
| 1560 | |||
| 1561 | // Neither flag is the ordinary start, and both must stay off there — | ||
| 1562 | // a `run` that thought it was resuming would adopt nothing and serve | ||
| 1563 | // nothing. | ||
| 1564 | const plain = parse(&.{ "muxd", "run" }); | ||
| 1565 | try std.testing.expect(plain.ok.resume_fd == null); | ||
| 1566 | try std.testing.expect(!plain.ok.check); | ||
| 1567 | } | ||
| 1568 | |||
| 1569 | test "failAtFrom: the flag beats the environment, and neither is no abort" { | ||
| 1570 | try std.testing.expectEqualStrings("session", failAtFrom("session", "daemon")); | ||
| 1571 | // The environment is how an e2e leg arms an abort at all: the exec | ||
| 1572 | // builds a fixed argv, so there is no flag for it to put a word in. | ||
| 1573 | try std.testing.expectEqualStrings("daemon", failAtFrom(null, "daemon")); | ||
| 1574 | try std.testing.expectEqualStrings("", failAtFrom(null, null)); | ||
| 1575 | } | ||
| 1576 | |||
| 1577 | test "rollbackKeepsEnv: the rollback does not inherit the abort that caused it" { | ||
| 1578 | // Inherited, the old binary would abort at the same section, find the | ||
| 1579 | // marker, give up, and take every shell with it. | ||
| 1580 | try std.testing.expect(!rollbackKeepsEnv("MUX_RESUME_FAIL_AT=session")); | ||
| 1581 | try std.testing.expect(rollbackKeepsEnv("MUX_SHELL_INTEGRATION=1")); | ||
| 1582 | // The name is a prefix of nothing else, but a variable that merely | ||
| 1583 | // starts with the same letters is not this one. | ||
| 1584 | try std.testing.expect(rollbackKeepsEnv("MUX_RESUME_FAIL_AT_NOT=1")); | ||
| 1585 | } | ||
| 1586 | |||
| 1587 | test "parseArgs: upgrade is a command, and same-version is a flag it takes" { | ||
| 1588 | const r = parse(&.{ "muxd", "upgrade" }); | ||
| 1589 | try std.testing.expect(r == .ok); | ||
| 1590 | try std.testing.expect(r.ok._cmd == .upgrade); | ||
| 1591 | // Off unless asked: the skew rule is strictly-newer, and an operator who | ||
| 1592 | // did not name the exception must not get it. | ||
| 1593 | try std.testing.expect(!r.ok.allow_same_version); | ||
| 1594 | |||
| 1595 | const s = parse(&.{ "muxd", "upgrade", "--sock", "/tmp/x.sock", "--allow-same-version" }); | ||
| 1596 | try std.testing.expect(s.ok._cmd == .upgrade); | ||
| 1597 | try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); | ||
| 1598 | try std.testing.expect(s.ok.allow_same_version); | ||
| 1599 | } | ||
| 1600 | |||
| 1601 | test "resumeRun: --check adopts nothing, so --resume-fail-at has nothing to abort" { | ||
| 1602 | const alloc = std.testing.allocator; | ||
| 1603 | |||
| 1604 | const memfd = try std.posix.memfd_create("mux-resume-check-test", 0); | ||
| 1605 | defer std.posix.close(memfd); | ||
| 1606 | |||
| 1607 | var buf: std.ArrayList(u8) = .empty; | ||
| 1608 | defer buf.deinit(alloc); | ||
| 1609 | try upgrade.writeManifest(buf.writer(alloc), alloc, .{ | ||
| 1610 | .writer_version = "0.0.1-99", | ||
| 1611 | // Deliberately a path that cannot exec: a --check that rolled back | ||
| 1612 | // would exec the OLD binary out of a probe the old daemon runs as a | ||
| 1613 | // CHILD, and a rollback target that cannot be exec'd fails this | ||
| 1614 | // test instead of replacing the test runner with it. | ||
| 1615 | .writer_path = "/nonexistent/muxd", | ||
| 1616 | .sock_path = "/tmp/mux-resume-check-test.sock", | ||
| 1617 | .listener_fd = -1, | ||
| 1618 | .shellint_dir = null, | ||
| 1619 | .agent_dir = null, | ||
| 1620 | .shell = "/bin/sh", | ||
| 1621 | .shell_integration = false, | ||
| 1622 | .extra_env = &.{}, | ||
| 1623 | .quic = .{}, | ||
| 1624 | .counters = .{}, | ||
| 1625 | }, &.{}); | ||
| 1626 | var file = std.fs.File{ .handle = memfd }; | ||
| 1627 | try file.writeAll(buf.items); | ||
| 1628 | |||
| 1629 | const code = try resumeRun(alloc, .{ | ||
| 1630 | ._cmd = .run, | ||
| 1631 | .check = true, | ||
| 1632 | .resume_fd = memfd, | ||
| 1633 | .resume_fail_at = "daemon", | ||
| 1634 | }, memfd); | ||
| 1635 | try std.testing.expectEqual(@as(u8, 0), code); | ||
| 1636 | } | ||
| 1637 | |||
| 1638 | test "announceKeyFrom: MUX_KEY_FILE wins, and the default it skipped is not created" { | ||
| 1639 | const testtmp = @import("testtmp"); | ||
| 1640 | var tmp = try testtmp.TmpDir.make(); | ||
| 1641 | defer tmp.cleanup(); | ||
| 1642 | |||
| 1643 | var ebuf: [280]u8 = undefined; | ||
| 1644 | var dbuf: [280]u8 = undefined; | ||
| 1645 | const env = try std.fmt.bufPrint(&ebuf, "{s}/env-key", .{tmp.path()}); | ||
| 1646 | const dflt = try std.fmt.bufPrint(&dbuf, "{s}/cfg/mux/key", .{tmp.path()}); | ||
| 1647 | try xdg.writeNewKey(env); | ||
| 1648 | |||
| 1649 | const r = announceKeyFrom(env, dflt); | ||
| 1650 | try std.testing.expect(r == .key); | ||
| 1651 | // The key it returned is the file it was pointed at, not merely some | ||
| 1652 | // key: the announce is only worth anything if it names the one the | ||
| 1653 | // daemon will authenticate with. | ||
| 1654 | var on_disk: [32]u8 = undefined; | ||
| 1655 | try std.testing.expectEqualSlices(u8, try std.fs.cwd().readFile(env, &on_disk), &r.key.bytes); | ||
| 1656 | |||
| 1657 | // The default is not merely unused, it is uncreated. Creating a key | ||
| 1658 | // beside one the user named would leave a credential nobody asked for | ||
| 1659 | // and, worse, one the daemon might later pick up instead. | ||
| 1660 | try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(dflt, .{})); | ||
| 1661 | } | ||
| 1662 | |||
| 1663 | test "announceKeyFrom: the default is created when absent, and no path at all is no_path" { | ||
| 1664 | const testtmp = @import("testtmp"); | ||
| 1665 | var tmp = try testtmp.TmpDir.make(); | ||
| 1666 | defer tmp.cleanup(); | ||
| 1667 | |||
| 1668 | var dbuf: [280]u8 = undefined; | ||
| 1669 | const dflt = try std.fmt.bufPrint(&dbuf, "{s}/cfg/mux/key", .{tmp.path()}); | ||
| 1670 | |||
| 1671 | // The mosh-server move: a fresh box gets a key rather than a lecture. | ||
| 1672 | const made = announceKeyFrom(null, dflt); | ||
| 1673 | try std.testing.expect(made == .key); | ||
| 1674 | const st = try std.fs.cwd().statFile(dflt); | ||
| 1675 | try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(st.mode & 0o777))); | ||
| 1676 | |||
| 1677 | // A second call loads the SAME key rather than rotating it: the | ||
| 1678 | // announce must name what the daemon will authenticate with, and this | ||
| 1679 | // process runs once per attach. | ||
| 1680 | const again = announceKeyFrom(null, dflt); | ||
| 1681 | try std.testing.expect(again == .key); | ||
| 1682 | try std.testing.expectEqualSlices(u8, &made.key.bytes, &again.key.bytes); | ||
| 1683 | |||
| 1684 | try std.testing.expect(announceKeyFrom(null, null) == .no_path); | ||
| 1685 | } | ||
| 1686 | |||
| 1687 | test "announceKeyFrom: a default that cannot be created reports the create, not the load" { | ||
| 1688 | const testtmp = @import("testtmp"); | ||
| 1689 | var tmp = try testtmp.TmpDir.make(); | ||
| 1690 | defer tmp.cleanup(); | ||
| 1691 | |||
| 1692 | var robuf: [280]u8 = undefined; | ||
| 1693 | var dbuf: [280]u8 = undefined; | ||
| 1694 | const ro = try std.fmt.bufPrint(&robuf, "{s}/ro", .{tmp.path()}); | ||
| 1695 | const dflt = try std.fmt.bufPrint(&dbuf, "{s}/mux/key", .{ro}); | ||
| 1696 | try std.fs.cwd().makePath(ro); | ||
| 1697 | { | ||
| 1698 | var d = try std.fs.cwd().openDir(ro, .{ .iterate = true }); | ||
| 1699 | defer d.close(); | ||
| 1700 | try d.chmod(0o500); | ||
| 1701 | } | ||
| 1702 | // Left at 0500 for cleanup, deliberately: 0500 still grants read and | ||
| 1703 | // execute, so deleteTree can enter and list it, and removing the empty | ||
| 1704 | // directory itself needs write on the tmp ROOT, which is untouched. | ||
| 1705 | // Emptiness is not an assumption — it is the assertion below. | ||
| 1706 | |||
| 1707 | // Swallowing the create error leaves the load to speak, and all it can | ||
| 1708 | // say is `no such key file` — which names the symptom and sends | ||
| 1709 | // someone reading it over ssh to look for a file, when the real story | ||
| 1710 | // is a directory they cannot write. The distinction is the whole | ||
| 1711 | // reason the create's error is retained. | ||
| 1712 | const r = announceKeyFrom(null, dflt); | ||
| 1713 | try std.testing.expect(r == .create_failed); | ||
| 1714 | try std.testing.expectEqual(error.AccessDenied, r.create_failed.err); | ||
| 1715 | try std.testing.expectEqualStrings(dflt, r.create_failed.path); | ||
| 1716 | } | ||
| 1717 | |||
| 1718 | test "askEndpointPort: a socket nobody serves answers 0, quickly" { | ||
| 1719 | const testtmp = @import("testtmp"); | ||
| 1720 | var tmp = try testtmp.TmpDir.make(); | ||
| 1721 | defer tmp.cleanup(); | ||
| 1722 | var buf: [280]u8 = undefined; | ||
| 1723 | const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()}); | ||
| 1724 | |||
| 1725 | // Zero is the announce-none path, and getting there fast is the point: | ||
| 1726 | // the connect refusal is immediate, so the bounded wait below it is | ||
| 1727 | // never entered. An implementation that reached for the deadline | ||
| 1728 | // first — polling for a reply that cannot come, retrying the connect | ||
| 1729 | // until the budget ran out — would answer the same 0 two seconds | ||
| 1730 | // later, and every `muxd endpoint` against a daemon that died between | ||
| 1731 | // the ensure and the ask would pay it. | ||
| 1732 | const t0 = std.time.milliTimestamp(); | ||
| 1733 | try std.testing.expectEqual(@as(u16, 0), askEndpointPort(std.testing.allocator, sock)); | ||
| 1734 | try std.testing.expect(std.time.milliTimestamp() - t0 < 500); | ||
| 1735 | } | ||
| 1736 | |||
| 1737 | test "oneShotQuery: a socket nobody serves is exit 1" { | ||
| 1738 | const testtmp = @import("testtmp"); | ||
| 1739 | var tmp = try testtmp.TmpDir.make(); | ||
| 1740 | defer tmp.cleanup(); | ||
| 1741 | var buf: [280]u8 = undefined; | ||
| 1742 | const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()}); | ||
| 1743 | |||
| 1744 | // The opposite verdict from `stopCmd` below, on the identical input, and | ||
| 1745 | // both are right: `stop` asked for a state the absence already satisfies, | ||
| 1746 | // while `dump` and `stats` asked a question nothing answered. Sharing one | ||
| 1747 | // round-trip between the two query verbs must not quietly make it three. | ||
| 1748 | // | ||
| 1749 | // The exit is all this pins. Both verbs are run because both must reach | ||
| 1750 | // that verdict, but the line naming the verb goes to stderr rather than | ||
| 1751 | // being returned, so nothing here can assert it — the way `lostMsg` and | ||
| 1752 | // `keyRefusalBody` are asserted is by being pure, and this is not. | ||
| 1753 | try std.testing.expectEqual( | ||
| 1754 | @as(u8, 1), | ||
| 1755 | try oneShotQuery(std.testing.allocator, sock, "dump", .debug_dump, "", .dump_reply), | ||
| 1756 | ); | ||
| 1757 | try std.testing.expectEqual( | ||
| 1758 | @as(u8, 1), | ||
| 1759 | try oneShotQuery(std.testing.allocator, sock, "stats", .stats_req, "", .stats_reply), | ||
| 1760 | ); | ||
| 1761 | } | ||
| 1762 | |||
| 1763 | test "stopCmd: a socket path with nothing on it is exit 0, not a failure" { | ||
| 1764 | const testtmp = @import("testtmp"); | ||
| 1765 | var tmp = try testtmp.TmpDir.make(); | ||
| 1766 | defer tmp.cleanup(); | ||
| 1767 | var buf: [280]u8 = undefined; | ||
| 1768 | const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()}); | ||
| 1769 | try std.testing.expectEqual(@as(u8, 0), try stopCmd(std.testing.allocator, sock)); | ||
| 1770 | } | ||
| 1771 | |||
| 1772 | test "peerPid: the kernel names the peer" { | ||
| 1773 | // Both ends of a socketpair are this process, so the only right answer | ||
| 1774 | // is our own pid — and it comes from the kernel, not from anything the | ||
| 1775 | // peer said about itself. | ||
| 1776 | var sp: [2]i32 = undefined; | ||
| 1777 | try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp)); | ||
| 1778 | defer std.posix.close(sp[0]); | ||
| 1779 | defer std.posix.close(sp[1]); | ||
| 1780 | try std.testing.expectEqual(std.os.linux.getpid(), peerPid(sp[0]).?); | ||
| 1781 | } | ||
| 1782 | |||
| 1783 | test "waitPidGone: returns only once the OS has no such process" { | ||
| 1784 | // A grandchild, deliberately: a child of ours would linger as a zombie | ||
| 1785 | // that signal 0 still finds, which is the trap this test would fall | ||
| 1786 | // into if it held the dimension constant. The shell prints the pid and | ||
| 1787 | // exits; the sleeper is reparented and dies on its own clock. | ||
| 1788 | var child = std.process.Child.init(&.{ "sh", "-c", "sleep 0.3 & echo $!" }, std.testing.allocator); | ||
| 1789 | child.stdout_behavior = .Pipe; | ||
| 1790 | try child.spawn(); | ||
| 1791 | var buf: [32]u8 = undefined; | ||
| 1792 | const n = try child.stdout.?.readAll(&buf); | ||
| 1793 | _ = try child.wait(); | ||
| 1794 | const pid = try std.fmt.parseInt(std.posix.pid_t, std.mem.trim(u8, buf[0..n], "\n "), 10); | ||
| 1795 | try std.posix.kill(pid, 0); // alive when we start, or the wait proves nothing | ||
| 1796 | try std.testing.expectEqual(@as(u8, 0), waitPidGone(pid, "(test)")); | ||
| 1797 | try std.testing.expectError(error.ProcessNotFound, std.posix.kill(pid, 0)); | ||
| 1798 | } | ||
| 1799 | |||
| 1800 | test "shellIntegrationEnabled: an unset environment means off" { | ||
| 1801 | // The daily-driver default. The injection is not free — under zsh the | ||
| 1802 | // ZDOTDIR shim costs the user their ~/.zshenv, and under bash the DEBUG | ||
| 1803 | // trap displaces theirs (atuin, bash-preexec) — and what it buys is a | ||
| 1804 | // knowable exit code, which only `muxa` reads. A user who never asked | ||
| 1805 | // for it must not pay for it. | ||
| 1806 | try std.testing.expect(!shellIntegrationEnabled(null)); | ||
| 1807 | } | ||
| 1808 | |||
| 1809 | test "shellIntegrationEnabled: `1` and nothing else turns it on" { | ||
| 1810 | try std.testing.expect(shellIntegrationEnabled("1")); | ||
| 1811 | // Every other spelling is off, including the one that used to mean off | ||
| 1812 | // when this variable was an opt-OUT: a stale `=0` in someone's profile | ||
| 1813 | // still reads as off, which is the safe direction for an inversion. | ||
| 1814 | try std.testing.expect(!shellIntegrationEnabled("0")); | ||
| 1815 | try std.testing.expect(!shellIntegrationEnabled("")); | ||
| 1816 | try std.testing.expect(!shellIntegrationEnabled("true")); | ||
| 1817 | try std.testing.expect(!shellIntegrationEnabled("yes")); | ||
| 1818 | } | ||
| 1819 | |||
| 1820 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 1821 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 1822 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 1823 | test { | ||
| 1824 | std.testing.refAllDeclsRecursive(@This()); | ||
| 1825 | } | ||
src/cli/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,984 @@ | |||
| 1 | //! mux — client binary. `mux [--sock PATH]` attaches to the local muxd; | ||
| 2 | //! `mux --via CMD` attaches over CMD's stdio instead (any command that | ||
| 3 | //! exposes a session socket as a byte pipe, e.g. `ssh host muxd proxy`); | ||
| 4 | //! `mux HOST` runs the ssh→QUIC handoff — ssh fetches the daemon's QUIC | ||
| 5 | //! coordinates and carries the session only if the QUIC dial does not. | ||
| 6 | //! | ||
| 7 | //! Every one of those is a WALL of one tile whose rect is the whole terminal | ||
| 8 | //! (`wallview.runAttach`); `mux wall` is the same program entered on the | ||
| 9 | //! wall itself. What lives up here is argv, the refusals that must happen | ||
| 10 | //! before a dial (self-attach, an unbindable socket path), the auto-start, | ||
| 11 | //! and the wall-file subcommands — everything, in other words, that is | ||
| 12 | //! about the command line rather than about a session. | ||
| 13 | const std = @import("std"); | ||
| 14 | const client = @import("client"); | ||
| 15 | const proto = @import("protocol"); | ||
| 16 | const build_options = @import("build_options"); | ||
| 17 | const xdg = @import("xdg"); | ||
| 18 | const spawn = @import("spawn"); | ||
| 19 | const handoff = @import("handoff"); | ||
| 20 | const sockpath = @import("sockpath"); | ||
| 21 | const wallview = @import("wallview"); | ||
| 22 | const wall = @import("wall"); | ||
| 23 | const TmpDir = @import("testtmp").TmpDir; | ||
| 24 | |||
| 25 | const usage = | ||
| 26 | \\usage: mux [HOST | --sock PATH | --via CMD | quic://HOST[:PORT]] | ||
| 27 | \\ HOST attaches over ssh and hands off to QUIC when the daemon offers it | ||
| 28 | \\ (muxd must be on HOST's PATH; cached coordinates make later attaches | ||
| 29 | \\ skip ssh entirely) | ||
| 30 | \\ quic://HOST[:PORT] (PORT defaults to 4433) uses --key FILE, | ||
| 31 | \\ MUX_KEY_FILE, or ~/.config/mux/key; muxd must be running with a | ||
| 32 | \\ matching --quic and key | ||
| 33 | \\ [--quic-idle-ms N] tunes how fast a dead link is noticed | ||
| 34 | \\ [--session NAME] attaches to (or creates) a named session instead of | ||
| 35 | \\ the default (`0`); NAME is printable ASCII, no space, no '#' or '/' | ||
| 36 | \\ -A forwards this client's ssh-agent into the session, like ssh -A: | ||
| 37 | \\ whoever typed last is whose agent signs, and only while attached | ||
| 38 | \\ --version prints the version | ||
| 39 | \\ | ||
| 40 | \\ mux wall [SPELLING...] shows several sessions at once, one stripe | ||
| 41 | \\ each; `Ctrl-\ 1-9` focuses a tile and types into it, `Ctrl-\ h/j/k/l` | ||
| 42 | \\ moves between panes, `Ctrl-\ |/-` split right/below, `Ctrl-\ f` | ||
| 43 | \\ fullscreen, `Ctrl-\ r` resize mode, `Ctrl-\ :` adds a tile by | ||
| 44 | \\ spelling (Enter adds, Esc cancels), `Ctrl-\ d` leaves. SPELLING is | ||
| 45 | \\ the wall grammar | ||
| 46 | \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION], | ||
| 47 | \\ one argument per tile, but `--sock PATH` may also be two arguments | ||
| 48 | \\ as in muxweb); with none, the saved wall is shown. | ||
| 49 | \\ | ||
| 50 | \\ mux wall add SPELLING... / mux wall rm SPELLING... edit the saved | ||
| 51 | \\ wall without dialling anything. The wall is attach history: `mux` | ||
| 52 | \\ itself adds the tile it attaches to, `Ctrl-\ x` on the wall forgets one, | ||
| 53 | \\ and neither ever kills a session. | ||
| 54 | \\ | ||
| 55 | ; | ||
| 56 | |||
| 57 | /// What the command line asked for. A tagged union rather than a struct of | ||
| 58 | /// optionals so the two failure modes are results in their own right, and so | ||
| 59 | /// the parse can be tested without a process to exit from. | ||
| 60 | const ParseResult = union(enum) { | ||
| 61 | /// At most one of these is set; both null means the default local socket. | ||
| 62 | /// `session` defaults to "" (empty), the wire-compatible name that puts | ||
| 63 | /// exactly the old bytes on the wire — see encodeAttachNamed. | ||
| 64 | attach: struct { sock: ?[]const u8 = null, via: ?[]const u8 = null, session: []const u8 = "", agent: bool = false }, | ||
| 65 | /// A bare hostname: the ssh recipe is built from it in main, where there | ||
| 66 | /// is an allocator to build it with. `idle_ms` rides along because the | ||
| 67 | /// handoff ends in a QUIC link like any other — muxweb's HOST tiles | ||
| 68 | /// have always carried it, and mux dropping it on the floor made | ||
| 69 | /// `--quic-idle-ms` silently do nothing on exactly the spelling most | ||
| 70 | /// people use. | ||
| 71 | host: struct { name: []const u8, idle_ms: u32, session: []const u8 = "", agent: bool = false }, | ||
| 72 | /// A direct QUIC attach. The key is resolved in main, where the | ||
| 73 | /// environment can be consulted. | ||
| 74 | quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32, session: []const u8 = "", agent: bool = false }, | ||
| 75 | /// `--version`: not a transport at all, so it short-circuits the rest of | ||
| 76 | /// the parse rather than being reconciled with it. | ||
| 77 | version, | ||
| 78 | /// More than one transport named — a request that cannot be honoured | ||
| 79 | /// rather than one to reconcile. | ||
| 80 | conflict, | ||
| 81 | usage_error, | ||
| 82 | }; | ||
| 83 | |||
| 84 | /// `SSH_AGENTC_REQUEST_IDENTITIES` in the ssh-agent framing: a 4-byte | ||
| 85 | /// big-endian length, then the message type. `ssh-add -l` sends exactly | ||
| 86 | /// this, which is why every agent implementation answers it — with an | ||
| 87 | /// identities list, or a failure if it holds no keys. Either is proof of | ||
| 88 | /// an agent; the preflight never reads past the type. | ||
| 89 | /// | ||
| 90 | /// This is the one place mux knows any ssh-agent bytes, and it belongs | ||
| 91 | /// here rather than in `protocol`, `proxy` or the QUIC modules: the | ||
| 92 | /// forwarding path stays opaque end to end (the daemon pumps blind, by | ||
| 93 | /// design), and what speaks here is the client, which is the agent's own | ||
| 94 | /// peer and the process that made the `-A` promise. | ||
| 95 | const agent_request_identities = [_]u8{ 0, 0, 0, 1, 11 }; | ||
| 96 | |||
| 97 | /// How long a probe waits for an answer before deciding it cannot tell. | ||
| 98 | /// The asymmetry below is what sets it: a refusal is a hangup on an | ||
| 99 | /// already-accepted connection and arrives in microseconds, so this bound | ||
| 100 | /// is not asked to separate refused from slow — it only has to outlast a | ||
| 101 | /// real agent's round trip, including one forwarded back out of an outer | ||
| 102 | /// session over a link with an RTT. | ||
| 103 | const agent_probe_ms = 500; | ||
| 104 | |||
| 105 | /// Whether an ssh-agent is actually there to forward. A request and a | ||
| 106 | /// reply, not a dial: inside a mux session `SSH_AUTH_SOCK` names the | ||
| 107 | /// DAEMON's per-session socket, which accepts every connection and only | ||
| 108 | /// then looks for a client to route it to. A bare connect passes there | ||
| 109 | /// even when nobody is offering — and the client it waves through is | ||
| 110 | /// precisely the silent offerer this check exists to refuse, one that can | ||
| 111 | /// out-rank a working `-A` client because offering is a declaration and | ||
| 112 | /// not a capability. The nested case where the outer session DOES have an | ||
| 113 | /// answerer is the one that must keep working, and it does: the request is | ||
| 114 | /// forwarded out and the real agent's reply comes back. | ||
| 115 | /// | ||
| 116 | /// Fails open on silence, closed on a hangup. A refusal is immediate, so | ||
| 117 | /// slowness is not the discriminator: a hardware token or a cold-started | ||
| 118 | /// gpg-agent is slow and is still an agent. | ||
| 119 | fn agentReachable(path: []const u8) bool { | ||
| 120 | const fd = client.connectAgent(path) orelse return false; | ||
| 121 | defer std.posix.close(fd); | ||
| 122 | |||
| 123 | // MSG_NOSIGNAL rather than a `write`: the peer may already be gone, and | ||
| 124 | // the preflight runs before the client installs any signal handling, so | ||
| 125 | // an EPIPE has to arrive as an error and not as a fatal signal. | ||
| 126 | _ = std.posix.send(fd, &agent_request_identities, std.posix.MSG.NOSIGNAL) catch return false; | ||
| 127 | |||
| 128 | var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }}; | ||
| 129 | const ready = std.posix.poll(&pfd, agent_probe_ms) catch return true; | ||
| 130 | if (ready == 0) return true; | ||
| 131 | var reply: [1]u8 = undefined; | ||
| 132 | // Zero bytes is EOF: accepted, then hung up without answering. That is | ||
| 133 | // the daemon with no offerer behind it, and the only shape refused here. | ||
| 134 | const n = std.posix.read(fd, &reply) catch return false; | ||
| 135 | return n != 0; | ||
| 136 | } | ||
| 137 | |||
| 138 | /// The environment variable consulted when `--key` is absent. Named rather | ||
| 139 | /// than inlined because the parse cannot read it — the parse stays pure so | ||
| 140 | /// it stays testable — and `main` has to use exactly the same name. | ||
| 141 | pub const key_env = "MUX_KEY_FILE"; | ||
| 142 | |||
| 143 | /// Built from `proto.session_env` so the message and the planter cannot | ||
| 144 | /// disagree about the spelling. | ||
| 145 | const self_attach_refusal = | ||
| 146 | "mux: this shell is inside that session (unset " ++ proto.session_env ++ " to override)\n"; | ||
| 147 | |||
| 148 | /// An inner client repaints its own grid forever, and no chord steers | ||
| 149 | /// back out. Refused before it starts. | ||
| 150 | fn insideThisSession( | ||
| 151 | env_sock: ?[]const u8, | ||
| 152 | env_session: ?[]const u8, | ||
| 153 | sock: ?[]const u8, | ||
| 154 | session: []const u8, | ||
| 155 | ) bool { | ||
| 156 | const es = env_sock orelse return false; | ||
| 157 | const en = env_session orelse return false; | ||
| 158 | // A unix socket path only: a host or quic:// target is a different | ||
| 159 | // daemon whatever its sessions are called. | ||
| 160 | const target = sock orelse return false; | ||
| 161 | // Emptied counts as unset: `MUX_SESSION=` is how a shell overrides an | ||
| 162 | // exported variable it cannot unset, and the refusal names unsetting as | ||
| 163 | // the way out — both spellings of that have to work. | ||
| 164 | if (es.len == 0 or en.len == 0) return false; | ||
| 165 | // Both halves, so session 0 attaching to session 1 of the same daemon | ||
| 166 | // keeps working. String equality on the path: a symlinked or relatively | ||
| 167 | // spelled `--sock` for the same socket evades this, accepted, because | ||
| 168 | // this guards the mistake people make (typing `mux` in a mux shell) and | ||
| 169 | // is not a security boundary. | ||
| 170 | return std.mem.eql(u8, es, target) and | ||
| 171 | std.mem.eql(u8, en, proto.resolveName(session)); | ||
| 172 | } | ||
| 173 | |||
| 174 | fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult { | ||
| 175 | var sock: ?[]const u8 = null; | ||
| 176 | var via: ?[]const u8 = null; | ||
| 177 | var host: ?[]const u8 = null; | ||
| 178 | var quic: ?[]const u8 = null; | ||
| 179 | var key: ?[]const u8 = null; | ||
| 180 | var idle_ms: u32 = client.quic_idle_ms_default; | ||
| 181 | // Rides every transport below, unlike --key: a session name is not | ||
| 182 | // authenticating anything, so there is no "no quic:// means ignore it" | ||
| 183 | // escape hatch — it applies whichever spelling wins. | ||
| 184 | var session: []const u8 = ""; | ||
| 185 | // Rides every transport too, and for the same reason: an offer to | ||
| 186 | // answer for this client's agent is about the client, not the wire it | ||
| 187 | // reached the daemon over. | ||
| 188 | var agent = false; | ||
| 189 | |||
| 190 | var i: usize = 1; | ||
| 191 | while (i < args.len) : (i += 1) { | ||
| 192 | const a = args[i]; | ||
| 193 | // First branch, and it returns rather than recording: asking a binary | ||
| 194 | // its version must answer whatever else is on the line, including a | ||
| 195 | // transport that would otherwise conflict or fail to parse. | ||
| 196 | if (std.mem.eql(u8, a, "--version")) { | ||
| 197 | return .version; | ||
| 198 | } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { | ||
| 199 | i += 1; | ||
| 200 | if (sock != null) return .conflict; | ||
| 201 | sock = args[i]; | ||
| 202 | } else if (std.mem.eql(u8, a, "--via") and i + 1 < args.len) { | ||
| 203 | i += 1; | ||
| 204 | if (via != null) return .conflict; | ||
| 205 | via = args[i]; | ||
| 206 | } else if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) { | ||
| 207 | i += 1; | ||
| 208 | key = args[i]; | ||
| 209 | } else if (std.mem.eql(u8, a, "--session") and i + 1 < args.len) { | ||
| 210 | i += 1; | ||
| 211 | // A name that cannot be spelled must not become wire bytes: catch | ||
| 212 | // it here, at usage-error altitude, rather than downstream where | ||
| 213 | // it would look like a rejected attach. | ||
| 214 | if (!proto.validSessionName(args[i])) return .usage_error; | ||
| 215 | session = args[i]; | ||
| 216 | } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) { | ||
| 217 | i += 1; | ||
| 218 | const n = std.fmt.parseInt(u32, args[i], 10) catch return .usage_error; | ||
| 219 | // Zero means "no idle timeout" to ngtcp2, the inverse of what | ||
| 220 | // anyone typing a timeout of zero is asking for. | ||
| 221 | if (n == 0) return .usage_error; | ||
| 222 | idle_ms = n; | ||
| 223 | } else if (std.mem.eql(u8, a, "-A")) { | ||
| 224 | // Named explicitly: the bare-word arm below only takes words | ||
| 225 | // that do not start with '-', so an unnamed flag is a usage | ||
| 226 | // error rather than a hostname. | ||
| 227 | agent = true; | ||
| 228 | } else if (std.mem.startsWith(u8, a, "quic://")) { | ||
| 229 | // Counted with the others, so `mux quic://a:1 --sock /x` is the | ||
| 230 | // same conflict as naming any other two transports. | ||
| 231 | if (quic != null) return .conflict; | ||
| 232 | quic = a["quic://".len..]; | ||
| 233 | if (quic.?.len == 0) return .usage_error; | ||
| 234 | } else if (a.len > 0 and a[0] != '-') { | ||
| 235 | // A bare word is a host to hop to. Two of them is as ambiguous | ||
| 236 | // as naming two transports, so it lands in the same place. | ||
| 237 | if (host != null) return .conflict; | ||
| 238 | host = a; | ||
| 239 | } else { | ||
| 240 | // Includes `--sock`/`--via` with no value left to take: a flag | ||
| 241 | // whose argument is missing is a usage mistake, not a transport. | ||
| 242 | return .usage_error; | ||
| 243 | } | ||
| 244 | } | ||
| 245 | |||
| 246 | // Every pairing of the four is two transports for one session. | ||
| 247 | const named: u8 = @as(u8, @intFromBool(sock != null)) + | ||
| 248 | @intFromBool(via != null) + @intFromBool(host != null) + | ||
| 249 | @intFromBool(quic != null); | ||
| 250 | if (named > 1) return .conflict; | ||
| 251 | |||
| 252 | if (quic) |hp| { | ||
| 253 | // Neither spelling being set is not a refusal: main has a default | ||
| 254 | // path to try, and parse is not allowed to look at the filesystem. | ||
| 255 | return .{ .quic = .{ | ||
| 256 | .host_port = hp, | ||
| 257 | .key = xdg.pickKey(key, env_key), | ||
| 258 | .idle_ms = idle_ms, | ||
| 259 | .session = session, | ||
| 260 | .agent = agent, | ||
| 261 | } }; | ||
| 262 | } | ||
| 263 | // A key with no quic:// has nothing to authenticate and is ignored | ||
| 264 | // rather than refused: unlike muxd, where --key without --quic means a | ||
| 265 | // listener was meant, here it is one env var away from being set for | ||
| 266 | // every invocation in a shell, and refusing `mux --sock ...` because | ||
| 267 | // MUX_KEY_FILE happens to be exported would be absurd. | ||
| 268 | if (host) |h| return .{ .host = .{ .name = h, .idle_ms = idle_ms, .session = session, .agent = agent } }; | ||
| 269 | return .{ .attach = .{ .sock = sock, .via = via, .session = session, .agent = agent } }; | ||
| 270 | } | ||
| 271 | |||
| 272 | pub fn main() !u8 { | ||
| 273 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 274 | defer if (gpa.deinit() == .leak) | ||
| 275 | std.debug.print("mux: LEAK: allocations outlived deinit\n", .{}); | ||
| 276 | const alloc = gpa.allocator(); | ||
| 277 | |||
| 278 | const args = try std.process.argsAlloc(alloc); | ||
| 279 | defer std.process.argsFree(alloc, args); | ||
| 280 | |||
| 281 | // A subcommand, checked before the flag parse: `wall` is a different | ||
| 282 | // program (N passive tiles), not a transport spelling for one attach. | ||
| 283 | if (args.len > 1 and std.mem.eql(u8, args[1], "wall")) | ||
| 284 | return wallMain(alloc, args[2..]); | ||
| 285 | |||
| 286 | const parsed = parseArgs(args, std.posix.getenv(key_env)); | ||
| 287 | |||
| 288 | // `-A` is a promise, and a client with no agent behind it cannot keep | ||
| 289 | // one. Left to attach, it offers anyway — the offer is a declaration, | ||
| 290 | // not a capability — so every dial the session makes is refused in | ||
| 291 | // silence, and on a session with a second `-A` client it can out-rank | ||
| 292 | // one that WOULD have answered. Refusing here says so once, at the | ||
| 293 | // altitude the flag was typed at, instead of surfacing three layers | ||
| 294 | // down as `permission denied (publickey)` from a git remote. | ||
| 295 | const wants_agent = switch (parsed) { | ||
| 296 | .host => |h| h.agent, | ||
| 297 | .quic => |q| q.agent, | ||
| 298 | .attach => |at| at.agent, | ||
| 299 | else => false, | ||
| 300 | }; | ||
| 301 | if (wants_agent) { | ||
| 302 | const sock = std.posix.getenv(proto.agent_sock_env) orelse ""; | ||
| 303 | if (!agentReachable(sock)) { | ||
| 304 | if (sock.len == 0) { | ||
| 305 | std.debug.print( | ||
| 306 | "mux: -A: " ++ proto.agent_sock_env ++ " is not set — no ssh-agent to forward\n", | ||
| 307 | .{}, | ||
| 308 | ); | ||
| 309 | } else { | ||
| 310 | std.debug.print( | ||
| 311 | "mux: -A: no ssh-agent answering at {s}\n", | ||
| 312 | .{sock}, | ||
| 313 | ); | ||
| 314 | } | ||
| 315 | return 2; | ||
| 316 | } | ||
| 317 | } | ||
| 318 | |||
| 319 | switch (parsed) { | ||
| 320 | .version => { | ||
| 321 | var vbuf: [64]u8 = undefined; | ||
| 322 | const s = std.fmt.bufPrint(&vbuf, "mux {s}\n", .{build_options.version}) catch unreachable; | ||
| 323 | _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {}; | ||
| 324 | return 0; | ||
| 325 | }, | ||
| 326 | .usage_error => { | ||
| 327 | std.debug.print("{s}", .{usage}); | ||
| 328 | return 2; | ||
| 329 | }, | ||
| 330 | .conflict => { | ||
| 331 | std.debug.print( | ||
| 332 | "mux: name one transport: HOST, --sock, --via or quic://\n{s}", | ||
| 333 | .{usage}, | ||
| 334 | ); | ||
| 335 | return 2; | ||
| 336 | }, | ||
| 337 | .quic => |q| { | ||
| 338 | const res = try xdg.resolveKeyPath(alloc, q.key); | ||
| 339 | defer switch (res) { | ||
| 340 | .given => {}, | ||
| 341 | .default, .missing => |p| alloc.free(p), | ||
| 342 | }; | ||
| 343 | const key_path = switch (res) { | ||
| 344 | .given, .default => |p| p, | ||
| 345 | .missing => |p| { | ||
| 346 | std.debug.print( | ||
| 347 | "mux: no key: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n", | ||
| 348 | .{p}, | ||
| 349 | ); | ||
| 350 | return 2; | ||
| 351 | }, | ||
| 352 | }; | ||
| 353 | return wallview.runAttach(alloc, .{ .quic = .{ | ||
| 354 | .host_port = q.host_port, | ||
| 355 | .key_path = key_path, | ||
| 356 | .idle_ms = q.idle_ms, | ||
| 357 | } }, q.session, q.key, q.idle_ms, q.agent); | ||
| 358 | }, | ||
| 359 | .host => |h| { | ||
| 360 | // The handoff recipe: ssh fetches the coordinates (and, on a | ||
| 361 | // cold attach, carries the session if QUIC cannot), while a | ||
| 362 | // warm attach dials from the cache and never spawns ssh at all. | ||
| 363 | // handoff.recipeFor owns both pieces; muxweb builds its HOST | ||
| 364 | // tiles from the same call. | ||
| 365 | const r = try handoff.recipeFor(alloc, h.name); | ||
| 366 | defer r.deinit(alloc); | ||
| 367 | return wallview.runAttach(alloc, .{ .hand = .{ | ||
| 368 | .host = h.name, | ||
| 369 | .ssh_cmd = r.ssh_cmd, | ||
| 370 | .cache_path = r.cache_path, | ||
| 371 | .idle_ms = h.idle_ms, | ||
| 372 | } }, h.session, null, h.idle_ms, h.agent); | ||
| 373 | }, | ||
| 374 | .attach => |t| { | ||
| 375 | if (t.via) |cmd| return wallview.runAttach( | ||
| 376 | alloc, | ||
| 377 | .{ .via = cmd }, | ||
| 378 | t.session, | ||
| 379 | null, | ||
| 380 | client.quic_idle_ms_default, | ||
| 381 | t.agent, | ||
| 382 | ); | ||
| 383 | const sock_path = if (t.sock) |s| | ||
| 384 | try alloc.dupe(u8, s) | ||
| 385 | else | ||
| 386 | sockpath.defaultSockPath(alloc) catch |err| switch (err) { | ||
| 387 | error.NoRuntimeDir => { | ||
| 388 | std.debug.print( | ||
| 389 | "mux: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n", | ||
| 390 | .{}, | ||
| 391 | ); | ||
| 392 | return 1; | ||
| 393 | }, | ||
| 394 | else => |e| return e, | ||
| 395 | }; | ||
| 396 | defer alloc.free(sock_path); | ||
| 397 | |||
| 398 | // Before the PATH search, before auto-start, before the dial: | ||
| 399 | // the refusal is about where this process is standing, and none | ||
| 400 | // of those three change the answer. Placed here rather than in | ||
| 401 | // parseArgs because the default socket path is resolved here, | ||
| 402 | // and a bare `mux` typed in a session shell is exactly the | ||
| 403 | // mistake this catches. It sits on the USER's attach only — | ||
| 404 | // the Ctrl-\ chords grow their tiles from inside the wall and | ||
| 405 | // never come back through this switch, so focusing from session | ||
| 406 | // 0 to session 1 keeps working. `Ctrl-\ :` is the one chord | ||
| 407 | // that takes a spelling, and it runs `wallview.showsSelf` | ||
| 408 | // itself. | ||
| 409 | if (insideThisSession( | ||
| 410 | std.posix.getenv(proto.sock_env), | ||
| 411 | std.posix.getenv(proto.session_env), | ||
| 412 | sock_path, | ||
| 413 | t.session, | ||
| 414 | )) { | ||
| 415 | std.debug.print("{s}", .{self_attach_refusal}); | ||
| 416 | return 2; | ||
| 417 | } | ||
| 418 | |||
| 419 | // The same `sun_path` guard muxd applies (main.zig), for the | ||
| 420 | // same reason and off the same constant. It sits before the | ||
| 421 | // PATH search rather than at the connect because auto-start would | ||
| 422 | // otherwise reach it first: mux finds muxd, spawns a child that | ||
| 423 | // refuses the path instantly, and polls the full 2s into "daemon | ||
| 424 | // did not answer" — a timeout story about a path that was doomed | ||
| 425 | // at parse. Refusing here costs nothing and says the real thing. | ||
| 426 | if (sock_path.len > sockpath.max_sun_path) { | ||
| 427 | std.debug.print( | ||
| 428 | "mux: socket path too long ({d} bytes, max {d}): {s}\n", | ||
| 429 | .{ sock_path.len, sockpath.max_sun_path, sock_path }, | ||
| 430 | ); | ||
| 431 | return 1; | ||
| 432 | } | ||
| 433 | |||
| 434 | // Attach auto-start: give the attach a daemon to land on. | ||
| 435 | // Unix-socket transport only — quic:// has nothing local to | ||
| 436 | // spawn, and --via's auto-starter is the remote proxy. | ||
| 437 | const muxd_path = try spawn.findInPath( | ||
| 438 | alloc, | ||
| 439 | std.posix.getenv("PATH") orelse "", | ||
| 440 | "muxd", | ||
| 441 | ); | ||
| 442 | defer if (muxd_path) |p| alloc.free(p); | ||
| 443 | if (muxd_path) |exe| { | ||
| 444 | if (!try spawn.ensureForAttach(alloc, exe, sock_path, "mux")) return 1; | ||
| 445 | } else if (!spawn.probe(sock_path)) { | ||
| 446 | // No muxd anywhere AND nothing serving: only now is the | ||
| 447 | // missing binary the user's problem, and both facts fit in | ||
| 448 | // one honest line. A live daemon needs no binary on PATH. | ||
| 449 | std.debug.print("mux: no daemon on {s} and no muxd in PATH to start one\n", .{sock_path}); | ||
| 450 | return 1; | ||
| 451 | } | ||
| 452 | return wallview.runAttach( | ||
| 453 | alloc, | ||
| 454 | .{ .sock = sock_path }, | ||
| 455 | t.session, | ||
| 456 | null, | ||
| 457 | client.quic_idle_ms_default, | ||
| 458 | t.agent, | ||
| 459 | ); | ||
| 460 | }, | ||
| 461 | } | ||
| 462 | } | ||
| 463 | |||
| 464 | /// `mux wall`: gather spellings (argv, or with none the saved wall — the | ||
| 465 | /// attach history mux itself writes, muxweb's too via argv and POST | ||
| 466 | /// /tiles), resolve each through the one grammar, hand the lot to | ||
| 467 | /// wallview.run. Resolution allocates into an arena because run() never | ||
| 468 | /// returns on the success path (it exits the process — see wallview.run); | ||
| 469 | /// only the early usage-error paths come back through the defers here. | ||
| 470 | fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 { | ||
| 471 | var arena_state = std.heap.ArenaAllocator.init(alloc); | ||
| 472 | defer arena_state.deinit(); | ||
| 473 | const arena = arena_state.allocator(); | ||
| 474 | |||
| 475 | // `add`/`rm` before the tile parse, exactly as `wall` itself sits | ||
| 476 | // before the flag parse: they are file edits, not a wall to show. The | ||
| 477 | // cost is that a tile spelled literally `add` can no longer be the | ||
| 478 | // first argument of `mux wall` — a hostname the wall grammar would | ||
| 479 | // otherwise accept — which is the ordinary price of a subcommand. | ||
| 480 | if (args.len > 0 and | ||
| 481 | (std.mem.eql(u8, args[0], "add") or std.mem.eql(u8, args[0], "rm"))) | ||
| 482 | return wallEdit(arena, args[0], args[1..]); | ||
| 483 | |||
| 484 | var key: ?[]const u8 = null; | ||
| 485 | var idle_ms: u32 = client.quic_idle_ms_default; | ||
| 486 | var spellings: std.ArrayList([]const u8) = .empty; | ||
| 487 | |||
| 488 | var i: usize = 0; | ||
| 489 | while (i < args.len) : (i += 1) { | ||
| 490 | const a = args[i]; | ||
| 491 | if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) { | ||
| 492 | i += 1; | ||
| 493 | key = args[i]; | ||
| 494 | } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) { | ||
| 495 | i += 1; | ||
| 496 | const n = std.fmt.parseInt(u32, args[i], 10) catch 0; | ||
| 497 | if (n == 0) { | ||
| 498 | std.debug.print("{s}", .{usage}); | ||
| 499 | return 2; | ||
| 500 | } | ||
| 501 | idle_ms = n; | ||
| 502 | } else { | ||
| 503 | // Every other argument is a tile. `--sock` is not one of this | ||
| 504 | // command's own flags, so wall may claim it and its path as | ||
| 505 | // one spelling — muxweb's dialect, accepted here too. | ||
| 506 | const n = wall.spellingFromArgv(arena, args, i) catch |err| switch (err) { | ||
| 507 | error.MissingSockPath => { | ||
| 508 | std.debug.print("mux: wall target '--sock' names no path\n", .{}); | ||
| 509 | return 2; | ||
| 510 | }, | ||
| 511 | error.FlagLikeTarget => { | ||
| 512 | std.debug.print( | ||
| 513 | "mux: wall takes targets, not flags: '{s}'\n{s}", | ||
| 514 | .{ args[i], usage }, | ||
| 515 | ); | ||
| 516 | return 2; | ||
| 517 | }, | ||
| 518 | else => |e| return e, | ||
| 519 | }; | ||
| 520 | i += n.consumed - 1; | ||
| 521 | try spellings.append(arena, n.spelling); | ||
| 522 | } | ||
| 523 | } | ||
| 524 | |||
| 525 | var from_file = false; | ||
| 526 | if (spellings.items.len == 0) { | ||
| 527 | const path = try wall.statePath(arena); | ||
| 528 | const w = try wall.load(arena, path); | ||
| 529 | for (w.targets.items) |t| try spellings.append(arena, t); | ||
| 530 | if (spellings.items.len == 0) { | ||
| 531 | std.debug.print("mux: wall is empty: name targets, or add tiles in muxweb\n", .{}); | ||
| 532 | return 2; | ||
| 533 | } | ||
| 534 | from_file = true; | ||
| 535 | } | ||
| 536 | |||
| 537 | const resolved = try arena.alloc(wallview.Resolved, spellings.items.len); | ||
| 538 | const env_sock = std.posix.getenv(proto.sock_env); | ||
| 539 | const env_session = std.posix.getenv(proto.session_env); | ||
| 540 | for (resolved, spellings.items) |*r, s| { | ||
| 541 | r.* = wallview.resolveSpelling(arena, s, xdg.pickKey(key, std.posix.getenv(key_env)), idle_ms) catch |err| { | ||
| 542 | std.debug.print("mux: bad wall target '{s}': {s}\n", .{ s, @errorName(err) }); | ||
| 543 | return 2; | ||
| 544 | }; | ||
| 545 | // A tile is a read-only attach, but it is still an attach: a stripe | ||
| 546 | // of the session the wall is running in paints into the grid it is | ||
| 547 | // reading, which is the same feedback loop with a nicer name. The | ||
| 548 | // WHOLE wall goes, not just the tile — dropping one silently would | ||
| 549 | // be a wall that lies about which sessions it is showing. | ||
| 550 | const tile_sock = switch (r.target) { | ||
| 551 | .sock => |p| p, | ||
| 552 | else => null, | ||
| 553 | }; | ||
| 554 | if (insideThisSession(env_sock, env_session, tile_sock, r.session)) { | ||
| 555 | std.debug.print("mux: wall target '{s}' is the session this shell is inside\n", .{s}); | ||
| 556 | std.debug.print("{s}", .{self_attach_refusal}); | ||
| 557 | return 2; | ||
| 558 | } | ||
| 559 | } | ||
| 560 | // The other door into the same program: `mux wall` opens on the wall, | ||
| 561 | // `mux TARGET` opens focused on the tile it just attached to. It does | ||
| 562 | // not hydrate — it has already read the file, or was handed the | ||
| 563 | // spellings it must show and no others — and it does need a terminal, | ||
| 564 | // because there is nothing to cut stripes from without one. | ||
| 565 | return wallview.run(arena, resolved, .{ .hydrated = from_file }); | ||
| 566 | } | ||
| 567 | |||
| 568 | /// Why one spelling cannot be a tile, in the words the hub already uses | ||
| 569 | /// (webhub_main.addSpelling): one grammar, one vocabulary for refusing it. | ||
| 570 | fn spellingReason(err: anyerror) []const u8 { | ||
| 571 | return switch (err) { | ||
| 572 | error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", | ||
| 573 | error.EmptySpec => "empty target", | ||
| 574 | error.BadByte => "control byte in target", | ||
| 575 | error.SockPathTooLong => "socket path too long to bind", | ||
| 576 | else => @errorName(err), | ||
| 577 | }; | ||
| 578 | } | ||
| 579 | |||
| 580 | /// `mux wall add|rm SPELLING...`: file operations only, neither verb dials. | ||
| 581 | fn wallEdit( | ||
| 582 | arena: std.mem.Allocator, | ||
| 583 | verb: []const u8, | ||
| 584 | args: []const [:0]const u8, | ||
| 585 | ) !u8 { | ||
| 586 | const adding = std.mem.eql(u8, verb, "add"); | ||
| 587 | |||
| 588 | var spellings: std.ArrayList([]const u8) = .empty; | ||
| 589 | var i: usize = 0; | ||
| 590 | while (i < args.len) : (i += 1) { | ||
| 591 | const n = wall.spellingFromArgv(arena, args, i) catch |err| switch (err) { | ||
| 592 | error.MissingSockPath => { | ||
| 593 | std.debug.print("mux: wall {s}: '--sock' names no path\n", .{verb}); | ||
| 594 | return 2; | ||
| 595 | }, | ||
| 596 | error.FlagLikeTarget => { | ||
| 597 | std.debug.print("mux: wall {s}: '{s}' is a flag, not a target\n", .{ verb, args[i] }); | ||
| 598 | return 2; | ||
| 599 | }, | ||
| 600 | else => |e| return e, | ||
| 601 | }; | ||
| 602 | i += n.consumed - 1; | ||
| 603 | try spellings.append(arena, n.spelling); | ||
| 604 | } | ||
| 605 | if (spellings.items.len == 0) { | ||
| 606 | std.debug.print("mux: wall {s}: name at least one target\n", .{verb}); | ||
| 607 | return 2; | ||
| 608 | } | ||
| 609 | |||
| 610 | // Every spelling is validated BEFORE any of them is written, and the | ||
| 611 | // file is written ONCE below: an IO error on the third of four must not | ||
| 612 | // leave the first two applied and the rest not. | ||
| 613 | if (adding) for (spellings.items) |s| { | ||
| 614 | // The grammar's own refusals, plus the one refusal that belongs to | ||
| 615 | // the transport rather than the grammar: a sun_path that cannot be | ||
| 616 | // bound is a tile that could never dial, and ADD time is the only | ||
| 617 | // moment the user is still looking at what they typed. | ||
| 618 | const p = wall.parseSpelling(s) catch |err| { | ||
| 619 | std.debug.print("mux: wall add: {s}: {s}\n", .{ s, spellingReason(err) }); | ||
| 620 | return 2; | ||
| 621 | }; | ||
| 622 | if (p.spec == .sock and p.spec.sock.len > sockpath.max_sun_path) { | ||
| 623 | std.debug.print("mux: wall add: {s}: {s}\n", .{ s, spellingReason(error.SockPathTooLong) }); | ||
| 624 | return 2; | ||
| 625 | } | ||
| 626 | }; | ||
| 627 | |||
| 628 | const path = try wall.statePath(arena); | ||
| 629 | var rc: u8 = 0; | ||
| 630 | |||
| 631 | if (adding) { | ||
| 632 | // Strict: growing a wall whose existing content is not understood | ||
| 633 | // would re-save garbage as if it had been read. | ||
| 634 | var w = wall.load(arena, path) catch |err| { | ||
| 635 | std.debug.print("mux: wall add: {s}: {s}\n", .{ path, @errorName(err) }); | ||
| 636 | return 1; | ||
| 637 | }; | ||
| 638 | for (spellings.items) |s| { | ||
| 639 | // Already there is not a failure: `add` states what the wall | ||
| 640 | // should contain, and afterwards it does. | ||
| 641 | var present = false; | ||
| 642 | for (w.targets.items) |t| { | ||
| 643 | if (std.mem.eql(u8, t, s)) present = true; | ||
| 644 | } | ||
| 645 | if (!present) _ = try w.add(arena, s); | ||
| 646 | } | ||
| 647 | wall.save(&w, path) catch |err| { | ||
| 648 | std.debug.print("mux: wall add: {s}: {s}\n", .{ path, @errorName(err) }); | ||
| 649 | return 1; | ||
| 650 | }; | ||
| 651 | return 0; | ||
| 652 | } | ||
| 653 | |||
| 654 | // Lenient, so a hand-edited line the grammar cannot parse can still be | ||
| 655 | // removed and the others survive it verbatim. | ||
| 656 | var lines = wall.loadLines(arena, path) catch |err| { | ||
| 657 | std.debug.print("mux: wall rm: {s}: {s}\n", .{ path, @errorName(err) }); | ||
| 658 | return 1; | ||
| 659 | }; | ||
| 660 | for (spellings.items) |s| { | ||
| 661 | var found = false; | ||
| 662 | for (lines.items, 0..) |t, at| { | ||
| 663 | if (!std.mem.eql(u8, t, s)) continue; | ||
| 664 | _ = lines.orderedRemove(at); | ||
| 665 | found = true; | ||
| 666 | break; | ||
| 667 | } | ||
| 668 | // Removing what is not there is reported and non-zero — a script | ||
| 669 | // that thinks it cleaned up a tile should learn it was spelled | ||
| 670 | // differently. The rest of the line still applies: the names that | ||
| 671 | // WERE on the wall come off it. | ||
| 672 | if (!found) { | ||
| 673 | std.debug.print("mux: wall rm: not on the wall: {s}\n", .{s}); | ||
| 674 | rc = 1; | ||
| 675 | } | ||
| 676 | } | ||
| 677 | wall.saveLines(lines.items, path) catch |err| { | ||
| 678 | std.debug.print("mux: wall rm: {s}: {s}\n", .{ path, @errorName(err) }); | ||
| 679 | return 1; | ||
| 680 | }; | ||
| 681 | return rc; | ||
| 682 | } | ||
| 683 | |||
| 684 | /// parseArgs takes what argsAlloc produces; the tests must match the type. | ||
| 685 | fn parse(comptime argv: []const [:0]const u8) ParseResult { | ||
| 686 | return parseArgs(argv, null); | ||
| 687 | } | ||
| 688 | |||
| 689 | /// The same, with `MUX_KEY_FILE` set to `env`. | ||
| 690 | fn parseEnv(comptime argv: []const [:0]const u8, env: ?[]const u8) ParseResult { | ||
| 691 | return parseArgs(argv, env); | ||
| 692 | } | ||
| 693 | |||
| 694 | test "parseArgs: no arguments means the default local socket" { | ||
| 695 | const r = parse(&.{"mux"}); | ||
| 696 | try std.testing.expect(r == .attach); | ||
| 697 | try std.testing.expect(r.attach.sock == null); | ||
| 698 | try std.testing.expect(r.attach.via == null); | ||
| 699 | } | ||
| 700 | |||
| 701 | test "parseArgs: --sock and --via each name their transport" { | ||
| 702 | const s = parse(&.{ "mux", "--sock", "/tmp/x.sock" }); | ||
| 703 | try std.testing.expect(s == .attach); | ||
| 704 | try std.testing.expectEqualStrings("/tmp/x.sock", s.attach.sock.?); | ||
| 705 | try std.testing.expect(s.attach.via == null); | ||
| 706 | |||
| 707 | const v = parse(&.{ "mux", "--via", "ssh box muxd proxy" }); | ||
| 708 | try std.testing.expect(v == .attach); | ||
| 709 | try std.testing.expectEqualStrings("ssh box muxd proxy", v.attach.via.?); | ||
| 710 | try std.testing.expect(v.attach.sock == null); | ||
| 711 | } | ||
| 712 | |||
| 713 | test "parseArgs: a bare word is a host to hop to" { | ||
| 714 | const h = parse(&.{ "mux", "vm1" }); | ||
| 715 | try std.testing.expect(h == .host); | ||
| 716 | try std.testing.expectEqualStrings("vm1", h.host.name); | ||
| 717 | // Spelled out rather than written `client.quic_idle_ms_default` — see | ||
| 718 | // the quic:// test for why asserting against the parser's own constant | ||
| 719 | // could never catch the number changing. | ||
| 720 | try std.testing.expectEqual(@as(u32, 15_000), h.host.idle_ms); | ||
| 721 | |||
| 722 | // The user@host form is just as bare a word; nothing parses inside it, | ||
| 723 | // which is what lets ssh's own config (aliases, ports, ProxyJump) keep | ||
| 724 | // working untouched. | ||
| 725 | const u = parse(&.{ "mux", "ubuntu@sandbox-9b70e9" }); | ||
| 726 | try std.testing.expect(u == .host); | ||
| 727 | try std.testing.expectEqualStrings("ubuntu@sandbox-9b70e9", u.host.name); | ||
| 728 | } | ||
| 729 | |||
| 730 | test "parseArgs: naming two transports is a conflict, however it is spelled" { | ||
| 731 | try std.testing.expect(parse(&.{ "mux", "vm1", "--sock", "/tmp/x.sock" }) == .conflict); | ||
| 732 | try std.testing.expect(parse(&.{ "mux", "--sock", "/tmp/x.sock", "vm1" }) == .conflict); | ||
| 733 | try std.testing.expect(parse(&.{ "mux", "vm1", "--via", "ssh box muxd proxy" }) == .conflict); | ||
| 734 | try std.testing.expect(parse(&.{ "mux", "--sock", "/a", "--via", "c" }) == .conflict); | ||
| 735 | // Two of the same kind is the same ambiguity as two different kinds. | ||
| 736 | try std.testing.expect(parse(&.{ "mux", "vm1", "vm2" }) == .conflict); | ||
| 737 | try std.testing.expect(parse(&.{ "mux", "--sock", "/a", "--sock", "/b" }) == .conflict); | ||
| 738 | } | ||
| 739 | |||
| 740 | test "parseArgs: unknown flags and valueless flags are usage errors" { | ||
| 741 | try std.testing.expect(parse(&.{ "mux", "--wat" }) == .usage_error); | ||
| 742 | try std.testing.expect(parse(&.{ "mux", "-x" }) == .usage_error); | ||
| 743 | // A flag whose value is missing must not be mistaken for a bare host. | ||
| 744 | // Every value-taking flag has to have a row here: the fall-through that | ||
| 745 | // catches a missing value is one `else` arm shared by all of them, so a | ||
| 746 | // flag added without a row here is a flag nobody actually checked. | ||
| 747 | inline for (.{ "--sock", "--via", "--key", "--quic-idle-ms", "--session" }) |flag| { | ||
| 748 | try std.testing.expect(parse(&.{ "mux", flag }) == .usage_error); | ||
| 749 | } | ||
| 750 | } | ||
| 751 | |||
| 752 | test "-A rides every transport spelling" { | ||
| 753 | try std.testing.expect(parse(&.{ "mux", "-A", "somehost" }).host.agent); | ||
| 754 | try std.testing.expect(parse(&.{ "mux", "-A", "--sock", "/tmp/x.sock" }).attach.agent); | ||
| 755 | try std.testing.expect(parse(&.{ "mux", "quic://h:1", "-A" }).quic.agent); | ||
| 756 | try std.testing.expect(!parse(&.{ "mux", "somehost" }).host.agent); | ||
| 757 | } | ||
| 758 | |||
| 759 | test "parseArgs: quic:// is a transport like any other" { | ||
| 760 | const q = parse(&.{ "mux", "quic://box:4433", "--key", "/k" }); | ||
| 761 | try std.testing.expect(q == .quic); | ||
| 762 | try std.testing.expectEqualStrings("box:4433", q.quic.host_port); | ||
| 763 | try std.testing.expectEqualStrings("/k", q.quic.key.?); | ||
| 764 | // Spelled out rather than written `client.quic_idle_ms_default`: | ||
| 765 | // asserting against the same constant the parser reads would hold for | ||
| 766 | // any value, so it could never catch the number changing. | ||
| 767 | try std.testing.expectEqual(@as(u32, 15_000), q.quic.idle_ms); | ||
| 768 | |||
| 769 | // Counted with the rest: naming it alongside another transport is the | ||
| 770 | // same ambiguity as any other pairing, whichever order they arrive in. | ||
| 771 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--sock", "/x" }) == .conflict); | ||
| 772 | try std.testing.expect(parse(&.{ "mux", "--sock", "/x", "quic://a:1", "--key", "/k" }) == .conflict); | ||
| 773 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--via", "ssh h" }) == .conflict); | ||
| 774 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "vm1" }) == .conflict); | ||
| 775 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "quic://b:2", "--key", "/k" }) == .conflict); | ||
| 776 | |||
| 777 | // The scheme with nothing after it names no host. | ||
| 778 | try std.testing.expect(parse(&.{ "mux", "quic://", "--key", "/k" }) == .usage_error); | ||
| 779 | } | ||
| 780 | |||
| 781 | test "parseArgs: a quic attach without a key defers to main, which resolves it" { | ||
| 782 | // No --key and no environment: not a refusal any more. main has a | ||
| 783 | // default path to try and parse cannot see the filesystem. | ||
| 784 | const q = parse(&.{ "mux", "quic://a:1" }); | ||
| 785 | try std.testing.expect(q == .quic); | ||
| 786 | try std.testing.expect(q.quic.key == null); | ||
| 787 | |||
| 788 | // Empty env var means unset, same as an empty --key would be nonsense. | ||
| 789 | const empty_env = parseEnv(&.{ "mux", "quic://a:1" }, ""); | ||
| 790 | try std.testing.expect(empty_env == .quic); | ||
| 791 | try std.testing.expect(empty_env.quic.key == null); | ||
| 792 | |||
| 793 | // The environment supplies it when the flag does not... | ||
| 794 | const e = parseEnv(&.{ "mux", "quic://a:1" }, "/env.key"); | ||
| 795 | try std.testing.expect(e == .quic); | ||
| 796 | try std.testing.expectEqualStrings("/env.key", e.quic.key.?); | ||
| 797 | |||
| 798 | // ...and the flag wins when both are there, because it is the more | ||
| 799 | // specific statement of intent. | ||
| 800 | const both = parseEnv(&.{ "mux", "quic://a:1", "--key", "/flag.key" }, "/env.key"); | ||
| 801 | try std.testing.expectEqualStrings("/flag.key", both.quic.key.?); | ||
| 802 | |||
| 803 | // A key with no quic:// is ignored rather than refused: MUX_KEY_FILE | ||
| 804 | // exported in a shell must not break an ordinary local attach. | ||
| 805 | try std.testing.expect(parseEnv(&.{"mux"}, "/env.key") == .attach); | ||
| 806 | try std.testing.expect(parse(&.{ "mux", "--key", "/k" }) == .attach); | ||
| 807 | try std.testing.expect(parse(&.{ "mux", "--key", "/k", "vm1" }) == .host); | ||
| 808 | } | ||
| 809 | |||
| 810 | test "parseArgs: --quic-idle-ms parses, and refuses what ngtcp2 would invert" { | ||
| 811 | const t = parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "1500" }); | ||
| 812 | try std.testing.expectEqual(@as(u32, 1500), t.quic.idle_ms); | ||
| 813 | |||
| 814 | // A bare HOST ends in a QUIC link too, so the flag has to reach it — | ||
| 815 | // the .host result carried no idle_ms at all and the flag was accepted | ||
| 816 | // and then dropped, which is worse than refusing it. muxweb's HOST | ||
| 817 | // tiles were already right; this is mux catching up. | ||
| 818 | const h = parse(&.{ "mux", "vm1", "--quic-idle-ms", "1500" }); | ||
| 819 | try std.testing.expect(h == .host); | ||
| 820 | try std.testing.expectEqual(@as(u32, 1500), h.host.idle_ms); | ||
| 821 | try std.testing.expect(parse(&.{ "mux", "vm1", "--quic-idle-ms", "0" }) == .usage_error); | ||
| 822 | |||
| 823 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "0" }) == .usage_error); | ||
| 824 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "soon" }) == .usage_error); | ||
| 825 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "99999999999" }) == .usage_error); | ||
| 826 | // The bare-flag/missing-value case is covered once, for every | ||
| 827 | // value-taking flag, by the valueless-flags sweep above. | ||
| 828 | } | ||
| 829 | |||
| 830 | test "parseArgs: --version wins wherever it appears" { | ||
| 831 | try std.testing.expect(parse(&.{ "mux", "--version" }) == .version); | ||
| 832 | try std.testing.expect(parse(&.{ "mux", "--sock", "/x", "--version" }) == .version); | ||
| 833 | } | ||
| 834 | |||
| 835 | test "parseArgs: --session rides every transport spelling" { | ||
| 836 | const s = parse(&.{ "mux", "--session", "b", "--sock", "/tmp/x.sock" }); | ||
| 837 | try std.testing.expectEqualStrings("b", s.attach.session); | ||
| 838 | const h = parse(&.{ "mux", "somehost", "--session", "b" }); | ||
| 839 | try std.testing.expectEqualStrings("b", h.host.session); | ||
| 840 | const q = parse(&.{ "mux", "quic://h:1", "--session", "b" }); | ||
| 841 | try std.testing.expectEqualStrings("b", q.quic.session); | ||
| 842 | } | ||
| 843 | |||
| 844 | test "parseArgs: a bad --session is a usage error, not a wire experiment" { | ||
| 845 | const r = parse(&.{ "mux", "--session", "has space" }); | ||
| 846 | try std.testing.expect(r == .usage_error); | ||
| 847 | } | ||
| 848 | |||
| 849 | test "parseArgs: no --session means the empty wire name (older-daemon compat)" { | ||
| 850 | const s = parse(&.{"mux"}); | ||
| 851 | try std.testing.expectEqualStrings("", s.attach.session); | ||
| 852 | } | ||
| 853 | |||
| 854 | test "insideThisSession: only the exact socket-and-session pair is the loop" { | ||
| 855 | const sock = "/run/user/1000/muxd.sock"; | ||
| 856 | |||
| 857 | // The incident, both spellings of the default session: `mux` typed in a | ||
| 858 | // shell of session 0, and `mux --session 0` typed in the same shell. | ||
| 859 | try std.testing.expect(insideThisSession(sock, "0", sock, "")); | ||
| 860 | try std.testing.expect(insideThisSession(sock, "0", sock, "0")); | ||
| 861 | try std.testing.expect(insideThisSession(sock, "work", sock, "work")); | ||
| 862 | |||
| 863 | // A different session of the SAME daemon is the useful case and must | ||
| 864 | // stay allowed — only the self-pair feeds its own paint back. | ||
| 865 | try std.testing.expect(!insideThisSession(sock, "0", sock, "1")); | ||
| 866 | try std.testing.expect(!insideThisSession(sock, "work", sock, "")); | ||
| 867 | |||
| 868 | // Same session name, different daemon: names are per-daemon, so this is | ||
| 869 | // two unrelated sessions that happen to agree on a word. | ||
| 870 | try std.testing.expect(!insideThisSession(sock, "0", "/run/user/1000/other.sock", "0")); | ||
| 871 | |||
| 872 | // Nothing planted, or emptied to override: no refusal either way. | ||
| 873 | try std.testing.expect(!insideThisSession(null, "0", sock, "0")); | ||
| 874 | try std.testing.expect(!insideThisSession(sock, null, sock, "0")); | ||
| 875 | try std.testing.expect(!insideThisSession("", "0", sock, "0")); | ||
| 876 | try std.testing.expect(!insideThisSession(sock, "", sock, "0")); | ||
| 877 | |||
| 878 | // A host or quic:// target has no unix socket to compare, and a remote | ||
| 879 | // daemon is a different daemon whatever its sessions are called — the | ||
| 880 | // call sites pass null for those, so null must never refuse. | ||
| 881 | try std.testing.expect(!insideThisSession(sock, "0", null, "0")); | ||
| 882 | } | ||
| 883 | |||
| 884 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 885 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 886 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 887 | test { | ||
| 888 | std.testing.refAllDeclsRecursive(@This()); | ||
| 889 | } | ||
| 890 | |||
| 891 | /// A socket that answers the way a real ssh-agent does, or one that hangs | ||
| 892 | /// up the way the daemon does for a session nobody has offered an agent to. | ||
| 893 | /// A thread because the probe is a round trip — it writes before it reads, | ||
| 894 | /// so a listener nobody is accepting on cannot play either part. | ||
| 895 | const AgentStub = struct { | ||
| 896 | listener: *std.net.Server, | ||
| 897 | answer: bool, | ||
| 898 | /// Set only when the exact bytes `ssh-add -l` sends arrived. Asserted | ||
| 899 | /// by the test, because every other expectation here is also satisfied | ||
| 900 | /// by a probe that asks nothing and times out. | ||
| 901 | asked: bool = false, | ||
| 902 | |||
| 903 | fn run(self: *AgentStub) void { | ||
| 904 | const conn = self.listener.accept() catch return; | ||
| 905 | defer std.posix.close(conn.stream.handle); | ||
| 906 | if (!self.answer) return; | ||
| 907 | var buf: [64]u8 = undefined; | ||
| 908 | const n = std.posix.read(conn.stream.handle, &buf) catch return; | ||
| 909 | // Spelled out rather than compared against `agent_request_identities`: | ||
| 910 | // the constant IS what is under test, and a test that reads it back | ||
| 911 | // would accept any bytes the client decided to send. | ||
| 912 | self.asked = std.mem.eql(u8, buf[0..n], &[_]u8{ 0, 0, 0, 1, 11 }); | ||
| 913 | if (!self.asked) return; | ||
| 914 | // SSH_AGENT_IDENTITIES_ANSWER carrying zero keys. An agent holding | ||
| 915 | // nothing still proves an agent is there, which is the whole | ||
| 916 | // question — the preflight never looks at the key list. | ||
| 917 | const reply = [_]u8{ 0, 0, 0, 5, 12, 0, 0, 0, 0 }; | ||
| 918 | _ = std.posix.write(conn.stream.handle, &reply) catch {}; | ||
| 919 | } | ||
| 920 | }; | ||
| 921 | |||
| 922 | test "agentReachable: an agent answers; a socket that hangs up is not one" { | ||
| 923 | // The states a user is actually in: an agent running, the daemon's own | ||
| 924 | // per-session socket with nobody offering behind it, a variable | ||
| 925 | // pointing at an agent that has died, and no variable at all. Only the | ||
| 926 | // first may attach with `-A`. | ||
| 927 | var tmp = try TmpDir.make(); | ||
| 928 | defer tmp.cleanup(); | ||
| 929 | var buf: [128]u8 = undefined; | ||
| 930 | const sock = try std.fmt.bufPrintZ(&buf, "{s}/agent.sock", .{tmp.path()}); | ||
| 931 | |||
| 932 | { | ||
| 933 | const addr = try std.net.Address.initUnix(sock); | ||
| 934 | var listener = try addr.listen(.{}); | ||
| 935 | defer listener.deinit(); | ||
| 936 | var stub = AgentStub{ .listener = &listener, .answer = true }; | ||
| 937 | const th = try std.Thread.spawn(.{}, AgentStub.run, .{&stub}); | ||
| 938 | const reachable = agentReachable(sock); | ||
| 939 | th.join(); | ||
| 940 | try std.testing.expect(reachable); | ||
| 941 | try std.testing.expect(stub.asked); | ||
| 942 | } | ||
| 943 | std.fs.deleteFileAbsolute(sock) catch {}; | ||
| 944 | |||
| 945 | // The case a bare connect cannot see, and the reason this is a request | ||
| 946 | // and not a dial: inside a session `SSH_AUTH_SOCK` names the DAEMON, | ||
| 947 | // which accepts every connection and only then decides it has no | ||
| 948 | // client to route it to. The connect succeeds; the exchange does not. | ||
| 949 | { | ||
| 950 | const addr = try std.net.Address.initUnix(sock); | ||
| 951 | var listener = try addr.listen(.{}); | ||
| 952 | defer listener.deinit(); | ||
| 953 | var stub = AgentStub{ .listener = &listener, .answer = false }; | ||
| 954 | const th = try std.Thread.spawn(.{}, AgentStub.run, .{&stub}); | ||
| 955 | defer th.join(); | ||
| 956 | try std.testing.expect(!agentReachable(sock)); | ||
| 957 | } | ||
| 958 | std.fs.deleteFileAbsolute(sock) catch {}; | ||
| 959 | |||
| 960 | // Fail OPEN on silence, closed only on a hangup. A listener nobody is | ||
| 961 | // accepting on is the shape a slow or wedged agent presents, and a | ||
| 962 | // slow agent is still an agent; the daemon's refusal is immediate, so | ||
| 963 | // taking too long is not what separates the two. | ||
| 964 | { | ||
| 965 | const addr = try std.net.Address.initUnix(sock); | ||
| 966 | var listener = try addr.listen(.{}); | ||
| 967 | defer listener.deinit(); | ||
| 968 | try std.testing.expect(agentReachable(sock)); | ||
| 969 | } | ||
| 970 | |||
| 971 | // The stale case, and the reason this dials at all rather than reading | ||
| 972 | // the variable: the agent is gone but its socket FILE is still there, | ||
| 973 | // so the path stats fine and the connect is refused. `deinit` closed | ||
| 974 | // the listener above without unlinking, which is exactly what a killed | ||
| 975 | // agent leaves behind. | ||
| 976 | try std.fs.accessAbsolute(sock, .{}); | ||
| 977 | try std.testing.expect(!agentReachable(sock)); | ||
| 978 | |||
| 979 | // And the two cheaper absences, so every one of a user's states is | ||
| 980 | // covered by the one probe. | ||
| 981 | std.fs.deleteFileAbsolute(sock) catch {}; | ||
| 982 | try std.testing.expect(!agentReachable(sock)); | ||
| 983 | try std.testing.expect(!agentReachable("")); | ||
| 984 | } | ||
src/cli/muxa.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,1864 @@ | |||
| 1 | //! muxa: the agent-facing mux client. Every verb prints one JSON object on | ||
| 2 | //! stdout and exits 0 on success; failures print {"error": "..."} and exit | ||
| 3 | //! nonzero. Attaches at 0x0 always — an agent must never claim the grid | ||
| 4 | //! out from under the human's size (load-bearing spec rule). | ||
| 5 | //! | ||
| 6 | //! The exit codes, and there are only these five: | ||
| 7 | //! | ||
| 8 | //! 0 the object on stdout is the answer | ||
| 9 | //! 1 the object on stdout is an `{"error":...,"detail":...}` | ||
| 10 | //! 2 the arguments did not parse — usage on stderr, nothing on stdout | ||
| 11 | //! 3 the wait timed out; the object says `"reason":"timeout"` | ||
| 12 | //! 4 the object could not be written at all (see `emit`) | ||
| 13 | //! | ||
| 14 | //! A command's own exit code is never muxa's: it is the `exit_code` field, | ||
| 15 | //! and a `run` whose command failed still exits 0 because the question was | ||
| 16 | //! answered. | ||
| 17 | const std = @import("std"); | ||
| 18 | const proto = @import("protocol"); | ||
| 19 | const sockpath = @import("sockpath"); | ||
| 20 | const quic_client = @import("quic_client"); | ||
| 21 | const quic = @import("quic"); | ||
| 22 | const xdg = @import("xdg"); | ||
| 23 | |||
| 24 | const usage = | ||
| 25 | \\usage: muxa <verb> [--sock PATH | --quic HOST[:PORT] [--key PATH]] | ||
| 26 | \\ [--settle MS] [--timeout MS] [--vt] [--session NAME] [args] | ||
| 27 | \\NAME must already exist: muxa attaches at 0x0 and never creates a session | ||
| 28 | \\(`capture` is the exception that stays quiet: it answers in the grid). | ||
| 29 | \\verbs: | ||
| 30 | \\ status session snapshot as JSON | ||
| 31 | \\ capture current grid as text (--vt for styled) | ||
| 32 | \\ send BYTES raw bytes to the pty (C-style escapes: \n \r \t \e \xNN) | ||
| 33 | \\ run CMDLINE send CMDLINE + newline, await return, report exit/output | ||
| 34 | \\ await wait for the current/next command to return | ||
| 35 | \\ | ||
| 36 | ; | ||
| 37 | |||
| 38 | const Opts = struct { | ||
| 39 | verb: enum { status, capture, send, run, await }, | ||
| 40 | sock: ?[]const u8 = null, | ||
| 41 | /// `HOST[:PORT]` of a remote daemon's QUIC listener. The verbs are | ||
| 42 | /// identical over it — same frames, same JSON — which is the whole | ||
| 43 | /// claim: an agent driving a session over a WAN types one more flag. | ||
| 44 | quic: ?[]const u8 = null, | ||
| 45 | /// `--key PATH`, the highest-priority spelling of the QUIC key. Null | ||
| 46 | /// does NOT mean "no key": `$MUX_KEY_FILE` and the XDG default are | ||
| 47 | /// still to be tried, and neither is parse's to look at (xdg.pickKey | ||
| 48 | /// and xdg.resolveKeyPath own that order here as they do for mux). | ||
| 49 | key: ?[]const u8 = null, | ||
| 50 | settle_ms: u32 = 0, | ||
| 51 | // Never 0 by default: the daemon reads a 0 timeout on await_req as "no | ||
| 52 | // bound at all" (documented on AwaitReq), so a muxa that defaulted to 0 | ||
| 53 | // would turn every await into an unbounded wait. | ||
| 54 | timeout_ms: u32 = 30_000, | ||
| 55 | vt: bool = false, | ||
| 56 | /// Which session every verb this invocation makes asks about — the | ||
| 57 | /// attach it opens with AND every ask that follows carry the same | ||
| 58 | /// name, which is what keeps the daemon's attached-tail equality rule | ||
| 59 | /// (server.zig) from ever seeing a mismatch out of this binary. Empty | ||
| 60 | /// is the wire's own default spelling, so a bare `muxa status` builds | ||
| 61 | /// byte-identical frames to before this flag existed. | ||
| 62 | session: []const u8 = "", | ||
| 63 | arg: ?[]const u8 = null, | ||
| 64 | }; | ||
| 65 | |||
| 66 | fn parseArgs(args: []const [:0]const u8) ?Opts { | ||
| 67 | if (args.len < 2) return null; | ||
| 68 | const verb = std.meta.stringToEnum(@FieldType(Opts, "verb"), args[1]) orelse return null; | ||
| 69 | var o: Opts = .{ .verb = verb }; | ||
| 70 | var i: usize = 2; | ||
| 71 | // Everything after a bare `--` is the positional argument, whatever it | ||
| 72 | // looks like. Agents send byte-strings for their own reasons, and | ||
| 73 | // `muxa send -- '-n foo\n'` must reach the pty rather than be read as | ||
| 74 | // a flag this binary does not have. | ||
| 75 | var end_of_flags = false; | ||
| 76 | while (i < args.len) : (i += 1) { | ||
| 77 | const a = args[i]; | ||
| 78 | if (end_of_flags) { | ||
| 79 | if (o.arg != null) return null; | ||
| 80 | o.arg = a; | ||
| 81 | } else if (std.mem.eql(u8, a, "--")) { | ||
| 82 | end_of_flags = true; | ||
| 83 | } else if (std.mem.eql(u8, a, "--sock")) { | ||
| 84 | i += 1; | ||
| 85 | if (i >= args.len) return null; | ||
| 86 | o.sock = args[i]; | ||
| 87 | } else if (std.mem.eql(u8, a, "--quic")) { | ||
| 88 | i += 1; | ||
| 89 | if (i >= args.len) return null; | ||
| 90 | o.quic = args[i]; | ||
| 91 | } else if (std.mem.eql(u8, a, "--key")) { | ||
| 92 | i += 1; | ||
| 93 | if (i >= args.len) return null; | ||
| 94 | o.key = args[i]; | ||
| 95 | } else if (std.mem.eql(u8, a, "--settle")) { | ||
| 96 | i += 1; | ||
| 97 | if (i >= args.len) return null; | ||
| 98 | o.settle_ms = std.fmt.parseInt(u32, args[i], 10) catch return null; | ||
| 99 | } else if (std.mem.eql(u8, a, "--timeout")) { | ||
| 100 | i += 1; | ||
| 101 | if (i >= args.len) return null; | ||
| 102 | o.timeout_ms = std.fmt.parseInt(u32, args[i], 10) catch return null; | ||
| 103 | } else if (std.mem.eql(u8, a, "--vt")) { | ||
| 104 | o.vt = true; | ||
| 105 | } else if (std.mem.eql(u8, a, "--session")) { | ||
| 106 | i += 1; | ||
| 107 | if (i >= args.len) return null; | ||
| 108 | // Refused here rather than carried to the wire as a payload | ||
| 109 | // nothing could ever look up: usage exit (2), not a frame. | ||
| 110 | if (!proto.validSessionName(args[i])) return null; | ||
| 111 | o.session = args[i]; | ||
| 112 | } else if (o.arg == null and a.len > 0 and a[0] != '-') { | ||
| 113 | o.arg = a; | ||
| 114 | } else return null; | ||
| 115 | } | ||
| 116 | // Name ONE transport. A `--sock` silently ignored beside a `--quic` | ||
| 117 | // would send an agent's frames somewhere other than the socket it | ||
| 118 | // named, and the two answers differ — this is the mistake `mux` | ||
| 119 | // refuses as `.conflict` for the same reason. | ||
| 120 | if (o.quic != null and o.sock != null) return null; | ||
| 121 | // A key with nothing to authenticate to, refused exactly where muxd | ||
| 122 | // refuses it: there is no reading of `--key` without `--quic` that | ||
| 123 | // makes it sensible, and the unix socket has no key at all. | ||
| 124 | if (o.key != null and o.quic == null) return null; | ||
| 125 | return o; | ||
| 126 | } | ||
| 127 | |||
| 128 | /// JSON string escape, the six mandatory escapes + control bytes as \u00XX. | ||
| 129 | fn jsonEscape(writer: anytype, s: []const u8) !void { | ||
| 130 | try writer.writeByte('"'); | ||
| 131 | for (s) |b| switch (b) { | ||
| 132 | '"' => try writer.writeAll("\\\""), | ||
| 133 | '\\' => try writer.writeAll("\\\\"), | ||
| 134 | '\n' => try writer.writeAll("\\n"), | ||
| 135 | '\r' => try writer.writeAll("\\r"), | ||
| 136 | '\t' => try writer.writeAll("\\t"), | ||
| 137 | 0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f => try writer.print("\\u{x:0>4}", .{b}), | ||
| 138 | else => try writer.writeByte(b), | ||
| 139 | }; | ||
| 140 | try writer.writeByte('"'); | ||
| 141 | } | ||
| 142 | |||
| 143 | test "jsonEscape pins the escapes" { | ||
| 144 | var buf: [128]u8 = undefined; | ||
| 145 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 146 | try jsonEscape(fbs.writer(), "a\"b\\c\nd\x1be"); | ||
| 147 | try std.testing.expectEqualStrings("\"a\\\"b\\\\c\\nd\\u001be\"", fbs.getWritten()); | ||
| 148 | } | ||
| 149 | |||
| 150 | /// Decode C-style escapes for `send`. Caller frees. | ||
| 151 | fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 { | ||
| 152 | var out: std.ArrayList(u8) = .empty; | ||
| 153 | errdefer out.deinit(alloc); | ||
| 154 | var i: usize = 0; | ||
| 155 | while (i < s.len) : (i += 1) { | ||
| 156 | if (s[i] != '\\') { | ||
| 157 | try out.append(alloc, s[i]); | ||
| 158 | continue; | ||
| 159 | } | ||
| 160 | // A backslash with nothing after it is an unfinished escape, and it | ||
| 161 | // is refused like any other one we cannot read (\q). Passing it | ||
| 162 | // through as a literal would be the single case where a typo in an | ||
| 163 | // escape reaches the pty instead of being reported. | ||
| 164 | if (i + 1 >= s.len) return error.BadEscape; | ||
| 165 | i += 1; | ||
| 166 | switch (s[i]) { | ||
| 167 | 'n' => try out.append(alloc, '\n'), | ||
| 168 | 'r' => try out.append(alloc, '\r'), | ||
| 169 | 't' => try out.append(alloc, '\t'), | ||
| 170 | 'e' => try out.append(alloc, 0x1b), | ||
| 171 | '\\' => try out.append(alloc, '\\'), | ||
| 172 | 'x' => { | ||
| 173 | if (i + 2 >= s.len) return error.BadEscape; | ||
| 174 | try out.append(alloc, try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16)); | ||
| 175 | i += 2; | ||
| 176 | }, | ||
| 177 | else => return error.BadEscape, | ||
| 178 | } | ||
| 179 | } | ||
| 180 | return out.toOwnedSlice(alloc); | ||
| 181 | } | ||
| 182 | |||
| 183 | test "decodeEscapes covers the sequences send needs" { | ||
| 184 | const alloc = std.testing.allocator; | ||
| 185 | const got = try decodeEscapes(alloc, "q\\n\\e[A\\x03"); | ||
| 186 | defer alloc.free(got); | ||
| 187 | try std.testing.expectEqualSlices(u8, "q\n\x1b[A\x03", got); | ||
| 188 | try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "\\q")); | ||
| 189 | // A dangling backslash is an escape the caller did not finish writing, | ||
| 190 | // and it is refused rather than passed through as a literal. | ||
| 191 | try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "ok\\")); | ||
| 192 | } | ||
| 193 | |||
| 194 | test "parseArgs verbs and flags" { | ||
| 195 | const a1 = [_][:0]const u8{ "muxa", "status" }; | ||
| 196 | try std.testing.expectEqual(@FieldType(Opts, "verb").status, parseArgs(&a1).?.verb); | ||
| 197 | const a2 = [_][:0]const u8{ "muxa", "run", "--timeout", "5000", "make test" }; | ||
| 198 | const o2 = parseArgs(&a2).?; | ||
| 199 | try std.testing.expectEqual(@as(u32, 5000), o2.timeout_ms); | ||
| 200 | try std.testing.expectEqualStrings("make test", o2.arg.?); | ||
| 201 | const a3 = [_][:0]const u8{ "muxa", "bogus" }; | ||
| 202 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&a3)); | ||
| 203 | } | ||
| 204 | |||
| 205 | test "parseArgs: -- hands the rest to the verb, flags and all" { | ||
| 206 | // Without the end-of-flags marker this is an unknown flag and the whole | ||
| 207 | // invocation is refused — the exact shape an agent sends when a key | ||
| 208 | // sequence starts with a dash. | ||
| 209 | const dashed = [_][:0]const u8{ "muxa", "send", "-n foo" }; | ||
| 210 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dashed)); | ||
| 211 | |||
| 212 | const a = [_][:0]const u8{ "muxa", "send", "--settle", "50", "--", "-n foo" }; | ||
| 213 | const o = parseArgs(&a).?; | ||
| 214 | try std.testing.expectEqual(@as(u32, 50), o.settle_ms); | ||
| 215 | try std.testing.expectEqualStrings("-n foo", o.arg.?); | ||
| 216 | |||
| 217 | // Past the marker, a flag spelling is just text — and a second | ||
| 218 | // positional is still one too many. | ||
| 219 | const flagish = [_][:0]const u8{ "muxa", "run", "--", "--timeout" }; | ||
| 220 | try std.testing.expectEqualStrings("--timeout", parseArgs(&flagish).?.arg.?); | ||
| 221 | const two = [_][:0]const u8{ "muxa", "run", "--", "a", "b" }; | ||
| 222 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&two)); | ||
| 223 | } | ||
| 224 | |||
| 225 | test "muxa: --session rides every verb; a bad name is usage, not wire bytes" { | ||
| 226 | const a = [_][:0]const u8{ "muxa", "status", "--session", "b" }; | ||
| 227 | const o = parseArgs(&a).?; | ||
| 228 | try std.testing.expectEqualStrings("b", o.session); | ||
| 229 | |||
| 230 | // No --session named: the wire's own default spelling, empty. | ||
| 231 | const bare = [_][:0]const u8{ "muxa", "status" }; | ||
| 232 | try std.testing.expectEqualStrings("", parseArgs(&bare).?.session); | ||
| 233 | |||
| 234 | // A name no tool could ever address is refused at parse (the usage | ||
| 235 | // exit, 2) rather than reaching a daemon as a payload nothing can | ||
| 236 | // look up. | ||
| 237 | const bad = [_][:0]const u8{ "muxa", "status", "--session", "has space" }; | ||
| 238 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&bad)); | ||
| 239 | |||
| 240 | // Dangling like every other value-taking flag. | ||
| 241 | const dangling = [_][:0]const u8{ "muxa", "status", "--session" }; | ||
| 242 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling)); | ||
| 243 | } | ||
| 244 | |||
| 245 | test "parseArgs: --quic and --key, and the pairs that make no sense" { | ||
| 246 | const q = [_][:0]const u8{ "muxa", "status", "--quic", "10.0.0.2:4433" }; | ||
| 247 | const oq = parseArgs(&q).?; | ||
| 248 | try std.testing.expectEqualStrings("10.0.0.2:4433", oq.quic.?); | ||
| 249 | // Not naming a key is not an error here: MUX_KEY_FILE and the XDG | ||
| 250 | // default are still to be tried, and parse may look at neither. | ||
| 251 | try std.testing.expectEqual(@as(?[]const u8, null), oq.key); | ||
| 252 | |||
| 253 | const k = [_][:0]const u8{ "muxa", "run", "--quic", "box:4433", "--key", "/k", "make test" }; | ||
| 254 | const ok = parseArgs(&k).?; | ||
| 255 | try std.testing.expectEqualStrings("box:4433", ok.quic.?); | ||
| 256 | try std.testing.expectEqualStrings("/k", ok.key.?); | ||
| 257 | try std.testing.expectEqualStrings("make test", ok.arg.?); | ||
| 258 | |||
| 259 | // A flag at the end of argv with no value is refused, like every other | ||
| 260 | // value-taking flag this parser has. | ||
| 261 | const dangling_q = [_][:0]const u8{ "muxa", "status", "--quic" }; | ||
| 262 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling_q)); | ||
| 263 | const dangling_k = [_][:0]const u8{ "muxa", "status", "--quic", "b:1", "--key" }; | ||
| 264 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling_k)); | ||
| 265 | |||
| 266 | // Two transports named at once: which one an agent's frames went to | ||
| 267 | // would be this parser's private business, and it is not entitled to | ||
| 268 | // one — the same refusal `mux` spells as `.conflict`. | ||
| 269 | const both = [_][:0]const u8{ "muxa", "status", "--sock", "/tmp/s", "--quic", "b:1" }; | ||
| 270 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&both)); | ||
| 271 | |||
| 272 | // A key with nothing to authenticate to, refused exactly where muxd | ||
| 273 | // refuses it. | ||
| 274 | const lonely_key = [_][:0]const u8{ "muxa", "status", "--key", "/k" }; | ||
| 275 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&lonely_key)); | ||
| 276 | |||
| 277 | // Neither named is the ordinary local case and stays silent. | ||
| 278 | const neither = [_][:0]const u8{ "muxa", "status" }; | ||
| 279 | try std.testing.expectEqual(@as(?[]const u8, null), parseArgs(&neither).?.quic); | ||
| 280 | } | ||
| 281 | |||
| 282 | /// A live QUIC connection plus everything a REDIAL of it needs. The dial | ||
| 283 | /// coordinates are kept rather than re-derived because the reconnect below | ||
| 284 | /// happens mid-verb, long after argv and the key file have been read: a | ||
| 285 | /// second resolution could pick a different key (the file having been | ||
| 286 | /// rotated under us) and would then fail the handshake for a reason that | ||
| 287 | /// has nothing to do with why the first connection died. | ||
| 288 | const Quic = struct { | ||
| 289 | cl: *quic_client.Client, | ||
| 290 | addr: std.net.Address, | ||
| 291 | key: quic_client.Key, | ||
| 292 | idle_ms: u32, | ||
| 293 | /// Wall-clock milliseconds the FIRST handshake took, which is this | ||
| 294 | /// client's only measurement of how far away the daemon is. `graceMs` | ||
| 295 | /// turns it into the await grace window; see there. | ||
| 296 | connect_ms: i64, | ||
| 297 | /// The one reconnect, spent or not. It lives HERE rather than on Conn | ||
| 298 | /// because only this arm can reconnect: a socket Conn carrying a | ||
| 299 | /// `reconnected` flag would be a field with no reachable true, and the | ||
| 300 | /// guard reading it would be re-establishing in code what the type can | ||
| 301 | /// state outright. (`session_exit` stays on Conn for the mirror | ||
| 302 | /// reason: both arms genuinely set it.) | ||
| 303 | reconnected: bool = false, | ||
| 304 | }; | ||
| 305 | |||
| 306 | const Conn = struct { | ||
| 307 | /// Verbs are transport-blind; `--quic` chooses here. | ||
| 308 | link: union(enum) { | ||
| 309 | fd: std.posix.fd_t, | ||
| 310 | quic: Quic, | ||
| 311 | }, | ||
| 312 | /// The allocator the transport itself works with: the QUIC arm's frame | ||
| 313 | /// staging and its redials. Distinct from the `alloc` awaitFrame takes, | ||
| 314 | /// which owns the frame handed BACK to the caller — one process, one | ||
| 315 | /// arena, so they are the same allocator today and separate in the | ||
| 316 | /// signature because they answer to different owners. | ||
| 317 | alloc: std.mem.Allocator, | ||
| 318 | /// Whether a snapshot has arrived since the last attach, which is the | ||
| 319 | /// ONLY thing on the wire that tells a refused attach from a session | ||
| 320 | /// that ended: the daemon spells both as `exit_status 1` and closes | ||
| 321 | /// (server.zig's attach arm refuses a 0x0 join of a name it does not | ||
| 322 | /// hold). A served attach always sends the snapshot first, so an | ||
| 323 | /// `exit_status` before one cannot be an ending. Same rule the CLI wall | ||
| 324 | /// reads (wallview.zig's `state_since_attach`), and no wire change. | ||
| 325 | saw_snapshot: bool = false, | ||
| 326 | /// The code from the `exit_status` frame that ended a wait, set the | ||
| 327 | /// moment awaitFrame returns error.SessionExited. The frame is the | ||
| 328 | /// session's last word and carries the only copy of the code, so it is | ||
| 329 | /// captured here rather than thrown away with the frame; callers read | ||
| 330 | /// it to turn the error into an answer. | ||
| 331 | session_exit: ?u8 = null, | ||
| 332 | /// Why the reconnect could not be made, set the moment a redial fails | ||
| 333 | /// — and set for the same reason `session_exit` is: the error that | ||
| 334 | /// ends the verb is `ConnectionLost`, which is the story's beginning, | ||
| 335 | /// while THIS is how it finished. An agent told only | ||
| 336 | /// `QuicHandshakeFailed` goes and checks its key; an agent told | ||
| 337 | /// `connection lost; reconnect failed: QuicHandshakeFailed` knows the | ||
| 338 | /// path tore mid-wait and the redial could not complete. | ||
| 339 | /// | ||
| 340 | /// An `@errorName`, so this borrows a static string and owns no | ||
| 341 | /// storage. See `waitFailDetail`, which composes the line. | ||
| 342 | reconnect_failure: ?[]const u8 = null, | ||
| 343 | |||
| 344 | fn open(alloc: std.mem.Allocator, sock_path: []const u8) !Conn { | ||
| 345 | const s = try std.net.connectUnixSocket(sock_path); | ||
| 346 | return .{ .link = .{ .fd = s.handle }, .alloc = alloc }; | ||
| 347 | } | ||
| 348 | |||
| 349 | /// A `send` before the stream exists takes zero bytes, so the frame | ||
| 350 | /// would silently never leave. | ||
| 351 | fn openQuic( | ||
| 352 | alloc: std.mem.Allocator, | ||
| 353 | addr: std.net.Address, | ||
| 354 | key: quic_client.Key, | ||
| 355 | idle_ms: u32, | ||
| 356 | deadline_ms: i64, | ||
| 357 | ) !Conn { | ||
| 358 | const started = std.time.milliTimestamp(); | ||
| 359 | const cl = try quic_client.Client.connect(alloc, addr, key, idle_ms); | ||
| 360 | errdefer cl.deinit(); | ||
| 361 | try waitReady(cl, deadline_ms); | ||
| 362 | return .{ | ||
| 363 | .link = .{ .quic = .{ | ||
| 364 | .cl = cl, | ||
| 365 | .addr = addr, | ||
| 366 | .key = key, | ||
| 367 | .idle_ms = idle_ms, | ||
| 368 | .connect_ms = elapsed(started), | ||
| 369 | } }, | ||
| 370 | .alloc = alloc, | ||
| 371 | }; | ||
| 372 | } | ||
| 373 | |||
| 374 | fn close(self: *Conn) void { | ||
| 375 | switch (self.link) { | ||
| 376 | .fd => |fd| std.posix.close(fd), | ||
| 377 | .quic => |q| q.cl.deinit(), | ||
| 378 | } | ||
| 379 | } | ||
| 380 | |||
| 381 | /// QUIC widens the grace: the daemon's window opens a flight after | ||
| 382 | /// ours. The cap bounds a slow handshake. | ||
| 383 | fn graceMs(self: *const Conn) i64 { | ||
| 384 | return switch (self.link) { | ||
| 385 | .fd => await_grace_ms, | ||
| 386 | .quic => |q| @min(grace_cap_ms, @max(await_grace_ms, 4 * q.connect_ms)), | ||
| 387 | }; | ||
| 388 | } | ||
| 389 | |||
| 390 | /// The socket arm ignores `deadline_ms` — a local write either takes | ||
| 391 | /// the bytes or fails. The QUIC arm gives it precedence over | ||
| 392 | /// `send_flush_ms`, so a verb asked for a 100ms answer cannot spend | ||
| 393 | /// five seconds sending. | ||
| 394 | fn sendFrame(self: *Conn, t: proto.MsgType, payload: []const u8, deadline_ms: i64) !void { | ||
| 395 | const wrote: anyerror!void = switch (self.link) { | ||
| 396 | .fd => |fd| proto.writeFrame(fd, t, payload), | ||
| 397 | .quic => self.sendFrameQuic(t, payload, deadline_ms), | ||
| 398 | }; | ||
| 399 | wrote catch |e| { | ||
| 400 | switch (e) { | ||
| 401 | error.BrokenPipe, | ||
| 402 | error.ConnectionResetByPeer, | ||
| 403 | error.ConnectionLost, | ||
| 404 | => try self.refusalPending(), | ||
| 405 | else => {}, | ||
| 406 | } | ||
| 407 | return e; | ||
| 408 | }; | ||
| 409 | } | ||
| 410 | |||
| 411 | /// A refusal is `exit_status` then close, and that close can beat | ||
| 412 | /// our next write: the `BrokenPipe` then stands where the refusal | ||
| 413 | /// belongs. Rare untraced, certain under ptrace. The want is one | ||
| 414 | /// the daemon never sends: only the snapshot rule ends it. | ||
| 415 | fn refusalPending(self: *Conn) error{AttachRefused}!void { | ||
| 416 | const frame = self.awaitFrame(.attach, std.time.milliTimestamp() + refusal_drain_ms) catch |e| { | ||
| 417 | if (e == error.AttachRefused) return error.AttachRefused; | ||
| 418 | return; | ||
| 419 | }; | ||
| 420 | frame.deinit(self.alloc); | ||
| 421 | } | ||
| 422 | |||
| 423 | /// A half-written frame reads as a corrupt stream, so the short take | ||
| 424 | /// is re-offered. | ||
| 425 | fn sendFrameQuic( | ||
| 426 | self: *Conn, | ||
| 427 | t: proto.MsgType, | ||
| 428 | payload: []const u8, | ||
| 429 | deadline_ms: i64, | ||
| 430 | ) !void { | ||
| 431 | var buf: std.ArrayList(u8) = .empty; | ||
| 432 | defer buf.deinit(self.alloc); | ||
| 433 | try proto.appendFrame(&buf, self.alloc, t, payload); | ||
| 434 | |||
| 435 | const q = &self.link.quic; | ||
| 436 | // The flush cap is what bounds an UNBOUNDED caller (`--timeout 0`); | ||
| 437 | // the caller's own deadline bounds every other one. | ||
| 438 | const deadline = @min(deadline_ms, std.time.milliTimestamp() + send_flush_ms); | ||
| 439 | var off: usize = 0; | ||
| 440 | while (off < buf.items.len) { | ||
| 441 | if (q.cl.dead) return error.ConnectionLost; | ||
| 442 | off += q.cl.send(buf.items[off..]); | ||
| 443 | if (off == buf.items.len) return; | ||
| 444 | if (std.time.milliTimestamp() >= deadline) return error.SendStalled; | ||
| 445 | // The ring is full: only the peer's acks can empty it, and they | ||
| 446 | // arrive through pump. Polling first keeps this from spinning. | ||
| 447 | var fds = [_]std.posix.pollfd{ | ||
| 448 | .{ .fd = q.cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 449 | }; | ||
| 450 | _ = std.posix.poll(&fds, q.cl.timeoutMs(50)) catch return error.ConnectionLost; | ||
| 451 | q.cl.pump(); | ||
| 452 | } | ||
| 453 | } | ||
| 454 | |||
| 455 | /// Snapshots and deltas stream past an attached client, so unwanted | ||
| 456 | /// frames are skipped. `exit_status` ends the wait instead: the reply | ||
| 457 | /// is never coming, and the session ending is an answer, not a | ||
| 458 | /// transport failure. | ||
| 459 | fn awaitFrame(self: *Conn, want: proto.MsgType, deadline_ms: i64) !proto.Frame { | ||
| 460 | return switch (self.link) { | ||
| 461 | .fd => self.awaitFrameFd(self.alloc, want, deadline_ms), | ||
| 462 | .quic => self.awaitFrameQuic(self.alloc, want, deadline_ms), | ||
| 463 | }; | ||
| 464 | } | ||
| 465 | |||
| 466 | /// Deadline-bounded wait, unbounded read: harmless where a stall | ||
| 467 | /// means a dead daemon. | ||
| 468 | fn awaitFrameFd( | ||
| 469 | self: *Conn, | ||
| 470 | alloc: std.mem.Allocator, | ||
| 471 | want: proto.MsgType, | ||
| 472 | deadline_ms: i64, | ||
| 473 | ) !proto.Frame { | ||
| 474 | const fd = self.link.fd; | ||
| 475 | while (true) { | ||
| 476 | const now = std.time.milliTimestamp(); | ||
| 477 | if (now >= deadline_ms) return error.Timeout; | ||
| 478 | var fds = [_]std.posix.pollfd{ | ||
| 479 | .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 480 | }; | ||
| 481 | const n = try std.posix.poll(&fds, @intCast(@min(deadline_ms - now, 250))); | ||
| 482 | if (n == 0) continue; | ||
| 483 | const frame = try proto.readFrame(alloc, fd) orelse return error.DaemonGone; | ||
| 484 | if (frame.type == want) return frame; | ||
| 485 | defer frame.deinit(alloc); | ||
| 486 | if (frame.type == .snapshot) self.saw_snapshot = true; | ||
| 487 | if (frame.type == .exit_status) { | ||
| 488 | if (!self.saw_snapshot) return error.AttachRefused; | ||
| 489 | // A daemon that spelled the frame without a code still ends | ||
| 490 | // the session; null is the honest code, not 0. | ||
| 491 | self.session_exit = if (frame.payload.len >= 1) frame.payload[0] else null; | ||
| 492 | return error.SessionExited; | ||
| 493 | } | ||
| 494 | } | ||
| 495 | } | ||
| 496 | |||
| 497 | /// Drain the buffer before checking `dead`, or bytes that arrived | ||
| 498 | /// first are lost. | ||
| 499 | fn awaitFrameQuic( | ||
| 500 | self: *Conn, | ||
| 501 | alloc: std.mem.Allocator, | ||
| 502 | want: proto.MsgType, | ||
| 503 | deadline_ms: i64, | ||
| 504 | ) !proto.Frame { | ||
| 505 | const q = &self.link.quic; | ||
| 506 | while (true) { | ||
| 507 | q.cl.pump(); | ||
| 508 | while (try frameFrom(alloc, q.cl.inbound())) |got| { | ||
| 509 | q.cl.consume(got.consumed); | ||
| 510 | if (got.frame.type == want) return got.frame; | ||
| 511 | defer got.frame.deinit(alloc); | ||
| 512 | // The socket arm's rule again rather than a shared helper: | ||
| 513 | // the two arms own their frame differently (`got.frame` | ||
| 514 | // paired with `consume`, against a plain `frame`), so there | ||
| 515 | // is no cursor for one reader to hand the other. | ||
| 516 | if (got.frame.type == .snapshot) self.saw_snapshot = true; | ||
| 517 | if (got.frame.type == .exit_status) { | ||
| 518 | if (!self.saw_snapshot) return error.AttachRefused; | ||
| 519 | self.session_exit = if (got.frame.payload.len >= 1) got.frame.payload[0] else null; | ||
| 520 | return error.SessionExited; | ||
| 521 | } | ||
| 522 | } | ||
| 523 | // Not `DaemonGone`: over a network the difference between "the | ||
| 524 | // daemon exited" and "the path to it went away" is not ours to | ||
| 525 | // claim, and the reconnect above only fires on this one. | ||
| 526 | if (q.cl.dead) return error.ConnectionLost; | ||
| 527 | const now = std.time.milliTimestamp(); | ||
| 528 | if (now >= deadline_ms) return error.Timeout; | ||
| 529 | var fds = [_]std.posix.pollfd{ | ||
| 530 | .{ .fd = q.cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 531 | }; | ||
| 532 | // Through timeoutMs, so ngtcp2's own timers — loss detection | ||
| 533 | // and, on a quiet await, the keepalive that keeps the idle | ||
| 534 | // timeout from firing under us — are serviced on schedule | ||
| 535 | // rather than whenever the daemon happens to say something. | ||
| 536 | const cap: i32 = @intCast(@min(deadline_ms - now, 250)); | ||
| 537 | _ = std.posix.poll(&fds, q.cl.timeoutMs(cap)) catch return error.ConnectionLost; | ||
| 538 | } | ||
| 539 | } | ||
| 540 | |||
| 541 | /// `connect_ms` deliberately keeps the FIRST handshake's measurement: | ||
| 542 | /// what a reader wants is the distance to the daemon, not the cost of | ||
| 543 | /// a redial made while the path was still coming back. Nothing reads | ||
| 544 | /// it after this point anyway. | ||
| 545 | fn reconnect(self: *Conn, deadline_ms: i64) !void { | ||
| 546 | const q = &self.link.quic; | ||
| 547 | const cl = try quic_client.Client.connect(self.alloc, q.addr, q.key, q.idle_ms); | ||
| 548 | errdefer cl.deinit(); | ||
| 549 | try waitReady(cl, deadline_ms); | ||
| 550 | q.cl.deinit(); | ||
| 551 | q.cl = cl; | ||
| 552 | q.reconnected = true; | ||
| 553 | } | ||
| 554 | }; | ||
| 555 | |||
| 556 | /// Copies: the caller consumes the bytes it points at. | ||
| 557 | fn frameFrom( | ||
| 558 | alloc: std.mem.Allocator, | ||
| 559 | buf: []const u8, | ||
| 560 | ) !?struct { frame: proto.Frame, consumed: usize } { | ||
| 561 | const d = try proto.delimitFrame(buf) orelse return null; | ||
| 562 | const payload = try alloc.alloc(u8, d.payload.len); | ||
| 563 | errdefer alloc.free(payload); | ||
| 564 | @memcpy(payload, d.payload); | ||
| 565 | return .{ | ||
| 566 | .frame = .{ .type = d.type, .payload = payload }, | ||
| 567 | .consumed = d.consumed, | ||
| 568 | }; | ||
| 569 | } | ||
| 570 | |||
| 571 | test "frameFrom: a partial tail is not a frame and not an error" { | ||
| 572 | const alloc = std.testing.allocator; | ||
| 573 | |||
| 574 | // Nothing, and less than a header: the two shapes a datagram that | ||
| 575 | // carried the start of a frame leaves behind. | ||
| 576 | try std.testing.expect(try frameFrom(alloc, "") == null); | ||
| 577 | try std.testing.expect(try frameFrom(alloc, &[_]u8{ 0x0a, 1, 0 }) == null); | ||
| 578 | |||
| 579 | // A whole header whose payload is still in flight. This is the case a | ||
| 580 | // blocking read would have sat on: the length is known, the bytes are | ||
| 581 | // not here, and the answer is to wait rather than to read. | ||
| 582 | const partial = [_]u8{ @intFromEnum(proto.MsgType.input), 4, 0, 0, 0, 'a', 'b' }; | ||
| 583 | try std.testing.expect(try frameFrom(alloc, &partial) == null); | ||
| 584 | |||
| 585 | // The same bytes, completed. | ||
| 586 | const whole = [_]u8{ @intFromEnum(proto.MsgType.input), 4, 0, 0, 0, 'a', 'b', 'c', 'd' }; | ||
| 587 | const got = (try frameFrom(alloc, &whole)).?; | ||
| 588 | defer got.frame.deinit(alloc); | ||
| 589 | try std.testing.expectEqual(proto.MsgType.input, got.frame.type); | ||
| 590 | try std.testing.expectEqualStrings("abcd", got.frame.payload); | ||
| 591 | try std.testing.expectEqual(@as(usize, 9), got.consumed); | ||
| 592 | } | ||
| 593 | |||
| 594 | test "frameFrom: two frames in one buffer, walked by consumed" { | ||
| 595 | const alloc = std.testing.allocator; | ||
| 596 | // What a single datagram routinely carries: the push we skip and the | ||
| 597 | // reply we asked for. A walk that stopped after one would leave the | ||
| 598 | // answer sitting in the buffer while the deadline ran out. | ||
| 599 | var buf: std.ArrayList(u8) = .empty; | ||
| 600 | defer buf.deinit(alloc); | ||
| 601 | try proto.appendFrame(&buf, alloc, .pty_mode, &[_]u8{0}); | ||
| 602 | try proto.appendFrame(&buf, alloc, .status_reply, "xy"); | ||
| 603 | |||
| 604 | const first = (try frameFrom(alloc, buf.items)).?; | ||
| 605 | defer first.frame.deinit(alloc); | ||
| 606 | try std.testing.expectEqual(proto.MsgType.pty_mode, first.frame.type); | ||
| 607 | |||
| 608 | const second = (try frameFrom(alloc, buf.items[first.consumed..])).?; | ||
| 609 | defer second.frame.deinit(alloc); | ||
| 610 | try std.testing.expectEqual(proto.MsgType.status_reply, second.frame.type); | ||
| 611 | try std.testing.expectEqualStrings("xy", second.frame.payload); | ||
| 612 | try std.testing.expectEqual(buf.items.len, first.consumed + second.consumed); | ||
| 613 | |||
| 614 | // An empty payload is a frame like any other — `status_req` and | ||
| 615 | // `detach` are nothing else — and must not read as "nothing yet". | ||
| 616 | var empty: std.ArrayList(u8) = .empty; | ||
| 617 | defer empty.deinit(alloc); | ||
| 618 | try proto.appendFrame(&empty, alloc, .detach, ""); | ||
| 619 | const none = (try frameFrom(alloc, empty.items)).?; | ||
| 620 | defer none.frame.deinit(alloc); | ||
| 621 | try std.testing.expectEqual(@as(usize, proto.frame_header_len), none.consumed); | ||
| 622 | } | ||
| 623 | |||
| 624 | test "frameFrom: a length no frame can carry is refused, not allocated" { | ||
| 625 | const alloc = std.testing.allocator; | ||
| 626 | // The peer chose this number. Reading on would mean allocating against | ||
| 627 | // it; the daemon's own walk refuses the same bound the same way. | ||
| 628 | var hdr: [proto.frame_header_len]u8 = undefined; | ||
| 629 | hdr[0] = @intFromEnum(proto.MsgType.input); | ||
| 630 | std.mem.writeInt(u32, hdr[1..5], proto.max_payload + 1, .little); | ||
| 631 | try std.testing.expectError(error.FrameTooLarge, frameFrom(alloc, &hdr)); | ||
| 632 | } | ||
| 633 | |||
| 634 | test "graceMs: flat over a socket, RTT-derived over QUIC, and capped" { | ||
| 635 | const alloc = std.testing.allocator; | ||
| 636 | const local = Conn{ .link = .{ .fd = -1 }, .alloc = alloc }; | ||
| 637 | try std.testing.expectEqual(@as(i64, 2_000), local.graceMs()); | ||
| 638 | |||
| 639 | // The derivation is 4x the handshake, and it only ever WIDENS the | ||
| 640 | // window: a loopback or LAN daemon keeps the flat 2s. | ||
| 641 | // No client: the window is a function of the measurement, not of the | ||
| 642 | // connection, and nothing here may touch one. | ||
| 643 | var far = Conn{ | ||
| 644 | .link = .{ .quic = .{ | ||
| 645 | .cl = undefined, | ||
| 646 | .addr = undefined, | ||
| 647 | .key = undefined, | ||
| 648 | .idle_ms = 0, | ||
| 649 | .connect_ms = 1, | ||
| 650 | } }, | ||
| 651 | .alloc = alloc, | ||
| 652 | }; | ||
| 653 | try std.testing.expectEqual(@as(i64, 2_000), far.graceMs()); | ||
| 654 | |||
| 655 | // A 300ms handshake — a real intercontinental link — buys 1.2s, which | ||
| 656 | // is still under the floor, so the first number that moves it is a | ||
| 657 | // handshake past half a second. | ||
| 658 | far.link.quic.connect_ms = 300; | ||
| 659 | try std.testing.expectEqual(@as(i64, 2_000), far.graceMs()); | ||
| 660 | far.link.quic.connect_ms = 900; | ||
| 661 | try std.testing.expectEqual(@as(i64, 3_600), far.graceMs()); | ||
| 662 | |||
| 663 | // And it stops widening: past the cap we are no longer waiting on a | ||
| 664 | // daemon, we are waiting on a network that has already failed to carry | ||
| 665 | // an answer. | ||
| 666 | far.link.quic.connect_ms = 60_000; | ||
| 667 | try std.testing.expectEqual(@as(i64, 30_000), far.graceMs()); | ||
| 668 | } | ||
| 669 | |||
| 670 | /// Drive a fresh connection until it can carry bytes, or give up. | ||
| 671 | /// | ||
| 672 | /// A refused port ends this early — quic_client turns the ICMP unreachable | ||
| 673 | /// into `dead` — so the common mistake (no daemon on that port) costs | ||
| 674 | /// milliseconds. A blackholed one produces no error at all, and there the | ||
| 675 | /// deadline is the only thing that ends the wait; even an unbounded one | ||
| 676 | /// (`--timeout 0`) terminates, because the connection's own idle timeout | ||
| 677 | /// kills it after `idle_ms`. | ||
| 678 | fn waitReady(cl: *quic_client.Client, deadline_ms: i64) !void { | ||
| 679 | while (true) { | ||
| 680 | cl.pump(); | ||
| 681 | if (cl.isReady()) return; | ||
| 682 | if (cl.dead) return error.QuicHandshakeFailed; | ||
| 683 | const now = std.time.milliTimestamp(); | ||
| 684 | if (now >= deadline_ms) return error.Timeout; | ||
| 685 | var fds = [_]std.posix.pollfd{ | ||
| 686 | .{ .fd = cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 687 | }; | ||
| 688 | const cap: i32 = @intCast(@min(deadline_ms - now, 50)); | ||
| 689 | _ = std.posix.poll(&fds, cl.timeoutMs(cap)) catch return error.QuicHandshakeFailed; | ||
| 690 | } | ||
| 691 | } | ||
| 692 | |||
| 693 | test "reconnect: redials the same coordinates, and a dead port is a fast no" { | ||
| 694 | const alloc = std.testing.allocator; | ||
| 695 | // 127.0.0.1:1, where nothing listens: the refusal is REAL — an ICMP | ||
| 696 | // unreachable comes back and quic_client acts on it — which is what | ||
| 697 | // lets this exercise the whole redial path (dial, handshake wait, | ||
| 698 | // verdict) in a couple of loopback round trips instead of a timeout. | ||
| 699 | const addr = try std.net.Address.parseIp("127.0.0.1", 1); | ||
| 700 | const key: quic_client.Key = .{ .bytes = [_]u8{7} ** quic_client.key_len }; | ||
| 701 | |||
| 702 | // The dial that stands in for the connection this client had before | ||
| 703 | // the network went away. It dies for the same reason the redial will, | ||
| 704 | // which is fine: what is under test is what `reconnect` DOES, and it | ||
| 705 | // does the same thing to a connection that died at second 30. | ||
| 706 | const deadline = std.time.milliTimestamp() + 2_000; | ||
| 707 | var conn = Conn{ | ||
| 708 | .link = .{ .quic = .{ | ||
| 709 | .cl = try quic_client.Client.connect(alloc, addr, key, 1_000), | ||
| 710 | .addr = addr, | ||
| 711 | .key = key, | ||
| 712 | .idle_ms = 1_000, | ||
| 713 | .connect_ms = 0, | ||
| 714 | } }, | ||
| 715 | .alloc = alloc, | ||
| 716 | }; | ||
| 717 | defer conn.close(); | ||
| 718 | |||
| 719 | const t0 = std.time.milliTimestamp(); | ||
| 720 | if (conn.reconnect(deadline)) |_| { | ||
| 721 | // Nothing listens there; a redial that reported success would mean | ||
| 722 | // the handshake wait had stopped being a wait for a handshake. | ||
| 723 | return error.TestUnexpectedResult; | ||
| 724 | } else |redial| { | ||
| 725 | try std.testing.expectEqual(error.QuicHandshakeFailed, redial); | ||
| 726 | // The bookkeeping awaitReissuing does, done here with the real | ||
| 727 | // error the real redial produced, so the sentence below is the one | ||
| 728 | // an agent gets rather than one this test made up. | ||
| 729 | conn.reconnect_failure = @errorName(redial); | ||
| 730 | } | ||
| 731 | // Fast, because the port refused rather than went quiet. A redial that | ||
| 732 | // swallowed the refusal would spend the whole 2s here — and in the | ||
| 733 | // field it would spend the agent's remaining deadline. | ||
| 734 | try std.testing.expect(std.time.milliTimestamp() - t0 < 1_000); | ||
| 735 | |||
| 736 | // The whole story, in the order it happened: the wait died because the | ||
| 737 | // path tore, and it stayed dead because the redial could not complete. | ||
| 738 | // An agent told only the second half goes and checks its key. | ||
| 739 | var buf: [128]u8 = undefined; | ||
| 740 | try std.testing.expectEqualStrings( | ||
| 741 | "connection lost; reconnect failed: QuicHandshakeFailed", | ||
| 742 | waitFailDetail(&buf, &conn, error.ConnectionLost), | ||
| 743 | ); | ||
| 744 | |||
| 745 | // A redial that failed is not a reconnect spent — but it is also not a | ||
| 746 | // Conn holding a freed client: the old one is torn down only once a | ||
| 747 | // new one is up, so the close above is safe on this path. | ||
| 748 | try std.testing.expect(!conn.link.quic.reconnected); | ||
| 749 | } | ||
| 750 | |||
| 751 | test "waitFailDetail: only a lost connection gets a sentence; the rest keep their names" { | ||
| 752 | const alloc = std.testing.allocator; | ||
| 753 | var buf: [128]u8 = undefined; | ||
| 754 | |||
| 755 | // Every other failure is untouched — the socket arm's reports must | ||
| 756 | // read exactly as they did before there was a QUIC arm. | ||
| 757 | const local = Conn{ .link = .{ .fd = -1 }, .alloc = alloc }; | ||
| 758 | try std.testing.expectEqualStrings("Timeout", waitFailDetail(&buf, &local, error.Timeout)); | ||
| 759 | try std.testing.expectEqualStrings("DaemonGone", waitFailDetail(&buf, &local, error.DaemonGone)); | ||
| 760 | |||
| 761 | // A tear with the one reconnect still unspent (nothing tried yet). | ||
| 762 | var far = Conn{ | ||
| 763 | .link = .{ .quic = .{ | ||
| 764 | .cl = undefined, | ||
| 765 | .addr = undefined, | ||
| 766 | .key = undefined, | ||
| 767 | .idle_ms = 0, | ||
| 768 | .connect_ms = 0, | ||
| 769 | } }, | ||
| 770 | .alloc = alloc, | ||
| 771 | }; | ||
| 772 | try std.testing.expectEqualStrings("connection lost", waitFailDetail(&buf, &far, error.ConnectionLost)); | ||
| 773 | |||
| 774 | // A tear AFTER a reconnect that worked: the second one inside a single | ||
| 775 | // wait, which is a different thing to be told than the first — the | ||
| 776 | // client did reconnect, and the path tore again anyway. | ||
| 777 | far.link.quic.reconnected = true; | ||
| 778 | try std.testing.expectEqualStrings( | ||
| 779 | "connection lost again, after the one reconnect", | ||
| 780 | waitFailDetail(&buf, &far, error.ConnectionLost), | ||
| 781 | ); | ||
| 782 | |||
| 783 | // A buffer too small to hold the composed line drops the reason rather | ||
| 784 | // than the finding: the detail is the agent's only account of this. | ||
| 785 | far.reconnect_failure = "QuicHandshakeFailed"; | ||
| 786 | var tiny: [8]u8 = undefined; | ||
| 787 | try std.testing.expectEqualStrings( | ||
| 788 | "connection lost; reconnect failed", | ||
| 789 | waitFailDetail(&tiny, &far, error.ConnectionLost), | ||
| 790 | ); | ||
| 791 | } | ||
| 792 | |||
| 793 | test "awaitFrame ends a wait on exit_status, keeping the code" { | ||
| 794 | const alloc = std.testing.allocator; | ||
| 795 | // A pipe stands in for the daemon: awaitFrame polls and reads an fd and | ||
| 796 | // asks nothing else of it. | ||
| 797 | const pipe = try std.posix.pipe(); | ||
| 798 | defer std.posix.close(pipe[0]); | ||
| 799 | defer std.posix.close(pipe[1]); | ||
| 800 | |||
| 801 | var conn = Conn{ .link = .{ .fd = pipe[0] }, .alloc = alloc }; | ||
| 802 | // The snapshot first, because it is what makes this an ENDING rather | ||
| 803 | // than a refusal: the attach was served, so the exit_status after it | ||
| 804 | // is the session's last word (see the refusal test below). | ||
| 805 | try proto.writeFrame(pipe[1], .snapshot, ""); | ||
| 806 | // A push to skip on the way, then the session's last word. The reply | ||
| 807 | // this wait asked for is never coming, and the code is the answer. | ||
| 808 | try proto.writeFrame(pipe[1], .pty_mode, &[_]u8{0}); | ||
| 809 | try proto.writeFrame(pipe[1], .exit_status, &[_]u8{5}); | ||
| 810 | try std.testing.expectError( | ||
| 811 | error.SessionExited, | ||
| 812 | conn.awaitFrame(.status_reply, std.time.milliTimestamp() + 2000), | ||
| 813 | ); | ||
| 814 | try std.testing.expectEqual(@as(?u8, 5), conn.session_exit); | ||
| 815 | |||
| 816 | // ...and it is spelled as a session ending, not as a command's code. | ||
| 817 | var out: std.ArrayList(u8) = .empty; | ||
| 818 | defer out.deinit(alloc); | ||
| 819 | try printSessionEnded(out.writer(alloc), conn.session_exit, 42); | ||
| 820 | try std.testing.expectEqualStrings( | ||
| 821 | "{\"reason\":\"session_ended\",\"exit_code\":5,\"duration_ms\":42}\n", | ||
| 822 | out.items, | ||
| 823 | ); | ||
| 824 | } | ||
| 825 | |||
| 826 | test "an exit_status before any snapshot is a refused attach, not a session that ended" { | ||
| 827 | const alloc = std.testing.allocator; | ||
| 828 | // The daemon's whole vocabulary for "no": resolveSession refuses a 0x0 | ||
| 829 | // attach of a name it does not hold by sending exit_status 1 and | ||
| 830 | // closing (server.zig's attach arm), which is byte-identical to a | ||
| 831 | // shell's real exit. The snapshot is what tells them apart — a served | ||
| 832 | // attach always sends one first, so nothing before one can be an | ||
| 833 | // ending. Reporting this as "session ended" told an agent a shell had | ||
| 834 | // run and failed when no shell ever existed. | ||
| 835 | const pipe = try std.posix.pipe(); | ||
| 836 | defer std.posix.close(pipe[0]); | ||
| 837 | defer std.posix.close(pipe[1]); | ||
| 838 | |||
| 839 | var conn = Conn{ .link = .{ .fd = pipe[0] }, .alloc = alloc }; | ||
| 840 | try proto.writeFrame(pipe[1], .exit_status, &[_]u8{1}); | ||
| 841 | try std.testing.expectError( | ||
| 842 | error.AttachRefused, | ||
| 843 | conn.awaitFrame(.status_reply, std.time.milliTimestamp() + 2000), | ||
| 844 | ); | ||
| 845 | } | ||
| 846 | |||
| 847 | test "a re-attach forgets the snapshot it saw, so a refused reconnect is not an ending" { | ||
| 848 | const alloc = std.testing.allocator; | ||
| 849 | // A socketpair, not a pipe: this conn has to WRITE (the attach) as well | ||
| 850 | // as read down the ONE fd it holds, which is the whole shape under test | ||
| 851 | // — awaitReissuing attaches a second time on a connection that has | ||
| 852 | // already been served. Through std.os.linux because std.posix has no | ||
| 853 | // socketpair on 0.15.2 (server.zig's connectedPair says the same). | ||
| 854 | var sp: [2]i32 = undefined; | ||
| 855 | try std.testing.expectEqual( | ||
| 856 | @as(usize, 0), | ||
| 857 | std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp), | ||
| 858 | ); | ||
| 859 | defer std.posix.close(sp[0]); | ||
| 860 | defer std.posix.close(sp[1]); | ||
| 861 | |||
| 862 | var conn = Conn{ .link = .{ .fd = sp[0] }, .alloc = alloc }; | ||
| 863 | // The first attach is served: a snapshot arrives and is skipped past on | ||
| 864 | // the way to a reply that never comes. | ||
| 865 | try proto.writeFrame(sp[1], .snapshot, ""); | ||
| 866 | try std.testing.expectError( | ||
| 867 | error.Timeout, | ||
| 868 | conn.awaitFrame(.status_reply, std.time.milliTimestamp() + 50), | ||
| 869 | ); | ||
| 870 | try std.testing.expect(conn.saw_snapshot); | ||
| 871 | |||
| 872 | // The reconnect's attach, refused. Without the reset in attachZero the | ||
| 873 | // stale `saw_snapshot` reads this as the session ending, and the verb | ||
| 874 | // reports a shell exit for a session it never reached. | ||
| 875 | try attachZero(&conn, "s", std.time.milliTimestamp() + 2000); | ||
| 876 | try proto.writeFrame(sp[1], .exit_status, &[_]u8{1}); | ||
| 877 | try std.testing.expectError( | ||
| 878 | error.AttachRefused, | ||
| 879 | conn.awaitFrame(.status_reply, std.time.milliTimestamp() + 2000), | ||
| 880 | ); | ||
| 881 | } | ||
| 882 | |||
| 883 | test "a refusal that closes the socket before the input write is still reported as the refusal" { | ||
| 884 | const alloc = std.testing.allocator; | ||
| 885 | // The refusal is `exit_status` + close (server.zig's attach arm), and | ||
| 886 | // nothing makes our next write lose to that close except timing: the | ||
| 887 | // socket buffer usually takes the bytes first, so the refusal is read | ||
| 888 | // where it is expected. Under a ptrace tracer (kcov) the close wins | ||
| 889 | // every time, and the write's BrokenPipe was reported INSTEAD of the | ||
| 890 | // refusal — a daemon saying "no such session" spelled as a transport | ||
| 891 | // failure. | ||
| 892 | var sp: [2]i32 = undefined; | ||
| 893 | try std.testing.expectEqual( | ||
| 894 | @as(usize, 0), | ||
| 895 | std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp), | ||
| 896 | ); | ||
| 897 | defer std.posix.close(sp[0]); | ||
| 898 | |||
| 899 | var conn = Conn{ .link = .{ .fd = sp[0] }, .alloc = alloc }; | ||
| 900 | const deadline = std.time.milliTimestamp() + 2000; | ||
| 901 | // The field ordering: the attach is served, the refusal comes back, | ||
| 902 | // and the close beats the input write that follows it. | ||
| 903 | try attachZero(&conn, "nosuch", deadline); | ||
| 904 | try proto.writeFrame(sp[1], .exit_status, &[_]u8{1}); | ||
| 905 | std.posix.close(sp[1]); | ||
| 906 | try std.testing.expectError(error.AttachRefused, conn.sendFrame(.input, "x", deadline)); | ||
| 907 | |||
| 908 | // And the verb's answer is the refusal's JSON, not the write's. | ||
| 909 | var sp2: [2]i32 = undefined; | ||
| 910 | try std.testing.expectEqual( | ||
| 911 | @as(usize, 0), | ||
| 912 | std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp2), | ||
| 913 | ); | ||
| 914 | defer std.posix.close(sp2[0]); | ||
| 915 | try proto.writeFrame(sp2[1], .exit_status, &[_]u8{1}); | ||
| 916 | std.posix.close(sp2[1]); | ||
| 917 | |||
| 918 | const saved = try std.posix.dup(std.posix.STDOUT_FILENO); | ||
| 919 | defer { | ||
| 920 | std.posix.dup2(saved, std.posix.STDOUT_FILENO) catch {}; | ||
| 921 | std.posix.close(saved); | ||
| 922 | } | ||
| 923 | const cap = try std.posix.pipe(); | ||
| 924 | defer std.posix.close(cap[0]); | ||
| 925 | try std.posix.dup2(cap[1], std.posix.STDOUT_FILENO); | ||
| 926 | std.posix.close(cap[1]); | ||
| 927 | |||
| 928 | var conn2 = Conn{ .link = .{ .fd = sp2[0] }, .alloc = alloc }; | ||
| 929 | const code = try verbSend(alloc, &conn2, "x", "nosuch", std.time.milliTimestamp() + 2000); | ||
| 930 | try std.posix.dup2(saved, std.posix.STDOUT_FILENO); | ||
| 931 | |||
| 932 | var buf: [1024]u8 = undefined; | ||
| 933 | const n = try std.posix.read(cap[0], &buf); | ||
| 934 | try std.testing.expectEqual(@as(u8, 1), code); | ||
| 935 | const want = "{\"error\":\"attach refused\",\"detail\":"; | ||
| 936 | try std.testing.expectEqualStrings(want, buf[0..@min(n, want.len)]); | ||
| 937 | } | ||
| 938 | |||
| 939 | test "the refused-attach failure keeps the error+detail shape every failure has" { | ||
| 940 | var buf: [512]u8 = undefined; | ||
| 941 | |||
| 942 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 943 | try writeAttachRefusedError(fbs.writer(), "nosuch", .attach); | ||
| 944 | // The name is in the detail because it is the one thing the agent got | ||
| 945 | // wrong, and an agent reading only `.error` still gets a phrase that | ||
| 946 | // cannot be confused with a command's failure. | ||
| 947 | try std.testing.expect(std.mem.startsWith(u8, fbs.getWritten(), "{\"error\":\"attach refused\",\"detail\":")); | ||
| 948 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "so nosuch must already exist") != null); | ||
| 949 | // Both conditions, never just absence: the daemon sends the same | ||
| 950 | // refusal when its client table is full, and a detail claiming the | ||
| 951 | // session does not exist would be a lie at a daemon that holds it. | ||
| 952 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "room for one more client") != null); | ||
| 953 | |||
| 954 | // The empty name is the wire's default spelling, not a session called | ||
| 955 | // "": a detail reading `so must already exist` would send an agent | ||
| 956 | // looking for a name it never typed. | ||
| 957 | var dflt = std.io.fixedBufferStream(&buf); | ||
| 958 | try writeAttachRefusedError(dflt.writer(), "", .attach); | ||
| 959 | try std.testing.expect(std.mem.indexOf(u8, dflt.getWritten(), "so the default session must already exist") != null); | ||
| 960 | } | ||
| 961 | |||
| 962 | test "a refusal a full client table cannot have caused does not blame one" { | ||
| 963 | var buf: [512]u8 = undefined; | ||
| 964 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 965 | // `status` never attaches, so `exit_status 1` here has one producer: | ||
| 966 | // findSession missed. An observer with no slot is closed frameless, so | ||
| 967 | // no fullness of any table can reach this reply — naming a constant | ||
| 968 | // that cannot be involved sends an agent to read `muxd stats`. | ||
| 969 | try writeAttachRefusedError(fbs.writer(), "nosuch", .query); | ||
| 970 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "so nosuch must already exist") != null); | ||
| 971 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "max_clients") == null); | ||
| 972 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "room for one more client") == null); | ||
| 973 | // The verb never attached, so the sentence must not open by calling | ||
| 974 | // this an attach either. | ||
| 975 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "refused this attach") == null); | ||
| 976 | } | ||
| 977 | |||
| 978 | /// The exit code for "the one JSON object never reached stdout". Distinct | ||
| 979 | /// from all four codes that mean it DID: an object plus 0/1/3, or a usage | ||
| 980 | /// error whose 2 promises stdout was left empty on purpose. | ||
| 981 | const write_failed_code: u8 = 4; | ||
| 982 | |||
| 983 | /// A write that fails must not exit 0: an agent checks the status, | ||
| 984 | /// then has no object. | ||
| 985 | fn emit(json: []const u8, ok: u8) u8 { | ||
| 986 | return emitTo(std.posix.STDOUT_FILENO, json, ok); | ||
| 987 | } | ||
| 988 | |||
| 989 | /// `emit` against a named fd, which is the whole reason it is split out: | ||
| 990 | /// stdout is not something a test can break without breaking the runner. | ||
| 991 | fn emitTo(fd: std.posix.fd_t, json: []const u8, ok: u8) u8 { | ||
| 992 | proto.writeAllFd(fd, json) catch |e| { | ||
| 993 | // Best-effort by construction: whatever took stdout away has very | ||
| 994 | // often taken stderr with it, and the exit code is the half that | ||
| 995 | // survives either way. This line is for the human reading the log. | ||
| 996 | var buf: [128]u8 = undefined; | ||
| 997 | const msg = std.fmt.bufPrint(&buf, "muxa: cannot write the reply: {s}\n", .{@errorName(e)}) catch | ||
| 998 | "muxa: cannot write the reply\n"; | ||
| 999 | proto.writeAllFd(std.posix.STDERR_FILENO, msg) catch {}; | ||
| 1000 | return write_failed_code; | ||
| 1001 | }; | ||
| 1002 | return ok; | ||
| 1003 | } | ||
| 1004 | |||
| 1005 | test "an unwritable stdout is a distinct exit code, never a silent 0" { | ||
| 1006 | // The diagnostic goes to stderr, and a test that let it through would | ||
| 1007 | // print a line that reads like a failure on every green run. Swapped | ||
| 1008 | // for /dev/null and put back. | ||
| 1009 | const saved = try std.posix.dup(std.posix.STDERR_FILENO); | ||
| 1010 | defer { | ||
| 1011 | std.posix.dup2(saved, std.posix.STDERR_FILENO) catch {}; | ||
| 1012 | std.posix.close(saved); | ||
| 1013 | } | ||
| 1014 | const devnull = try std.posix.open("/dev/null", .{ .ACCMODE = .WRONLY }, 0); | ||
| 1015 | defer std.posix.close(devnull); | ||
| 1016 | try std.posix.dup2(devnull, std.posix.STDERR_FILENO); | ||
| 1017 | |||
| 1018 | // A pipe whose reader is gone: the next write is EPIPE, which is the | ||
| 1019 | // field case — an agent harness that stopped reading muxa's stdout. | ||
| 1020 | const gone = try std.posix.pipe(); | ||
| 1021 | std.posix.close(gone[0]); | ||
| 1022 | defer std.posix.close(gone[1]); | ||
| 1023 | try std.testing.expectEqual(write_failed_code, emitTo(gone[1], "{\"sent\":true}\n", 0)); | ||
| 1024 | |||
| 1025 | // And the code the caller asked for is passed through untouched when | ||
| 1026 | // the object does land — including the nonzero ones, which must not be | ||
| 1027 | // confused with the write having failed. | ||
| 1028 | const live = try std.posix.pipe(); | ||
| 1029 | defer std.posix.close(live[0]); | ||
| 1030 | defer std.posix.close(live[1]); | ||
| 1031 | try std.testing.expectEqual(@as(u8, 3), emitTo(live[1], "{\"reason\":\"timeout\"}\n", 3)); | ||
| 1032 | var buf: [64]u8 = undefined; | ||
| 1033 | const n = try std.posix.read(live[0], &buf); | ||
| 1034 | try std.testing.expectEqualStrings("{\"reason\":\"timeout\"}\n", buf[0..n]); | ||
| 1035 | } | ||
| 1036 | |||
| 1037 | /// Every failure exit goes through here, so stdout carries one JSON object | ||
| 1038 | /// whatever went wrong — a driving agent parses the same shape on both | ||
| 1039 | /// paths instead of switching on exit code first. | ||
| 1040 | fn fail(msg: []const u8, detail: []const u8) u8 { | ||
| 1041 | var buf: [2048]u8 = undefined; | ||
| 1042 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 1043 | writeError(fbs.writer(), msg, detail) catch { | ||
| 1044 | // The message did not fit. Still JSON, still one line. | ||
| 1045 | return emit("{\"error\":\"failure too long to report\"}\n", 1); | ||
| 1046 | }; | ||
| 1047 | return emit(fbs.getWritten(), 1); | ||
| 1048 | } | ||
| 1049 | |||
| 1050 | /// `fail` for a verb prefix known only at runtime — every failure in | ||
| 1051 | /// the shared await/run pipeline. A message too long to prefix falls | ||
| 1052 | /// back to the unprefixed one rather than losing the failure. | ||
| 1053 | fn failAs(who: []const u8, msg: []const u8, detail: []const u8) u8 { | ||
| 1054 | var buf: [256]u8 = undefined; | ||
| 1055 | const joined = std.fmt.bufPrint(&buf, "{s}: {s}", .{ who, msg }) catch msg; | ||
| 1056 | return fail(joined, detail); | ||
| 1057 | } | ||
| 1058 | |||
| 1059 | fn writeError(writer: anytype, msg: []const u8, detail: []const u8) !void { | ||
| 1060 | try writer.writeAll("{\"error\":"); | ||
| 1061 | try jsonEscape(writer, msg); | ||
| 1062 | try writer.writeAll(",\"detail\":"); | ||
| 1063 | try jsonEscape(writer, detail); | ||
| 1064 | try writer.writeAll("}\n"); | ||
| 1065 | } | ||
| 1066 | |||
| 1067 | /// `--timeout 0` means no bound here as everywhere else (AwaitReq): a | ||
| 1068 | /// past deadline would fail instantly instead of waiting forever. | ||
| 1069 | fn deadlineFor(timeout_ms: u32) i64 { | ||
| 1070 | if (timeout_ms == 0) return std.math.maxInt(i64); | ||
| 1071 | return std.time.milliTimestamp() + timeout_ms; | ||
| 1072 | } | ||
| 1073 | |||
| 1074 | /// The session ended under us: JSON, but on the failure path — the | ||
| 1075 | /// verb that asked (status, capture, send) has no answer to give. | ||
| 1076 | /// `run` and `await` do have one and print it themselves. | ||
| 1077 | fn failSessionEnded(code: ?u8) u8 { | ||
| 1078 | var buf: [192]u8 = undefined; | ||
| 1079 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 1080 | writeSessionEndedError(fbs.writer(), code) catch return 1; | ||
| 1081 | return emit(fbs.getWritten(), 1); | ||
| 1082 | } | ||
| 1083 | |||
| 1084 | /// `detail` is here because every other failure has one: an agent that | ||
| 1085 | /// reads `.detail` on any exit-1 must never meet a missing key, and one | ||
| 1086 | /// verb quietly dropping it is exactly the shape a driver hits in the | ||
| 1087 | /// field and not in a test. `exit_code` is the machine field; the detail | ||
| 1088 | /// says the same thing in the prose the other failures use. | ||
| 1089 | fn writeSessionEndedError(writer: anytype, code: ?u8) !void { | ||
| 1090 | try writer.writeAll("{\"error\":\"session ended\",\"detail\":"); | ||
| 1091 | if (code) |c| { | ||
| 1092 | var buf: [40]u8 = undefined; | ||
| 1093 | try jsonEscape(writer, try std.fmt.bufPrint(&buf, "shell exited with {d}", .{c})); | ||
| 1094 | } else { | ||
| 1095 | try jsonEscape(writer, "shell exited without reporting a code"); | ||
| 1096 | } | ||
| 1097 | try writer.writeAll(",\"exit_code\":"); | ||
| 1098 | try writeExitCode(writer, code); | ||
| 1099 | try writer.writeAll("}\n"); | ||
| 1100 | } | ||
| 1101 | |||
| 1102 | /// Which ask the daemon refused. `status` and `capture` never attach, so | ||
| 1103 | /// their `exit_status 1` has exactly one producer — a name the daemon does | ||
| 1104 | /// not hold. An observer that cannot be seated is closed without a frame | ||
| 1105 | /// (`acceptConn`), so no table being full can reach them. | ||
| 1106 | const Refused = enum { attach, query }; | ||
| 1107 | |||
| 1108 | /// `failSessionEnded` where the snapshot rule says refusal instead: an | ||
| 1109 | /// agent told "session ended" goes looking for a shell that never ran. | ||
| 1110 | fn failAttachRefused(name: []const u8, ask: Refused) u8 { | ||
| 1111 | var buf: [1024]u8 = undefined; | ||
| 1112 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 1113 | writeAttachRefusedError(fbs.writer(), name, ask) catch return 1; | ||
| 1114 | return emit(fbs.getWritten(), 1); | ||
| 1115 | } | ||
| 1116 | |||
| 1117 | /// A send raises the same refusal `awaitFrame` does, so it reaches the same | ||
| 1118 | /// producer: a verb spelling its own switch would answer half of them. | ||
| 1119 | fn failSend(e: anyerror, name: []const u8, ask: Refused, who: []const u8, msg: []const u8) u8 { | ||
| 1120 | if (e == error.AttachRefused) return failAttachRefused(name, ask); | ||
| 1121 | return failAs(who, msg, @errorName(e)); | ||
| 1122 | } | ||
| 1123 | |||
| 1124 | /// An attach's `exit_status 1` before a snapshot has two producers and the | ||
| 1125 | /// frame does not say which: `resolveSession` refused the name (absent, | ||
| 1126 | /// since a 0x0 attach cannot create, or unspelled), or `freeClientSlot` had | ||
| 1127 | /// no seat left. Naming only absence would print "no session 0" at a daemon | ||
| 1128 | /// that holds session 0 and is merely full — so the attach detail names | ||
| 1129 | /// both, and the query detail names only the one that can reach it. A name | ||
| 1130 | /// too long to fit still leaves a detail: losing the whole reply to a long | ||
| 1131 | /// argv is worse than losing the name from it. | ||
| 1132 | fn writeAttachRefusedError(writer: anytype, name: []const u8, ask: Refused) !void { | ||
| 1133 | const why, const need = switch (ask) { | ||
| 1134 | .attach => .{ | ||
| 1135 | "the daemon refused this attach: muxa joins at 0x0 and never creates, so ", | ||
| 1136 | " must already exist and the daemon must have room for one more client (max_clients)", | ||
| 1137 | }, | ||
| 1138 | .query => .{ | ||
| 1139 | "the daemon refused this query: muxa never creates a session, so ", | ||
| 1140 | " must already exist", | ||
| 1141 | }, | ||
| 1142 | }; | ||
| 1143 | try writer.writeAll("{\"error\":\"attach refused\",\"detail\":"); | ||
| 1144 | var buf: [768]u8 = undefined; | ||
| 1145 | // "" is the wire's default spelling, not a session with no name: a | ||
| 1146 | // detail reading `so must already exist` sends an agent looking for a | ||
| 1147 | // name it never typed. | ||
| 1148 | const shown = if (name.len == 0) "the default session" else name; | ||
| 1149 | const line = std.fmt.bufPrint(&buf, "{s}{s}{s}", .{ why, shown, need }) catch | ||
| 1150 | std.fmt.bufPrint(&buf, "{s}the session asked for{s}", .{ why, need }) catch why; | ||
| 1151 | try jsonEscape(writer, line); | ||
| 1152 | try writer.writeAll("}\n"); | ||
| 1153 | } | ||
| 1154 | |||
| 1155 | test "the session-ended failure keeps the error+detail shape every failure has" { | ||
| 1156 | var buf: [192]u8 = undefined; | ||
| 1157 | |||
| 1158 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 1159 | try writeSessionEndedError(fbs.writer(), 5); | ||
| 1160 | try std.testing.expectEqualStrings( | ||
| 1161 | "{\"error\":\"session ended\",\"detail\":\"shell exited with 5\",\"exit_code\":5}\n", | ||
| 1162 | fbs.getWritten(), | ||
| 1163 | ); | ||
| 1164 | |||
| 1165 | // No code is still a detail, never a missing key. | ||
| 1166 | var none = std.io.fixedBufferStream(&buf); | ||
| 1167 | try writeSessionEndedError(none.writer(), null); | ||
| 1168 | try std.testing.expect(std.mem.indexOf(u8, none.getWritten(), "\"detail\":\"shell exited without") != null); | ||
| 1169 | try std.testing.expect(std.mem.indexOf(u8, none.getWritten(), "\"exit_code\":null") != null); | ||
| 1170 | } | ||
| 1171 | |||
| 1172 | pub fn main() !u8 { | ||
| 1173 | var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); | ||
| 1174 | defer arena_state.deinit(); | ||
| 1175 | const alloc = arena_state.allocator(); | ||
| 1176 | |||
| 1177 | const args = try std.process.argsAlloc(alloc); | ||
| 1178 | const o = parseArgs(args) orelse { | ||
| 1179 | // Usage is diagnostic, so it goes to stderr: stdout stays strictly | ||
| 1180 | // one JSON object per invocation, even on the argument-error path. | ||
| 1181 | proto.writeAllFd(std.posix.STDERR_FILENO, usage) catch {}; | ||
| 1182 | return 2; | ||
| 1183 | }; | ||
| 1184 | |||
| 1185 | // Started BEFORE the connect, not after: over QUIC the handshake is | ||
| 1186 | // part of the round trip the caller bounded, and a `--timeout` that | ||
| 1187 | // began counting only once the connection was up would promise | ||
| 1188 | // something different on the two transports. Over a unix socket the | ||
| 1189 | // connect is a syscall, so this moves the instant by microseconds. | ||
| 1190 | const deadline = deadlineFor(o.timeout_ms); | ||
| 1191 | |||
| 1192 | if (o.quic) |host_port| { | ||
| 1193 | var conn = switch (openQuicConn(alloc, o, host_port, deadline)) { | ||
| 1194 | .conn => |c| c, | ||
| 1195 | .exit => |code| return code, | ||
| 1196 | }; | ||
| 1197 | defer conn.close(); | ||
| 1198 | return dispatch(alloc, &conn, o, deadline); | ||
| 1199 | } | ||
| 1200 | |||
| 1201 | const sock_path = if (o.sock) |s| s else sockpath.defaultSockPath(alloc) catch |err| switch (err) { | ||
| 1202 | // An agent reads replies, not stderr, so this one refuses through | ||
| 1203 | // the same JSON shape as every other muxa failure. | ||
| 1204 | error.NoRuntimeDir => return fail( | ||
| 1205 | "no default socket path", | ||
| 1206 | "XDG_RUNTIME_DIR is unset; name the socket with --sock", | ||
| 1207 | ), | ||
| 1208 | else => |e| return e, | ||
| 1209 | }; | ||
| 1210 | |||
| 1211 | // Refused by name, before connecting: connect would bounce a too-long | ||
| 1212 | // path off the kernel with a generic error, and the path is the whole | ||
| 1213 | // story. Every binary owes this check in its own words (sockpath). | ||
| 1214 | if (sock_path.len > sockpath.max_sun_path) { | ||
| 1215 | var buf: [64]u8 = undefined; | ||
| 1216 | const detail = std.fmt.bufPrint( | ||
| 1217 | &buf, | ||
| 1218 | "{d} bytes, max {d}", | ||
| 1219 | .{ sock_path.len, sockpath.max_sun_path }, | ||
| 1220 | ) catch "too long"; | ||
| 1221 | return fail("socket path too long", detail); | ||
| 1222 | } | ||
| 1223 | |||
| 1224 | var conn = Conn.open(alloc, sock_path) catch |e| { | ||
| 1225 | // The path goes in the detail: a muxa pointed at the wrong socket | ||
| 1226 | // is this binary's likeliest field failure, and an agent reading | ||
| 1227 | // "FileNotFound" alone cannot tell which path it was that missed. | ||
| 1228 | var buf: [256]u8 = undefined; | ||
| 1229 | const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ sock_path, @errorName(e) }) catch | ||
| 1230 | @errorName(e); | ||
| 1231 | return fail("cannot connect to the daemon", detail); | ||
| 1232 | }; | ||
| 1233 | defer conn.close(); | ||
| 1234 | |||
| 1235 | return dispatch(alloc, &conn, o, deadline); | ||
| 1236 | } | ||
| 1237 | |||
| 1238 | /// The verbs, once. Both transports arrive here with a Conn and nothing | ||
| 1239 | /// else that distinguishes them, which is the property `--quic` is selling. | ||
| 1240 | fn dispatch(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 { | ||
| 1241 | return switch (o.verb) { | ||
| 1242 | .status => verbStatus(alloc, conn, o.session, deadline), | ||
| 1243 | .capture => verbCapture(alloc, conn, o.vt, o.session, deadline), | ||
| 1244 | .send => verbSend(alloc, conn, o.arg, o.session, deadline), | ||
| 1245 | // The one thing `run` needs that `await` does not, checked here so | ||
| 1246 | // the shared pipeline below can read `cmdline == null` as "this is | ||
| 1247 | // an await" rather than as "a run that was spelled wrong". | ||
| 1248 | .run => if (o.arg) |cmdline| | ||
| 1249 | awaitVerb(alloc, conn, o, deadline, cmdline) | ||
| 1250 | else | ||
| 1251 | fail("run: needs CMDLINE", ""), | ||
| 1252 | .await => awaitVerb(alloc, conn, o, deadline, null), | ||
| 1253 | }; | ||
| 1254 | } | ||
| 1255 | |||
| 1256 | /// A QUIC transport, or the exit code standing in for the reason there is | ||
| 1257 | /// not one. Every refusal here goes through `fail`, so a dial that never | ||
| 1258 | /// happened prints the same one-JSON-object-on-stdout shape as a verb that | ||
| 1259 | /// ran — an agent parses one thing whatever went wrong. | ||
| 1260 | const Opened = union(enum) { conn: Conn, exit: u8 }; | ||
| 1261 | |||
| 1262 | fn openQuicConn( | ||
| 1263 | alloc: std.mem.Allocator, | ||
| 1264 | o: Opts, | ||
| 1265 | host_port: []const u8, | ||
| 1266 | deadline: i64, | ||
| 1267 | ) Opened { | ||
| 1268 | // `--key`, then `$MUX_KEY_FILE`, then the XDG default if it exists. | ||
| 1269 | // The order is not spelled here on purpose: xdg owns it, mux reads the | ||
| 1270 | // same two functions, and a copy that drifted is how two binaries end | ||
| 1271 | // up authenticating with different keys. (muxd's pickKey stands apart | ||
| 1272 | // deliberately: it takes the default only when the file exists.) | ||
| 1273 | const res = xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv("MUX_KEY_FILE"))) catch |e| | ||
| 1274 | return .{ .exit = fail("quic: cannot resolve a key path", @errorName(e)) }; | ||
| 1275 | const key_path = switch (res) { | ||
| 1276 | .given, .default => |p| p, | ||
| 1277 | // The path is the detail because it is the actionable half: the | ||
| 1278 | // agent (or the human reading its log) needs to know which file | ||
| 1279 | // `muxd keygen` was supposed to have written. | ||
| 1280 | .missing => |p| return .{ .exit = fail( | ||
| 1281 | "quic: no key: pass --key, set MUX_KEY_FILE, or run `muxd keygen`", | ||
| 1282 | p, | ||
| 1283 | ) }, | ||
| 1284 | }; | ||
| 1285 | const key = quic_client.Key.load(key_path) catch |e| { | ||
| 1286 | // The daemon's words for a key the daemon would also refuse — | ||
| 1287 | // including the group/other-readable refusal, which this binary | ||
| 1288 | // gets for free by loading the key the same way. | ||
| 1289 | var buf: [quic_client.key_refusal_len]u8 = undefined; | ||
| 1290 | return .{ .exit = fail("quic: unusable key", quic_client.keyRefusalBody(&buf, e, key_path)) }; | ||
| 1291 | }; | ||
| 1292 | const addr = quic.parseAddr(alloc, host_port) catch |e| { | ||
| 1293 | var buf: [512]u8 = undefined; | ||
| 1294 | const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ host_port, @errorName(e) }) catch | ||
| 1295 | @errorName(e); | ||
| 1296 | return .{ .exit = fail("quic: cannot read HOST:PORT", detail) }; | ||
| 1297 | }; | ||
| 1298 | const conn = Conn.openQuic(alloc, addr, key, quic_client.default_idle_ms, deadline) catch |e| { | ||
| 1299 | var buf: [512]u8 = undefined; | ||
| 1300 | const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ host_port, @errorName(e) }) catch | ||
| 1301 | @errorName(e); | ||
| 1302 | return .{ .exit = fail("cannot connect to the daemon", detail) }; | ||
| 1303 | }; | ||
| 1304 | return .{ .conn = conn }; | ||
| 1305 | } | ||
| 1306 | |||
| 1307 | fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, session: []const u8, deadline: i64) !u8 { | ||
| 1308 | // status_req's WHOLE payload is the name — this connection never | ||
| 1309 | // attaches (see attachZero's callers; `status` is not one of them), so | ||
| 1310 | // there is no slot for the daemon to fall back to and the tail is the | ||
| 1311 | // only word this ask gets to say. | ||
| 1312 | conn.sendFrame(.status_req, session, deadline) catch |e| | ||
| 1313 | return failSend(e, session, .query, "status", "send failed"); | ||
| 1314 | const frame = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) { | ||
| 1315 | error.AttachRefused => return failAttachRefused(session, .query), | ||
| 1316 | error.SessionExited => return failSessionEnded(conn.session_exit), | ||
| 1317 | else => return fail("status: no reply", @errorName(e)), | ||
| 1318 | }; | ||
| 1319 | defer frame.deinit(alloc); | ||
| 1320 | const st = proto.decodeStatusReply(frame.payload) catch |e| | ||
| 1321 | return fail("status: bad reply", @errorName(e)); | ||
| 1322 | |||
| 1323 | var out: std.ArrayList(u8) = .empty; | ||
| 1324 | defer out.deinit(alloc); | ||
| 1325 | try printStatus(out.writer(alloc), st); | ||
| 1326 | return emit(out.items, 0); | ||
| 1327 | } | ||
| 1328 | |||
| 1329 | /// A command that has not returned, or whose mechanism cannot know a | ||
| 1330 | /// code, has no exit code: 0 would read as "succeeded". | ||
| 1331 | fn writeExitCode(writer: anytype, code: ?u8) !void { | ||
| 1332 | if (code) |c| { | ||
| 1333 | try writer.print("{d}", .{c}); | ||
| 1334 | } else { | ||
| 1335 | try writer.writeAll("null"); | ||
| 1336 | } | ||
| 1337 | } | ||
| 1338 | |||
| 1339 | /// The five CmdState fields `status` and `await`/`run` both publish. | ||
| 1340 | /// Written as a bare fragment — no braces, no leading or trailing | ||
| 1341 | /// comma — because the two verbs nest it differently: `status` puts | ||
| 1342 | /// it inside a `"cmd"` object and follows it with the seq, while | ||
| 1343 | /// `await` inlines it at the top level and follows it with the | ||
| 1344 | /// duration. | ||
| 1345 | fn writeCmdFields(writer: anytype, st: proto.CmdState) !void { | ||
| 1346 | try writer.writeAll("\"phase\":"); | ||
| 1347 | try jsonEscape(writer, @tagName(st.phase)); | ||
| 1348 | try writer.writeAll(",\"mechanism\":"); | ||
| 1349 | try jsonEscape(writer, @tagName(st.mechanism)); | ||
| 1350 | try writer.writeAll(",\"exit_code\":"); | ||
| 1351 | try writeExitCode(writer, st.exit_code); | ||
| 1352 | try writer.print(",\"start_row\":{d},\"end_row\":{d}", .{ st.start_row, st.end_row }); | ||
| 1353 | } | ||
| 1354 | |||
| 1355 | fn printStatus(writer: anytype, st: proto.StatusReply) !void { | ||
| 1356 | try writer.print( | ||
| 1357 | "{{\"cols\":{d},\"rows\":{d},\"cursor\":{{\"x\":{d},\"y\":{d}}}," ++ | ||
| 1358 | "\"history_rows\":{d},\"alt_screen\":{},\"icanon\":{},\"echo\":{},\"cmd\":{{", | ||
| 1359 | .{ st.cols, st.rows, st.cursor_x, st.cursor_y, st.history_rows, st.alt_screen, st.mode.icanon, st.mode.echo }, | ||
| 1360 | ); | ||
| 1361 | try writeCmdFields(writer, st.cmd); | ||
| 1362 | // The watermark, and only `status` carries it: this is the number an | ||
| 1363 | // agent feeds back as `since_seq`, which is why `await` does not print | ||
| 1364 | // one (see proto.CmdState.seq). | ||
| 1365 | try writer.print(",\"seq\":{d}}}}}\n", .{st.cmd.seq}); | ||
| 1366 | } | ||
| 1367 | |||
| 1368 | test "printStatus spells a pending exit code as JSON null" { | ||
| 1369 | var buf: [512]u8 = undefined; | ||
| 1370 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 1371 | try printStatus(fbs.writer(), .{ | ||
| 1372 | .cols = 80, | ||
| 1373 | .rows = 24, | ||
| 1374 | .cursor_x = 1, | ||
| 1375 | .cursor_y = 2, | ||
| 1376 | .history_rows = 7, | ||
| 1377 | .alt_screen = false, | ||
| 1378 | .mode = .{ .icanon = true, .echo = true }, | ||
| 1379 | .cmd = .{ .phase = .running, .mechanism = .marks, .exit_code = null, .start_row = 3, .end_row = 4, .seq = 9 }, | ||
| 1380 | }); | ||
| 1381 | // The whole object, byte for byte, not a handful of substrings: this is | ||
| 1382 | // muxa's published contract with an agent's JSON parser, and the fields | ||
| 1383 | // it shares with `await` are written by a helper both verbs call — a | ||
| 1384 | // pin on the parts cannot see a comma or a nesting level move. | ||
| 1385 | try std.testing.expectEqualStrings( | ||
| 1386 | "{\"cols\":80,\"rows\":24,\"cursor\":{\"x\":1,\"y\":2},\"history_rows\":7," ++ | ||
| 1387 | "\"alt_screen\":false,\"icanon\":true,\"echo\":true," ++ | ||
| 1388 | "\"cmd\":{\"phase\":\"running\",\"mechanism\":\"marks\",\"exit_code\":null," ++ | ||
| 1389 | "\"start_row\":3,\"end_row\":4,\"seq\":9}}\n", | ||
| 1390 | fbs.getWritten(), | ||
| 1391 | ); | ||
| 1392 | } | ||
| 1393 | |||
| 1394 | fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, session: []const u8, deadline: i64) !u8 { | ||
| 1395 | // vt byte ++ session-name tail, the same shape muxd's own `dump` sends | ||
| 1396 | // — and built by the same encoder, so it cannot drift from it. | ||
| 1397 | var buf: [proto.debug_dump_max_len]u8 = undefined; | ||
| 1398 | const payload = proto.encodeDebugDumpNamed(&buf, vt, session); | ||
| 1399 | conn.sendFrame(.debug_dump, payload, deadline) catch |e| | ||
| 1400 | return failSend(e, session, .query, "capture", "send failed"); | ||
| 1401 | const frame = conn.awaitFrame(.dump_reply, deadline) catch |e| switch (e) { | ||
| 1402 | error.AttachRefused => return failAttachRefused(session, .query), | ||
| 1403 | error.SessionExited => return failSessionEnded(conn.session_exit), | ||
| 1404 | else => return fail("capture: no reply", @errorName(e)), | ||
| 1405 | }; | ||
| 1406 | defer frame.deinit(alloc); | ||
| 1407 | |||
| 1408 | var out: std.ArrayList(u8) = .empty; | ||
| 1409 | defer out.deinit(alloc); | ||
| 1410 | const writer = out.writer(alloc); | ||
| 1411 | try writer.writeAll("{\"grid\":"); | ||
| 1412 | try jsonEscape(writer, frame.payload); | ||
| 1413 | try writer.writeAll("}\n"); | ||
| 1414 | return emit(out.items, 0); | ||
| 1415 | } | ||
| 1416 | |||
| 1417 | /// Join claiming NO grid: applySize refuses under 2, so the 0x0 slot | ||
| 1418 | /// makes no claim and no human's terminal is resized. `name` is | ||
| 1419 | /// joins-only: a 0x0 attach cannot create, resolveSession demands a | ||
| 1420 | /// real size. | ||
| 1421 | fn attachZero(conn: *Conn, name: []const u8, deadline: i64) !void { | ||
| 1422 | // Cleared here and nowhere else: the reconnect path attaches a SECOND | ||
| 1423 | // time on a connection that has already seen a snapshot, and a stale | ||
| 1424 | // true would read that reconnect's refusal as the session ending. | ||
| 1425 | conn.saw_snapshot = false; | ||
| 1426 | var buf: [proto.attach_max_len]u8 = undefined; | ||
| 1427 | try conn.sendFrame(.attach, proto.encodeAttachNamed(&buf, 0, 0, 0, 0, name), deadline); | ||
| 1428 | } | ||
| 1429 | |||
| 1430 | fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: ?[]const u8, session: []const u8, deadline: i64) !u8 { | ||
| 1431 | const spec = arg orelse return fail("send: needs BYTES", ""); | ||
| 1432 | const bytes = decodeEscapes(alloc, spec) catch |e| return fail("send: bad escape", @errorName(e)); | ||
| 1433 | defer alloc.free(bytes); | ||
| 1434 | |||
| 1435 | attachZero(conn, session, deadline) catch |e| | ||
| 1436 | return failSend(e, session, .attach, "send", "attach failed"); | ||
| 1437 | conn.sendFrame(.input, bytes, deadline) catch |e| | ||
| 1438 | return failSend(e, session, .attach, "send", "input failed"); | ||
| 1439 | |||
| 1440 | // Write-and-close LOSES the input, and not as a rare race: attaching | ||
| 1441 | // queues a snapshot, and the daemon flushes a client's pending bytes | ||
| 1442 | // BEFORE it reads that client (server.zig's poll arm). Closing straight | ||
| 1443 | // after the write means the flush hits EPIPE, the daemon drops us, and | ||
| 1444 | // the input frame is discarded still unread. Measured: closing at once | ||
| 1445 | // never lands, while any delay or drain always does. | ||
| 1446 | // | ||
| 1447 | // So the round trip is the acknowledgement. Frames are served in stream | ||
| 1448 | // order, so a status_reply is proof the daemon has already read PAST the | ||
| 1449 | // input frame and fed it to the pty; awaitFrame skips the snapshot and | ||
| 1450 | // the pushes on the way, which is what keeps the socket drained enough | ||
| 1451 | // for that flush to succeed. Nothing is done with the reply — its | ||
| 1452 | // arrival is the whole content. | ||
| 1453 | // | ||
| 1454 | // The same `session` as the attach above, not "" — the daemon's | ||
| 1455 | // attached-tail rule (server.zig) answers only a tail that names the | ||
| 1456 | // slot's own session, and this connection attached to `session`. | ||
| 1457 | conn.sendFrame(.status_req, session, deadline) catch |e| | ||
| 1458 | return failSend(e, session, .attach, "send", "ack request failed"); | ||
| 1459 | const ack = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) { | ||
| 1460 | // The bytes we sent ended the session (`exit\n`). Reported as the | ||
| 1461 | // session's death rather than as "sent", because this verb's answer | ||
| 1462 | // is about the send and there is no longer a session to have sent | ||
| 1463 | // to — an agent that wants the death to be an ANSWER runs `run`. | ||
| 1464 | error.AttachRefused => return failAttachRefused(session, .attach), | ||
| 1465 | error.SessionExited => return failSessionEnded(conn.session_exit), | ||
| 1466 | else => return fail("send: daemon never acknowledged the input", @errorName(e)), | ||
| 1467 | }; | ||
| 1468 | ack.deinit(alloc); | ||
| 1469 | |||
| 1470 | conn.sendFrame(.detach, "", deadline) catch |e| return fail("send: detach failed", @errorName(e)); | ||
| 1471 | |||
| 1472 | return emit("{\"sent\":true}\n", 0); | ||
| 1473 | } | ||
| 1474 | |||
| 1475 | /// How much longer than the daemon this client is willing to wait. | ||
| 1476 | /// | ||
| 1477 | /// Load-bearing: the daemon starts its own `timeout_ms` window when it | ||
| 1478 | /// READS the await_req, which is already later than the instant this | ||
| 1479 | /// process started counting. Waiting exactly `timeout_ms` here would lose | ||
| 1480 | /// that race every single time, and every timeout would surface as | ||
| 1481 | /// `{"error":"await: no reply"}` instead of the structured | ||
| 1482 | /// `{"reason":"timeout"}` with exit 3 that the agent is meant to read. | ||
| 1483 | const await_grace_ms = 2_000; | ||
| 1484 | |||
| 1485 | /// The ceiling on the QUIC arm's derived grace (Conn.graceMs), and the | ||
| 1486 | /// reason it has one is that `connect_ms` has no bound of its own worth | ||
| 1487 | /// multiplying by four. | ||
| 1488 | const grace_cap_ms = 30_000; | ||
| 1489 | |||
| 1490 | /// How long `sendFrameQuic` will keep offering a frame's tail to a full | ||
| 1491 | /// egress ring before giving up on it. Reaching this means the peer has | ||
| 1492 | /// stopped acknowledging 256KB of backlog, which is a dead connection | ||
| 1493 | /// wearing a different hat — but a bound is what keeps it from being an | ||
| 1494 | /// unbounded wait inside a call that has no deadline of its own. | ||
| 1495 | const send_flush_ms = 5_000; | ||
| 1496 | |||
| 1497 | /// How long a write that died of a closed peer will look for the refusal | ||
| 1498 | /// the peer left behind. The socket is already closed, so the drain ends at | ||
| 1499 | /// EOF long before this — the bound is for the half-closed case, where | ||
| 1500 | /// nothing else would end the wait. | ||
| 1501 | const refusal_drain_ms = 100; | ||
| 1502 | |||
| 1503 | /// The span fetch gets its own window rather than the tail of the run's: a | ||
| 1504 | /// command that returned in the last millisecond of `--timeout` still has a | ||
| 1505 | /// transcript worth having, and this round trip is a local read that either | ||
| 1506 | /// answers promptly or is not coming. | ||
| 1507 | const span_fetch_ms = 2_000; | ||
| 1508 | |||
| 1509 | /// No run deadline: `--timeout 0` would make the fetch unbounded. | ||
| 1510 | fn spanFetchDeadline() i64 { | ||
| 1511 | return deadlineFor(span_fetch_ms); | ||
| 1512 | } | ||
| 1513 | |||
| 1514 | test "the span fetch is bounded even when the run it follows was not" { | ||
| 1515 | // `--timeout 0` is the case that matters: the run's deadline is then | ||
| 1516 | // "never", and a fetch that inherited it would outlive the answer. | ||
| 1517 | try std.testing.expectEqual(std.math.maxInt(i64), deadlineFor(0)); | ||
| 1518 | |||
| 1519 | const before = std.time.milliTimestamp(); | ||
| 1520 | const span = spanFetchDeadline(); | ||
| 1521 | try std.testing.expect(span >= before); | ||
| 1522 | try std.testing.expect(span <= std.time.milliTimestamp() + span_fetch_ms); | ||
| 1523 | } | ||
| 1524 | |||
| 1525 | /// Ask to be told when the session next comes to rest, and wait for it. | ||
| 1526 | fn doAwait( | ||
| 1527 | alloc: std.mem.Allocator, | ||
| 1528 | conn: *Conn, | ||
| 1529 | o: Opts, | ||
| 1530 | since_seq: u64, | ||
| 1531 | deadline: i64, | ||
| 1532 | ) !proto.AwaitReply { | ||
| 1533 | var buf: [proto.await_req_max_len]u8 = undefined; | ||
| 1534 | const payload = proto.encodeAwaitReqNamed(&buf, .{ | ||
| 1535 | .since_seq = since_seq, | ||
| 1536 | .settle_ms = o.settle_ms, | ||
| 1537 | .timeout_ms = o.timeout_ms, | ||
| 1538 | }, o.session); | ||
| 1539 | try conn.sendFrame(.await_req, payload, deadline); | ||
| 1540 | const frame = try conn.awaitFrame(.await_reply, deadline); | ||
| 1541 | defer frame.deinit(alloc); | ||
| 1542 | return try proto.decodeAwaitReply(frame.payload); | ||
| 1543 | } | ||
| 1544 | |||
| 1545 | /// At-most-once: the re-issue re-sends the attach and the request, | ||
| 1546 | /// never `run`'s input. | ||
| 1547 | fn awaitReissuing( | ||
| 1548 | alloc: std.mem.Allocator, | ||
| 1549 | conn: *Conn, | ||
| 1550 | o: Opts, | ||
| 1551 | since_seq: u64, | ||
| 1552 | deadline: i64, | ||
| 1553 | ) !proto.AwaitReply { | ||
| 1554 | return doAwait(alloc, conn, o, since_seq, deadline) catch |e| switch (e) { | ||
| 1555 | // `SendStalled` never redials: the peer is still there, it has | ||
| 1556 | // just stopped acking, so a redial would be a second guess about | ||
| 1557 | // a connection that never said it was gone. | ||
| 1558 | error.ConnectionLost => { | ||
| 1559 | // Once per process, not per await: a loop here is a client | ||
| 1560 | // that hides a daemon that is gone. | ||
| 1561 | if (conn.link != .quic or conn.link.quic.reconnected) return e; | ||
| 1562 | // The deadline continues across the redial — four seconds | ||
| 1563 | // spent redialling are four seconds of the caller's wait, not | ||
| 1564 | // a fresh bound. | ||
| 1565 | conn.reconnect(deadline) catch |redial| { | ||
| 1566 | conn.reconnect_failure = @errorName(redial); | ||
| 1567 | return e; | ||
| 1568 | }; | ||
| 1569 | // The attach is part of the reconnect, not a separate step: a | ||
| 1570 | // daemon that lost our connection lost the client slot with | ||
| 1571 | // it, so an await_req arriving unattached asks about nothing. | ||
| 1572 | // A failure here is still the reconnect failing. | ||
| 1573 | attachZero(conn, o.session, deadline) catch |reattach| { | ||
| 1574 | // A refused re-attach is the daemon's answer, not the tear | ||
| 1575 | // that got us here: `ConnectionLost` would send an agent | ||
| 1576 | // to check the network for a session that is gone. | ||
| 1577 | if (reattach == error.AttachRefused) return reattach; | ||
| 1578 | conn.reconnect_failure = @errorName(reattach); | ||
| 1579 | return e; | ||
| 1580 | }; | ||
| 1581 | // The SAME `since_seq`, re-read from nothing: the request is a | ||
| 1582 | // question about a watermark, so re-asking it is idempotent, | ||
| 1583 | // while a watermark taken from the new connection would sit | ||
| 1584 | // past a return that happened while we were disconnected. | ||
| 1585 | return doAwait(alloc, conn, o, since_seq, deadline); | ||
| 1586 | }, | ||
| 1587 | else => e, | ||
| 1588 | }; | ||
| 1589 | } | ||
| 1590 | |||
| 1591 | /// What a wait that ended without a reply says past the verb's own | ||
| 1592 | /// "no reply". Every error but one is its own name — the socket | ||
| 1593 | /// arm's failures are untouched — because `ConnectionLost` is the | ||
| 1594 | /// only one whose name is half the story. | ||
| 1595 | /// | ||
| 1596 | /// The three endings a lost connection has, and they are worth | ||
| 1597 | /// telling apart: the redial failed (why), the redial had already | ||
| 1598 | /// been spent (so this is the second tear of the same wait), or | ||
| 1599 | /// nothing tried to redial. | ||
| 1600 | fn waitFailDetail(buf: []u8, conn: *const Conn, e: anyerror) []const u8 { | ||
| 1601 | if (e != error.ConnectionLost) return @errorName(e); | ||
| 1602 | if (conn.reconnect_failure) |why| { | ||
| 1603 | return std.fmt.bufPrint(buf, "connection lost; reconnect failed: {s}", .{why}) catch | ||
| 1604 | "connection lost; reconnect failed"; | ||
| 1605 | } | ||
| 1606 | const spent = switch (conn.link) { | ||
| 1607 | .quic => |q| q.reconnected, | ||
| 1608 | .fd => false, | ||
| 1609 | }; | ||
| 1610 | if (spent) return "connection lost again, after the one reconnect"; | ||
| 1611 | return "connection lost"; | ||
| 1612 | } | ||
| 1613 | |||
| 1614 | /// The session's RETURN WATERMARK: the seq of the last command return, 0 if | ||
| 1615 | /// none. Handed straight to `since_seq`, where it means "only a return | ||
| 1616 | /// newer than this may answer me". | ||
| 1617 | fn currentSeq(alloc: std.mem.Allocator, conn: *Conn, session: []const u8, deadline: i64) !u64 { | ||
| 1618 | // Same session as the attach that precedes this call — the attached- | ||
| 1619 | // tail equality rule (server.zig) demands it. | ||
| 1620 | try conn.sendFrame(.status_req, session, deadline); | ||
| 1621 | const frame = try conn.awaitFrame(.status_reply, deadline); | ||
| 1622 | defer frame.deinit(alloc); | ||
| 1623 | const s = try proto.decodeStatusReply(frame.payload); | ||
| 1624 | return s.cmd.seq; | ||
| 1625 | } | ||
| 1626 | |||
| 1627 | /// Strip the styling out of scrollback rows: an agent reading `output` | ||
| 1628 | /// wants what the command printed, not how it was coloured. | ||
| 1629 | /// | ||
| 1630 | /// CSI (ESC [ … final byte) and OSC (ESC ] … BEL or ST) go, as does any | ||
| 1631 | /// other two-byte escape; text and newlines stay. Deliberately not a VT | ||
| 1632 | /// parser — these rows come from our own formatter, which emits SGR and | ||
| 1633 | /// nothing more exotic. Caller frees. | ||
| 1634 | fn stripSgr(alloc: std.mem.Allocator, s: []const u8) ![]u8 { | ||
| 1635 | var out: std.ArrayList(u8) = .empty; | ||
| 1636 | errdefer out.deinit(alloc); | ||
| 1637 | var i: usize = 0; | ||
| 1638 | while (i < s.len) { | ||
| 1639 | if (s[i] != 0x1b or i + 1 >= s.len) { | ||
| 1640 | try out.append(alloc, s[i]); | ||
| 1641 | i += 1; | ||
| 1642 | continue; | ||
| 1643 | } | ||
| 1644 | switch (s[i + 1]) { | ||
| 1645 | '[' => { | ||
| 1646 | i += 2; | ||
| 1647 | // Parameter and intermediate bytes, then one final byte in | ||
| 1648 | // 0x40..0x7e that ends the sequence. | ||
| 1649 | while (i < s.len and (s[i] < 0x40 or s[i] > 0x7e)) i += 1; | ||
| 1650 | if (i < s.len) i += 1; | ||
| 1651 | }, | ||
| 1652 | ']' => { | ||
| 1653 | i += 2; | ||
| 1654 | while (i < s.len) : (i += 1) { | ||
| 1655 | if (s[i] == 0x07) { | ||
| 1656 | i += 1; | ||
| 1657 | break; | ||
| 1658 | } | ||
| 1659 | if (s[i] == 0x1b and i + 1 < s.len and s[i + 1] == '\\') { | ||
| 1660 | i += 2; | ||
| 1661 | break; | ||
| 1662 | } | ||
| 1663 | } | ||
| 1664 | }, | ||
| 1665 | // ESC 7, ESC M and friends: two bytes, both dropped. | ||
| 1666 | else => i += 2, | ||
| 1667 | } | ||
| 1668 | } | ||
| 1669 | return out.toOwnedSlice(alloc); | ||
| 1670 | } | ||
| 1671 | |||
| 1672 | test "stripSgr leaves text, drops SGR and OSC" { | ||
| 1673 | const alloc = std.testing.allocator; | ||
| 1674 | const got = try stripSgr(alloc, "\x1b[0m\x1b[1;31mred\x1b[0m ok\n\x1b]0;title\x07plain"); | ||
| 1675 | defer alloc.free(got); | ||
| 1676 | try std.testing.expectEqualStrings("red ok\nplain", got); | ||
| 1677 | } | ||
| 1678 | |||
| 1679 | /// Rows go stale between reply and fetch, so failure here is a null | ||
| 1680 | /// output, not a failed run. | ||
| 1681 | fn fetchSpan( | ||
| 1682 | alloc: std.mem.Allocator, | ||
| 1683 | conn: *Conn, | ||
| 1684 | start_row: u32, | ||
| 1685 | end_row: u32, | ||
| 1686 | deadline: i64, | ||
| 1687 | ) !?[]u8 { | ||
| 1688 | if (end_row <= start_row) return null; | ||
| 1689 | const count: u16 = @intCast(@min(end_row - start_row, std.math.maxInt(u16))); | ||
| 1690 | try conn.sendFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start_row, count), deadline); | ||
| 1691 | const frame = try conn.awaitFrame(.scrollback_chunk, deadline); | ||
| 1692 | defer frame.deinit(alloc); | ||
| 1693 | // The chunk leads with the request it answers; the rows follow. | ||
| 1694 | if (frame.payload.len <= 6) return null; | ||
| 1695 | return try stripSgr(alloc, frame.payload[6..]); | ||
| 1696 | } | ||
| 1697 | |||
| 1698 | /// `output` is absent when there is no transcript: an absent key and | ||
| 1699 | /// an empty string differ. | ||
| 1700 | fn printAwaitReply( | ||
| 1701 | writer: anytype, | ||
| 1702 | r: proto.AwaitReply, | ||
| 1703 | output: ?[]const u8, | ||
| 1704 | duration_ms: i64, | ||
| 1705 | ) !void { | ||
| 1706 | try writer.writeAll("{\"reason\":"); | ||
| 1707 | try jsonEscape(writer, @tagName(r.reason)); | ||
| 1708 | try writer.writeAll(","); | ||
| 1709 | try writeCmdFields(writer, r.state); | ||
| 1710 | try writer.print(",\"duration_ms\":{d}", .{duration_ms}); | ||
| 1711 | if (output) |text| { | ||
| 1712 | try writer.writeAll(",\"output\":"); | ||
| 1713 | try jsonEscape(writer, text); | ||
| 1714 | } | ||
| 1715 | try writer.writeAll("}\n"); | ||
| 1716 | } | ||
| 1717 | |||
| 1718 | test "printAwaitReply omits output when there is none and spells a missing code null" { | ||
| 1719 | const alloc = std.testing.allocator; | ||
| 1720 | const r: proto.AwaitReply = .{ | ||
| 1721 | .state = .{ | ||
| 1722 | .phase = .returned, | ||
| 1723 | .mechanism = .settle, | ||
| 1724 | .exit_code = null, | ||
| 1725 | .start_row = 3, | ||
| 1726 | .end_row = 9, | ||
| 1727 | .seq = 12, | ||
| 1728 | }, | ||
| 1729 | .reason = .settled, | ||
| 1730 | }; | ||
| 1731 | |||
| 1732 | var bare: std.ArrayList(u8) = .empty; | ||
| 1733 | defer bare.deinit(alloc); | ||
| 1734 | try printAwaitReply(bare.writer(alloc), r, null, 250); | ||
| 1735 | try std.testing.expectEqualStrings( | ||
| 1736 | "{\"reason\":\"settled\",\"phase\":\"returned\",\"mechanism\":\"settle\"," ++ | ||
| 1737 | "\"exit_code\":null,\"start_row\":3,\"end_row\":9,\"duration_ms\":250}\n", | ||
| 1738 | bare.items, | ||
| 1739 | ); | ||
| 1740 | |||
| 1741 | var with: std.ArrayList(u8) = .empty; | ||
| 1742 | defer with.deinit(alloc); | ||
| 1743 | try printAwaitReply(with.writer(alloc), r, "a\nb", 250); | ||
| 1744 | try std.testing.expect(std.mem.indexOf(u8, with.items, "\"output\":\"a\\nb\"") != null); | ||
| 1745 | } | ||
| 1746 | |||
| 1747 | /// The session ran its last command. An ANSWER for `run` and `await` — the | ||
| 1748 | /// command is over and this is how — so it prints on stdout and exits 0, | ||
| 1749 | /// unlike the other verbs, which have nothing to report and fail. | ||
| 1750 | fn printSessionEnded(writer: anytype, code: ?u8, duration_ms: i64) !void { | ||
| 1751 | try writer.writeAll("{\"reason\":\"session_ended\",\"exit_code\":"); | ||
| 1752 | try writeExitCode(writer, code); | ||
| 1753 | try writer.print(",\"duration_ms\":{d}}}\n", .{duration_ms}); | ||
| 1754 | } | ||
| 1755 | |||
| 1756 | /// Timeout is the only nonzero code: a command returning nonzero | ||
| 1757 | /// failed in `exit_code`, not here. | ||
| 1758 | fn reportAwait( | ||
| 1759 | alloc: std.mem.Allocator, | ||
| 1760 | r: proto.AwaitReply, | ||
| 1761 | output: ?[]const u8, | ||
| 1762 | duration_ms: i64, | ||
| 1763 | ) !u8 { | ||
| 1764 | var out: std.ArrayList(u8) = .empty; | ||
| 1765 | defer out.deinit(alloc); | ||
| 1766 | try printAwaitReply(out.writer(alloc), r, output, duration_ms); | ||
| 1767 | return emit(out.items, if (r.reason == .timeout) 3 else 0); | ||
| 1768 | } | ||
| 1769 | |||
| 1770 | fn reportSessionEnded(alloc: std.mem.Allocator, code: ?u8, duration_ms: i64) !u8 { | ||
| 1771 | var out: std.ArrayList(u8) = .empty; | ||
| 1772 | defer out.deinit(alloc); | ||
| 1773 | try printSessionEnded(out.writer(alloc), code, duration_ms); | ||
| 1774 | return emit(out.items, 0); | ||
| 1775 | } | ||
| 1776 | |||
| 1777 | /// `run` is `await` with a command line put in: `cmdline` non-null is | ||
| 1778 | /// the whole difference. | ||
| 1779 | fn awaitVerb( | ||
| 1780 | alloc: std.mem.Allocator, | ||
| 1781 | conn: *Conn, | ||
| 1782 | o: Opts, | ||
| 1783 | deadline: i64, | ||
| 1784 | cmdline: ?[]const u8, | ||
| 1785 | ) !u8 { | ||
| 1786 | // Every error string this function can print names the verb the user | ||
| 1787 | // typed, because "attach failed" from the wrong verb sends an agent | ||
| 1788 | // looking in the wrong place. | ||
| 1789 | const who = if (cmdline == null) "await" else "run"; | ||
| 1790 | const started = std.time.milliTimestamp(); | ||
| 1791 | |||
| 1792 | attachZero(conn, o.session, deadline) catch |e| | ||
| 1793 | return failSend(e, o.session, .attach, who, "attach failed"); | ||
| 1794 | |||
| 1795 | // BEFORE the input, not after: the watermark has to be the one this | ||
| 1796 | // command must beat. Read afterwards, a command fast enough to return | ||
| 1797 | // between the two would have already moved the seq past a value we | ||
| 1798 | // never recorded, and the await would sit waiting for a return that | ||
| 1799 | // had happened. | ||
| 1800 | const since = currentSeq(alloc, conn, o.session, deadline) catch |e| switch (e) { | ||
| 1801 | error.AttachRefused => return failAttachRefused(o.session, .attach), | ||
| 1802 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), | ||
| 1803 | else => return failAs(who, "status failed", @errorName(e)), | ||
| 1804 | }; | ||
| 1805 | |||
| 1806 | if (cmdline) |cmd| { | ||
| 1807 | // The cmdline goes to the pty verbatim — escapes are `send`'s | ||
| 1808 | // business — plus the newline that submits it. No ack round-trip is | ||
| 1809 | // needed the way `send` needs one: the await_req that follows is | ||
| 1810 | // itself the read that proves the daemon got past this frame, and | ||
| 1811 | // this process stays connected until the reply lands. | ||
| 1812 | const line = std.fmt.allocPrint(alloc, "{s}\n", .{cmd}) catch |e| | ||
| 1813 | return failAs(who, "cannot build the command line", @errorName(e)); | ||
| 1814 | defer alloc.free(line); | ||
| 1815 | conn.sendFrame(.input, line, deadline) catch |e| | ||
| 1816 | return failSend(e, o.session, .attach, who, "input failed"); | ||
| 1817 | } | ||
| 1818 | |||
| 1819 | const r = awaitReissuing(alloc, conn, o, since, awaitDeadline(o, conn)) catch |e| switch (e) { | ||
| 1820 | error.AttachRefused => return failAttachRefused(o.session, .attach), | ||
| 1821 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), | ||
| 1822 | else => { | ||
| 1823 | var detail: [128]u8 = undefined; | ||
| 1824 | return failAs(who, "no reply", waitFailDetail(&detail, conn, e)); | ||
| 1825 | }, | ||
| 1826 | }; | ||
| 1827 | |||
| 1828 | // Only the marks regime knows where the command's rows are; pgid and | ||
| 1829 | // settle answer WHEN, never WHERE, and a span from them would be a | ||
| 1830 | // guess dressed as a transcript. `await` never fetches one at all: it | ||
| 1831 | // did not start the command, so the span it would name is not its own. | ||
| 1832 | var output: ?[]u8 = null; | ||
| 1833 | defer if (output) |text| alloc.free(text); | ||
| 1834 | if (cmdline != null and r.state.mechanism == .marks and r.reason == .returned) { | ||
| 1835 | output = fetchSpan( | ||
| 1836 | alloc, | ||
| 1837 | conn, | ||
| 1838 | r.state.start_row, | ||
| 1839 | r.state.end_row, | ||
| 1840 | spanFetchDeadline(), | ||
| 1841 | ) catch null; | ||
| 1842 | } | ||
| 1843 | |||
| 1844 | return reportAwait(alloc, r, output, elapsed(started)); | ||
| 1845 | } | ||
| 1846 | |||
| 1847 | fn elapsed(started: i64) i64 { | ||
| 1848 | return std.time.milliTimestamp() - started; | ||
| 1849 | } | ||
| 1850 | |||
| 1851 | /// This client's deadline: the daemon's own bound plus the grace | ||
| 1852 | /// window (await_grace_ms, widened per transport by `Conn.graceMs`). | ||
| 1853 | /// An unbounded request stays unbounded. | ||
| 1854 | fn awaitDeadline(o: Opts, conn: *const Conn) i64 { | ||
| 1855 | if (o.timeout_ms == 0) return std.math.maxInt(i64); | ||
| 1856 | return std.time.milliTimestamp() + o.timeout_ms + conn.graceMs(); | ||
| 1857 | } | ||
| 1858 | |||
| 1859 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 1860 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 1861 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 1862 | test { | ||
| 1863 | std.testing.refAllDeclsRecursive(@This()); | ||
| 1864 | } | ||
src/cli/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,440 @@ | |||
| 1 | //! muxweb — the hub binary. `muxweb [TARGET ...] | ||
| 2 | //! [--port N]`: serves the wall page on 127.0.0.1 and pumps one | ||
| 3 | //! WebSocket per tile, dialing each TARGET the way the mux CLI does. | ||
| 4 | //! TARGET spellings are mux's own: bare HOST (ssh→QUIC handoff), | ||
| 5 | //! --sock PATH, quic://HOST[:PORT] (with --key / MUX_KEY_FILE as in | ||
| 6 | //! mux). A `#NAME` suffix on a TARGET names the daemon session that tile | ||
| 7 | //! attaches to, which is how one host becomes two tiles. The TARGET string | ||
| 8 | //! is the tile's label, suffix and all. | ||
| 9 | //! | ||
| 10 | //! The wall is now a persisted list the page edits at runtime, so argv is | ||
| 11 | //! an override of the VIEW only: with targets, argv is what this run shows | ||
| 12 | //! and is ADDED to the wall (deduped, nothing removed); without, the last | ||
| 13 | //! run's whole wall is restored and served. The standing | ||
| 14 | //! non-goal (no config file) survives — the state file is written by the | ||
| 15 | //! program, never by hand. | ||
| 16 | |||
| 17 | const std = @import("std"); | ||
| 18 | const client = @import("client"); | ||
| 19 | const webhub = @import("webhub"); | ||
| 20 | const wall = @import("wall"); | ||
| 21 | const build_options = @import("build_options"); | ||
| 22 | const xdg = @import("xdg"); | ||
| 23 | const sockpath = @import("sockpath"); | ||
| 24 | |||
| 25 | const usage = | ||
| 26 | \\usage: muxweb [TARGET[#SESSION] ...] [--port N] | ||
| 27 | \\ each TARGET is a tile: HOST | --sock PATH | quic://HOST[:PORT] | ||
| 28 | \\ `--sock PATH` may be two arguments or one quoted '--sock PATH', the | ||
| 29 | \\ spelling the wall file holds; `mux wall` takes both too | ||
| 30 | \\ with no TARGET the wall from the last run is restored; with TARGETs | ||
| 31 | \\ argv is added to the saved wall (deduped) and shown; nothing already | ||
| 32 | \\ there is removed | ||
| 33 | \\ #SESSION names the daemon session the tile attaches to (default: the | ||
| 34 | \\ default session) — the same host twice, two sessions, two tiles | ||
| 35 | \\ quic:// tiles use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key | ||
| 36 | \\ [--quic-idle-ms N] tunes how fast a dead link is noticed | ||
| 37 | \\ --port N serves on 127.0.0.1:N (default 7681); localhost only, | ||
| 38 | \\ remote viewing is `ssh -L` | ||
| 39 | \\ --version prints the version | ||
| 40 | \\ | ||
| 41 | ; | ||
| 42 | |||
| 43 | /// Refused at usage altitude; downstream it is a rejected attach in | ||
| 44 | /// one tile, unexplained. | ||
| 45 | fn addSpelling( | ||
| 46 | alloc: std.mem.Allocator, | ||
| 47 | list: *std.ArrayList([]const u8), | ||
| 48 | spelling: []const u8, | ||
| 49 | ) ParseError!void { | ||
| 50 | // The ONE grammar: argv, the state file and POST /tiles are all read | ||
| 51 | // with this, so what argv accepts is exactly what the page can add. | ||
| 52 | // The message names the tile — with several targets on the line, | ||
| 53 | // `usage` alone would not say which. | ||
| 54 | _ = wall.parseSpelling(spelling) catch |err| { | ||
| 55 | std.debug.print("muxweb: tile {s}: {s}\n", .{ spelling, switch (err) { | ||
| 56 | error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", | ||
| 57 | error.EmptySpec => "empty target", | ||
| 58 | error.BadByte => "control byte in target", | ||
| 59 | } }); | ||
| 60 | return error.Usage; | ||
| 61 | }; | ||
| 62 | const copy = try alloc.dupe(u8, spelling); | ||
| 63 | errdefer alloc.free(copy); | ||
| 64 | try list.append(alloc, copy); | ||
| 65 | } | ||
| 66 | |||
| 67 | const Parsed = struct { | ||
| 68 | /// One wall spelling per tile, in argv order — the same string that | ||
| 69 | /// reaches the state file, the resolver and the page's label. Owned | ||
| 70 | /// uniformly rather than half-borrowed from argv, because `--sock PATH` | ||
| 71 | /// has to synthesize its `--sock ` prefix and one ownership rule beats | ||
| 72 | /// two. That prefix is now part of a sock tile's label: the label IS | ||
| 73 | /// the spelling. | ||
| 74 | tiles: std.ArrayList([]const u8), | ||
| 75 | port: u16 = webhub.default_port, | ||
| 76 | key: ?[]const u8 = null, | ||
| 77 | idle_ms: u32 = client.quic_idle_ms_default, | ||
| 78 | |||
| 79 | fn deinit(self: *Parsed, alloc: std.mem.Allocator) void { | ||
| 80 | for (self.tiles.items) |t| alloc.free(t); | ||
| 81 | self.tiles.deinit(alloc); | ||
| 82 | } | ||
| 83 | }; | ||
| 84 | |||
| 85 | const ParseResult = union(enum) { | ||
| 86 | serve: Parsed, | ||
| 87 | version, | ||
| 88 | }; | ||
| 89 | |||
| 90 | /// A usage mistake is an ERROR, not a third result: it is the one outcome | ||
| 91 | /// with nothing to hand back, and saying so lets the single errdefer own | ||
| 92 | /// the tile list on every refusing path. Spelling it as a value meant a | ||
| 93 | /// `tiles.deinit` beside each of the seven `return .usage_error`s, which | ||
| 94 | /// is seven chances to forget one. | ||
| 95 | const ParseError = error{Usage} || std.mem.Allocator.Error; | ||
| 96 | |||
| 97 | fn parseArgs( | ||
| 98 | alloc: std.mem.Allocator, | ||
| 99 | args: []const [:0]const u8, | ||
| 100 | env_key: ?[]const u8, | ||
| 101 | ) ParseError!ParseResult { | ||
| 102 | var p = Parsed{ .tiles = .empty }; | ||
| 103 | errdefer p.deinit(alloc); | ||
| 104 | var key: ?[]const u8 = null; | ||
| 105 | |||
| 106 | var i: usize = 1; | ||
| 107 | while (i < args.len) : (i += 1) { | ||
| 108 | const a = args[i]; | ||
| 109 | if (std.mem.eql(u8, a, "--version")) { | ||
| 110 | // The one non-error early return, so the one that still frees | ||
| 111 | // for itself: errdefer does not run on the way out with a | ||
| 112 | // result in hand. | ||
| 113 | p.deinit(alloc); | ||
| 114 | return .version; | ||
| 115 | } else if (std.mem.eql(u8, a, "--sock") or std.mem.startsWith(u8, a, "--sock ")) { | ||
| 116 | // The flag and its value become ONE spelling — `--sock ` is | ||
| 117 | // part of the grammar wall.zig reads, not a shape only argv | ||
| 118 | // has. Two spellings of the same tile would be two parsers. | ||
| 119 | // wall owns the join so `mux wall` accepts the same two forms. | ||
| 120 | const n = wall.spellingFromArgv(alloc, args, i) catch |err| switch (err) { | ||
| 121 | error.MissingSockPath, error.FlagLikeTarget => return error.Usage, | ||
| 122 | else => |e| return e, | ||
| 123 | }; | ||
| 124 | defer alloc.free(n.spelling); | ||
| 125 | i += n.consumed - 1; | ||
| 126 | try addSpelling(alloc, &p.tiles, n.spelling); | ||
| 127 | } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) { | ||
| 128 | i += 1; | ||
| 129 | p.port = std.fmt.parseInt(u16, args[i], 10) catch return error.Usage; | ||
| 130 | // Port 0 asks the kernel to choose, and the hub prints the port | ||
| 131 | // it was asked for as the door to open — a door nobody could | ||
| 132 | // find. Refused like `--quic-idle-ms 0` and for the same | ||
| 133 | // reason: the number inverts what typing it means. | ||
| 134 | if (p.port == 0) return error.Usage; | ||
| 135 | } else if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) { | ||
| 136 | i += 1; | ||
| 137 | key = args[i]; | ||
| 138 | } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) { | ||
| 139 | i += 1; | ||
| 140 | const n = std.fmt.parseInt(u32, args[i], 10) catch return error.Usage; | ||
| 141 | if (n == 0) return error.Usage; | ||
| 142 | p.idle_ms = n; | ||
| 143 | } else if (std.mem.startsWith(u8, a, "quic://") or (a.len > 0 and a[0] != '-')) { | ||
| 144 | // Bare HOST and quic:// are already wall spellings verbatim. | ||
| 145 | try addSpelling(alloc, &p.tiles, a); | ||
| 146 | } else { | ||
| 147 | return error.Usage; | ||
| 148 | } | ||
| 149 | } | ||
| 150 | |||
| 151 | // No targets is not a usage error any more: it asks for the wall the | ||
| 152 | // last run persisted. main decides what an empty argv means; the parse | ||
| 153 | // only reports what was on the line. | ||
| 154 | p.key = xdg.pickKey(key, env_key); | ||
| 155 | return .{ .serve = p }; | ||
| 156 | } | ||
| 157 | |||
| 158 | pub fn main() !u8 { | ||
| 159 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 160 | defer if (gpa.deinit() == .leak) | ||
| 161 | std.debug.print("muxweb: LEAK: allocations outlived deinit\n", .{}); | ||
| 162 | const alloc = gpa.allocator(); | ||
| 163 | |||
| 164 | const args = try std.process.argsAlloc(alloc); | ||
| 165 | defer std.process.argsFree(alloc, args); | ||
| 166 | |||
| 167 | const result = parseArgs(alloc, args, std.posix.getenv(client_key_env)) catch |err| switch (err) { | ||
| 168 | error.Usage => { | ||
| 169 | std.debug.print("{s}", .{usage}); | ||
| 170 | return 2; | ||
| 171 | }, | ||
| 172 | else => |e| return e, | ||
| 173 | }; | ||
| 174 | var parsed = switch (result) { | ||
| 175 | .version => { | ||
| 176 | var vbuf: [64]u8 = undefined; | ||
| 177 | const s = std.fmt.bufPrint(&vbuf, "muxweb {s}\n", .{build_options.version}) catch unreachable; | ||
| 178 | _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {}; | ||
| 179 | return 0; | ||
| 180 | }, | ||
| 181 | .serve => |p| p, | ||
| 182 | }; | ||
| 183 | defer parsed.deinit(alloc); | ||
| 184 | |||
| 185 | // An arena, because every string built here lives exactly as long as | ||
| 186 | // the hub does — the state path, the wall it starts from, and the | ||
| 187 | // Hub's own allocations — so nothing is ever freed early and the | ||
| 188 | // hand-rolled list of pointers-to-free was a lifetime nobody needed to | ||
| 189 | // track. The process exits from inside the accept loop, so "as long as | ||
| 190 | // the hub" is "until exit". | ||
| 191 | var arena_state = std.heap.ArenaAllocator.init(alloc); | ||
| 192 | defer arena_state.deinit(); | ||
| 193 | const arena = arena_state.allocator(); | ||
| 194 | |||
| 195 | const state_path = try wall.statePath(arena); | ||
| 196 | var w: wall.Wall = undefined; | ||
| 197 | if (parsed.tiles.items.len == 0) { | ||
| 198 | // No argv: the wall is whatever the last run persisted. | ||
| 199 | w = wall.load(arena, state_path) catch |err| { | ||
| 200 | // The file may have been hand-edited into a line that no longer | ||
| 201 | // parses. Naming it beats a stack trace: the fix is in the file. | ||
| 202 | std.debug.print("muxweb: cannot read wall {s}: {s}\n", .{ state_path, @errorName(err) }); | ||
| 203 | return 2; | ||
| 204 | }; | ||
| 205 | } else { | ||
| 206 | // Argv present: the explicit override. It becomes the persisted wall. | ||
| 207 | w = wall.Wall{}; | ||
| 208 | for (parsed.tiles.items) |s| _ = try w.add(arena, s); | ||
| 209 | } | ||
| 210 | |||
| 211 | // The Hub resolves every spelling into a dialable Target through the | ||
| 212 | // SAME owners mux_main uses — handoff.recipeFor and xdg.resolveKeyPath — | ||
| 213 | // so the two binaries cannot drift on what a bare HOST or a `quic://` | ||
| 214 | // means, and a tile POSTed by the page means what one typed on the | ||
| 215 | // command line. | ||
| 216 | var hub = webhub.Hub.init(arena, w, state_path, parsed.key, parsed.idle_ms) catch |err| switch (err) { | ||
| 217 | error.MissingKey => { | ||
| 218 | std.debug.print( | ||
| 219 | "muxweb: no key for a quic:// tile: pass --key, set MUX_KEY_FILE, or run `muxd keygen`\n", | ||
| 220 | .{}, | ||
| 221 | ); | ||
| 222 | return 2; | ||
| 223 | }, | ||
| 224 | error.SockPathTooLong => { | ||
| 225 | std.debug.print( | ||
| 226 | "muxweb: socket path too long (max {d} bytes)\n", | ||
| 227 | .{sockpath.max_sun_path}, | ||
| 228 | ); | ||
| 229 | return 2; | ||
| 230 | }, | ||
| 231 | else => return err, | ||
| 232 | }; | ||
| 233 | defer hub.deinit(); | ||
| 234 | // Argv overrides the VIEW — this run shows the tiles it named, and only | ||
| 235 | // those — but it no longer overwrites the FILE. That file stopped being | ||
| 236 | // "the last wall muxweb was told to show" when attaches started writing | ||
| 237 | // to it: it is the user's attach history now, and one | ||
| 238 | // `muxweb HOST` would have silently erased every tile every `mux` had | ||
| 239 | // recorded. So each argv tile is ADDED (deduped by spelling, wall.zig) | ||
| 240 | // and nothing is removed. Forgetting stays explicit, which is the whole | ||
| 241 | // "remove is detach" doctrine: the page's `×`, the wall's `x`, | ||
| 242 | // `mux wall rm`. | ||
| 243 | // | ||
| 244 | // Written only when there WAS argv, still: a restore that rewrote what | ||
| 245 | // it just read would turn a read failure into a lost wall. | ||
| 246 | if (parsed.tiles.items.len != 0) for (hub.wall_state.targets.items) |spelling| { | ||
| 247 | _ = wall.record(alloc, state_path, spelling) catch |err| { | ||
| 248 | std.debug.print("muxweb: cannot save wall {s}: {s}\n", .{ state_path, @errorName(err) }); | ||
| 249 | return 2; | ||
| 250 | }; | ||
| 251 | }; | ||
| 252 | |||
| 253 | const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable; | ||
| 254 | var listener = addr.listen(.{ .reuse_address = true }) catch |err| { | ||
| 255 | std.debug.print("muxweb: cannot bind 127.0.0.1:{d}: {s}\n", .{ parsed.port, @errorName(err) }); | ||
| 256 | return 1; | ||
| 257 | }; | ||
| 258 | defer listener.deinit(); | ||
| 259 | |||
| 260 | // The tile list, one line each, then the door: everything a script | ||
| 261 | // (or a user) needs to know the hub is up and what it serves. The id | ||
| 262 | // is the hub's, not a position — it is what `/ws/<id>` names, and the | ||
| 263 | // page can add and remove tiles under it while this line stays true. | ||
| 264 | for (hub.tiles.items) |t| { | ||
| 265 | std.debug.print("muxweb: tile {d}: {s}\n", .{ t.id, t.label }); | ||
| 266 | } | ||
| 267 | std.debug.print("muxweb: serving http://127.0.0.1:{d} pid={d}\n", .{ | ||
| 268 | parsed.port, | ||
| 269 | std.os.linux.getpid(), | ||
| 270 | }); | ||
| 271 | |||
| 272 | const assets = webhub.Assets{ | ||
| 273 | .index_html = @embedFile("index.html"), | ||
| 274 | .mux_js = @embedFile("mux.js"), | ||
| 275 | .core_wasm = @embedFile("mux_core.wasm"), | ||
| 276 | }; | ||
| 277 | |||
| 278 | while (true) { | ||
| 279 | const conn = listener.accept() catch continue; | ||
| 280 | const th = std.Thread.spawn(.{}, webhub.serveConn, .{ | ||
| 281 | alloc, conn.stream, parsed.port, &hub, assets, | ||
| 282 | }) catch { | ||
| 283 | conn.stream.close(); | ||
| 284 | continue; | ||
| 285 | }; | ||
| 286 | th.detach(); | ||
| 287 | } | ||
| 288 | } | ||
| 289 | |||
| 290 | /// mux_main.key_env's value, spelled again rather than imported: pulling | ||
| 291 | /// an exe root into the module graph for one string constant is not | ||
| 292 | /// worth the tangle. mux_main.key_env is the origin; a drift between | ||
| 293 | /// the two spellings would make the two binaries read different | ||
| 294 | /// variables, so change both or neither. | ||
| 295 | const client_key_env = "MUX_KEY_FILE"; | ||
| 296 | |||
| 297 | test "parse: three spellings become three tiles in argv order, port and key bind" { | ||
| 298 | const alloc = std.testing.allocator; | ||
| 299 | const args = [_][:0]const u8{ | ||
| 300 | "muxweb", "box1", "--sock", "/tmp/a.sock", "quic://h:4433", "--key", "/k", "--port", "8000", | ||
| 301 | }; | ||
| 302 | var r = (try parseArgs(alloc, &args, null)).serve; | ||
| 303 | defer r.deinit(alloc); | ||
| 304 | try std.testing.expectEqual(@as(usize, 3), r.tiles.items.len); | ||
| 305 | try std.testing.expectEqualStrings("box1", r.tiles.items[0]); | ||
| 306 | // `--sock PATH` is ONE spelling from here on, prefix included — that | ||
| 307 | // string is the label, the wall line, and the resolver's input alike. | ||
| 308 | try std.testing.expectEqualStrings("--sock /tmp/a.sock", r.tiles.items[1]); | ||
| 309 | try std.testing.expectEqualStrings("quic://h:4433", r.tiles.items[2]); | ||
| 310 | try std.testing.expectEqual(@as(u16, 8000), r.port); | ||
| 311 | try std.testing.expectEqualStrings("/k", r.key.?); | ||
| 312 | } | ||
| 313 | |||
| 314 | test "parse: a quoted '--sock PATH#SESSION' is the same tile as the two-argument form" { | ||
| 315 | const alloc = std.testing.allocator; | ||
| 316 | // The wall file's own spelling, pasted straight onto the command line: | ||
| 317 | // muxweb used to refuse it while `mux wall` required it. | ||
| 318 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock /tmp/a.sock#b" }, null)).serve; | ||
| 319 | defer r.deinit(alloc); | ||
| 320 | try std.testing.expectEqual(@as(usize, 1), r.tiles.items.len); | ||
| 321 | try std.testing.expectEqualStrings("--sock /tmp/a.sock#b", r.tiles.items[0]); | ||
| 322 | } | ||
| 323 | |||
| 324 | test "parse: zero targets, bad flags, and flag-beats-env" { | ||
| 325 | const alloc = std.testing.allocator; | ||
| 326 | // No targets is an empty argv wall, not a refusal: restore-from-file | ||
| 327 | // semantics live in main, which is the only place that can read a file. | ||
| 328 | { | ||
| 329 | var r = (try parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null)).serve; | ||
| 330 | defer r.deinit(alloc); | ||
| 331 | try std.testing.expectEqual(@as(usize, 0), r.tiles.items.len); | ||
| 332 | } | ||
| 333 | // Every other refusal arrives as error.Usage — and the testing | ||
| 334 | // allocator is the other half of this pin: a refusal that leaked the | ||
| 335 | // tile list (whose strings are now owned) would fail the test that | ||
| 336 | // provoked it, which is what the single errdefer guarantees. | ||
| 337 | // | ||
| 338 | // A flag with no value is a usage mistake, not a transport. | ||
| 339 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock" }, null)); | ||
| 340 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "x" }, null)); | ||
| 341 | // The refusals that had a tile on the list already, so the cleanup is | ||
| 342 | // load-bearing rather than theoretical. | ||
| 343 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--wat" }, null)); | ||
| 344 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "quic://" }, null)); | ||
| 345 | // A `#NAME` that ate the whole target: all three transport spellings | ||
| 346 | // refuse an empty spec, so `--sock '#b'` fails at usage altitude rather | ||
| 347 | // than at a connect to the empty path, far from the typo. | ||
| 348 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--sock", "#b" }, null)); | ||
| 349 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--quic-idle-ms", "0" }, null)); | ||
| 350 | // Port 0 means "kernel, you pick" — but the hub announces the port it | ||
| 351 | // was asked for, so the door it prints is not the door it opened. | ||
| 352 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "0" }, null)); | ||
| 353 | // ...and an ordinary port still binds, so the refusal is the zero and | ||
| 354 | // not the flag. | ||
| 355 | { | ||
| 356 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "1" }, null)).serve; | ||
| 357 | defer r.deinit(alloc); | ||
| 358 | try std.testing.expectEqual(@as(u16, 1), r.port); | ||
| 359 | } | ||
| 360 | // Env fills in when --key is absent; --key wins when both are set. | ||
| 361 | { | ||
| 362 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "/env-key")).serve; | ||
| 363 | defer r.deinit(alloc); | ||
| 364 | try std.testing.expectEqualStrings("/env-key", r.key.?); | ||
| 365 | } | ||
| 366 | { | ||
| 367 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--key", "/flag-key" }, "/env-key")).serve; | ||
| 368 | defer r.deinit(alloc); | ||
| 369 | try std.testing.expectEqualStrings("/flag-key", r.key.?); | ||
| 370 | } | ||
| 371 | // Empty either way means unset. | ||
| 372 | { | ||
| 373 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "")).serve; | ||
| 374 | defer r.deinit(alloc); | ||
| 375 | try std.testing.expectEqual(@as(?[]const u8, null), r.key); | ||
| 376 | } | ||
| 377 | } | ||
| 378 | |||
| 379 | test "tiles: the spelling reaches the wall verbatim, `#NAME` and all" { | ||
| 380 | const alloc = std.testing.allocator; | ||
| 381 | const args = [_][:0]const u8{ | ||
| 382 | "muxweb", "host#b", "quic://h:1#b", "--sock", "/tmp/x#b", "plainhost", "a#b#c", | ||
| 383 | }; | ||
| 384 | var r = (try parseArgs(alloc, &args, null)).serve; | ||
| 385 | defer r.deinit(alloc); | ||
| 386 | try std.testing.expectEqual(@as(usize, 5), r.tiles.items.len); | ||
| 387 | |||
| 388 | // The session SPLIT is wall.parseSpelling's, tested there. What is | ||
| 389 | // this parse's own is that the argv string arrives intact: the user | ||
| 390 | // asked for `host#b`, so that is the wall line, and therefore the | ||
| 391 | // tile's name on screen — nobody decorates it on the way. | ||
| 392 | try std.testing.expectEqualStrings("host#b", r.tiles.items[0]); | ||
| 393 | try std.testing.expectEqualStrings("quic://h:1#b", r.tiles.items[1]); | ||
| 394 | // The flag and its value become one spelling; the `#NAME` rides on the | ||
| 395 | // VALUE, where the user put it. | ||
| 396 | try std.testing.expectEqualStrings("--sock /tmp/x#b", r.tiles.items[2]); | ||
| 397 | try std.testing.expectEqualStrings("plainhost", r.tiles.items[3]); | ||
| 398 | try std.testing.expectEqualStrings("a#b#c", r.tiles.items[4]); | ||
| 399 | } | ||
| 400 | |||
| 401 | test "tiles: a bad session name after # is still a usage error at parse" { | ||
| 402 | const alloc = std.testing.allocator; | ||
| 403 | // The refusal stays HERE, at argv altitude, rather than surfacing later | ||
| 404 | // as one tile that will not attach. These print a line naming the tile | ||
| 405 | // before returning, so the muxweb: lines in this test's output are the | ||
| 406 | // point, not noise: with several tiles on the line, `usage` alone would | ||
| 407 | // not say which one. | ||
| 408 | try std.testing.expectError( | ||
| 409 | error.Usage, | ||
| 410 | parseArgs(alloc, &[_][:0]const u8{ "muxweb", "host#has space" }, null), | ||
| 411 | ); | ||
| 412 | // A bare trailing '#' asks for the empty name. It is the default ON THE | ||
| 413 | // WIRE but not a name a user may spell, so typing it is a mistake. | ||
| 414 | try std.testing.expectError( | ||
| 415 | error.Usage, | ||
| 416 | parseArgs(alloc, &[_][:0]const u8{ "muxweb", "host#" }, null), | ||
| 417 | ); | ||
| 418 | // Same rule through --sock's value and through quic://. | ||
| 419 | try std.testing.expectError( | ||
| 420 | error.Usage, | ||
| 421 | parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock", "/tmp/x#bad name" }, null), | ||
| 422 | ); | ||
| 423 | try std.testing.expectError( | ||
| 424 | error.Usage, | ||
| 425 | parseArgs(alloc, &[_][:0]const u8{ "muxweb", "quic://h:1#a/b" }, null), | ||
| 426 | ); | ||
| 427 | } | ||
| 428 | |||
| 429 | test "version short-circuits everything else on the line" { | ||
| 430 | const alloc = std.testing.allocator; | ||
| 431 | const r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--version", "--bogus" }, null); | ||
| 432 | try std.testing.expect(r == .version); | ||
| 433 | } | ||
| 434 | |||
| 435 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 436 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 437 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 438 | test { | ||
| 439 | std.testing.refAllDeclsRecursive(@This()); | ||
| 440 | } | ||
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,1825 +0,0 @@ | |||
| 1 | //! muxd — daemon entrypoint. `run` hosts the session; `dump` prints the | ||
| 2 | //! authoritative grid over the protocol (debug aid, also used by e2e); | ||
| 3 | //! `proxy` exposes the session socket over stdio for `mux --via`. | ||
| 4 | const std = @import("std"); | ||
| 5 | const Server = @import("server").Server; | ||
| 6 | const proto = @import("protocol"); | ||
| 7 | const proxy = @import("proxy"); | ||
| 8 | const quic = @import("quic"); | ||
| 9 | const quic_server = @import("quic_server"); | ||
| 10 | const build_options = @import("build_options"); | ||
| 11 | const xdg = @import("xdg"); | ||
| 12 | const spawn = @import("spawn"); | ||
| 13 | const handoff = @import("handoff"); | ||
| 14 | const sockpath = @import("sockpath"); | ||
| 15 | const upgrade = @import("upgrade"); | ||
| 16 | const cliflags = @import("cliflags"); | ||
| 17 | |||
| 18 | const usage = | ||
| 19 | \\usage: | ||
| 20 | \\ muxd run [--sock PATH] [--shell PATH] [--cols N] [--rows N] | ||
| 21 | \\ [--quic HOST[:PORT] --key FILE] [--quic-idle-ms N] | ||
| 22 | \\ muxd dump [--vt] [--session NAME] [--sock PATH] | ||
| 23 | \\ muxd stats [--sock PATH] | ||
| 24 | \\ muxd stop [--sock PATH] (ask the daemon on PATH to exit) | ||
| 25 | \\ muxd proxy [--sock PATH] (byte pump: stdio <-> session socket) | ||
| 26 | \\ muxd endpoint [--sock PATH] (proxy that first announces QUIC port+key) | ||
| 27 | \\ muxd keygen (write a fresh key to ~/.config/mux/key) | ||
| 28 | \\ muxd start [run's flags] (spawn a daemon detached; no-op if one is up) | ||
| 29 | \\ muxd upgrade [--sock PATH] (exec THIS binary over the daemon; sessions live) | ||
| 30 | \\ [--allow-same-version] (strictly newer, unless this; the e2e leg's) | ||
| 31 | \\ muxd --version | ||
| 32 | \\ muxd --help | ||
| 33 | \\ | ||
| 34 | ; | ||
| 35 | |||
| 36 | /// `--key` beats `MUX_KEY_FILE` beats the default path — more specific | ||
| 37 | /// intent sits higher. Split out so the order is testable without a | ||
| 38 | /// daemon. | ||
| 39 | fn pickKey(flag: ?[]const u8, env: ?[]const u8, default_if_present: ?[]const u8) ?[]const u8 { | ||
| 40 | return flag orelse env orelse default_if_present; | ||
| 41 | } | ||
| 42 | |||
| 43 | /// MUX_KEY_FILE, with "set but empty" read as unset — an empty path | ||
| 44 | /// could only be a mistake, and Key.load would blame "". | ||
| 45 | fn envKey() ?[]const u8 { | ||
| 46 | const v = std.posix.getenv("MUX_KEY_FILE") orelse return null; | ||
| 47 | return if (v.len == 0) null else v; | ||
| 48 | } | ||
| 49 | |||
| 50 | /// The daemon's name for the shared default; see `quic.default_idle_ms` | ||
| 51 | /// for what the number means and why it lives there. | ||
| 52 | const default_quic_idle_ms: u32 = quic.default_idle_ms; | ||
| 53 | |||
| 54 | const Cmd = enum { run, dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade }; | ||
| 55 | |||
| 56 | /// One row per verb. Adding a subcommand used to mean editing the usage | ||
| 57 | /// literal, the Cmd enum, a name→Cmd if/else chain, keygen's hand-rolled | ||
| 58 | /// no-flags exception, and main's uses_socket switch — of which only the | ||
| 59 | /// dispatch switch was compiler-checked. Everything except the dispatch body | ||
| 60 | /// and the prose now reads off this table, and the two legs a table cannot | ||
| 61 | /// check itself have their own: the comptime block below (every Cmd has | ||
| 62 | /// exactly one row) and "usage names every subcommand" (every row is in the | ||
| 63 | /// prose, on its own line). | ||
| 64 | const Spec = struct { | ||
| 65 | name: []const u8, | ||
| 66 | cmd: Cmd, | ||
| 67 | /// Whether the resolved socket path is this verb's business — the | ||
| 68 | /// length guard in `main` reads this, and answering `false` means a | ||
| 69 | /// doomed path cannot refuse a command that never touches a socket. | ||
| 70 | uses_socket: bool, | ||
| 71 | /// What the verb does with the words after its name. `all` runs the | ||
| 72 | /// shared flag loop; `none` refuses any argument at all; `ignored` | ||
| 73 | /// returns before the loop, so nothing after the name is read. | ||
| 74 | /// | ||
| 75 | /// `ignored` is a contract, not an oversight: trailing arguments are | ||
| 76 | /// ACCEPTED and vanish silently. `muxd --version` with an over-long | ||
| 77 | /// `--sock` exits 0 today, pinned in e2e.sh ("--version refused over | ||
| 78 | /// sock length"). Narrowing it to `none` would turn that into a | ||
| 79 | /// refusal. | ||
| 80 | flags: enum { none, all, ignored }, | ||
| 81 | }; | ||
| 82 | |||
| 83 | const specs = [_]Spec{ | ||
| 84 | // First as the odd one out — the lookup is exact-match, so position | ||
| 85 | // carries no meaning. Spelled as a flag because that is what everyone | ||
| 86 | // types, but it is a command: it names what the process does instead of | ||
| 87 | // configuring one, and answers from the binary alone. | ||
| 88 | .{ .name = "--version", .cmd = .version, .uses_socket = false, .flags = .ignored }, | ||
| 89 | // Spelled as a flag for the same reason, and `.ignored` for a second: | ||
| 90 | // asking for the usage must never be refused over the words next to it. | ||
| 91 | .{ .name = "--help", .cmd = .help, .uses_socket = false, .flags = .ignored }, | ||
| 92 | .{ .name = "run", .cmd = .run, .uses_socket = true, .flags = .all }, | ||
| 93 | .{ .name = "dump", .cmd = .dump, .uses_socket = true, .flags = .all }, | ||
| 94 | .{ .name = "stats", .cmd = .stats, .uses_socket = true, .flags = .all }, | ||
| 95 | .{ .name = "proxy", .cmd = .proxy, .uses_socket = true, .flags = .all }, | ||
| 96 | .{ .name = "endpoint", .cmd = .endpoint, .uses_socket = true, .flags = .all }, | ||
| 97 | // keygen configures nothing: its one output is the default path, and a | ||
| 98 | // flag here would be a request this command cannot honor. | ||
| 99 | .{ .name = "keygen", .cmd = .keygen, .uses_socket = false, .flags = .none }, | ||
| 100 | .{ .name = "start", .cmd = .start, .uses_socket = true, .flags = .all }, | ||
| 101 | .{ .name = "stop", .cmd = .stop, .uses_socket = true, .flags = .all }, | ||
| 102 | .{ .name = "upgrade", .cmd = .upgrade, .uses_socket = true, .flags = .all }, | ||
| 103 | }; | ||
| 104 | |||
| 105 | comptime { | ||
| 106 | for (std.enums.values(Cmd)) |c| { | ||
| 107 | var rows = 0; | ||
| 108 | for (specs) |s| { | ||
| 109 | if (s.cmd == c) rows += 1; | ||
| 110 | } | ||
| 111 | // Two rows for one Cmd is as wrong as none, and quieter: the second | ||
| 112 | // is unreachable through `specForName` only if its name is dead, and | ||
| 113 | // `specForCmd` would answer with whichever came first. | ||
| 114 | if (rows == 0) @compileError("Cmd has no row in specs: " ++ @tagName(c)); | ||
| 115 | if (rows > 1) @compileError("Cmd has more than one row in specs: " ++ @tagName(c)); | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | /// No prefix matching: `ru` is a typo, and guessing which verb it | ||
| 120 | /// meant is how a typo becomes a daemon. | ||
| 121 | fn specForName(name: []const u8) ?Spec { | ||
| 122 | for (specs) |s| { | ||
| 123 | if (std.mem.eql(u8, name, s.name)) return s; | ||
| 124 | } | ||
| 125 | return null; | ||
| 126 | } | ||
| 127 | |||
| 128 | /// Unreachable is honest because the comptime check fails the build | ||
| 129 | /// for a Cmd with no row. | ||
| 130 | fn specForCmd(cmd: Cmd) Spec { | ||
| 131 | for (specs) |s| { | ||
| 132 | if (s.cmd == cmd) return s; | ||
| 133 | } | ||
| 134 | unreachable; | ||
| 135 | } | ||
| 136 | |||
| 137 | /// Everything the command line can say, once. Parsed away from `main` so it | ||
| 138 | /// can be tested without a process to exit from — the same reason | ||
| 139 | /// mux_main.zig's parser is its own function. | ||
| 140 | const Opts = struct { | ||
| 141 | /// Not a flag, and the leading underscore is what says so: cliflags.parse | ||
| 142 | /// skips it, the verb having been settled by the row above. | ||
| 143 | _cmd: Cmd, | ||
| 144 | sock: ?[]const u8 = null, | ||
| 145 | shell: ?[]const u8 = null, | ||
| 146 | cols: u16 = 80, | ||
| 147 | rows: u16 = 24, | ||
| 148 | vt: bool = false, | ||
| 149 | quic: ?[]const u8 = null, | ||
| 150 | /// Null does NOT mean "no key": it means the command line named none, | ||
| 151 | /// and `run` still has MUX_KEY_FILE and the default path to try. Only | ||
| 152 | /// `--key` without `--quic` is settled here, because that one has no | ||
| 153 | /// reading that makes it sensible. | ||
| 154 | key: ?[]const u8 = null, | ||
| 155 | /// u32 rather than u64 so that an absurd value is a parse failure rather | ||
| 156 | /// than an overflow where it is multiplied out to nanoseconds. | ||
| 157 | quic_idle_ms: u32 = default_quic_idle_ms, | ||
| 158 | /// The session `dump` names. Optional so that only a name that was TYPED | ||
| 159 | /// is validated: `""` is the wire's own default spelling — no tail at | ||
| 160 | /// all — and would pass a check written against the empty string. | ||
| 161 | session: ?[]const u8 = null, | ||
| 162 | /// The inherited manifest descriptor an upgrade exec'd us with. Not a | ||
| 163 | /// user flag: the old daemon writes it into our argv. Its presence is | ||
| 164 | /// what makes `run` an ADOPTION rather than a start, and it is also | ||
| 165 | /// what excuses this process from resolving a socket path — the | ||
| 166 | /// manifest names the socket the inherited listener is already bound to. | ||
| 167 | resume_fd: ?std.posix.fd_t = null, | ||
| 168 | /// The old daemon's dry run: parse the manifest to the end and exit 0 | ||
| 169 | /// having adopted nothing. A candidate that cannot read the manifest | ||
| 170 | /// must fail HERE, in a child, while the old daemon is still serving. | ||
| 171 | check: bool = false, | ||
| 172 | /// Test-only: abort adoption after the named section (`daemon`, | ||
| 173 | /// `session`) so the rollback exec has something to trigger it. The | ||
| 174 | /// rollback leg is the only thing that can prove a daemon survives a | ||
| 175 | /// candidate that reads the manifest and then cannot use it. | ||
| 176 | resume_fail_at: ?[]const u8 = null, | ||
| 177 | /// `upgrade`'s one exception to the strictly-newer rule. It exists for | ||
| 178 | /// the e2e leg, which has only one binary to upgrade with. | ||
| 179 | allow_same_version: bool = false, | ||
| 180 | }; | ||
| 181 | |||
| 182 | // The three flags left out are written into argv by the OLD daemon on an | ||
| 183 | // upgrade and never typed by a hand, so the prose does not offer them. | ||
| 184 | comptime { | ||
| 185 | cliflags.assertDocumented(Opts, usage, &.{ "resume_fd", "check", "resume_fail_at" }); | ||
| 186 | } | ||
| 187 | |||
| 188 | /// What `main` prints instead of running the command. Every case but `help` | ||
| 189 | /// is a refusal; none of them is a daemon bug, so none gets a stack trace. | ||
| 190 | const Usage = union(enum) { | ||
| 191 | no_command, | ||
| 192 | /// `--help` on a subcommand. The answer the user asked for, so it is the | ||
| 193 | /// one case `usageExit` exits 0 on. | ||
| 194 | help, | ||
| 195 | unknown_command: []const u8, | ||
| 196 | unknown_arg: []const u8, | ||
| 197 | /// A flag at the end of argv with nothing left to consume. | ||
| 198 | missing_value: []const u8, | ||
| 199 | /// The flag whose value would not parse as the number it wants. | ||
| 200 | bad_number: []const u8, | ||
| 201 | key_without_quic, | ||
| 202 | /// The name itself, not the flag: `muxd: bad session name: {s}` names | ||
| 203 | /// what was typed, which is the actionable half. | ||
| 204 | bad_session_name: []const u8, | ||
| 205 | }; | ||
| 206 | |||
| 207 | const ParseResult = union(enum) { ok: Opts, err: Usage }; | ||
| 208 | |||
| 209 | fn parseArgs(args: []const [:0]const u8) ParseResult { | ||
| 210 | if (args.len < 2) return .{ .err = .no_command }; | ||
| 211 | const spec = specForName(args[1]) orelse return .{ .err = .{ .unknown_command = args[1] } }; | ||
| 212 | |||
| 213 | // The one place a verb's flag class is enforced; which class each verb | ||
| 214 | // is in is stated once, in its row. | ||
| 215 | switch (spec.flags) { | ||
| 216 | .ignored => return .{ .ok = .{ ._cmd = spec.cmd } }, | ||
| 217 | .none => if (args.len > 2) return .{ .err = .{ .unknown_arg = args[2] } }, | ||
| 218 | .all => {}, | ||
| 219 | } | ||
| 220 | |||
| 221 | var o: Opts = .{ ._cmd = spec.cmd }; | ||
| 222 | switch (cliflags.parse(Opts, &o, args[2..])) { | ||
| 223 | .ok => {}, | ||
| 224 | .help => return .{ .err = .help }, | ||
| 225 | .unknown_arg => |a| return .{ .err = .{ .unknown_arg = a } }, | ||
| 226 | .missing_value => |f| return .{ .err = .{ .missing_value = f } }, | ||
| 227 | .bad_number => |f| return .{ .err = .{ .bad_number = f } }, | ||
| 228 | } | ||
| 229 | |||
| 230 | // Refused here rather than carried to the wire as a payload nothing could | ||
| 231 | // ever look up — the same "check before it becomes a frame" the socket | ||
| 232 | // path length guard follows. | ||
| 233 | if (o.session) |name| { | ||
| 234 | if (!proto.validSessionName(name)) return .{ .err = .{ .bad_session_name = name } }; | ||
| 235 | } | ||
| 236 | |||
| 237 | // Zero is refused because ngtcp2 reads it as "no idle timeout", the | ||
| 238 | // opposite of what the flag says. | ||
| 239 | if (o.quic_idle_ms == 0) return .{ .err = .{ .bad_number = "--quic-idle-ms" } }; | ||
| 240 | |||
| 241 | // A key with nowhere to listen is a mistake parse can see the whole of. | ||
| 242 | // The mirror case is NOT one: `--quic` with no `--key` may still be | ||
| 243 | // answered by MUX_KEY_FILE or the default key path, neither of which | ||
| 244 | // parse is allowed to look at, so it defers to `run`. | ||
| 245 | if (o.key != null and o.quic == null) return .{ .err = .key_without_quic }; | ||
| 246 | |||
| 247 | return .{ .ok = o }; | ||
| 248 | } | ||
| 249 | |||
| 250 | /// The code alone: a test can ask it without a process. | ||
| 251 | fn usageCode(u: Usage) u8 { | ||
| 252 | return if (u == .help) 0 else 2; | ||
| 253 | } | ||
| 254 | |||
| 255 | fn usageExit(u: Usage) u8 { | ||
| 256 | switch (u) { | ||
| 257 | // stdout, unlike every refusal below: a usage someone asked for is | ||
| 258 | // output, and they may well have piped it into a pager. | ||
| 259 | .help => { | ||
| 260 | _ = std.posix.write(std.posix.STDOUT_FILENO, usage) catch {}; | ||
| 261 | }, | ||
| 262 | .no_command => std.debug.print("{s}", .{usage}), | ||
| 263 | .unknown_command => std.debug.print("{s}", .{usage}), | ||
| 264 | .unknown_arg => |a| std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }), | ||
| 265 | .missing_value => |f| std.debug.print("muxd: {s} needs a value\n{s}", .{ f, usage }), | ||
| 266 | .bad_number => |f| std.debug.print("muxd: {s} needs a positive number\n{s}", .{ f, usage }), | ||
| 267 | .key_without_quic => std.debug.print( | ||
| 268 | "muxd: --key without --quic has nothing to listen on; name both or neither\n", | ||
| 269 | .{}, | ||
| 270 | ), | ||
| 271 | .bad_session_name => |n| std.debug.print("muxd: bad session name: {s}\n{s}", .{ n, usage }), | ||
| 272 | } | ||
| 273 | return usageCode(u); | ||
| 274 | } | ||
| 275 | |||
| 276 | /// `HOST[:PORT]` where HOST is a literal address — `127.0.0.1:4433`, | ||
| 277 | /// `0.0.0.0:4433`, `[::]:4433`. An omitted port means `quic.default_port`; | ||
| 278 | /// an empty one (`127.0.0.1:`) is still a mistake and still refused. | ||
| 279 | /// Deliberately no DNS: this is the address to bind, and a name resolving | ||
| 280 | /// to several is a question, not an answer. | ||
| 281 | fn splitHostPort(s: []const u8) !struct { host: []const u8, port: u16 } { | ||
| 282 | if (s.len > 0 and s[0] == '[') { | ||
| 283 | const close = std.mem.indexOfScalar(u8, s, ']') orelse return error.MalformedAddress; | ||
| 284 | if (close + 1 == s.len) return .{ .host = s[1..close], .port = quic.default_port }; | ||
| 285 | if (s[close + 1] != ':') return error.MalformedAddress; | ||
| 286 | return .{ .host = s[1..close], .port = try parsePort(s[close + 2 ..]) }; | ||
| 287 | } | ||
| 288 | const colon = std.mem.lastIndexOfScalar(u8, s, ':') orelse | ||
| 289 | return .{ .host = s, .port = quic.default_port }; | ||
| 290 | // An unbracketed IPv6 literal carries colons of its own, and splitting | ||
| 291 | // on the last one would quietly take its final group as a port: | ||
| 292 | // `fe80::1:4433` reads equally well as host `fe80::1` port 4433 and as | ||
| 293 | // host `fe80::1:4433` with the port left off. Brackets are how that | ||
| 294 | // ambiguity is spelled out, so without them it is refused rather than | ||
| 295 | // guessed at. | ||
| 296 | if (std.mem.indexOfScalar(u8, s[0..colon], ':') != null) return error.MalformedAddress; | ||
| 297 | return .{ .host = s[0..colon], .port = try parsePort(s[colon + 1 ..]) }; | ||
| 298 | } | ||
| 299 | |||
| 300 | fn parsePort(s: []const u8) !u16 { | ||
| 301 | return std.fmt.parseInt(u16, s, 10) catch error.MalformedAddress; | ||
| 302 | } | ||
| 303 | |||
| 304 | fn parseBindAddr(s: []const u8) !std.net.Address { | ||
| 305 | const hp = try splitHostPort(s); | ||
| 306 | return std.net.Address.parseIp(hp.host, hp.port); | ||
| 307 | } | ||
| 308 | |||
| 309 | pub fn main() !u8 { | ||
| 310 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 311 | defer if (gpa.deinit() == .leak) | ||
| 312 | std.debug.print("muxd: LEAK: allocations outlived deinit\n", .{}); | ||
| 313 | const alloc = gpa.allocator(); | ||
| 314 | |||
| 315 | const args = try std.process.argsAlloc(alloc); | ||
| 316 | defer std.process.argsFree(alloc, args); | ||
| 317 | |||
| 318 | const o = switch (parseArgs(args)) { | ||
| 319 | .err => |u| return usageExit(u), | ||
| 320 | .ok => |o| o, | ||
| 321 | }; | ||
| 322 | |||
| 323 | // The same row that exempts `--version` and `keygen` from the sun_path | ||
| 324 | // bound below exempts them from HAVING a path: the default can now | ||
| 325 | // refuse (sockpath.defaultSockPath), and a version string must never | ||
| 326 | // fail on the environment. So a verb that touches no socket gets no | ||
| 327 | // path rather than a path it must first survive resolving. | ||
| 328 | // A resuming `run` is exempt from the socket path entirely: the exec | ||
| 329 | // that started it passed only `--resume-fd`, and the path it must serve | ||
| 330 | // is in the manifest, under a listener that is already bound to it. | ||
| 331 | // Resolving the default here would refuse an upgrade on any daemon | ||
| 332 | // started with `--sock` outside XDG_RUNTIME_DIR. | ||
| 333 | const uses_socket = specForCmd(o._cmd).uses_socket and o.resume_fd == null; | ||
| 334 | const sock_path = if (o.sock) |s| | ||
| 335 | try alloc.dupe(u8, s) | ||
| 336 | else if (!uses_socket) | ||
| 337 | try alloc.dupe(u8, "") | ||
| 338 | else | ||
| 339 | sockpath.defaultSockPath(alloc) catch |err| switch (err) { | ||
| 340 | // Named in this binary's own words, like the sun_path bound | ||
| 341 | // below: sockpath owns the rule, each binary owns the wording. | ||
| 342 | error.NoRuntimeDir => { | ||
| 343 | std.debug.print( | ||
| 344 | "muxd: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n", | ||
| 345 | .{}, | ||
| 346 | ); | ||
| 347 | return 1; | ||
| 348 | }, | ||
| 349 | else => |e| return e, | ||
| 350 | }; | ||
| 351 | defer alloc.free(sock_path); | ||
| 352 | |||
| 353 | // The sun_path bound (sockpath.max_sun_path). Checked here, once, | ||
| 354 | // before any command acts: the alternative is a spawned daemon that can | ||
| 355 | // never answer and a 2s timeout story about a path doomed at parse. | ||
| 356 | // `--version` and `keygen` are dispatched from the switch below, i.e. | ||
| 357 | // after this point, so they are exempted by their rows rather than by | ||
| 358 | // order — neither touches the socket, and neither should be refused | ||
| 359 | // over it. Which verbs those are is their rows' business, not this | ||
| 360 | // line's. | ||
| 361 | if (uses_socket and sock_path.len > sockpath.max_sun_path) { | ||
| 362 | std.debug.print( | ||
| 363 | "muxd: socket path too long ({d} bytes, max {d}): {s}\n", | ||
| 364 | .{ sock_path.len, sockpath.max_sun_path, sock_path }, | ||
| 365 | ); | ||
| 366 | return 1; | ||
| 367 | } | ||
| 368 | |||
| 369 | switch (o._cmd) { | ||
| 370 | // The socket path resolved above is unused here and unchecked (see | ||
| 371 | // the length guard above): asking a binary its version must work | ||
| 372 | // with no daemon and no runtime dir. | ||
| 373 | .version => { | ||
| 374 | var vbuf: [64]u8 = undefined; | ||
| 375 | const s = std.fmt.bufPrint(&vbuf, "muxd {s}\n", .{build_options.version}) catch unreachable; | ||
| 376 | _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {}; | ||
| 377 | return 0; | ||
| 378 | }, | ||
| 379 | .help => return usageExit(.help), | ||
| 380 | .keygen => return keygen(alloc), | ||
| 381 | .start => return startCmd(alloc, sock_path, args[2..]), | ||
| 382 | .run => return if (o.resume_fd) |fd| resumeRun(alloc, o, fd) else run(alloc, o, sock_path), | ||
| 383 | .dump => return dump(alloc, sock_path, o.vt, o.session orelse ""), | ||
| 384 | .stats => return stats(alloc, sock_path), | ||
| 385 | .stop => return stopCmd(alloc, sock_path), | ||
| 386 | .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version), | ||
| 387 | .proxy => { | ||
| 388 | // Attach auto-start: the user asked for a session, not a | ||
| 389 | // daemon. Same helper and deadline as `muxd start`. Unlike it, a | ||
| 390 | // daemon that was already there is silent: the user asked for a | ||
| 391 | // session and is about to get one. | ||
| 392 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 393 | const exe = std.fs.selfExePath(&exe_buf) catch { | ||
| 394 | std.debug.print("muxd proxy: cannot find own binary via /proc/self/exe\n", .{}); | ||
| 395 | return 1; | ||
| 396 | }; | ||
| 397 | if (!try spawn.ensureForAttach(alloc, exe, sock_path, "muxd proxy")) return 1; | ||
| 398 | return proxy.run(sock_path); | ||
| 399 | }, | ||
| 400 | .endpoint => return endpointCmd(alloc, sock_path), | ||
| 401 | } | ||
| 402 | } | ||
| 403 | |||
| 404 | /// Opt-IN, `=1` alone: the shim costs a zsh user `~/.zshenv` and a | ||
| 405 | /// bash user's DEBUG trap. | ||
| 406 | fn shellIntegrationEnabled(env: ?[]const u8) bool { | ||
| 407 | return std.mem.eql(u8, env orelse "", "1"); | ||
| 408 | } | ||
| 409 | |||
| 410 | /// Bigger than any manifest a full session table can produce (one replayed | ||
| 411 | /// viewport each) and small enough that a descriptor that is not a manifest | ||
| 412 | /// cannot make this process eat the machine. | ||
| 413 | const manifest_read_max = 64 * 1024 * 1024; | ||
| 414 | |||
| 415 | /// Set on the environment the rollback exec builds, and read at the top of | ||
| 416 | /// the next adoption. Two binaries that both refuse one manifest would | ||
| 417 | /// otherwise trade the sessions forever, exec'ing each other at full speed. | ||
| 418 | /// A VARIABLE rather than a flag because the binary being exec'd back is by | ||
| 419 | /// definition older than this one: an unknown variable is ignored by any | ||
| 420 | /// muxd, an unknown flag is fatal usage. | ||
| 421 | const rollback_marker = "MUX_UPGRADE_ROLLBACK"; | ||
| 422 | |||
| 423 | /// The environment's spelling of `--resume-fail-at`. The flag alone cannot | ||
| 424 | /// be driven by a real upgrade: the exec builds a fixed argv | ||
| 425 | /// (`run --resume-fd N`), so an e2e leg has no way to put a word in it. The | ||
| 426 | /// daemon's ENVIRONMENT crosses the exec untouched, which is how the leg | ||
| 427 | /// arms the abort — on the daemon it starts, before any upgrade. | ||
| 428 | const fail_at_env = "MUX_RESUME_FAIL_AT"; | ||
| 429 | |||
| 430 | /// The flag beats the environment, as `--key` beats `MUX_KEY_FILE`: more | ||
| 431 | /// specific intent sits higher. | ||
| 432 | fn failAtFrom(flag: ?[]const u8, env: ?[]const u8) []const u8 { | ||
| 433 | return flag orelse env orelse ""; | ||
| 434 | } | ||
| 435 | |||
| 436 | /// What the rollback exec keeps from this process's environment. | ||
| 437 | fn rollbackKeepsEnv(entry: []const u8) bool { | ||
| 438 | // The abort that caused the rollback must not be inherited by the | ||
| 439 | // binary being rolled back TO: it would abort at the same section, | ||
| 440 | // find the marker, give up, and take every shell with it. | ||
| 441 | return !std.mem.startsWith(u8, entry, fail_at_env ++ "="); | ||
| 442 | } | ||
| 443 | |||
| 444 | /// libc's, because std.c does not declare it and the marker must not | ||
| 445 | /// outlive the exec that set it — a daemon carrying it would refuse to roll | ||
| 446 | /// back the NEXT upgrade, and would hand it to every shell it spawns. | ||
| 447 | extern "c" fn unsetenv(name: [*:0]const u8) c_int; | ||
| 448 | |||
| 449 | /// Hand the sessions back to the binary that wrote the manifest. | ||
| 450 | fn rollback( | ||
| 451 | alloc: std.mem.Allocator, | ||
| 452 | writer_path: []const u8, | ||
| 453 | resume_fd: std.posix.fd_t, | ||
| 454 | section: []const u8, | ||
| 455 | ) u8 { | ||
| 456 | // Adoption failed before the pump started, so nothing has changed: | ||
| 457 | // every descriptor is still open and still inherited, and the manifest | ||
| 458 | // names the binary that opened them. | ||
| 459 | std.debug.print( | ||
| 460 | "muxd run: adoption failed at {s}; exec'ing {s} back\n", | ||
| 461 | .{ section, writer_path }, | ||
| 462 | ); | ||
| 463 | if (std.posix.getenv(rollback_marker) != null) { | ||
| 464 | std.debug.print( | ||
| 465 | "muxd run: this IS the rollback ({s} refused the manifest it wrote); giving up\n", | ||
| 466 | .{writer_path}, | ||
| 467 | ); | ||
| 468 | return 1; | ||
| 469 | } | ||
| 470 | |||
| 471 | // The manifest was read to EOF and the descriptor is the one being | ||
| 472 | // passed on: the next binary starts where the writer left it. | ||
| 473 | var file = std.fs.File{ .handle = resume_fd }; | ||
| 474 | file.seekTo(0) catch {}; | ||
| 475 | |||
| 476 | var fd_buf: [12]u8 = undefined; | ||
| 477 | const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{resume_fd}) catch return 1; | ||
| 478 | const argv = [_:null]?[*:0]const u8{ "muxd", "run", "--resume-fd", fd_str.ptr }; | ||
| 479 | const path_z = alloc.dupeZ(u8, writer_path) catch return 1; | ||
| 480 | const envp = rollbackEnvp(alloc) catch return 1; | ||
| 481 | |||
| 482 | // Only a failed exec ends the process, and then the shells get SIGHUP | ||
| 483 | // as the pty masters close — exactly today's `stop` + `run` outcome, | ||
| 484 | // not a worse one. | ||
| 485 | const exec_err = std.posix.execveZ(path_z.ptr, &argv, envp); | ||
| 486 | alloc.free(path_z); | ||
| 487 | alloc.free(std.mem.span(envp)); | ||
| 488 | std.debug.print( | ||
| 489 | "muxd run: rollback exec of {s} failed: {s}\n", | ||
| 490 | .{ writer_path, @errorName(exec_err) }, | ||
| 491 | ); | ||
| 492 | return 1; | ||
| 493 | } | ||
| 494 | |||
| 495 | /// This process's environment plus the marker. Leaked deliberately: the | ||
| 496 | /// only thing that reads it is the execve on the next line. | ||
| 497 | fn rollbackEnvp(alloc: std.mem.Allocator) ![*:null]const ?[*:0]const u8 { | ||
| 498 | var n: usize = 0; | ||
| 499 | while (std.c.environ[n] != null) n += 1; | ||
| 500 | const envp = try alloc.allocSentinel(?[*:0]const u8, n + 1, null); | ||
| 501 | var kept: usize = 0; | ||
| 502 | for (0..n) |i| { | ||
| 503 | const entry = std.c.environ[i].?; | ||
| 504 | if (!rollbackKeepsEnv(std.mem.span(entry))) continue; | ||
| 505 | envp[kept] = entry; | ||
| 506 | kept += 1; | ||
| 507 | } | ||
| 508 | envp[kept] = rollback_marker ++ "=1"; | ||
| 509 | kept += 1; | ||
| 510 | // The dropped entries leave a tail of undefined pointers between the | ||
| 511 | // last kept one and the sentinel; execve reads to the first null. | ||
| 512 | for (envp[kept..]) |*slot| slot.* = null; | ||
| 513 | return envp.ptr; | ||
| 514 | } | ||
| 515 | |||
| 516 | /// `muxd run --resume-fd N`: the argv an upgrading daemon exec'd this binary | ||
| 517 | /// with. Same pid, same children, same descriptors — the manifest names | ||
| 518 | /// which ones. It is read from the descriptor and never from a path: the | ||
| 519 | /// memfd is anonymous memory, and the QUIC key inside it must not touch disk. | ||
| 520 | fn resumeRun(alloc: std.mem.Allocator, o: Opts, resume_fd: std.posix.fd_t) !u8 { | ||
| 521 | // The writer left the offset at the end of what it wrote, and a child | ||
| 522 | // shares the file description with it, so the rewind is ours to do. | ||
| 523 | var file = std.fs.File{ .handle = resume_fd }; | ||
| 524 | file.seekTo(0) catch |err| { | ||
| 525 | std.debug.print("muxd run: --resume-fd {d} does not seek ({t})\n", .{ resume_fd, err }); | ||
| 526 | return 1; | ||
| 527 | }; | ||
| 528 | const bytes = file.readToEndAlloc(alloc, manifest_read_max) catch |err| { | ||
| 529 | std.debug.print("muxd run: cannot read the manifest on fd {d} ({t})\n", .{ resume_fd, err }); | ||
| 530 | return 1; | ||
| 531 | }; | ||
| 532 | defer alloc.free(bytes); | ||
| 533 | |||
| 534 | var parsed = upgrade.parseManifest(alloc, bytes) catch |err| { | ||
| 535 | std.debug.print("muxd run: manifest on fd {d} is not one ({t})\n", .{ resume_fd, err }); | ||
| 536 | return 1; | ||
| 537 | }; | ||
| 538 | defer parsed.deinit(); | ||
| 539 | |||
| 540 | // `--check` is the old daemon's dry run and this is the whole of it: the | ||
| 541 | // candidate proves it can read the manifest to the end, exits 0, and | ||
| 542 | // adopts nothing — the daemon that wrote it is still serving. Before | ||
| 543 | // the abort flag, which is about adoption and so has nothing to abort | ||
| 544 | // here: a probe that rolled back would exec the old binary out of a | ||
| 545 | // CHILD of the daemon that is still running. | ||
| 546 | if (o.check) return 0; | ||
| 547 | |||
| 548 | const fail_at = failAtFrom(o.resume_fail_at, std.posix.getenv(fail_at_env)); | ||
| 549 | if (std.mem.eql(u8, fail_at, "daemon")) | ||
| 550 | return rollback(alloc, parsed.daemon.writer_path, resume_fd, "daemon (--resume-fail-at)"); | ||
| 551 | |||
| 552 | var srv = Server.initFromManifest(alloc, &parsed, build_options.version) catch |err| { | ||
| 553 | var reason: [64]u8 = undefined; | ||
| 554 | return rollback(alloc, parsed.daemon.writer_path, resume_fd, std.fmt.bufPrint(&reason, "sessions ({t})", .{err}) catch "sessions"); | ||
| 555 | }; | ||
| 556 | // Before the teardown defer, never after: a rollback must leave every | ||
| 557 | // shell, socket and directory exactly as it found them, and deinit is | ||
| 558 | // the demolition list. | ||
| 559 | if (std.mem.eql(u8, fail_at, "session")) | ||
| 560 | return rollback(alloc, parsed.daemon.writer_path, resume_fd, "session (--resume-fail-at)"); | ||
| 561 | defer srv.deinit(); | ||
| 562 | |||
| 563 | // The marker's job ended the moment this image started serving. | ||
| 564 | _ = unsetenv(rollback_marker); | ||
| 565 | |||
| 566 | @import("server").installSignalHandlers(); | ||
| 567 | return try srv.run(); | ||
| 568 | } | ||
| 569 | |||
| 570 | fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 { | ||
| 571 | // Address and key are settled before anything binds: a mistyped address | ||
| 572 | // or an unreadable key must not first leave a session socket and a live | ||
| 573 | // shell behind. Same discipline as the key loader's own refusals. | ||
| 574 | var quic_bind: ?std.net.Address = null; | ||
| 575 | var quic_key: quic.Key = undefined; | ||
| 576 | if (o.quic) |hostport| { | ||
| 577 | quic_bind = parseBindAddr(hostport) catch { | ||
| 578 | std.debug.print( | ||
| 579 | "muxd: --quic wants HOST:PORT with a literal address, got {s}\n", | ||
| 580 | .{hostport}, | ||
| 581 | ); | ||
| 582 | return 1; | ||
| 583 | }; | ||
| 584 | // --key, then MUX_KEY_FILE, then the default path if it exists. | ||
| 585 | // The default is only reached for by a daemon that was asked for | ||
| 586 | // QUIC, so an absent one is a message rather than a silent skip — | ||
| 587 | // and the message can always name the path because it is resolved | ||
| 588 | // whether or not it is the one chosen. | ||
| 589 | const dflt = try xdg.keyPath(alloc); | ||
| 590 | defer alloc.free(dflt); | ||
| 591 | const dflt_if_present: ?[]const u8 = | ||
| 592 | if (std.fs.cwd().access(dflt, .{})) |_| dflt else |_| null; | ||
| 593 | const key_path = pickKey(o.key, envKey(), dflt_if_present) orelse { | ||
| 594 | std.debug.print( | ||
| 595 | "muxd: no key: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n", | ||
| 596 | .{dflt}, | ||
| 597 | ); | ||
| 598 | return 2; | ||
| 599 | }; | ||
| 600 | quic_key = quic.Key.load(key_path) catch |err| switch (err) { | ||
| 601 | // The three the user can act on, in quic.zig's words — the one | ||
| 602 | // owner of them, because `muxd endpoint`, the daemon's | ||
| 603 | // `endpoint_req` and the client print the same sentences. | ||
| 604 | // | ||
| 605 | // Anything else still propagates rather than being flattened | ||
| 606 | // into a refusal line: `run` is a foreground start that may | ||
| 607 | // fail, so the error goes up. The announce paths cannot do | ||
| 608 | // that — they must stay on ssh — which is why their catch-all | ||
| 609 | // prints the body's fourth sentence and this one does not. | ||
| 610 | error.KeyFileMissing, | ||
| 611 | error.KeyFilePermissive, | ||
| 612 | error.KeyFileMalformed, | ||
| 613 | => { | ||
| 614 | var buf: [quic.key_refusal_len]u8 = undefined; | ||
| 615 | std.debug.print("muxd: {s}\n", .{quic.keyRefusalBody(&buf, err, key_path)}); | ||
| 616 | return 1; | ||
| 617 | }, | ||
| 618 | else => return err, | ||
| 619 | }; | ||
| 620 | } | ||
| 621 | |||
| 622 | // The UDP socket is bound BEFORE the session socket, so a port that is | ||
| 623 | // already taken costs nothing: no shell has been started and no socket | ||
| 624 | // left on disk. It is the same discipline as loading the key first, one | ||
| 625 | // syscall further along. | ||
| 626 | var listener: ?*quic_server.Listener = null; | ||
| 627 | if (quic_bind) |addr| { | ||
| 628 | listener = quic_server.Listener.bind(alloc, addr, quic_key, o.quic_idle_ms) catch |err| switch (err) { | ||
| 629 | // The QUIC edition of "a daemon is already running", refused for | ||
| 630 | // the same reason: the listener sets no SO_REUSEADDR, so rather | ||
| 631 | // than silently splitting a port's datagrams with the daemon | ||
| 632 | // already there, the second one says so and stops. | ||
| 633 | error.AddressInUse => { | ||
| 634 | std.debug.print( | ||
| 635 | "muxd: a daemon is already listening on udp {s}\n", | ||
| 636 | .{o.quic.?}, | ||
| 637 | ); | ||
| 638 | return 1; | ||
| 639 | }, | ||
| 640 | else => { | ||
| 641 | std.debug.print( | ||
| 642 | "muxd: cannot listen on udp {s}: {s}\n", | ||
| 643 | .{ o.quic.?, @errorName(err) }, | ||
| 644 | ); | ||
| 645 | return 1; | ||
| 646 | }, | ||
| 647 | }; | ||
| 648 | } | ||
| 649 | // Registered before the server's, so it runs after it: a client slot | ||
| 650 | // backed by QUIC closes through the listener, and the server must finish | ||
| 651 | // tearing its slots down before the listener is freed. | ||
| 652 | defer if (listener) |l| l.deinit(); | ||
| 653 | |||
| 654 | const shell_z: [:0]const u8 = if (o.shell) |s| | ||
| 655 | try alloc.dupeZ(u8, s) | ||
| 656 | else | ||
| 657 | try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); | ||
| 658 | defer alloc.free(shell_z); | ||
| 659 | |||
| 660 | // Read from the DAEMON's environment, necessarily: muxd forks the | ||
| 661 | // session shell, so by the time anyone could pass a flag through a | ||
| 662 | // client the shell has been running for a while. | ||
| 663 | const shell_integration = shellIntegrationEnabled( | ||
| 664 | std.posix.getenv("MUX_SHELL_INTEGRATION"), | ||
| 665 | ); | ||
| 666 | |||
| 667 | var srv = Server.init(alloc, .{ | ||
| 668 | .sock_path = sock_path, | ||
| 669 | .shell = shell_z, | ||
| 670 | .cols = o.cols, | ||
| 671 | .rows = o.rows, | ||
| 672 | .shell_integration = shell_integration, | ||
| 673 | .version = build_options.version, | ||
| 674 | }) catch |err| switch (err) { | ||
| 675 | // All of these mean "that path is not ours to take", and all | ||
| 676 | // are ordinary operator mistakes rather than daemon bugs: say | ||
| 677 | // so in one line and exit, no stack trace. | ||
| 678 | // | ||
| 679 | // AddressInUse is the same situation found one syscall later: | ||
| 680 | // two daemons starting at once can both see an empty path and | ||
| 681 | // both try to bind it. The loser has simply lost a dead heat, | ||
| 682 | // and telling it "a daemon is already running" is exactly | ||
| 683 | // right — by the time it reads the message, one is. | ||
| 684 | error.DaemonAlreadyRunning, error.AddressInUse => { | ||
| 685 | std.debug.print("muxd: a daemon is already running on {s}\n", .{sock_path}); | ||
| 686 | return 1; | ||
| 687 | }, | ||
| 688 | error.SockPathNotASocket => { | ||
| 689 | std.debug.print( | ||
| 690 | "muxd: {s} exists and is not a socket (move it, or name another with --sock)\n", | ||
| 691 | .{sock_path}, | ||
| 692 | ); | ||
| 693 | return 1; | ||
| 694 | }, | ||
| 695 | else => return err, | ||
| 696 | }; | ||
| 697 | |||
| 698 | defer srv.deinit(); | ||
| 699 | |||
| 700 | if (listener) |l| { | ||
| 701 | l.setHandler(srv.quicHandler()); | ||
| 702 | srv.attachQuic(l); | ||
| 703 | } | ||
| 704 | |||
| 705 | @import("server").installSignalHandlers(); | ||
| 706 | return try srv.run(); | ||
| 707 | } | ||
| 708 | |||
| 709 | /// `askEndpointPort` is not folded in here: it waits under a deadline | ||
| 710 | /// for a daemon too old for `endpoint_req`. This one blocks, so a | ||
| 711 | /// wedge shows. | ||
| 712 | fn oneShotQuery( | ||
| 713 | alloc: std.mem.Allocator, | ||
| 714 | sock_path: []const u8, | ||
| 715 | verb: []const u8, | ||
| 716 | req: proto.MsgType, | ||
| 717 | req_payload: []const u8, | ||
| 718 | want: proto.MsgType, | ||
| 719 | ) !u8 { | ||
| 720 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 721 | std.debug.print( | ||
| 722 | "muxd {s}: nothing listening on {s} (`muxd start` starts a daemon)\n", | ||
| 723 | .{ verb, sock_path }, | ||
| 724 | ); | ||
| 725 | return 1; | ||
| 726 | }; | ||
| 727 | defer stream.close(); | ||
| 728 | |||
| 729 | try proto.writeFrame(stream.handle, req, req_payload); | ||
| 730 | while (try proto.readFrame(alloc, stream.handle)) |frame| { | ||
| 731 | defer frame.deinit(alloc); | ||
| 732 | if (frame.type != want) continue; | ||
| 733 | try proto.writeAllFd(std.posix.STDOUT_FILENO, frame.payload); | ||
| 734 | try proto.writeAllFd(std.posix.STDOUT_FILENO, "\n"); | ||
| 735 | return 0; | ||
| 736 | } | ||
| 737 | return 1; | ||
| 738 | } | ||
| 739 | |||
| 740 | fn dump(alloc: std.mem.Allocator, sock_path: []const u8, vt_mode: bool, session: []const u8) !u8 { | ||
| 741 | // vt byte ++ session-name tail, built by the wire module — empty is the | ||
| 742 | // wire's own default spelling, so a bare `muxd dump` sends exactly the | ||
| 743 | // one-byte payload that predates session names. | ||
| 744 | var buf: [proto.debug_dump_max_len]u8 = undefined; | ||
| 745 | const payload = proto.encodeDebugDumpNamed(&buf, vt_mode, session); | ||
| 746 | return oneShotQuery(alloc, sock_path, "dump", .debug_dump, payload, .dump_reply); | ||
| 747 | } | ||
| 748 | |||
| 749 | fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | ||
| 750 | return oneShotQuery(alloc, sock_path, "stats", .stats_req, "", .stats_reply); | ||
| 751 | } | ||
| 752 | |||
| 753 | /// Ask the daemon on `sock_path` to exit, then wait until the PROCESS is | ||
| 754 | /// gone, not just the path. Exit 0 covers both "stopped" and "nothing | ||
| 755 | /// there" — the state the user asked for is the state they got, which is | ||
| 756 | /// what makes the verb safe to script (`muxd start`'s re-runnability, | ||
| 757 | /// mirrored). | ||
| 758 | /// | ||
| 759 | /// Prefixes split the way `start`'s do: `muxd stop:` for a refusal or a | ||
| 760 | /// report about this command, plain `muxd:` for a lifecycle verdict. | ||
| 761 | fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | ||
| 762 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 763 | std.debug.print("muxd stop: nothing listening on {s}\n", .{sock_path}); | ||
| 764 | return 0; | ||
| 765 | }; | ||
| 766 | // A daemon that dies between connect and write reached the asked-for | ||
| 767 | // state on its own; the poll below confirms it either way. But whether | ||
| 768 | // the frame LANDED is kept: a daemon can accept a connection and close | ||
| 769 | // it before the request arrives, and a log holds nothing about a | ||
| 770 | // request it never saw — pointing there would send the reader to an | ||
| 771 | // empty page. | ||
| 772 | const asked = if (proto.writeFrame(stream.handle, .stop_req, "")) |_| true else |_| false; | ||
| 773 | const peer = peerPid(stream.handle); | ||
| 774 | stream.close(); | ||
| 775 | |||
| 776 | // Probe-first, deadline-second — ensureDaemon's poll shape (spawn.zig), | ||
| 777 | // so the final window before the deadline is still probed and the | ||
| 778 | // failure line is never printed about an interval nobody checked. | ||
| 779 | // spawn.probe is the connect-refusal test: a live listener's backlog | ||
| 780 | // accepts even when its event loop is wedged (up to backlog depth — | ||
| 781 | // past ~128 pending connects AF_UNIX blocks rather than refuses, so | ||
| 782 | // this loop would wait, not lie), and only the shutdown unlink can | ||
| 783 | // produce a refusal, which makes it the true signal. | ||
| 784 | const stop_deadline_ms: i64 = 2000; | ||
| 785 | const t0 = std.time.milliTimestamp(); | ||
| 786 | while (true) { | ||
| 787 | if (!spawn.probe(sock_path)) return waitPidGone(peer, sock_path); | ||
| 788 | if (std.time.milliTimestamp() - t0 >= stop_deadline_ms) break; | ||
| 789 | std.Thread.sleep(50 * std.time.ns_per_ms); | ||
| 790 | } | ||
| 791 | const secs = @divTrunc(stop_deadline_ms, 1000); | ||
| 792 | if (!asked) { | ||
| 793 | std.debug.print( | ||
| 794 | "muxd stop: could not deliver the stop request to {s}, and it is still answering after {d}s\n", | ||
| 795 | .{ sock_path, secs }, | ||
| 796 | ); | ||
| 797 | return 1; | ||
| 798 | } | ||
| 799 | var hint: [log_hint_len]u8 = undefined; | ||
| 800 | std.debug.print( | ||
| 801 | "muxd stop: {s} still answering after {d}s{s}\n", | ||
| 802 | .{ sock_path, secs, logHint(alloc, &hint) }, | ||
| 803 | ); | ||
| 804 | return 1; | ||
| 805 | } | ||
| 806 | |||
| 807 | /// The daemon's pid from the kernel, not from the daemon: `stop`'s promise | ||
| 808 | /// is about a process, and only the OS can vouch for one. Null when the | ||
| 809 | /// kernel cannot name the peer (another pid namespace reports 0), and then | ||
| 810 | /// the socket's silence is all there is to wait on. | ||
| 811 | fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t { | ||
| 812 | const Ucred = extern struct { pid: std.posix.pid_t, uid: std.posix.uid_t, gid: std.posix.gid_t }; | ||
| 813 | var cred: Ucred = undefined; | ||
| 814 | std.posix.getsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, std.mem.asBytes(&cred)) catch return null; | ||
| 815 | return if (cred.pid > 0) cred.pid else null; | ||
| 816 | } | ||
| 817 | |||
| 818 | /// A socket gone quiet is the unlink, and the unlink is the FIRST thing a | ||
| 819 | /// stopping daemon does; reaping its shells (one TERM grace when one | ||
| 820 | /// ignores it) and deleting its dirs come after. "stopped" said at the | ||
| 821 | /// unlink handed a scripted `muxd start`, or a supervisor's "is it down", | ||
| 822 | /// a daemon still running. The bound is the reap's own grace with room to | ||
| 823 | /// spare — a daemon still here after it is wedged in teardown, and that | ||
| 824 | /// is a report, not a wait. | ||
| 825 | fn waitPidGone(peer: ?std.posix.pid_t, sock_path: []const u8) u8 { | ||
| 826 | const pid = peer orelse { | ||
| 827 | std.debug.print("muxd: stopped\n", .{}); | ||
| 828 | return 0; | ||
| 829 | }; | ||
| 830 | const gone_deadline_ms: i64 = 3000; | ||
| 831 | const t0 = std.time.milliTimestamp(); | ||
| 832 | // Only a live process answers signal 0 with success. ESRCH is the | ||
| 833 | // answer wanted; EPERM means the pid was reused by someone else's | ||
| 834 | // process, and the daemon is just as gone. | ||
| 835 | while (std.posix.kill(pid, 0)) |_| { | ||
| 836 | if (std.time.milliTimestamp() - t0 >= gone_deadline_ms) { | ||
| 837 | std.debug.print( | ||
| 838 | "muxd stop: {s} is closed, but pid {d} is still running {d}s later\n", | ||
| 839 | .{ sock_path, pid, @divTrunc(gone_deadline_ms, 1000) }, | ||
| 840 | ); | ||
| 841 | return 1; | ||
| 842 | } | ||
| 843 | std.Thread.sleep(20 * std.time.ns_per_ms); | ||
| 844 | } else |_| {} | ||
| 845 | std.debug.print("muxd: stopped\n", .{}); | ||
| 846 | return 0; | ||
| 847 | } | ||
| 848 | |||
| 849 | /// Ask the daemon on `sock_path` to become THIS binary. The new binary is | ||
| 850 | /// the one that asks: it knows its own version and its own path, and the | ||
| 851 | /// daemon is the one that decides. | ||
| 852 | /// | ||
| 853 | /// Prefixes split as `stop`'s do: `muxd upgrade:` for a refusal or a report | ||
| 854 | /// about this command, plain `muxd:` for the lifecycle verdict. | ||
| 855 | fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 { | ||
| 856 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 857 | const exe = std.fs.selfExePath(&exe_buf) catch { | ||
| 858 | std.debug.print("muxd upgrade: cannot find own binary via /proc/self/exe\n", .{}); | ||
| 859 | return 1; | ||
| 860 | }; | ||
| 861 | |||
| 862 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 863 | std.debug.print("muxd upgrade: nothing listening on {s}\n", .{sock_path}); | ||
| 864 | return 1; | ||
| 865 | }; | ||
| 866 | defer stream.close(); | ||
| 867 | |||
| 868 | var buf: [std.fs.max_path_bytes + 64]u8 = undefined; | ||
| 869 | const payload = proto.encodeUpgradeReq(&buf, .{ | ||
| 870 | .allow_same_version = allow_same, | ||
| 871 | .version = build_options.version, | ||
| 872 | .path = exe, | ||
| 873 | }) catch { | ||
| 874 | std.debug.print("muxd upgrade: cannot name {s} in a request\n", .{exe}); | ||
| 875 | return 1; | ||
| 876 | }; | ||
| 877 | proto.writeFrame(stream.handle, .upgrade_req, payload) catch { | ||
| 878 | std.debug.print("muxd upgrade: {s} closed before the request landed\n", .{sock_path}); | ||
| 879 | return 1; | ||
| 880 | }; | ||
| 881 | |||
| 882 | // Bounded, because a daemon older than this feature drops an unknown | ||
| 883 | // frame without a word: the expiry is a diagnosis, not a timeout. | ||
| 884 | const deadline_ms: i64 = 5000; | ||
| 885 | const t0 = std.time.milliTimestamp(); | ||
| 886 | while (true) { | ||
| 887 | const left = deadline_ms - (std.time.milliTimestamp() - t0); | ||
| 888 | if (left <= 0) break; | ||
| 889 | var pfd = [_]std.posix.pollfd{ | ||
| 890 | .{ .fd = stream.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 891 | }; | ||
| 892 | if ((std.posix.poll(&pfd, @intCast(left)) catch break) == 0) break; | ||
| 893 | // EOF: an older daemon that drops the connection over a frame it | ||
| 894 | // cannot read reaches the same conclusion as silence does. | ||
| 895 | const frame = (proto.readFrame(alloc, stream.handle) catch break) orelse break; | ||
| 896 | defer frame.deinit(alloc); | ||
| 897 | if (frame.type != .upgrade_reply or frame.payload.len == 0) continue; | ||
| 898 | if (frame.payload[0] != 0) { | ||
| 899 | // The daemon's words, verbatim: it is the side that knows which | ||
| 900 | // check failed, and paraphrasing here would lose the versions. | ||
| 901 | std.debug.print("muxd upgrade: refused: {s}\n", .{frame.payload[1..]}); | ||
| 902 | return 1; | ||
| 903 | } | ||
| 904 | std.debug.print("muxd: upgraded to {s}\n", .{build_options.version}); | ||
| 905 | return confirmServing(alloc, sock_path); | ||
| 906 | } | ||
| 907 | std.debug.print( | ||
| 908 | "muxd upgrade: no reply: this daemon predates upgrade — stop and run\n", | ||
| 909 | .{}, | ||
| 910 | ); | ||
| 911 | return 1; | ||
| 912 | } | ||
| 913 | |||
| 914 | /// The socket answered, and the answer came from the new image. | ||
| 915 | fn confirmServing(alloc: std.mem.Allocator, sock_path: []const u8) u8 { | ||
| 916 | // Not `spawn.probe`: the listener fd crosses the exec, so a connect | ||
| 917 | // succeeds throughout the handover — it is served out of the backlog by | ||
| 918 | // whichever image accepts it. Only an ANSWERED frame says the new one | ||
| 919 | // is pumping. | ||
| 920 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 921 | std.debug.print("muxd upgrade: {s} stopped answering after the exec\n", .{sock_path}); | ||
| 922 | return 1; | ||
| 923 | }; | ||
| 924 | defer stream.close(); | ||
| 925 | proto.writeFrame(stream.handle, .stats_req, "") catch return 1; | ||
| 926 | |||
| 927 | const deadline_ms: i64 = 5000; | ||
| 928 | var pfd = [_]std.posix.pollfd{ | ||
| 929 | .{ .fd = stream.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 930 | }; | ||
| 931 | if ((std.posix.poll(&pfd, deadline_ms) catch 0) > 0) { | ||
| 932 | if (proto.readFrame(alloc, stream.handle) catch null) |frame| { | ||
| 933 | defer frame.deinit(alloc); | ||
| 934 | if (frame.type == .stats_reply) return 0; | ||
| 935 | } | ||
| 936 | } | ||
| 937 | const secs = @divTrunc(deadline_ms, 1000); | ||
| 938 | var hint: [log_hint_len]u8 = undefined; | ||
| 939 | std.debug.print( | ||
| 940 | "muxd upgrade: exec'd, but {s} has not answered in {d}s{s}\n", | ||
| 941 | .{ sock_path, secs, logHint(alloc, &hint) }, | ||
| 942 | ); | ||
| 943 | return 1; | ||
| 944 | } | ||
| 945 | |||
| 946 | const log_hint_len = std.fs.max_path_bytes + 64; | ||
| 947 | |||
| 948 | /// The "where the rest of the story is" clause, or "" when there is no | ||
| 949 | /// path to name. `stopCmd` and `reportNoListener` are read by someone | ||
| 950 | /// who is not at that box. | ||
| 951 | /// | ||
| 952 | /// Only when the path resolves: an absent HOME (a container, a systemd | ||
| 953 | /// unit) must not replace the finding that matters with an error trace. | ||
| 954 | /// And the hedge stays in the words: a foreground `muxd run` logs to | ||
| 955 | /// its own stderr, so naming the xdg path unconditionally would guess. | ||
| 956 | fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 { | ||
| 957 | const log = xdg.logPath(alloc) catch return ""; | ||
| 958 | defer alloc.free(log); | ||
| 959 | // A path too long for the buffer is dropped rather than clipped: half | ||
| 960 | // a path is worse than none, and `log_hint_len` clears PATH_MAX, so | ||
| 961 | // the only paths it drops are ones nothing could have opened anyway. | ||
| 962 | return std.fmt.bufPrint( | ||
| 963 | buf, | ||
| 964 | " (if it was started detached, its log is {s})", | ||
| 965 | .{log}, | ||
| 966 | ) catch ""; | ||
| 967 | } | ||
| 968 | |||
| 969 | /// `muxd proxy` with a one-line preamble: ensure a daemon, ensure a key, | ||
| 970 | /// ask the daemon for its QUIC port, print `endpoint <port> <hex-key>` (or | ||
| 971 | /// `endpoint none`) as the FIRST bytes on stdout, then become exactly the | ||
| 972 | /// proxy byte pump. This is what `mux HOST` runs over ssh. | ||
| 973 | /// | ||
| 974 | /// The announce is mandatory in both directions. The client blocks on one | ||
| 975 | /// newline-terminated line, and the daemon side of the frame protocol | ||
| 976 | /// sends nothing unprompted — so silence here is indistinguishable from a | ||
| 977 | /// slow ssh and would hang the attach rather than degrade it. | ||
| 978 | /// | ||
| 979 | /// The two kinds of failure therefore end differently. A SOFT one — no | ||
| 980 | /// usable key, no listener — announces `endpoint none` and pumps anyway: | ||
| 981 | /// the ssh session is real and carries the whole session. A HARD one — no | ||
| 982 | /// daemon to pump to, a stdout that will not take the announce — exits, | ||
| 983 | /// and the client reads EOF on the pipe, which `handoff.readLine` already | ||
| 984 | /// tells apart from a line (`UnterminatedLine`). Announcing none and THEN | ||
| 985 | /// exiting is the one dishonest option available: it would tell the client | ||
| 986 | /// it has a working ssh session at the moment that session goes away. | ||
| 987 | /// | ||
| 988 | /// stdout carries the announce and then frames, nothing else: every | ||
| 989 | /// human-facing word here goes to stderr, which ssh already carries to the | ||
| 990 | /// user's terminal. That includes `ensureForAttach`'s progress, whose | ||
| 991 | /// Progress is pinned to STDERR_FILENO in spawn.zig — a stray stdout byte | ||
| 992 | /// ahead of the announce would land in the middle of the client's parse. | ||
| 993 | /// | ||
| 994 | /// The pump that follows keeps speaking in its own name: a socket that | ||
| 995 | /// disappears between the ask and the attach is reported by proxy.zig as | ||
| 996 | /// `muxd proxy: cannot connect to …`. That is deliberate — reusing | ||
| 997 | /// `proxy.run` is the whole design, and the line names the code that | ||
| 998 | /// failed rather than the verb that was typed. | ||
| 999 | fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | ||
| 1000 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 1001 | const exe = std.fs.selfExePath(&exe_buf) catch { | ||
| 1002 | std.debug.print("muxd endpoint: cannot find own binary via /proc/self/exe\n", .{}); | ||
| 1003 | return 1; | ||
| 1004 | }; | ||
| 1005 | if (!try spawn.ensureForAttach(alloc, exe, sock_path, "muxd endpoint")) return 1; | ||
| 1006 | |||
| 1007 | // The announce goes out on the same stdout the pump is about to use, | ||
| 1008 | // so it wants the same EPIPE-not-SIGPIPE treatment — and it wants it | ||
| 1009 | // from proxy.zig's installer rather than from a std default this file | ||
| 1010 | // would be leaning on. AFTER the auto-start above, never before: the | ||
| 1011 | // ignore is SIG_IGN, which survives exec, so installing it first would | ||
| 1012 | // hand the spawned daemon an inherited disposition it never chose. | ||
| 1013 | proxy.ignoreSigpipe(); | ||
| 1014 | |||
| 1015 | // Key first, then the ask, and the order is load-bearing: the daemon's | ||
| 1016 | // lazy bind takes the default key path only if the file already exists | ||
| 1017 | // and never creates one (server.zig endpointPortFrom). Creating it here | ||
| 1018 | // first is what lets a first-ever attach to a fresh box produce | ||
| 1019 | // coordinates instead of announcing none and being told to run keygen. | ||
| 1020 | const key = announceKey(alloc); | ||
| 1021 | const port: u16 = if (key == null) 0 else askEndpointPort(alloc, sock_path); | ||
| 1022 | |||
| 1023 | var line_buf: [handoff.announce_max_len]u8 = undefined; | ||
| 1024 | const line: []const u8 = blk: { | ||
| 1025 | const k = key orelse break :blk handoff.announce_none; | ||
| 1026 | // 0 is `endpoint_reply`'s "could not" — no key the daemon could | ||
| 1027 | // load, no bind, no reply — and turning it into the negative | ||
| 1028 | // announce is this caller's job. formatAnnounce refuses port 0 | ||
| 1029 | // rather than doing it quietly, so an omission here would fail on | ||
| 1030 | // this line instead of arriving on another machine as a parse error | ||
| 1031 | // about a message we wrote. | ||
| 1032 | if (port == 0) { | ||
| 1033 | reportNoListener(alloc, sock_path); | ||
| 1034 | break :blk handoff.announce_none; | ||
| 1035 | } | ||
| 1036 | // Unreachable in fact — port 0 is gone by here, and line_buf is | ||
| 1037 | // sized by the same constant that bounds the grammar — but written | ||
| 1038 | // as a fallback rather than `unreachable` because of what the two | ||
| 1039 | // do if that ever stops being true. A panic on the remote box takes | ||
| 1040 | // down a session ssh was about to carry perfectly well, to report a | ||
| 1041 | // bug about a preamble; announcing none costs the user QUIC and | ||
| 1042 | // nothing else, which is what every other unhappy path here does. | ||
| 1043 | break :blk handoff.formatAnnounce(&line_buf, .{ .port = port, .key = k.bytes }) catch | ||
| 1044 | handoff.announce_none; | ||
| 1045 | }; | ||
| 1046 | proto.writeAllFd(std.posix.STDOUT_FILENO, line) catch |err| { | ||
| 1047 | // stdout is the pipe the pump is about to need, so there is no | ||
| 1048 | // session left to fall back to — only a line about why. The error | ||
| 1049 | // is named rather than guessed at: EPIPE (the ssh client gave up | ||
| 1050 | // first) is the likely one, but a full disk under a redirect and a | ||
| 1051 | // closed fd reach here too, and they want different reactions. | ||
| 1052 | std.debug.print( | ||
| 1053 | "muxd endpoint: cannot write the announce to stdout: {s}\n", | ||
| 1054 | .{@errorName(err)}, | ||
| 1055 | ); | ||
| 1056 | return 1; | ||
| 1057 | }; | ||
| 1058 | |||
| 1059 | return proxy.run(sock_path); | ||
| 1060 | } | ||
| 1061 | |||
| 1062 | /// The daemon is up and answering but has no QUIC listener: no key it | ||
| 1063 | /// could load, a bind that failed, or a binary too old to know the | ||
| 1064 | /// verb. The reason went to the daemon's log, on a box the reader is | ||
| 1065 | /// not sitting at, so the line says where the rest is. | ||
| 1066 | fn reportNoListener(alloc: std.mem.Allocator, sock_path: []const u8) void { | ||
| 1067 | var hint: [log_hint_len]u8 = undefined; | ||
| 1068 | std.debug.print( | ||
| 1069 | "muxd endpoint: the daemon on {s} produced no QUIC listener; staying on ssh{s}\n", | ||
| 1070 | .{ sock_path, logHint(alloc, &hint) }, | ||
| 1071 | ); | ||
| 1072 | } | ||
| 1073 | |||
| 1074 | /// The key `muxd endpoint` announces, or null with exactly one stderr line | ||
| 1075 | /// saying why not. The reading of the environment and the deciding live in | ||
| 1076 | /// `announceKeyFrom` below; this half owns the words. | ||
| 1077 | fn announceKey(alloc: std.mem.Allocator) ?quic.Key { | ||
| 1078 | // Resolved whether or not it is the one chosen, so the no-HOME case can | ||
| 1079 | // be told apart from the have-a-path cases below. | ||
| 1080 | const dflt: ?[]const u8 = xdg.keyPath(alloc) catch null; | ||
| 1081 | defer if (dflt) |p| alloc.free(p); | ||
| 1082 | |||
| 1083 | switch (announceKeyFrom(envKey(), dflt)) { | ||
| 1084 | .key => |k| return k, | ||
| 1085 | .no_path => std.debug.print( | ||
| 1086 | "muxd endpoint: no HOME to resolve a key path; staying on ssh\n", | ||
| 1087 | .{}, | ||
| 1088 | ), | ||
| 1089 | .create_failed => |f| std.debug.print( | ||
| 1090 | "muxd endpoint: cannot create {s}: {s}; staying on ssh\n", | ||
| 1091 | .{ f.path, @errorName(f.err) }, | ||
| 1092 | ), | ||
| 1093 | .load_failed => |f| reportKeyRefusal(f.path, f.err), | ||
| 1094 | } | ||
| 1095 | return null; | ||
| 1096 | } | ||
| 1097 | |||
| 1098 | /// What the key resolution decided and why, separated from the printing of | ||
| 1099 | /// it. `pickKey` above exists for the same reason and the daemon's | ||
| 1100 | /// `endpointPortFrom` is this decision's other half: the two must agree on | ||
| 1101 | /// which file "the key" names, and an order that quietly inverted would | ||
| 1102 | /// otherwise show up only as a client authenticating to nothing. | ||
| 1103 | const KeyResult = union(enum) { | ||
| 1104 | key: quic.Key, | ||
| 1105 | /// No MUX_KEY_FILE and no HOME to build a default under: there is not | ||
| 1106 | /// even a path to try. | ||
| 1107 | no_path, | ||
| 1108 | /// The default key was absent and could not be created. Kept apart | ||
| 1109 | /// from `load_failed` because it is the cause and the load's | ||
| 1110 | /// `KeyFileMissing` would only be its symptom. | ||
| 1111 | create_failed: struct { path: []const u8, err: anyerror }, | ||
| 1112 | load_failed: struct { path: []const u8, err: anyerror }, | ||
| 1113 | }; | ||
| 1114 | |||
| 1115 | /// MUX_KEY_FILE, then the default path — the same order the daemon's lazy | ||
| 1116 | /// bind uses, because the announce hands a client the key it will | ||
| 1117 | /// authenticate WITH against a listener holding whatever the daemon | ||
| 1118 | /// loaded. Two spellings of "the key" would attach to nothing. | ||
| 1119 | /// | ||
| 1120 | /// Only the default is created when absent: that is the mosh-server move, | ||
| 1121 | /// and it is what lets a first attach to a fresh box produce coordinates | ||
| 1122 | /// at all. A MUX_KEY_FILE that is set but missing names a file the user | ||
| 1123 | /// manages, and writing one there would be a credential appearing where | ||
| 1124 | /// nobody asked for it — so that path is loaded, never created. | ||
| 1125 | /// | ||
| 1126 | /// Environment handed in and nothing printed, so the order and the | ||
| 1127 | /// create-vs-load distinction are testable: the `*From` discipline xdg.zig | ||
| 1128 | /// set and server.zig's `endpointPortFrom` follows. | ||
| 1129 | fn announceKeyFrom(env: ?[]const u8, dflt: ?[]const u8) KeyResult { | ||
| 1130 | if (env) |p| return if (quic.Key.load(p)) |k| | ||
| 1131 | .{ .key = k } | ||
| 1132 | else |err| | ||
| 1133 | .{ .load_failed = .{ .path = p, .err = err } }; | ||
| 1134 | |||
| 1135 | const path = dflt orelse return .no_path; | ||
| 1136 | // KeyExists is the ordinary case and no news: the key is already there | ||
| 1137 | // and the load below is what wanted it. Any OTHER create failure is | ||
| 1138 | // kept, because if the load then fails too it is the create that holds | ||
| 1139 | // the reason — an unwritable config directory, a full disk — while the | ||
| 1140 | // load can only report the file's absence, which is its symptom. | ||
| 1141 | const create_failed: ?anyerror = if (xdg.writeNewKey(path)) |_| | ||
| 1142 | null | ||
| 1143 | else |err| if (err == error.KeyExists) null else err; | ||
| 1144 | |||
| 1145 | return if (quic.Key.load(path)) |k| | ||
| 1146 | .{ .key = k } | ||
| 1147 | else |load_err| if (create_failed) |err| | ||
| 1148 | .{ .create_failed = .{ .path = path, .err = err } } | ||
| 1149 | else | ||
| 1150 | .{ .load_failed = .{ .path = path, .err = load_err } }; | ||
| 1151 | } | ||
| 1152 | |||
| 1153 | /// One line naming the reason: it rides ssh's stderr to someone who is | ||
| 1154 | /// not on that box. The words are `quic.keyRefusalBody`'s, so a refusal | ||
| 1155 | /// reads the same however the daemon was asked. | ||
| 1156 | fn reportKeyRefusal(path: []const u8, err: anyerror) void { | ||
| 1157 | var buf: [quic.key_refusal_len]u8 = undefined; | ||
| 1158 | std.debug.print( | ||
| 1159 | "muxd endpoint: {s}; staying on ssh\n", | ||
| 1160 | .{quic.keyRefusalBody(&buf, err, path)}, | ||
| 1161 | ); | ||
| 1162 | } | ||
| 1163 | |||
| 1164 | /// One observer round-trip: `endpoint_req`, then a bounded wait for the | ||
| 1165 | /// reply. 0 is every failure — nothing listening, no reply, a reply that | ||
| 1166 | /// is not one — because the caller does the same thing with all of them. | ||
| 1167 | /// | ||
| 1168 | /// The bound is what converts an old daemon's silence into the | ||
| 1169 | /// announce-none path instead of a hang: `MsgType` is non-exhaustive, so a | ||
| 1170 | /// binary from before the verb existed simply ignores it. It reuses | ||
| 1171 | /// `spawn.start_deadline_ms` rather than naming a number of its own — the | ||
| 1172 | /// same "how long can a local daemon reasonably take" the ensure above | ||
| 1173 | /// already waited on. | ||
| 1174 | /// | ||
| 1175 | /// The connect is outside the loop, so the ordinary failure — nothing on | ||
| 1176 | /// the socket — is a refusal in microseconds and never touches the budget. | ||
| 1177 | fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 { | ||
| 1178 | const stream = std.net.connectUnixSocket(sock_path) catch return 0; | ||
| 1179 | defer stream.close(); | ||
| 1180 | proto.writeFrame(stream.handle, .endpoint_req, "") catch return 0; | ||
| 1181 | |||
| 1182 | const deadline = std.time.milliTimestamp() + spawn.start_deadline_ms; | ||
| 1183 | while (std.time.milliTimestamp() < deadline) { | ||
| 1184 | var fds = [_]std.posix.pollfd{ | ||
| 1185 | .{ .fd = stream.handle, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 1186 | }; | ||
| 1187 | const remaining: i32 = @intCast(@max(1, deadline - std.time.milliTimestamp())); | ||
| 1188 | _ = std.posix.poll(&fds, remaining) catch return 0; | ||
| 1189 | if (fds[0].revents == 0) continue; | ||
| 1190 | // Blocking, and bounded only by the poll that said bytes are here: | ||
| 1191 | // a daemon that wrote half a seven-byte frame and then stopped | ||
| 1192 | // would hold this past the deadline. That daemon is this same | ||
| 1193 | // binary on the same box, writing that frame in one call — the | ||
| 1194 | // hazard is worth naming and not worth a state machine. | ||
| 1195 | const f = (proto.readFrame(alloc, stream.handle) catch return 0) orelse return 0; | ||
| 1196 | defer f.deinit(alloc); | ||
| 1197 | if (f.type != .endpoint_reply) continue; | ||
| 1198 | // A reply of the wrong length is one we do not understand, which | ||
| 1199 | // lands in the same place as no reply at all. | ||
| 1200 | return proto.decodeEndpointReply(f.payload) catch 0; | ||
| 1201 | } | ||
| 1202 | return 0; | ||
| 1203 | } | ||
| 1204 | |||
| 1205 | /// `muxd start` = ensureDaemon under an explicit flag. Everything after | ||
| 1206 | /// `start` is forwarded to `run` verbatim — no re-serialization, so a flag | ||
| 1207 | /// that parses here behaves identically there. parseArgs has already | ||
| 1208 | /// validated the flags in THIS process; what it cannot validate (a bad | ||
| 1209 | /// bind address, a missing key file) surfaces in the daemon's log, which | ||
| 1210 | /// the failure path names. | ||
| 1211 | fn startCmd(alloc: std.mem.Allocator, sock_path: []const u8, forwarded: []const [:0]const u8) !u8 { | ||
| 1212 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 1213 | const exe = std.fs.selfExePath(&exe_buf) catch { | ||
| 1214 | std.debug.print("muxd: cannot find own binary via /proc/self/exe\n", .{}); | ||
| 1215 | return 1; | ||
| 1216 | }; | ||
| 1217 | const progress: spawn.Progress = .{ | ||
| 1218 | .fd = std.posix.STDERR_FILENO, | ||
| 1219 | .prefix = "muxd", | ||
| 1220 | .tty = std.posix.isatty(std.posix.STDERR_FILENO), | ||
| 1221 | }; | ||
| 1222 | // Default log path: the xdg one is the whole point for a real daemon. | ||
| 1223 | // Truncating, and this is the only caller that does: `start` is the one | ||
| 1224 | // verb whose user asked for a (re)start, so the log they go on to read | ||
| 1225 | // must be about the daemon they just started. | ||
| 1226 | const r = spawn.ensureDaemon(alloc, exe, forwarded, sock_path, progress, spawn.start_deadline_ms, .{ | ||
| 1227 | .truncate = true, | ||
| 1228 | }) catch |err| switch (err) { | ||
| 1229 | // The failure line, with the log path, was already printed by | ||
| 1230 | // Progress — a second line here would say the same thing worse. | ||
| 1231 | error.NeverAnswered => return 1, | ||
| 1232 | error.BinaryNotFound, error.SpawnFailed => { | ||
| 1233 | std.debug.print("muxd: could not spawn {s}: {s}\n", .{ exe, @errorName(err) }); | ||
| 1234 | return 1; | ||
| 1235 | }, | ||
| 1236 | }; | ||
| 1237 | if (r == .already_running) { | ||
| 1238 | std.debug.print( | ||
| 1239 | "muxd: already running on {s} (stop it first with `muxd stop --sock {s}` if you meant different flags)\n", | ||
| 1240 | .{ sock_path, sock_path }, | ||
| 1241 | ); | ||
| 1242 | } | ||
| 1243 | return 0; | ||
| 1244 | } | ||
| 1245 | |||
| 1246 | fn keygen(alloc: std.mem.Allocator) !u8 { | ||
| 1247 | const path = try xdg.keyPath(alloc); | ||
| 1248 | defer alloc.free(path); | ||
| 1249 | xdg.writeNewKey(path) catch |err| switch (err) { | ||
| 1250 | error.KeyExists => { | ||
| 1251 | std.debug.print( | ||
| 1252 | "muxd keygen: {s} already exists; rotation is `rm` + `keygen`, deliberately\n", | ||
| 1253 | .{path}, | ||
| 1254 | ); | ||
| 1255 | return 1; | ||
| 1256 | }, | ||
| 1257 | else => |e| return e, | ||
| 1258 | }; | ||
| 1259 | var buf: [std.fs.max_path_bytes + 1]u8 = undefined; | ||
| 1260 | const line = std.fmt.bufPrint(&buf, "{s}\n", .{path}) catch unreachable; | ||
| 1261 | _ = std.posix.write(std.posix.STDOUT_FILENO, line) catch {}; | ||
| 1262 | return 0; | ||
| 1263 | } | ||
| 1264 | |||
| 1265 | // --------------------------------------------------------------------------- | ||
| 1266 | // Tests. These run because `exe_mod` was added to build.zig's test loop in | ||
| 1267 | // the same commit; before that, a test written here would have compiled and | ||
| 1268 | // silently never executed (the hazard recorded in decisions.md, which cost | ||
| 1269 | // mux_main.zig five invisible tests). | ||
| 1270 | // --------------------------------------------------------------------------- | ||
| 1271 | |||
| 1272 | /// The tests must speak argsAlloc's type: a slice of | ||
| 1273 | /// sentinel-terminated strings. | ||
| 1274 | fn parse(comptime argv: []const [:0]const u8) ParseResult { | ||
| 1275 | return parseArgs(argv); | ||
| 1276 | } | ||
| 1277 | |||
| 1278 | test "parseArgs: subcommands and their existing flags" { | ||
| 1279 | const r = parse(&.{ "muxd", "run" }); | ||
| 1280 | try std.testing.expect(r == .ok); | ||
| 1281 | try std.testing.expect(r.ok._cmd == .run); | ||
| 1282 | try std.testing.expect(r.ok.sock == null); | ||
| 1283 | try std.testing.expectEqual(@as(u16, 80), r.ok.cols); | ||
| 1284 | try std.testing.expectEqual(@as(u16, 24), r.ok.rows); | ||
| 1285 | |||
| 1286 | const d = parse(&.{ "muxd", "dump", "--vt", "--sock", "/tmp/x.sock" }); | ||
| 1287 | try std.testing.expect(d.ok._cmd == .dump); | ||
| 1288 | try std.testing.expect(d.ok.vt); | ||
| 1289 | try std.testing.expectEqualStrings("/tmp/x.sock", d.ok.sock.?); | ||
| 1290 | // No --session named: nothing to validate, and `dump` spells the absence | ||
| 1291 | // on the wire as the empty tail. | ||
| 1292 | try std.testing.expect(d.ok.session == null); | ||
| 1293 | |||
| 1294 | const g = parse(&.{ "muxd", "run", "--cols", "120", "--rows", "40", "--shell", "/bin/dash" }); | ||
| 1295 | try std.testing.expectEqual(@as(u16, 120), g.ok.cols); | ||
| 1296 | try std.testing.expectEqual(@as(u16, 40), g.ok.rows); | ||
| 1297 | try std.testing.expectEqualStrings("/bin/dash", g.ok.shell.?); | ||
| 1298 | |||
| 1299 | try std.testing.expect(parse(&.{"muxd"}).err == .no_command); | ||
| 1300 | try std.testing.expect(parse(&.{ "muxd", "wat" }).err == .unknown_command); | ||
| 1301 | try std.testing.expect(parse(&.{ "muxd", "run", "--wat" }).err == .unknown_arg); | ||
| 1302 | } | ||
| 1303 | |||
| 1304 | test "parse: dump --session rides into the payload" { | ||
| 1305 | const d = parse(&.{ "muxd", "dump", "--session", "b", "--sock", "/tmp/x.sock" }); | ||
| 1306 | try std.testing.expect(d == .ok); | ||
| 1307 | try std.testing.expectEqualStrings("b", d.ok.session.?); | ||
| 1308 | |||
| 1309 | // A name no tool could ever address is refused at parse — usage on | ||
| 1310 | // stderr, never carried to the wire as a payload nothing can look up. | ||
| 1311 | const bad = parse(&.{ "muxd", "dump", "--session", "has space" }); | ||
| 1312 | try std.testing.expect(bad.err == .bad_session_name); | ||
| 1313 | try std.testing.expectEqualStrings("has space", bad.err.bad_session_name); | ||
| 1314 | } | ||
| 1315 | |||
| 1316 | test "parseArgs: --key without --quic is refused; --quic alone defers to main" { | ||
| 1317 | const both = parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433", "--key", "/k" }); | ||
| 1318 | try std.testing.expect(both == .ok); | ||
| 1319 | try std.testing.expectEqualStrings("0.0.0.0:4433", both.ok.quic.?); | ||
| 1320 | try std.testing.expectEqualStrings("/k", both.ok.key.?); | ||
| 1321 | |||
| 1322 | // --quic without --key is no longer a parse error: main resolves | ||
| 1323 | // MUX_KEY_FILE and the default path, and parse cannot see either. | ||
| 1324 | const deferred = parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433" }); | ||
| 1325 | try std.testing.expect(deferred == .ok); | ||
| 1326 | try std.testing.expect(deferred.ok.key == null); | ||
| 1327 | |||
| 1328 | // A key with nowhere to listen is still a mistake with no reading that | ||
| 1329 | // makes it sensible, and parse can see the whole of it. | ||
| 1330 | try std.testing.expect(parse(&.{ "muxd", "run", "--key", "/k" }).err == .key_without_quic); | ||
| 1331 | |||
| 1332 | // Neither is the ordinary case and must stay silent. | ||
| 1333 | const neither = parse(&.{ "muxd", "run" }); | ||
| 1334 | try std.testing.expect(neither.ok.quic == null); | ||
| 1335 | try std.testing.expect(neither.ok.key == null); | ||
| 1336 | } | ||
| 1337 | |||
| 1338 | test "parseArgs: --quic-idle-ms defaults, parses, and refuses nonsense" { | ||
| 1339 | const dflt = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k" }); | ||
| 1340 | // Spelled out rather than written `default_quic_idle_ms`: asserting | ||
| 1341 | // against the same constant the parser reads would hold for any value, | ||
| 1342 | // so it could never catch the number changing. | ||
| 1343 | try std.testing.expectEqual(@as(u32, 15_000), dflt.ok.quic_idle_ms); | ||
| 1344 | |||
| 1345 | const set = parse(&.{ "muxd", "run", "--quic", "127.0.0.1:1", "--key", "/k", "--quic-idle-ms", "2500" }); | ||
| 1346 | try std.testing.expectEqual(@as(u32, 2500), set.ok.quic_idle_ms); | ||
| 1347 | |||
| 1348 | // Zero means "no idle timeout" to ngtcp2 — the opposite of what anyone | ||
| 1349 | // typing a timeout of zero is asking for, so it is refused rather than | ||
| 1350 | // silently inverted. | ||
| 1351 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "0" }).err == .bad_number); | ||
| 1352 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "soon" }).err == .bad_number); | ||
| 1353 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "-5" }).err == .bad_number); | ||
| 1354 | // Wider than u32: refused at the parse rather than overflowing where it | ||
| 1355 | // is multiplied out to nanoseconds. | ||
| 1356 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "99999999999" }).err == .bad_number); | ||
| 1357 | // The idle flag alone does not turn QUIC on, and must not smuggle the | ||
| 1358 | // both-or-neither rule past the check. | ||
| 1359 | try std.testing.expect(parse(&.{ "muxd", "run", "--quic-idle-ms", "2500" }) == .ok); | ||
| 1360 | |||
| 1361 | // Same treatment for the numbers that were already here. | ||
| 1362 | try std.testing.expect(parse(&.{ "muxd", "run", "--cols", "wide" }).err == .bad_number); | ||
| 1363 | try std.testing.expect(parse(&.{ "muxd", "run", "--rows", "99999" }).err == .bad_number); | ||
| 1364 | } | ||
| 1365 | |||
| 1366 | test "parseArgs: a value-taking flag at the end of argv names itself" { | ||
| 1367 | // This used to report "unknown argument: --quic", which blames the flag | ||
| 1368 | // rather than the missing value. | ||
| 1369 | inline for (.{ "--sock", "--shell", "--cols", "--rows", "--quic", "--key", "--quic-idle-ms", "--session", "--resume-fd", "--resume-fail-at" }) |flag| { | ||
| 1370 | const r = parse(&.{ "muxd", "run", flag }); | ||
| 1371 | try std.testing.expect(r.err == .missing_value); | ||
| 1372 | try std.testing.expectEqualStrings(flag, r.err.missing_value); | ||
| 1373 | } | ||
| 1374 | } | ||
| 1375 | |||
| 1376 | // The leg the table cannot check itself: `usage` is hand-tuned prose, so a | ||
| 1377 | // verb added as a row and forgotten in the text would ship undocumented. | ||
| 1378 | // This pins the CROSS-CHECK, not the wording — the text stays free to say | ||
| 1379 | // whatever it likes about a subcommand as long as it lists it. | ||
| 1380 | // | ||
| 1381 | // Anchored to the command position rather than to a bare name, because the | ||
| 1382 | // prose says these words in passing: `endpoint`'s parenthetical contains | ||
| 1383 | // "proxy", so deleting the whole `muxd proxy` line still leaves the word in | ||
| 1384 | // the text, and an unanchored search would call that documented. | ||
| 1385 | // `inline for` is what makes `s.name` comptime, so the anchor can be built | ||
| 1386 | // with `++`. | ||
| 1387 | test "usage names every subcommand" { | ||
| 1388 | inline for (specs) |s| { | ||
| 1389 | const named = std.mem.indexOf(u8, usage, "\n muxd " ++ s.name) != null; | ||
| 1390 | // expect() alone would print only "expected true", which does not | ||
| 1391 | // say which verb went missing. | ||
| 1392 | if (!named) std.debug.print("usage never names the subcommand `{s}`\n", .{s.name}); | ||
| 1393 | try std.testing.expect(named); | ||
| 1394 | } | ||
| 1395 | } | ||
| 1396 | |||
| 1397 | test "splitHostPort: literal addresses, bracketed and not" { | ||
| 1398 | const v4 = try splitHostPort("127.0.0.1:4433"); | ||
| 1399 | try std.testing.expectEqualStrings("127.0.0.1", v4.host); | ||
| 1400 | try std.testing.expectEqual(@as(u16, 4433), v4.port); | ||
| 1401 | |||
| 1402 | const v6 = try splitHostPort("[::1]:4433"); | ||
| 1403 | try std.testing.expectEqualStrings("::1", v6.host); | ||
| 1404 | try std.testing.expectEqual(@as(u16, 4433), v6.port); | ||
| 1405 | |||
| 1406 | const any6 = try splitHostPort("[::]:1"); | ||
| 1407 | try std.testing.expectEqualStrings("::", any6.host); | ||
| 1408 | try std.testing.expectEqual(@as(u16, 1), any6.port); | ||
| 1409 | |||
| 1410 | // No port names the default. 4433 is mux's convention; an explicit | ||
| 1411 | // port always wins. | ||
| 1412 | // | ||
| 1413 | // The number is spelled out rather than written `quic.default_port`: | ||
| 1414 | // comparing the parse's answer against the same constant the parse | ||
| 1415 | // reads holds for ANY value, so it would pin the wiring and say | ||
| 1416 | // nothing about the port — and 4433 is the half both ends of a | ||
| 1417 | // connection have to agree on. | ||
| 1418 | const dflt = try splitHostPort("127.0.0.1"); | ||
| 1419 | try std.testing.expectEqualStrings("127.0.0.1", dflt.host); | ||
| 1420 | try std.testing.expectEqual(@as(u16, 4433), dflt.port); | ||
| 1421 | |||
| 1422 | const dflt6 = try splitHostPort("[::1]"); | ||
| 1423 | try std.testing.expectEqualStrings("::1", dflt6.host); | ||
| 1424 | try std.testing.expectEqual(@as(u16, 4433), dflt6.port); | ||
| 1425 | |||
| 1426 | try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:")); | ||
| 1427 | try std.testing.expectError(error.MalformedAddress, splitHostPort("127.0.0.1:99999")); | ||
| 1428 | try std.testing.expectError(error.MalformedAddress, splitHostPort("[::1]4433")); | ||
| 1429 | |||
| 1430 | // An IPv6 literal without brackets is ambiguous about where the address | ||
| 1431 | // stops, so it is refused instead of being read either way. | ||
| 1432 | try std.testing.expectError(error.MalformedAddress, splitHostPort("::1:4433")); | ||
| 1433 | try std.testing.expectError(error.MalformedAddress, splitHostPort("fe80::1:4433")); | ||
| 1434 | } | ||
| 1435 | |||
| 1436 | test "parseBindAddr: a hostname is refused, not resolved" { | ||
| 1437 | const a = try parseBindAddr("127.0.0.1:4433"); | ||
| 1438 | try std.testing.expectEqual(@as(u16, 4433), a.getPort()); | ||
| 1439 | |||
| 1440 | const six = try parseBindAddr("[::1]:4433"); | ||
| 1441 | try std.testing.expectEqual(@as(u16, 4433), six.getPort()); | ||
| 1442 | try std.testing.expect(six.any.family == std.posix.AF.INET6); | ||
| 1443 | |||
| 1444 | // No DNS at bind time, deliberately: this is the address to bind, and a | ||
| 1445 | // name resolving to several is a question rather than an answer. | ||
| 1446 | try std.testing.expect(std.meta.isError(parseBindAddr("localhost:4433"))); | ||
| 1447 | } | ||
| 1448 | |||
| 1449 | test "keygen: a generated key loads through quic.Key.load" { | ||
| 1450 | const testtmp = @import("testtmp"); | ||
| 1451 | var tmp = try testtmp.TmpDir.make(); | ||
| 1452 | defer tmp.cleanup(); | ||
| 1453 | |||
| 1454 | var buf: [128]u8 = undefined; | ||
| 1455 | const path = try std.fmt.bufPrint(&buf, "{s}/key", .{tmp.path()}); | ||
| 1456 | try xdg.writeNewKey(path); | ||
| 1457 | _ = try quic.Key.load(path); | ||
| 1458 | } | ||
| 1459 | |||
| 1460 | test "parseArgs: --version is a command, not a flag on one" { | ||
| 1461 | const r = parse(&.{ "muxd", "--version" }); | ||
| 1462 | try std.testing.expect(r == .ok); | ||
| 1463 | try std.testing.expect(r.ok._cmd == .version); | ||
| 1464 | } | ||
| 1465 | |||
| 1466 | test "parseArgs: --help is a command, and a flag on one, and both exit 0 on stdout" { | ||
| 1467 | const bare = parse(&.{ "muxd", "--help" }); | ||
| 1468 | try std.testing.expect(bare == .ok); | ||
| 1469 | try std.testing.expect(bare.ok._cmd == .help); | ||
| 1470 | |||
| 1471 | // On a subcommand it is an outcome of the flag parse rather than a row, | ||
| 1472 | // and it must outrank the grammar: `--sock` here is still waiting for a | ||
| 1473 | // value, and asking for the usage is not a way to mistype one. | ||
| 1474 | try std.testing.expect(parse(&.{ "muxd", "run", "--help" }).err == .help); | ||
| 1475 | try std.testing.expect(parse(&.{ "muxd", "dump", "-h", "--sock", "/x" }).err == .help); | ||
| 1476 | try std.testing.expect(parse(&.{ "muxd", "run", "--sock", "--help" }).err == .help); | ||
| 1477 | |||
| 1478 | // The code, asked of `usageCode` rather than of `usageExit`: the latter | ||
| 1479 | // writes the usage to STDOUT, which under `zig build test` is the build | ||
| 1480 | // runner's own IPC channel, and the step hangs forever. 0 is the whole | ||
| 1481 | // difference between an answer and a refusal, so a refusal is asserted | ||
| 1482 | // beside it. | ||
| 1483 | try std.testing.expectEqual(@as(u8, 0), usageCode(.help)); | ||
| 1484 | try std.testing.expectEqual(@as(u8, 2), usageCode(.no_command)); | ||
| 1485 | } | ||
| 1486 | |||
| 1487 | test "parseArgs: keygen takes no flags" { | ||
| 1488 | const r = parse(&.{ "muxd", "keygen" }); | ||
| 1489 | try std.testing.expect(r == .ok); | ||
| 1490 | try std.testing.expect(r.ok._cmd == .keygen); | ||
| 1491 | try std.testing.expect(parse(&.{ "muxd", "keygen", "--sock", "/x" }).err == .unknown_arg); | ||
| 1492 | } | ||
| 1493 | |||
| 1494 | test "pickKey: --key beats MUX_KEY_FILE beats the default path" { | ||
| 1495 | try std.testing.expectEqualStrings("/flag", pickKey("/flag", "/env", "/dflt").?); | ||
| 1496 | try std.testing.expectEqualStrings("/env", pickKey(null, "/env", "/dflt").?); | ||
| 1497 | try std.testing.expectEqualStrings("/dflt", pickKey(null, null, "/dflt").?); | ||
| 1498 | // Nothing named anywhere is the triad-message case, not a silent skip. | ||
| 1499 | try std.testing.expect(pickKey(null, null, null) == null); | ||
| 1500 | } | ||
| 1501 | |||
| 1502 | test "parseArgs: start takes run's flags" { | ||
| 1503 | const r = parse(&.{ "muxd", "start", "--sock", "/tmp/x.sock", "--cols", "100" }); | ||
| 1504 | try std.testing.expect(r == .ok); | ||
| 1505 | try std.testing.expect(r.ok._cmd == .start); | ||
| 1506 | try std.testing.expectEqualStrings("/tmp/x.sock", r.ok.sock.?); | ||
| 1507 | try std.testing.expectEqual(@as(u16, 100), r.ok.cols); | ||
| 1508 | } | ||
| 1509 | |||
| 1510 | test "parseArgs: stop is a command and takes --sock" { | ||
| 1511 | const r = parse(&.{ "muxd", "stop" }); | ||
| 1512 | try std.testing.expect(r == .ok); | ||
| 1513 | try std.testing.expect(r.ok._cmd == .stop); | ||
| 1514 | try std.testing.expect(r.ok.sock == null); | ||
| 1515 | |||
| 1516 | const s = parse(&.{ "muxd", "stop", "--sock", "/tmp/x.sock" }); | ||
| 1517 | try std.testing.expect(s.ok._cmd == .stop); | ||
| 1518 | try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); | ||
| 1519 | } | ||
| 1520 | |||
| 1521 | test "parseArgs: endpoint is a command and takes --sock" { | ||
| 1522 | const r = parse(&.{ "muxd", "endpoint" }); | ||
| 1523 | try std.testing.expect(r == .ok); | ||
| 1524 | try std.testing.expect(r.ok._cmd == .endpoint); | ||
| 1525 | try std.testing.expect(r.ok.sock == null); | ||
| 1526 | |||
| 1527 | const s = parse(&.{ "muxd", "endpoint", "--sock", "/tmp/x.sock" }); | ||
| 1528 | try std.testing.expect(s.ok._cmd == .endpoint); | ||
| 1529 | try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); | ||
| 1530 | |||
| 1531 | // An unknown flag is refused, as it is for every command: a client of | ||
| 1532 | // some later version asking this binary for something it cannot do | ||
| 1533 | // gets a legible refusal rather than silence. | ||
| 1534 | // | ||
| 1535 | // Only UNKNOWN, though. The value-taking flags share one loop, so | ||
| 1536 | // `endpoint --cols 100` parses and is then ignored — `--sock` is the | ||
| 1537 | // only one this command reads. `keygen` is the sole verb that narrows | ||
| 1538 | // its own surface, and widening that rule to `proxy` and `endpoint` | ||
| 1539 | // together is its own change, not this one's. | ||
| 1540 | try std.testing.expect(parse(&.{ "muxd", "endpoint", "--quiet" }).err == .unknown_arg); | ||
| 1541 | try std.testing.expect(parse(&.{ "muxd", "endpoint", "--cols", "100" }) == .ok); | ||
| 1542 | const missing = parse(&.{ "muxd", "endpoint", "--sock" }); | ||
| 1543 | try std.testing.expect(missing.err == .missing_value); | ||
| 1544 | try std.testing.expectEqualStrings("--sock", missing.err.missing_value); | ||
| 1545 | } | ||
| 1546 | |||
| 1547 | test "parseArgs: run --resume-fd N --check is the old daemon's dry run" { | ||
| 1548 | const r = parse(&.{ "muxd", "run", "--resume-fd", "7", "--check" }); | ||
| 1549 | try std.testing.expect(r == .ok); | ||
| 1550 | try std.testing.expect(r.ok._cmd == .run); | ||
| 1551 | try std.testing.expectEqual(@as(std.posix.fd_t, 7), r.ok.resume_fd.?); | ||
| 1552 | try std.testing.expect(r.ok.check); | ||
| 1553 | |||
| 1554 | // A number, like --cols: an fd that is not one would be read as a | ||
| 1555 | // descriptor the daemon never passed. | ||
| 1556 | try std.testing.expect(parse(&.{ "muxd", "run", "--resume-fd", "x" }).err == .bad_number); | ||
| 1557 | |||
| 1558 | const f = parse(&.{ "muxd", "run", "--resume-fd", "3", "--resume-fail-at", "session" }); | ||
| 1559 | try std.testing.expectEqualStrings("session", f.ok.resume_fail_at.?); | ||
| 1560 | |||
| 1561 | // Neither flag is the ordinary start, and both must stay off there — | ||
| 1562 | // a `run` that thought it was resuming would adopt nothing and serve | ||
| 1563 | // nothing. | ||
| 1564 | const plain = parse(&.{ "muxd", "run" }); | ||
| 1565 | try std.testing.expect(plain.ok.resume_fd == null); | ||
| 1566 | try std.testing.expect(!plain.ok.check); | ||
| 1567 | } | ||
| 1568 | |||
| 1569 | test "failAtFrom: the flag beats the environment, and neither is no abort" { | ||
| 1570 | try std.testing.expectEqualStrings("session", failAtFrom("session", "daemon")); | ||
| 1571 | // The environment is how an e2e leg arms an abort at all: the exec | ||
| 1572 | // builds a fixed argv, so there is no flag for it to put a word in. | ||
| 1573 | try std.testing.expectEqualStrings("daemon", failAtFrom(null, "daemon")); | ||
| 1574 | try std.testing.expectEqualStrings("", failAtFrom(null, null)); | ||
| 1575 | } | ||
| 1576 | |||
| 1577 | test "rollbackKeepsEnv: the rollback does not inherit the abort that caused it" { | ||
| 1578 | // Inherited, the old binary would abort at the same section, find the | ||
| 1579 | // marker, give up, and take every shell with it. | ||
| 1580 | try std.testing.expect(!rollbackKeepsEnv("MUX_RESUME_FAIL_AT=session")); | ||
| 1581 | try std.testing.expect(rollbackKeepsEnv("MUX_SHELL_INTEGRATION=1")); | ||
| 1582 | // The name is a prefix of nothing else, but a variable that merely | ||
| 1583 | // starts with the same letters is not this one. | ||
| 1584 | try std.testing.expect(rollbackKeepsEnv("MUX_RESUME_FAIL_AT_NOT=1")); | ||
| 1585 | } | ||
| 1586 | |||
| 1587 | test "parseArgs: upgrade is a command, and same-version is a flag it takes" { | ||
| 1588 | const r = parse(&.{ "muxd", "upgrade" }); | ||
| 1589 | try std.testing.expect(r == .ok); | ||
| 1590 | try std.testing.expect(r.ok._cmd == .upgrade); | ||
| 1591 | // Off unless asked: the skew rule is strictly-newer, and an operator who | ||
| 1592 | // did not name the exception must not get it. | ||
| 1593 | try std.testing.expect(!r.ok.allow_same_version); | ||
| 1594 | |||
| 1595 | const s = parse(&.{ "muxd", "upgrade", "--sock", "/tmp/x.sock", "--allow-same-version" }); | ||
| 1596 | try std.testing.expect(s.ok._cmd == .upgrade); | ||
| 1597 | try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); | ||
| 1598 | try std.testing.expect(s.ok.allow_same_version); | ||
| 1599 | } | ||
| 1600 | |||
| 1601 | test "resumeRun: --check adopts nothing, so --resume-fail-at has nothing to abort" { | ||
| 1602 | const alloc = std.testing.allocator; | ||
| 1603 | |||
| 1604 | const memfd = try std.posix.memfd_create("mux-resume-check-test", 0); | ||
| 1605 | defer std.posix.close(memfd); | ||
| 1606 | |||
| 1607 | var buf: std.ArrayList(u8) = .empty; | ||
| 1608 | defer buf.deinit(alloc); | ||
| 1609 | try upgrade.writeManifest(buf.writer(alloc), alloc, .{ | ||
| 1610 | .writer_version = "0.0.1-99", | ||
| 1611 | // Deliberately a path that cannot exec: a --check that rolled back | ||
| 1612 | // would exec the OLD binary out of a probe the old daemon runs as a | ||
| 1613 | // CHILD, and a rollback target that cannot be exec'd fails this | ||
| 1614 | // test instead of replacing the test runner with it. | ||
| 1615 | .writer_path = "/nonexistent/muxd", | ||
| 1616 | .sock_path = "/tmp/mux-resume-check-test.sock", | ||
| 1617 | .listener_fd = -1, | ||
| 1618 | .shellint_dir = null, | ||
| 1619 | .agent_dir = null, | ||
| 1620 | .shell = "/bin/sh", | ||
| 1621 | .shell_integration = false, | ||
| 1622 | .extra_env = &.{}, | ||
| 1623 | .quic = .{}, | ||
| 1624 | .counters = .{}, | ||
| 1625 | }, &.{}); | ||
| 1626 | var file = std.fs.File{ .handle = memfd }; | ||
| 1627 | try file.writeAll(buf.items); | ||
| 1628 | |||
| 1629 | const code = try resumeRun(alloc, .{ | ||
| 1630 | ._cmd = .run, | ||
| 1631 | .check = true, | ||
| 1632 | .resume_fd = memfd, | ||
| 1633 | .resume_fail_at = "daemon", | ||
| 1634 | }, memfd); | ||
| 1635 | try std.testing.expectEqual(@as(u8, 0), code); | ||
| 1636 | } | ||
| 1637 | |||
| 1638 | test "announceKeyFrom: MUX_KEY_FILE wins, and the default it skipped is not created" { | ||
| 1639 | const testtmp = @import("testtmp"); | ||
| 1640 | var tmp = try testtmp.TmpDir.make(); | ||
| 1641 | defer tmp.cleanup(); | ||
| 1642 | |||
| 1643 | var ebuf: [280]u8 = undefined; | ||
| 1644 | var dbuf: [280]u8 = undefined; | ||
| 1645 | const env = try std.fmt.bufPrint(&ebuf, "{s}/env-key", .{tmp.path()}); | ||
| 1646 | const dflt = try std.fmt.bufPrint(&dbuf, "{s}/cfg/mux/key", .{tmp.path()}); | ||
| 1647 | try xdg.writeNewKey(env); | ||
| 1648 | |||
| 1649 | const r = announceKeyFrom(env, dflt); | ||
| 1650 | try std.testing.expect(r == .key); | ||
| 1651 | // The key it returned is the file it was pointed at, not merely some | ||
| 1652 | // key: the announce is only worth anything if it names the one the | ||
| 1653 | // daemon will authenticate with. | ||
| 1654 | var on_disk: [32]u8 = undefined; | ||
| 1655 | try std.testing.expectEqualSlices(u8, try std.fs.cwd().readFile(env, &on_disk), &r.key.bytes); | ||
| 1656 | |||
| 1657 | // The default is not merely unused, it is uncreated. Creating a key | ||
| 1658 | // beside one the user named would leave a credential nobody asked for | ||
| 1659 | // and, worse, one the daemon might later pick up instead. | ||
| 1660 | try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(dflt, .{})); | ||
| 1661 | } | ||
| 1662 | |||
| 1663 | test "announceKeyFrom: the default is created when absent, and no path at all is no_path" { | ||
| 1664 | const testtmp = @import("testtmp"); | ||
| 1665 | var tmp = try testtmp.TmpDir.make(); | ||
| 1666 | defer tmp.cleanup(); | ||
| 1667 | |||
| 1668 | var dbuf: [280]u8 = undefined; | ||
| 1669 | const dflt = try std.fmt.bufPrint(&dbuf, "{s}/cfg/mux/key", .{tmp.path()}); | ||
| 1670 | |||
| 1671 | // The mosh-server move: a fresh box gets a key rather than a lecture. | ||
| 1672 | const made = announceKeyFrom(null, dflt); | ||
| 1673 | try std.testing.expect(made == .key); | ||
| 1674 | const st = try std.fs.cwd().statFile(dflt); | ||
| 1675 | try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(st.mode & 0o777))); | ||
| 1676 | |||
| 1677 | // A second call loads the SAME key rather than rotating it: the | ||
| 1678 | // announce must name what the daemon will authenticate with, and this | ||
| 1679 | // process runs once per attach. | ||
| 1680 | const again = announceKeyFrom(null, dflt); | ||
| 1681 | try std.testing.expect(again == .key); | ||
| 1682 | try std.testing.expectEqualSlices(u8, &made.key.bytes, &again.key.bytes); | ||
| 1683 | |||
| 1684 | try std.testing.expect(announceKeyFrom(null, null) == .no_path); | ||
| 1685 | } | ||
| 1686 | |||
| 1687 | test "announceKeyFrom: a default that cannot be created reports the create, not the load" { | ||
| 1688 | const testtmp = @import("testtmp"); | ||
| 1689 | var tmp = try testtmp.TmpDir.make(); | ||
| 1690 | defer tmp.cleanup(); | ||
| 1691 | |||
| 1692 | var robuf: [280]u8 = undefined; | ||
| 1693 | var dbuf: [280]u8 = undefined; | ||
| 1694 | const ro = try std.fmt.bufPrint(&robuf, "{s}/ro", .{tmp.path()}); | ||
| 1695 | const dflt = try std.fmt.bufPrint(&dbuf, "{s}/mux/key", .{ro}); | ||
| 1696 | try std.fs.cwd().makePath(ro); | ||
| 1697 | { | ||
| 1698 | var d = try std.fs.cwd().openDir(ro, .{ .iterate = true }); | ||
| 1699 | defer d.close(); | ||
| 1700 | try d.chmod(0o500); | ||
| 1701 | } | ||
| 1702 | // Left at 0500 for cleanup, deliberately: 0500 still grants read and | ||
| 1703 | // execute, so deleteTree can enter and list it, and removing the empty | ||
| 1704 | // directory itself needs write on the tmp ROOT, which is untouched. | ||
| 1705 | // Emptiness is not an assumption — it is the assertion below. | ||
| 1706 | |||
| 1707 | // Swallowing the create error leaves the load to speak, and all it can | ||
| 1708 | // say is `no such key file` — which names the symptom and sends | ||
| 1709 | // someone reading it over ssh to look for a file, when the real story | ||
| 1710 | // is a directory they cannot write. The distinction is the whole | ||
| 1711 | // reason the create's error is retained. | ||
| 1712 | const r = announceKeyFrom(null, dflt); | ||
| 1713 | try std.testing.expect(r == .create_failed); | ||
| 1714 | try std.testing.expectEqual(error.AccessDenied, r.create_failed.err); | ||
| 1715 | try std.testing.expectEqualStrings(dflt, r.create_failed.path); | ||
| 1716 | } | ||
| 1717 | |||
| 1718 | test "askEndpointPort: a socket nobody serves answers 0, quickly" { | ||
| 1719 | const testtmp = @import("testtmp"); | ||
| 1720 | var tmp = try testtmp.TmpDir.make(); | ||
| 1721 | defer tmp.cleanup(); | ||
| 1722 | var buf: [280]u8 = undefined; | ||
| 1723 | const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()}); | ||
| 1724 | |||
| 1725 | // Zero is the announce-none path, and getting there fast is the point: | ||
| 1726 | // the connect refusal is immediate, so the bounded wait below it is | ||
| 1727 | // never entered. An implementation that reached for the deadline | ||
| 1728 | // first — polling for a reply that cannot come, retrying the connect | ||
| 1729 | // until the budget ran out — would answer the same 0 two seconds | ||
| 1730 | // later, and every `muxd endpoint` against a daemon that died between | ||
| 1731 | // the ensure and the ask would pay it. | ||
| 1732 | const t0 = std.time.milliTimestamp(); | ||
| 1733 | try std.testing.expectEqual(@as(u16, 0), askEndpointPort(std.testing.allocator, sock)); | ||
| 1734 | try std.testing.expect(std.time.milliTimestamp() - t0 < 500); | ||
| 1735 | } | ||
| 1736 | |||
| 1737 | test "oneShotQuery: a socket nobody serves is exit 1" { | ||
| 1738 | const testtmp = @import("testtmp"); | ||
| 1739 | var tmp = try testtmp.TmpDir.make(); | ||
| 1740 | defer tmp.cleanup(); | ||
| 1741 | var buf: [280]u8 = undefined; | ||
| 1742 | const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()}); | ||
| 1743 | |||
| 1744 | // The opposite verdict from `stopCmd` below, on the identical input, and | ||
| 1745 | // both are right: `stop` asked for a state the absence already satisfies, | ||
| 1746 | // while `dump` and `stats` asked a question nothing answered. Sharing one | ||
| 1747 | // round-trip between the two query verbs must not quietly make it three. | ||
| 1748 | // | ||
| 1749 | // The exit is all this pins. Both verbs are run because both must reach | ||
| 1750 | // that verdict, but the line naming the verb goes to stderr rather than | ||
| 1751 | // being returned, so nothing here can assert it — the way `lostMsg` and | ||
| 1752 | // `keyRefusalBody` are asserted is by being pure, and this is not. | ||
| 1753 | try std.testing.expectEqual( | ||
| 1754 | @as(u8, 1), | ||
| 1755 | try oneShotQuery(std.testing.allocator, sock, "dump", .debug_dump, "", .dump_reply), | ||
| 1756 | ); | ||
| 1757 | try std.testing.expectEqual( | ||
| 1758 | @as(u8, 1), | ||
| 1759 | try oneShotQuery(std.testing.allocator, sock, "stats", .stats_req, "", .stats_reply), | ||
| 1760 | ); | ||
| 1761 | } | ||
| 1762 | |||
| 1763 | test "stopCmd: a socket path with nothing on it is exit 0, not a failure" { | ||
| 1764 | const testtmp = @import("testtmp"); | ||
| 1765 | var tmp = try testtmp.TmpDir.make(); | ||
| 1766 | defer tmp.cleanup(); | ||
| 1767 | var buf: [280]u8 = undefined; | ||
| 1768 | const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()}); | ||
| 1769 | try std.testing.expectEqual(@as(u8, 0), try stopCmd(std.testing.allocator, sock)); | ||
| 1770 | } | ||
| 1771 | |||
| 1772 | test "peerPid: the kernel names the peer" { | ||
| 1773 | // Both ends of a socketpair are this process, so the only right answer | ||
| 1774 | // is our own pid — and it comes from the kernel, not from anything the | ||
| 1775 | // peer said about itself. | ||
| 1776 | var sp: [2]i32 = undefined; | ||
| 1777 | try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp)); | ||
| 1778 | defer std.posix.close(sp[0]); | ||
| 1779 | defer std.posix.close(sp[1]); | ||
| 1780 | try std.testing.expectEqual(std.os.linux.getpid(), peerPid(sp[0]).?); | ||
| 1781 | } | ||
| 1782 | |||
| 1783 | test "waitPidGone: returns only once the OS has no such process" { | ||
| 1784 | // A grandchild, deliberately: a child of ours would linger as a zombie | ||
| 1785 | // that signal 0 still finds, which is the trap this test would fall | ||
| 1786 | // into if it held the dimension constant. The shell prints the pid and | ||
| 1787 | // exits; the sleeper is reparented and dies on its own clock. | ||
| 1788 | var child = std.process.Child.init(&.{ "sh", "-c", "sleep 0.3 & echo $!" }, std.testing.allocator); | ||
| 1789 | child.stdout_behavior = .Pipe; | ||
| 1790 | try child.spawn(); | ||
| 1791 | var buf: [32]u8 = undefined; | ||
| 1792 | const n = try child.stdout.?.readAll(&buf); | ||
| 1793 | _ = try child.wait(); | ||
| 1794 | const pid = try std.fmt.parseInt(std.posix.pid_t, std.mem.trim(u8, buf[0..n], "\n "), 10); | ||
| 1795 | try std.posix.kill(pid, 0); // alive when we start, or the wait proves nothing | ||
| 1796 | try std.testing.expectEqual(@as(u8, 0), waitPidGone(pid, "(test)")); | ||
| 1797 | try std.testing.expectError(error.ProcessNotFound, std.posix.kill(pid, 0)); | ||
| 1798 | } | ||
| 1799 | |||
| 1800 | test "shellIntegrationEnabled: an unset environment means off" { | ||
| 1801 | // The daily-driver default. The injection is not free — under zsh the | ||
| 1802 | // ZDOTDIR shim costs the user their ~/.zshenv, and under bash the DEBUG | ||
| 1803 | // trap displaces theirs (atuin, bash-preexec) — and what it buys is a | ||
| 1804 | // knowable exit code, which only `muxa` reads. A user who never asked | ||
| 1805 | // for it must not pay for it. | ||
| 1806 | try std.testing.expect(!shellIntegrationEnabled(null)); | ||
| 1807 | } | ||
| 1808 | |||
| 1809 | test "shellIntegrationEnabled: `1` and nothing else turns it on" { | ||
| 1810 | try std.testing.expect(shellIntegrationEnabled("1")); | ||
| 1811 | // Every other spelling is off, including the one that used to mean off | ||
| 1812 | // when this variable was an opt-OUT: a stale `=0` in someone's profile | ||
| 1813 | // still reads as off, which is the safe direction for an inversion. | ||
| 1814 | try std.testing.expect(!shellIntegrationEnabled("0")); | ||
| 1815 | try std.testing.expect(!shellIntegrationEnabled("")); | ||
| 1816 | try std.testing.expect(!shellIntegrationEnabled("true")); | ||
| 1817 | try std.testing.expect(!shellIntegrationEnabled("yes")); | ||
| 1818 | } | ||
| 1819 | |||
| 1820 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 1821 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 1822 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 1823 | test { | ||
| 1824 | std.testing.refAllDeclsRecursive(@This()); | ||
| 1825 | } | ||
src/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,984 +0,0 @@ | |||
| 1 | //! mux — client binary. `mux [--sock PATH]` attaches to the local muxd; | ||
| 2 | //! `mux --via CMD` attaches over CMD's stdio instead (any command that | ||
| 3 | //! exposes a session socket as a byte pipe, e.g. `ssh host muxd proxy`); | ||
| 4 | //! `mux HOST` runs the ssh→QUIC handoff — ssh fetches the daemon's QUIC | ||
| 5 | //! coordinates and carries the session only if the QUIC dial does not. | ||
| 6 | //! | ||
| 7 | //! Every one of those is a WALL of one tile whose rect is the whole terminal | ||
| 8 | //! (`wallview.runAttach`); `mux wall` is the same program entered on the | ||
| 9 | //! wall itself. What lives up here is argv, the refusals that must happen | ||
| 10 | //! before a dial (self-attach, an unbindable socket path), the auto-start, | ||
| 11 | //! and the wall-file subcommands — everything, in other words, that is | ||
| 12 | //! about the command line rather than about a session. | ||
| 13 | const std = @import("std"); | ||
| 14 | const client = @import("client"); | ||
| 15 | const proto = @import("protocol"); | ||
| 16 | const build_options = @import("build_options"); | ||
| 17 | const xdg = @import("xdg"); | ||
| 18 | const spawn = @import("spawn"); | ||
| 19 | const handoff = @import("handoff"); | ||
| 20 | const sockpath = @import("sockpath"); | ||
| 21 | const wallview = @import("wallview"); | ||
| 22 | const wall = @import("wall"); | ||
| 23 | const TmpDir = @import("testtmp").TmpDir; | ||
| 24 | |||
| 25 | const usage = | ||
| 26 | \\usage: mux [HOST | --sock PATH | --via CMD | quic://HOST[:PORT]] | ||
| 27 | \\ HOST attaches over ssh and hands off to QUIC when the daemon offers it | ||
| 28 | \\ (muxd must be on HOST's PATH; cached coordinates make later attaches | ||
| 29 | \\ skip ssh entirely) | ||
| 30 | \\ quic://HOST[:PORT] (PORT defaults to 4433) uses --key FILE, | ||
| 31 | \\ MUX_KEY_FILE, or ~/.config/mux/key; muxd must be running with a | ||
| 32 | \\ matching --quic and key | ||
| 33 | \\ [--quic-idle-ms N] tunes how fast a dead link is noticed | ||
| 34 | \\ [--session NAME] attaches to (or creates) a named session instead of | ||
| 35 | \\ the default (`0`); NAME is printable ASCII, no space, no '#' or '/' | ||
| 36 | \\ -A forwards this client's ssh-agent into the session, like ssh -A: | ||
| 37 | \\ whoever typed last is whose agent signs, and only while attached | ||
| 38 | \\ --version prints the version | ||
| 39 | \\ | ||
| 40 | \\ mux wall [SPELLING...] shows several sessions at once, one stripe | ||
| 41 | \\ each; `Ctrl-\ 1-9` focuses a tile and types into it, `Ctrl-\ h/j/k/l` | ||
| 42 | \\ moves between panes, `Ctrl-\ |/-` split right/below, `Ctrl-\ f` | ||
| 43 | \\ fullscreen, `Ctrl-\ r` resize mode, `Ctrl-\ :` adds a tile by | ||
| 44 | \\ spelling (Enter adds, Esc cancels), `Ctrl-\ d` leaves. SPELLING is | ||
| 45 | \\ the wall grammar | ||
| 46 | \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION], | ||
| 47 | \\ one argument per tile, but `--sock PATH` may also be two arguments | ||
| 48 | \\ as in muxweb); with none, the saved wall is shown. | ||
| 49 | \\ | ||
| 50 | \\ mux wall add SPELLING... / mux wall rm SPELLING... edit the saved | ||
| 51 | \\ wall without dialling anything. The wall is attach history: `mux` | ||
| 52 | \\ itself adds the tile it attaches to, `Ctrl-\ x` on the wall forgets one, | ||
| 53 | \\ and neither ever kills a session. | ||
| 54 | \\ | ||
| 55 | ; | ||
| 56 | |||
| 57 | /// What the command line asked for. A tagged union rather than a struct of | ||
| 58 | /// optionals so the two failure modes are results in their own right, and so | ||
| 59 | /// the parse can be tested without a process to exit from. | ||
| 60 | const ParseResult = union(enum) { | ||
| 61 | /// At most one of these is set; both null means the default local socket. | ||
| 62 | /// `session` defaults to "" (empty), the wire-compatible name that puts | ||
| 63 | /// exactly the old bytes on the wire — see encodeAttachNamed. | ||
| 64 | attach: struct { sock: ?[]const u8 = null, via: ?[]const u8 = null, session: []const u8 = "", agent: bool = false }, | ||
| 65 | /// A bare hostname: the ssh recipe is built from it in main, where there | ||
| 66 | /// is an allocator to build it with. `idle_ms` rides along because the | ||
| 67 | /// handoff ends in a QUIC link like any other — muxweb's HOST tiles | ||
| 68 | /// have always carried it, and mux dropping it on the floor made | ||
| 69 | /// `--quic-idle-ms` silently do nothing on exactly the spelling most | ||
| 70 | /// people use. | ||
| 71 | host: struct { name: []const u8, idle_ms: u32, session: []const u8 = "", agent: bool = false }, | ||
| 72 | /// A direct QUIC attach. The key is resolved in main, where the | ||
| 73 | /// environment can be consulted. | ||
| 74 | quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32, session: []const u8 = "", agent: bool = false }, | ||
| 75 | /// `--version`: not a transport at all, so it short-circuits the rest of | ||
| 76 | /// the parse rather than being reconciled with it. | ||
| 77 | version, | ||
| 78 | /// More than one transport named — a request that cannot be honoured | ||
| 79 | /// rather than one to reconcile. | ||
| 80 | conflict, | ||
| 81 | usage_error, | ||
| 82 | }; | ||
| 83 | |||
| 84 | /// `SSH_AGENTC_REQUEST_IDENTITIES` in the ssh-agent framing: a 4-byte | ||
| 85 | /// big-endian length, then the message type. `ssh-add -l` sends exactly | ||
| 86 | /// this, which is why every agent implementation answers it — with an | ||
| 87 | /// identities list, or a failure if it holds no keys. Either is proof of | ||
| 88 | /// an agent; the preflight never reads past the type. | ||
| 89 | /// | ||
| 90 | /// This is the one place mux knows any ssh-agent bytes, and it belongs | ||
| 91 | /// here rather than in `protocol`, `proxy` or the QUIC modules: the | ||
| 92 | /// forwarding path stays opaque end to end (the daemon pumps blind, by | ||
| 93 | /// design), and what speaks here is the client, which is the agent's own | ||
| 94 | /// peer and the process that made the `-A` promise. | ||
| 95 | const agent_request_identities = [_]u8{ 0, 0, 0, 1, 11 }; | ||
| 96 | |||
| 97 | /// How long a probe waits for an answer before deciding it cannot tell. | ||
| 98 | /// The asymmetry below is what sets it: a refusal is a hangup on an | ||
| 99 | /// already-accepted connection and arrives in microseconds, so this bound | ||
| 100 | /// is not asked to separate refused from slow — it only has to outlast a | ||
| 101 | /// real agent's round trip, including one forwarded back out of an outer | ||
| 102 | /// session over a link with an RTT. | ||
| 103 | const agent_probe_ms = 500; | ||
| 104 | |||
| 105 | /// Whether an ssh-agent is actually there to forward. A request and a | ||
| 106 | /// reply, not a dial: inside a mux session `SSH_AUTH_SOCK` names the | ||
| 107 | /// DAEMON's per-session socket, which accepts every connection and only | ||
| 108 | /// then looks for a client to route it to. A bare connect passes there | ||
| 109 | /// even when nobody is offering — and the client it waves through is | ||
| 110 | /// precisely the silent offerer this check exists to refuse, one that can | ||
| 111 | /// out-rank a working `-A` client because offering is a declaration and | ||
| 112 | /// not a capability. The nested case where the outer session DOES have an | ||
| 113 | /// answerer is the one that must keep working, and it does: the request is | ||
| 114 | /// forwarded out and the real agent's reply comes back. | ||
| 115 | /// | ||
| 116 | /// Fails open on silence, closed on a hangup. A refusal is immediate, so | ||
| 117 | /// slowness is not the discriminator: a hardware token or a cold-started | ||
| 118 | /// gpg-agent is slow and is still an agent. | ||
| 119 | fn agentReachable(path: []const u8) bool { | ||
| 120 | const fd = client.connectAgent(path) orelse return false; | ||
| 121 | defer std.posix.close(fd); | ||
| 122 | |||
| 123 | // MSG_NOSIGNAL rather than a `write`: the peer may already be gone, and | ||
| 124 | // the preflight runs before the client installs any signal handling, so | ||
| 125 | // an EPIPE has to arrive as an error and not as a fatal signal. | ||
| 126 | _ = std.posix.send(fd, &agent_request_identities, std.posix.MSG.NOSIGNAL) catch return false; | ||
| 127 | |||
| 128 | var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }}; | ||
| 129 | const ready = std.posix.poll(&pfd, agent_probe_ms) catch return true; | ||
| 130 | if (ready == 0) return true; | ||
| 131 | var reply: [1]u8 = undefined; | ||
| 132 | // Zero bytes is EOF: accepted, then hung up without answering. That is | ||
| 133 | // the daemon with no offerer behind it, and the only shape refused here. | ||
| 134 | const n = std.posix.read(fd, &reply) catch return false; | ||
| 135 | return n != 0; | ||
| 136 | } | ||
| 137 | |||
| 138 | /// The environment variable consulted when `--key` is absent. Named rather | ||
| 139 | /// than inlined because the parse cannot read it — the parse stays pure so | ||
| 140 | /// it stays testable — and `main` has to use exactly the same name. | ||
| 141 | pub const key_env = "MUX_KEY_FILE"; | ||
| 142 | |||
| 143 | /// Built from `proto.session_env` so the message and the planter cannot | ||
| 144 | /// disagree about the spelling. | ||
| 145 | const self_attach_refusal = | ||
| 146 | "mux: this shell is inside that session (unset " ++ proto.session_env ++ " to override)\n"; | ||
| 147 | |||
| 148 | /// An inner client repaints its own grid forever, and no chord steers | ||
| 149 | /// back out. Refused before it starts. | ||
| 150 | fn insideThisSession( | ||
| 151 | env_sock: ?[]const u8, | ||
| 152 | env_session: ?[]const u8, | ||
| 153 | sock: ?[]const u8, | ||
| 154 | session: []const u8, | ||
| 155 | ) bool { | ||
| 156 | const es = env_sock orelse return false; | ||
| 157 | const en = env_session orelse return false; | ||
| 158 | // A unix socket path only: a host or quic:// target is a different | ||
| 159 | // daemon whatever its sessions are called. | ||
| 160 | const target = sock orelse return false; | ||
| 161 | // Emptied counts as unset: `MUX_SESSION=` is how a shell overrides an | ||
| 162 | // exported variable it cannot unset, and the refusal names unsetting as | ||
| 163 | // the way out — both spellings of that have to work. | ||
| 164 | if (es.len == 0 or en.len == 0) return false; | ||
| 165 | // Both halves, so session 0 attaching to session 1 of the same daemon | ||
| 166 | // keeps working. String equality on the path: a symlinked or relatively | ||
| 167 | // spelled `--sock` for the same socket evades this, accepted, because | ||
| 168 | // this guards the mistake people make (typing `mux` in a mux shell) and | ||
| 169 | // is not a security boundary. | ||
| 170 | return std.mem.eql(u8, es, target) and | ||
| 171 | std.mem.eql(u8, en, proto.resolveName(session)); | ||
| 172 | } | ||
| 173 | |||
| 174 | fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult { | ||
| 175 | var sock: ?[]const u8 = null; | ||
| 176 | var via: ?[]const u8 = null; | ||
| 177 | var host: ?[]const u8 = null; | ||
| 178 | var quic: ?[]const u8 = null; | ||
| 179 | var key: ?[]const u8 = null; | ||
| 180 | var idle_ms: u32 = client.quic_idle_ms_default; | ||
| 181 | // Rides every transport below, unlike --key: a session name is not | ||
| 182 | // authenticating anything, so there is no "no quic:// means ignore it" | ||
| 183 | // escape hatch — it applies whichever spelling wins. | ||
| 184 | var session: []const u8 = ""; | ||
| 185 | // Rides every transport too, and for the same reason: an offer to | ||
| 186 | // answer for this client's agent is about the client, not the wire it | ||
| 187 | // reached the daemon over. | ||
| 188 | var agent = false; | ||
| 189 | |||
| 190 | var i: usize = 1; | ||
| 191 | while (i < args.len) : (i += 1) { | ||
| 192 | const a = args[i]; | ||
| 193 | // First branch, and it returns rather than recording: asking a binary | ||
| 194 | // its version must answer whatever else is on the line, including a | ||
| 195 | // transport that would otherwise conflict or fail to parse. | ||
| 196 | if (std.mem.eql(u8, a, "--version")) { | ||
| 197 | return .version; | ||
| 198 | } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { | ||
| 199 | i += 1; | ||
| 200 | if (sock != null) return .conflict; | ||
| 201 | sock = args[i]; | ||
| 202 | } else if (std.mem.eql(u8, a, "--via") and i + 1 < args.len) { | ||
| 203 | i += 1; | ||
| 204 | if (via != null) return .conflict; | ||
| 205 | via = args[i]; | ||
| 206 | } else if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) { | ||
| 207 | i += 1; | ||
| 208 | key = args[i]; | ||
| 209 | } else if (std.mem.eql(u8, a, "--session") and i + 1 < args.len) { | ||
| 210 | i += 1; | ||
| 211 | // A name that cannot be spelled must not become wire bytes: catch | ||
| 212 | // it here, at usage-error altitude, rather than downstream where | ||
| 213 | // it would look like a rejected attach. | ||
| 214 | if (!proto.validSessionName(args[i])) return .usage_error; | ||
| 215 | session = args[i]; | ||
| 216 | } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) { | ||
| 217 | i += 1; | ||
| 218 | const n = std.fmt.parseInt(u32, args[i], 10) catch return .usage_error; | ||
| 219 | // Zero means "no idle timeout" to ngtcp2, the inverse of what | ||
| 220 | // anyone typing a timeout of zero is asking for. | ||
| 221 | if (n == 0) return .usage_error; | ||
| 222 | idle_ms = n; | ||
| 223 | } else if (std.mem.eql(u8, a, "-A")) { | ||
| 224 | // Named explicitly: the bare-word arm below only takes words | ||
| 225 | // that do not start with '-', so an unnamed flag is a usage | ||
| 226 | // error rather than a hostname. | ||
| 227 | agent = true; | ||
| 228 | } else if (std.mem.startsWith(u8, a, "quic://")) { | ||
| 229 | // Counted with the others, so `mux quic://a:1 --sock /x` is the | ||
| 230 | // same conflict as naming any other two transports. | ||
| 231 | if (quic != null) return .conflict; | ||
| 232 | quic = a["quic://".len..]; | ||
| 233 | if (quic.?.len == 0) return .usage_error; | ||
| 234 | } else if (a.len > 0 and a[0] != '-') { | ||
| 235 | // A bare word is a host to hop to. Two of them is as ambiguous | ||
| 236 | // as naming two transports, so it lands in the same place. | ||
| 237 | if (host != null) return .conflict; | ||
| 238 | host = a; | ||
| 239 | } else { | ||
| 240 | // Includes `--sock`/`--via` with no value left to take: a flag | ||
| 241 | // whose argument is missing is a usage mistake, not a transport. | ||
| 242 | return .usage_error; | ||
| 243 | } | ||
| 244 | } | ||
| 245 | |||
| 246 | // Every pairing of the four is two transports for one session. | ||
| 247 | const named: u8 = @as(u8, @intFromBool(sock != null)) + | ||
| 248 | @intFromBool(via != null) + @intFromBool(host != null) + | ||
| 249 | @intFromBool(quic != null); | ||
| 250 | if (named > 1) return .conflict; | ||
| 251 | |||
| 252 | if (quic) |hp| { | ||
| 253 | // Neither spelling being set is not a refusal: main has a default | ||
| 254 | // path to try, and parse is not allowed to look at the filesystem. | ||
| 255 | return .{ .quic = .{ | ||
| 256 | .host_port = hp, | ||
| 257 | .key = xdg.pickKey(key, env_key), | ||
| 258 | .idle_ms = idle_ms, | ||
| 259 | .session = session, | ||
| 260 | .agent = agent, | ||
| 261 | } }; | ||
| 262 | } | ||
| 263 | // A key with no quic:// has nothing to authenticate and is ignored | ||
| 264 | // rather than refused: unlike muxd, where --key without --quic means a | ||
| 265 | // listener was meant, here it is one env var away from being set for | ||
| 266 | // every invocation in a shell, and refusing `mux --sock ...` because | ||
| 267 | // MUX_KEY_FILE happens to be exported would be absurd. | ||
| 268 | if (host) |h| return .{ .host = .{ .name = h, .idle_ms = idle_ms, .session = session, .agent = agent } }; | ||
| 269 | return .{ .attach = .{ .sock = sock, .via = via, .session = session, .agent = agent } }; | ||
| 270 | } | ||
| 271 | |||
| 272 | pub fn main() !u8 { | ||
| 273 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 274 | defer if (gpa.deinit() == .leak) | ||
| 275 | std.debug.print("mux: LEAK: allocations outlived deinit\n", .{}); | ||
| 276 | const alloc = gpa.allocator(); | ||
| 277 | |||
| 278 | const args = try std.process.argsAlloc(alloc); | ||
| 279 | defer std.process.argsFree(alloc, args); | ||
| 280 | |||
| 281 | // A subcommand, checked before the flag parse: `wall` is a different | ||
| 282 | // program (N passive tiles), not a transport spelling for one attach. | ||
| 283 | if (args.len > 1 and std.mem.eql(u8, args[1], "wall")) | ||
| 284 | return wallMain(alloc, args[2..]); | ||
| 285 | |||
| 286 | const parsed = parseArgs(args, std.posix.getenv(key_env)); | ||
| 287 | |||
| 288 | // `-A` is a promise, and a client with no agent behind it cannot keep | ||
| 289 | // one. Left to attach, it offers anyway — the offer is a declaration, | ||
| 290 | // not a capability — so every dial the session makes is refused in | ||
| 291 | // silence, and on a session with a second `-A` client it can out-rank | ||
| 292 | // one that WOULD have answered. Refusing here says so once, at the | ||
| 293 | // altitude the flag was typed at, instead of surfacing three layers | ||
| 294 | // down as `permission denied (publickey)` from a git remote. | ||
| 295 | const wants_agent = switch (parsed) { | ||
| 296 | .host => |h| h.agent, | ||
| 297 | .quic => |q| q.agent, | ||
| 298 | .attach => |at| at.agent, | ||
| 299 | else => false, | ||
| 300 | }; | ||
| 301 | if (wants_agent) { | ||
| 302 | const sock = std.posix.getenv(proto.agent_sock_env) orelse ""; | ||
| 303 | if (!agentReachable(sock)) { | ||
| 304 | if (sock.len == 0) { | ||
| 305 | std.debug.print( | ||
| 306 | "mux: -A: " ++ proto.agent_sock_env ++ " is not set — no ssh-agent to forward\n", | ||
| 307 | .{}, | ||
| 308 | ); | ||
| 309 | } else { | ||
| 310 | std.debug.print( | ||
| 311 | "mux: -A: no ssh-agent answering at {s}\n", | ||
| 312 | .{sock}, | ||
| 313 | ); | ||
| 314 | } | ||
| 315 | return 2; | ||
| 316 | } | ||
| 317 | } | ||
| 318 | |||
| 319 | switch (parsed) { | ||
| 320 | .version => { | ||
| 321 | var vbuf: [64]u8 = undefined; | ||
| 322 | const s = std.fmt.bufPrint(&vbuf, "mux {s}\n", .{build_options.version}) catch unreachable; | ||
| 323 | _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {}; | ||
| 324 | return 0; | ||
| 325 | }, | ||
| 326 | .usage_error => { | ||
| 327 | std.debug.print("{s}", .{usage}); | ||
| 328 | return 2; | ||
| 329 | }, | ||
| 330 | .conflict => { | ||
| 331 | std.debug.print( | ||
| 332 | "mux: name one transport: HOST, --sock, --via or quic://\n{s}", | ||
| 333 | .{usage}, | ||
| 334 | ); | ||
| 335 | return 2; | ||
| 336 | }, | ||
| 337 | .quic => |q| { | ||
| 338 | const res = try xdg.resolveKeyPath(alloc, q.key); | ||
| 339 | defer switch (res) { | ||
| 340 | .given => {}, | ||
| 341 | .default, .missing => |p| alloc.free(p), | ||
| 342 | }; | ||
| 343 | const key_path = switch (res) { | ||
| 344 | .given, .default => |p| p, | ||
| 345 | .missing => |p| { | ||
| 346 | std.debug.print( | ||
| 347 | "mux: no key: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n", | ||
| 348 | .{p}, | ||
| 349 | ); | ||
| 350 | return 2; | ||
| 351 | }, | ||
| 352 | }; | ||
| 353 | return wallview.runAttach(alloc, .{ .quic = .{ | ||
| 354 | .host_port = q.host_port, | ||
| 355 | .key_path = key_path, | ||
| 356 | .idle_ms = q.idle_ms, | ||
| 357 | } }, q.session, q.key, q.idle_ms, q.agent); | ||
| 358 | }, | ||
| 359 | .host => |h| { | ||
| 360 | // The handoff recipe: ssh fetches the coordinates (and, on a | ||
| 361 | // cold attach, carries the session if QUIC cannot), while a | ||
| 362 | // warm attach dials from the cache and never spawns ssh at all. | ||
| 363 | // handoff.recipeFor owns both pieces; muxweb builds its HOST | ||
| 364 | // tiles from the same call. | ||
| 365 | const r = try handoff.recipeFor(alloc, h.name); | ||
| 366 | defer r.deinit(alloc); | ||
| 367 | return wallview.runAttach(alloc, .{ .hand = .{ | ||
| 368 | .host = h.name, | ||
| 369 | .ssh_cmd = r.ssh_cmd, | ||
| 370 | .cache_path = r.cache_path, | ||
| 371 | .idle_ms = h.idle_ms, | ||
| 372 | } }, h.session, null, h.idle_ms, h.agent); | ||
| 373 | }, | ||
| 374 | .attach => |t| { | ||
| 375 | if (t.via) |cmd| return wallview.runAttach( | ||
| 376 | alloc, | ||
| 377 | .{ .via = cmd }, | ||
| 378 | t.session, | ||
| 379 | null, | ||
| 380 | client.quic_idle_ms_default, | ||
| 381 | t.agent, | ||
| 382 | ); | ||
| 383 | const sock_path = if (t.sock) |s| | ||
| 384 | try alloc.dupe(u8, s) | ||
| 385 | else | ||
| 386 | sockpath.defaultSockPath(alloc) catch |err| switch (err) { | ||
| 387 | error.NoRuntimeDir => { | ||
| 388 | std.debug.print( | ||
| 389 | "mux: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n", | ||
| 390 | .{}, | ||
| 391 | ); | ||
| 392 | return 1; | ||
| 393 | }, | ||
| 394 | else => |e| return e, | ||
| 395 | }; | ||
| 396 | defer alloc.free(sock_path); | ||
| 397 | |||
| 398 | // Before the PATH search, before auto-start, before the dial: | ||
| 399 | // the refusal is about where this process is standing, and none | ||
| 400 | // of those three change the answer. Placed here rather than in | ||
| 401 | // parseArgs because the default socket path is resolved here, | ||
| 402 | // and a bare `mux` typed in a session shell is exactly the | ||
| 403 | // mistake this catches. It sits on the USER's attach only — | ||
| 404 | // the Ctrl-\ chords grow their tiles from inside the wall and | ||
| 405 | // never come back through this switch, so focusing from session | ||
| 406 | // 0 to session 1 keeps working. `Ctrl-\ :` is the one chord | ||
| 407 | // that takes a spelling, and it runs `wallview.showsSelf` | ||
| 408 | // itself. | ||
| 409 | if (insideThisSession( | ||
| 410 | std.posix.getenv(proto.sock_env), | ||
| 411 | std.posix.getenv(proto.session_env), | ||
| 412 | sock_path, | ||
| 413 | t.session, | ||
| 414 | )) { | ||
| 415 | std.debug.print("{s}", .{self_attach_refusal}); | ||
| 416 | return 2; | ||
| 417 | } | ||
| 418 | |||
| 419 | // The same `sun_path` guard muxd applies (main.zig), for the | ||
| 420 | // same reason and off the same constant. It sits before the | ||
| 421 | // PATH search rather than at the connect because auto-start would | ||
| 422 | // otherwise reach it first: mux finds muxd, spawns a child that | ||
| 423 | // refuses the path instantly, and polls the full 2s into "daemon | ||
| 424 | // did not answer" — a timeout story about a path that was doomed | ||
| 425 | // at parse. Refusing here costs nothing and says the real thing. | ||
| 426 | if (sock_path.len > sockpath.max_sun_path) { | ||
| 427 | std.debug.print( | ||
| 428 | "mux: socket path too long ({d} bytes, max {d}): {s}\n", | ||
| 429 | .{ sock_path.len, sockpath.max_sun_path, sock_path }, | ||
| 430 | ); | ||
| 431 | return 1; | ||
| 432 | } | ||
| 433 | |||
| 434 | // Attach auto-start: give the attach a daemon to land on. | ||
| 435 | // Unix-socket transport only — quic:// has nothing local to | ||
| 436 | // spawn, and --via's auto-starter is the remote proxy. | ||
| 437 | const muxd_path = try spawn.findInPath( | ||
| 438 | alloc, | ||
| 439 | std.posix.getenv("PATH") orelse "", | ||
| 440 | "muxd", | ||
| 441 | ); | ||
| 442 | defer if (muxd_path) |p| alloc.free(p); | ||
| 443 | if (muxd_path) |exe| { | ||
| 444 | if (!try spawn.ensureForAttach(alloc, exe, sock_path, "mux")) return 1; | ||
| 445 | } else if (!spawn.probe(sock_path)) { | ||
| 446 | // No muxd anywhere AND nothing serving: only now is the | ||
| 447 | // missing binary the user's problem, and both facts fit in | ||
| 448 | // one honest line. A live daemon needs no binary on PATH. | ||
| 449 | std.debug.print("mux: no daemon on {s} and no muxd in PATH to start one\n", .{sock_path}); | ||
| 450 | return 1; | ||
| 451 | } | ||
| 452 | return wallview.runAttach( | ||
| 453 | alloc, | ||
| 454 | .{ .sock = sock_path }, | ||
| 455 | t.session, | ||
| 456 | null, | ||
| 457 | client.quic_idle_ms_default, | ||
| 458 | t.agent, | ||
| 459 | ); | ||
| 460 | }, | ||
| 461 | } | ||
| 462 | } | ||
| 463 | |||
| 464 | /// `mux wall`: gather spellings (argv, or with none the saved wall — the | ||
| 465 | /// attach history mux itself writes, muxweb's too via argv and POST | ||
| 466 | /// /tiles), resolve each through the one grammar, hand the lot to | ||
| 467 | /// wallview.run. Resolution allocates into an arena because run() never | ||
| 468 | /// returns on the success path (it exits the process — see wallview.run); | ||
| 469 | /// only the early usage-error paths come back through the defers here. | ||
| 470 | fn wallMain(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 { | ||
| 471 | var arena_state = std.heap.ArenaAllocator.init(alloc); | ||
| 472 | defer arena_state.deinit(); | ||
| 473 | const arena = arena_state.allocator(); | ||
| 474 | |||
| 475 | // `add`/`rm` before the tile parse, exactly as `wall` itself sits | ||
| 476 | // before the flag parse: they are file edits, not a wall to show. The | ||
| 477 | // cost is that a tile spelled literally `add` can no longer be the | ||
| 478 | // first argument of `mux wall` — a hostname the wall grammar would | ||
| 479 | // otherwise accept — which is the ordinary price of a subcommand. | ||
| 480 | if (args.len > 0 and | ||
| 481 | (std.mem.eql(u8, args[0], "add") or std.mem.eql(u8, args[0], "rm"))) | ||
| 482 | return wallEdit(arena, args[0], args[1..]); | ||
| 483 | |||
| 484 | var key: ?[]const u8 = null; | ||
| 485 | var idle_ms: u32 = client.quic_idle_ms_default; | ||
| 486 | var spellings: std.ArrayList([]const u8) = .empty; | ||
| 487 | |||
| 488 | var i: usize = 0; | ||
| 489 | while (i < args.len) : (i += 1) { | ||
| 490 | const a = args[i]; | ||
| 491 | if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) { | ||
| 492 | i += 1; | ||
| 493 | key = args[i]; | ||
| 494 | } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) { | ||
| 495 | i += 1; | ||
| 496 | const n = std.fmt.parseInt(u32, args[i], 10) catch 0; | ||
| 497 | if (n == 0) { | ||
| 498 | std.debug.print("{s}", .{usage}); | ||
| 499 | return 2; | ||
| 500 | } | ||
| 501 | idle_ms = n; | ||
| 502 | } else { | ||
| 503 | // Every other argument is a tile. `--sock` is not one of this | ||
| 504 | // command's own flags, so wall may claim it and its path as | ||
| 505 | // one spelling — muxweb's dialect, accepted here too. | ||
| 506 | const n = wall.spellingFromArgv(arena, args, i) catch |err| switch (err) { | ||
| 507 | error.MissingSockPath => { | ||
| 508 | std.debug.print("mux: wall target '--sock' names no path\n", .{}); | ||
| 509 | return 2; | ||
| 510 | }, | ||
| 511 | error.FlagLikeTarget => { | ||
| 512 | std.debug.print( | ||
| 513 | "mux: wall takes targets, not flags: '{s}'\n{s}", | ||
| 514 | .{ args[i], usage }, | ||
| 515 | ); | ||
| 516 | return 2; | ||
| 517 | }, | ||
| 518 | else => |e| return e, | ||
| 519 | }; | ||
| 520 | i += n.consumed - 1; | ||
| 521 | try spellings.append(arena, n.spelling); | ||
| 522 | } | ||
| 523 | } | ||
| 524 | |||
| 525 | var from_file = false; | ||
| 526 | if (spellings.items.len == 0) { | ||
| 527 | const path = try wall.statePath(arena); | ||
| 528 | const w = try wall.load(arena, path); | ||
| 529 | for (w.targets.items) |t| try spellings.append(arena, t); | ||
| 530 | if (spellings.items.len == 0) { | ||
| 531 | std.debug.print("mux: wall is empty: name targets, or add tiles in muxweb\n", .{}); | ||
| 532 | return 2; | ||
| 533 | } | ||
| 534 | from_file = true; | ||
| 535 | } | ||
| 536 | |||
| 537 | const resolved = try arena.alloc(wallview.Resolved, spellings.items.len); | ||
| 538 | const env_sock = std.posix.getenv(proto.sock_env); | ||
| 539 | const env_session = std.posix.getenv(proto.session_env); | ||
| 540 | for (resolved, spellings.items) |*r, s| { | ||
| 541 | r.* = wallview.resolveSpelling(arena, s, xdg.pickKey(key, std.posix.getenv(key_env)), idle_ms) catch |err| { | ||
| 542 | std.debug.print("mux: bad wall target '{s}': {s}\n", .{ s, @errorName(err) }); | ||
| 543 | return 2; | ||
| 544 | }; | ||
| 545 | // A tile is a read-only attach, but it is still an attach: a stripe | ||
| 546 | // of the session the wall is running in paints into the grid it is | ||
| 547 | // reading, which is the same feedback loop with a nicer name. The | ||
| 548 | // WHOLE wall goes, not just the tile — dropping one silently would | ||
| 549 | // be a wall that lies about which sessions it is showing. | ||
| 550 | const tile_sock = switch (r.target) { | ||
| 551 | .sock => |p| p, | ||
| 552 | else => null, | ||
| 553 | }; | ||
| 554 | if (insideThisSession(env_sock, env_session, tile_sock, r.session)) { | ||
| 555 | std.debug.print("mux: wall target '{s}' is the session this shell is inside\n", .{s}); | ||
| 556 | std.debug.print("{s}", .{self_attach_refusal}); | ||
| 557 | return 2; | ||
| 558 | } | ||
| 559 | } | ||
| 560 | // The other door into the same program: `mux wall` opens on the wall, | ||
| 561 | // `mux TARGET` opens focused on the tile it just attached to. It does | ||
| 562 | // not hydrate — it has already read the file, or was handed the | ||
| 563 | // spellings it must show and no others — and it does need a terminal, | ||
| 564 | // because there is nothing to cut stripes from without one. | ||
| 565 | return wallview.run(arena, resolved, .{ .hydrated = from_file }); | ||
| 566 | } | ||
| 567 | |||
| 568 | /// Why one spelling cannot be a tile, in the words the hub already uses | ||
| 569 | /// (webhub_main.addSpelling): one grammar, one vocabulary for refusing it. | ||
| 570 | fn spellingReason(err: anyerror) []const u8 { | ||
| 571 | return switch (err) { | ||
| 572 | error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", | ||
| 573 | error.EmptySpec => "empty target", | ||
| 574 | error.BadByte => "control byte in target", | ||
| 575 | error.SockPathTooLong => "socket path too long to bind", | ||
| 576 | else => @errorName(err), | ||
| 577 | }; | ||
| 578 | } | ||
| 579 | |||
| 580 | /// `mux wall add|rm SPELLING...`: file operations only, neither verb dials. | ||
| 581 | fn wallEdit( | ||
| 582 | arena: std.mem.Allocator, | ||
| 583 | verb: []const u8, | ||
| 584 | args: []const [:0]const u8, | ||
| 585 | ) !u8 { | ||
| 586 | const adding = std.mem.eql(u8, verb, "add"); | ||
| 587 | |||
| 588 | var spellings: std.ArrayList([]const u8) = .empty; | ||
| 589 | var i: usize = 0; | ||
| 590 | while (i < args.len) : (i += 1) { | ||
| 591 | const n = wall.spellingFromArgv(arena, args, i) catch |err| switch (err) { | ||
| 592 | error.MissingSockPath => { | ||
| 593 | std.debug.print("mux: wall {s}: '--sock' names no path\n", .{verb}); | ||
| 594 | return 2; | ||
| 595 | }, | ||
| 596 | error.FlagLikeTarget => { | ||
| 597 | std.debug.print("mux: wall {s}: '{s}' is a flag, not a target\n", .{ verb, args[i] }); | ||
| 598 | return 2; | ||
| 599 | }, | ||
| 600 | else => |e| return e, | ||
| 601 | }; | ||
| 602 | i += n.consumed - 1; | ||
| 603 | try spellings.append(arena, n.spelling); | ||
| 604 | } | ||
| 605 | if (spellings.items.len == 0) { | ||
| 606 | std.debug.print("mux: wall {s}: name at least one target\n", .{verb}); | ||
| 607 | return 2; | ||
| 608 | } | ||
| 609 | |||
| 610 | // Every spelling is validated BEFORE any of them is written, and the | ||
| 611 | // file is written ONCE below: an IO error on the third of four must not | ||
| 612 | // leave the first two applied and the rest not. | ||
| 613 | if (adding) for (spellings.items) |s| { | ||
| 614 | // The grammar's own refusals, plus the one refusal that belongs to | ||
| 615 | // the transport rather than the grammar: a sun_path that cannot be | ||
| 616 | // bound is a tile that could never dial, and ADD time is the only | ||
| 617 | // moment the user is still looking at what they typed. | ||
| 618 | const p = wall.parseSpelling(s) catch |err| { | ||
| 619 | std.debug.print("mux: wall add: {s}: {s}\n", .{ s, spellingReason(err) }); | ||
| 620 | return 2; | ||
| 621 | }; | ||
| 622 | if (p.spec == .sock and p.spec.sock.len > sockpath.max_sun_path) { | ||
| 623 | std.debug.print("mux: wall add: {s}: {s}\n", .{ s, spellingReason(error.SockPathTooLong) }); | ||
| 624 | return 2; | ||
| 625 | } | ||
| 626 | }; | ||
| 627 | |||
| 628 | const path = try wall.statePath(arena); | ||
| 629 | var rc: u8 = 0; | ||
| 630 | |||
| 631 | if (adding) { | ||
| 632 | // Strict: growing a wall whose existing content is not understood | ||
| 633 | // would re-save garbage as if it had been read. | ||
| 634 | var w = wall.load(arena, path) catch |err| { | ||
| 635 | std.debug.print("mux: wall add: {s}: {s}\n", .{ path, @errorName(err) }); | ||
| 636 | return 1; | ||
| 637 | }; | ||
| 638 | for (spellings.items) |s| { | ||
| 639 | // Already there is not a failure: `add` states what the wall | ||
| 640 | // should contain, and afterwards it does. | ||
| 641 | var present = false; | ||
| 642 | for (w.targets.items) |t| { | ||
| 643 | if (std.mem.eql(u8, t, s)) present = true; | ||
| 644 | } | ||
| 645 | if (!present) _ = try w.add(arena, s); | ||
| 646 | } | ||
| 647 | wall.save(&w, path) catch |err| { | ||
| 648 | std.debug.print("mux: wall add: {s}: {s}\n", .{ path, @errorName(err) }); | ||
| 649 | return 1; | ||
| 650 | }; | ||
| 651 | return 0; | ||
| 652 | } | ||
| 653 | |||
| 654 | // Lenient, so a hand-edited line the grammar cannot parse can still be | ||
| 655 | // removed and the others survive it verbatim. | ||
| 656 | var lines = wall.loadLines(arena, path) catch |err| { | ||
| 657 | std.debug.print("mux: wall rm: {s}: {s}\n", .{ path, @errorName(err) }); | ||
| 658 | return 1; | ||
| 659 | }; | ||
| 660 | for (spellings.items) |s| { | ||
| 661 | var found = false; | ||
| 662 | for (lines.items, 0..) |t, at| { | ||
| 663 | if (!std.mem.eql(u8, t, s)) continue; | ||
| 664 | _ = lines.orderedRemove(at); | ||
| 665 | found = true; | ||
| 666 | break; | ||
| 667 | } | ||
| 668 | // Removing what is not there is reported and non-zero — a script | ||
| 669 | // that thinks it cleaned up a tile should learn it was spelled | ||
| 670 | // differently. The rest of the line still applies: the names that | ||
| 671 | // WERE on the wall come off it. | ||
| 672 | if (!found) { | ||
| 673 | std.debug.print("mux: wall rm: not on the wall: {s}\n", .{s}); | ||
| 674 | rc = 1; | ||
| 675 | } | ||
| 676 | } | ||
| 677 | wall.saveLines(lines.items, path) catch |err| { | ||
| 678 | std.debug.print("mux: wall rm: {s}: {s}\n", .{ path, @errorName(err) }); | ||
| 679 | return 1; | ||
| 680 | }; | ||
| 681 | return rc; | ||
| 682 | } | ||
| 683 | |||
| 684 | /// parseArgs takes what argsAlloc produces; the tests must match the type. | ||
| 685 | fn parse(comptime argv: []const [:0]const u8) ParseResult { | ||
| 686 | return parseArgs(argv, null); | ||
| 687 | } | ||
| 688 | |||
| 689 | /// The same, with `MUX_KEY_FILE` set to `env`. | ||
| 690 | fn parseEnv(comptime argv: []const [:0]const u8, env: ?[]const u8) ParseResult { | ||
| 691 | return parseArgs(argv, env); | ||
| 692 | } | ||
| 693 | |||
| 694 | test "parseArgs: no arguments means the default local socket" { | ||
| 695 | const r = parse(&.{"mux"}); | ||
| 696 | try std.testing.expect(r == .attach); | ||
| 697 | try std.testing.expect(r.attach.sock == null); | ||
| 698 | try std.testing.expect(r.attach.via == null); | ||
| 699 | } | ||
| 700 | |||
| 701 | test "parseArgs: --sock and --via each name their transport" { | ||
| 702 | const s = parse(&.{ "mux", "--sock", "/tmp/x.sock" }); | ||
| 703 | try std.testing.expect(s == .attach); | ||
| 704 | try std.testing.expectEqualStrings("/tmp/x.sock", s.attach.sock.?); | ||
| 705 | try std.testing.expect(s.attach.via == null); | ||
| 706 | |||
| 707 | const v = parse(&.{ "mux", "--via", "ssh box muxd proxy" }); | ||
| 708 | try std.testing.expect(v == .attach); | ||
| 709 | try std.testing.expectEqualStrings("ssh box muxd proxy", v.attach.via.?); | ||
| 710 | try std.testing.expect(v.attach.sock == null); | ||
| 711 | } | ||
| 712 | |||
| 713 | test "parseArgs: a bare word is a host to hop to" { | ||
| 714 | const h = parse(&.{ "mux", "vm1" }); | ||
| 715 | try std.testing.expect(h == .host); | ||
| 716 | try std.testing.expectEqualStrings("vm1", h.host.name); | ||
| 717 | // Spelled out rather than written `client.quic_idle_ms_default` — see | ||
| 718 | // the quic:// test for why asserting against the parser's own constant | ||
| 719 | // could never catch the number changing. | ||
| 720 | try std.testing.expectEqual(@as(u32, 15_000), h.host.idle_ms); | ||
| 721 | |||
| 722 | // The user@host form is just as bare a word; nothing parses inside it, | ||
| 723 | // which is what lets ssh's own config (aliases, ports, ProxyJump) keep | ||
| 724 | // working untouched. | ||
| 725 | const u = parse(&.{ "mux", "ubuntu@sandbox-9b70e9" }); | ||
| 726 | try std.testing.expect(u == .host); | ||
| 727 | try std.testing.expectEqualStrings("ubuntu@sandbox-9b70e9", u.host.name); | ||
| 728 | } | ||
| 729 | |||
| 730 | test "parseArgs: naming two transports is a conflict, however it is spelled" { | ||
| 731 | try std.testing.expect(parse(&.{ "mux", "vm1", "--sock", "/tmp/x.sock" }) == .conflict); | ||
| 732 | try std.testing.expect(parse(&.{ "mux", "--sock", "/tmp/x.sock", "vm1" }) == .conflict); | ||
| 733 | try std.testing.expect(parse(&.{ "mux", "vm1", "--via", "ssh box muxd proxy" }) == .conflict); | ||
| 734 | try std.testing.expect(parse(&.{ "mux", "--sock", "/a", "--via", "c" }) == .conflict); | ||
| 735 | // Two of the same kind is the same ambiguity as two different kinds. | ||
| 736 | try std.testing.expect(parse(&.{ "mux", "vm1", "vm2" }) == .conflict); | ||
| 737 | try std.testing.expect(parse(&.{ "mux", "--sock", "/a", "--sock", "/b" }) == .conflict); | ||
| 738 | } | ||
| 739 | |||
| 740 | test "parseArgs: unknown flags and valueless flags are usage errors" { | ||
| 741 | try std.testing.expect(parse(&.{ "mux", "--wat" }) == .usage_error); | ||
| 742 | try std.testing.expect(parse(&.{ "mux", "-x" }) == .usage_error); | ||
| 743 | // A flag whose value is missing must not be mistaken for a bare host. | ||
| 744 | // Every value-taking flag has to have a row here: the fall-through that | ||
| 745 | // catches a missing value is one `else` arm shared by all of them, so a | ||
| 746 | // flag added without a row here is a flag nobody actually checked. | ||
| 747 | inline for (.{ "--sock", "--via", "--key", "--quic-idle-ms", "--session" }) |flag| { | ||
| 748 | try std.testing.expect(parse(&.{ "mux", flag }) == .usage_error); | ||
| 749 | } | ||
| 750 | } | ||
| 751 | |||
| 752 | test "-A rides every transport spelling" { | ||
| 753 | try std.testing.expect(parse(&.{ "mux", "-A", "somehost" }).host.agent); | ||
| 754 | try std.testing.expect(parse(&.{ "mux", "-A", "--sock", "/tmp/x.sock" }).attach.agent); | ||
| 755 | try std.testing.expect(parse(&.{ "mux", "quic://h:1", "-A" }).quic.agent); | ||
| 756 | try std.testing.expect(!parse(&.{ "mux", "somehost" }).host.agent); | ||
| 757 | } | ||
| 758 | |||
| 759 | test "parseArgs: quic:// is a transport like any other" { | ||
| 760 | const q = parse(&.{ "mux", "quic://box:4433", "--key", "/k" }); | ||
| 761 | try std.testing.expect(q == .quic); | ||
| 762 | try std.testing.expectEqualStrings("box:4433", q.quic.host_port); | ||
| 763 | try std.testing.expectEqualStrings("/k", q.quic.key.?); | ||
| 764 | // Spelled out rather than written `client.quic_idle_ms_default`: | ||
| 765 | // asserting against the same constant the parser reads would hold for | ||
| 766 | // any value, so it could never catch the number changing. | ||
| 767 | try std.testing.expectEqual(@as(u32, 15_000), q.quic.idle_ms); | ||
| 768 | |||
| 769 | // Counted with the rest: naming it alongside another transport is the | ||
| 770 | // same ambiguity as any other pairing, whichever order they arrive in. | ||
| 771 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--sock", "/x" }) == .conflict); | ||
| 772 | try std.testing.expect(parse(&.{ "mux", "--sock", "/x", "quic://a:1", "--key", "/k" }) == .conflict); | ||
| 773 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--via", "ssh h" }) == .conflict); | ||
| 774 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "vm1" }) == .conflict); | ||
| 775 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "quic://b:2", "--key", "/k" }) == .conflict); | ||
| 776 | |||
| 777 | // The scheme with nothing after it names no host. | ||
| 778 | try std.testing.expect(parse(&.{ "mux", "quic://", "--key", "/k" }) == .usage_error); | ||
| 779 | } | ||
| 780 | |||
| 781 | test "parseArgs: a quic attach without a key defers to main, which resolves it" { | ||
| 782 | // No --key and no environment: not a refusal any more. main has a | ||
| 783 | // default path to try and parse cannot see the filesystem. | ||
| 784 | const q = parse(&.{ "mux", "quic://a:1" }); | ||
| 785 | try std.testing.expect(q == .quic); | ||
| 786 | try std.testing.expect(q.quic.key == null); | ||
| 787 | |||
| 788 | // Empty env var means unset, same as an empty --key would be nonsense. | ||
| 789 | const empty_env = parseEnv(&.{ "mux", "quic://a:1" }, ""); | ||
| 790 | try std.testing.expect(empty_env == .quic); | ||
| 791 | try std.testing.expect(empty_env.quic.key == null); | ||
| 792 | |||
| 793 | // The environment supplies it when the flag does not... | ||
| 794 | const e = parseEnv(&.{ "mux", "quic://a:1" }, "/env.key"); | ||
| 795 | try std.testing.expect(e == .quic); | ||
| 796 | try std.testing.expectEqualStrings("/env.key", e.quic.key.?); | ||
| 797 | |||
| 798 | // ...and the flag wins when both are there, because it is the more | ||
| 799 | // specific statement of intent. | ||
| 800 | const both = parseEnv(&.{ "mux", "quic://a:1", "--key", "/flag.key" }, "/env.key"); | ||
| 801 | try std.testing.expectEqualStrings("/flag.key", both.quic.key.?); | ||
| 802 | |||
| 803 | // A key with no quic:// is ignored rather than refused: MUX_KEY_FILE | ||
| 804 | // exported in a shell must not break an ordinary local attach. | ||
| 805 | try std.testing.expect(parseEnv(&.{"mux"}, "/env.key") == .attach); | ||
| 806 | try std.testing.expect(parse(&.{ "mux", "--key", "/k" }) == .attach); | ||
| 807 | try std.testing.expect(parse(&.{ "mux", "--key", "/k", "vm1" }) == .host); | ||
| 808 | } | ||
| 809 | |||
| 810 | test "parseArgs: --quic-idle-ms parses, and refuses what ngtcp2 would invert" { | ||
| 811 | const t = parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "1500" }); | ||
| 812 | try std.testing.expectEqual(@as(u32, 1500), t.quic.idle_ms); | ||
| 813 | |||
| 814 | // A bare HOST ends in a QUIC link too, so the flag has to reach it — | ||
| 815 | // the .host result carried no idle_ms at all and the flag was accepted | ||
| 816 | // and then dropped, which is worse than refusing it. muxweb's HOST | ||
| 817 | // tiles were already right; this is mux catching up. | ||
| 818 | const h = parse(&.{ "mux", "vm1", "--quic-idle-ms", "1500" }); | ||
| 819 | try std.testing.expect(h == .host); | ||
| 820 | try std.testing.expectEqual(@as(u32, 1500), h.host.idle_ms); | ||
| 821 | try std.testing.expect(parse(&.{ "mux", "vm1", "--quic-idle-ms", "0" }) == .usage_error); | ||
| 822 | |||
| 823 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "0" }) == .usage_error); | ||
| 824 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "soon" }) == .usage_error); | ||
| 825 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "99999999999" }) == .usage_error); | ||
| 826 | // The bare-flag/missing-value case is covered once, for every | ||
| 827 | // value-taking flag, by the valueless-flags sweep above. | ||
| 828 | } | ||
| 829 | |||
| 830 | test "parseArgs: --version wins wherever it appears" { | ||
| 831 | try std.testing.expect(parse(&.{ "mux", "--version" }) == .version); | ||
| 832 | try std.testing.expect(parse(&.{ "mux", "--sock", "/x", "--version" }) == .version); | ||
| 833 | } | ||
| 834 | |||
| 835 | test "parseArgs: --session rides every transport spelling" { | ||
| 836 | const s = parse(&.{ "mux", "--session", "b", "--sock", "/tmp/x.sock" }); | ||
| 837 | try std.testing.expectEqualStrings("b", s.attach.session); | ||
| 838 | const h = parse(&.{ "mux", "somehost", "--session", "b" }); | ||
| 839 | try std.testing.expectEqualStrings("b", h.host.session); | ||
| 840 | const q = parse(&.{ "mux", "quic://h:1", "--session", "b" }); | ||
| 841 | try std.testing.expectEqualStrings("b", q.quic.session); | ||
| 842 | } | ||
| 843 | |||
| 844 | test "parseArgs: a bad --session is a usage error, not a wire experiment" { | ||
| 845 | const r = parse(&.{ "mux", "--session", "has space" }); | ||
| 846 | try std.testing.expect(r == .usage_error); | ||
| 847 | } | ||
| 848 | |||
| 849 | test "parseArgs: no --session means the empty wire name (older-daemon compat)" { | ||
| 850 | const s = parse(&.{"mux"}); | ||
| 851 | try std.testing.expectEqualStrings("", s.attach.session); | ||
| 852 | } | ||
| 853 | |||
| 854 | test "insideThisSession: only the exact socket-and-session pair is the loop" { | ||
| 855 | const sock = "/run/user/1000/muxd.sock"; | ||
| 856 | |||
| 857 | // The incident, both spellings of the default session: `mux` typed in a | ||
| 858 | // shell of session 0, and `mux --session 0` typed in the same shell. | ||
| 859 | try std.testing.expect(insideThisSession(sock, "0", sock, "")); | ||
| 860 | try std.testing.expect(insideThisSession(sock, "0", sock, "0")); | ||
| 861 | try std.testing.expect(insideThisSession(sock, "work", sock, "work")); | ||
| 862 | |||
| 863 | // A different session of the SAME daemon is the useful case and must | ||
| 864 | // stay allowed — only the self-pair feeds its own paint back. | ||
| 865 | try std.testing.expect(!insideThisSession(sock, "0", sock, "1")); | ||
| 866 | try std.testing.expect(!insideThisSession(sock, "work", sock, "")); | ||
| 867 | |||
| 868 | // Same session name, different daemon: names are per-daemon, so this is | ||
| 869 | // two unrelated sessions that happen to agree on a word. | ||
| 870 | try std.testing.expect(!insideThisSession(sock, "0", "/run/user/1000/other.sock", "0")); | ||
| 871 | |||
| 872 | // Nothing planted, or emptied to override: no refusal either way. | ||
| 873 | try std.testing.expect(!insideThisSession(null, "0", sock, "0")); | ||
| 874 | try std.testing.expect(!insideThisSession(sock, null, sock, "0")); | ||
| 875 | try std.testing.expect(!insideThisSession("", "0", sock, "0")); | ||
| 876 | try std.testing.expect(!insideThisSession(sock, "", sock, "0")); | ||
| 877 | |||
| 878 | // A host or quic:// target has no unix socket to compare, and a remote | ||
| 879 | // daemon is a different daemon whatever its sessions are called — the | ||
| 880 | // call sites pass null for those, so null must never refuse. | ||
| 881 | try std.testing.expect(!insideThisSession(sock, "0", null, "0")); | ||
| 882 | } | ||
| 883 | |||
| 884 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 885 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 886 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 887 | test { | ||
| 888 | std.testing.refAllDeclsRecursive(@This()); | ||
| 889 | } | ||
| 890 | |||
| 891 | /// A socket that answers the way a real ssh-agent does, or one that hangs | ||
| 892 | /// up the way the daemon does for a session nobody has offered an agent to. | ||
| 893 | /// A thread because the probe is a round trip — it writes before it reads, | ||
| 894 | /// so a listener nobody is accepting on cannot play either part. | ||
| 895 | const AgentStub = struct { | ||
| 896 | listener: *std.net.Server, | ||
| 897 | answer: bool, | ||
| 898 | /// Set only when the exact bytes `ssh-add -l` sends arrived. Asserted | ||
| 899 | /// by the test, because every other expectation here is also satisfied | ||
| 900 | /// by a probe that asks nothing and times out. | ||
| 901 | asked: bool = false, | ||
| 902 | |||
| 903 | fn run(self: *AgentStub) void { | ||
| 904 | const conn = self.listener.accept() catch return; | ||
| 905 | defer std.posix.close(conn.stream.handle); | ||
| 906 | if (!self.answer) return; | ||
| 907 | var buf: [64]u8 = undefined; | ||
| 908 | const n = std.posix.read(conn.stream.handle, &buf) catch return; | ||
| 909 | // Spelled out rather than compared against `agent_request_identities`: | ||
| 910 | // the constant IS what is under test, and a test that reads it back | ||
| 911 | // would accept any bytes the client decided to send. | ||
| 912 | self.asked = std.mem.eql(u8, buf[0..n], &[_]u8{ 0, 0, 0, 1, 11 }); | ||
| 913 | if (!self.asked) return; | ||
| 914 | // SSH_AGENT_IDENTITIES_ANSWER carrying zero keys. An agent holding | ||
| 915 | // nothing still proves an agent is there, which is the whole | ||
| 916 | // question — the preflight never looks at the key list. | ||
| 917 | const reply = [_]u8{ 0, 0, 0, 5, 12, 0, 0, 0, 0 }; | ||
| 918 | _ = std.posix.write(conn.stream.handle, &reply) catch {}; | ||
| 919 | } | ||
| 920 | }; | ||
| 921 | |||
| 922 | test "agentReachable: an agent answers; a socket that hangs up is not one" { | ||
| 923 | // The states a user is actually in: an agent running, the daemon's own | ||
| 924 | // per-session socket with nobody offering behind it, a variable | ||
| 925 | // pointing at an agent that has died, and no variable at all. Only the | ||
| 926 | // first may attach with `-A`. | ||
| 927 | var tmp = try TmpDir.make(); | ||
| 928 | defer tmp.cleanup(); | ||
| 929 | var buf: [128]u8 = undefined; | ||
| 930 | const sock = try std.fmt.bufPrintZ(&buf, "{s}/agent.sock", .{tmp.path()}); | ||
| 931 | |||
| 932 | { | ||
| 933 | const addr = try std.net.Address.initUnix(sock); | ||
| 934 | var listener = try addr.listen(.{}); | ||
| 935 | defer listener.deinit(); | ||
| 936 | var stub = AgentStub{ .listener = &listener, .answer = true }; | ||
| 937 | const th = try std.Thread.spawn(.{}, AgentStub.run, .{&stub}); | ||
| 938 | const reachable = agentReachable(sock); | ||
| 939 | th.join(); | ||
| 940 | try std.testing.expect(reachable); | ||
| 941 | try std.testing.expect(stub.asked); | ||
| 942 | } | ||
| 943 | std.fs.deleteFileAbsolute(sock) catch {}; | ||
| 944 | |||
| 945 | // The case a bare connect cannot see, and the reason this is a request | ||
| 946 | // and not a dial: inside a session `SSH_AUTH_SOCK` names the DAEMON, | ||
| 947 | // which accepts every connection and only then decides it has no | ||
| 948 | // client to route it to. The connect succeeds; the exchange does not. | ||
| 949 | { | ||
| 950 | const addr = try std.net.Address.initUnix(sock); | ||
| 951 | var listener = try addr.listen(.{}); | ||
| 952 | defer listener.deinit(); | ||
| 953 | var stub = AgentStub{ .listener = &listener, .answer = false }; | ||
| 954 | const th = try std.Thread.spawn(.{}, AgentStub.run, .{&stub}); | ||
| 955 | defer th.join(); | ||
| 956 | try std.testing.expect(!agentReachable(sock)); | ||
| 957 | } | ||
| 958 | std.fs.deleteFileAbsolute(sock) catch {}; | ||
| 959 | |||
| 960 | // Fail OPEN on silence, closed only on a hangup. A listener nobody is | ||
| 961 | // accepting on is the shape a slow or wedged agent presents, and a | ||
| 962 | // slow agent is still an agent; the daemon's refusal is immediate, so | ||
| 963 | // taking too long is not what separates the two. | ||
| 964 | { | ||
| 965 | const addr = try std.net.Address.initUnix(sock); | ||
| 966 | var listener = try addr.listen(.{}); | ||
| 967 | defer listener.deinit(); | ||
| 968 | try std.testing.expect(agentReachable(sock)); | ||
| 969 | } | ||
| 970 | |||
| 971 | // The stale case, and the reason this dials at all rather than reading | ||
| 972 | // the variable: the agent is gone but its socket FILE is still there, | ||
| 973 | // so the path stats fine and the connect is refused. `deinit` closed | ||
| 974 | // the listener above without unlinking, which is exactly what a killed | ||
| 975 | // agent leaves behind. | ||
| 976 | try std.fs.accessAbsolute(sock, .{}); | ||
| 977 | try std.testing.expect(!agentReachable(sock)); | ||
| 978 | |||
| 979 | // And the two cheaper absences, so every one of a user's states is | ||
| 980 | // covered by the one probe. | ||
| 981 | std.fs.deleteFileAbsolute(sock) catch {}; | ||
| 982 | try std.testing.expect(!agentReachable(sock)); | ||
| 983 | try std.testing.expect(!agentReachable("")); | ||
| 984 | } | ||
src/muxa.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,1864 +0,0 @@ | |||
| 1 | //! muxa: the agent-facing mux client. Every verb prints one JSON object on | ||
| 2 | //! stdout and exits 0 on success; failures print {"error": "..."} and exit | ||
| 3 | //! nonzero. Attaches at 0x0 always — an agent must never claim the grid | ||
| 4 | //! out from under the human's size (load-bearing spec rule). | ||
| 5 | //! | ||
| 6 | //! The exit codes, and there are only these five: | ||
| 7 | //! | ||
| 8 | //! 0 the object on stdout is the answer | ||
| 9 | //! 1 the object on stdout is an `{"error":...,"detail":...}` | ||
| 10 | //! 2 the arguments did not parse — usage on stderr, nothing on stdout | ||
| 11 | //! 3 the wait timed out; the object says `"reason":"timeout"` | ||
| 12 | //! 4 the object could not be written at all (see `emit`) | ||
| 13 | //! | ||
| 14 | //! A command's own exit code is never muxa's: it is the `exit_code` field, | ||
| 15 | //! and a `run` whose command failed still exits 0 because the question was | ||
| 16 | //! answered. | ||
| 17 | const std = @import("std"); | ||
| 18 | const proto = @import("protocol"); | ||
| 19 | const sockpath = @import("sockpath"); | ||
| 20 | const quic_client = @import("quic_client"); | ||
| 21 | const quic = @import("quic"); | ||
| 22 | const xdg = @import("xdg"); | ||
| 23 | |||
| 24 | const usage = | ||
| 25 | \\usage: muxa <verb> [--sock PATH | --quic HOST[:PORT] [--key PATH]] | ||
| 26 | \\ [--settle MS] [--timeout MS] [--vt] [--session NAME] [args] | ||
| 27 | \\NAME must already exist: muxa attaches at 0x0 and never creates a session | ||
| 28 | \\(`capture` is the exception that stays quiet: it answers in the grid). | ||
| 29 | \\verbs: | ||
| 30 | \\ status session snapshot as JSON | ||
| 31 | \\ capture current grid as text (--vt for styled) | ||
| 32 | \\ send BYTES raw bytes to the pty (C-style escapes: \n \r \t \e \xNN) | ||
| 33 | \\ run CMDLINE send CMDLINE + newline, await return, report exit/output | ||
| 34 | \\ await wait for the current/next command to return | ||
| 35 | \\ | ||
| 36 | ; | ||
| 37 | |||
| 38 | const Opts = struct { | ||
| 39 | verb: enum { status, capture, send, run, await }, | ||
| 40 | sock: ?[]const u8 = null, | ||
| 41 | /// `HOST[:PORT]` of a remote daemon's QUIC listener. The verbs are | ||
| 42 | /// identical over it — same frames, same JSON — which is the whole | ||
| 43 | /// claim: an agent driving a session over a WAN types one more flag. | ||
| 44 | quic: ?[]const u8 = null, | ||
| 45 | /// `--key PATH`, the highest-priority spelling of the QUIC key. Null | ||
| 46 | /// does NOT mean "no key": `$MUX_KEY_FILE` and the XDG default are | ||
| 47 | /// still to be tried, and neither is parse's to look at (xdg.pickKey | ||
| 48 | /// and xdg.resolveKeyPath own that order here as they do for mux). | ||
| 49 | key: ?[]const u8 = null, | ||
| 50 | settle_ms: u32 = 0, | ||
| 51 | // Never 0 by default: the daemon reads a 0 timeout on await_req as "no | ||
| 52 | // bound at all" (documented on AwaitReq), so a muxa that defaulted to 0 | ||
| 53 | // would turn every await into an unbounded wait. | ||
| 54 | timeout_ms: u32 = 30_000, | ||
| 55 | vt: bool = false, | ||
| 56 | /// Which session every verb this invocation makes asks about — the | ||
| 57 | /// attach it opens with AND every ask that follows carry the same | ||
| 58 | /// name, which is what keeps the daemon's attached-tail equality rule | ||
| 59 | /// (server.zig) from ever seeing a mismatch out of this binary. Empty | ||
| 60 | /// is the wire's own default spelling, so a bare `muxa status` builds | ||
| 61 | /// byte-identical frames to before this flag existed. | ||
| 62 | session: []const u8 = "", | ||
| 63 | arg: ?[]const u8 = null, | ||
| 64 | }; | ||
| 65 | |||
| 66 | fn parseArgs(args: []const [:0]const u8) ?Opts { | ||
| 67 | if (args.len < 2) return null; | ||
| 68 | const verb = std.meta.stringToEnum(@FieldType(Opts, "verb"), args[1]) orelse return null; | ||
| 69 | var o: Opts = .{ .verb = verb }; | ||
| 70 | var i: usize = 2; | ||
| 71 | // Everything after a bare `--` is the positional argument, whatever it | ||
| 72 | // looks like. Agents send byte-strings for their own reasons, and | ||
| 73 | // `muxa send -- '-n foo\n'` must reach the pty rather than be read as | ||
| 74 | // a flag this binary does not have. | ||
| 75 | var end_of_flags = false; | ||
| 76 | while (i < args.len) : (i += 1) { | ||
| 77 | const a = args[i]; | ||
| 78 | if (end_of_flags) { | ||
| 79 | if (o.arg != null) return null; | ||
| 80 | o.arg = a; | ||
| 81 | } else if (std.mem.eql(u8, a, "--")) { | ||
| 82 | end_of_flags = true; | ||
| 83 | } else if (std.mem.eql(u8, a, "--sock")) { | ||
| 84 | i += 1; | ||
| 85 | if (i >= args.len) return null; | ||
| 86 | o.sock = args[i]; | ||
| 87 | } else if (std.mem.eql(u8, a, "--quic")) { | ||
| 88 | i += 1; | ||
| 89 | if (i >= args.len) return null; | ||
| 90 | o.quic = args[i]; | ||
| 91 | } else if (std.mem.eql(u8, a, "--key")) { | ||
| 92 | i += 1; | ||
| 93 | if (i >= args.len) return null; | ||
| 94 | o.key = args[i]; | ||
| 95 | } else if (std.mem.eql(u8, a, "--settle")) { | ||
| 96 | i += 1; | ||
| 97 | if (i >= args.len) return null; | ||
| 98 | o.settle_ms = std.fmt.parseInt(u32, args[i], 10) catch return null; | ||
| 99 | } else if (std.mem.eql(u8, a, "--timeout")) { | ||
| 100 | i += 1; | ||
| 101 | if (i >= args.len) return null; | ||
| 102 | o.timeout_ms = std.fmt.parseInt(u32, args[i], 10) catch return null; | ||
| 103 | } else if (std.mem.eql(u8, a, "--vt")) { | ||
| 104 | o.vt = true; | ||
| 105 | } else if (std.mem.eql(u8, a, "--session")) { | ||
| 106 | i += 1; | ||
| 107 | if (i >= args.len) return null; | ||
| 108 | // Refused here rather than carried to the wire as a payload | ||
| 109 | // nothing could ever look up: usage exit (2), not a frame. | ||
| 110 | if (!proto.validSessionName(args[i])) return null; | ||
| 111 | o.session = args[i]; | ||
| 112 | } else if (o.arg == null and a.len > 0 and a[0] != '-') { | ||
| 113 | o.arg = a; | ||
| 114 | } else return null; | ||
| 115 | } | ||
| 116 | // Name ONE transport. A `--sock` silently ignored beside a `--quic` | ||
| 117 | // would send an agent's frames somewhere other than the socket it | ||
| 118 | // named, and the two answers differ — this is the mistake `mux` | ||
| 119 | // refuses as `.conflict` for the same reason. | ||
| 120 | if (o.quic != null and o.sock != null) return null; | ||
| 121 | // A key with nothing to authenticate to, refused exactly where muxd | ||
| 122 | // refuses it: there is no reading of `--key` without `--quic` that | ||
| 123 | // makes it sensible, and the unix socket has no key at all. | ||
| 124 | if (o.key != null and o.quic == null) return null; | ||
| 125 | return o; | ||
| 126 | } | ||
| 127 | |||
| 128 | /// JSON string escape, the six mandatory escapes + control bytes as \u00XX. | ||
| 129 | fn jsonEscape(writer: anytype, s: []const u8) !void { | ||
| 130 | try writer.writeByte('"'); | ||
| 131 | for (s) |b| switch (b) { | ||
| 132 | '"' => try writer.writeAll("\\\""), | ||
| 133 | '\\' => try writer.writeAll("\\\\"), | ||
| 134 | '\n' => try writer.writeAll("\\n"), | ||
| 135 | '\r' => try writer.writeAll("\\r"), | ||
| 136 | '\t' => try writer.writeAll("\\t"), | ||
| 137 | 0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f => try writer.print("\\u{x:0>4}", .{b}), | ||
| 138 | else => try writer.writeByte(b), | ||
| 139 | }; | ||
| 140 | try writer.writeByte('"'); | ||
| 141 | } | ||
| 142 | |||
| 143 | test "jsonEscape pins the escapes" { | ||
| 144 | var buf: [128]u8 = undefined; | ||
| 145 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 146 | try jsonEscape(fbs.writer(), "a\"b\\c\nd\x1be"); | ||
| 147 | try std.testing.expectEqualStrings("\"a\\\"b\\\\c\\nd\\u001be\"", fbs.getWritten()); | ||
| 148 | } | ||
| 149 | |||
| 150 | /// Decode C-style escapes for `send`. Caller frees. | ||
| 151 | fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 { | ||
| 152 | var out: std.ArrayList(u8) = .empty; | ||
| 153 | errdefer out.deinit(alloc); | ||
| 154 | var i: usize = 0; | ||
| 155 | while (i < s.len) : (i += 1) { | ||
| 156 | if (s[i] != '\\') { | ||
| 157 | try out.append(alloc, s[i]); | ||
| 158 | continue; | ||
| 159 | } | ||
| 160 | // A backslash with nothing after it is an unfinished escape, and it | ||
| 161 | // is refused like any other one we cannot read (\q). Passing it | ||
| 162 | // through as a literal would be the single case where a typo in an | ||
| 163 | // escape reaches the pty instead of being reported. | ||
| 164 | if (i + 1 >= s.len) return error.BadEscape; | ||
| 165 | i += 1; | ||
| 166 | switch (s[i]) { | ||
| 167 | 'n' => try out.append(alloc, '\n'), | ||
| 168 | 'r' => try out.append(alloc, '\r'), | ||
| 169 | 't' => try out.append(alloc, '\t'), | ||
| 170 | 'e' => try out.append(alloc, 0x1b), | ||
| 171 | '\\' => try out.append(alloc, '\\'), | ||
| 172 | 'x' => { | ||
| 173 | if (i + 2 >= s.len) return error.BadEscape; | ||
| 174 | try out.append(alloc, try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16)); | ||
| 175 | i += 2; | ||
| 176 | }, | ||
| 177 | else => return error.BadEscape, | ||
| 178 | } | ||
| 179 | } | ||
| 180 | return out.toOwnedSlice(alloc); | ||
| 181 | } | ||
| 182 | |||
| 183 | test "decodeEscapes covers the sequences send needs" { | ||
| 184 | const alloc = std.testing.allocator; | ||
| 185 | const got = try decodeEscapes(alloc, "q\\n\\e[A\\x03"); | ||
| 186 | defer alloc.free(got); | ||
| 187 | try std.testing.expectEqualSlices(u8, "q\n\x1b[A\x03", got); | ||
| 188 | try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "\\q")); | ||
| 189 | // A dangling backslash is an escape the caller did not finish writing, | ||
| 190 | // and it is refused rather than passed through as a literal. | ||
| 191 | try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "ok\\")); | ||
| 192 | } | ||
| 193 | |||
| 194 | test "parseArgs verbs and flags" { | ||
| 195 | const a1 = [_][:0]const u8{ "muxa", "status" }; | ||
| 196 | try std.testing.expectEqual(@FieldType(Opts, "verb").status, parseArgs(&a1).?.verb); | ||
| 197 | const a2 = [_][:0]const u8{ "muxa", "run", "--timeout", "5000", "make test" }; | ||
| 198 | const o2 = parseArgs(&a2).?; | ||
| 199 | try std.testing.expectEqual(@as(u32, 5000), o2.timeout_ms); | ||
| 200 | try std.testing.expectEqualStrings("make test", o2.arg.?); | ||
| 201 | const a3 = [_][:0]const u8{ "muxa", "bogus" }; | ||
| 202 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&a3)); | ||
| 203 | } | ||
| 204 | |||
| 205 | test "parseArgs: -- hands the rest to the verb, flags and all" { | ||
| 206 | // Without the end-of-flags marker this is an unknown flag and the whole | ||
| 207 | // invocation is refused — the exact shape an agent sends when a key | ||
| 208 | // sequence starts with a dash. | ||
| 209 | const dashed = [_][:0]const u8{ "muxa", "send", "-n foo" }; | ||
| 210 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dashed)); | ||
| 211 | |||
| 212 | const a = [_][:0]const u8{ "muxa", "send", "--settle", "50", "--", "-n foo" }; | ||
| 213 | const o = parseArgs(&a).?; | ||
| 214 | try std.testing.expectEqual(@as(u32, 50), o.settle_ms); | ||
| 215 | try std.testing.expectEqualStrings("-n foo", o.arg.?); | ||
| 216 | |||
| 217 | // Past the marker, a flag spelling is just text — and a second | ||
| 218 | // positional is still one too many. | ||
| 219 | const flagish = [_][:0]const u8{ "muxa", "run", "--", "--timeout" }; | ||
| 220 | try std.testing.expectEqualStrings("--timeout", parseArgs(&flagish).?.arg.?); | ||
| 221 | const two = [_][:0]const u8{ "muxa", "run", "--", "a", "b" }; | ||
| 222 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&two)); | ||
| 223 | } | ||
| 224 | |||
| 225 | test "muxa: --session rides every verb; a bad name is usage, not wire bytes" { | ||
| 226 | const a = [_][:0]const u8{ "muxa", "status", "--session", "b" }; | ||
| 227 | const o = parseArgs(&a).?; | ||
| 228 | try std.testing.expectEqualStrings("b", o.session); | ||
| 229 | |||
| 230 | // No --session named: the wire's own default spelling, empty. | ||
| 231 | const bare = [_][:0]const u8{ "muxa", "status" }; | ||
| 232 | try std.testing.expectEqualStrings("", parseArgs(&bare).?.session); | ||
| 233 | |||
| 234 | // A name no tool could ever address is refused at parse (the usage | ||
| 235 | // exit, 2) rather than reaching a daemon as a payload nothing can | ||
| 236 | // look up. | ||
| 237 | const bad = [_][:0]const u8{ "muxa", "status", "--session", "has space" }; | ||
| 238 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&bad)); | ||
| 239 | |||
| 240 | // Dangling like every other value-taking flag. | ||
| 241 | const dangling = [_][:0]const u8{ "muxa", "status", "--session" }; | ||
| 242 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling)); | ||
| 243 | } | ||
| 244 | |||
| 245 | test "parseArgs: --quic and --key, and the pairs that make no sense" { | ||
| 246 | const q = [_][:0]const u8{ "muxa", "status", "--quic", "10.0.0.2:4433" }; | ||
| 247 | const oq = parseArgs(&q).?; | ||
| 248 | try std.testing.expectEqualStrings("10.0.0.2:4433", oq.quic.?); | ||
| 249 | // Not naming a key is not an error here: MUX_KEY_FILE and the XDG | ||
| 250 | // default are still to be tried, and parse may look at neither. | ||
| 251 | try std.testing.expectEqual(@as(?[]const u8, null), oq.key); | ||
| 252 | |||
| 253 | const k = [_][:0]const u8{ "muxa", "run", "--quic", "box:4433", "--key", "/k", "make test" }; | ||
| 254 | const ok = parseArgs(&k).?; | ||
| 255 | try std.testing.expectEqualStrings("box:4433", ok.quic.?); | ||
| 256 | try std.testing.expectEqualStrings("/k", ok.key.?); | ||
| 257 | try std.testing.expectEqualStrings("make test", ok.arg.?); | ||
| 258 | |||
| 259 | // A flag at the end of argv with no value is refused, like every other | ||
| 260 | // value-taking flag this parser has. | ||
| 261 | const dangling_q = [_][:0]const u8{ "muxa", "status", "--quic" }; | ||
| 262 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling_q)); | ||
| 263 | const dangling_k = [_][:0]const u8{ "muxa", "status", "--quic", "b:1", "--key" }; | ||
| 264 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling_k)); | ||
| 265 | |||
| 266 | // Two transports named at once: which one an agent's frames went to | ||
| 267 | // would be this parser's private business, and it is not entitled to | ||
| 268 | // one — the same refusal `mux` spells as `.conflict`. | ||
| 269 | const both = [_][:0]const u8{ "muxa", "status", "--sock", "/tmp/s", "--quic", "b:1" }; | ||
| 270 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&both)); | ||
| 271 | |||
| 272 | // A key with nothing to authenticate to, refused exactly where muxd | ||
| 273 | // refuses it. | ||
| 274 | const lonely_key = [_][:0]const u8{ "muxa", "status", "--key", "/k" }; | ||
| 275 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&lonely_key)); | ||
| 276 | |||
| 277 | // Neither named is the ordinary local case and stays silent. | ||
| 278 | const neither = [_][:0]const u8{ "muxa", "status" }; | ||
| 279 | try std.testing.expectEqual(@as(?[]const u8, null), parseArgs(&neither).?.quic); | ||
| 280 | } | ||
| 281 | |||
| 282 | /// A live QUIC connection plus everything a REDIAL of it needs. The dial | ||
| 283 | /// coordinates are kept rather than re-derived because the reconnect below | ||
| 284 | /// happens mid-verb, long after argv and the key file have been read: a | ||
| 285 | /// second resolution could pick a different key (the file having been | ||
| 286 | /// rotated under us) and would then fail the handshake for a reason that | ||
| 287 | /// has nothing to do with why the first connection died. | ||
| 288 | const Quic = struct { | ||
| 289 | cl: *quic_client.Client, | ||
| 290 | addr: std.net.Address, | ||
| 291 | key: quic_client.Key, | ||
| 292 | idle_ms: u32, | ||
| 293 | /// Wall-clock milliseconds the FIRST handshake took, which is this | ||
| 294 | /// client's only measurement of how far away the daemon is. `graceMs` | ||
| 295 | /// turns it into the await grace window; see there. | ||
| 296 | connect_ms: i64, | ||
| 297 | /// The one reconnect, spent or not. It lives HERE rather than on Conn | ||
| 298 | /// because only this arm can reconnect: a socket Conn carrying a | ||
| 299 | /// `reconnected` flag would be a field with no reachable true, and the | ||
| 300 | /// guard reading it would be re-establishing in code what the type can | ||
| 301 | /// state outright. (`session_exit` stays on Conn for the mirror | ||
| 302 | /// reason: both arms genuinely set it.) | ||
| 303 | reconnected: bool = false, | ||
| 304 | }; | ||
| 305 | |||
| 306 | const Conn = struct { | ||
| 307 | /// Verbs are transport-blind; `--quic` chooses here. | ||
| 308 | link: union(enum) { | ||
| 309 | fd: std.posix.fd_t, | ||
| 310 | quic: Quic, | ||
| 311 | }, | ||
| 312 | /// The allocator the transport itself works with: the QUIC arm's frame | ||
| 313 | /// staging and its redials. Distinct from the `alloc` awaitFrame takes, | ||
| 314 | /// which owns the frame handed BACK to the caller — one process, one | ||
| 315 | /// arena, so they are the same allocator today and separate in the | ||
| 316 | /// signature because they answer to different owners. | ||
| 317 | alloc: std.mem.Allocator, | ||
| 318 | /// Whether a snapshot has arrived since the last attach, which is the | ||
| 319 | /// ONLY thing on the wire that tells a refused attach from a session | ||
| 320 | /// that ended: the daemon spells both as `exit_status 1` and closes | ||
| 321 | /// (server.zig's attach arm refuses a 0x0 join of a name it does not | ||
| 322 | /// hold). A served attach always sends the snapshot first, so an | ||
| 323 | /// `exit_status` before one cannot be an ending. Same rule the CLI wall | ||
| 324 | /// reads (wallview.zig's `state_since_attach`), and no wire change. | ||
| 325 | saw_snapshot: bool = false, | ||
| 326 | /// The code from the `exit_status` frame that ended a wait, set the | ||
| 327 | /// moment awaitFrame returns error.SessionExited. The frame is the | ||
| 328 | /// session's last word and carries the only copy of the code, so it is | ||
| 329 | /// captured here rather than thrown away with the frame; callers read | ||
| 330 | /// it to turn the error into an answer. | ||
| 331 | session_exit: ?u8 = null, | ||
| 332 | /// Why the reconnect could not be made, set the moment a redial fails | ||
| 333 | /// — and set for the same reason `session_exit` is: the error that | ||
| 334 | /// ends the verb is `ConnectionLost`, which is the story's beginning, | ||
| 335 | /// while THIS is how it finished. An agent told only | ||
| 336 | /// `QuicHandshakeFailed` goes and checks its key; an agent told | ||
| 337 | /// `connection lost; reconnect failed: QuicHandshakeFailed` knows the | ||
| 338 | /// path tore mid-wait and the redial could not complete. | ||
| 339 | /// | ||
| 340 | /// An `@errorName`, so this borrows a static string and owns no | ||
| 341 | /// storage. See `waitFailDetail`, which composes the line. | ||
| 342 | reconnect_failure: ?[]const u8 = null, | ||
| 343 | |||
| 344 | fn open(alloc: std.mem.Allocator, sock_path: []const u8) !Conn { | ||
| 345 | const s = try std.net.connectUnixSocket(sock_path); | ||
| 346 | return .{ .link = .{ .fd = s.handle }, .alloc = alloc }; | ||
| 347 | } | ||
| 348 | |||
| 349 | /// A `send` before the stream exists takes zero bytes, so the frame | ||
| 350 | /// would silently never leave. | ||
| 351 | fn openQuic( | ||
| 352 | alloc: std.mem.Allocator, | ||
| 353 | addr: std.net.Address, | ||
| 354 | key: quic_client.Key, | ||
| 355 | idle_ms: u32, | ||
| 356 | deadline_ms: i64, | ||
| 357 | ) !Conn { | ||
| 358 | const started = std.time.milliTimestamp(); | ||
| 359 | const cl = try quic_client.Client.connect(alloc, addr, key, idle_ms); | ||
| 360 | errdefer cl.deinit(); | ||
| 361 | try waitReady(cl, deadline_ms); | ||
| 362 | return .{ | ||
| 363 | .link = .{ .quic = .{ | ||
| 364 | .cl = cl, | ||
| 365 | .addr = addr, | ||
| 366 | .key = key, | ||
| 367 | .idle_ms = idle_ms, | ||
| 368 | .connect_ms = elapsed(started), | ||
| 369 | } }, | ||
| 370 | .alloc = alloc, | ||
| 371 | }; | ||
| 372 | } | ||
| 373 | |||
| 374 | fn close(self: *Conn) void { | ||
| 375 | switch (self.link) { | ||
| 376 | .fd => |fd| std.posix.close(fd), | ||
| 377 | .quic => |q| q.cl.deinit(), | ||
| 378 | } | ||
| 379 | } | ||
| 380 | |||
| 381 | /// QUIC widens the grace: the daemon's window opens a flight after | ||
| 382 | /// ours. The cap bounds a slow handshake. | ||
| 383 | fn graceMs(self: *const Conn) i64 { | ||
| 384 | return switch (self.link) { | ||
| 385 | .fd => await_grace_ms, | ||
| 386 | .quic => |q| @min(grace_cap_ms, @max(await_grace_ms, 4 * q.connect_ms)), | ||
| 387 | }; | ||
| 388 | } | ||
| 389 | |||
| 390 | /// The socket arm ignores `deadline_ms` — a local write either takes | ||
| 391 | /// the bytes or fails. The QUIC arm gives it precedence over | ||
| 392 | /// `send_flush_ms`, so a verb asked for a 100ms answer cannot spend | ||
| 393 | /// five seconds sending. | ||
| 394 | fn sendFrame(self: *Conn, t: proto.MsgType, payload: []const u8, deadline_ms: i64) !void { | ||
| 395 | const wrote: anyerror!void = switch (self.link) { | ||
| 396 | .fd => |fd| proto.writeFrame(fd, t, payload), | ||
| 397 | .quic => self.sendFrameQuic(t, payload, deadline_ms), | ||
| 398 | }; | ||
| 399 | wrote catch |e| { | ||
| 400 | switch (e) { | ||
| 401 | error.BrokenPipe, | ||
| 402 | error.ConnectionResetByPeer, | ||
| 403 | error.ConnectionLost, | ||
| 404 | => try self.refusalPending(), | ||
| 405 | else => {}, | ||
| 406 | } | ||
| 407 | return e; | ||
| 408 | }; | ||
| 409 | } | ||
| 410 | |||
| 411 | /// A refusal is `exit_status` then close, and that close can beat | ||
| 412 | /// our next write: the `BrokenPipe` then stands where the refusal | ||
| 413 | /// belongs. Rare untraced, certain under ptrace. The want is one | ||
| 414 | /// the daemon never sends: only the snapshot rule ends it. | ||
| 415 | fn refusalPending(self: *Conn) error{AttachRefused}!void { | ||
| 416 | const frame = self.awaitFrame(.attach, std.time.milliTimestamp() + refusal_drain_ms) catch |e| { | ||
| 417 | if (e == error.AttachRefused) return error.AttachRefused; | ||
| 418 | return; | ||
| 419 | }; | ||
| 420 | frame.deinit(self.alloc); | ||
| 421 | } | ||
| 422 | |||
| 423 | /// A half-written frame reads as a corrupt stream, so the short take | ||
| 424 | /// is re-offered. | ||
| 425 | fn sendFrameQuic( | ||
| 426 | self: *Conn, | ||
| 427 | t: proto.MsgType, | ||
| 428 | payload: []const u8, | ||
| 429 | deadline_ms: i64, | ||
| 430 | ) !void { | ||
| 431 | var buf: std.ArrayList(u8) = .empty; | ||
| 432 | defer buf.deinit(self.alloc); | ||
| 433 | try proto.appendFrame(&buf, self.alloc, t, payload); | ||
| 434 | |||
| 435 | const q = &self.link.quic; | ||
| 436 | // The flush cap is what bounds an UNBOUNDED caller (`--timeout 0`); | ||
| 437 | // the caller's own deadline bounds every other one. | ||
| 438 | const deadline = @min(deadline_ms, std.time.milliTimestamp() + send_flush_ms); | ||
| 439 | var off: usize = 0; | ||
| 440 | while (off < buf.items.len) { | ||
| 441 | if (q.cl.dead) return error.ConnectionLost; | ||
| 442 | off += q.cl.send(buf.items[off..]); | ||
| 443 | if (off == buf.items.len) return; | ||
| 444 | if (std.time.milliTimestamp() >= deadline) return error.SendStalled; | ||
| 445 | // The ring is full: only the peer's acks can empty it, and they | ||
| 446 | // arrive through pump. Polling first keeps this from spinning. | ||
| 447 | var fds = [_]std.posix.pollfd{ | ||
| 448 | .{ .fd = q.cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 449 | }; | ||
| 450 | _ = std.posix.poll(&fds, q.cl.timeoutMs(50)) catch return error.ConnectionLost; | ||
| 451 | q.cl.pump(); | ||
| 452 | } | ||
| 453 | } | ||
| 454 | |||
| 455 | /// Snapshots and deltas stream past an attached client, so unwanted | ||
| 456 | /// frames are skipped. `exit_status` ends the wait instead: the reply | ||
| 457 | /// is never coming, and the session ending is an answer, not a | ||
| 458 | /// transport failure. | ||
| 459 | fn awaitFrame(self: *Conn, want: proto.MsgType, deadline_ms: i64) !proto.Frame { | ||
| 460 | return switch (self.link) { | ||
| 461 | .fd => self.awaitFrameFd(self.alloc, want, deadline_ms), | ||
| 462 | .quic => self.awaitFrameQuic(self.alloc, want, deadline_ms), | ||
| 463 | }; | ||
| 464 | } | ||
| 465 | |||
| 466 | /// Deadline-bounded wait, unbounded read: harmless where a stall | ||
| 467 | /// means a dead daemon. | ||
| 468 | fn awaitFrameFd( | ||
| 469 | self: *Conn, | ||
| 470 | alloc: std.mem.Allocator, | ||
| 471 | want: proto.MsgType, | ||
| 472 | deadline_ms: i64, | ||
| 473 | ) !proto.Frame { | ||
| 474 | const fd = self.link.fd; | ||
| 475 | while (true) { | ||
| 476 | const now = std.time.milliTimestamp(); | ||
| 477 | if (now >= deadline_ms) return error.Timeout; | ||
| 478 | var fds = [_]std.posix.pollfd{ | ||
| 479 | .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 480 | }; | ||
| 481 | const n = try std.posix.poll(&fds, @intCast(@min(deadline_ms - now, 250))); | ||
| 482 | if (n == 0) continue; | ||
| 483 | const frame = try proto.readFrame(alloc, fd) orelse return error.DaemonGone; | ||
| 484 | if (frame.type == want) return frame; | ||
| 485 | defer frame.deinit(alloc); | ||
| 486 | if (frame.type == .snapshot) self.saw_snapshot = true; | ||
| 487 | if (frame.type == .exit_status) { | ||
| 488 | if (!self.saw_snapshot) return error.AttachRefused; | ||
| 489 | // A daemon that spelled the frame without a code still ends | ||
| 490 | // the session; null is the honest code, not 0. | ||
| 491 | self.session_exit = if (frame.payload.len >= 1) frame.payload[0] else null; | ||
| 492 | return error.SessionExited; | ||
| 493 | } | ||
| 494 | } | ||
| 495 | } | ||
| 496 | |||
| 497 | /// Drain the buffer before checking `dead`, or bytes that arrived | ||
| 498 | /// first are lost. | ||
| 499 | fn awaitFrameQuic( | ||
| 500 | self: *Conn, | ||
| 501 | alloc: std.mem.Allocator, | ||
| 502 | want: proto.MsgType, | ||
| 503 | deadline_ms: i64, | ||
| 504 | ) !proto.Frame { | ||
| 505 | const q = &self.link.quic; | ||
| 506 | while (true) { | ||
| 507 | q.cl.pump(); | ||
| 508 | while (try frameFrom(alloc, q.cl.inbound())) |got| { | ||
| 509 | q.cl.consume(got.consumed); | ||
| 510 | if (got.frame.type == want) return got.frame; | ||
| 511 | defer got.frame.deinit(alloc); | ||
| 512 | // The socket arm's rule again rather than a shared helper: | ||
| 513 | // the two arms own their frame differently (`got.frame` | ||
| 514 | // paired with `consume`, against a plain `frame`), so there | ||
| 515 | // is no cursor for one reader to hand the other. | ||
| 516 | if (got.frame.type == .snapshot) self.saw_snapshot = true; | ||
| 517 | if (got.frame.type == .exit_status) { | ||
| 518 | if (!self.saw_snapshot) return error.AttachRefused; | ||
| 519 | self.session_exit = if (got.frame.payload.len >= 1) got.frame.payload[0] else null; | ||
| 520 | return error.SessionExited; | ||
| 521 | } | ||
| 522 | } | ||
| 523 | // Not `DaemonGone`: over a network the difference between "the | ||
| 524 | // daemon exited" and "the path to it went away" is not ours to | ||
| 525 | // claim, and the reconnect above only fires on this one. | ||
| 526 | if (q.cl.dead) return error.ConnectionLost; | ||
| 527 | const now = std.time.milliTimestamp(); | ||
| 528 | if (now >= deadline_ms) return error.Timeout; | ||
| 529 | var fds = [_]std.posix.pollfd{ | ||
| 530 | .{ .fd = q.cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 531 | }; | ||
| 532 | // Through timeoutMs, so ngtcp2's own timers — loss detection | ||
| 533 | // and, on a quiet await, the keepalive that keeps the idle | ||
| 534 | // timeout from firing under us — are serviced on schedule | ||
| 535 | // rather than whenever the daemon happens to say something. | ||
| 536 | const cap: i32 = @intCast(@min(deadline_ms - now, 250)); | ||
| 537 | _ = std.posix.poll(&fds, q.cl.timeoutMs(cap)) catch return error.ConnectionLost; | ||
| 538 | } | ||
| 539 | } | ||
| 540 | |||
| 541 | /// `connect_ms` deliberately keeps the FIRST handshake's measurement: | ||
| 542 | /// what a reader wants is the distance to the daemon, not the cost of | ||
| 543 | /// a redial made while the path was still coming back. Nothing reads | ||
| 544 | /// it after this point anyway. | ||
| 545 | fn reconnect(self: *Conn, deadline_ms: i64) !void { | ||
| 546 | const q = &self.link.quic; | ||
| 547 | const cl = try quic_client.Client.connect(self.alloc, q.addr, q.key, q.idle_ms); | ||
| 548 | errdefer cl.deinit(); | ||
| 549 | try waitReady(cl, deadline_ms); | ||
| 550 | q.cl.deinit(); | ||
| 551 | q.cl = cl; | ||
| 552 | q.reconnected = true; | ||
| 553 | } | ||
| 554 | }; | ||
| 555 | |||
| 556 | /// Copies: the caller consumes the bytes it points at. | ||
| 557 | fn frameFrom( | ||
| 558 | alloc: std.mem.Allocator, | ||
| 559 | buf: []const u8, | ||
| 560 | ) !?struct { frame: proto.Frame, consumed: usize } { | ||
| 561 | const d = try proto.delimitFrame(buf) orelse return null; | ||
| 562 | const payload = try alloc.alloc(u8, d.payload.len); | ||
| 563 | errdefer alloc.free(payload); | ||
| 564 | @memcpy(payload, d.payload); | ||
| 565 | return .{ | ||
| 566 | .frame = .{ .type = d.type, .payload = payload }, | ||
| 567 | .consumed = d.consumed, | ||
| 568 | }; | ||
| 569 | } | ||
| 570 | |||
| 571 | test "frameFrom: a partial tail is not a frame and not an error" { | ||
| 572 | const alloc = std.testing.allocator; | ||
| 573 | |||
| 574 | // Nothing, and less than a header: the two shapes a datagram that | ||
| 575 | // carried the start of a frame leaves behind. | ||
| 576 | try std.testing.expect(try frameFrom(alloc, "") == null); | ||
| 577 | try std.testing.expect(try frameFrom(alloc, &[_]u8{ 0x0a, 1, 0 }) == null); | ||
| 578 | |||
| 579 | // A whole header whose payload is still in flight. This is the case a | ||
| 580 | // blocking read would have sat on: the length is known, the bytes are | ||
| 581 | // not here, and the answer is to wait rather than to read. | ||
| 582 | const partial = [_]u8{ @intFromEnum(proto.MsgType.input), 4, 0, 0, 0, 'a', 'b' }; | ||
| 583 | try std.testing.expect(try frameFrom(alloc, &partial) == null); | ||
| 584 | |||
| 585 | // The same bytes, completed. | ||
| 586 | const whole = [_]u8{ @intFromEnum(proto.MsgType.input), 4, 0, 0, 0, 'a', 'b', 'c', 'd' }; | ||
| 587 | const got = (try frameFrom(alloc, &whole)).?; | ||
| 588 | defer got.frame.deinit(alloc); | ||
| 589 | try std.testing.expectEqual(proto.MsgType.input, got.frame.type); | ||
| 590 | try std.testing.expectEqualStrings("abcd", got.frame.payload); | ||
| 591 | try std.testing.expectEqual(@as(usize, 9), got.consumed); | ||
| 592 | } | ||
| 593 | |||
| 594 | test "frameFrom: two frames in one buffer, walked by consumed" { | ||
| 595 | const alloc = std.testing.allocator; | ||
| 596 | // What a single datagram routinely carries: the push we skip and the | ||
| 597 | // reply we asked for. A walk that stopped after one would leave the | ||
| 598 | // answer sitting in the buffer while the deadline ran out. | ||
| 599 | var buf: std.ArrayList(u8) = .empty; | ||
| 600 | defer buf.deinit(alloc); | ||
| 601 | try proto.appendFrame(&buf, alloc, .pty_mode, &[_]u8{0}); | ||
| 602 | try proto.appendFrame(&buf, alloc, .status_reply, "xy"); | ||
| 603 | |||
| 604 | const first = (try frameFrom(alloc, buf.items)).?; | ||
| 605 | defer first.frame.deinit(alloc); | ||
| 606 | try std.testing.expectEqual(proto.MsgType.pty_mode, first.frame.type); | ||
| 607 | |||
| 608 | const second = (try frameFrom(alloc, buf.items[first.consumed..])).?; | ||
| 609 | defer second.frame.deinit(alloc); | ||
| 610 | try std.testing.expectEqual(proto.MsgType.status_reply, second.frame.type); | ||
| 611 | try std.testing.expectEqualStrings("xy", second.frame.payload); | ||
| 612 | try std.testing.expectEqual(buf.items.len, first.consumed + second.consumed); | ||
| 613 | |||
| 614 | // An empty payload is a frame like any other — `status_req` and | ||
| 615 | // `detach` are nothing else — and must not read as "nothing yet". | ||
| 616 | var empty: std.ArrayList(u8) = .empty; | ||
| 617 | defer empty.deinit(alloc); | ||
| 618 | try proto.appendFrame(&empty, alloc, .detach, ""); | ||
| 619 | const none = (try frameFrom(alloc, empty.items)).?; | ||
| 620 | defer none.frame.deinit(alloc); | ||
| 621 | try std.testing.expectEqual(@as(usize, proto.frame_header_len), none.consumed); | ||
| 622 | } | ||
| 623 | |||
| 624 | test "frameFrom: a length no frame can carry is refused, not allocated" { | ||
| 625 | const alloc = std.testing.allocator; | ||
| 626 | // The peer chose this number. Reading on would mean allocating against | ||
| 627 | // it; the daemon's own walk refuses the same bound the same way. | ||
| 628 | var hdr: [proto.frame_header_len]u8 = undefined; | ||
| 629 | hdr[0] = @intFromEnum(proto.MsgType.input); | ||
| 630 | std.mem.writeInt(u32, hdr[1..5], proto.max_payload + 1, .little); | ||
| 631 | try std.testing.expectError(error.FrameTooLarge, frameFrom(alloc, &hdr)); | ||
| 632 | } | ||
| 633 | |||
| 634 | test "graceMs: flat over a socket, RTT-derived over QUIC, and capped" { | ||
| 635 | const alloc = std.testing.allocator; | ||
| 636 | const local = Conn{ .link = .{ .fd = -1 }, .alloc = alloc }; | ||
| 637 | try std.testing.expectEqual(@as(i64, 2_000), local.graceMs()); | ||
| 638 | |||
| 639 | // The derivation is 4x the handshake, and it only ever WIDENS the | ||
| 640 | // window: a loopback or LAN daemon keeps the flat 2s. | ||
| 641 | // No client: the window is a function of the measurement, not of the | ||
| 642 | // connection, and nothing here may touch one. | ||
| 643 | var far = Conn{ | ||
| 644 | .link = .{ .quic = .{ | ||
| 645 | .cl = undefined, | ||
| 646 | .addr = undefined, | ||
| 647 | .key = undefined, | ||
| 648 | .idle_ms = 0, | ||
| 649 | .connect_ms = 1, | ||
| 650 | } }, | ||
| 651 | .alloc = alloc, | ||
| 652 | }; | ||
| 653 | try std.testing.expectEqual(@as(i64, 2_000), far.graceMs()); | ||
| 654 | |||
| 655 | // A 300ms handshake — a real intercontinental link — buys 1.2s, which | ||
| 656 | // is still under the floor, so the first number that moves it is a | ||
| 657 | // handshake past half a second. | ||
| 658 | far.link.quic.connect_ms = 300; | ||
| 659 | try std.testing.expectEqual(@as(i64, 2_000), far.graceMs()); | ||
| 660 | far.link.quic.connect_ms = 900; | ||
| 661 | try std.testing.expectEqual(@as(i64, 3_600), far.graceMs()); | ||
| 662 | |||
| 663 | // And it stops widening: past the cap we are no longer waiting on a | ||
| 664 | // daemon, we are waiting on a network that has already failed to carry | ||
| 665 | // an answer. | ||
| 666 | far.link.quic.connect_ms = 60_000; | ||
| 667 | try std.testing.expectEqual(@as(i64, 30_000), far.graceMs()); | ||
| 668 | } | ||
| 669 | |||
| 670 | /// Drive a fresh connection until it can carry bytes, or give up. | ||
| 671 | /// | ||
| 672 | /// A refused port ends this early — quic_client turns the ICMP unreachable | ||
| 673 | /// into `dead` — so the common mistake (no daemon on that port) costs | ||
| 674 | /// milliseconds. A blackholed one produces no error at all, and there the | ||
| 675 | /// deadline is the only thing that ends the wait; even an unbounded one | ||
| 676 | /// (`--timeout 0`) terminates, because the connection's own idle timeout | ||
| 677 | /// kills it after `idle_ms`. | ||
| 678 | fn waitReady(cl: *quic_client.Client, deadline_ms: i64) !void { | ||
| 679 | while (true) { | ||
| 680 | cl.pump(); | ||
| 681 | if (cl.isReady()) return; | ||
| 682 | if (cl.dead) return error.QuicHandshakeFailed; | ||
| 683 | const now = std.time.milliTimestamp(); | ||
| 684 | if (now >= deadline_ms) return error.Timeout; | ||
| 685 | var fds = [_]std.posix.pollfd{ | ||
| 686 | .{ .fd = cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 687 | }; | ||
| 688 | const cap: i32 = @intCast(@min(deadline_ms - now, 50)); | ||
| 689 | _ = std.posix.poll(&fds, cl.timeoutMs(cap)) catch return error.QuicHandshakeFailed; | ||
| 690 | } | ||
| 691 | } | ||
| 692 | |||
| 693 | test "reconnect: redials the same coordinates, and a dead port is a fast no" { | ||
| 694 | const alloc = std.testing.allocator; | ||
| 695 | // 127.0.0.1:1, where nothing listens: the refusal is REAL — an ICMP | ||
| 696 | // unreachable comes back and quic_client acts on it — which is what | ||
| 697 | // lets this exercise the whole redial path (dial, handshake wait, | ||
| 698 | // verdict) in a couple of loopback round trips instead of a timeout. | ||
| 699 | const addr = try std.net.Address.parseIp("127.0.0.1", 1); | ||
| 700 | const key: quic_client.Key = .{ .bytes = [_]u8{7} ** quic_client.key_len }; | ||
| 701 | |||
| 702 | // The dial that stands in for the connection this client had before | ||
| 703 | // the network went away. It dies for the same reason the redial will, | ||
| 704 | // which is fine: what is under test is what `reconnect` DOES, and it | ||
| 705 | // does the same thing to a connection that died at second 30. | ||
| 706 | const deadline = std.time.milliTimestamp() + 2_000; | ||
| 707 | var conn = Conn{ | ||
| 708 | .link = .{ .quic = .{ | ||
| 709 | .cl = try quic_client.Client.connect(alloc, addr, key, 1_000), | ||
| 710 | .addr = addr, | ||
| 711 | .key = key, | ||
| 712 | .idle_ms = 1_000, | ||
| 713 | .connect_ms = 0, | ||
| 714 | } }, | ||
| 715 | .alloc = alloc, | ||
| 716 | }; | ||
| 717 | defer conn.close(); | ||
| 718 | |||
| 719 | const t0 = std.time.milliTimestamp(); | ||
| 720 | if (conn.reconnect(deadline)) |_| { | ||
| 721 | // Nothing listens there; a redial that reported success would mean | ||
| 722 | // the handshake wait had stopped being a wait for a handshake. | ||
| 723 | return error.TestUnexpectedResult; | ||
| 724 | } else |redial| { | ||
| 725 | try std.testing.expectEqual(error.QuicHandshakeFailed, redial); | ||
| 726 | // The bookkeeping awaitReissuing does, done here with the real | ||
| 727 | // error the real redial produced, so the sentence below is the one | ||
| 728 | // an agent gets rather than one this test made up. | ||
| 729 | conn.reconnect_failure = @errorName(redial); | ||
| 730 | } | ||
| 731 | // Fast, because the port refused rather than went quiet. A redial that | ||
| 732 | // swallowed the refusal would spend the whole 2s here — and in the | ||
| 733 | // field it would spend the agent's remaining deadline. | ||
| 734 | try std.testing.expect(std.time.milliTimestamp() - t0 < 1_000); | ||
| 735 | |||
| 736 | // The whole story, in the order it happened: the wait died because the | ||
| 737 | // path tore, and it stayed dead because the redial could not complete. | ||
| 738 | // An agent told only the second half goes and checks its key. | ||
| 739 | var buf: [128]u8 = undefined; | ||
| 740 | try std.testing.expectEqualStrings( | ||
| 741 | "connection lost; reconnect failed: QuicHandshakeFailed", | ||
| 742 | waitFailDetail(&buf, &conn, error.ConnectionLost), | ||
| 743 | ); | ||
| 744 | |||
| 745 | // A redial that failed is not a reconnect spent — but it is also not a | ||
| 746 | // Conn holding a freed client: the old one is torn down only once a | ||
| 747 | // new one is up, so the close above is safe on this path. | ||
| 748 | try std.testing.expect(!conn.link.quic.reconnected); | ||
| 749 | } | ||
| 750 | |||
| 751 | test "waitFailDetail: only a lost connection gets a sentence; the rest keep their names" { | ||
| 752 | const alloc = std.testing.allocator; | ||
| 753 | var buf: [128]u8 = undefined; | ||
| 754 | |||
| 755 | // Every other failure is untouched — the socket arm's reports must | ||
| 756 | // read exactly as they did before there was a QUIC arm. | ||
| 757 | const local = Conn{ .link = .{ .fd = -1 }, .alloc = alloc }; | ||
| 758 | try std.testing.expectEqualStrings("Timeout", waitFailDetail(&buf, &local, error.Timeout)); | ||
| 759 | try std.testing.expectEqualStrings("DaemonGone", waitFailDetail(&buf, &local, error.DaemonGone)); | ||
| 760 | |||
| 761 | // A tear with the one reconnect still unspent (nothing tried yet). | ||
| 762 | var far = Conn{ | ||
| 763 | .link = .{ .quic = .{ | ||
| 764 | .cl = undefined, | ||
| 765 | .addr = undefined, | ||
| 766 | .key = undefined, | ||
| 767 | .idle_ms = 0, | ||
| 768 | .connect_ms = 0, | ||
| 769 | } }, | ||
| 770 | .alloc = alloc, | ||
| 771 | }; | ||
| 772 | try std.testing.expectEqualStrings("connection lost", waitFailDetail(&buf, &far, error.ConnectionLost)); | ||
| 773 | |||
| 774 | // A tear AFTER a reconnect that worked: the second one inside a single | ||
| 775 | // wait, which is a different thing to be told than the first — the | ||
| 776 | // client did reconnect, and the path tore again anyway. | ||
| 777 | far.link.quic.reconnected = true; | ||
| 778 | try std.testing.expectEqualStrings( | ||
| 779 | "connection lost again, after the one reconnect", | ||
| 780 | waitFailDetail(&buf, &far, error.ConnectionLost), | ||
| 781 | ); | ||
| 782 | |||
| 783 | // A buffer too small to hold the composed line drops the reason rather | ||
| 784 | // than the finding: the detail is the agent's only account of this. | ||
| 785 | far.reconnect_failure = "QuicHandshakeFailed"; | ||
| 786 | var tiny: [8]u8 = undefined; | ||
| 787 | try std.testing.expectEqualStrings( | ||
| 788 | "connection lost; reconnect failed", | ||
| 789 | waitFailDetail(&tiny, &far, error.ConnectionLost), | ||
| 790 | ); | ||
| 791 | } | ||
| 792 | |||
| 793 | test "awaitFrame ends a wait on exit_status, keeping the code" { | ||
| 794 | const alloc = std.testing.allocator; | ||
| 795 | // A pipe stands in for the daemon: awaitFrame polls and reads an fd and | ||
| 796 | // asks nothing else of it. | ||
| 797 | const pipe = try std.posix.pipe(); | ||
| 798 | defer std.posix.close(pipe[0]); | ||
| 799 | defer std.posix.close(pipe[1]); | ||
| 800 | |||
| 801 | var conn = Conn{ .link = .{ .fd = pipe[0] }, .alloc = alloc }; | ||
| 802 | // The snapshot first, because it is what makes this an ENDING rather | ||
| 803 | // than a refusal: the attach was served, so the exit_status after it | ||
| 804 | // is the session's last word (see the refusal test below). | ||
| 805 | try proto.writeFrame(pipe[1], .snapshot, ""); | ||
| 806 | // A push to skip on the way, then the session's last word. The reply | ||
| 807 | // this wait asked for is never coming, and the code is the answer. | ||
| 808 | try proto.writeFrame(pipe[1], .pty_mode, &[_]u8{0}); | ||
| 809 | try proto.writeFrame(pipe[1], .exit_status, &[_]u8{5}); | ||
| 810 | try std.testing.expectError( | ||
| 811 | error.SessionExited, | ||
| 812 | conn.awaitFrame(.status_reply, std.time.milliTimestamp() + 2000), | ||
| 813 | ); | ||
| 814 | try std.testing.expectEqual(@as(?u8, 5), conn.session_exit); | ||
| 815 | |||
| 816 | // ...and it is spelled as a session ending, not as a command's code. | ||
| 817 | var out: std.ArrayList(u8) = .empty; | ||
| 818 | defer out.deinit(alloc); | ||
| 819 | try printSessionEnded(out.writer(alloc), conn.session_exit, 42); | ||
| 820 | try std.testing.expectEqualStrings( | ||
| 821 | "{\"reason\":\"session_ended\",\"exit_code\":5,\"duration_ms\":42}\n", | ||
| 822 | out.items, | ||
| 823 | ); | ||
| 824 | } | ||
| 825 | |||
| 826 | test "an exit_status before any snapshot is a refused attach, not a session that ended" { | ||
| 827 | const alloc = std.testing.allocator; | ||
| 828 | // The daemon's whole vocabulary for "no": resolveSession refuses a 0x0 | ||
| 829 | // attach of a name it does not hold by sending exit_status 1 and | ||
| 830 | // closing (server.zig's attach arm), which is byte-identical to a | ||
| 831 | // shell's real exit. The snapshot is what tells them apart — a served | ||
| 832 | // attach always sends one first, so nothing before one can be an | ||
| 833 | // ending. Reporting this as "session ended" told an agent a shell had | ||
| 834 | // run and failed when no shell ever existed. | ||
| 835 | const pipe = try std.posix.pipe(); | ||
| 836 | defer std.posix.close(pipe[0]); | ||
| 837 | defer std.posix.close(pipe[1]); | ||
| 838 | |||
| 839 | var conn = Conn{ .link = .{ .fd = pipe[0] }, .alloc = alloc }; | ||
| 840 | try proto.writeFrame(pipe[1], .exit_status, &[_]u8{1}); | ||
| 841 | try std.testing.expectError( | ||
| 842 | error.AttachRefused, | ||
| 843 | conn.awaitFrame(.status_reply, std.time.milliTimestamp() + 2000), | ||
| 844 | ); | ||
| 845 | } | ||
| 846 | |||
| 847 | test "a re-attach forgets the snapshot it saw, so a refused reconnect is not an ending" { | ||
| 848 | const alloc = std.testing.allocator; | ||
| 849 | // A socketpair, not a pipe: this conn has to WRITE (the attach) as well | ||
| 850 | // as read down the ONE fd it holds, which is the whole shape under test | ||
| 851 | // — awaitReissuing attaches a second time on a connection that has | ||
| 852 | // already been served. Through std.os.linux because std.posix has no | ||
| 853 | // socketpair on 0.15.2 (server.zig's connectedPair says the same). | ||
| 854 | var sp: [2]i32 = undefined; | ||
| 855 | try std.testing.expectEqual( | ||
| 856 | @as(usize, 0), | ||
| 857 | std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp), | ||
| 858 | ); | ||
| 859 | defer std.posix.close(sp[0]); | ||
| 860 | defer std.posix.close(sp[1]); | ||
| 861 | |||
| 862 | var conn = Conn{ .link = .{ .fd = sp[0] }, .alloc = alloc }; | ||
| 863 | // The first attach is served: a snapshot arrives and is skipped past on | ||
| 864 | // the way to a reply that never comes. | ||
| 865 | try proto.writeFrame(sp[1], .snapshot, ""); | ||
| 866 | try std.testing.expectError( | ||
| 867 | error.Timeout, | ||
| 868 | conn.awaitFrame(.status_reply, std.time.milliTimestamp() + 50), | ||
| 869 | ); | ||
| 870 | try std.testing.expect(conn.saw_snapshot); | ||
| 871 | |||
| 872 | // The reconnect's attach, refused. Without the reset in attachZero the | ||
| 873 | // stale `saw_snapshot` reads this as the session ending, and the verb | ||
| 874 | // reports a shell exit for a session it never reached. | ||
| 875 | try attachZero(&conn, "s", std.time.milliTimestamp() + 2000); | ||
| 876 | try proto.writeFrame(sp[1], .exit_status, &[_]u8{1}); | ||
| 877 | try std.testing.expectError( | ||
| 878 | error.AttachRefused, | ||
| 879 | conn.awaitFrame(.status_reply, std.time.milliTimestamp() + 2000), | ||
| 880 | ); | ||
| 881 | } | ||
| 882 | |||
| 883 | test "a refusal that closes the socket before the input write is still reported as the refusal" { | ||
| 884 | const alloc = std.testing.allocator; | ||
| 885 | // The refusal is `exit_status` + close (server.zig's attach arm), and | ||
| 886 | // nothing makes our next write lose to that close except timing: the | ||
| 887 | // socket buffer usually takes the bytes first, so the refusal is read | ||
| 888 | // where it is expected. Under a ptrace tracer (kcov) the close wins | ||
| 889 | // every time, and the write's BrokenPipe was reported INSTEAD of the | ||
| 890 | // refusal — a daemon saying "no such session" spelled as a transport | ||
| 891 | // failure. | ||
| 892 | var sp: [2]i32 = undefined; | ||
| 893 | try std.testing.expectEqual( | ||
| 894 | @as(usize, 0), | ||
| 895 | std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp), | ||
| 896 | ); | ||
| 897 | defer std.posix.close(sp[0]); | ||
| 898 | |||
| 899 | var conn = Conn{ .link = .{ .fd = sp[0] }, .alloc = alloc }; | ||
| 900 | const deadline = std.time.milliTimestamp() + 2000; | ||
| 901 | // The field ordering: the attach is served, the refusal comes back, | ||
| 902 | // and the close beats the input write that follows it. | ||
| 903 | try attachZero(&conn, "nosuch", deadline); | ||
| 904 | try proto.writeFrame(sp[1], .exit_status, &[_]u8{1}); | ||
| 905 | std.posix.close(sp[1]); | ||
| 906 | try std.testing.expectError(error.AttachRefused, conn.sendFrame(.input, "x", deadline)); | ||
| 907 | |||
| 908 | // And the verb's answer is the refusal's JSON, not the write's. | ||
| 909 | var sp2: [2]i32 = undefined; | ||
| 910 | try std.testing.expectEqual( | ||
| 911 | @as(usize, 0), | ||
| 912 | std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp2), | ||
| 913 | ); | ||
| 914 | defer std.posix.close(sp2[0]); | ||
| 915 | try proto.writeFrame(sp2[1], .exit_status, &[_]u8{1}); | ||
| 916 | std.posix.close(sp2[1]); | ||
| 917 | |||
| 918 | const saved = try std.posix.dup(std.posix.STDOUT_FILENO); | ||
| 919 | defer { | ||
| 920 | std.posix.dup2(saved, std.posix.STDOUT_FILENO) catch {}; | ||
| 921 | std.posix.close(saved); | ||
| 922 | } | ||
| 923 | const cap = try std.posix.pipe(); | ||
| 924 | defer std.posix.close(cap[0]); | ||
| 925 | try std.posix.dup2(cap[1], std.posix.STDOUT_FILENO); | ||
| 926 | std.posix.close(cap[1]); | ||
| 927 | |||
| 928 | var conn2 = Conn{ .link = .{ .fd = sp2[0] }, .alloc = alloc }; | ||
| 929 | const code = try verbSend(alloc, &conn2, "x", "nosuch", std.time.milliTimestamp() + 2000); | ||
| 930 | try std.posix.dup2(saved, std.posix.STDOUT_FILENO); | ||
| 931 | |||
| 932 | var buf: [1024]u8 = undefined; | ||
| 933 | const n = try std.posix.read(cap[0], &buf); | ||
| 934 | try std.testing.expectEqual(@as(u8, 1), code); | ||
| 935 | const want = "{\"error\":\"attach refused\",\"detail\":"; | ||
| 936 | try std.testing.expectEqualStrings(want, buf[0..@min(n, want.len)]); | ||
| 937 | } | ||
| 938 | |||
| 939 | test "the refused-attach failure keeps the error+detail shape every failure has" { | ||
| 940 | var buf: [512]u8 = undefined; | ||
| 941 | |||
| 942 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 943 | try writeAttachRefusedError(fbs.writer(), "nosuch", .attach); | ||
| 944 | // The name is in the detail because it is the one thing the agent got | ||
| 945 | // wrong, and an agent reading only `.error` still gets a phrase that | ||
| 946 | // cannot be confused with a command's failure. | ||
| 947 | try std.testing.expect(std.mem.startsWith(u8, fbs.getWritten(), "{\"error\":\"attach refused\",\"detail\":")); | ||
| 948 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "so nosuch must already exist") != null); | ||
| 949 | // Both conditions, never just absence: the daemon sends the same | ||
| 950 | // refusal when its client table is full, and a detail claiming the | ||
| 951 | // session does not exist would be a lie at a daemon that holds it. | ||
| 952 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "room for one more client") != null); | ||
| 953 | |||
| 954 | // The empty name is the wire's default spelling, not a session called | ||
| 955 | // "": a detail reading `so must already exist` would send an agent | ||
| 956 | // looking for a name it never typed. | ||
| 957 | var dflt = std.io.fixedBufferStream(&buf); | ||
| 958 | try writeAttachRefusedError(dflt.writer(), "", .attach); | ||
| 959 | try std.testing.expect(std.mem.indexOf(u8, dflt.getWritten(), "so the default session must already exist") != null); | ||
| 960 | } | ||
| 961 | |||
| 962 | test "a refusal a full client table cannot have caused does not blame one" { | ||
| 963 | var buf: [512]u8 = undefined; | ||
| 964 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 965 | // `status` never attaches, so `exit_status 1` here has one producer: | ||
| 966 | // findSession missed. An observer with no slot is closed frameless, so | ||
| 967 | // no fullness of any table can reach this reply — naming a constant | ||
| 968 | // that cannot be involved sends an agent to read `muxd stats`. | ||
| 969 | try writeAttachRefusedError(fbs.writer(), "nosuch", .query); | ||
| 970 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "so nosuch must already exist") != null); | ||
| 971 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "max_clients") == null); | ||
| 972 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "room for one more client") == null); | ||
| 973 | // The verb never attached, so the sentence must not open by calling | ||
| 974 | // this an attach either. | ||
| 975 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "refused this attach") == null); | ||
| 976 | } | ||
| 977 | |||
| 978 | /// The exit code for "the one JSON object never reached stdout". Distinct | ||
| 979 | /// from all four codes that mean it DID: an object plus 0/1/3, or a usage | ||
| 980 | /// error whose 2 promises stdout was left empty on purpose. | ||
| 981 | const write_failed_code: u8 = 4; | ||
| 982 | |||
| 983 | /// A write that fails must not exit 0: an agent checks the status, | ||
| 984 | /// then has no object. | ||
| 985 | fn emit(json: []const u8, ok: u8) u8 { | ||
| 986 | return emitTo(std.posix.STDOUT_FILENO, json, ok); | ||
| 987 | } | ||
| 988 | |||
| 989 | /// `emit` against a named fd, which is the whole reason it is split out: | ||
| 990 | /// stdout is not something a test can break without breaking the runner. | ||
| 991 | fn emitTo(fd: std.posix.fd_t, json: []const u8, ok: u8) u8 { | ||
| 992 | proto.writeAllFd(fd, json) catch |e| { | ||
| 993 | // Best-effort by construction: whatever took stdout away has very | ||
| 994 | // often taken stderr with it, and the exit code is the half that | ||
| 995 | // survives either way. This line is for the human reading the log. | ||
| 996 | var buf: [128]u8 = undefined; | ||
| 997 | const msg = std.fmt.bufPrint(&buf, "muxa: cannot write the reply: {s}\n", .{@errorName(e)}) catch | ||
| 998 | "muxa: cannot write the reply\n"; | ||
| 999 | proto.writeAllFd(std.posix.STDERR_FILENO, msg) catch {}; | ||
| 1000 | return write_failed_code; | ||
| 1001 | }; | ||
| 1002 | return ok; | ||
| 1003 | } | ||
| 1004 | |||
| 1005 | test "an unwritable stdout is a distinct exit code, never a silent 0" { | ||
| 1006 | // The diagnostic goes to stderr, and a test that let it through would | ||
| 1007 | // print a line that reads like a failure on every green run. Swapped | ||
| 1008 | // for /dev/null and put back. | ||
| 1009 | const saved = try std.posix.dup(std.posix.STDERR_FILENO); | ||
| 1010 | defer { | ||
| 1011 | std.posix.dup2(saved, std.posix.STDERR_FILENO) catch {}; | ||
| 1012 | std.posix.close(saved); | ||
| 1013 | } | ||
| 1014 | const devnull = try std.posix.open("/dev/null", .{ .ACCMODE = .WRONLY }, 0); | ||
| 1015 | defer std.posix.close(devnull); | ||
| 1016 | try std.posix.dup2(devnull, std.posix.STDERR_FILENO); | ||
| 1017 | |||
| 1018 | // A pipe whose reader is gone: the next write is EPIPE, which is the | ||
| 1019 | // field case — an agent harness that stopped reading muxa's stdout. | ||
| 1020 | const gone = try std.posix.pipe(); | ||
| 1021 | std.posix.close(gone[0]); | ||
| 1022 | defer std.posix.close(gone[1]); | ||
| 1023 | try std.testing.expectEqual(write_failed_code, emitTo(gone[1], "{\"sent\":true}\n", 0)); | ||
| 1024 | |||
| 1025 | // And the code the caller asked for is passed through untouched when | ||
| 1026 | // the object does land — including the nonzero ones, which must not be | ||
| 1027 | // confused with the write having failed. | ||
| 1028 | const live = try std.posix.pipe(); | ||
| 1029 | defer std.posix.close(live[0]); | ||
| 1030 | defer std.posix.close(live[1]); | ||
| 1031 | try std.testing.expectEqual(@as(u8, 3), emitTo(live[1], "{\"reason\":\"timeout\"}\n", 3)); | ||
| 1032 | var buf: [64]u8 = undefined; | ||
| 1033 | const n = try std.posix.read(live[0], &buf); | ||
| 1034 | try std.testing.expectEqualStrings("{\"reason\":\"timeout\"}\n", buf[0..n]); | ||
| 1035 | } | ||
| 1036 | |||
| 1037 | /// Every failure exit goes through here, so stdout carries one JSON object | ||
| 1038 | /// whatever went wrong — a driving agent parses the same shape on both | ||
| 1039 | /// paths instead of switching on exit code first. | ||
| 1040 | fn fail(msg: []const u8, detail: []const u8) u8 { | ||
| 1041 | var buf: [2048]u8 = undefined; | ||
| 1042 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 1043 | writeError(fbs.writer(), msg, detail) catch { | ||
| 1044 | // The message did not fit. Still JSON, still one line. | ||
| 1045 | return emit("{\"error\":\"failure too long to report\"}\n", 1); | ||
| 1046 | }; | ||
| 1047 | return emit(fbs.getWritten(), 1); | ||
| 1048 | } | ||
| 1049 | |||
| 1050 | /// `fail` for a verb prefix known only at runtime — every failure in | ||
| 1051 | /// the shared await/run pipeline. A message too long to prefix falls | ||
| 1052 | /// back to the unprefixed one rather than losing the failure. | ||
| 1053 | fn failAs(who: []const u8, msg: []const u8, detail: []const u8) u8 { | ||
| 1054 | var buf: [256]u8 = undefined; | ||
| 1055 | const joined = std.fmt.bufPrint(&buf, "{s}: {s}", .{ who, msg }) catch msg; | ||
| 1056 | return fail(joined, detail); | ||
| 1057 | } | ||
| 1058 | |||
| 1059 | fn writeError(writer: anytype, msg: []const u8, detail: []const u8) !void { | ||
| 1060 | try writer.writeAll("{\"error\":"); | ||
| 1061 | try jsonEscape(writer, msg); | ||
| 1062 | try writer.writeAll(",\"detail\":"); | ||
| 1063 | try jsonEscape(writer, detail); | ||
| 1064 | try writer.writeAll("}\n"); | ||
| 1065 | } | ||
| 1066 | |||
| 1067 | /// `--timeout 0` means no bound here as everywhere else (AwaitReq): a | ||
| 1068 | /// past deadline would fail instantly instead of waiting forever. | ||
| 1069 | fn deadlineFor(timeout_ms: u32) i64 { | ||
| 1070 | if (timeout_ms == 0) return std.math.maxInt(i64); | ||
| 1071 | return std.time.milliTimestamp() + timeout_ms; | ||
| 1072 | } | ||
| 1073 | |||
| 1074 | /// The session ended under us: JSON, but on the failure path — the | ||
| 1075 | /// verb that asked (status, capture, send) has no answer to give. | ||
| 1076 | /// `run` and `await` do have one and print it themselves. | ||
| 1077 | fn failSessionEnded(code: ?u8) u8 { | ||
| 1078 | var buf: [192]u8 = undefined; | ||
| 1079 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 1080 | writeSessionEndedError(fbs.writer(), code) catch return 1; | ||
| 1081 | return emit(fbs.getWritten(), 1); | ||
| 1082 | } | ||
| 1083 | |||
| 1084 | /// `detail` is here because every other failure has one: an agent that | ||
| 1085 | /// reads `.detail` on any exit-1 must never meet a missing key, and one | ||
| 1086 | /// verb quietly dropping it is exactly the shape a driver hits in the | ||
| 1087 | /// field and not in a test. `exit_code` is the machine field; the detail | ||
| 1088 | /// says the same thing in the prose the other failures use. | ||
| 1089 | fn writeSessionEndedError(writer: anytype, code: ?u8) !void { | ||
| 1090 | try writer.writeAll("{\"error\":\"session ended\",\"detail\":"); | ||
| 1091 | if (code) |c| { | ||
| 1092 | var buf: [40]u8 = undefined; | ||
| 1093 | try jsonEscape(writer, try std.fmt.bufPrint(&buf, "shell exited with {d}", .{c})); | ||
| 1094 | } else { | ||
| 1095 | try jsonEscape(writer, "shell exited without reporting a code"); | ||
| 1096 | } | ||
| 1097 | try writer.writeAll(",\"exit_code\":"); | ||
| 1098 | try writeExitCode(writer, code); | ||
| 1099 | try writer.writeAll("}\n"); | ||
| 1100 | } | ||
| 1101 | |||
| 1102 | /// Which ask the daemon refused. `status` and `capture` never attach, so | ||
| 1103 | /// their `exit_status 1` has exactly one producer — a name the daemon does | ||
| 1104 | /// not hold. An observer that cannot be seated is closed without a frame | ||
| 1105 | /// (`acceptConn`), so no table being full can reach them. | ||
| 1106 | const Refused = enum { attach, query }; | ||
| 1107 | |||
| 1108 | /// `failSessionEnded` where the snapshot rule says refusal instead: an | ||
| 1109 | /// agent told "session ended" goes looking for a shell that never ran. | ||
| 1110 | fn failAttachRefused(name: []const u8, ask: Refused) u8 { | ||
| 1111 | var buf: [1024]u8 = undefined; | ||
| 1112 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 1113 | writeAttachRefusedError(fbs.writer(), name, ask) catch return 1; | ||
| 1114 | return emit(fbs.getWritten(), 1); | ||
| 1115 | } | ||
| 1116 | |||
| 1117 | /// A send raises the same refusal `awaitFrame` does, so it reaches the same | ||
| 1118 | /// producer: a verb spelling its own switch would answer half of them. | ||
| 1119 | fn failSend(e: anyerror, name: []const u8, ask: Refused, who: []const u8, msg: []const u8) u8 { | ||
| 1120 | if (e == error.AttachRefused) return failAttachRefused(name, ask); | ||
| 1121 | return failAs(who, msg, @errorName(e)); | ||
| 1122 | } | ||
| 1123 | |||
| 1124 | /// An attach's `exit_status 1` before a snapshot has two producers and the | ||
| 1125 | /// frame does not say which: `resolveSession` refused the name (absent, | ||
| 1126 | /// since a 0x0 attach cannot create, or unspelled), or `freeClientSlot` had | ||
| 1127 | /// no seat left. Naming only absence would print "no session 0" at a daemon | ||
| 1128 | /// that holds session 0 and is merely full — so the attach detail names | ||
| 1129 | /// both, and the query detail names only the one that can reach it. A name | ||
| 1130 | /// too long to fit still leaves a detail: losing the whole reply to a long | ||
| 1131 | /// argv is worse than losing the name from it. | ||
| 1132 | fn writeAttachRefusedError(writer: anytype, name: []const u8, ask: Refused) !void { | ||
| 1133 | const why, const need = switch (ask) { | ||
| 1134 | .attach => .{ | ||
| 1135 | "the daemon refused this attach: muxa joins at 0x0 and never creates, so ", | ||
| 1136 | " must already exist and the daemon must have room for one more client (max_clients)", | ||
| 1137 | }, | ||
| 1138 | .query => .{ | ||
| 1139 | "the daemon refused this query: muxa never creates a session, so ", | ||
| 1140 | " must already exist", | ||
| 1141 | }, | ||
| 1142 | }; | ||
| 1143 | try writer.writeAll("{\"error\":\"attach refused\",\"detail\":"); | ||
| 1144 | var buf: [768]u8 = undefined; | ||
| 1145 | // "" is the wire's default spelling, not a session with no name: a | ||
| 1146 | // detail reading `so must already exist` sends an agent looking for a | ||
| 1147 | // name it never typed. | ||
| 1148 | const shown = if (name.len == 0) "the default session" else name; | ||
| 1149 | const line = std.fmt.bufPrint(&buf, "{s}{s}{s}", .{ why, shown, need }) catch | ||
| 1150 | std.fmt.bufPrint(&buf, "{s}the session asked for{s}", .{ why, need }) catch why; | ||
| 1151 | try jsonEscape(writer, line); | ||
| 1152 | try writer.writeAll("}\n"); | ||
| 1153 | } | ||
| 1154 | |||
| 1155 | test "the session-ended failure keeps the error+detail shape every failure has" { | ||
| 1156 | var buf: [192]u8 = undefined; | ||
| 1157 | |||
| 1158 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 1159 | try writeSessionEndedError(fbs.writer(), 5); | ||
| 1160 | try std.testing.expectEqualStrings( | ||
| 1161 | "{\"error\":\"session ended\",\"detail\":\"shell exited with 5\",\"exit_code\":5}\n", | ||
| 1162 | fbs.getWritten(), | ||
| 1163 | ); | ||
| 1164 | |||
| 1165 | // No code is still a detail, never a missing key. | ||
| 1166 | var none = std.io.fixedBufferStream(&buf); | ||
| 1167 | try writeSessionEndedError(none.writer(), null); | ||
| 1168 | try std.testing.expect(std.mem.indexOf(u8, none.getWritten(), "\"detail\":\"shell exited without") != null); | ||
| 1169 | try std.testing.expect(std.mem.indexOf(u8, none.getWritten(), "\"exit_code\":null") != null); | ||
| 1170 | } | ||
| 1171 | |||
| 1172 | pub fn main() !u8 { | ||
| 1173 | var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); | ||
| 1174 | defer arena_state.deinit(); | ||
| 1175 | const alloc = arena_state.allocator(); | ||
| 1176 | |||
| 1177 | const args = try std.process.argsAlloc(alloc); | ||
| 1178 | const o = parseArgs(args) orelse { | ||
| 1179 | // Usage is diagnostic, so it goes to stderr: stdout stays strictly | ||
| 1180 | // one JSON object per invocation, even on the argument-error path. | ||
| 1181 | proto.writeAllFd(std.posix.STDERR_FILENO, usage) catch {}; | ||
| 1182 | return 2; | ||
| 1183 | }; | ||
| 1184 | |||
| 1185 | // Started BEFORE the connect, not after: over QUIC the handshake is | ||
| 1186 | // part of the round trip the caller bounded, and a `--timeout` that | ||
| 1187 | // began counting only once the connection was up would promise | ||
| 1188 | // something different on the two transports. Over a unix socket the | ||
| 1189 | // connect is a syscall, so this moves the instant by microseconds. | ||
| 1190 | const deadline = deadlineFor(o.timeout_ms); | ||
| 1191 | |||
| 1192 | if (o.quic) |host_port| { | ||
| 1193 | var conn = switch (openQuicConn(alloc, o, host_port, deadline)) { | ||
| 1194 | .conn => |c| c, | ||
| 1195 | .exit => |code| return code, | ||
| 1196 | }; | ||
| 1197 | defer conn.close(); | ||
| 1198 | return dispatch(alloc, &conn, o, deadline); | ||
| 1199 | } | ||
| 1200 | |||
| 1201 | const sock_path = if (o.sock) |s| s else sockpath.defaultSockPath(alloc) catch |err| switch (err) { | ||
| 1202 | // An agent reads replies, not stderr, so this one refuses through | ||
| 1203 | // the same JSON shape as every other muxa failure. | ||
| 1204 | error.NoRuntimeDir => return fail( | ||
| 1205 | "no default socket path", | ||
| 1206 | "XDG_RUNTIME_DIR is unset; name the socket with --sock", | ||
| 1207 | ), | ||
| 1208 | else => |e| return e, | ||
| 1209 | }; | ||
| 1210 | |||
| 1211 | // Refused by name, before connecting: connect would bounce a too-long | ||
| 1212 | // path off the kernel with a generic error, and the path is the whole | ||
| 1213 | // story. Every binary owes this check in its own words (sockpath). | ||
| 1214 | if (sock_path.len > sockpath.max_sun_path) { | ||
| 1215 | var buf: [64]u8 = undefined; | ||
| 1216 | const detail = std.fmt.bufPrint( | ||
| 1217 | &buf, | ||
| 1218 | "{d} bytes, max {d}", | ||
| 1219 | .{ sock_path.len, sockpath.max_sun_path }, | ||
| 1220 | ) catch "too long"; | ||
| 1221 | return fail("socket path too long", detail); | ||
| 1222 | } | ||
| 1223 | |||
| 1224 | var conn = Conn.open(alloc, sock_path) catch |e| { | ||
| 1225 | // The path goes in the detail: a muxa pointed at the wrong socket | ||
| 1226 | // is this binary's likeliest field failure, and an agent reading | ||
| 1227 | // "FileNotFound" alone cannot tell which path it was that missed. | ||
| 1228 | var buf: [256]u8 = undefined; | ||
| 1229 | const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ sock_path, @errorName(e) }) catch | ||
| 1230 | @errorName(e); | ||
| 1231 | return fail("cannot connect to the daemon", detail); | ||
| 1232 | }; | ||
| 1233 | defer conn.close(); | ||
| 1234 | |||
| 1235 | return dispatch(alloc, &conn, o, deadline); | ||
| 1236 | } | ||
| 1237 | |||
| 1238 | /// The verbs, once. Both transports arrive here with a Conn and nothing | ||
| 1239 | /// else that distinguishes them, which is the property `--quic` is selling. | ||
| 1240 | fn dispatch(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 { | ||
| 1241 | return switch (o.verb) { | ||
| 1242 | .status => verbStatus(alloc, conn, o.session, deadline), | ||
| 1243 | .capture => verbCapture(alloc, conn, o.vt, o.session, deadline), | ||
| 1244 | .send => verbSend(alloc, conn, o.arg, o.session, deadline), | ||
| 1245 | // The one thing `run` needs that `await` does not, checked here so | ||
| 1246 | // the shared pipeline below can read `cmdline == null` as "this is | ||
| 1247 | // an await" rather than as "a run that was spelled wrong". | ||
| 1248 | .run => if (o.arg) |cmdline| | ||
| 1249 | awaitVerb(alloc, conn, o, deadline, cmdline) | ||
| 1250 | else | ||
| 1251 | fail("run: needs CMDLINE", ""), | ||
| 1252 | .await => awaitVerb(alloc, conn, o, deadline, null), | ||
| 1253 | }; | ||
| 1254 | } | ||
| 1255 | |||
| 1256 | /// A QUIC transport, or the exit code standing in for the reason there is | ||
| 1257 | /// not one. Every refusal here goes through `fail`, so a dial that never | ||
| 1258 | /// happened prints the same one-JSON-object-on-stdout shape as a verb that | ||
| 1259 | /// ran — an agent parses one thing whatever went wrong. | ||
| 1260 | const Opened = union(enum) { conn: Conn, exit: u8 }; | ||
| 1261 | |||
| 1262 | fn openQuicConn( | ||
| 1263 | alloc: std.mem.Allocator, | ||
| 1264 | o: Opts, | ||
| 1265 | host_port: []const u8, | ||
| 1266 | deadline: i64, | ||
| 1267 | ) Opened { | ||
| 1268 | // `--key`, then `$MUX_KEY_FILE`, then the XDG default if it exists. | ||
| 1269 | // The order is not spelled here on purpose: xdg owns it, mux reads the | ||
| 1270 | // same two functions, and a copy that drifted is how two binaries end | ||
| 1271 | // up authenticating with different keys. (muxd's pickKey stands apart | ||
| 1272 | // deliberately: it takes the default only when the file exists.) | ||
| 1273 | const res = xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv("MUX_KEY_FILE"))) catch |e| | ||
| 1274 | return .{ .exit = fail("quic: cannot resolve a key path", @errorName(e)) }; | ||
| 1275 | const key_path = switch (res) { | ||
| 1276 | .given, .default => |p| p, | ||
| 1277 | // The path is the detail because it is the actionable half: the | ||
| 1278 | // agent (or the human reading its log) needs to know which file | ||
| 1279 | // `muxd keygen` was supposed to have written. | ||
| 1280 | .missing => |p| return .{ .exit = fail( | ||
| 1281 | "quic: no key: pass --key, set MUX_KEY_FILE, or run `muxd keygen`", | ||
| 1282 | p, | ||
| 1283 | ) }, | ||
| 1284 | }; | ||
| 1285 | const key = quic_client.Key.load(key_path) catch |e| { | ||
| 1286 | // The daemon's words for a key the daemon would also refuse — | ||
| 1287 | // including the group/other-readable refusal, which this binary | ||
| 1288 | // gets for free by loading the key the same way. | ||
| 1289 | var buf: [quic_client.key_refusal_len]u8 = undefined; | ||
| 1290 | return .{ .exit = fail("quic: unusable key", quic_client.keyRefusalBody(&buf, e, key_path)) }; | ||
| 1291 | }; | ||
| 1292 | const addr = quic.parseAddr(alloc, host_port) catch |e| { | ||
| 1293 | var buf: [512]u8 = undefined; | ||
| 1294 | const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ host_port, @errorName(e) }) catch | ||
| 1295 | @errorName(e); | ||
| 1296 | return .{ .exit = fail("quic: cannot read HOST:PORT", detail) }; | ||
| 1297 | }; | ||
| 1298 | const conn = Conn.openQuic(alloc, addr, key, quic_client.default_idle_ms, deadline) catch |e| { | ||
| 1299 | var buf: [512]u8 = undefined; | ||
| 1300 | const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ host_port, @errorName(e) }) catch | ||
| 1301 | @errorName(e); | ||
| 1302 | return .{ .exit = fail("cannot connect to the daemon", detail) }; | ||
| 1303 | }; | ||
| 1304 | return .{ .conn = conn }; | ||
| 1305 | } | ||
| 1306 | |||
| 1307 | fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, session: []const u8, deadline: i64) !u8 { | ||
| 1308 | // status_req's WHOLE payload is the name — this connection never | ||
| 1309 | // attaches (see attachZero's callers; `status` is not one of them), so | ||
| 1310 | // there is no slot for the daemon to fall back to and the tail is the | ||
| 1311 | // only word this ask gets to say. | ||
| 1312 | conn.sendFrame(.status_req, session, deadline) catch |e| | ||
| 1313 | return failSend(e, session, .query, "status", "send failed"); | ||
| 1314 | const frame = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) { | ||
| 1315 | error.AttachRefused => return failAttachRefused(session, .query), | ||
| 1316 | error.SessionExited => return failSessionEnded(conn.session_exit), | ||
| 1317 | else => return fail("status: no reply", @errorName(e)), | ||
| 1318 | }; | ||
| 1319 | defer frame.deinit(alloc); | ||
| 1320 | const st = proto.decodeStatusReply(frame.payload) catch |e| | ||
| 1321 | return fail("status: bad reply", @errorName(e)); | ||
| 1322 | |||
| 1323 | var out: std.ArrayList(u8) = .empty; | ||
| 1324 | defer out.deinit(alloc); | ||
| 1325 | try printStatus(out.writer(alloc), st); | ||
| 1326 | return emit(out.items, 0); | ||
| 1327 | } | ||
| 1328 | |||
| 1329 | /// A command that has not returned, or whose mechanism cannot know a | ||
| 1330 | /// code, has no exit code: 0 would read as "succeeded". | ||
| 1331 | fn writeExitCode(writer: anytype, code: ?u8) !void { | ||
| 1332 | if (code) |c| { | ||
| 1333 | try writer.print("{d}", .{c}); | ||
| 1334 | } else { | ||
| 1335 | try writer.writeAll("null"); | ||
| 1336 | } | ||
| 1337 | } | ||
| 1338 | |||
| 1339 | /// The five CmdState fields `status` and `await`/`run` both publish. | ||
| 1340 | /// Written as a bare fragment — no braces, no leading or trailing | ||
| 1341 | /// comma — because the two verbs nest it differently: `status` puts | ||
| 1342 | /// it inside a `"cmd"` object and follows it with the seq, while | ||
| 1343 | /// `await` inlines it at the top level and follows it with the | ||
| 1344 | /// duration. | ||
| 1345 | fn writeCmdFields(writer: anytype, st: proto.CmdState) !void { | ||
| 1346 | try writer.writeAll("\"phase\":"); | ||
| 1347 | try jsonEscape(writer, @tagName(st.phase)); | ||
| 1348 | try writer.writeAll(",\"mechanism\":"); | ||
| 1349 | try jsonEscape(writer, @tagName(st.mechanism)); | ||
| 1350 | try writer.writeAll(",\"exit_code\":"); | ||
| 1351 | try writeExitCode(writer, st.exit_code); | ||
| 1352 | try writer.print(",\"start_row\":{d},\"end_row\":{d}", .{ st.start_row, st.end_row }); | ||
| 1353 | } | ||
| 1354 | |||
| 1355 | fn printStatus(writer: anytype, st: proto.StatusReply) !void { | ||
| 1356 | try writer.print( | ||
| 1357 | "{{\"cols\":{d},\"rows\":{d},\"cursor\":{{\"x\":{d},\"y\":{d}}}," ++ | ||
| 1358 | "\"history_rows\":{d},\"alt_screen\":{},\"icanon\":{},\"echo\":{},\"cmd\":{{", | ||
| 1359 | .{ st.cols, st.rows, st.cursor_x, st.cursor_y, st.history_rows, st.alt_screen, st.mode.icanon, st.mode.echo }, | ||
| 1360 | ); | ||
| 1361 | try writeCmdFields(writer, st.cmd); | ||
| 1362 | // The watermark, and only `status` carries it: this is the number an | ||
| 1363 | // agent feeds back as `since_seq`, which is why `await` does not print | ||
| 1364 | // one (see proto.CmdState.seq). | ||
| 1365 | try writer.print(",\"seq\":{d}}}}}\n", .{st.cmd.seq}); | ||
| 1366 | } | ||
| 1367 | |||
| 1368 | test "printStatus spells a pending exit code as JSON null" { | ||
| 1369 | var buf: [512]u8 = undefined; | ||
| 1370 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 1371 | try printStatus(fbs.writer(), .{ | ||
| 1372 | .cols = 80, | ||
| 1373 | .rows = 24, | ||
| 1374 | .cursor_x = 1, | ||
| 1375 | .cursor_y = 2, | ||
| 1376 | .history_rows = 7, | ||
| 1377 | .alt_screen = false, | ||
| 1378 | .mode = .{ .icanon = true, .echo = true }, | ||
| 1379 | .cmd = .{ .phase = .running, .mechanism = .marks, .exit_code = null, .start_row = 3, .end_row = 4, .seq = 9 }, | ||
| 1380 | }); | ||
| 1381 | // The whole object, byte for byte, not a handful of substrings: this is | ||
| 1382 | // muxa's published contract with an agent's JSON parser, and the fields | ||
| 1383 | // it shares with `await` are written by a helper both verbs call — a | ||
| 1384 | // pin on the parts cannot see a comma or a nesting level move. | ||
| 1385 | try std.testing.expectEqualStrings( | ||
| 1386 | "{\"cols\":80,\"rows\":24,\"cursor\":{\"x\":1,\"y\":2},\"history_rows\":7," ++ | ||
| 1387 | "\"alt_screen\":false,\"icanon\":true,\"echo\":true," ++ | ||
| 1388 | "\"cmd\":{\"phase\":\"running\",\"mechanism\":\"marks\",\"exit_code\":null," ++ | ||
| 1389 | "\"start_row\":3,\"end_row\":4,\"seq\":9}}\n", | ||
| 1390 | fbs.getWritten(), | ||
| 1391 | ); | ||
| 1392 | } | ||
| 1393 | |||
| 1394 | fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, session: []const u8, deadline: i64) !u8 { | ||
| 1395 | // vt byte ++ session-name tail, the same shape muxd's own `dump` sends | ||
| 1396 | // — and built by the same encoder, so it cannot drift from it. | ||
| 1397 | var buf: [proto.debug_dump_max_len]u8 = undefined; | ||
| 1398 | const payload = proto.encodeDebugDumpNamed(&buf, vt, session); | ||
| 1399 | conn.sendFrame(.debug_dump, payload, deadline) catch |e| | ||
| 1400 | return failSend(e, session, .query, "capture", "send failed"); | ||
| 1401 | const frame = conn.awaitFrame(.dump_reply, deadline) catch |e| switch (e) { | ||
| 1402 | error.AttachRefused => return failAttachRefused(session, .query), | ||
| 1403 | error.SessionExited => return failSessionEnded(conn.session_exit), | ||
| 1404 | else => return fail("capture: no reply", @errorName(e)), | ||
| 1405 | }; | ||
| 1406 | defer frame.deinit(alloc); | ||
| 1407 | |||
| 1408 | var out: std.ArrayList(u8) = .empty; | ||
| 1409 | defer out.deinit(alloc); | ||
| 1410 | const writer = out.writer(alloc); | ||
| 1411 | try writer.writeAll("{\"grid\":"); | ||
| 1412 | try jsonEscape(writer, frame.payload); | ||
| 1413 | try writer.writeAll("}\n"); | ||
| 1414 | return emit(out.items, 0); | ||
| 1415 | } | ||
| 1416 | |||
| 1417 | /// Join claiming NO grid: applySize refuses under 2, so the 0x0 slot | ||
| 1418 | /// makes no claim and no human's terminal is resized. `name` is | ||
| 1419 | /// joins-only: a 0x0 attach cannot create, resolveSession demands a | ||
| 1420 | /// real size. | ||
| 1421 | fn attachZero(conn: *Conn, name: []const u8, deadline: i64) !void { | ||
| 1422 | // Cleared here and nowhere else: the reconnect path attaches a SECOND | ||
| 1423 | // time on a connection that has already seen a snapshot, and a stale | ||
| 1424 | // true would read that reconnect's refusal as the session ending. | ||
| 1425 | conn.saw_snapshot = false; | ||
| 1426 | var buf: [proto.attach_max_len]u8 = undefined; | ||
| 1427 | try conn.sendFrame(.attach, proto.encodeAttachNamed(&buf, 0, 0, 0, 0, name), deadline); | ||
| 1428 | } | ||
| 1429 | |||
| 1430 | fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: ?[]const u8, session: []const u8, deadline: i64) !u8 { | ||
| 1431 | const spec = arg orelse return fail("send: needs BYTES", ""); | ||
| 1432 | const bytes = decodeEscapes(alloc, spec) catch |e| return fail("send: bad escape", @errorName(e)); | ||
| 1433 | defer alloc.free(bytes); | ||
| 1434 | |||
| 1435 | attachZero(conn, session, deadline) catch |e| | ||
| 1436 | return failSend(e, session, .attach, "send", "attach failed"); | ||
| 1437 | conn.sendFrame(.input, bytes, deadline) catch |e| | ||
| 1438 | return failSend(e, session, .attach, "send", "input failed"); | ||
| 1439 | |||
| 1440 | // Write-and-close LOSES the input, and not as a rare race: attaching | ||
| 1441 | // queues a snapshot, and the daemon flushes a client's pending bytes | ||
| 1442 | // BEFORE it reads that client (server.zig's poll arm). Closing straight | ||
| 1443 | // after the write means the flush hits EPIPE, the daemon drops us, and | ||
| 1444 | // the input frame is discarded still unread. Measured: closing at once | ||
| 1445 | // never lands, while any delay or drain always does. | ||
| 1446 | // | ||
| 1447 | // So the round trip is the acknowledgement. Frames are served in stream | ||
| 1448 | // order, so a status_reply is proof the daemon has already read PAST the | ||
| 1449 | // input frame and fed it to the pty; awaitFrame skips the snapshot and | ||
| 1450 | // the pushes on the way, which is what keeps the socket drained enough | ||
| 1451 | // for that flush to succeed. Nothing is done with the reply — its | ||
| 1452 | // arrival is the whole content. | ||
| 1453 | // | ||
| 1454 | // The same `session` as the attach above, not "" — the daemon's | ||
| 1455 | // attached-tail rule (server.zig) answers only a tail that names the | ||
| 1456 | // slot's own session, and this connection attached to `session`. | ||
| 1457 | conn.sendFrame(.status_req, session, deadline) catch |e| | ||
| 1458 | return failSend(e, session, .attach, "send", "ack request failed"); | ||
| 1459 | const ack = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) { | ||
| 1460 | // The bytes we sent ended the session (`exit\n`). Reported as the | ||
| 1461 | // session's death rather than as "sent", because this verb's answer | ||
| 1462 | // is about the send and there is no longer a session to have sent | ||
| 1463 | // to — an agent that wants the death to be an ANSWER runs `run`. | ||
| 1464 | error.AttachRefused => return failAttachRefused(session, .attach), | ||
| 1465 | error.SessionExited => return failSessionEnded(conn.session_exit), | ||
| 1466 | else => return fail("send: daemon never acknowledged the input", @errorName(e)), | ||
| 1467 | }; | ||
| 1468 | ack.deinit(alloc); | ||
| 1469 | |||
| 1470 | conn.sendFrame(.detach, "", deadline) catch |e| return fail("send: detach failed", @errorName(e)); | ||
| 1471 | |||
| 1472 | return emit("{\"sent\":true}\n", 0); | ||
| 1473 | } | ||
| 1474 | |||
| 1475 | /// How much longer than the daemon this client is willing to wait. | ||
| 1476 | /// | ||
| 1477 | /// Load-bearing: the daemon starts its own `timeout_ms` window when it | ||
| 1478 | /// READS the await_req, which is already later than the instant this | ||
| 1479 | /// process started counting. Waiting exactly `timeout_ms` here would lose | ||
| 1480 | /// that race every single time, and every timeout would surface as | ||
| 1481 | /// `{"error":"await: no reply"}` instead of the structured | ||
| 1482 | /// `{"reason":"timeout"}` with exit 3 that the agent is meant to read. | ||
| 1483 | const await_grace_ms = 2_000; | ||
| 1484 | |||
| 1485 | /// The ceiling on the QUIC arm's derived grace (Conn.graceMs), and the | ||
| 1486 | /// reason it has one is that `connect_ms` has no bound of its own worth | ||
| 1487 | /// multiplying by four. | ||
| 1488 | const grace_cap_ms = 30_000; | ||
| 1489 | |||
| 1490 | /// How long `sendFrameQuic` will keep offering a frame's tail to a full | ||
| 1491 | /// egress ring before giving up on it. Reaching this means the peer has | ||
| 1492 | /// stopped acknowledging 256KB of backlog, which is a dead connection | ||
| 1493 | /// wearing a different hat — but a bound is what keeps it from being an | ||
| 1494 | /// unbounded wait inside a call that has no deadline of its own. | ||
| 1495 | const send_flush_ms = 5_000; | ||
| 1496 | |||
| 1497 | /// How long a write that died of a closed peer will look for the refusal | ||
| 1498 | /// the peer left behind. The socket is already closed, so the drain ends at | ||
| 1499 | /// EOF long before this — the bound is for the half-closed case, where | ||
| 1500 | /// nothing else would end the wait. | ||
| 1501 | const refusal_drain_ms = 100; | ||
| 1502 | |||
| 1503 | /// The span fetch gets its own window rather than the tail of the run's: a | ||
| 1504 | /// command that returned in the last millisecond of `--timeout` still has a | ||
| 1505 | /// transcript worth having, and this round trip is a local read that either | ||
| 1506 | /// answers promptly or is not coming. | ||
| 1507 | const span_fetch_ms = 2_000; | ||
| 1508 | |||
| 1509 | /// No run deadline: `--timeout 0` would make the fetch unbounded. | ||
| 1510 | fn spanFetchDeadline() i64 { | ||
| 1511 | return deadlineFor(span_fetch_ms); | ||
| 1512 | } | ||
| 1513 | |||
| 1514 | test "the span fetch is bounded even when the run it follows was not" { | ||
| 1515 | // `--timeout 0` is the case that matters: the run's deadline is then | ||
| 1516 | // "never", and a fetch that inherited it would outlive the answer. | ||
| 1517 | try std.testing.expectEqual(std.math.maxInt(i64), deadlineFor(0)); | ||
| 1518 | |||
| 1519 | const before = std.time.milliTimestamp(); | ||
| 1520 | const span = spanFetchDeadline(); | ||
| 1521 | try std.testing.expect(span >= before); | ||
| 1522 | try std.testing.expect(span <= std.time.milliTimestamp() + span_fetch_ms); | ||
| 1523 | } | ||
| 1524 | |||
| 1525 | /// Ask to be told when the session next comes to rest, and wait for it. | ||
| 1526 | fn doAwait( | ||
| 1527 | alloc: std.mem.Allocator, | ||
| 1528 | conn: *Conn, | ||
| 1529 | o: Opts, | ||
| 1530 | since_seq: u64, | ||
| 1531 | deadline: i64, | ||
| 1532 | ) !proto.AwaitReply { | ||
| 1533 | var buf: [proto.await_req_max_len]u8 = undefined; | ||
| 1534 | const payload = proto.encodeAwaitReqNamed(&buf, .{ | ||
| 1535 | .since_seq = since_seq, | ||
| 1536 | .settle_ms = o.settle_ms, | ||
| 1537 | .timeout_ms = o.timeout_ms, | ||
| 1538 | }, o.session); | ||
| 1539 | try conn.sendFrame(.await_req, payload, deadline); | ||
| 1540 | const frame = try conn.awaitFrame(.await_reply, deadline); | ||
| 1541 | defer frame.deinit(alloc); | ||
| 1542 | return try proto.decodeAwaitReply(frame.payload); | ||
| 1543 | } | ||
| 1544 | |||
| 1545 | /// At-most-once: the re-issue re-sends the attach and the request, | ||
| 1546 | /// never `run`'s input. | ||
| 1547 | fn awaitReissuing( | ||
| 1548 | alloc: std.mem.Allocator, | ||
| 1549 | conn: *Conn, | ||
| 1550 | o: Opts, | ||
| 1551 | since_seq: u64, | ||
| 1552 | deadline: i64, | ||
| 1553 | ) !proto.AwaitReply { | ||
| 1554 | return doAwait(alloc, conn, o, since_seq, deadline) catch |e| switch (e) { | ||
| 1555 | // `SendStalled` never redials: the peer is still there, it has | ||
| 1556 | // just stopped acking, so a redial would be a second guess about | ||
| 1557 | // a connection that never said it was gone. | ||
| 1558 | error.ConnectionLost => { | ||
| 1559 | // Once per process, not per await: a loop here is a client | ||
| 1560 | // that hides a daemon that is gone. | ||
| 1561 | if (conn.link != .quic or conn.link.quic.reconnected) return e; | ||
| 1562 | // The deadline continues across the redial — four seconds | ||
| 1563 | // spent redialling are four seconds of the caller's wait, not | ||
| 1564 | // a fresh bound. | ||
| 1565 | conn.reconnect(deadline) catch |redial| { | ||
| 1566 | conn.reconnect_failure = @errorName(redial); | ||
| 1567 | return e; | ||
| 1568 | }; | ||
| 1569 | // The attach is part of the reconnect, not a separate step: a | ||
| 1570 | // daemon that lost our connection lost the client slot with | ||
| 1571 | // it, so an await_req arriving unattached asks about nothing. | ||
| 1572 | // A failure here is still the reconnect failing. | ||
| 1573 | attachZero(conn, o.session, deadline) catch |reattach| { | ||
| 1574 | // A refused re-attach is the daemon's answer, not the tear | ||
| 1575 | // that got us here: `ConnectionLost` would send an agent | ||
| 1576 | // to check the network for a session that is gone. | ||
| 1577 | if (reattach == error.AttachRefused) return reattach; | ||
| 1578 | conn.reconnect_failure = @errorName(reattach); | ||
| 1579 | return e; | ||
| 1580 | }; | ||
| 1581 | // The SAME `since_seq`, re-read from nothing: the request is a | ||
| 1582 | // question about a watermark, so re-asking it is idempotent, | ||
| 1583 | // while a watermark taken from the new connection would sit | ||
| 1584 | // past a return that happened while we were disconnected. | ||
| 1585 | return doAwait(alloc, conn, o, since_seq, deadline); | ||
| 1586 | }, | ||
| 1587 | else => e, | ||
| 1588 | }; | ||
| 1589 | } | ||
| 1590 | |||
| 1591 | /// What a wait that ended without a reply says past the verb's own | ||
| 1592 | /// "no reply". Every error but one is its own name — the socket | ||
| 1593 | /// arm's failures are untouched — because `ConnectionLost` is the | ||
| 1594 | /// only one whose name is half the story. | ||
| 1595 | /// | ||
| 1596 | /// The three endings a lost connection has, and they are worth | ||
| 1597 | /// telling apart: the redial failed (why), the redial had already | ||
| 1598 | /// been spent (so this is the second tear of the same wait), or | ||
| 1599 | /// nothing tried to redial. | ||
| 1600 | fn waitFailDetail(buf: []u8, conn: *const Conn, e: anyerror) []const u8 { | ||
| 1601 | if (e != error.ConnectionLost) return @errorName(e); | ||
| 1602 | if (conn.reconnect_failure) |why| { | ||
| 1603 | return std.fmt.bufPrint(buf, "connection lost; reconnect failed: {s}", .{why}) catch | ||
| 1604 | "connection lost; reconnect failed"; | ||
| 1605 | } | ||
| 1606 | const spent = switch (conn.link) { | ||
| 1607 | .quic => |q| q.reconnected, | ||
| 1608 | .fd => false, | ||
| 1609 | }; | ||
| 1610 | if (spent) return "connection lost again, after the one reconnect"; | ||
| 1611 | return "connection lost"; | ||
| 1612 | } | ||
| 1613 | |||
| 1614 | /// The session's RETURN WATERMARK: the seq of the last command return, 0 if | ||
| 1615 | /// none. Handed straight to `since_seq`, where it means "only a return | ||
| 1616 | /// newer than this may answer me". | ||
| 1617 | fn currentSeq(alloc: std.mem.Allocator, conn: *Conn, session: []const u8, deadline: i64) !u64 { | ||
| 1618 | // Same session as the attach that precedes this call — the attached- | ||
| 1619 | // tail equality rule (server.zig) demands it. | ||
| 1620 | try conn.sendFrame(.status_req, session, deadline); | ||
| 1621 | const frame = try conn.awaitFrame(.status_reply, deadline); | ||
| 1622 | defer frame.deinit(alloc); | ||
| 1623 | const s = try proto.decodeStatusReply(frame.payload); | ||
| 1624 | return s.cmd.seq; | ||
| 1625 | } | ||
| 1626 | |||
| 1627 | /// Strip the styling out of scrollback rows: an agent reading `output` | ||
| 1628 | /// wants what the command printed, not how it was coloured. | ||
| 1629 | /// | ||
| 1630 | /// CSI (ESC [ … final byte) and OSC (ESC ] … BEL or ST) go, as does any | ||
| 1631 | /// other two-byte escape; text and newlines stay. Deliberately not a VT | ||
| 1632 | /// parser — these rows come from our own formatter, which emits SGR and | ||
| 1633 | /// nothing more exotic. Caller frees. | ||
| 1634 | fn stripSgr(alloc: std.mem.Allocator, s: []const u8) ![]u8 { | ||
| 1635 | var out: std.ArrayList(u8) = .empty; | ||
| 1636 | errdefer out.deinit(alloc); | ||
| 1637 | var i: usize = 0; | ||
| 1638 | while (i < s.len) { | ||
| 1639 | if (s[i] != 0x1b or i + 1 >= s.len) { | ||
| 1640 | try out.append(alloc, s[i]); | ||
| 1641 | i += 1; | ||
| 1642 | continue; | ||
| 1643 | } | ||
| 1644 | switch (s[i + 1]) { | ||
| 1645 | '[' => { | ||
| 1646 | i += 2; | ||
| 1647 | // Parameter and intermediate bytes, then one final byte in | ||
| 1648 | // 0x40..0x7e that ends the sequence. | ||
| 1649 | while (i < s.len and (s[i] < 0x40 or s[i] > 0x7e)) i += 1; | ||
| 1650 | if (i < s.len) i += 1; | ||
| 1651 | }, | ||
| 1652 | ']' => { | ||
| 1653 | i += 2; | ||
| 1654 | while (i < s.len) : (i += 1) { | ||
| 1655 | if (s[i] == 0x07) { | ||
| 1656 | i += 1; | ||
| 1657 | break; | ||
| 1658 | } | ||
| 1659 | if (s[i] == 0x1b and i + 1 < s.len and s[i + 1] == '\\') { | ||
| 1660 | i += 2; | ||
| 1661 | break; | ||
| 1662 | } | ||
| 1663 | } | ||
| 1664 | }, | ||
| 1665 | // ESC 7, ESC M and friends: two bytes, both dropped. | ||
| 1666 | else => i += 2, | ||
| 1667 | } | ||
| 1668 | } | ||
| 1669 | return out.toOwnedSlice(alloc); | ||
| 1670 | } | ||
| 1671 | |||
| 1672 | test "stripSgr leaves text, drops SGR and OSC" { | ||
| 1673 | const alloc = std.testing.allocator; | ||
| 1674 | const got = try stripSgr(alloc, "\x1b[0m\x1b[1;31mred\x1b[0m ok\n\x1b]0;title\x07plain"); | ||
| 1675 | defer alloc.free(got); | ||
| 1676 | try std.testing.expectEqualStrings("red ok\nplain", got); | ||
| 1677 | } | ||
| 1678 | |||
| 1679 | /// Rows go stale between reply and fetch, so failure here is a null | ||
| 1680 | /// output, not a failed run. | ||
| 1681 | fn fetchSpan( | ||
| 1682 | alloc: std.mem.Allocator, | ||
| 1683 | conn: *Conn, | ||
| 1684 | start_row: u32, | ||
| 1685 | end_row: u32, | ||
| 1686 | deadline: i64, | ||
| 1687 | ) !?[]u8 { | ||
| 1688 | if (end_row <= start_row) return null; | ||
| 1689 | const count: u16 = @intCast(@min(end_row - start_row, std.math.maxInt(u16))); | ||
| 1690 | try conn.sendFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start_row, count), deadline); | ||
| 1691 | const frame = try conn.awaitFrame(.scrollback_chunk, deadline); | ||
| 1692 | defer frame.deinit(alloc); | ||
| 1693 | // The chunk leads with the request it answers; the rows follow. | ||
| 1694 | if (frame.payload.len <= 6) return null; | ||
| 1695 | return try stripSgr(alloc, frame.payload[6..]); | ||
| 1696 | } | ||
| 1697 | |||
| 1698 | /// `output` is absent when there is no transcript: an absent key and | ||
| 1699 | /// an empty string differ. | ||
| 1700 | fn printAwaitReply( | ||
| 1701 | writer: anytype, | ||
| 1702 | r: proto.AwaitReply, | ||
| 1703 | output: ?[]const u8, | ||
| 1704 | duration_ms: i64, | ||
| 1705 | ) !void { | ||
| 1706 | try writer.writeAll("{\"reason\":"); | ||
| 1707 | try jsonEscape(writer, @tagName(r.reason)); | ||
| 1708 | try writer.writeAll(","); | ||
| 1709 | try writeCmdFields(writer, r.state); | ||
| 1710 | try writer.print(",\"duration_ms\":{d}", .{duration_ms}); | ||
| 1711 | if (output) |text| { | ||
| 1712 | try writer.writeAll(",\"output\":"); | ||
| 1713 | try jsonEscape(writer, text); | ||
| 1714 | } | ||
| 1715 | try writer.writeAll("}\n"); | ||
| 1716 | } | ||
| 1717 | |||
| 1718 | test "printAwaitReply omits output when there is none and spells a missing code null" { | ||
| 1719 | const alloc = std.testing.allocator; | ||
| 1720 | const r: proto.AwaitReply = .{ | ||
| 1721 | .state = .{ | ||
| 1722 | .phase = .returned, | ||
| 1723 | .mechanism = .settle, | ||
| 1724 | .exit_code = null, | ||
| 1725 | .start_row = 3, | ||
| 1726 | .end_row = 9, | ||
| 1727 | .seq = 12, | ||
| 1728 | }, | ||
| 1729 | .reason = .settled, | ||
| 1730 | }; | ||
| 1731 | |||
| 1732 | var bare: std.ArrayList(u8) = .empty; | ||
| 1733 | defer bare.deinit(alloc); | ||
| 1734 | try printAwaitReply(bare.writer(alloc), r, null, 250); | ||
| 1735 | try std.testing.expectEqualStrings( | ||
| 1736 | "{\"reason\":\"settled\",\"phase\":\"returned\",\"mechanism\":\"settle\"," ++ | ||
| 1737 | "\"exit_code\":null,\"start_row\":3,\"end_row\":9,\"duration_ms\":250}\n", | ||
| 1738 | bare.items, | ||
| 1739 | ); | ||
| 1740 | |||
| 1741 | var with: std.ArrayList(u8) = .empty; | ||
| 1742 | defer with.deinit(alloc); | ||
| 1743 | try printAwaitReply(with.writer(alloc), r, "a\nb", 250); | ||
| 1744 | try std.testing.expect(std.mem.indexOf(u8, with.items, "\"output\":\"a\\nb\"") != null); | ||
| 1745 | } | ||
| 1746 | |||
| 1747 | /// The session ran its last command. An ANSWER for `run` and `await` — the | ||
| 1748 | /// command is over and this is how — so it prints on stdout and exits 0, | ||
| 1749 | /// unlike the other verbs, which have nothing to report and fail. | ||
| 1750 | fn printSessionEnded(writer: anytype, code: ?u8, duration_ms: i64) !void { | ||
| 1751 | try writer.writeAll("{\"reason\":\"session_ended\",\"exit_code\":"); | ||
| 1752 | try writeExitCode(writer, code); | ||
| 1753 | try writer.print(",\"duration_ms\":{d}}}\n", .{duration_ms}); | ||
| 1754 | } | ||
| 1755 | |||
| 1756 | /// Timeout is the only nonzero code: a command returning nonzero | ||
| 1757 | /// failed in `exit_code`, not here. | ||
| 1758 | fn reportAwait( | ||
| 1759 | alloc: std.mem.Allocator, | ||
| 1760 | r: proto.AwaitReply, | ||
| 1761 | output: ?[]const u8, | ||
| 1762 | duration_ms: i64, | ||
| 1763 | ) !u8 { | ||
| 1764 | var out: std.ArrayList(u8) = .empty; | ||
| 1765 | defer out.deinit(alloc); | ||
| 1766 | try printAwaitReply(out.writer(alloc), r, output, duration_ms); | ||
| 1767 | return emit(out.items, if (r.reason == .timeout) 3 else 0); | ||
| 1768 | } | ||
| 1769 | |||
| 1770 | fn reportSessionEnded(alloc: std.mem.Allocator, code: ?u8, duration_ms: i64) !u8 { | ||
| 1771 | var out: std.ArrayList(u8) = .empty; | ||
| 1772 | defer out.deinit(alloc); | ||
| 1773 | try printSessionEnded(out.writer(alloc), code, duration_ms); | ||
| 1774 | return emit(out.items, 0); | ||
| 1775 | } | ||
| 1776 | |||
| 1777 | /// `run` is `await` with a command line put in: `cmdline` non-null is | ||
| 1778 | /// the whole difference. | ||
| 1779 | fn awaitVerb( | ||
| 1780 | alloc: std.mem.Allocator, | ||
| 1781 | conn: *Conn, | ||
| 1782 | o: Opts, | ||
| 1783 | deadline: i64, | ||
| 1784 | cmdline: ?[]const u8, | ||
| 1785 | ) !u8 { | ||
| 1786 | // Every error string this function can print names the verb the user | ||
| 1787 | // typed, because "attach failed" from the wrong verb sends an agent | ||
| 1788 | // looking in the wrong place. | ||
| 1789 | const who = if (cmdline == null) "await" else "run"; | ||
| 1790 | const started = std.time.milliTimestamp(); | ||
| 1791 | |||
| 1792 | attachZero(conn, o.session, deadline) catch |e| | ||
| 1793 | return failSend(e, o.session, .attach, who, "attach failed"); | ||
| 1794 | |||
| 1795 | // BEFORE the input, not after: the watermark has to be the one this | ||
| 1796 | // command must beat. Read afterwards, a command fast enough to return | ||
| 1797 | // between the two would have already moved the seq past a value we | ||
| 1798 | // never recorded, and the await would sit waiting for a return that | ||
| 1799 | // had happened. | ||
| 1800 | const since = currentSeq(alloc, conn, o.session, deadline) catch |e| switch (e) { | ||
| 1801 | error.AttachRefused => return failAttachRefused(o.session, .attach), | ||
| 1802 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), | ||
| 1803 | else => return failAs(who, "status failed", @errorName(e)), | ||
| 1804 | }; | ||
| 1805 | |||
| 1806 | if (cmdline) |cmd| { | ||
| 1807 | // The cmdline goes to the pty verbatim — escapes are `send`'s | ||
| 1808 | // business — plus the newline that submits it. No ack round-trip is | ||
| 1809 | // needed the way `send` needs one: the await_req that follows is | ||
| 1810 | // itself the read that proves the daemon got past this frame, and | ||
| 1811 | // this process stays connected until the reply lands. | ||
| 1812 | const line = std.fmt.allocPrint(alloc, "{s}\n", .{cmd}) catch |e| | ||
| 1813 | return failAs(who, "cannot build the command line", @errorName(e)); | ||
| 1814 | defer alloc.free(line); | ||
| 1815 | conn.sendFrame(.input, line, deadline) catch |e| | ||
| 1816 | return failSend(e, o.session, .attach, who, "input failed"); | ||
| 1817 | } | ||
| 1818 | |||
| 1819 | const r = awaitReissuing(alloc, conn, o, since, awaitDeadline(o, conn)) catch |e| switch (e) { | ||
| 1820 | error.AttachRefused => return failAttachRefused(o.session, .attach), | ||
| 1821 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), | ||
| 1822 | else => { | ||
| 1823 | var detail: [128]u8 = undefined; | ||
| 1824 | return failAs(who, "no reply", waitFailDetail(&detail, conn, e)); | ||
| 1825 | }, | ||
| 1826 | }; | ||
| 1827 | |||
| 1828 | // Only the marks regime knows where the command's rows are; pgid and | ||
| 1829 | // settle answer WHEN, never WHERE, and a span from them would be a | ||
| 1830 | // guess dressed as a transcript. `await` never fetches one at all: it | ||
| 1831 | // did not start the command, so the span it would name is not its own. | ||
| 1832 | var output: ?[]u8 = null; | ||
| 1833 | defer if (output) |text| alloc.free(text); | ||
| 1834 | if (cmdline != null and r.state.mechanism == .marks and r.reason == .returned) { | ||
| 1835 | output = fetchSpan( | ||
| 1836 | alloc, | ||
| 1837 | conn, | ||
| 1838 | r.state.start_row, | ||
| 1839 | r.state.end_row, | ||
| 1840 | spanFetchDeadline(), | ||
| 1841 | ) catch null; | ||
| 1842 | } | ||
| 1843 | |||
| 1844 | return reportAwait(alloc, r, output, elapsed(started)); | ||
| 1845 | } | ||
| 1846 | |||
| 1847 | fn elapsed(started: i64) i64 { | ||
| 1848 | return std.time.milliTimestamp() - started; | ||
| 1849 | } | ||
| 1850 | |||
| 1851 | /// This client's deadline: the daemon's own bound plus the grace | ||
| 1852 | /// window (await_grace_ms, widened per transport by `Conn.graceMs`). | ||
| 1853 | /// An unbounded request stays unbounded. | ||
| 1854 | fn awaitDeadline(o: Opts, conn: *const Conn) i64 { | ||
| 1855 | if (o.timeout_ms == 0) return std.math.maxInt(i64); | ||
| 1856 | return std.time.milliTimestamp() + o.timeout_ms + conn.graceMs(); | ||
| 1857 | } | ||
| 1858 | |||
| 1859 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 1860 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 1861 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 1862 | test { | ||
| 1863 | std.testing.refAllDeclsRecursive(@This()); | ||
| 1864 | } | ||
src/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,440 +0,0 @@ | |||
| 1 | //! muxweb — the hub binary. `muxweb [TARGET ...] | ||
| 2 | //! [--port N]`: serves the wall page on 127.0.0.1 and pumps one | ||
| 3 | //! WebSocket per tile, dialing each TARGET the way the mux CLI does. | ||
| 4 | //! TARGET spellings are mux's own: bare HOST (ssh→QUIC handoff), | ||
| 5 | //! --sock PATH, quic://HOST[:PORT] (with --key / MUX_KEY_FILE as in | ||
| 6 | //! mux). A `#NAME` suffix on a TARGET names the daemon session that tile | ||
| 7 | //! attaches to, which is how one host becomes two tiles. The TARGET string | ||
| 8 | //! is the tile's label, suffix and all. | ||
| 9 | //! | ||
| 10 | //! The wall is now a persisted list the page edits at runtime, so argv is | ||
| 11 | //! an override of the VIEW only: with targets, argv is what this run shows | ||
| 12 | //! and is ADDED to the wall (deduped, nothing removed); without, the last | ||
| 13 | //! run's whole wall is restored and served. The standing | ||
| 14 | //! non-goal (no config file) survives — the state file is written by the | ||
| 15 | //! program, never by hand. | ||
| 16 | |||
| 17 | const std = @import("std"); | ||
| 18 | const client = @import("client"); | ||
| 19 | const webhub = @import("webhub"); | ||
| 20 | const wall = @import("wall"); | ||
| 21 | const build_options = @import("build_options"); | ||
| 22 | const xdg = @import("xdg"); | ||
| 23 | const sockpath = @import("sockpath"); | ||
| 24 | |||
| 25 | const usage = | ||
| 26 | \\usage: muxweb [TARGET[#SESSION] ...] [--port N] | ||
| 27 | \\ each TARGET is a tile: HOST | --sock PATH | quic://HOST[:PORT] | ||
| 28 | \\ `--sock PATH` may be two arguments or one quoted '--sock PATH', the | ||
| 29 | \\ spelling the wall file holds; `mux wall` takes both too | ||
| 30 | \\ with no TARGET the wall from the last run is restored; with TARGETs | ||
| 31 | \\ argv is added to the saved wall (deduped) and shown; nothing already | ||
| 32 | \\ there is removed | ||
| 33 | \\ #SESSION names the daemon session the tile attaches to (default: the | ||
| 34 | \\ default session) — the same host twice, two sessions, two tiles | ||
| 35 | \\ quic:// tiles use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key | ||
| 36 | \\ [--quic-idle-ms N] tunes how fast a dead link is noticed | ||
| 37 | \\ --port N serves on 127.0.0.1:N (default 7681); localhost only, | ||
| 38 | \\ remote viewing is `ssh -L` | ||
| 39 | \\ --version prints the version | ||
| 40 | \\ | ||
| 41 | ; | ||
| 42 | |||
| 43 | /// Refused at usage altitude; downstream it is a rejected attach in | ||
| 44 | /// one tile, unexplained. | ||
| 45 | fn addSpelling( | ||
| 46 | alloc: std.mem.Allocator, | ||
| 47 | list: *std.ArrayList([]const u8), | ||
| 48 | spelling: []const u8, | ||
| 49 | ) ParseError!void { | ||
| 50 | // The ONE grammar: argv, the state file and POST /tiles are all read | ||
| 51 | // with this, so what argv accepts is exactly what the page can add. | ||
| 52 | // The message names the tile — with several targets on the line, | ||
| 53 | // `usage` alone would not say which. | ||
| 54 | _ = wall.parseSpelling(spelling) catch |err| { | ||
| 55 | std.debug.print("muxweb: tile {s}: {s}\n", .{ spelling, switch (err) { | ||
| 56 | error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", | ||
| 57 | error.EmptySpec => "empty target", | ||
| 58 | error.BadByte => "control byte in target", | ||
| 59 | } }); | ||
| 60 | return error.Usage; | ||
| 61 | }; | ||
| 62 | const copy = try alloc.dupe(u8, spelling); | ||
| 63 | errdefer alloc.free(copy); | ||
| 64 | try list.append(alloc, copy); | ||
| 65 | } | ||
| 66 | |||
| 67 | const Parsed = struct { | ||
| 68 | /// One wall spelling per tile, in argv order — the same string that | ||
| 69 | /// reaches the state file, the resolver and the page's label. Owned | ||
| 70 | /// uniformly rather than half-borrowed from argv, because `--sock PATH` | ||
| 71 | /// has to synthesize its `--sock ` prefix and one ownership rule beats | ||
| 72 | /// two. That prefix is now part of a sock tile's label: the label IS | ||
| 73 | /// the spelling. | ||
| 74 | tiles: std.ArrayList([]const u8), | ||
| 75 | port: u16 = webhub.default_port, | ||
| 76 | key: ?[]const u8 = null, | ||
| 77 | idle_ms: u32 = client.quic_idle_ms_default, | ||
| 78 | |||
| 79 | fn deinit(self: *Parsed, alloc: std.mem.Allocator) void { | ||
| 80 | for (self.tiles.items) |t| alloc.free(t); | ||
| 81 | self.tiles.deinit(alloc); | ||
| 82 | } | ||
| 83 | }; | ||
| 84 | |||
| 85 | const ParseResult = union(enum) { | ||
| 86 | serve: Parsed, | ||
| 87 | version, | ||
| 88 | }; | ||
| 89 | |||
| 90 | /// A usage mistake is an ERROR, not a third result: it is the one outcome | ||
| 91 | /// with nothing to hand back, and saying so lets the single errdefer own | ||
| 92 | /// the tile list on every refusing path. Spelling it as a value meant a | ||
| 93 | /// `tiles.deinit` beside each of the seven `return .usage_error`s, which | ||
| 94 | /// is seven chances to forget one. | ||
| 95 | const ParseError = error{Usage} || std.mem.Allocator.Error; | ||
| 96 | |||
| 97 | fn parseArgs( | ||
| 98 | alloc: std.mem.Allocator, | ||
| 99 | args: []const [:0]const u8, | ||
| 100 | env_key: ?[]const u8, | ||
| 101 | ) ParseError!ParseResult { | ||
| 102 | var p = Parsed{ .tiles = .empty }; | ||
| 103 | errdefer p.deinit(alloc); | ||
| 104 | var key: ?[]const u8 = null; | ||
| 105 | |||
| 106 | var i: usize = 1; | ||
| 107 | while (i < args.len) : (i += 1) { | ||
| 108 | const a = args[i]; | ||
| 109 | if (std.mem.eql(u8, a, "--version")) { | ||
| 110 | // The one non-error early return, so the one that still frees | ||
| 111 | // for itself: errdefer does not run on the way out with a | ||
| 112 | // result in hand. | ||
| 113 | p.deinit(alloc); | ||
| 114 | return .version; | ||
| 115 | } else if (std.mem.eql(u8, a, "--sock") or std.mem.startsWith(u8, a, "--sock ")) { | ||
| 116 | // The flag and its value become ONE spelling — `--sock ` is | ||
| 117 | // part of the grammar wall.zig reads, not a shape only argv | ||
| 118 | // has. Two spellings of the same tile would be two parsers. | ||
| 119 | // wall owns the join so `mux wall` accepts the same two forms. | ||
| 120 | const n = wall.spellingFromArgv(alloc, args, i) catch |err| switch (err) { | ||
| 121 | error.MissingSockPath, error.FlagLikeTarget => return error.Usage, | ||
| 122 | else => |e| return e, | ||
| 123 | }; | ||
| 124 | defer alloc.free(n.spelling); | ||
| 125 | i += n.consumed - 1; | ||
| 126 | try addSpelling(alloc, &p.tiles, n.spelling); | ||
| 127 | } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) { | ||
| 128 | i += 1; | ||
| 129 | p.port = std.fmt.parseInt(u16, args[i], 10) catch return error.Usage; | ||
| 130 | // Port 0 asks the kernel to choose, and the hub prints the port | ||
| 131 | // it was asked for as the door to open — a door nobody could | ||
| 132 | // find. Refused like `--quic-idle-ms 0` and for the same | ||
| 133 | // reason: the number inverts what typing it means. | ||
| 134 | if (p.port == 0) return error.Usage; | ||
| 135 | } else if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) { | ||
| 136 | i += 1; | ||
| 137 | key = args[i]; | ||
| 138 | } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) { | ||
| 139 | i += 1; | ||
| 140 | const n = std.fmt.parseInt(u32, args[i], 10) catch return error.Usage; | ||
| 141 | if (n == 0) return error.Usage; | ||
| 142 | p.idle_ms = n; | ||
| 143 | } else if (std.mem.startsWith(u8, a, "quic://") or (a.len > 0 and a[0] != '-')) { | ||
| 144 | // Bare HOST and quic:// are already wall spellings verbatim. | ||
| 145 | try addSpelling(alloc, &p.tiles, a); | ||
| 146 | } else { | ||
| 147 | return error.Usage; | ||
| 148 | } | ||
| 149 | } | ||
| 150 | |||
| 151 | // No targets is not a usage error any more: it asks for the wall the | ||
| 152 | // last run persisted. main decides what an empty argv means; the parse | ||
| 153 | // only reports what was on the line. | ||
| 154 | p.key = xdg.pickKey(key, env_key); | ||
| 155 | return .{ .serve = p }; | ||
| 156 | } | ||
| 157 | |||
| 158 | pub fn main() !u8 { | ||
| 159 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 160 | defer if (gpa.deinit() == .leak) | ||
| 161 | std.debug.print("muxweb: LEAK: allocations outlived deinit\n", .{}); | ||
| 162 | const alloc = gpa.allocator(); | ||
| 163 | |||
| 164 | const args = try std.process.argsAlloc(alloc); | ||
| 165 | defer std.process.argsFree(alloc, args); | ||
| 166 | |||
| 167 | const result = parseArgs(alloc, args, std.posix.getenv(client_key_env)) catch |err| switch (err) { | ||
| 168 | error.Usage => { | ||
| 169 | std.debug.print("{s}", .{usage}); | ||
| 170 | return 2; | ||
| 171 | }, | ||
| 172 | else => |e| return e, | ||
| 173 | }; | ||
| 174 | var parsed = switch (result) { | ||
| 175 | .version => { | ||
| 176 | var vbuf: [64]u8 = undefined; | ||
| 177 | const s = std.fmt.bufPrint(&vbuf, "muxweb {s}\n", .{build_options.version}) catch unreachable; | ||
| 178 | _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {}; | ||
| 179 | return 0; | ||
| 180 | }, | ||
| 181 | .serve => |p| p, | ||
| 182 | }; | ||
| 183 | defer parsed.deinit(alloc); | ||
| 184 | |||
| 185 | // An arena, because every string built here lives exactly as long as | ||
| 186 | // the hub does — the state path, the wall it starts from, and the | ||
| 187 | // Hub's own allocations — so nothing is ever freed early and the | ||
| 188 | // hand-rolled list of pointers-to-free was a lifetime nobody needed to | ||
| 189 | // track. The process exits from inside the accept loop, so "as long as | ||
| 190 | // the hub" is "until exit". | ||
| 191 | var arena_state = std.heap.ArenaAllocator.init(alloc); | ||
| 192 | defer arena_state.deinit(); | ||
| 193 | const arena = arena_state.allocator(); | ||
| 194 | |||
| 195 | const state_path = try wall.statePath(arena); | ||
| 196 | var w: wall.Wall = undefined; | ||
| 197 | if (parsed.tiles.items.len == 0) { | ||
| 198 | // No argv: the wall is whatever the last run persisted. | ||
| 199 | w = wall.load(arena, state_path) catch |err| { | ||
| 200 | // The file may have been hand-edited into a line that no longer | ||
| 201 | // parses. Naming it beats a stack trace: the fix is in the file. | ||
| 202 | std.debug.print("muxweb: cannot read wall {s}: {s}\n", .{ state_path, @errorName(err) }); | ||
| 203 | return 2; | ||
| 204 | }; | ||
| 205 | } else { | ||
| 206 | // Argv present: the explicit override. It becomes the persisted wall. | ||
| 207 | w = wall.Wall{}; | ||
| 208 | for (parsed.tiles.items) |s| _ = try w.add(arena, s); | ||
| 209 | } | ||
| 210 | |||
| 211 | // The Hub resolves every spelling into a dialable Target through the | ||
| 212 | // SAME owners mux_main uses — handoff.recipeFor and xdg.resolveKeyPath — | ||
| 213 | // so the two binaries cannot drift on what a bare HOST or a `quic://` | ||
| 214 | // means, and a tile POSTed by the page means what one typed on the | ||
| 215 | // command line. | ||
| 216 | var hub = webhub.Hub.init(arena, w, state_path, parsed.key, parsed.idle_ms) catch |err| switch (err) { | ||
| 217 | error.MissingKey => { | ||
| 218 | std.debug.print( | ||
| 219 | "muxweb: no key for a quic:// tile: pass --key, set MUX_KEY_FILE, or run `muxd keygen`\n", | ||
| 220 | .{}, | ||
| 221 | ); | ||
| 222 | return 2; | ||
| 223 | }, | ||
| 224 | error.SockPathTooLong => { | ||
| 225 | std.debug.print( | ||
| 226 | "muxweb: socket path too long (max {d} bytes)\n", | ||
| 227 | .{sockpath.max_sun_path}, | ||
| 228 | ); | ||
| 229 | return 2; | ||
| 230 | }, | ||
| 231 | else => return err, | ||
| 232 | }; | ||
| 233 | defer hub.deinit(); | ||
| 234 | // Argv overrides the VIEW — this run shows the tiles it named, and only | ||
| 235 | // those — but it no longer overwrites the FILE. That file stopped being | ||
| 236 | // "the last wall muxweb was told to show" when attaches started writing | ||
| 237 | // to it: it is the user's attach history now, and one | ||
| 238 | // `muxweb HOST` would have silently erased every tile every `mux` had | ||
| 239 | // recorded. So each argv tile is ADDED (deduped by spelling, wall.zig) | ||
| 240 | // and nothing is removed. Forgetting stays explicit, which is the whole | ||
| 241 | // "remove is detach" doctrine: the page's `×`, the wall's `x`, | ||
| 242 | // `mux wall rm`. | ||
| 243 | // | ||
| 244 | // Written only when there WAS argv, still: a restore that rewrote what | ||
| 245 | // it just read would turn a read failure into a lost wall. | ||
| 246 | if (parsed.tiles.items.len != 0) for (hub.wall_state.targets.items) |spelling| { | ||
| 247 | _ = wall.record(alloc, state_path, spelling) catch |err| { | ||
| 248 | std.debug.print("muxweb: cannot save wall {s}: {s}\n", .{ state_path, @errorName(err) }); | ||
| 249 | return 2; | ||
| 250 | }; | ||
| 251 | }; | ||
| 252 | |||
| 253 | const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable; | ||
| 254 | var listener = addr.listen(.{ .reuse_address = true }) catch |err| { | ||
| 255 | std.debug.print("muxweb: cannot bind 127.0.0.1:{d}: {s}\n", .{ parsed.port, @errorName(err) }); | ||
| 256 | return 1; | ||
| 257 | }; | ||
| 258 | defer listener.deinit(); | ||
| 259 | |||
| 260 | // The tile list, one line each, then the door: everything a script | ||
| 261 | // (or a user) needs to know the hub is up and what it serves. The id | ||
| 262 | // is the hub's, not a position — it is what `/ws/<id>` names, and the | ||
| 263 | // page can add and remove tiles under it while this line stays true. | ||
| 264 | for (hub.tiles.items) |t| { | ||
| 265 | std.debug.print("muxweb: tile {d}: {s}\n", .{ t.id, t.label }); | ||
| 266 | } | ||
| 267 | std.debug.print("muxweb: serving http://127.0.0.1:{d} pid={d}\n", .{ | ||
| 268 | parsed.port, | ||
| 269 | std.os.linux.getpid(), | ||
| 270 | }); | ||
| 271 | |||
| 272 | const assets = webhub.Assets{ | ||
| 273 | .index_html = @embedFile("index.html"), | ||
| 274 | .mux_js = @embedFile("mux.js"), | ||
| 275 | .core_wasm = @embedFile("mux_core.wasm"), | ||
| 276 | }; | ||
| 277 | |||
| 278 | while (true) { | ||
| 279 | const conn = listener.accept() catch continue; | ||
| 280 | const th = std.Thread.spawn(.{}, webhub.serveConn, .{ | ||
| 281 | alloc, conn.stream, parsed.port, &hub, assets, | ||
| 282 | }) catch { | ||
| 283 | conn.stream.close(); | ||
| 284 | continue; | ||
| 285 | }; | ||
| 286 | th.detach(); | ||
| 287 | } | ||
| 288 | } | ||
| 289 | |||
| 290 | /// mux_main.key_env's value, spelled again rather than imported: pulling | ||
| 291 | /// an exe root into the module graph for one string constant is not | ||
| 292 | /// worth the tangle. mux_main.key_env is the origin; a drift between | ||
| 293 | /// the two spellings would make the two binaries read different | ||
| 294 | /// variables, so change both or neither. | ||
| 295 | const client_key_env = "MUX_KEY_FILE"; | ||
| 296 | |||
| 297 | test "parse: three spellings become three tiles in argv order, port and key bind" { | ||
| 298 | const alloc = std.testing.allocator; | ||
| 299 | const args = [_][:0]const u8{ | ||
| 300 | "muxweb", "box1", "--sock", "/tmp/a.sock", "quic://h:4433", "--key", "/k", "--port", "8000", | ||
| 301 | }; | ||
| 302 | var r = (try parseArgs(alloc, &args, null)).serve; | ||
| 303 | defer r.deinit(alloc); | ||
| 304 | try std.testing.expectEqual(@as(usize, 3), r.tiles.items.len); | ||
| 305 | try std.testing.expectEqualStrings("box1", r.tiles.items[0]); | ||
| 306 | // `--sock PATH` is ONE spelling from here on, prefix included — that | ||
| 307 | // string is the label, the wall line, and the resolver's input alike. | ||
| 308 | try std.testing.expectEqualStrings("--sock /tmp/a.sock", r.tiles.items[1]); | ||
| 309 | try std.testing.expectEqualStrings("quic://h:4433", r.tiles.items[2]); | ||
| 310 | try std.testing.expectEqual(@as(u16, 8000), r.port); | ||
| 311 | try std.testing.expectEqualStrings("/k", r.key.?); | ||
| 312 | } | ||
| 313 | |||
| 314 | test "parse: a quoted '--sock PATH#SESSION' is the same tile as the two-argument form" { | ||
| 315 | const alloc = std.testing.allocator; | ||
| 316 | // The wall file's own spelling, pasted straight onto the command line: | ||
| 317 | // muxweb used to refuse it while `mux wall` required it. | ||
| 318 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock /tmp/a.sock#b" }, null)).serve; | ||
| 319 | defer r.deinit(alloc); | ||
| 320 | try std.testing.expectEqual(@as(usize, 1), r.tiles.items.len); | ||
| 321 | try std.testing.expectEqualStrings("--sock /tmp/a.sock#b", r.tiles.items[0]); | ||
| 322 | } | ||
| 323 | |||
| 324 | test "parse: zero targets, bad flags, and flag-beats-env" { | ||
| 325 | const alloc = std.testing.allocator; | ||
| 326 | // No targets is an empty argv wall, not a refusal: restore-from-file | ||
| 327 | // semantics live in main, which is the only place that can read a file. | ||
| 328 | { | ||
| 329 | var r = (try parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null)).serve; | ||
| 330 | defer r.deinit(alloc); | ||
| 331 | try std.testing.expectEqual(@as(usize, 0), r.tiles.items.len); | ||
| 332 | } | ||
| 333 | // Every other refusal arrives as error.Usage — and the testing | ||
| 334 | // allocator is the other half of this pin: a refusal that leaked the | ||
| 335 | // tile list (whose strings are now owned) would fail the test that | ||
| 336 | // provoked it, which is what the single errdefer guarantees. | ||
| 337 | // | ||
| 338 | // A flag with no value is a usage mistake, not a transport. | ||
| 339 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock" }, null)); | ||
| 340 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "x" }, null)); | ||
| 341 | // The refusals that had a tile on the list already, so the cleanup is | ||
| 342 | // load-bearing rather than theoretical. | ||
| 343 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--wat" }, null)); | ||
| 344 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "quic://" }, null)); | ||
| 345 | // A `#NAME` that ate the whole target: all three transport spellings | ||
| 346 | // refuse an empty spec, so `--sock '#b'` fails at usage altitude rather | ||
| 347 | // than at a connect to the empty path, far from the typo. | ||
| 348 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--sock", "#b" }, null)); | ||
| 349 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--quic-idle-ms", "0" }, null)); | ||
| 350 | // Port 0 means "kernel, you pick" — but the hub announces the port it | ||
| 351 | // was asked for, so the door it prints is not the door it opened. | ||
| 352 | try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "0" }, null)); | ||
| 353 | // ...and an ordinary port still binds, so the refusal is the zero and | ||
| 354 | // not the flag. | ||
| 355 | { | ||
| 356 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "1" }, null)).serve; | ||
| 357 | defer r.deinit(alloc); | ||
| 358 | try std.testing.expectEqual(@as(u16, 1), r.port); | ||
| 359 | } | ||
| 360 | // Env fills in when --key is absent; --key wins when both are set. | ||
| 361 | { | ||
| 362 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "/env-key")).serve; | ||
| 363 | defer r.deinit(alloc); | ||
| 364 | try std.testing.expectEqualStrings("/env-key", r.key.?); | ||
| 365 | } | ||
| 366 | { | ||
| 367 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--key", "/flag-key" }, "/env-key")).serve; | ||
| 368 | defer r.deinit(alloc); | ||
| 369 | try std.testing.expectEqualStrings("/flag-key", r.key.?); | ||
| 370 | } | ||
| 371 | // Empty either way means unset. | ||
| 372 | { | ||
| 373 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "")).serve; | ||
| 374 | defer r.deinit(alloc); | ||
| 375 | try std.testing.expectEqual(@as(?[]const u8, null), r.key); | ||
| 376 | } | ||
| 377 | } | ||
| 378 | |||
| 379 | test "tiles: the spelling reaches the wall verbatim, `#NAME` and all" { | ||
| 380 | const alloc = std.testing.allocator; | ||
| 381 | const args = [_][:0]const u8{ | ||
| 382 | "muxweb", "host#b", "quic://h:1#b", "--sock", "/tmp/x#b", "plainhost", "a#b#c", | ||
| 383 | }; | ||
| 384 | var r = (try parseArgs(alloc, &args, null)).serve; | ||
| 385 | defer r.deinit(alloc); | ||
| 386 | try std.testing.expectEqual(@as(usize, 5), r.tiles.items.len); | ||
| 387 | |||
| 388 | // The session SPLIT is wall.parseSpelling's, tested there. What is | ||
| 389 | // this parse's own is that the argv string arrives intact: the user | ||
| 390 | // asked for `host#b`, so that is the wall line, and therefore the | ||
| 391 | // tile's name on screen — nobody decorates it on the way. | ||
| 392 | try std.testing.expectEqualStrings("host#b", r.tiles.items[0]); | ||
| 393 | try std.testing.expectEqualStrings("quic://h:1#b", r.tiles.items[1]); | ||
| 394 | // The flag and its value become one spelling; the `#NAME` rides on the | ||
| 395 | // VALUE, where the user put it. | ||
| 396 | try std.testing.expectEqualStrings("--sock /tmp/x#b", r.tiles.items[2]); | ||
| 397 | try std.testing.expectEqualStrings("plainhost", r.tiles.items[3]); | ||
| 398 | try std.testing.expectEqualStrings("a#b#c", r.tiles.items[4]); | ||
| 399 | } | ||
| 400 | |||
| 401 | test "tiles: a bad session name after # is still a usage error at parse" { | ||
| 402 | const alloc = std.testing.allocator; | ||
| 403 | // The refusal stays HERE, at argv altitude, rather than surfacing later | ||
| 404 | // as one tile that will not attach. These print a line naming the tile | ||
| 405 | // before returning, so the muxweb: lines in this test's output are the | ||
| 406 | // point, not noise: with several tiles on the line, `usage` alone would | ||
| 407 | // not say which one. | ||
| 408 | try std.testing.expectError( | ||
| 409 | error.Usage, | ||
| 410 | parseArgs(alloc, &[_][:0]const u8{ "muxweb", "host#has space" }, null), | ||
| 411 | ); | ||
| 412 | // A bare trailing '#' asks for the empty name. It is the default ON THE | ||
| 413 | // WIRE but not a name a user may spell, so typing it is a mistake. | ||
| 414 | try std.testing.expectError( | ||
| 415 | error.Usage, | ||
| 416 | parseArgs(alloc, &[_][:0]const u8{ "muxweb", "host#" }, null), | ||
| 417 | ); | ||
| 418 | // Same rule through --sock's value and through quic://. | ||
| 419 | try std.testing.expectError( | ||
| 420 | error.Usage, | ||
| 421 | parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock", "/tmp/x#bad name" }, null), | ||
| 422 | ); | ||
| 423 | try std.testing.expectError( | ||
| 424 | error.Usage, | ||
| 425 | parseArgs(alloc, &[_][:0]const u8{ "muxweb", "quic://h:1#a/b" }, null), | ||
| 426 | ); | ||
| 427 | } | ||
| 428 | |||
| 429 | test "version short-circuits everything else on the line" { | ||
| 430 | const alloc = std.testing.allocator; | ||
| 431 | const r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--version", "--bogus" }, null); | ||
| 432 | try std.testing.expect(r == .version); | ||
| 433 | } | ||
| 434 | |||
| 435 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 436 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 437 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 438 | test { | ||
| 439 | std.testing.refAllDeclsRecursive(@This()); | ||
| 440 | } | ||
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -17,7 +17,7 @@ RENDER="$5" | |||
| 17 | # M12 pty fixture: runs the client on a real pty (test/ptyclient.zig). | 17 | # M12 pty fixture: runs the client on a real pty (test/ptyclient.zig). |
| 18 | PTYCLIENT="$6" | 18 | PTYCLIENT="$6" |
| 19 | # M-web: the scripted WebSocket replica client and the hub it speaks to | 19 | # M-web: the scripted WebSocket replica client and the hub it speaks to |
| 20 | # (test/wsclient.zig, src/webhub_main.zig). | 20 | # (test/wsclient.zig, src/cli/webhub_main.zig). |
| 21 | WSCLIENT="$7" | 21 | WSCLIENT="$7" |
| 22 | MUXWEB="$8" | 22 | MUXWEB="$8" |
| 23 | # M18: the agent client, used by one block — the out-of-band verbs that | 23 | # M18: the agent client, used by one block — the out-of-band verbs that |
tools/docscheck.zig
| Old | New | ||
|---|---|---|---|
| @@ -61,7 +61,7 @@ fn commentBody(line: []const u8) ?[]const u8 { | |||
| 61 | /// The code half of a line, for the corpus: literal spans are blanked and | 61 | /// The code half of a line, for the corpus: literal spans are blanked and |
| 62 | /// everything from an unquoted `//` onward is cut off. A word that exists | 62 | /// everything from an unquoted `//` onward is cut off. A word that exists |
| 63 | /// only inside quotes is no evidence that a symbol exists — `zig` resolved | 63 | /// only inside quotes is no evidence that a symbol exists — `zig` resolved |
| 64 | /// for exactly that reason, off `b.path("src/main.zig")` in build.zig, and | 64 | /// for exactly that reason, off `b.path("src/cli/main.zig")` in build.zig, and |
| 65 | /// `zig` is the tail of every module citation. | 65 | /// `zig` is the tail of every module citation. |
| 66 | /// | 66 | /// |
| 67 | /// Blanking IN PLACE is deliberate: the corpus stores slices of these | 67 | /// Blanking IN PLACE is deliberate: the corpus stores slices of these |
| @@ -224,6 +224,7 @@ fn zigRefAt(s: []const u8, dot: usize) ?ZigRef { | |||
| 224 | 224 | ||
| 225 | fn prefixIsOurs(prefix: []const u8) bool { | 225 | fn prefixIsOurs(prefix: []const u8) bool { |
| 226 | return prefix.len == 0 or std.mem.eql(u8, prefix, "src") or | 226 | return prefix.len == 0 or std.mem.eql(u8, prefix, "src") or |
| 227 | std.mem.eql(u8, prefix, "src/cli") or | ||
| 227 | std.mem.eql(u8, prefix, "test") or std.mem.eql(u8, prefix, "./src") or | 228 | std.mem.eql(u8, prefix, "test") or std.mem.eql(u8, prefix, "./src") or |
| 228 | std.mem.eql(u8, prefix, "./test"); | 229 | std.mem.eql(u8, prefix, "./test"); |
| 229 | } | 230 | } |
| @@ -686,6 +687,9 @@ test "a .zig reference knows whose tree it names" { | |||
| 686 | try std.testing.expect(prefixIsOurs(pair.prefix)); | 687 | try std.testing.expect(prefixIsOurs(pair.prefix)); |
| 687 | 688 | ||
| 688 | try std.testing.expectEqualStrings("src", lastRef("src/protocol.zig").prefix); | 689 | try std.testing.expectEqualStrings("src", lastRef("src/protocol.zig").prefix); |
| 690 | // The entrypoints live a directory down, and a citation of one must still | ||
| 691 | // be checked rather than waved through as somebody else's tree. | ||
| 692 | try std.testing.expect(prefixIsOurs(lastRef("src/cli/main.zig").prefix)); | ||
| 689 | } | 693 | } |
| 690 | 694 | ||
| 691 | test "only leading // is a comment, so string literals are left alone" { | 695 | test "only leading // is a comment, so string literals are left alone" { |