0a285e41
feat: lazy QUIC bind — endpoint_req stands up an ephemeral-port listener on demand
a73x 2026-08-11 10:48
Commit message
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 | } | ||