a73x

593f8fa3

feat(muxa): QUIC transport — same verbs, remote daemons, one reconnect re-issue

a73x   2026-08-13 18:47

Commit message
feat(muxa): QUIC transport — same verbs, remote daemons, one reconnect re-issue

`--quic HOST[:PORT]` and `--key PATH` put every verb over a remote daemon
with the same frames and the same JSON. Conn becomes a union over the two
links; the verbs above it were not touched and cannot tell which one they
are on, which is the claim the flag makes.

The QUIC arm does NOT inherit the socket arm's blocking read. It delimits
whole frames out of the client's inbound buffer and leaves a partial tail
there, so a daemon that stops mid-frame costs the deadline it was given and
not a second more. The debt comment moves to the unix arm, which keeps it
deliberately: over a local socket a mid-frame stall means a daemon that has
stopped running, and a second partial-frame buffer would be paid for a case
that cannot happen there.

The await grace window becomes RTT-aware — max(2s, 4x the handshake),
capped at 30s — because the daemon's timeout window opens a flight after
this process starts counting and closes a flight before the reply lands. On
loopback and LAN the derivation never leaves the old 2s floor.

A lost connection mid-await is the one transport failure retried rather
than reported: reconnect once, re-attach at 0x0, re-issue the await with
the ORIGINAL since_seq, and continue the original deadline. since_seq is
what makes it safe — the request is a question about a watermark, so
re-asking it is the same question.

Measured, loopback: run/status/capture/send/await all valid JSON with marks
spans and output; an await in flight when the daemon is stopped fails in
4.0s of a 20s deadline with QuicHandshakeFailed, which only waitReady
inside the redial can produce; a refused port is a no in under 1ms; an
unbounded `--timeout 0` dial to a silent host still ends, at the 15s idle
timeout. The unix-socket smokes are unchanged.

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

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 }