a73x

316ac48e

feat(mux): --session NAME on every transport spelling

a73x   2026-08-15 12:21

Commit message
feat(mux): --session NAME on every transport spelling

--session threads through parseArgs (validated with proto.validSessionName,
a bad name is a usage error rather than becoming wire bytes) into the
.attach/.host/.quic ParseResult variants, and from there into
client.attach/session/reconnect, which now carry a session_name through to
every encodeAttachNamed call — initial attach, delta-resync re-attach, and
reconnect. An empty name (no --session given) is the wire-compatible
default: encodeAttachNamed("") writes the same fixed 20 bytes encodeAttach
always did, so mux keeps working unmodified against a pre-M18 daemon.

build.zig: mux_main.zig now imports protocol directly, to validate the
name at parse time the same way muxa.zig already does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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.