6cbd6c4c
refactor: muxa rides the Link, and the second transport retires
a73x 2026-08-31 19:29
Commit message
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 | } |