a73x

0a285e41

feat: lazy QUIC bind — endpoint_req stands up an ephemeral-port listener on demand

a73x   2026-08-11 10:48

Commit message
feat: lazy QUIC bind — endpoint_req stands up an ephemeral-port listener on demand

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

build.zig
Old New
@@ -146,6 +146,9 @@ pub fn build(b: *std.Build) void {
146 server_mod.addImport("protocol", protocol_mod); 146 server_mod.addImport("protocol", protocol_mod);
147 server_mod.addImport("quic", quic_mod); 147 server_mod.addImport("quic", quic_mod);
148 server_mod.addImport("testtmp", testtmp_mod); 148 server_mod.addImport("testtmp", testtmp_mod);
149 // For endpoint_req's lazy bind: the default key path, resolved by the
150 // daemon itself when nobody handed it a --key.
151 server_mod.addImport("xdg", xdg_mod);
149 152
150 // The client's QUIC transport. Imports the listener's module for the 153 // The client's QUIC transport. Imports the listener's module for the
151 // pieces both ends must agree on (the key, the egress ring's lifetime 154 // pieces both ends must agree on (the key, the egress ring's lifetime
src/server.zig
Old New
@@ -10,6 +10,7 @@ const Engine = @import("engine").Engine;
10 const Pty = @import("pty").Pty; 10 const Pty = @import("pty").Pty;
11 const proto = @import("protocol"); 11 const proto = @import("protocol");
12 const quic = @import("quic"); 12 const quic = @import("quic");
13 const xdg = @import("xdg");
13 const TmpDir = @import("testtmp").TmpDir; 14 const TmpDir = @import("testtmp").TmpDir;
14 15
15 const max_clients = 8; 16 const max_clients = 8;
@@ -57,6 +58,17 @@ fn drainWaitMs(remaining: i64, quic_hint: ?i32) i32 {
57 return @max(1, @min(hint, rem)); 58 return @max(1, @min(hint, rem));
58 } 59 }
59 60
61 /// The port the kernel actually gave a listener, asked of the socket rather
62 /// than remembered: a lazy bind names port 0 and only getsockname knows what
63 /// came back. 0 on failure, which is the same "no port to announce" the
64 /// endpoint_reply already spells that way.
65 fn boundUdpPort(l: *quic.Listener) u16 {
66 var actual: std.posix.sockaddr.storage = undefined;
67 var len: std.posix.socklen_t = @sizeOf(@TypeOf(actual));
68 std.posix.getsockname(l.pollFd(), @ptrCast(&actual), &len) catch return 0;
69 return std.net.Address.initPosix(@ptrCast(@alignCast(&actual))).getPort();
70 }
71
60 const Wyhash = std.hash.Wyhash; 72 const Wyhash = std.hash.Wyhash;
61 73
62 const Stats = struct { 74 const Stats = struct {
@@ -385,6 +397,11 @@ pub const Server = struct {
385 /// QUIC is opt-in per invocation and the unix socket is unaffected by 397 /// QUIC is opt-in per invocation and the unix socket is unaffected by
386 /// its presence or absence. 398 /// its presence or absence.
387 quic_listener: ?*quic.Listener = null, 399 quic_listener: ?*quic.Listener = null,
400 /// True when the listener was bound lazily by endpoint_req, and is
401 /// therefore ours to deinit. A listener handed in by main.zig (the
402 /// explicit --quic path) has its own deferred deinit out there, and
403 /// freeing it twice would be a use-after-free at shutdown.
404 quic_owned: bool = false,
388 /// The pty's line-discipline bits as last put on the wire, or null 405 /// The pty's line-discipline bits as last put on the wire, or null
389 /// before the first poll. Deliberately "what clients have been told" 406 /// before the first poll. Deliberately "what clients have been told"
390 /// rather than "what the pty says": the two differ for exactly the span 407 /// rather than "what the pty says": the two differ for exactly the span
@@ -492,6 +509,16 @@ pub const Server = struct {
492 for (self.observers) |slot| { 509 for (self.observers) |slot| {
493 if (slot) |fd| std.posix.close(fd); 510 if (slot) |fd| std.posix.close(fd);
494 } 511 }
512 // After the client slots, never before: a QUIC sink closes its
513 // connection THROUGH the listener, so the listener has to outlive
514 // the slots that hold it. That is the ordering main.zig's defers
515 // give the explicit --quic path, kept here for the lazy one.
516 // Only a listener we bound ourselves — see quic_owned.
517 if (self.quic_owned) {
518 if (self.quic_listener) |l| l.deinit();
519 self.quic_listener = null;
520 self.quic_owned = false;
521 }
495 // Only unlink the socket path if it still refers to *our* socket — a 522 // Only unlink the socket path if it still refers to *our* socket — a
496 // newer daemon may have replaced the file since we bound it, and 523 // newer daemon may have replaced the file since we bound it, and
497 // deleting that one would hand its clients the same field incident 524 // deleting that one would hand its clients the same field incident
@@ -914,6 +941,71 @@ pub const Server = struct {
914 self.quic_listener = listener; 941 self.quic_listener = listener;
915 } 942 }
916 943
944 /// The endpoint_req answer: the bound QUIC port, standing a listener up
945 /// on demand if none exists. 0 means "could not", and the reason goes to
946 /// the daemon log (stderr) rather than into the frame — the asker can
947 /// do nothing with it but relay, and `muxd endpoint`'s announce-none
948 /// already tells the client everything it can act on.
949 fn endpointPort(self: *Server) u16 {
950 return self.endpointPortFrom(
951 std.posix.getenv("MUX_KEY_FILE"),
952 std.posix.getenv("XDG_CONFIG_HOME"),
953 std.posix.getenv("HOME"),
954 );
955 }
956
957 /// Env handed in, nothing read: the xdg *From pattern, for the same
958 /// reason — tests cannot setenv. Key resolution is MUX_KEY_FILE then
959 /// the default path; there is no --key half because a daemon being
960 /// asked lazily is one that was never handed a flag.
961 fn endpointPortFrom(
962 self: *Server,
963 env_key: ?[]const u8,
964 xdg_config_home: ?[]const u8,
965 home: ?[]const u8,
966 ) u16 {
967 if (self.quic_listener) |q| return boundUdpPort(q);
968 // "Set but empty" is unset, the same reading main.zig's envKey gives
969 // it: an empty path could only ever be a mistake.
970 const env: ?[]const u8 = if (env_key) |v|
971 (if (v.len == 0) null else v)
972 else
973 null;
974 var owned: ?[]const u8 = null;
975 defer if (owned) |p| self.alloc.free(p);
976 const key_path = env orelse blk: {
977 const dflt = xdg.keyPathFrom(self.alloc, xdg_config_home, home) catch break :blk null;
978 owned = dflt;
979 break :blk if (std.fs.cwd().access(dflt, .{})) |_| dflt else |_| @as(?[]const u8, null);
980 } orelse {
981 std.debug.print("muxd: endpoint_req: no key to listen with (run `muxd keygen`)\n", .{});
982 return 0;
983 };
984 const key = quic.Key.load(key_path) catch |err| {
985 std.debug.print(
986 "muxd: endpoint_req: cannot load key {s}: {s}\n",
987 .{ key_path, @errorName(err) },
988 );
989 return 0;
990 };
991 return self.lazyBindQuic(key) catch |err| {
992 std.debug.print("muxd: endpoint_req: cannot bind udp: {s}\n", .{@errorName(err)});
993 return 0;
994 };
995 }
996
997 /// Bind 0.0.0.0 on a kernel-assigned port and wire it in. The poll loop
998 /// re-reads `quic_listener` every iteration, so there is no loop surgery
999 /// here — setting the field IS the integration.
1000 fn lazyBindQuic(self: *Server, key: quic.Key) !u16 {
1001 const addr = try std.net.Address.parseIp("0.0.0.0", 0);
1002 const l = try quic.Listener.bind(self.alloc, addr, key, quic.default_idle_ms);
1003 l.setHandler(self.quicHandler());
1004 self.quic_listener = l;
1005 self.quic_owned = true;
1006 return boundUdpPort(l);
1007 }
1008
917 /// One frame, on the stack, for the two places the QUIC path has to 1009 /// One frame, on the stack, for the two places the QUIC path has to
918 /// speak before a client slot exists. 1010 /// speak before a client slot exists.
919 fn frameBytes(t: proto.MsgType, payload: []const u8) [6]u8 { 1011 fn frameBytes(t: proto.MsgType, payload: []const u8) [6]u8 {
@@ -1172,6 +1264,13 @@ pub const Server = struct {
1172 // authority an attached client lacks — see the .input arm above, 1264 // authority an attached client lacks — see the .input arm above,
1173 // which already writes to the pty master. 1265 // which already writes to the pty master.
1174 .stop_req => shutdown_flag.store(true, .release), 1266 .stop_req => shutdown_flag.store(true, .release),
1267 // The other half of the observer arm below, here for the same
1268 // reason stop_req's is: the verb means the same thing on any
1269 // connection. Replies through the queue like stats does.
1270 .endpoint_req => {
1271 const payload = proto.encodeEndpointReply(self.endpointPort());
1272 _ = self.queueFrame(i, .endpoint_reply, &payload);
1273 },
1175 .debug_dump => { 1274 .debug_dump => {
1176 const dump = self.buildDump(frame.payload) catch { 1275 const dump = self.buildDump(frame.payload) catch {
1177 self.dropClient(i); 1276 self.dropClient(i);
@@ -1234,6 +1333,11 @@ pub const Server = struct {
1234 // Where `muxd stop` actually lands, since it never attaches. 1333 // Where `muxd stop` actually lands, since it never attaches.
1235 // Same signal path as the client arm. 1334 // Same signal path as the client arm.
1236 .stop_req => shutdown_flag.store(true, .release), 1335 .stop_req => shutdown_flag.store(true, .release),
1336 // Where `muxd endpoint` actually lands, since it never attaches.
1337 .endpoint_req => {
1338 const payload = proto.encodeEndpointReply(self.endpointPort());
1339 proto.writeFrame(fd, .endpoint_reply, &payload) catch self.dropObserver(i);
1340 },
1237 else => {}, 1341 else => {},
1238 } 1342 }
1239 } 1343 }
@@ -4318,3 +4422,144 @@ test "Server: stop_req from an attached client is honored too" {
4318 } 4422 }
4319 try std.testing.expect(shutdown_flag.load(.acquire)); 4423 try std.testing.expect(shutdown_flag.load(.acquire));
4320 } 4424 }
4425
4426 // ---------------------------------------------------------------------------
4427 // endpoint_req: the lazy QUIC bind.
4428 //
4429 // Consolidated into two tests on purpose. `quic.Listener` keeps a
4430 // process-global "one listener at a time" latch, so every binding test has to
4431 // give it back before the next one asks — fewer tests is fewer places that
4432 // can fail to.
4433 // ---------------------------------------------------------------------------
4434
4435 /// Pump the daemon until a frame of `want` arrives on `fd`, or the budget of
4436 /// iterations runs out. Single-threaded on purpose: the loop that services
4437 /// the socket is the same one that has to answer, so the pump and the read
4438 /// have to interleave. Caller owns the returned frame.
4439 fn awaitFrame(
4440 alloc: std.mem.Allocator,
4441 srv: *Server,
4442 fd: std.posix.fd_t,
4443 want: proto.MsgType,
4444 iters: usize,
4445 ) !?proto.Frame {
4446 var i: usize = 0;
4447 while (i < iters) : (i += 1) {
4448 _ = try srv.pumpOnce(5);
4449 var pfd = [_]std.posix.pollfd{
4450 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
4451 };
4452 if ((std.posix.poll(&pfd, 1) catch 0) == 0) continue;
4453 const frame = (try proto.readFrame(alloc, fd)) orelse return null;
4454 if (frame.type == want) return frame;
4455 // Anything else on the way (a snapshot for the attached half of this
4456 // test) is not what was asked for; drop it and keep pumping.
4457 frame.deinit(alloc);
4458 }
4459 return null;
4460 }
4461
4462 test "Server: endpoint_req binds a listener lazily, answers the same port on both dispatches, and deinit hands it back" {
4463 const alloc = std.testing.allocator;
4464
4465 var tmp = try TmpDir.make();
4466 defer tmp.cleanup();
4467 var kbuf: [128]u8 = undefined;
4468 const key_path = try std.fmt.bufPrint(&kbuf, "{s}/key", .{tmp.path()});
4469 try xdg.writeNewKey(key_path);
4470 const key = try quic.Key.load(key_path);
4471
4472 var sbuf: [128]u8 = undefined;
4473 const sock_path = try std.fmt.bufPrint(&sbuf, "{s}/ep.sock", .{tmp.path()});
4474
4475 // Scoped so srv.deinit() runs before the release check below, and still
4476 // runs if an assertion inside fails.
4477 var first_port: u16 = 0;
4478 {
4479 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
4480 defer srv.deinit();
4481
4482 // Key resolution first, through the env seam: MUX_KEY_FILE's value
4483 // handed in rather than set, since tests cannot setenv.
4484 first_port = srv.endpointPortFrom(key_path, null, null);
4485 try std.testing.expect(first_port != 0);
4486 try std.testing.expect(srv.quic_listener != null);
4487
4488 // Now the wire, on a listener that already exists — which is what
4489 // keeps this half from depending on the test machine having a real
4490 // ~/.config/mux/key. The observer arm is the load-bearing one:
4491 // `muxd endpoint` never attaches.
4492 const obs = try std.net.connectUnixSocket(sock_path);
4493 defer obs.close();
4494 try proto.writeFrame(obs.handle, .endpoint_req, "");
4495 const reply = (try awaitFrame(alloc, &srv, obs.handle, .endpoint_reply, 200)) orelse
4496 return error.NoEndpointReply;
4497 defer reply.deinit(alloc);
4498 try std.testing.expectEqual(first_port, try proto.decodeEndpointReply(reply.payload));
4499
4500 // Asked twice, answered the same: a second bind attempt would be
4501 // refused by the process-global latch and surface as port 0, so
4502 // equality is the stronger claim than "nonzero again".
4503 try proto.writeFrame(obs.handle, .endpoint_req, "");
4504 const reply2 = (try awaitFrame(alloc, &srv, obs.handle, .endpoint_reply, 200)) orelse
4505 return error.NoSecondEndpointReply;
4506 defer reply2.deinit(alloc);
4507 try std.testing.expectEqual(first_port, try proto.decodeEndpointReply(reply2.payload));
4508
4509 // The attached-client arm answers the same verb the same way, on a
4510 // separate connection that attaches first.
4511 const cl = try std.net.connectUnixSocket(sock_path);
4512 defer cl.close();
4513 try proto.writeFrame(cl.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
4514 try proto.writeFrame(cl.handle, .endpoint_req, "");
4515 const reply3 = (try awaitFrame(alloc, &srv, cl.handle, .endpoint_reply, 400)) orelse
4516 return error.NoAttachedEndpointReply;
4517 defer reply3.deinit(alloc);
4518 try std.testing.expectEqual(first_port, try proto.decodeEndpointReply(reply3.payload));
4519 }
4520
4521 // A lazily bound listener is the server's to free, and this is the
4522 // observable for that: the latch is only released by a deinit that
4523 // happened, so a fresh bind succeeding proves the old one is gone.
4524 const addr = try std.net.Address.parseIp("0.0.0.0", 0);
4525 const l2 = try quic.Listener.bind(alloc, addr, key, quic.default_idle_ms);
4526 l2.deinit();
4527 }
4528
4529 test "Server: endpointPortFrom refuses without a key, survives it, and prefers a listener already bound" {
4530 const alloc = std.testing.allocator;
4531
4532 var tmp = try TmpDir.make();
4533 defer tmp.cleanup();
4534 var kbuf: [128]u8 = undefined;
4535 const key_path = try std.fmt.bufPrint(&kbuf, "{s}/key", .{tmp.path()});
4536 try xdg.writeNewKey(key_path);
4537 const key = try quic.Key.load(key_path);
4538
4539 var sbuf: [128]u8 = undefined;
4540 const sock_path = try std.fmt.bufPrint(&sbuf, "{s}/ep2.sock", .{tmp.path()});
4541
4542 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
4543 defer srv.deinit();
4544
4545 // No env at all: keyPathFrom has nothing to build a default from, so
4546 // there is no key and the answer is 0 rather than a crash.
4547 try std.testing.expectEqual(@as(u16, 0), srv.endpointPortFrom(null, null, null));
4548 // Named but absent: Key.load fails, same answer.
4549 var mbuf: [128]u8 = undefined;
4550 const missing = try std.fmt.bufPrint(&mbuf, "{s}/absent-key", .{tmp.path()});
4551 try std.testing.expectEqual(@as(u16, 0), srv.endpointPortFrom(missing, null, null));
4552 // A default path pointing at a directory with no key in it: resolvable,
4553 // not present, so the access check refuses before Key.load is asked.
4554 try std.testing.expectEqual(@as(u16, 0), srv.endpointPortFrom(null, tmp.path(), null));
4555
4556 // Refusing left no half-state behind: nothing was bound, and the daemon
4557 // is still able to bind when a real key does turn up.
4558 try std.testing.expect(srv.quic_listener == null);
4559 const port = try srv.lazyBindQuic(key);
4560 try std.testing.expect(port != 0);
4561
4562 // With a listener in hand the answer is the bound port, and key
4563 // resolution is never reached — all-null env would otherwise return 0.
4564 try std.testing.expectEqual(port, srv.endpointPortFrom(null, null, null));
4565 }