40b14979
feat: link is the fd|pipe|quic union and the one await loop, as a row
a73x 2026-08-31 18:26
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -167,6 +167,10 @@ const mod_table = [_]ModSpec{ | |||
| 167 | // embedder reaches a daemon by linking this and the wire contract | 167 | // embedder reaches a daemon by linking this and the wire contract |
| 168 | // instead of the whole client module. | 168 | // instead of the whole client module. |
| 169 | .{ .name = "dial", .path = "src/dial.zig", .imports = &.{"term"} }, | 169 | .{ .name = "dial", .path = "src/dial.zig", .imports = &.{"term"} }, |
| 170 | // The live connection itself — fd, pipe or QUIC — and the one wait-for-a- | ||
| 171 | // frame loop. `term` for frames, `quic` for the third arm; policy stays | ||
| 172 | // with the rows that import this one. | ||
| 173 | .{ .name = "link", .path = "src/link.zig", .link_libc = true, .imports = &.{ "term", "quic" }, .quic_tests = true }, | ||
| 170 | // Replays a captured client stdout stream and prints the final grid in | 174 | // Replays a captured client stdout stream and prints the final grid in |
| 171 | // `mux d dump`'s formats — the client half of the M11 render-vs-dump | 175 | // `mux d dump`'s formats — the client half of the M11 render-vs-dump |
| 172 | // convergence check. Imports term so both sides of the diff go through | 176 | // convergence check. Imports term so both sides of the diff go through |
| @@ -717,10 +721,10 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui | |||
| 717 | /// of all: it carries every argument parser but muxa's, its mains being | 721 | /// of all: it carries every argument parser but muxa's, its mains being |
| 718 | /// child files — a test that is never built is not a test (decisions.md). | 722 | /// child files — a test that is never built is not a test (decisions.md). |
| 719 | const test_order = [_][]const u8{ | 723 | const test_order = [_][]const u8{ |
| 720 | "script", "cliflags", "testtmp", "spawn", "dial", "quic", | 724 | "script", "cliflags", "testtmp", "spawn", "dial", "link", |
| 721 | "webhub", "agent", "term", "rawmode", "delaypipe", "render", | 725 | "quic", "webhub", "agent", "term", "rawmode", "delaypipe", |
| 722 | "wsclient", "ptyclient", "pty", "sockpath", "xdg", "proxy", | 726 | "render", "wsclient", "ptyclient", "pty", "sockpath", "xdg", |
| 723 | "wall", "client", "daemon", "mux", | 727 | "proxy", "wall", "client", "daemon", "mux", |
| 724 | }; | 728 | }; |
| 725 | 729 | ||
| 726 | comptime { | 730 | comptime { |
src/link.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,439 @@ | |||
| 1 | //! The live connection to a daemon, however it was reached: a unix-socket fd, | ||
| 2 | //! the stdio of a `--via`/handoff child, or a QUIC client. Mechanics only — | ||
| 3 | //! send a frame, read a frame, wait, close. Policy stays with the owners: | ||
| 4 | //! redial and backoff are `client.Transport`'s and muxa's, attach semantics | ||
| 5 | //! are dial's and the callers'. This row exists because the fd|pipe|quic | ||
| 6 | //! union used to live twice (client.Transport, muxa.AgentConnection), each | ||
| 7 | //! with its own send, await and close. | ||
| 8 | const std = @import("std"); | ||
| 9 | const proto = @import("term").protocol; | ||
| 10 | const quic = @import("quic"); | ||
| 11 | |||
| 12 | /// Three outcomes, not two: QUIC's socket goes readable for acks and half | ||
| 13 | /// frames, so `null` cannot keep the socket path's meaning of "peer gone" | ||
| 14 | /// without making every partial frame a reconnect. (Moved from client.zig.) | ||
| 15 | pub const Incoming = union(enum) { | ||
| 16 | frame: proto.Frame, | ||
| 17 | incomplete, | ||
| 18 | closed, | ||
| 19 | }; | ||
| 20 | |||
| 21 | /// What a non-matching frame does to an `awaitFrame` wait. `on == null` is | ||
| 22 | /// drop: the frame is freed and the wait continues — the observer-verb | ||
| 23 | /// policy dial.ask always had. A non-null `on` BORROWS the frame for the | ||
| 24 | /// duration of the call and must copy anything it keeps; awaitFrame frees | ||
| 25 | /// the frame when `on` returns. An error out of `on` ends the wait with | ||
| 26 | /// that error — some "other" frames are answers, not noise (muxa's | ||
| 27 | /// exit_status), and only the caller knows which. | ||
| 28 | pub const Sink = struct { | ||
| 29 | ctx: ?*anyopaque = null, | ||
| 30 | on: ?*const fn (ctx: ?*anyopaque, frame: proto.Frame) anyerror!void = null, | ||
| 31 | }; | ||
| 32 | |||
| 33 | pub const Link = union(enum) { | ||
| 34 | /// A unix socket: one fd, read and write. -1 once closed — close() is | ||
| 35 | /// idempotent because a re-dial releases the dead link on entry and an | ||
| 36 | /// abort then closes the same value again through a defer; a second | ||
| 37 | /// close(2) on a stale fd is EBADF, which std.posix maps to unreachable. | ||
| 38 | fd: std.posix.fd_t, | ||
| 39 | /// `--via`, and the ssh half of a handoff: the child whose stdio IS the | ||
| 40 | /// transport. `r` is its stdout, `w` its stdin. | ||
| 41 | pipe: Pipe, | ||
| 42 | /// The connection that IS the transport; bytes go through the stream | ||
| 43 | /// layer, so there is nothing to write(2) to. | ||
| 44 | quic: Quic, | ||
| 45 | |||
| 46 | pub const Pipe = struct { | ||
| 47 | child: std.process.Child, | ||
| 48 | r: std.posix.fd_t, | ||
| 49 | w: std.posix.fd_t, | ||
| 50 | }; | ||
| 51 | |||
| 52 | pub const Quic = struct { | ||
| 53 | cl: *quic.Client, | ||
| 54 | /// Bytes the QUIC ring would not take yet. Frames are appended whole | ||
| 55 | /// and handed over a prefix at a time, so a short accept can never | ||
| 56 | /// split one on the wire — the remainder is offered again next pass. | ||
| 57 | /// Lives here and not in the wrappers because the staging is a | ||
| 58 | /// property of the quic link, and it used to exist twice | ||
| 59 | /// (Transport.qout, muxa.sendFrameQuic's stack buffer). | ||
| 60 | qout: std.ArrayList(u8) = .empty, | ||
| 61 | alloc: std.mem.Allocator, | ||
| 62 | }; | ||
| 63 | |||
| 64 | pub fn pollFd(self: *const Link) std.posix.fd_t { | ||
| 65 | return switch (self.*) { | ||
| 66 | .fd => |fd| fd, | ||
| 67 | .pipe => |p| p.r, | ||
| 68 | .quic => |q| q.cl.pollFd(), | ||
| 69 | }; | ||
| 70 | } | ||
| 71 | |||
| 72 | /// Folds ngtcp2's next deadline in, so retransmits and idle timeouts | ||
| 73 | /// happen on time without a second timer. | ||
| 74 | pub fn timeoutMs(self: *Link, cap_ms: i32) i32 { | ||
| 75 | return switch (self.*) { | ||
| 76 | .quic => |*q| q.cl.timeoutMs(cap_ms), | ||
| 77 | .fd, .pipe => cap_ms, | ||
| 78 | }; | ||
| 79 | } | ||
| 80 | |||
| 81 | /// Unconditional: a QUIC connection's timers are the only thing that | ||
| 82 | /// notices a peer which stopped answering. | ||
| 83 | pub fn service(self: *Link) void { | ||
| 84 | switch (self.*) { | ||
| 85 | .quic => |*q| { | ||
| 86 | q.cl.pump(); | ||
| 87 | self.flushQuic(); | ||
| 88 | }, | ||
| 89 | .fd, .pipe => {}, | ||
| 90 | } | ||
| 91 | } | ||
| 92 | |||
| 93 | /// Queue-and-offer, never blocking: fd and pipe write through; quic | ||
| 94 | /// appends whole and flushes what the ring takes. A caller that must | ||
| 95 | /// KNOW the bytes left (muxa's verbs) follows with flushWithin. | ||
| 96 | pub fn sendFrame(self: *Link, t: proto.MsgType, payload: []const u8) !void { | ||
| 97 | switch (self.*) { | ||
| 98 | .quic => |*q| { | ||
| 99 | try proto.appendFrame(&q.qout, q.alloc, t, payload); | ||
| 100 | self.flushQuic(); | ||
| 101 | }, | ||
| 102 | .fd => |fd| return proto.writeFrame(fd, t, payload), | ||
| 103 | .pipe => |p| return proto.writeFrame(p.w, t, payload), | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | /// Offer the outbound queue to the ring again. After every write and on | ||
| 108 | /// every service pass, because the room to accept comes from | ||
| 109 | /// acknowledgements, which arrive on their own schedule. | ||
| 110 | fn flushQuic(self: *Link) void { | ||
| 111 | const q = switch (self.*) { | ||
| 112 | .quic => |*q| q, | ||
| 113 | .fd, .pipe => return, | ||
| 114 | }; | ||
| 115 | if (q.qout.items.len == 0) return; | ||
| 116 | const n = q.cl.send(q.qout.items); | ||
| 117 | if (n == 0) return; | ||
| 118 | q.qout.replaceRangeAssumeCapacity(0, n, &.{}); | ||
| 119 | } | ||
| 120 | |||
| 121 | /// Drive the staged bytes out or say why not, within the deadline. A | ||
| 122 | /// no-op for fd/pipe (their sendFrame already either took the bytes or | ||
| 123 | /// failed). The QUIC arm is muxa's old sendFrameQuic loop: the ring is | ||
| 124 | /// full, only the peer's acks empty it, and they arrive through pump — | ||
| 125 | /// polling first keeps this from spinning. | ||
| 126 | pub fn flushWithin(self: *Link, deadline_ms: u32) !void { | ||
| 127 | const q = switch (self.*) { | ||
| 128 | .quic => |*q| q, | ||
| 129 | .fd, .pipe => return, | ||
| 130 | }; | ||
| 131 | const end = std.time.milliTimestamp() + deadline_ms; | ||
| 132 | while (q.qout.items.len != 0) { | ||
| 133 | if (q.cl.dead) return error.ConnectionLost; | ||
| 134 | self.flushQuic(); | ||
| 135 | if (q.qout.items.len == 0) return; | ||
| 136 | if (std.time.milliTimestamp() >= end) return error.SendStalled; | ||
| 137 | var fds = [_]std.posix.pollfd{ | ||
| 138 | .{ .fd = q.cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 139 | }; | ||
| 140 | _ = std.posix.poll(&fds, q.cl.timeoutMs(50)) catch return error.ConnectionLost; | ||
| 141 | q.cl.pump(); | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | /// The next whole frame, if there is one. (Moved from | ||
| 146 | /// client.Transport.readFrame; see Incoming for why a missing frame is | ||
| 147 | /// not automatically a dead transport.) | ||
| 148 | pub fn readFrame(self: *Link, alloc: std.mem.Allocator) !Incoming { | ||
| 149 | switch (self.*) { | ||
| 150 | .quic => |*q| { | ||
| 151 | // Death is checked after the pump, so bytes that arrived in | ||
| 152 | // the same pass as the close are still delivered before the | ||
| 153 | // tear. | ||
| 154 | const got = proto.takeFrame(alloc, &q.cl.in) catch |err| switch (err) { | ||
| 155 | error.OutOfMemory => return err, // not a transport event | ||
| 156 | else => return .closed, | ||
| 157 | }; | ||
| 158 | if (got) |frame| return .{ .frame = frame }; | ||
| 159 | return if (q.cl.dead) .closed else .incomplete; | ||
| 160 | }, | ||
| 161 | .fd => |fd| { | ||
| 162 | const frame = (proto.readFrame(alloc, fd) catch |err| switch (err) { | ||
| 163 | error.OutOfMemory => return err, | ||
| 164 | else => return .closed, | ||
| 165 | }) orelse return .closed; | ||
| 166 | return .{ .frame = frame }; | ||
| 167 | }, | ||
| 168 | .pipe => |p| { | ||
| 169 | const frame = (proto.readFrame(alloc, p.r) catch |err| switch (err) { | ||
| 170 | error.OutOfMemory => return err, | ||
| 171 | else => return .closed, | ||
| 172 | }) orelse return .closed; | ||
| 173 | return .{ .frame = frame }; | ||
| 174 | }, | ||
| 175 | } | ||
| 176 | } | ||
| 177 | |||
| 178 | /// Wait for one frame of type `want`. THE primitive this row exists | ||
| 179 | /// for; every hand-rolled poll+readFrame loop in the tree is a copy of | ||
| 180 | /// this. Returns null when the deadline runs out (a null deadline waits | ||
| 181 | /// forever), error.Closed when the peer is gone — callers own the | ||
| 182 | /// wording for both (dial.ask maps Closed to its "no answer" null; muxa | ||
| 183 | /// maps it to DaemonGone/ConnectionLost). Non-matching frames go to the | ||
| 184 | /// sink (see Sink for ownership). | ||
| 185 | pub fn awaitFrame( | ||
| 186 | self: *Link, | ||
| 187 | alloc: std.mem.Allocator, | ||
| 188 | want: proto.MsgType, | ||
| 189 | deadline_ms: ?u32, | ||
| 190 | sink: Sink, | ||
| 191 | ) !?proto.Frame { | ||
| 192 | const end: ?i64 = if (deadline_ms) |ms| std.time.milliTimestamp() + ms else null; | ||
| 193 | // Whether this link may be asked for a frame without poll saying so | ||
| 194 | // first. QUIC may: a whole frame can already sit in cl.in from an | ||
| 195 | // earlier pump, and poll would never fire for bytes that are already | ||
| 196 | // in userspace. A BOUNDED fd or pipe may not: readFrame there is a | ||
| 197 | // read(2) on a stream, which on a quiet peer returns only when that | ||
| 198 | // peer speaks or goes away — the wait has to be spent in poll, where | ||
| 199 | // the deadline can end it, which is why dial.askOn polls first too. | ||
| 200 | // With no deadline the blocking read IS the wait, so they read | ||
| 201 | // straight away and never reach the poll below. | ||
| 202 | const eager = switch (self.*) { | ||
| 203 | .quic => true, | ||
| 204 | .fd, .pipe => end == null, | ||
| 205 | }; | ||
| 206 | var ready = eager; | ||
| 207 | while (true) { | ||
| 208 | if (ready) { | ||
| 209 | // A non-match falls back to `eager`: on a bounded fd the next | ||
| 210 | // frame has to be announced by poll again, or draining noise | ||
| 211 | // would walk straight into a blocking read. | ||
| 212 | ready = eager; | ||
| 213 | switch (try self.readFrame(alloc)) { | ||
| 214 | .frame => |frame| { | ||
| 215 | if (frame.type == want) return frame; | ||
| 216 | if (sink.on) |on| { | ||
| 217 | defer frame.deinit(alloc); | ||
| 218 | try on(sink.ctx, frame); | ||
| 219 | } else frame.deinit(alloc); | ||
| 220 | continue; | ||
| 221 | }, | ||
| 222 | .closed => return error.Closed, | ||
| 223 | .incomplete => {}, | ||
| 224 | } | ||
| 225 | } | ||
| 226 | // Nothing whole in hand: wait. The wait is capped at 250ms even | ||
| 227 | // with no caller deadline, because a QUIC link's timers (loss | ||
| 228 | // detection, keepalive) need servicing on schedule rather than | ||
| 229 | // whenever the daemon happens to say something; a plain fd with | ||
| 230 | // no deadline may block indefinitely, which is what a caller | ||
| 231 | // wants when a stopped daemon should be a visible hang. | ||
| 232 | var cap: i32 = undefined; | ||
| 233 | if (end) |e| { | ||
| 234 | const left = e - std.time.milliTimestamp(); | ||
| 235 | if (left <= 0) return null; | ||
| 236 | cap = @intCast(@min(left, 250)); | ||
| 237 | } else cap = switch (self.*) { | ||
| 238 | .quic => 250, | ||
| 239 | .fd, .pipe => -1, | ||
| 240 | }; | ||
| 241 | var fds = [_]std.posix.pollfd{ | ||
| 242 | .{ .fd = self.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 243 | }; | ||
| 244 | const n = std.posix.poll(&fds, self.timeoutMs(cap)) catch return error.Closed; | ||
| 245 | self.service(); | ||
| 246 | // A poll that timed out leaves a bounded fd with nothing to read, | ||
| 247 | // and going back to read(2) on it would block past the deadline; | ||
| 248 | // the next pass re-checks the clock instead. QUIC always looks | ||
| 249 | // again — readFrame never blocks there, and the pump just now may | ||
| 250 | // have completed a frame out of bytes that arrived earlier. | ||
| 251 | ready = eager or n != 0; | ||
| 252 | // Once poll HAS announced bytes, the fd arms still block in | ||
| 253 | // readFrame until that frame is whole, even past the deadline — | ||
| 254 | // the price of frames over a stream, same as dial.askOn always | ||
| 255 | // paid; a malformed frame is an error rather than an absent | ||
| 256 | // reply. | ||
| 257 | } | ||
| 258 | } | ||
| 259 | |||
| 260 | /// Idempotent, and it has to be: a re-dial releases the dead link on | ||
| 261 | /// entry, and an abort then closes the same value again through the | ||
| 262 | /// pump's defer. (The pipe-kill ordering is client.Transport.close's, | ||
| 263 | /// moved: stdin first so the command sees EOF and can wind down its | ||
| 264 | /// remote end, then TERM. kill() waitpid()s internally — no zombie.) | ||
| 265 | pub fn close(self: *Link) void { | ||
| 266 | switch (self.*) { | ||
| 267 | .fd => |fd| { | ||
| 268 | if (fd == -1) return; // already released | ||
| 269 | std.posix.close(fd); | ||
| 270 | }, | ||
| 271 | .pipe => |*p| { | ||
| 272 | if (p.child.stdin) |*in| { | ||
| 273 | in.close(); | ||
| 274 | p.child.stdin = null; | ||
| 275 | } | ||
| 276 | _ = p.child.kill() catch {}; | ||
| 277 | }, | ||
| 278 | .quic => |*q| { | ||
| 279 | q.qout.deinit(q.alloc); | ||
| 280 | // Closes the UDP socket with it, so pollFd's value must not | ||
| 281 | // be closed again. | ||
| 282 | q.cl.deinit(); | ||
| 283 | }, | ||
| 284 | } | ||
| 285 | self.* = .{ .fd = -1 }; | ||
| 286 | } | ||
| 287 | }; | ||
| 288 | |||
| 289 | const testing = std.testing; | ||
| 290 | |||
| 291 | fn mkPair() ![2]std.posix.fd_t { | ||
| 292 | var pair: [2]i32 = undefined; | ||
| 293 | try testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | ||
| 294 | return .{ pair[0], pair[1] }; | ||
| 295 | } | ||
| 296 | |||
| 297 | test "fd link: sendFrame puts one frame on the wire, readFrame takes it back" { | ||
| 298 | const alloc = testing.allocator; | ||
| 299 | const pair = try mkPair(); | ||
| 300 | var a: Link = .{ .fd = pair[0] }; | ||
| 301 | var b: Link = .{ .fd = pair[1] }; | ||
| 302 | defer a.close(); | ||
| 303 | defer b.close(); | ||
| 304 | try a.sendFrame(.input, "hi"); | ||
| 305 | const inc = try b.readFrame(alloc); | ||
| 306 | const frame = inc.frame; | ||
| 307 | defer frame.deinit(alloc); | ||
| 308 | try testing.expectEqual(proto.MsgType.input, frame.type); | ||
| 309 | try testing.expectEqualStrings("hi", frame.payload); | ||
| 310 | } | ||
| 311 | |||
| 312 | test "pipe link: the child's stdio IS the transport, and r and w are not interchangeable" { | ||
| 313 | const alloc = testing.allocator; | ||
| 314 | // A real child rather than a socketpair standing in for one, because the | ||
| 315 | // thing under test is which of the child's two fds each direction uses. | ||
| 316 | // `cat` echoes, so a frame that comes back proves sendFrame wrote the | ||
| 317 | // child's STDIN and readFrame read its STDOUT; swap the pair and both | ||
| 318 | // directions address the wrong end of a pipe and fail. A socketpair is | ||
| 319 | // symmetric and would pass either way. | ||
| 320 | var child = std.process.Child.init(&.{"cat"}, alloc); | ||
| 321 | child.stdin_behavior = .Pipe; | ||
| 322 | child.stdout_behavior = .Pipe; | ||
| 323 | try child.spawn(); | ||
| 324 | var l: Link = .{ .pipe = .{ | ||
| 325 | .child = child, | ||
| 326 | .r = child.stdout.?.handle, | ||
| 327 | .w = child.stdin.?.handle, | ||
| 328 | } }; | ||
| 329 | defer l.close(); | ||
| 330 | try l.sendFrame(.input, "echo me"); | ||
| 331 | const got = (try l.awaitFrame(alloc, .input, 5000, .{})).?; | ||
| 332 | defer got.deinit(alloc); | ||
| 333 | try testing.expectEqualStrings("echo me", got.payload); | ||
| 334 | } | ||
| 335 | |||
| 336 | test "close is idempotent on every arm that can be closed twice" { | ||
| 337 | const pair = try mkPair(); | ||
| 338 | std.posix.close(pair[1]); | ||
| 339 | var l: Link = .{ .fd = pair[0] }; | ||
| 340 | l.close(); | ||
| 341 | l.close(); // second close must be a no-op, not EBADF-unreachable | ||
| 342 | } | ||
| 343 | |||
| 344 | test "awaitFrame: null sink drops noise frames and returns the match" { | ||
| 345 | const alloc = testing.allocator; | ||
| 346 | const pair = try mkPair(); | ||
| 347 | var l: Link = .{ .fd = pair[0] }; | ||
| 348 | defer l.close(); | ||
| 349 | // Two noise frames BEFORE the answer: the wait must survive a stream, | ||
| 350 | // not a single-frame fixture. | ||
| 351 | try proto.writeFrame(pair[1], .pty_mode, &.{0}); | ||
| 352 | try proto.writeFrame(pair[1], .delta, "x"); | ||
| 353 | try proto.writeFrame(pair[1], .stats_reply, "ok"); | ||
| 354 | std.posix.close(pair[1]); | ||
| 355 | const got = (try l.awaitFrame(alloc, .stats_reply, 1000, .{})).?; | ||
| 356 | defer got.deinit(alloc); | ||
| 357 | try testing.expectEqualStrings("ok", got.payload); | ||
| 358 | } | ||
| 359 | |||
| 360 | test "awaitFrame: the sink sees every non-match, in order, and may end the wait" { | ||
| 361 | const alloc = testing.allocator; | ||
| 362 | const Seen = struct { | ||
| 363 | types: [8]proto.MsgType = undefined, | ||
| 364 | n: usize = 0, | ||
| 365 | fn on(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void { | ||
| 366 | const self: *@This() = @ptrCast(@alignCast(ctx.?)); | ||
| 367 | self.types[self.n] = frame.type; | ||
| 368 | self.n += 1; | ||
| 369 | if (frame.type == .exit_status) return error.SessionExited; | ||
| 370 | } | ||
| 371 | }; | ||
| 372 | // Leg one: the sink observes and the wait completes. | ||
| 373 | { | ||
| 374 | const pair = try mkPair(); | ||
| 375 | var l: Link = .{ .fd = pair[0] }; | ||
| 376 | defer l.close(); | ||
| 377 | try proto.writeFrame(pair[1], .snapshot, ""); | ||
| 378 | try proto.writeFrame(pair[1], .stats_reply, "ok"); | ||
| 379 | std.posix.close(pair[1]); | ||
| 380 | var seen: Seen = .{}; | ||
| 381 | const got = (try l.awaitFrame(alloc, .stats_reply, 1000, .{ .ctx = &seen, .on = Seen.on })).?; | ||
| 382 | defer got.deinit(alloc); | ||
| 383 | try testing.expectEqual(@as(usize, 1), seen.n); | ||
| 384 | try testing.expectEqual(proto.MsgType.snapshot, seen.types[0]); | ||
| 385 | } | ||
| 386 | // Leg two: the sink's error IS the outcome — an "other" frame that is | ||
| 387 | // an answer, muxa's whole reason for a second copy. | ||
| 388 | { | ||
| 389 | const pair = try mkPair(); | ||
| 390 | var l: Link = .{ .fd = pair[0] }; | ||
| 391 | defer l.close(); | ||
| 392 | try proto.writeFrame(pair[1], .exit_status, &.{7}); | ||
| 393 | std.posix.close(pair[1]); | ||
| 394 | var seen: Seen = .{}; | ||
| 395 | try testing.expectError( | ||
| 396 | error.SessionExited, | ||
| 397 | l.awaitFrame(alloc, .stats_reply, 1000, .{ .ctx = &seen, .on = Seen.on }), | ||
| 398 | ); | ||
| 399 | } | ||
| 400 | } | ||
| 401 | |||
| 402 | test "awaitFrame: silence consumes the deadline and returns null; a closed peer is error.Closed" { | ||
| 403 | const alloc = testing.allocator; | ||
| 404 | { | ||
| 405 | const pair = try mkPair(); | ||
| 406 | defer std.posix.close(pair[1]); | ||
| 407 | var l: Link = .{ .fd = pair[0] }; | ||
| 408 | defer l.close(); | ||
| 409 | const t0 = std.time.milliTimestamp(); | ||
| 410 | try testing.expect((try l.awaitFrame(alloc, .stats_reply, 100, .{})) == null); | ||
| 411 | try testing.expect(std.time.milliTimestamp() - t0 >= 100); | ||
| 412 | } | ||
| 413 | { | ||
| 414 | const pair = try mkPair(); | ||
| 415 | var l: Link = .{ .fd = pair[0] }; | ||
| 416 | defer l.close(); | ||
| 417 | std.posix.close(pair[1]); | ||
| 418 | try testing.expectError(error.Closed, l.awaitFrame(alloc, .stats_reply, 1000, .{})); | ||
| 419 | } | ||
| 420 | } | ||
| 421 | |||
| 422 | test "awaitFrame: no deadline waits out a late reply" { | ||
| 423 | const alloc = testing.allocator; | ||
| 424 | const pair = try mkPair(); | ||
| 425 | var l: Link = .{ .fd = pair[0] }; | ||
| 426 | defer l.close(); | ||
| 427 | const Late = struct { | ||
| 428 | fn run(fd: std.posix.fd_t) void { | ||
| 429 | std.Thread.sleep(150 * std.time.ns_per_ms); | ||
| 430 | proto.writeFrame(fd, .stats_reply, "late") catch {}; | ||
| 431 | std.posix.close(fd); | ||
| 432 | } | ||
| 433 | }; | ||
| 434 | const t = try std.Thread.spawn(.{}, Late.run, .{pair[1]}); | ||
| 435 | defer t.join(); | ||
| 436 | const got = (try l.awaitFrame(alloc, .stats_reply, null, .{})).?; | ||
| 437 | defer got.deinit(alloc); | ||
| 438 | try testing.expectEqualStrings("late", got.payload); | ||
| 439 | } | ||