593f8fa3
feat(muxa): QUIC transport — same verbs, remote daemons, one reconnect re-issue
a73x 2026-08-13 18:47
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -416,9 +416,12 @@ pub fn build(b: *std.Build) void { | |||
| 416 | linkQuic(b, exe, quic); | 416 | linkQuic(b, exe, quic); |
| 417 | b.installArtifact(exe); | 417 | b.installArtifact(exe); |
| 418 | 418 | ||
| 419 | // The agent-facing client. Protocol and nothing else: it speaks frames | 419 | // The agent-facing client. It speaks frames and owns no terminal, which |
| 420 | // over the unix socket and owns no terminal, which is the whole point — | 420 | // is the whole point — it attaches at 0x0 and never claims the grid. |
| 421 | // it attaches at 0x0 and never claims the grid. | 421 | // The transport modules are the CLI client's, minus everything that |
| 422 | // renders: `quic_client` for the remote arm and `xdg` for the one | ||
| 423 | // key-resolution rule all three binaries obey. Deliberately still no | ||
| 424 | // engine and no replica — muxa has nothing to draw. | ||
| 422 | const muxa_mod = b.createModule(.{ | 425 | const muxa_mod = b.createModule(.{ |
| 423 | .root_source_file = b.path("src/muxa.zig"), | 426 | .root_source_file = b.path("src/muxa.zig"), |
| 424 | .target = target, | 427 | .target = target, |
| @@ -427,6 +430,8 @@ pub fn build(b: *std.Build) void { | |||
| 427 | }); | 430 | }); |
| 428 | muxa_mod.addImport("protocol", protocol_mod); | 431 | muxa_mod.addImport("protocol", protocol_mod); |
| 429 | muxa_mod.addImport("sockpath", sockpath_mod); | 432 | muxa_mod.addImport("sockpath", sockpath_mod); |
| 433 | muxa_mod.addImport("quic_client", quic_client_mod); | ||
| 434 | muxa_mod.addImport("xdg", xdg_mod); | ||
| 430 | 435 | ||
| 431 | const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod }); | 436 | const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod }); |
| 432 | mux_exe.use_llvm = true; | 437 | mux_exe.use_llvm = true; |
| @@ -438,6 +443,8 @@ pub fn build(b: *std.Build) void { | |||
| 438 | const muxa_exe = b.addExecutable(.{ .name = "muxa", .root_module = muxa_mod }); | 443 | const muxa_exe = b.addExecutable(.{ .name = "muxa", .root_module = muxa_mod }); |
| 439 | muxa_exe.use_llvm = true; | 444 | muxa_exe.use_llvm = true; |
| 440 | muxa_exe.use_lld = true; | 445 | muxa_exe.use_lld = true; |
| 446 | // The agent client dials remote daemons now, so it carries the stack too. | ||
| 447 | linkQuic(b, muxa_exe, quic); | ||
| 441 | b.installArtifact(muxa_exe); | 448 | b.installArtifact(muxa_exe); |
| 442 | 449 | ||
| 443 | const rawmode_exe = b.addExecutable(.{ .name = "rawmode", .root_module = rawmode_mod }); | 450 | const rawmode_exe = b.addExecutable(.{ .name = "rawmode", .root_module = rawmode_mod }); |
| @@ -585,7 +592,7 @@ pub fn build(b: *std.Build) void { | |||
| 585 | if (mod == server_mod or mod == quic_mod or mod == quic_server_mod or | 592 | if (mod == server_mod or mod == quic_mod or mod == quic_server_mod or |
| 586 | mod == exe_mod or mod == client_mod or mod == mux_mod or | 593 | mod == exe_mod or mod == client_mod or mod == mux_mod or |
| 587 | mod == quic_client_mod or mod == webhub_mod or | 594 | mod == quic_client_mod or mod == webhub_mod or |
| 588 | mod == webhub_main_mod) linkQuic(b, t, quic); | 595 | mod == muxa_mod or mod == webhub_main_mod) linkQuic(b, t, quic); |
| 589 | test_step.dependOn(&b.addRunArtifact(t).step); | 596 | test_step.dependOn(&b.addRunArtifact(t).step); |
| 590 | } | 597 | } |
| 591 | 598 | ||
src/muxa.zig
| Old | New | ||
|---|---|---|---|
| @@ -5,9 +5,12 @@ | |||
| 5 | const std = @import("std"); | 5 | const std = @import("std"); |
| 6 | const proto = @import("protocol"); | 6 | const proto = @import("protocol"); |
| 7 | const sockpath = @import("sockpath"); | 7 | const sockpath = @import("sockpath"); |
| 8 | const quic_client = @import("quic_client"); | ||
| 9 | const xdg = @import("xdg"); | ||
| 8 | 10 | ||
| 9 | const usage = | 11 | const usage = |
| 10 | \\usage: muxa <verb> [--sock PATH] [--settle MS] [--timeout MS] [--vt] [args] | 12 | \\usage: muxa <verb> [--sock PATH | --quic HOST[:PORT] [--key PATH]] |
| 13 | \\ [--settle MS] [--timeout MS] [--vt] [args] | ||
| 11 | \\verbs: | 14 | \\verbs: |
| 12 | \\ status session snapshot as JSON | 15 | \\ status session snapshot as JSON |
| 13 | \\ capture current grid as text (--vt for styled) | 16 | \\ capture current grid as text (--vt for styled) |
| @@ -20,6 +23,15 @@ const usage = | |||
| 20 | const Opts = struct { | 23 | const Opts = struct { |
| 21 | verb: enum { status, capture, send, run, @"await" }, | 24 | verb: enum { status, capture, send, run, @"await" }, |
| 22 | sock: ?[]const u8 = null, | 25 | sock: ?[]const u8 = null, |
| 26 | /// `HOST[:PORT]` of a remote daemon's QUIC listener. The verbs are | ||
| 27 | /// identical over it — same frames, same JSON — which is the whole | ||
| 28 | /// claim: an agent driving a session over a WAN types one more flag. | ||
| 29 | quic: ?[]const u8 = null, | ||
| 30 | /// `--key PATH`, the highest-priority spelling of the QUIC key. Null | ||
| 31 | /// does NOT mean "no key": `$MUX_KEY_FILE` and the XDG default are | ||
| 32 | /// still to be tried, and neither is parse's to look at (xdg.pickKey | ||
| 33 | /// and xdg.resolveKeyPath own that order, as they do for muxd and mux). | ||
| 34 | key: ?[]const u8 = null, | ||
| 23 | settle_ms: u32 = 0, | 35 | settle_ms: u32 = 0, |
| 24 | // Never 0 by default: the daemon reads a 0 timeout on await_req as "no | 36 | // Never 0 by default: the daemon reads a 0 timeout on await_req as "no |
| 25 | // bound at all" (documented on AwaitReq), so a muxa that defaulted to 0 | 37 | // bound at all" (documented on AwaitReq), so a muxa that defaulted to 0 |
| @@ -50,6 +62,14 @@ fn parseArgs(args: []const [:0]const u8) ?Opts { | |||
| 50 | i += 1; | 62 | i += 1; |
| 51 | if (i >= args.len) return null; | 63 | if (i >= args.len) return null; |
| 52 | o.sock = args[i]; | 64 | o.sock = args[i]; |
| 65 | } else if (std.mem.eql(u8, a, "--quic")) { | ||
| 66 | i += 1; | ||
| 67 | if (i >= args.len) return null; | ||
| 68 | o.quic = args[i]; | ||
| 69 | } else if (std.mem.eql(u8, a, "--key")) { | ||
| 70 | i += 1; | ||
| 71 | if (i >= args.len) return null; | ||
| 72 | o.key = args[i]; | ||
| 53 | } else if (std.mem.eql(u8, a, "--settle")) { | 73 | } else if (std.mem.eql(u8, a, "--settle")) { |
| 54 | i += 1; | 74 | i += 1; |
| 55 | if (i >= args.len) return null; | 75 | if (i >= args.len) return null; |
| @@ -64,6 +84,15 @@ fn parseArgs(args: []const [:0]const u8) ?Opts { | |||
| 64 | o.arg = a; | 84 | o.arg = a; |
| 65 | } else return null; | 85 | } else return null; |
| 66 | } | 86 | } |
| 87 | // Name ONE transport. A `--sock` silently ignored beside a `--quic` | ||
| 88 | // would send an agent's frames somewhere other than the socket it | ||
| 89 | // named, and the two answers differ — this is the mistake `mux` | ||
| 90 | // refuses as `.conflict` for the same reason. | ||
| 91 | if (o.quic != null and o.sock != null) return null; | ||
| 92 | // A key with nothing to authenticate to, refused exactly where muxd | ||
| 93 | // refuses it: there is no reading of `--key` without `--quic` that | ||
| 94 | // makes it sensible, and the unix socket has no key at all. | ||
| 95 | if (o.key != null and o.quic == null) return null; | ||
| 67 | return o; | 96 | return o; |
| 68 | } | 97 | } |
| 69 | 98 | ||
| @@ -164,26 +193,185 @@ test "parseArgs: -- hands the rest to the verb, flags and all" { | |||
| 164 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&two)); | 193 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&two)); |
| 165 | } | 194 | } |
| 166 | 195 | ||
| 196 | test "parseArgs: --quic and --key, and the pairs that make no sense" { | ||
| 197 | const q = [_][:0]const u8{ "muxa", "status", "--quic", "10.0.0.2:4433" }; | ||
| 198 | const oq = parseArgs(&q).?; | ||
| 199 | try std.testing.expectEqualStrings("10.0.0.2:4433", oq.quic.?); | ||
| 200 | // Not naming a key is not an error here: MUX_KEY_FILE and the XDG | ||
| 201 | // default are still to be tried, and parse may look at neither. | ||
| 202 | try std.testing.expectEqual(@as(?[]const u8, null), oq.key); | ||
| 203 | |||
| 204 | const k = [_][:0]const u8{ "muxa", "run", "--quic", "box:4433", "--key", "/k", "make test" }; | ||
| 205 | const ok = parseArgs(&k).?; | ||
| 206 | try std.testing.expectEqualStrings("box:4433", ok.quic.?); | ||
| 207 | try std.testing.expectEqualStrings("/k", ok.key.?); | ||
| 208 | try std.testing.expectEqualStrings("make test", ok.arg.?); | ||
| 209 | |||
| 210 | // A flag at the end of argv with no value is refused, like every other | ||
| 211 | // value-taking flag this parser has. | ||
| 212 | const dangling_q = [_][:0]const u8{ "muxa", "status", "--quic" }; | ||
| 213 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling_q)); | ||
| 214 | const dangling_k = [_][:0]const u8{ "muxa", "status", "--quic", "b:1", "--key" }; | ||
| 215 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling_k)); | ||
| 216 | |||
| 217 | // Two transports named at once: which one an agent's frames went to | ||
| 218 | // would be this parser's private business, and it is not entitled to | ||
| 219 | // one — the same refusal `mux` spells as `.conflict`. | ||
| 220 | const both = [_][:0]const u8{ "muxa", "status", "--sock", "/tmp/s", "--quic", "b:1" }; | ||
| 221 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&both)); | ||
| 222 | |||
| 223 | // A key with nothing to authenticate to, refused exactly where muxd | ||
| 224 | // refuses it. | ||
| 225 | const lonely_key = [_][:0]const u8{ "muxa", "status", "--key", "/k" }; | ||
| 226 | try std.testing.expectEqual(@as(?Opts, null), parseArgs(&lonely_key)); | ||
| 227 | |||
| 228 | // Neither named is the ordinary local case and stays silent. | ||
| 229 | const neither = [_][:0]const u8{ "muxa", "status" }; | ||
| 230 | try std.testing.expectEqual(@as(?[]const u8, null), parseArgs(&neither).?.quic); | ||
| 231 | } | ||
| 232 | |||
| 233 | /// A live QUIC connection plus everything a REDIAL of it needs. The dial | ||
| 234 | /// coordinates are kept rather than re-derived because the reconnect below | ||
| 235 | /// happens mid-verb, long after argv and the key file have been read: a | ||
| 236 | /// second resolution could pick a different key (the file having been | ||
| 237 | /// rotated under us) and would then fail the handshake for a reason that | ||
| 238 | /// has nothing to do with why the first connection died. | ||
| 239 | const Quic = struct { | ||
| 240 | cl: *quic_client.Client, | ||
| 241 | addr: std.net.Address, | ||
| 242 | key: quic_client.Key, | ||
| 243 | idle_ms: u32, | ||
| 244 | /// Wall-clock milliseconds the FIRST handshake took, which is this | ||
| 245 | /// client's only measurement of how far away the daemon is. `graceMs` | ||
| 246 | /// turns it into the await grace window; see there. | ||
| 247 | connect_ms: i64, | ||
| 248 | }; | ||
| 249 | |||
| 167 | const Conn = struct { | 250 | const Conn = struct { |
| 168 | fd: std.posix.fd_t, | 251 | /// Which transport carries the frames. The verbs above this line are |
| 252 | /// written once and know nothing about the difference — that is the | ||
| 253 | /// claim `--quic` makes, and this union is where it is kept. | ||
| 254 | link: union(enum) { | ||
| 255 | fd: std.posix.fd_t, | ||
| 256 | quic: Quic, | ||
| 257 | }, | ||
| 258 | /// The allocator the transport itself works with: the QUIC arm's frame | ||
| 259 | /// staging and its redials. Distinct from the `alloc` awaitFrame takes, | ||
| 260 | /// which owns the frame handed BACK to the caller — one process, one | ||
| 261 | /// arena, so they are the same allocator today and separate in the | ||
| 262 | /// signature because they answer to different owners. | ||
| 263 | alloc: std.mem.Allocator, | ||
| 169 | /// The code from the `exit_status` frame that ended a wait, set the | 264 | /// The code from the `exit_status` frame that ended a wait, set the |
| 170 | /// moment awaitFrame returns error.SessionExited. The frame is the | 265 | /// moment awaitFrame returns error.SessionExited. The frame is the |
| 171 | /// session's last word and carries the only copy of the code, so it is | 266 | /// session's last word and carries the only copy of the code, so it is |
| 172 | /// captured here rather than thrown away with the frame; callers read | 267 | /// captured here rather than thrown away with the frame; callers read |
| 173 | /// it to turn the error into an answer. | 268 | /// it to turn the error into an answer. |
| 174 | session_exit: ?u8 = null, | 269 | session_exit: ?u8 = null, |
| 270 | /// One reconnect per process, spent or not. See `awaitReissuing`. | ||
| 271 | reconnected: bool = false, | ||
| 175 | 272 | ||
| 176 | fn open(sock_path: []const u8) !Conn { | 273 | fn open(alloc: std.mem.Allocator, sock_path: []const u8) !Conn { |
| 177 | const s = try std.net.connectUnixSocket(sock_path); | 274 | const s = try std.net.connectUnixSocket(sock_path); |
| 178 | return .{ .fd = s.handle }; | 275 | return .{ .link = .{ .fd = s.handle }, .alloc = alloc }; |
| 276 | } | ||
| 277 | |||
| 278 | /// Dial a daemon's QUIC listener and wait out the handshake before | ||
| 279 | /// returning. The wait is not optional and not the caller's: `connect` | ||
| 280 | /// only creates state — the first flight has not been answered — and a | ||
| 281 | /// `send` on a connection with no stream yet accepts zero bytes and | ||
| 282 | /// says so by returning 0, which would surface as a frame that | ||
| 283 | /// silently never left. Same reason client.zig's quicTransport waits. | ||
| 284 | fn openQuic( | ||
| 285 | alloc: std.mem.Allocator, | ||
| 286 | addr: std.net.Address, | ||
| 287 | key: quic_client.Key, | ||
| 288 | idle_ms: u32, | ||
| 289 | deadline_ms: i64, | ||
| 290 | ) !Conn { | ||
| 291 | const started = std.time.milliTimestamp(); | ||
| 292 | const cl = try quic_client.Client.connect(alloc, addr, key, idle_ms); | ||
| 293 | errdefer cl.deinit(); | ||
| 294 | try waitReady(cl, deadline_ms); | ||
| 295 | return .{ | ||
| 296 | .link = .{ .quic = .{ | ||
| 297 | .cl = cl, | ||
| 298 | .addr = addr, | ||
| 299 | .key = key, | ||
| 300 | .idle_ms = idle_ms, | ||
| 301 | .connect_ms = elapsed(started), | ||
| 302 | } }, | ||
| 303 | .alloc = alloc, | ||
| 304 | }; | ||
| 179 | } | 305 | } |
| 180 | 306 | ||
| 181 | fn close(self: *Conn) void { | 307 | fn close(self: *Conn) void { |
| 182 | std.posix.close(self.fd); | 308 | switch (self.link) { |
| 309 | .fd => |fd| std.posix.close(fd), | ||
| 310 | .quic => self.link.quic.cl.deinit(), | ||
| 311 | } | ||
| 312 | } | ||
| 313 | |||
| 314 | /// How much longer than the daemon this client waits for an await, | ||
| 315 | /// which over a network is a function of how far away the daemon is. | ||
| 316 | /// | ||
| 317 | /// The unix arm keeps the flat 2s (see await_grace_ms). The QUIC arm | ||
| 318 | /// adds nothing until four round trips of its own handshake exceed | ||
| 319 | /// that, which on a LAN or loopback is never and on a 200ms link is | ||
| 320 | /// most of a second: the daemon's timeout window opens when it READS | ||
| 321 | /// the request, a whole flight after this process started counting, | ||
| 322 | /// and closes a flight before the reply lands. Four, not two, because | ||
| 323 | /// the request and the reply are not the only flights in the trip — | ||
| 324 | /// the daemon may be settling a command when the timeout fires. | ||
| 325 | /// | ||
| 326 | /// Capped, because `connect_ms` is bounded only by the handshake wait: | ||
| 327 | /// a connection that took fifteen seconds to come up would otherwise | ||
| 328 | /// buy a minute of grace, and past this cap we are no longer waiting | ||
| 329 | /// for the daemon's answer but for a network that has already shown it | ||
| 330 | /// cannot carry one. | ||
| 331 | fn graceMs(self: *const Conn) i64 { | ||
| 332 | return switch (self.link) { | ||
| 333 | .fd => await_grace_ms, | ||
| 334 | .quic => |q| @min(grace_cap_ms, @max(await_grace_ms, 4 * q.connect_ms)), | ||
| 335 | }; | ||
| 183 | } | 336 | } |
| 184 | 337 | ||
| 185 | fn sendFrame(self: *Conn, t: proto.MsgType, payload: []const u8) !void { | 338 | fn sendFrame(self: *Conn, t: proto.MsgType, payload: []const u8) !void { |
| 186 | try proto.writeFrame(self.fd, t, payload); | 339 | switch (self.link) { |
| 340 | .fd => |fd| try proto.writeFrame(fd, t, payload), | ||
| 341 | .quic => try self.sendFrameQuic(t, payload), | ||
| 342 | } | ||
| 343 | } | ||
| 344 | |||
| 345 | /// The frame's wire bytes into the egress ring, all of them. | ||
| 346 | /// | ||
| 347 | /// `send` takes what fits and reports how much (a bounded ring: the | ||
| 348 | /// caller holds the backlog), so a short take is not a failure and not | ||
| 349 | /// ignorable either — the tail is offered again once acks have made | ||
| 350 | /// room. muxa's frames are a handful of bytes against a 256KB ring, so | ||
| 351 | /// this loop is expected never to turn twice; it is here because the | ||
| 352 | /// alternative to looping is a frame that leaves half-written, which | ||
| 353 | /// the peer reads as a corrupt stream rather than as an error. | ||
| 354 | fn sendFrameQuic(self: *Conn, t: proto.MsgType, payload: []const u8) !void { | ||
| 355 | var buf: std.ArrayList(u8) = .empty; | ||
| 356 | defer buf.deinit(self.alloc); | ||
| 357 | try proto.appendFrame(&buf, self.alloc, t, payload); | ||
| 358 | |||
| 359 | const q = &self.link.quic; | ||
| 360 | const deadline = std.time.milliTimestamp() + send_flush_ms; | ||
| 361 | var off: usize = 0; | ||
| 362 | while (off < buf.items.len) { | ||
| 363 | if (q.cl.dead) return error.ConnectionLost; | ||
| 364 | off += q.cl.send(buf.items[off..]); | ||
| 365 | if (off == buf.items.len) return; | ||
| 366 | if (std.time.milliTimestamp() >= deadline) return error.SendStalled; | ||
| 367 | // The ring is full: only the peer's acks can empty it, and they | ||
| 368 | // arrive through pump. Polling first keeps this from spinning. | ||
| 369 | var fds = [_]std.posix.pollfd{ | ||
| 370 | .{ .fd = q.cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 371 | }; | ||
| 372 | _ = std.posix.poll(&fds, q.cl.timeoutMs(50)) catch return error.ConnectionLost; | ||
| 373 | q.cl.pump(); | ||
| 374 | } | ||
| 187 | } | 375 | } |
| 188 | 376 | ||
| 189 | /// Read frames until one of type `want` arrives (snapshots, deltas and | 377 | /// Read frames until one of type `want` arrives (snapshots, deltas and |
| @@ -194,28 +382,44 @@ const Conn = struct { | |||
| 194 | /// the reply we are waiting for is never coming, and the reason is an | 382 | /// the reply we are waiting for is never coming, and the reason is an |
| 195 | /// answer — the session ran its last command — not a transport | 383 | /// answer — the session ran its last command — not a transport |
| 196 | /// failure. Callers get error.SessionExited plus `session_exit`. | 384 | /// failure. Callers get error.SessionExited plus `session_exit`. |
| 197 | /// | ||
| 198 | /// Debt: only the WAIT is deadline-bounded, not the read. Once poll | ||
| 199 | /// says a frame has begun, readFrame's readExact blocks until the whole | ||
| 200 | /// payload lands, so a peer that stalls mid-frame outlives the | ||
| 201 | /// deadline. Harmless over a local socket where the daemon writes whole | ||
| 202 | /// frames at once; it becomes real when a network is under this (QUIC, | ||
| 203 | /// Task 10) and wants a nonblocking fd with a partial-frame buffer. | ||
| 204 | fn awaitFrame( | 385 | fn awaitFrame( |
| 205 | self: *Conn, | 386 | self: *Conn, |
| 206 | alloc: std.mem.Allocator, | 387 | alloc: std.mem.Allocator, |
| 207 | want: proto.MsgType, | 388 | want: proto.MsgType, |
| 208 | deadline_ms: i64, | 389 | deadline_ms: i64, |
| 209 | ) !proto.Frame { | 390 | ) !proto.Frame { |
| 391 | return switch (self.link) { | ||
| 392 | .fd => self.awaitFrameFd(alloc, want, deadline_ms), | ||
| 393 | .quic => self.awaitFrameQuic(alloc, want, deadline_ms), | ||
| 394 | }; | ||
| 395 | } | ||
| 396 | |||
| 397 | /// Debt, deliberately retained on THIS arm: only the wait is | ||
| 398 | /// deadline-bounded, not the read. Once poll says a frame has begun, | ||
| 399 | /// readFrame's readExact blocks until the whole payload lands, so a | ||
| 400 | /// peer that stalls mid-frame outlives the deadline. That is harmless | ||
| 401 | /// over a local socket, where the daemon writes whole frames at once | ||
| 402 | /// and a stall means a daemon that has stopped running rather than a | ||
| 403 | /// path that has stopped delivering — and buying it off would mean a | ||
| 404 | /// second partial-frame buffer for a case that cannot happen here. | ||
| 405 | /// The QUIC arm below, where a network IS under the transport, does | ||
| 406 | /// not have the luxury and does not take it. | ||
| 407 | fn awaitFrameFd( | ||
| 408 | self: *Conn, | ||
| 409 | alloc: std.mem.Allocator, | ||
| 410 | want: proto.MsgType, | ||
| 411 | deadline_ms: i64, | ||
| 412 | ) !proto.Frame { | ||
| 413 | const fd = self.link.fd; | ||
| 210 | while (true) { | 414 | while (true) { |
| 211 | const now = std.time.milliTimestamp(); | 415 | const now = std.time.milliTimestamp(); |
| 212 | if (now >= deadline_ms) return error.Timeout; | 416 | if (now >= deadline_ms) return error.Timeout; |
| 213 | var fds = [_]std.posix.pollfd{ | 417 | var fds = [_]std.posix.pollfd{ |
| 214 | .{ .fd = self.fd, .events = std.posix.POLL.IN, .revents = 0 }, | 418 | .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, |
| 215 | }; | 419 | }; |
| 216 | const n = try std.posix.poll(&fds, @intCast(@min(deadline_ms - now, 250))); | 420 | const n = try std.posix.poll(&fds, @intCast(@min(deadline_ms - now, 250))); |
| 217 | if (n == 0) continue; | 421 | if (n == 0) continue; |
| 218 | const frame = try proto.readFrame(alloc, self.fd) orelse return error.DaemonGone; | 422 | const frame = try proto.readFrame(alloc, fd) orelse return error.DaemonGone; |
| 219 | if (frame.type == want) return frame; | 423 | if (frame.type == want) return frame; |
| 220 | defer frame.deinit(alloc); | 424 | defer frame.deinit(alloc); |
| 221 | if (frame.type == .exit_status) { | 425 | if (frame.type == .exit_status) { |
| @@ -226,8 +430,260 @@ const Conn = struct { | |||
| 226 | } | 430 | } |
| 227 | } | 431 | } |
| 228 | } | 432 | } |
| 433 | |||
| 434 | /// The same wait with a network under it, and the difference is that | ||
| 435 | /// NOTHING here blocks on the transport: a datagram carries whatever | ||
| 436 | /// arrived, whole frames or a third of one, so the frames are | ||
| 437 | /// delimited out of the client's inbound buffer and a partial tail | ||
| 438 | /// simply stays there until the rest lands. A daemon that stops | ||
| 439 | /// mid-frame costs this loop the deadline it was given and not a | ||
| 440 | /// second more. | ||
| 441 | /// | ||
| 442 | /// Every buffered frame is taken before the next poll — a datagram | ||
| 443 | /// routinely carries several, and the reply may be the second — and | ||
| 444 | /// `dead` is checked only once the buffer is empty, so bytes that | ||
| 445 | /// arrived before the connection died are still delivered. | ||
| 446 | fn awaitFrameQuic( | ||
| 447 | self: *Conn, | ||
| 448 | alloc: std.mem.Allocator, | ||
| 449 | want: proto.MsgType, | ||
| 450 | deadline_ms: i64, | ||
| 451 | ) !proto.Frame { | ||
| 452 | const q = &self.link.quic; | ||
| 453 | while (true) { | ||
| 454 | q.cl.pump(); | ||
| 455 | while (try frameFrom(alloc, q.cl.inbound())) |got| { | ||
| 456 | q.cl.consume(got.consumed); | ||
| 457 | if (got.frame.type == want) return got.frame; | ||
| 458 | defer got.frame.deinit(alloc); | ||
| 459 | if (got.frame.type == .exit_status) { | ||
| 460 | self.session_exit = if (got.frame.payload.len >= 1) got.frame.payload[0] else null; | ||
| 461 | return error.SessionExited; | ||
| 462 | } | ||
| 463 | } | ||
| 464 | // Not `DaemonGone`: over a network the difference between "the | ||
| 465 | // daemon exited" and "the path to it went away" is not ours to | ||
| 466 | // claim, and the reconnect above only fires on this one. | ||
| 467 | if (q.cl.dead) return error.ConnectionLost; | ||
| 468 | const now = std.time.milliTimestamp(); | ||
| 469 | if (now >= deadline_ms) return error.Timeout; | ||
| 470 | var fds = [_]std.posix.pollfd{ | ||
| 471 | .{ .fd = q.cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 472 | }; | ||
| 473 | // Through timeoutMs, so ngtcp2's own timers — loss detection | ||
| 474 | // and, on a quiet await, the keepalive that keeps the idle | ||
| 475 | // timeout from firing under us — are serviced on schedule | ||
| 476 | // rather than whenever the daemon happens to say something. | ||
| 477 | const cap: i32 = @intCast(@min(deadline_ms - now, 250)); | ||
| 478 | _ = std.posix.poll(&fds, q.cl.timeoutMs(cap)) catch return error.ConnectionLost; | ||
| 479 | } | ||
| 480 | } | ||
| 481 | |||
| 482 | /// Redial the same coordinates and hand the connection over. The old | ||
| 483 | /// client is torn down only once the new one is up, so a redial that | ||
| 484 | /// fails leaves this Conn holding a live (if dead-ended) client rather | ||
| 485 | /// than a freed one — `close` runs either way. | ||
| 486 | fn reconnect(self: *Conn, deadline_ms: i64) !void { | ||
| 487 | const q = &self.link.quic; | ||
| 488 | const cl = try quic_client.Client.connect(self.alloc, q.addr, q.key, q.idle_ms); | ||
| 489 | errdefer cl.deinit(); | ||
| 490 | try waitReady(cl, deadline_ms); | ||
| 491 | q.cl.deinit(); | ||
| 492 | q.cl = cl; | ||
| 493 | self.reconnected = true; | ||
| 494 | } | ||
| 229 | }; | 495 | }; |
| 230 | 496 | ||
| 497 | /// One frame delimited out of `buf`, and how many bytes of it that took. | ||
| 498 | /// Null while the tail is still partial — a header that has not all | ||
| 499 | /// arrived, or a payload still in flight — which is the ordinary state of | ||
| 500 | /// a byte stream and never an error. | ||
| 501 | /// | ||
| 502 | /// Takes a plain slice rather than the client, so the delimiting can be | ||
| 503 | /// exercised against a canned buffer with no connection anywhere. The | ||
| 504 | /// shape is the daemon's `pushInbound` walk, which delimits the same | ||
| 505 | /// frames off the same wire from the other end. | ||
| 506 | fn frameFrom( | ||
| 507 | alloc: std.mem.Allocator, | ||
| 508 | buf: []const u8, | ||
| 509 | ) !?struct { frame: proto.Frame, consumed: usize } { | ||
| 510 | if (buf.len < proto.frame_header_len) return null; | ||
| 511 | const len = std.mem.readInt(u32, buf[1..5], .little); | ||
| 512 | // A length no frame can legitimately carry: the stream is not what we | ||
| 513 | // think it is, and reading on would allocate against a number the peer | ||
| 514 | // chose. Same bound and same verdict as the daemon's walk. | ||
| 515 | if (len > proto.max_payload) return error.FrameTooLarge; | ||
| 516 | if (buf.len < proto.frame_header_len + len) return null; | ||
| 517 | const payload = try alloc.alloc(u8, len); | ||
| 518 | errdefer alloc.free(payload); | ||
| 519 | @memcpy(payload, buf[proto.frame_header_len..][0..len]); | ||
| 520 | return .{ | ||
| 521 | .frame = .{ .type = @enumFromInt(buf[0]), .payload = payload }, | ||
| 522 | .consumed = proto.frame_header_len + len, | ||
| 523 | }; | ||
| 524 | } | ||
| 525 | |||
| 526 | test "frameFrom: a partial tail is not a frame and not an error" { | ||
| 527 | const alloc = std.testing.allocator; | ||
| 528 | |||
| 529 | // Nothing, and less than a header: the two shapes a datagram that | ||
| 530 | // carried the start of a frame leaves behind. | ||
| 531 | try std.testing.expect(try frameFrom(alloc, "") == null); | ||
| 532 | try std.testing.expect(try frameFrom(alloc, &[_]u8{ 0x0a, 1, 0 }) == null); | ||
| 533 | |||
| 534 | // A whole header whose payload is still in flight. This is the case a | ||
| 535 | // blocking read would have sat on: the length is known, the bytes are | ||
| 536 | // not here, and the answer is to wait rather than to read. | ||
| 537 | const partial = [_]u8{ @intFromEnum(proto.MsgType.input), 4, 0, 0, 0, 'a', 'b' }; | ||
| 538 | try std.testing.expect(try frameFrom(alloc, &partial) == null); | ||
| 539 | |||
| 540 | // The same bytes, completed. | ||
| 541 | const whole = [_]u8{ @intFromEnum(proto.MsgType.input), 4, 0, 0, 0, 'a', 'b', 'c', 'd' }; | ||
| 542 | const got = (try frameFrom(alloc, &whole)).?; | ||
| 543 | defer got.frame.deinit(alloc); | ||
| 544 | try std.testing.expectEqual(proto.MsgType.input, got.frame.type); | ||
| 545 | try std.testing.expectEqualStrings("abcd", got.frame.payload); | ||
| 546 | try std.testing.expectEqual(@as(usize, 9), got.consumed); | ||
| 547 | } | ||
| 548 | |||
| 549 | test "frameFrom: two frames in one buffer, walked by consumed" { | ||
| 550 | const alloc = std.testing.allocator; | ||
| 551 | // What a single datagram routinely carries: the push we skip and the | ||
| 552 | // reply we asked for. A walk that stopped after one would leave the | ||
| 553 | // answer sitting in the buffer while the deadline ran out. | ||
| 554 | var buf: std.ArrayList(u8) = .empty; | ||
| 555 | defer buf.deinit(alloc); | ||
| 556 | try proto.appendFrame(&buf, alloc, .pty_mode, &[_]u8{0}); | ||
| 557 | try proto.appendFrame(&buf, alloc, .status_reply, "xy"); | ||
| 558 | |||
| 559 | const first = (try frameFrom(alloc, buf.items)).?; | ||
| 560 | defer first.frame.deinit(alloc); | ||
| 561 | try std.testing.expectEqual(proto.MsgType.pty_mode, first.frame.type); | ||
| 562 | |||
| 563 | const second = (try frameFrom(alloc, buf.items[first.consumed..])).?; | ||
| 564 | defer second.frame.deinit(alloc); | ||
| 565 | try std.testing.expectEqual(proto.MsgType.status_reply, second.frame.type); | ||
| 566 | try std.testing.expectEqualStrings("xy", second.frame.payload); | ||
| 567 | try std.testing.expectEqual(buf.items.len, first.consumed + second.consumed); | ||
| 568 | |||
| 569 | // An empty payload is a frame like any other — `status_req` and | ||
| 570 | // `detach` are nothing else — and must not read as "nothing yet". | ||
| 571 | var empty: std.ArrayList(u8) = .empty; | ||
| 572 | defer empty.deinit(alloc); | ||
| 573 | try proto.appendFrame(&empty, alloc, .detach, ""); | ||
| 574 | const none = (try frameFrom(alloc, empty.items)).?; | ||
| 575 | defer none.frame.deinit(alloc); | ||
| 576 | try std.testing.expectEqual(@as(usize, proto.frame_header_len), none.consumed); | ||
| 577 | } | ||
| 578 | |||
| 579 | test "frameFrom: a length no frame can carry is refused, not allocated" { | ||
| 580 | const alloc = std.testing.allocator; | ||
| 581 | // The peer chose this number. Reading on would mean allocating against | ||
| 582 | // it; the daemon's own walk refuses the same bound the same way. | ||
| 583 | var hdr: [proto.frame_header_len]u8 = undefined; | ||
| 584 | hdr[0] = @intFromEnum(proto.MsgType.input); | ||
| 585 | std.mem.writeInt(u32, hdr[1..5], proto.max_payload + 1, .little); | ||
| 586 | try std.testing.expectError(error.FrameTooLarge, frameFrom(alloc, &hdr)); | ||
| 587 | } | ||
| 588 | |||
| 589 | test "graceMs: flat over a socket, RTT-derived over QUIC, and capped" { | ||
| 590 | const alloc = std.testing.allocator; | ||
| 591 | const local = Conn{ .link = .{ .fd = -1 }, .alloc = alloc }; | ||
| 592 | try std.testing.expectEqual(@as(i64, 2_000), local.graceMs()); | ||
| 593 | |||
| 594 | // The derivation is 4x the handshake, and it only ever WIDENS the | ||
| 595 | // window: a loopback or LAN daemon keeps the flat 2s. | ||
| 596 | // No client: the window is a function of the measurement, not of the | ||
| 597 | // connection, and nothing here may touch one. | ||
| 598 | var far = Conn{ | ||
| 599 | .link = .{ .quic = .{ | ||
| 600 | .cl = undefined, | ||
| 601 | .addr = undefined, | ||
| 602 | .key = undefined, | ||
| 603 | .idle_ms = 0, | ||
| 604 | .connect_ms = 1, | ||
| 605 | } }, | ||
| 606 | .alloc = alloc, | ||
| 607 | }; | ||
| 608 | try std.testing.expectEqual(@as(i64, 2_000), far.graceMs()); | ||
| 609 | |||
| 610 | // A 300ms handshake — a real intercontinental link — buys 1.2s, which | ||
| 611 | // is still under the floor, so the first number that moves it is a | ||
| 612 | // handshake past half a second. | ||
| 613 | far.link.quic.connect_ms = 300; | ||
| 614 | try std.testing.expectEqual(@as(i64, 2_000), far.graceMs()); | ||
| 615 | far.link.quic.connect_ms = 900; | ||
| 616 | try std.testing.expectEqual(@as(i64, 3_600), far.graceMs()); | ||
| 617 | |||
| 618 | // And it stops widening: past the cap we are no longer waiting on a | ||
| 619 | // daemon, we are waiting on a network that has already failed to carry | ||
| 620 | // an answer. | ||
| 621 | far.link.quic.connect_ms = 60_000; | ||
| 622 | try std.testing.expectEqual(@as(i64, 30_000), far.graceMs()); | ||
| 623 | } | ||
| 624 | |||
| 625 | /// Drive a fresh connection until it can carry bytes, or give up. | ||
| 626 | /// | ||
| 627 | /// A refused port ends this early — quic_client turns the ICMP unreachable | ||
| 628 | /// into `dead` — so the common mistake (no daemon on that port) costs | ||
| 629 | /// milliseconds. A blackholed one produces no error at all, and there the | ||
| 630 | /// deadline is the only thing that ends the wait; even an unbounded one | ||
| 631 | /// (`--timeout 0`) terminates, because the connection's own idle timeout | ||
| 632 | /// kills it after `idle_ms`. | ||
| 633 | fn waitReady(cl: *quic_client.Client, deadline_ms: i64) !void { | ||
| 634 | while (true) { | ||
| 635 | cl.pump(); | ||
| 636 | if (cl.isReady()) return; | ||
| 637 | if (cl.dead) return error.QuicHandshakeFailed; | ||
| 638 | const now = std.time.milliTimestamp(); | ||
| 639 | if (now >= deadline_ms) return error.Timeout; | ||
| 640 | var fds = [_]std.posix.pollfd{ | ||
| 641 | .{ .fd = cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 642 | }; | ||
| 643 | const cap: i32 = @intCast(@min(deadline_ms - now, 50)); | ||
| 644 | _ = std.posix.poll(&fds, cl.timeoutMs(cap)) catch return error.QuicHandshakeFailed; | ||
| 645 | } | ||
| 646 | } | ||
| 647 | |||
| 648 | test "reconnect: redials the same coordinates, and a dead port is a fast no" { | ||
| 649 | const alloc = std.testing.allocator; | ||
| 650 | // 127.0.0.1:1, where nothing listens: the refusal is REAL — an ICMP | ||
| 651 | // unreachable comes back and quic_client acts on it — which is what | ||
| 652 | // lets this exercise the whole redial path (dial, handshake wait, | ||
| 653 | // verdict) in a couple of loopback round trips instead of a timeout. | ||
| 654 | const addr = try std.net.Address.parseIp("127.0.0.1", 1); | ||
| 655 | const key: quic_client.Key = .{ .bytes = [_]u8{7} ** quic_client.key_len }; | ||
| 656 | |||
| 657 | // The dial that stands in for the connection this client had before | ||
| 658 | // the network went away. It dies for the same reason the redial will, | ||
| 659 | // which is fine: what is under test is what `reconnect` DOES, and it | ||
| 660 | // does the same thing to a connection that died at second 30. | ||
| 661 | const deadline = std.time.milliTimestamp() + 2_000; | ||
| 662 | var conn = Conn{ | ||
| 663 | .link = .{ .quic = .{ | ||
| 664 | .cl = try quic_client.Client.connect(alloc, addr, key, 1_000), | ||
| 665 | .addr = addr, | ||
| 666 | .key = key, | ||
| 667 | .idle_ms = 1_000, | ||
| 668 | .connect_ms = 0, | ||
| 669 | } }, | ||
| 670 | .alloc = alloc, | ||
| 671 | }; | ||
| 672 | defer conn.close(); | ||
| 673 | |||
| 674 | const t0 = std.time.milliTimestamp(); | ||
| 675 | try std.testing.expectError(error.QuicHandshakeFailed, conn.reconnect(deadline)); | ||
| 676 | // Fast, because the port refused rather than went quiet. A redial that | ||
| 677 | // swallowed the refusal would spend the whole 2s here — and in the | ||
| 678 | // field it would spend the agent's remaining deadline. | ||
| 679 | try std.testing.expect(std.time.milliTimestamp() - t0 < 1_000); | ||
| 680 | |||
| 681 | // A redial that failed is not a reconnect spent — but it is also not a | ||
| 682 | // Conn holding a freed client: the old one is torn down only once a | ||
| 683 | // new one is up, so the close above is safe on this path. | ||
| 684 | try std.testing.expect(!conn.reconnected); | ||
| 685 | } | ||
| 686 | |||
| 231 | test "awaitFrame ends a wait on exit_status, keeping the code" { | 687 | test "awaitFrame ends a wait on exit_status, keeping the code" { |
| 232 | const alloc = std.testing.allocator; | 688 | const alloc = std.testing.allocator; |
| 233 | // A pipe stands in for the daemon: awaitFrame polls and reads an fd and | 689 | // A pipe stands in for the daemon: awaitFrame polls and reads an fd and |
| @@ -236,7 +692,7 @@ test "awaitFrame ends a wait on exit_status, keeping the code" { | |||
| 236 | defer std.posix.close(pipe[0]); | 692 | defer std.posix.close(pipe[0]); |
| 237 | defer std.posix.close(pipe[1]); | 693 | defer std.posix.close(pipe[1]); |
| 238 | 694 | ||
| 239 | var conn = Conn{ .fd = pipe[0] }; | 695 | var conn = Conn{ .link = .{ .fd = pipe[0] }, .alloc = alloc }; |
| 240 | // A push to skip on the way, then the session's last word. The reply | 696 | // A push to skip on the way, then the session's last word. The reply |
| 241 | // this wait asked for is never coming, and the code is the answer. | 697 | // this wait asked for is never coming, and the code is the answer. |
| 242 | try proto.writeFrame(pipe[1], .pty_mode, &[_]u8{0}); | 698 | try proto.writeFrame(pipe[1], .pty_mode, &[_]u8{0}); |
| @@ -349,6 +805,22 @@ pub fn main() !u8 { | |||
| 349 | return 2; | 805 | return 2; |
| 350 | }; | 806 | }; |
| 351 | 807 | ||
| 808 | // Started BEFORE the connect, not after: over QUIC the handshake is | ||
| 809 | // part of the round trip the caller bounded, and a `--timeout` that | ||
| 810 | // began counting only once the connection was up would promise | ||
| 811 | // something different on the two transports. Over a unix socket the | ||
| 812 | // connect is a syscall, so this moves the instant by microseconds. | ||
| 813 | const deadline = deadlineFor(o.timeout_ms); | ||
| 814 | |||
| 815 | if (o.quic) |host_port| { | ||
| 816 | var conn = switch (openQuicConn(alloc, o, host_port, deadline)) { | ||
| 817 | .conn => |c| c, | ||
| 818 | .exit => |code| return code, | ||
| 819 | }; | ||
| 820 | defer conn.close(); | ||
| 821 | return dispatch(alloc, &conn, o, deadline); | ||
| 822 | } | ||
| 823 | |||
| 352 | const sock_path = if (o.sock) |s| s else try sockpath.defaultSockPath(alloc); | 824 | const sock_path = if (o.sock) |s| s else try sockpath.defaultSockPath(alloc); |
| 353 | 825 | ||
| 354 | // Refused by name, before connecting: connect would bounce a too-long | 826 | // Refused by name, before connecting: connect would bounce a too-long |
| @@ -364,7 +836,7 @@ pub fn main() !u8 { | |||
| 364 | return fail("socket path too long", detail); | 836 | return fail("socket path too long", detail); |
| 365 | } | 837 | } |
| 366 | 838 | ||
| 367 | var conn = Conn.open(sock_path) catch |e| { | 839 | var conn = Conn.open(alloc, sock_path) catch |e| { |
| 368 | // The path goes in the detail: a muxa pointed at the wrong socket | 840 | // The path goes in the detail: a muxa pointed at the wrong socket |
| 369 | // is this binary's likeliest field failure, and an agent reading | 841 | // is this binary's likeliest field failure, and an agent reading |
| 370 | // "FileNotFound" alone cannot tell which path it was that missed. | 842 | // "FileNotFound" alone cannot tell which path it was that missed. |
| @@ -375,16 +847,144 @@ pub fn main() !u8 { | |||
| 375 | }; | 847 | }; |
| 376 | defer conn.close(); | 848 | defer conn.close(); |
| 377 | 849 | ||
| 378 | const deadline = deadlineFor(o.timeout_ms); | 850 | return dispatch(alloc, &conn, o, deadline); |
| 851 | } | ||
| 852 | |||
| 853 | /// The verbs, once. Both transports arrive here with a Conn and nothing | ||
| 854 | /// else that distinguishes them, which is the property `--quic` is selling. | ||
| 855 | fn dispatch(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 { | ||
| 379 | return switch (o.verb) { | 856 | return switch (o.verb) { |
| 380 | .status => verbStatus(alloc, &conn, deadline), | 857 | .status => verbStatus(alloc, conn, deadline), |
| 381 | .capture => verbCapture(alloc, &conn, o.vt, deadline), | 858 | .capture => verbCapture(alloc, conn, o.vt, deadline), |
| 382 | .send => verbSend(alloc, &conn, o.arg, deadline), | 859 | .send => verbSend(alloc, conn, o.arg, deadline), |
| 383 | .run => verbRun(alloc, &conn, o, deadline), | 860 | .run => verbRun(alloc, conn, o, deadline), |
| 384 | .@"await" => verbAwait(alloc, &conn, o, deadline), | 861 | .@"await" => verbAwait(alloc, conn, o, deadline), |
| 385 | }; | 862 | }; |
| 386 | } | 863 | } |
| 387 | 864 | ||
| 865 | /// A QUIC transport, or the exit code standing in for the reason there is | ||
| 866 | /// not one. Every refusal here goes through `fail`, so a dial that never | ||
| 867 | /// happened prints the same one-JSON-object-on-stdout shape as a verb that | ||
| 868 | /// ran — an agent parses one thing whatever went wrong. | ||
| 869 | const Opened = union(enum) { conn: Conn, exit: u8 }; | ||
| 870 | |||
| 871 | fn openQuicConn( | ||
| 872 | alloc: std.mem.Allocator, | ||
| 873 | o: Opts, | ||
| 874 | host_port: []const u8, | ||
| 875 | deadline: i64, | ||
| 876 | ) Opened { | ||
| 877 | // `--key`, then `$MUX_KEY_FILE`, then the XDG default if it exists. | ||
| 878 | // The order is not spelled here on purpose: xdg owns it, muxd and mux | ||
| 879 | // read it from the same two functions, and a third copy is how two | ||
| 880 | // binaries end up authenticating with different keys. | ||
| 881 | const res = xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv("MUX_KEY_FILE"))) catch |e| | ||
| 882 | return .{ .exit = fail("quic: cannot resolve a key path", @errorName(e)) }; | ||
| 883 | const key_path = switch (res) { | ||
| 884 | .given, .default => |p| p, | ||
| 885 | // The path is the detail because it is the actionable half: the | ||
| 886 | // agent (or the human reading its log) needs to know which file | ||
| 887 | // `muxd keygen` was supposed to have written. | ||
| 888 | .missing => |p| return .{ .exit = fail( | ||
| 889 | "quic: no key: pass --key, set MUX_KEY_FILE, or run `muxd keygen`", | ||
| 890 | p, | ||
| 891 | ) }, | ||
| 892 | }; | ||
| 893 | const key = quic_client.Key.load(key_path) catch |e| { | ||
| 894 | // The daemon's words for a key the daemon would also refuse — | ||
| 895 | // including the group/other-readable refusal, which this binary | ||
| 896 | // gets for free by loading the key the same way. | ||
| 897 | var buf: [quic_client.key_refusal_len]u8 = undefined; | ||
| 898 | return .{ .exit = fail("quic: unusable key", quic_client.keyRefusalBody(&buf, e, key_path)) }; | ||
| 899 | }; | ||
| 900 | const addr = parseQuicAddr(alloc, host_port) catch |e| { | ||
| 901 | var buf: [512]u8 = undefined; | ||
| 902 | const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ host_port, @errorName(e) }) catch | ||
| 903 | @errorName(e); | ||
| 904 | return .{ .exit = fail("quic: cannot read HOST:PORT", detail) }; | ||
| 905 | }; | ||
| 906 | const conn = Conn.openQuic(alloc, addr, key, quic_client.default_idle_ms, deadline) catch |e| { | ||
| 907 | var buf: [512]u8 = undefined; | ||
| 908 | const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ host_port, @errorName(e) }) catch | ||
| 909 | @errorName(e); | ||
| 910 | return .{ .exit = fail("cannot connect to the daemon", detail) }; | ||
| 911 | }; | ||
| 912 | return .{ .conn = conn }; | ||
| 913 | } | ||
| 914 | |||
| 915 | /// `HOST[:PORT]`, with an omitted port meaning `quic_client.default_port`. | ||
| 916 | /// A name is resolved rather than refused: unlike muxd's `--quic`, which | ||
| 917 | /// names an address to BIND, this one names a box to reach, and a box is | ||
| 918 | /// normally spelled with a name. | ||
| 919 | /// | ||
| 920 | /// The grammar is the CLI client's — `mux quic://HOST:PORT` accepts these | ||
| 921 | /// same spellings, brackets and all, and the two must not diverge: an | ||
| 922 | /// agent and a human pointing at the same daemon type the same thing. It | ||
| 923 | /// is a second copy of client.zig's `parseQuicAddr` and knowingly so: | ||
| 924 | /// folding both into the shared `quic` module is the right home for it and | ||
| 925 | /// is a change to the CLI client, which this one is not. | ||
| 926 | fn parseQuicAddr(alloc: std.mem.Allocator, host_port: []const u8) !std.net.Address { | ||
| 927 | // `[::1]` — bracketed and portless: the brackets say where the address | ||
| 928 | // stops, so the port can default. | ||
| 929 | if (host_port.len >= 2 and host_port[0] == '[' and host_port[host_port.len - 1] == ']') | ||
| 930 | return resolveHost(alloc, host_port[1 .. host_port.len - 1], quic_client.default_port); | ||
| 931 | const colon = std.mem.lastIndexOfScalar(u8, host_port, ':') orelse | ||
| 932 | return resolveHost(alloc, host_port, quic_client.default_port); | ||
| 933 | var host = host_port[0..colon]; | ||
| 934 | const port_s = host_port[colon + 1 ..]; | ||
| 935 | if (host.len >= 2 and host[0] == '[' and host[host.len - 1] == ']') { | ||
| 936 | host = host[1 .. host.len - 1]; | ||
| 937 | } else if (std.mem.indexOfScalar(u8, host, ':') != null) { | ||
| 938 | // An unbracketed IPv6 literal would have its last group taken as a | ||
| 939 | // port. Refused rather than guessed at. | ||
| 940 | return error.MalformedAddress; | ||
| 941 | } | ||
| 942 | const port = std.fmt.parseInt(u16, port_s, 10) catch return error.MalformedAddress; | ||
| 943 | return resolveHost(alloc, host, port); | ||
| 944 | } | ||
| 945 | |||
| 946 | fn resolveHost(alloc: std.mem.Allocator, host: []const u8, port: u16) !std.net.Address { | ||
| 947 | if (host.len == 0) return error.MalformedAddress; | ||
| 948 | if (std.net.Address.parseIp(host, port)) |addr| return addr else |_| {} | ||
| 949 | const list = try std.net.getAddressList(alloc, host, port); | ||
| 950 | defer list.deinit(); | ||
| 951 | if (list.addrs.len == 0) return error.UnknownHostName; | ||
| 952 | return list.addrs[0]; | ||
| 953 | } | ||
| 954 | |||
| 955 | test "parseQuicAddr: literals, brackets, and the spellings that are refused" { | ||
| 956 | const alloc = std.testing.allocator; | ||
| 957 | // Literals only here: a name would send this test to a resolver, and | ||
| 958 | // what it answered would depend on the machine running it. | ||
| 959 | try std.testing.expectEqual( | ||
| 960 | @as(u16, 4433), | ||
| 961 | (try parseQuicAddr(alloc, "127.0.0.1:4433")).getPort(), | ||
| 962 | ); | ||
| 963 | |||
| 964 | // An omitted port means mux's own. Spelled out rather than written | ||
| 965 | // `quic_client.default_port`, because comparing the parse's answer | ||
| 966 | // against the constant the parse reads would hold for any value and | ||
| 967 | // say nothing about the port — and this is the number the daemon at | ||
| 968 | // the other end has to agree on. | ||
| 969 | try std.testing.expectEqual(@as(u16, 4433), (try parseQuicAddr(alloc, "10.0.0.2")).getPort()); | ||
| 970 | |||
| 971 | const six = try parseQuicAddr(alloc, "[::1]:9999"); | ||
| 972 | try std.testing.expectEqual(@as(u16, 9999), six.getPort()); | ||
| 973 | try std.testing.expect(six.any.family == std.posix.AF.INET6); | ||
| 974 | // Bracketed and portless: the brackets say where the address stops, so | ||
| 975 | // the port can default. | ||
| 976 | try std.testing.expectEqual(@as(u16, 4433), (try parseQuicAddr(alloc, "[::1]")).getPort()); | ||
| 977 | |||
| 978 | // An unbracketed IPv6 literal would have its last group read as a | ||
| 979 | // port. Refused rather than guessed at — the same refusal muxd's | ||
| 980 | // splitHostPort makes about its bind address. | ||
| 981 | try std.testing.expectError(error.MalformedAddress, parseQuicAddr(alloc, "fe80::1:4433")); | ||
| 982 | try std.testing.expectError(error.MalformedAddress, parseQuicAddr(alloc, "127.0.0.1:")); | ||
| 983 | try std.testing.expectError(error.MalformedAddress, parseQuicAddr(alloc, "127.0.0.1:99999")); | ||
| 984 | try std.testing.expectError(error.MalformedAddress, parseQuicAddr(alloc, "")); | ||
| 985 | try std.testing.expectError(error.MalformedAddress, parseQuicAddr(alloc, ":4433")); | ||
| 986 | } | ||
| 987 | |||
| 388 | fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) !u8 { | 988 | fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) !u8 { |
| 389 | conn.sendFrame(.status_req, "") catch |e| return fail("status: send failed", @errorName(e)); | 989 | conn.sendFrame(.status_req, "") catch |e| return fail("status: send failed", @errorName(e)); |
| 390 | const frame = conn.awaitFrame(alloc, .status_reply, deadline) catch |e| switch (e) { | 990 | const frame = conn.awaitFrame(alloc, .status_reply, deadline) catch |e| switch (e) { |
| @@ -523,6 +1123,18 @@ fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: ?[]const u8, deadline: i | |||
| 523 | /// `{"reason":"timeout"}` with exit 3 that the agent is meant to read. | 1123 | /// `{"reason":"timeout"}` with exit 3 that the agent is meant to read. |
| 524 | const await_grace_ms = 2_000; | 1124 | const await_grace_ms = 2_000; |
| 525 | 1125 | ||
| 1126 | /// The ceiling on the QUIC arm's derived grace (Conn.graceMs), and the | ||
| 1127 | /// reason it has one is that `connect_ms` has no bound of its own worth | ||
| 1128 | /// multiplying by four. | ||
| 1129 | const grace_cap_ms = 30_000; | ||
| 1130 | |||
| 1131 | /// How long `sendFrameQuic` will keep offering a frame's tail to a full | ||
| 1132 | /// egress ring before giving up on it. Reaching this means the peer has | ||
| 1133 | /// stopped acknowledging 256KB of backlog, which is a dead connection | ||
| 1134 | /// wearing a different hat — but a bound is what keeps it from being an | ||
| 1135 | /// unbounded wait inside a call that has no deadline of its own. | ||
| 1136 | const send_flush_ms = 5_000; | ||
| 1137 | |||
| 526 | /// The span fetch gets its own window rather than the tail of the run's: a | 1138 | /// The span fetch gets its own window rather than the tail of the run's: a |
| 527 | /// command that returned in the last millisecond of `--timeout` still has a | 1139 | /// command that returned in the last millisecond of `--timeout` still has a |
| 528 | /// transcript worth having, and this round trip is a local read that either | 1140 | /// transcript worth having, and this round trip is a local read that either |
| @@ -547,6 +1159,52 @@ fn doAwait( | |||
| 547 | return try proto.decodeAwaitReply(frame.payload); | 1159 | return try proto.decodeAwaitReply(frame.payload); |
| 548 | } | 1160 | } |
| 549 | 1161 | ||
| 1162 | /// The await, plus the ONE reconnect this client is willing to spend on it. | ||
| 1163 | /// | ||
| 1164 | /// A wait is the only round trip long enough for a network to die under — | ||
| 1165 | /// a status round trip is over in a millisecond, a `run` on a build is not | ||
| 1166 | /// — and losing it costs an agent the whole command it was watching, so | ||
| 1167 | /// this is the one place a transport failure is retried rather than | ||
| 1168 | /// reported. What makes the retry safe rather than a second command is | ||
| 1169 | /// `since_seq`: the request is a question about a watermark ("tell me | ||
| 1170 | /// about a return newer than this"), so re-asking it after a reconnect is | ||
| 1171 | /// the SAME question and the daemon answers it identically whether or not | ||
| 1172 | /// it saw the first one. The server's own tests pin that idempotency. | ||
| 1173 | /// | ||
| 1174 | /// Three things are deliberately not reset: | ||
| 1175 | /// | ||
| 1176 | /// * the deadline, which is the caller's whole bound and continues | ||
| 1177 | /// across the reconnect — a redial that ate four seconds has spent | ||
| 1178 | /// four seconds of the wait, not bought a fresh one; | ||
| 1179 | /// * `since_seq`, for the reason above — re-reading the watermark from | ||
| 1180 | /// the new connection would move it past a return that had happened | ||
| 1181 | /// while we were disconnected, and the await would then sit waiting | ||
| 1182 | /// for one that already went by; | ||
| 1183 | /// * the attach, which IS re-sent, at 0x0 like every other attach this | ||
| 1184 | /// binary makes: the daemon dropped our old client slot with the | ||
| 1185 | /// connection and would have no session to answer about otherwise. | ||
| 1186 | /// | ||
| 1187 | /// Once, and once per process rather than per await: a loop here would be | ||
| 1188 | /// a client that hides a daemon that is gone, and the agent driving it | ||
| 1189 | /// asked a question that deserves an answer within the deadline it named. | ||
| 1190 | fn awaitReissuing( | ||
| 1191 | alloc: std.mem.Allocator, | ||
| 1192 | conn: *Conn, | ||
| 1193 | o: Opts, | ||
| 1194 | since_seq: u64, | ||
| 1195 | deadline: i64, | ||
| 1196 | ) !proto.AwaitReply { | ||
| 1197 | return doAwait(alloc, conn, o, since_seq, deadline) catch |e| switch (e) { | ||
| 1198 | error.ConnectionLost => { | ||
| 1199 | if (conn.link != .quic or conn.reconnected) return e; | ||
| 1200 | try conn.reconnect(deadline); | ||
| 1201 | try attachZero(conn); | ||
| 1202 | return doAwait(alloc, conn, o, since_seq, deadline); | ||
| 1203 | }, | ||
| 1204 | else => e, | ||
| 1205 | }; | ||
| 1206 | } | ||
| 1207 | |||
| 550 | /// The session's RETURN WATERMARK: the seq of the last command return, 0 if | 1208 | /// The session's RETURN WATERMARK: the seq of the last command return, 0 if |
| 551 | /// none. Handed straight to `since_seq`, where it means "only a return | 1209 | /// none. Handed straight to `since_seq`, where it means "only a return |
| 552 | /// newer than this may answer me". | 1210 | /// newer than this may answer me". |
| @@ -736,7 +1394,7 @@ fn verbAwait(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 | |||
| 736 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), | 1394 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), |
| 737 | else => return fail("await: status failed", @errorName(e)), | 1395 | else => return fail("await: status failed", @errorName(e)), |
| 738 | }; | 1396 | }; |
| 739 | const r = doAwait(alloc, conn, o, since, awaitDeadline(o)) catch |e| switch (e) { | 1397 | const r = awaitReissuing(alloc, conn, o, since, awaitDeadline(o, conn)) catch |e| switch (e) { |
| 740 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), | 1398 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), |
| 741 | else => return fail("await: no reply", @errorName(e)), | 1399 | else => return fail("await: no reply", @errorName(e)), |
| 742 | }; | 1400 | }; |
| @@ -769,7 +1427,7 @@ fn verbRun(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 { | |||
| 769 | defer alloc.free(line); | 1427 | defer alloc.free(line); |
| 770 | conn.sendFrame(.input, line) catch |e| return fail("run: input failed", @errorName(e)); | 1428 | conn.sendFrame(.input, line) catch |e| return fail("run: input failed", @errorName(e)); |
| 771 | 1429 | ||
| 772 | const r = doAwait(alloc, conn, o, since, awaitDeadline(o)) catch |e| switch (e) { | 1430 | const r = awaitReissuing(alloc, conn, o, since, awaitDeadline(o, conn)) catch |e| switch (e) { |
| 773 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), | 1431 | error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), |
| 774 | else => return fail("run: no reply", @errorName(e)), | 1432 | else => return fail("run: no reply", @errorName(e)), |
| 775 | }; | 1433 | }; |
| @@ -797,9 +1455,10 @@ fn elapsed(started: i64) i64 { | |||
| 797 | } | 1455 | } |
| 798 | 1456 | ||
| 799 | /// This client's deadline for the await itself — the daemon's own bound | 1457 | /// This client's deadline for the await itself — the daemon's own bound |
| 800 | /// plus the grace window (see await_grace_ms). An unbounded request stays | 1458 | /// plus the grace window (see await_grace_ms and Conn.graceMs, which is |
| 1459 | /// where the transport gets to widen it). An unbounded request stays | ||
| 801 | /// unbounded here too. | 1460 | /// unbounded here too. |
| 802 | fn awaitDeadline(o: Opts) i64 { | 1461 | fn awaitDeadline(o: Opts, conn: *const Conn) i64 { |
| 803 | if (o.timeout_ms == 0) return std.math.maxInt(i64); | 1462 | if (o.timeout_ms == 0) return std.math.maxInt(i64); |
| 804 | return std.time.milliTimestamp() + o.timeout_ms + await_grace_ms; | 1463 | return std.time.milliTimestamp() + o.timeout_ms + conn.graceMs(); |
| 805 | } | 1464 | } |