316ac48e
feat(mux): --session NAME on every transport spelling
a73x 2026-08-15 12:21
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -232,7 +232,10 @@ const mod_table = [_]ModSpec{ | |||
| 232 | // @embedFiles them), so its tests build no artifacts. | 232 | // @embedFiles them), so its tests build no artifacts. |
| 233 | .{ .name = "webhub", .path = "src/webhub.zig", .layer = 3, .imports = &.{ "protocol", "client" }, .quic_tests = true }, | 233 | .{ .name = "webhub", .path = "src/webhub.zig", .layer = 3, .imports = &.{ "protocol", "client" }, .quic_tests = true }, |
| 234 | // sockpath is the sun_path bound only; the client binds no socket itself. | 234 | // sockpath is the sun_path bound only; the client binds no socket itself. |
| 235 | .{ .name = "mux", .path = "src/mux_main.zig", .layer = 3, .link_libc = true, .imports = &.{ "client", "xdg", "spawn", "handoff", "sockpath" }, .quic_tests = true }, | 235 | // protocol is the session-name validator alone (validSessionName): a bad |
| 236 | // --session has to be a usage error here, at parse, not bytes some | ||
| 237 | // daemon downstream has to notice and refuse. | ||
| 238 | .{ .name = "mux", .path = "src/mux_main.zig", .layer = 3, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath" }, .quic_tests = true }, | ||
| 236 | // The daemon entrypoint loads the key and constructs the listener, so | 239 | // The daemon entrypoint loads the key and constructs the listener, so |
| 237 | // it needs quic/quic_server directly rather than through the server. | 240 | // it needs quic/quic_server directly rather than through the server. |
| 238 | // `muxd endpoint` prints the announce line handoff spells; sockpath is | 241 | // `muxd endpoint` prints the announce line handoff spells; sockpath is |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -842,10 +842,24 @@ fn openFailure(buf: []u8, target: Target, err: anyerror) OpenFailure { | |||
| 842 | }; | 842 | }; |
| 843 | } | 843 | } |
| 844 | 844 | ||
| 845 | /// The one place this client puts an attach on the wire. The buffer lives | ||
| 846 | /// exactly as long as the frame does, and the name is a parameter rather | ||
| 847 | /// than a thing a new call site can forget: `encodeAttach` is still the | ||
| 848 | /// right encoder for the server's tests, and reaching for it here would | ||
| 849 | /// silently ask for the default session. | ||
| 850 | fn sendAttach(t: *Transport, size: proto.Size, have_seq: u64, epoch: u64, name: []const u8) !void { | ||
| 851 | var buf: [proto.attach_max_len]u8 = undefined; | ||
| 852 | try t.writeFrame(.attach, proto.encodeAttachNamed(&buf, size.cols, size.rows, have_seq, epoch, name)); | ||
| 853 | } | ||
| 854 | |||
| 845 | /// Attach over a local socket (`.sock`), over an arbitrary command's stdio | 855 | /// Attach over a local socket (`.sock`), over an arbitrary command's stdio |
| 846 | /// (`.via`, typically `ssh host muxd proxy ...`), over a direct QUIC dial | 856 | /// (`.via`, typically `ssh host muxd proxy ...`), over a direct QUIC dial |
| 847 | /// (`.quic`), or over the bare-HOST ssh→QUIC handoff (`.hand`). | 857 | /// (`.quic`), or over the bare-HOST ssh→QUIC handoff (`.hand`). `session_name` |
| 848 | pub fn attach(alloc: std.mem.Allocator, target: Target) !u8 { | 858 | /// is put on the wire as-is: "" (the wire-compatible default) or a name the |
| 859 | /// caller has already run through `proto.validSessionName` — encodeAttachNamed | ||
| 860 | /// only asserts the length bound, so an unvalidated overlong name is a | ||
| 861 | /// compiled-out assert plus a memcpy past the array, not a caught error. | ||
| 862 | pub fn attach(alloc: std.mem.Allocator, target: Target, session_name: []const u8) !u8 { | ||
| 849 | // Anything typed while the first handshake is in flight belongs to the | 863 | // Anything typed while the first handshake is in flight belongs to the |
| 850 | // shell, so it is held here rather than dropped — and handed over once | 864 | // shell, so it is held here rather than dropped — and handed over once |
| 851 | // there is a session to hand it to. | 865 | // there is a session to hand it to. |
| @@ -859,7 +873,7 @@ pub fn attach(alloc: std.mem.Allocator, target: Target) !u8 { | |||
| 859 | return f.exit; | 873 | return f.exit; |
| 860 | }; | 874 | }; |
| 861 | defer transport.close(); | 875 | defer transport.close(); |
| 862 | return session(alloc, &transport, target, &carry); | 876 | return session(alloc, &transport, target, &carry, session_name); |
| 863 | } | 877 | } |
| 864 | 878 | ||
| 865 | /// `target` is the recipe this session's transport was built from, carried | 879 | /// `target` is the recipe this session's transport was built from, carried |
| @@ -877,6 +891,11 @@ fn session( | |||
| 877 | /// Keystrokes that arrived during the opening handshake, owed to the | 891 | /// Keystrokes that arrived during the opening handshake, owed to the |
| 878 | /// shell as soon as there is an attach to send them after. | 892 | /// shell as soon as there is an attach to send them after. |
| 879 | carry: *std.ArrayList(u8), | 893 | carry: *std.ArrayList(u8), |
| 894 | /// Which session every attach frame in this run names — carried through | ||
| 895 | /// so reconnects and resyncs keep asking for the same one. Named | ||
| 896 | /// `session_name` rather than `session` because this function is itself | ||
| 897 | /// named `session`. | ||
| 898 | session_name: []const u8, | ||
| 880 | ) !u8 { | 899 | ) !u8 { |
| 881 | // A daemon that dies mid-write must surface as an error return from | 900 | // A daemon that dies mid-write must surface as an error return from |
| 882 | // write(), not a fatal SIGPIPE. SIG_IGN survives exec while a handler | 901 | // write(), not a fatal SIGPIPE. SIG_IGN survives exec while a handler |
| @@ -945,10 +964,12 @@ fn session( | |||
| 945 | // no seq, and no epoch to interpret one in. A transport that died between | 964 | // no seq, and no epoch to interpret one in. A transport that died between |
| 946 | // spawn and here (ssh refused, host unreachable) makes this a broken pipe; | 965 | // spawn and here (ssh refused, host unreachable) makes this a broken pipe; |
| 947 | // that is a message, not a crash. | 966 | // that is a message, not a crash. |
| 948 | transport.writeFrame( | 967 | // |
| 949 | .attach, | 968 | // The empty name is the wire-compatible default (encodeAttachNamed with |
| 950 | &proto.encodeAttach(size.cols, size.rows, 0, 0), | 969 | // "" writes exactly the old fixed 20 bytes), so a `mux` invoked without |
| 951 | ) catch { | 970 | // `--session` still works unmodified against a pre-M18 daemon that has |
| 971 | // never heard of a name tail. | ||
| 972 | sendAttach(transport, size, 0, 0, session_name) catch { | ||
| 952 | // Nothing has been read yet, so the epoch is 0 by construction. | 973 | // Nothing has been read yet, so the epoch is 0 by construction. |
| 953 | exit_msg = lostMsg(target, 0); | 974 | exit_msg = lostMsg(target, 0); |
| 954 | return 1; | 975 | return 1; |
| @@ -1027,6 +1048,7 @@ fn session( | |||
| 1027 | stdin_fd, | 1048 | stdin_fd, |
| 1028 | stdout_fd, | 1049 | stdout_fd, |
| 1029 | is_tty, | 1050 | is_tty, |
| 1051 | session_name, | ||
| 1030 | )) { | 1052 | )) { |
| 1031 | // Ctrl-\ during a reconnect: the user is done waiting, but | 1053 | // Ctrl-\ during a reconnect: the user is done waiting, but |
| 1032 | // the session itself is still up wherever it lives. | 1054 | // the session itself is still up wherever it lives. |
| @@ -1169,10 +1191,7 @@ fn session( | |||
| 1169 | // quoting a seq would invite the delta that cannot | 1191 | // quoting a seq would invite the delta that cannot |
| 1170 | // fix us. A reconnect quotes last_seq for exactly the | 1192 | // fix us. A reconnect quotes last_seq for exactly the |
| 1171 | // opposite reason: there, the replica is known good. | 1193 | // opposite reason: there, the replica is known good. |
| 1172 | transport.writeFrame( | 1194 | sendAttach(transport, size, 0, 0, session_name) catch {}; |
| 1173 | .attach, | ||
| 1174 | &proto.encodeAttach(size.cols, size.rows, 0, 0), | ||
| 1175 | ) catch {}; | ||
| 1176 | continue; | 1195 | continue; |
| 1177 | } | 1196 | } |
| 1178 | // Judged against the replica the frame has just been fed | 1197 | // Judged against the replica the frame has just been fed |
| @@ -1545,6 +1564,9 @@ fn reconnect( | |||
| 1545 | stdin_fd: std.posix.fd_t, | 1564 | stdin_fd: std.posix.fd_t, |
| 1546 | stdout_fd: std.posix.fd_t, | 1565 | stdout_fd: std.posix.fd_t, |
| 1547 | is_tty: bool, | 1566 | is_tty: bool, |
| 1567 | /// Same session this whole run has been attaching to — a reconnect must | ||
| 1568 | /// ask for it again, not fall back to the default. | ||
| 1569 | session_name: []const u8, | ||
| 1548 | ) bool { | 1570 | ) bool { |
| 1549 | if (is_tty) paint_mod.paintBanner(stdout_fd, size, "[reconnecting]"); | 1571 | if (is_tty) paint_mod.paintBanner(stdout_fd, size, "[reconnecting]"); |
| 1550 | // The dead transport is released exactly once, here. Everything after | 1572 | // The dead transport is released exactly once, here. Everything after |
| @@ -1587,10 +1609,7 @@ fn reconnect( | |||
| 1587 | if (err == error.UserAbort) return false; | 1609 | if (err == error.UserAbort) return false; |
| 1588 | continue; | 1610 | continue; |
| 1589 | }; | 1611 | }; |
| 1590 | fresh.writeFrame( | 1612 | sendAttach(&fresh, size, last_seq, session_epoch, session_name) catch { |
| 1591 | .attach, | ||
| 1592 | &proto.encodeAttach(size.cols, size.rows, last_seq, session_epoch), | ||
| 1593 | ) catch { | ||
| 1594 | // Ours, and already broken: close it here rather than letting | 1613 | // Ours, and already broken: close it here rather than letting |
| 1595 | // the next iteration do it, which would otherwise be closing | 1614 | // the next iteration do it, which would otherwise be closing |
| 1596 | // whatever the previous round left behind. | 1615 | // whatever the previous round left behind. |
src/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -5,6 +5,7 @@ | |||
| 5 | //! coordinates and carries the session only if the QUIC dial does not. | 5 | //! coordinates and carries the session only if the QUIC dial does not. |
| 6 | const std = @import("std"); | 6 | const std = @import("std"); |
| 7 | const client = @import("client"); | 7 | const client = @import("client"); |
| 8 | const proto = @import("protocol"); | ||
| 8 | const build_options = @import("build_options"); | 9 | const build_options = @import("build_options"); |
| 9 | const xdg = @import("xdg"); | 10 | const xdg = @import("xdg"); |
| 10 | const spawn = @import("spawn"); | 11 | const spawn = @import("spawn"); |
| @@ -20,6 +21,8 @@ const usage = | |||
| 20 | \\ MUX_KEY_FILE, or ~/.config/mux/key; muxd must be running with a | 21 | \\ MUX_KEY_FILE, or ~/.config/mux/key; muxd must be running with a |
| 21 | \\ matching --quic and key | 22 | \\ matching --quic and key |
| 22 | \\ [--quic-idle-ms N] tunes how fast a dead link is noticed | 23 | \\ [--quic-idle-ms N] tunes how fast a dead link is noticed |
| 24 | \\ [--session NAME] attaches to (or creates) a named session instead of | ||
| 25 | \\ the default (`0`); NAME is printable ASCII, no space, no '#' or '/' | ||
| 23 | \\ --version prints the version | 26 | \\ --version prints the version |
| 24 | \\ | 27 | \\ |
| 25 | ; | 28 | ; |
| @@ -29,17 +32,19 @@ const usage = | |||
| 29 | /// the parse can be tested without a process to exit from. | 32 | /// the parse can be tested without a process to exit from. |
| 30 | const ParseResult = union(enum) { | 33 | const ParseResult = union(enum) { |
| 31 | /// At most one of these is set; both null means the default local socket. | 34 | /// At most one of these is set; both null means the default local socket. |
| 32 | attach: struct { sock: ?[]const u8 = null, via: ?[]const u8 = null }, | 35 | /// `session` defaults to "" (empty), the wire-compatible name that puts |
| 36 | /// exactly the old bytes on the wire — see encodeAttachNamed. | ||
| 37 | attach: struct { sock: ?[]const u8 = null, via: ?[]const u8 = null, session: []const u8 = "" }, | ||
| 33 | /// A bare hostname: the ssh recipe is built from it in main, where there | 38 | /// A bare hostname: the ssh recipe is built from it in main, where there |
| 34 | /// is an allocator to build it with. `idle_ms` rides along because the | 39 | /// is an allocator to build it with. `idle_ms` rides along because the |
| 35 | /// handoff ends in a QUIC link like any other — muxweb's HOST tiles | 40 | /// handoff ends in a QUIC link like any other — muxweb's HOST tiles |
| 36 | /// have always carried it, and mux dropping it on the floor made | 41 | /// have always carried it, and mux dropping it on the floor made |
| 37 | /// `--quic-idle-ms` silently do nothing on exactly the spelling most | 42 | /// `--quic-idle-ms` silently do nothing on exactly the spelling most |
| 38 | /// people use. | 43 | /// people use. |
| 39 | host: struct { name: []const u8, idle_ms: u32 }, | 44 | host: struct { name: []const u8, idle_ms: u32, session: []const u8 = "" }, |
| 40 | /// A direct QUIC attach. The key is resolved in main, where the | 45 | /// A direct QUIC attach. The key is resolved in main, where the |
| 41 | /// environment can be consulted. | 46 | /// environment can be consulted. |
| 42 | quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32 }, | 47 | quic: struct { host_port: []const u8, key: ?[]const u8, idle_ms: u32, session: []const u8 = "" }, |
| 43 | /// `--version`: not a transport at all, so it short-circuits the rest of | 48 | /// `--version`: not a transport at all, so it short-circuits the rest of |
| 44 | /// the parse rather than being reconciled with it. | 49 | /// the parse rather than being reconciled with it. |
| 45 | version, | 50 | version, |
| @@ -61,6 +66,10 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult { | |||
| 61 | var quic: ?[]const u8 = null; | 66 | var quic: ?[]const u8 = null; |
| 62 | var key: ?[]const u8 = null; | 67 | var key: ?[]const u8 = null; |
| 63 | var idle_ms: u32 = client.quic_idle_ms_default; | 68 | var idle_ms: u32 = client.quic_idle_ms_default; |
| 69 | // Rides every transport below, unlike --key: a session name is not | ||
| 70 | // authenticating anything, so there is no "no quic:// means ignore it" | ||
| 71 | // escape hatch — it applies whichever spelling wins. | ||
| 72 | var session: []const u8 = ""; | ||
| 64 | 73 | ||
| 65 | var i: usize = 1; | 74 | var i: usize = 1; |
| 66 | while (i < args.len) : (i += 1) { | 75 | while (i < args.len) : (i += 1) { |
| @@ -81,6 +90,13 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult { | |||
| 81 | } else if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) { | 90 | } else if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) { |
| 82 | i += 1; | 91 | i += 1; |
| 83 | key = args[i]; | 92 | key = args[i]; |
| 93 | } else if (std.mem.eql(u8, a, "--session") and i + 1 < args.len) { | ||
| 94 | i += 1; | ||
| 95 | // A name that cannot be spelled must not become wire bytes: catch | ||
| 96 | // it here, at usage-error altitude, rather than downstream where | ||
| 97 | // it would look like a rejected attach. | ||
| 98 | if (!proto.validSessionName(args[i])) return .usage_error; | ||
| 99 | session = args[i]; | ||
| 84 | } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) { | 100 | } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) { |
| 85 | i += 1; | 101 | i += 1; |
| 86 | const n = std.fmt.parseInt(u32, args[i], 10) catch return .usage_error; | 102 | const n = std.fmt.parseInt(u32, args[i], 10) catch return .usage_error; |
| @@ -119,6 +135,7 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult { | |||
| 119 | .host_port = hp, | 135 | .host_port = hp, |
| 120 | .key = xdg.pickKey(key, env_key), | 136 | .key = xdg.pickKey(key, env_key), |
| 121 | .idle_ms = idle_ms, | 137 | .idle_ms = idle_ms, |
| 138 | .session = session, | ||
| 122 | } }; | 139 | } }; |
| 123 | } | 140 | } |
| 124 | // A key with no quic:// has nothing to authenticate and is ignored | 141 | // A key with no quic:// has nothing to authenticate and is ignored |
| @@ -126,8 +143,8 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseResult { | |||
| 126 | // listener was meant, here it is one env var away from being set for | 143 | // listener was meant, here it is one env var away from being set for |
| 127 | // every invocation in a shell, and refusing `mux --sock ...` because | 144 | // every invocation in a shell, and refusing `mux --sock ...` because |
| 128 | // MUX_KEY_FILE happens to be exported would be absurd. | 145 | // MUX_KEY_FILE happens to be exported would be absurd. |
| 129 | if (host) |h| return .{ .host = .{ .name = h, .idle_ms = idle_ms } }; | 146 | if (host) |h| return .{ .host = .{ .name = h, .idle_ms = idle_ms, .session = session } }; |
| 130 | return .{ .attach = .{ .sock = sock, .via = via } }; | 147 | return .{ .attach = .{ .sock = sock, .via = via, .session = session } }; |
| 131 | } | 148 | } |
| 132 | 149 | ||
| 133 | pub fn main() !u8 { | 150 | pub fn main() !u8 { |
| @@ -178,7 +195,7 @@ pub fn main() !u8 { | |||
| 178 | .host_port = q.host_port, | 195 | .host_port = q.host_port, |
| 179 | .key_path = key_path, | 196 | .key_path = key_path, |
| 180 | .idle_ms = q.idle_ms, | 197 | .idle_ms = q.idle_ms, |
| 181 | } }); | 198 | } }, q.session); |
| 182 | }, | 199 | }, |
| 183 | .host => |h| { | 200 | .host => |h| { |
| 184 | // The handoff recipe: ssh fetches the coordinates (and, on a | 201 | // The handoff recipe: ssh fetches the coordinates (and, on a |
| @@ -193,10 +210,10 @@ pub fn main() !u8 { | |||
| 193 | .ssh_cmd = r.ssh_cmd, | 210 | .ssh_cmd = r.ssh_cmd, |
| 194 | .cache_path = r.cache_path, | 211 | .cache_path = r.cache_path, |
| 195 | .idle_ms = h.idle_ms, | 212 | .idle_ms = h.idle_ms, |
| 196 | } }); | 213 | } }, h.session); |
| 197 | }, | 214 | }, |
| 198 | .attach => |t| { | 215 | .attach => |t| { |
| 199 | if (t.via) |cmd| return client.attach(alloc, .{ .via = cmd }); | 216 | if (t.via) |cmd| return client.attach(alloc, .{ .via = cmd }, t.session); |
| 200 | const sock_path = if (t.sock) |s| | 217 | const sock_path = if (t.sock) |s| |
| 201 | try alloc.dupe(u8, s) | 218 | try alloc.dupe(u8, s) |
| 202 | else if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| | 219 | else if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| |
| @@ -238,7 +255,7 @@ pub fn main() !u8 { | |||
| 238 | std.debug.print("mux: no daemon on {s} and no muxd in PATH to start one\n", .{sock_path}); | 255 | std.debug.print("mux: no daemon on {s} and no muxd in PATH to start one\n", .{sock_path}); |
| 239 | return 1; | 256 | return 1; |
| 240 | } | 257 | } |
| 241 | return client.attach(alloc, .{ .sock = sock_path }); | 258 | return client.attach(alloc, .{ .sock = sock_path }, t.session); |
| 242 | }, | 259 | }, |
| 243 | } | 260 | } |
| 244 | } | 261 | } |
| @@ -304,8 +321,12 @@ test "parseArgs: unknown flags and valueless flags are usage errors" { | |||
| 304 | try std.testing.expect(parse(&.{ "mux", "--wat" }) == .usage_error); | 321 | try std.testing.expect(parse(&.{ "mux", "--wat" }) == .usage_error); |
| 305 | try std.testing.expect(parse(&.{ "mux", "-x" }) == .usage_error); | 322 | try std.testing.expect(parse(&.{ "mux", "-x" }) == .usage_error); |
| 306 | // A flag whose value is missing must not be mistaken for a bare host. | 323 | // A flag whose value is missing must not be mistaken for a bare host. |
| 307 | try std.testing.expect(parse(&.{ "mux", "--sock" }) == .usage_error); | 324 | // Every value-taking flag has to have a row here: the fall-through that |
| 308 | try std.testing.expect(parse(&.{ "mux", "--via" }) == .usage_error); | 325 | // catches a missing value is one `else` arm shared by all of them, so a |
| 326 | // flag added without a row here is a flag nobody actually checked. | ||
| 327 | inline for (.{ "--sock", "--via", "--key", "--quic-idle-ms", "--session" }) |flag| { | ||
| 328 | try std.testing.expect(parse(&.{ "mux", flag }) == .usage_error); | ||
| 329 | } | ||
| 309 | } | 330 | } |
| 310 | 331 | ||
| 311 | test "parseArgs: quic:// is a transport like any other" { | 332 | test "parseArgs: quic:// is a transport like any other" { |
| @@ -375,7 +396,8 @@ test "parseArgs: --quic-idle-ms parses, and refuses what ngtcp2 would invert" { | |||
| 375 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "0" }) == .usage_error); | 396 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "0" }) == .usage_error); |
| 376 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "soon" }) == .usage_error); | 397 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "soon" }) == .usage_error); |
| 377 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "99999999999" }) == .usage_error); | 398 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "99999999999" }) == .usage_error); |
| 378 | try std.testing.expect(parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms" }) == .usage_error); | 399 | // The bare-flag/missing-value case is covered once, for every |
| 400 | // value-taking flag, by the valueless-flags sweep above. | ||
| 379 | } | 401 | } |
| 380 | 402 | ||
| 381 | test "parseArgs: --version wins wherever it appears" { | 403 | test "parseArgs: --version wins wherever it appears" { |
| @@ -383,6 +405,25 @@ test "parseArgs: --version wins wherever it appears" { | |||
| 383 | try std.testing.expect(parse(&.{ "mux", "--sock", "/x", "--version" }) == .version); | 405 | try std.testing.expect(parse(&.{ "mux", "--sock", "/x", "--version" }) == .version); |
| 384 | } | 406 | } |
| 385 | 407 | ||
| 408 | test "parseArgs: --session rides every transport spelling" { | ||
| 409 | const s = parse(&.{ "mux", "--session", "b", "--sock", "/tmp/x.sock" }); | ||
| 410 | try std.testing.expectEqualStrings("b", s.attach.session); | ||
| 411 | const h = parse(&.{ "mux", "somehost", "--session", "b" }); | ||
| 412 | try std.testing.expectEqualStrings("b", h.host.session); | ||
| 413 | const q = parse(&.{ "mux", "quic://h:1", "--session", "b" }); | ||
| 414 | try std.testing.expectEqualStrings("b", q.quic.session); | ||
| 415 | } | ||
| 416 | |||
| 417 | test "parseArgs: a bad --session is a usage error, not a wire experiment" { | ||
| 418 | const r = parse(&.{ "mux", "--session", "has space" }); | ||
| 419 | try std.testing.expect(r == .usage_error); | ||
| 420 | } | ||
| 421 | |||
| 422 | test "parseArgs: no --session means the empty wire name (older-daemon compat)" { | ||
| 423 | const s = parse(&.{"mux"}); | ||
| 424 | try std.testing.expectEqualStrings("", s.attach.session); | ||
| 425 | } | ||
| 426 | |||
| 386 | // Forces semantic analysis of every pub decl under `zig build test`, so an | 427 | // Forces semantic analysis of every pub decl under `zig build test`, so an |
| 387 | // unreferenced decl must at least compile (the silent-module-loss hazard, | 428 | // unreferenced decl must at least compile (the silent-module-loss hazard, |
| 388 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | 429 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. |