a73x

cafa56ed

refactor: the four entrypoints move to src/cli/

a73x   2026-08-27 05:50

Commit message
refactor: the four entrypoints move to src/cli/

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" {