a73x

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
feat: link is the fd|pipe|quic union and the one await loop, as a row

One change from the brief's code, and it is behavioural rather than a
spelling: awaitFrame polls before it reads on a BOUNDED fd or pipe
instead of reading first.

The brief read first on every arm, for the QUIC reason — a whole frame
may already sit in cl.in from an earlier pump, and poll never fires for
bytes already in userspace. But readFrame on the fd and pipe arms is a
read(2) on a stream, and on a quiet peer that blocks until the peer
speaks or goes away. The brief's own "silence consumes the deadline"
test therefore never returned: `make check` ran 420s to a timeout, and
a standalone repro confirmed read(2) on a socketpair whose peer is open
and silent does not come back at all. A test that hangs takes the whole
`zig build test` runner down with it and prints nothing, which is the
failure mode CLAUDE.md names first.

So the eager read is now the QUIC arm's and the deadline-free arm's,
where the block IS the wait; a bounded fd spends its wait in poll,
where the deadline can end it, exactly as dial.askOn has always done.
Once poll has announced bytes the fd arms still block until the frame
is whole — that price is unchanged.

One test beyond the brief's list. Its fixture held the arm constant —
every case was `.fd` — so the pipe arm's r/w assignment was unpinned
and a swap of the two would have shipped green. The added test runs a
real `cat` and reads its echo back, which fails if either direction
addresses the wrong end.

Verified by breaking one assertion in each test and seeing each fail by
name, then restoring: make check rc=0.

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 }