a73x

6cbd6c4c

refactor: muxa rides the Link, and the second transport retires

a73x   2026-08-31 19:29

Commit message
refactor: muxa rides the Link, and the second transport retires

build.zig
Old New
@@ -200,7 +200,7 @@ const mod_table = [_]ModSpec{
200 // wire contract and nothing else — no engine and no replica, muxa having 200 // wire contract and nothing else — no engine and no replica, muxa having
201 // nothing to draw: a fact of muxa.zig itself, which the one-row component 201 // nothing to draw: a fact of muxa.zig itself, which the one-row component
202 // no longer refuses on its behalf. 202 // no longer refuses on its behalf.
203 .{ .name = "agent", .path = "src/cli/muxa.zig", .link_libc = true, .imports = &.{ "term", "sockpath", "quic", "xdg", "cliflags", "dial" }, .quic_tests = true }, 203 .{ .name = "agent", .path = "src/cli/muxa.zig", .link_libc = true, .imports = &.{ "term", "sockpath", "quic", "xdg", "cliflags", "dial", "link" }, .quic_tests = true },
204 .{ .name = "wsclient", .path = "test/wsclient.zig", .link_libc = true, .imports = &.{ "term", "script" } }, 204 .{ .name = "wsclient", .path = "test/wsclient.zig", .link_libc = true, .imports = &.{ "term", "script" } },
205 // Dialling, and what a chord means. The client is the only thing that 205 // Dialling, and what a chord means. The client is the only thing that
206 // predicts — the overlay is a local display decision and never becomes 206 // predicts — the overlay is a local display decision and never becomes
src/cli/muxa.zig
Old New
@@ -13,6 +13,7 @@ const quic = @import("quic");
13 const xdg = @import("xdg"); 13 const xdg = @import("xdg");
14 const cliflags = @import("cliflags"); 14 const cliflags = @import("cliflags");
15 const dial = @import("dial"); 15 const dial = @import("dial");
16 const link_mod = @import("link");
16 const build_options = @import("build_options"); 17 const build_options = @import("build_options");
17 18
18 const usage = 19 const usage =
@@ -266,28 +267,26 @@ test "parseArgs: --quic and --key, and the pairs that make no sense" {
266 try std.testing.expectEqual(@as(?[]const u8, null), (try parseArgs(&neither)).quic); 267 try std.testing.expectEqual(@as(?[]const u8, null), (try parseArgs(&neither)).quic);
267 } 268 }
268 269
269 /// Live QUIC client plus the endpoint state required for one reconnect. Reuse
270 /// the original address and key so mid-command redial cannot select rotated
271 /// credentials or a different resolved address.
272 const QuicConnectionState = struct {
273 cl: *quic.Client,
274 addr: std.net.Address,
275 key: quic.Key,
276 idle_ms: u32,
277 /// Duration of the initial handshake, used by `graceMs` as a rough network
278 /// latency measurement.
279 connect_ms: i64,
280 /// Whether the single permitted reconnect has been used. This state belongs
281 /// only to QUIC connections.
282 reconnected: bool = false,
283 };
284
285 const AgentConnection = struct { 270 const AgentConnection = struct {
286 /// Verbs are transport-blind; `--quic` chooses here. 271 /// Verbs are transport-blind; `--quic` chooses which arm of the Link.
287 link: union(enum) { 272 /// The mechanics — send, await, close — are the Link's; everything else
288 fd: std.posix.fd_t, 273 /// on this struct is muxa's policy.
289 quic: QuicConnectionState, 274 link: link_mod.Link,
290 }, 275 /// The endpoint state required for one reconnect, and the marker for a
276 /// QUIC connection: `open` leaves it null. Reconnect policy is muxa's,
277 /// not the Link's — reuse the original address and key so a mid-command
278 /// redial cannot select rotated credentials or a different resolved
279 /// address.
280 redial: ?struct {
281 addr: std.net.Address,
282 key: quic.Key,
283 idle_ms: u32,
284 /// Duration of the initial handshake, used by `graceMs` as a rough
285 /// network latency measurement.
286 connect_ms: i64,
287 /// Whether the single permitted reconnect has been used.
288 reconnected: bool = false,
289 } = null,
291 /// Allocator for QUIC frame staging and reconnect state. Frame-returning 290 /// Allocator for QUIC frame staging and reconnect state. Frame-returning
292 /// methods accept their result allocator separately. 291 /// methods accept their result allocator separately.
293 alloc: std.mem.Allocator, 292 alloc: std.mem.Allocator,
@@ -322,43 +321,35 @@ const AgentConnection = struct {
322 errdefer cl.deinit(); 321 errdefer cl.deinit();
323 try waitReady(cl, deadline_ms); 322 try waitReady(cl, deadline_ms);
324 return .{ 323 return .{
325 .link = .{ .quic = .{ 324 .link = .{ .quic = .{ .cl = cl, .alloc = alloc } },
326 .cl = cl, 325 .redial = .{
327 .addr = addr, 326 .addr = addr,
328 .key = key, 327 .key = key,
329 .idle_ms = idle_ms, 328 .idle_ms = idle_ms,
330 .connect_ms = elapsed(started), 329 .connect_ms = elapsed(started),
331 } }, 330 },
332 .alloc = alloc, 331 .alloc = alloc,
333 }; 332 };
334 } 333 }
335 334
336 fn close(self: *AgentConnection) void { 335 fn close(self: *AgentConnection) void {
337 switch (self.link) { 336 self.link.close();
338 .fd => |fd| std.posix.close(fd),
339 .quic => |q| q.cl.deinit(),
340 }
341 } 337 }
342 338
343 /// QUIC widens the grace: the daemon's window opens a flight after 339 /// QUIC widens the grace: the daemon's window opens a flight after
344 /// ours. The cap bounds a slow handshake. 340 /// ours. The cap bounds a slow handshake. A socket has no handshake to
341 /// measure, and no `redial` either, so it keeps the flat window.
345 fn graceMs(self: *const AgentConnection) i64 { 342 fn graceMs(self: *const AgentConnection) i64 {
346 return switch (self.link) { 343 const r = self.redial orelse return await_grace_ms;
347 .fd => await_grace_ms, 344 return @min(grace_cap_ms, @max(await_grace_ms, 4 * r.connect_ms));
348 .quic => |q| @min(grace_cap_ms, @max(await_grace_ms, 4 * q.connect_ms)),
349 };
350 } 345 }
351 346
352 /// The socket arm ignores `deadline_ms` — a local write either takes 347 /// The verbs' one way out. A write that died of a closed peer is the
353 /// the bytes or fails. The QUIC arm gives it precedence over 348 /// interesting case: the daemon may have refused the attach and gone,
354 /// `send_flush_ms`, so a verb asked for a 100ms answer cannot spend 349 /// in which case the refusal is the answer the agent wants and the
355 /// five seconds sending. 350 /// write error is only how we found out.
356 fn sendFrame(self: *AgentConnection, t: proto.MsgType, payload: []const u8, deadline_ms: i64) !void { 351 fn sendFrame(self: *AgentConnection, t: proto.MsgType, payload: []const u8, deadline_ms: i64) !void {
357 const wrote: anyerror!void = switch (self.link) { 352 self.writeThrough(t, payload, deadline_ms) catch |e| {
358 .fd => |fd| proto.writeFrame(fd, t, payload),
359 .quic => self.sendFrameQuic(t, payload, deadline_ms),
360 };
361 wrote catch |e| {
362 switch (e) { 353 switch (e) {
363 error.BrokenPipe, 354 error.BrokenPipe,
364 error.ConnectionResetByPeer, 355 error.ConnectionResetByPeer,
@@ -381,118 +372,71 @@ const AgentConnection = struct {
381 frame.deinit(self.alloc); 372 frame.deinit(self.alloc);
382 } 373 }
383 374
384 /// A half-written frame reads as a corrupt stream, so the short take 375 /// Hand the frame to the Link and, on QUIC, stay until the bytes are
385 /// is re-offered. 376 /// gone or the wait is spent. A socket write either takes the frame or
386 fn sendFrameQuic( 377 /// fails, so there is nothing to drive out there.
378 fn writeThrough(
387 self: *AgentConnection, 379 self: *AgentConnection,
388 t: proto.MsgType, 380 t: proto.MsgType,
389 payload: []const u8, 381 payload: []const u8,
390 deadline_ms: i64, 382 deadline_ms: i64,
391 ) !void { 383 ) !void {
392 var buf: std.ArrayList(u8) = .empty; 384 try self.link.sendFrame(t, payload);
393 defer buf.deinit(self.alloc); 385 if (self.link != .quic) return;
394 try proto.appendFrame(&buf, self.alloc, t, payload); 386 // The verb's deadline takes precedence over `send_flush_ms`, so a
395 387 // verb asked for a 100ms answer cannot spend five seconds sending;
396 const q = &self.link.quic; 388 // the flush cap is what bounds `--timeout 0`. Relative at this
397 // The flush cap bounds callers using `--timeout 0`; all other callers 389 // boundary because that is what the Link takes, and floored at zero
398 // provide an earlier deadline. 390 // rather than clamped away: a deadline already spent still offers
399 const deadline = @min(deadline_ms, std.time.milliTimestamp() + send_flush_ms); 391 // the bytes once before `SendStalled`, which is what the loop this
400 var off: usize = 0; 392 // replaced did with its first send.
401 while (off < buf.items.len) { 393 const left = deadline_ms - std.time.milliTimestamp();
402 if (q.cl.dead) return error.ConnectionLost; 394 const bounded: u32 = if (left <= 0) 0 else @intCast(@min(left, send_flush_ms));
403 off += q.cl.send(buf.items[off..]); 395 return self.link.flushWithin(bounded);
404 if (off == buf.items.len) return;
405 if (std.time.milliTimestamp() >= deadline) return error.SendStalled;
406 // The ring is full: only the peer's acks can empty it, and they
407 // arrive through pump. Polling first keeps this from spinning.
408 var fds = [_]std.posix.pollfd{
409 .{ .fd = q.cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
410 };
411 _ = std.posix.poll(&fds, q.cl.timeoutMs(50)) catch return error.ConnectionLost;
412 q.cl.pump();
413 }
414 } 396 }
415 397
416 /// Skip unrelated snapshots and deltas while waiting for `want`. An exit 398 /// Skip unrelated snapshots and deltas while waiting for `want`. An exit
417 /// frame ends the wait as a session outcome rather than a transport error. 399 /// frame ends the wait as a session outcome rather than a transport error:
418 fn awaitFrame(self: *AgentConnection, want: proto.MsgType, deadline_ms: i64) !proto.Frame { 400 /// the daemon uses the same exit frame for a rejected attach and an ended
419 return switch (self.link) { 401 /// session, and a valid attach always sends a snapshot first.
420 .fd => self.awaitFrameFd(self.alloc, want, deadline_ms), 402 fn onOther(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void {
421 .quic => self.awaitFrameQuic(self.alloc, want, deadline_ms), 403 const self: *AgentConnection = @ptrCast(@alignCast(ctx.?));
422 }; 404 if (frame.type == .snapshot) self.saw_snapshot = true;
423 } 405 if (frame.type == .exit_status) {
424 406 if (!self.saw_snapshot) return error.AttachRefused;
425 /// Deadline-bounded wait, unbounded read: harmless where a stall 407 // A missing status byte still ends the session, but its code is
426 /// means a dead daemon. 408 // unknown rather than zero.
427 fn awaitFrameFd( 409 self.session_exit = if (frame.payload.len >= 1) frame.payload[0] else null;
428 self: *AgentConnection, 410 return error.SessionExited;
429 alloc: std.mem.Allocator,
430 want: proto.MsgType,
431 deadline_ms: i64,
432 ) !proto.Frame {
433 const fd = self.link.fd;
434 while (true) {
435 const now = std.time.milliTimestamp();
436 if (now >= deadline_ms) return error.Timeout;
437 var fds = [_]std.posix.pollfd{
438 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
439 };
440 const n = try std.posix.poll(&fds, @intCast(@min(deadline_ms - now, 250)));
441 if (n == 0) continue;
442 const frame = try proto.readFrame(alloc, fd) orelse return error.DaemonGone;
443 if (frame.type == want) return frame;
444 defer frame.deinit(alloc);
445 if (frame.type == .snapshot) self.saw_snapshot = true;
446 if (frame.type == .exit_status) {
447 if (!self.saw_snapshot) return error.AttachRefused;
448 // A missing status byte still ends the session, but its code is
449 // unknown rather than zero.
450 self.session_exit = if (frame.payload.len >= 1) frame.payload[0] else null;
451 return error.SessionExited;
452 }
453 } 411 }
454 } 412 }
455 413
456 /// Drain the buffer before checking `dead`, or bytes that arrived 414 fn awaitFrame(self: *AgentConnection, want: proto.MsgType, deadline_ms: i64) !proto.Frame {
457 /// first are lost. 415 const left = deadline_ms - std.time.milliTimestamp();
458 fn awaitFrameQuic( 416 if (left <= 0) return error.Timeout;
459 self: *AgentConnection, 417 // `--timeout 0` spells its deadline as maxInt(i64); the Link takes a
460 alloc: std.mem.Allocator, 418 // relative u32, so the wait is capped at what that can hold. Seven
461 want: proto.MsgType, 419 // weeks is unbounded for anything a verb waits on, and the cast
462 deadline_ms: i64, 420 // would panic without it.
463 ) !proto.Frame { 421 const window: u32 = @intCast(@min(left, std.math.maxInt(u32)));
464 const q = &self.link.quic; 422 const got = self.link.awaitFrame(self.alloc, want, window, .{
465 while (true) { 423 .ctx = self,
466 q.cl.pump(); 424 .on = onOther,
467 while (try proto.takeFrame(alloc, &q.cl.in)) |frame| { 425 }) catch |e| switch (e) {
468 if (frame.type == want) return frame; 426 // Wording is per-arm on purpose: a closed unix socket is a dead
469 defer frame.deinit(alloc); 427 // daemon, while a dead QUIC connection cannot distinguish daemon
470 // The socket arm's classification again rather than a shared 428 // exit from path failure, so it reports `ConnectionLost` and
471 // helper: that arm answers `DaemonGone` on a closed stream 429 // allows the one reconnect.
472 // where this one answers `ConnectionLost`, so the walk that 430 error.Closed => return switch (self.link) {
473 // reaches these two lines is not the same walk. 431 .quic => error.ConnectionLost,
474 if (frame.type == .snapshot) self.saw_snapshot = true; 432 else => error.DaemonGone,
475 if (frame.type == .exit_status) { 433 },
476 if (!self.saw_snapshot) return error.AttachRefused; 434 // Everything else is itself, `FrameTooLarge` included: a daemon
477 self.session_exit = if (frame.payload.len >= 1) frame.payload[0] else null; 435 // that got the framing wrong is a different fact from a daemon
478 return error.SessionExited; 436 // that went away, and the verb's detail says which.
479 } 437 else => return e,
480 } 438 };
481 // Over a network, a dead connection cannot distinguish daemon exit 439 return got orelse error.Timeout;
482 // from path failure, so report `ConnectionLost` and allow reconnect.
483 if (q.cl.dead) return error.ConnectionLost;
484 const now = std.time.milliTimestamp();
485 if (now >= deadline_ms) return error.Timeout;
486 var fds = [_]std.posix.pollfd{
487 .{ .fd = q.cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
488 };
489 // Through timeoutMs, so ngtcp2's own timers — loss detection
490 // and, on a quiet await, the keepalive that keeps the idle
491 // timeout from firing under us — are serviced on schedule
492 // rather than whenever the daemon happens to say something.
493 const cap: i32 = @intCast(@min(deadline_ms - now, 250));
494 _ = std.posix.poll(&fds, q.cl.timeoutMs(cap)) catch return error.ConnectionLost;
495 }
496 } 440 }
497 441
498 /// `connect_ms` deliberately keeps the FIRST handshake's measurement: 442 /// `connect_ms` deliberately keeps the FIRST handshake's measurement:
@@ -500,13 +444,20 @@ const AgentConnection = struct {
500 /// a redial made while the path was still coming back. Nothing reads 444 /// a redial made while the path was still coming back. Nothing reads
501 /// it after this point anyway. 445 /// it after this point anyway.
502 fn reconnect(self: *AgentConnection, deadline_ms: i64) !void { 446 fn reconnect(self: *AgentConnection, deadline_ms: i64) !void {
447 const r = &self.redial.?;
503 const q = &self.link.quic; 448 const q = &self.link.quic;
504 const cl = try quic.Client.connect(self.alloc, q.addr, q.key, q.idle_ms); 449 const cl = try quic.Client.connect(self.alloc, r.addr, r.key, r.idle_ms);
505 errdefer cl.deinit(); 450 errdefer cl.deinit();
506 try waitReady(cl, deadline_ms); 451 try waitReady(cl, deadline_ms);
507 q.cl.deinit(); 452 q.cl.deinit();
508 q.cl = cl; 453 q.cl = cl;
509 q.reconnected = true; 454 // Whatever the torn connection would not take is dropped with it. It
455 // is the tail of a frame the daemon never finished reading, and
456 // prefixing it onto the new stream would make the re-sent attach
457 // unparseable. (The staging used to be a stack buffer inside the
458 // send, so this was implicit.)
459 q.qout.clearRetainingCapacity();
460 r.reconnected = true;
510 } 461 }
511 }; 462 };
512 463
@@ -519,13 +470,8 @@ test "graceMs: flat over a socket, RTT-derived over QUIC, and capped" {
519 // The calculation depends only on the stored measurement, so no live client 470 // The calculation depends only on the stored measurement, so no live client
520 // is required. 471 // is required.
521 var far = AgentConnection{ 472 var far = AgentConnection{
522 .link = .{ .quic = .{ 473 .link = .{ .quic = .{ .cl = undefined, .alloc = alloc } },
523 .cl = undefined, 474 .redial = .{ .addr = undefined, .key = undefined, .idle_ms = 0, .connect_ms = 1 },
524 .addr = undefined,
525 .key = undefined,
526 .idle_ms = 0,
527 .connect_ms = 1,
528 } },
529 .alloc = alloc, 475 .alloc = alloc,
530 }; 476 };
531 try std.testing.expectEqual(@as(i64, 2_000), far.graceMs()); 477 try std.testing.expectEqual(@as(i64, 2_000), far.graceMs());
@@ -533,15 +479,15 @@ test "graceMs: flat over a socket, RTT-derived over QUIC, and capped" {
533 // A 300ms handshake — a real intercontinental link — buys 1.2s, which 479 // A 300ms handshake — a real intercontinental link — buys 1.2s, which
534 // is still under the floor, so the first number that moves it is a 480 // is still under the floor, so the first number that moves it is a
535 // handshake past half a second. 481 // handshake past half a second.
536 far.link.quic.connect_ms = 300; 482 far.redial.?.connect_ms = 300;
537 try std.testing.expectEqual(@as(i64, 2_000), far.graceMs()); 483 try std.testing.expectEqual(@as(i64, 2_000), far.graceMs());
538 far.link.quic.connect_ms = 900; 484 far.redial.?.connect_ms = 900;
539 try std.testing.expectEqual(@as(i64, 3_600), far.graceMs()); 485 try std.testing.expectEqual(@as(i64, 3_600), far.graceMs());
540 486
541 // And it stops widening: past the cap we are no longer waiting on a 487 // And it stops widening: past the cap we are no longer waiting on a
542 // daemon, we are waiting on a network that has already failed to carry 488 // daemon, we are waiting on a network that has already failed to carry
543 // an answer. 489 // an answer.
544 far.link.quic.connect_ms = 60_000; 490 far.redial.?.connect_ms = 60_000;
545 try std.testing.expectEqual(@as(i64, 30_000), far.graceMs()); 491 try std.testing.expectEqual(@as(i64, 30_000), far.graceMs());
546 } 492 }
547 493
@@ -576,11 +522,9 @@ test "reconnect: redials the same coordinates, and a dead port is a fast no" {
576 var conn = AgentConnection{ 522 var conn = AgentConnection{
577 .link = .{ .quic = .{ 523 .link = .{ .quic = .{
578 .cl = try quic.Client.connect(alloc, addr, key, 1_000), 524 .cl = try quic.Client.connect(alloc, addr, key, 1_000),
579 .addr = addr, 525 .alloc = alloc,
580 .key = key,
581 .idle_ms = 1_000,
582 .connect_ms = 0,
583 } }, 526 } },
527 .redial = .{ .addr = addr, .key = key, .idle_ms = 1_000, .connect_ms = 0 },
584 .alloc = alloc, 528 .alloc = alloc,
585 }; 529 };
586 defer conn.close(); 530 defer conn.close();
@@ -612,7 +556,7 @@ test "reconnect: redials the same coordinates, and a dead port is a fast no" {
612 // A redial that failed is not a reconnect spent — but it is also not a 556 // A redial that failed is not a reconnect spent — but it is also not a
613 // AgentConnection holding a freed client: the old one is torn down only once a 557 // AgentConnection holding a freed client: the old one is torn down only once a
614 // new one is up, so the close above is safe on this path. 558 // new one is up, so the close above is safe on this path.
615 try std.testing.expect(!conn.link.quic.reconnected); 559 try std.testing.expect(!conn.redial.?.reconnected);
616 } 560 }
617 561
618 test "waitFailDetail: only a lost connection gets a sentence; the rest keep their names" { 562 test "waitFailDetail: only a lost connection gets a sentence; the rest keep their names" {
@@ -627,13 +571,8 @@ test "waitFailDetail: only a lost connection gets a sentence; the rest keep thei
627 571
628 // A tear with the one reconnect still unspent (nothing tried yet). 572 // A tear with the one reconnect still unspent (nothing tried yet).
629 var far = AgentConnection{ 573 var far = AgentConnection{
630 .link = .{ .quic = .{ 574 .link = .{ .quic = .{ .cl = undefined, .alloc = alloc } },
631 .cl = undefined, 575 .redial = .{ .addr = undefined, .key = undefined, .idle_ms = 0, .connect_ms = 0 },
632 .addr = undefined,
633 .key = undefined,
634 .idle_ms = 0,
635 .connect_ms = 0,
636 } },
637 .alloc = alloc, 576 .alloc = alloc,
638 }; 577 };
639 try std.testing.expectEqualStrings("connection lost", waitFailDetail(&buf, &far, error.ConnectionLost)); 578 try std.testing.expectEqualStrings("connection lost", waitFailDetail(&buf, &far, error.ConnectionLost));
@@ -641,7 +580,7 @@ test "waitFailDetail: only a lost connection gets a sentence; the rest keep thei
641 // A tear AFTER a reconnect that worked: the second one inside a single 580 // A tear AFTER a reconnect that worked: the second one inside a single
642 // wait, which is a different thing to be told than the first — the 581 // wait, which is a different thing to be told than the first — the
643 // client did reconnect, and the path tore again anyway. 582 // client did reconnect, and the path tore again anyway.
644 far.link.quic.reconnected = true; 583 far.redial.?.reconnected = true;
645 try std.testing.expectEqualStrings( 584 try std.testing.expectEqualStrings(
646 "connection lost again, after the one reconnect", 585 "connection lost again, after the one reconnect",
647 waitFailDetail(&buf, &far, error.ConnectionLost), 586 waitFailDetail(&buf, &far, error.ConnectionLost),
@@ -1261,7 +1200,7 @@ const await_grace_ms = 2_000;
1261 /// multiplying by four. 1200 /// multiplying by four.
1262 const grace_cap_ms = 30_000; 1201 const grace_cap_ms = 30_000;
1263 1202
1264 /// How long `sendFrameQuic` keeps offering a frame's tail to a full egress ring. 1203 /// How long `writeThrough` keeps offering a frame's tail to a full egress ring.
1265 /// Reaching it means the peer stopped acknowledging 256KB of backlog — a dead 1204 /// Reaching it means the peer stopped acknowledging 256KB of backlog — a dead
1266 /// connection in a different hat — but the bound is what keeps this call, which 1205 /// connection in a different hat — but the bound is what keeps this call, which
1267 /// has no deadline of its own, from waiting forever. 1206 /// has no deadline of its own, from waiting forever.
@@ -1331,7 +1270,7 @@ fn awaitReissuing(
1331 error.ConnectionLost => { 1270 error.ConnectionLost => {
1332 // Once per process, not per await: a loop here is a client 1271 // Once per process, not per await: a loop here is a client
1333 // that hides a daemon that is gone. 1272 // that hides a daemon that is gone.
1334 if (conn.link != .quic or conn.link.quic.reconnected) return e; 1273 if (conn.redial == null or conn.redial.?.reconnected) return e;
1335 // The deadline continues across the redial — four seconds 1274 // The deadline continues across the redial — four seconds
1336 // spent redialling are four seconds of the caller's wait, not 1275 // spent redialling are four seconds of the caller's wait, not
1337 // a fresh bound. 1276 // a fresh bound.
@@ -1371,10 +1310,7 @@ fn waitFailDetail(buf: []u8, conn: *const AgentConnection, e: anyerror) []const
1371 return std.fmt.bufPrint(buf, "connection lost; reconnect failed: {s}", .{why}) catch 1310 return std.fmt.bufPrint(buf, "connection lost; reconnect failed: {s}", .{why}) catch
1372 "connection lost; reconnect failed"; 1311 "connection lost; reconnect failed";
1373 } 1312 }
1374 const spent = switch (conn.link) { 1313 const spent = if (conn.redial) |r| r.reconnected else false;
1375 .quic => |q| q.reconnected,
1376 .fd => false,
1377 };
1378 if (spent) return "connection lost again, after the one reconnect"; 1314 if (spent) return "connection lost again, after the one reconnect";
1379 return "connection lost"; 1315 return "connection lost";
1380 } 1316 }