1aaa22fe
Plan: connection primitives, seven tasks
a73x 2026-08-31 18:05
Commit message
docs/superpowers/plans/2026-08-31-connection-primitives.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,1069 @@ | |||
| 1 | # Connection Primitives Implementation Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** One owner each for "a live connection to a daemon" (`src/link.zig`), "wait for one frame of type X" (`Link.awaitFrame`), "bind a unix socket I own" (`src/serve.zig`), and "pump a test daemon to a condition" (`harness.pumpUntil`) — deleting ~10 await-loop copies, 3 divergent binder sites, and 13 spin loops. | ||
| 6 | |||
| 7 | **Architecture:** The fd|pipe|quic union is promoted out of `client.Transport` into a shared `link` row; `Transport` and muxa's `AgentConnection` become policy wrappers around it. `serve.zig` owns bind+teardown (guarded unlink for all binders); accept loops stay with their owners. No wire change anywhere. | ||
| 8 | |||
| 9 | **Tech Stack:** Zig 0.15.2 (vendored: `deps/zig/zig`, the Makefile points at it — system zig will NOT build this). Linux only. | ||
| 10 | |||
| 11 | **Spec:** `docs/superpowers/specs/2026-08-31-connection-primitives-design.md` | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - Build/test ONLY through the Makefile: `make check` after every task (fmt + unit tests + comment-claim refs), `make ci` at delivery. Capture `$?` before piping (`make test | tail` reports tail's exit). | ||
| 16 | - **No wire change.** Every byte on every socket identical before and after. No new frame types, no encoder edits. | ||
| 17 | - `proxy.zig` and the QUIC modules must NEVER import `link` or `term` — the byte-blind invariant. | ||
| 18 | - Comments say *why*, plainly; `zig build check` gates that cited symbols resolve. When moving code, move its load-bearing comments with it. | ||
| 19 | - A unit test that writes to fd 1 wedges `zig build test` silently. Tests capture output into pipes if they must. | ||
| 20 | - Where a thing can be plural, the default test fixture is plural; N=1 is an extra case, not the baseline. | ||
| 21 | - Commit per step with `--fixup`/`--squash` where amending; autosquash before delivery so history tells the feature's story. The commit-msg hook stamps `Patch:` trailers. | ||
| 22 | - Any hand-run rig exports isolated `XDG_STATE_HOME` and `XDG_RUNTIME_DIR`. | ||
| 23 | - The plan's code blocks are the reference; if the vendored Zig rejects a detail (e.g. a comptime fn-param spelling), fix the spelling, keep the contract, and note it in the commit body. | ||
| 24 | |||
| 25 | --- | ||
| 26 | |||
| 27 | ### Task 1: `src/link.zig` — the Link union and its mechanics | ||
| 28 | |||
| 29 | **Files:** | ||
| 30 | - Create: `src/link.zig` | ||
| 31 | - Modify: `build.zig` (module table around line 169, test-rows list around line 720) | ||
| 32 | |||
| 33 | **Interfaces:** | ||
| 34 | - Consumes: `term.protocol` (`writeFrame`, `readFrame`, `takeFrame`, `appendFrame`, `Frame`, `MsgType`), `quic.Client` (`pollFd`, `timeoutMs`, `pump`, `send`, `deinit`, fields `in`, `dead`). | ||
| 35 | - Produces (later tasks rely on these exact names): | ||
| 36 | - `pub const Link = union(enum) { fd, pipe, quic }` with `Pipe = { child: std.process.Child, r, w: std.posix.fd_t }` and `Quic = { cl: *quic.Client, qout: std.ArrayList(u8), alloc: std.mem.Allocator }` | ||
| 37 | - `pub const Incoming = union(enum) { frame: proto.Frame, incomplete, closed }` | ||
| 38 | - `pollFd(self: *const Link) std.posix.fd_t` | ||
| 39 | - `timeoutMs(self: *Link, cap_ms: i32) i32` | ||
| 40 | - `service(self: *Link) void` | ||
| 41 | - `sendFrame(self: *Link, t: proto.MsgType, payload: []const u8) !void` | ||
| 42 | - `flushWithin(self: *Link, deadline_ms: u32) !void` (errors: `error.ConnectionLost`, `error.SendStalled`) | ||
| 43 | - `readFrame(self: *Link, alloc: std.mem.Allocator) !Incoming` | ||
| 44 | - `close(self: *Link) void` (idempotent) | ||
| 45 | |||
| 46 | - [ ] **Step 1: Write `src/link.zig` with its tests included (Zig keeps tests in-file)** | ||
| 47 | |||
| 48 | The file. Module header states the contract; bodies below are moved from | ||
| 49 | `client.zig:694-766` (write/service/timeout/flush/read) with the switch arms | ||
| 50 | preserved byte-for-byte where they exist today: | ||
| 51 | |||
| 52 | ```zig | ||
| 53 | //! The live connection to a daemon, however it was reached: a unix-socket fd, | ||
| 54 | //! the stdio of a `--via`/handoff child, or a QUIC client. Mechanics only — | ||
| 55 | //! send a frame, read a frame, wait, close. Policy stays with the owners: | ||
| 56 | //! redial and backoff are `client.Transport`'s and muxa's, attach semantics | ||
| 57 | //! are dial's and the callers'. This row exists because the fd|pipe|quic | ||
| 58 | //! union used to live twice (client.Transport, muxa.AgentConnection), each | ||
| 59 | //! with its own send, await and close. | ||
| 60 | const std = @import("std"); | ||
| 61 | const proto = @import("term").protocol; | ||
| 62 | const quic = @import("quic"); | ||
| 63 | |||
| 64 | /// Three outcomes, not two: QUIC's socket goes readable for acks and half | ||
| 65 | /// frames, so `null` cannot keep the socket path's meaning of "peer gone" | ||
| 66 | /// without making every partial frame a reconnect. (Moved from client.zig.) | ||
| 67 | pub const Incoming = union(enum) { | ||
| 68 | frame: proto.Frame, | ||
| 69 | incomplete, | ||
| 70 | closed, | ||
| 71 | }; | ||
| 72 | |||
| 73 | /// What a non-matching frame does to an `awaitFrame` wait. `on == null` is | ||
| 74 | /// drop: the frame is freed and the wait continues — the observer-verb | ||
| 75 | /// policy dial.ask always had. A non-null `on` BORROWS the frame for the | ||
| 76 | /// duration of the call and must copy anything it keeps; awaitFrame frees | ||
| 77 | /// the frame when `on` returns. An error out of `on` ends the wait with | ||
| 78 | /// that error — some "other" frames are answers, not noise (muxa's | ||
| 79 | /// exit_status), and only the caller knows which. | ||
| 80 | pub const Sink = struct { | ||
| 81 | ctx: ?*anyopaque = null, | ||
| 82 | on: ?*const fn (ctx: ?*anyopaque, frame: proto.Frame) anyerror!void = null, | ||
| 83 | }; | ||
| 84 | |||
| 85 | pub const Link = union(enum) { | ||
| 86 | /// A unix socket: one fd, read and write. -1 once closed — close() is | ||
| 87 | /// idempotent because a re-dial releases the dead link on entry and an | ||
| 88 | /// abort then closes the same value again through a defer; a second | ||
| 89 | /// close(2) on a stale fd is EBADF, which std.posix maps to unreachable. | ||
| 90 | fd: std.posix.fd_t, | ||
| 91 | /// `--via`, and the ssh half of a handoff: the child whose stdio IS the | ||
| 92 | /// transport. `r` is its stdout, `w` its stdin. | ||
| 93 | pipe: Pipe, | ||
| 94 | /// The connection that IS the transport; bytes go through the stream | ||
| 95 | /// layer, so there is nothing to write(2) to. | ||
| 96 | quic: Quic, | ||
| 97 | |||
| 98 | pub const Pipe = struct { | ||
| 99 | child: std.process.Child, | ||
| 100 | r: std.posix.fd_t, | ||
| 101 | w: std.posix.fd_t, | ||
| 102 | }; | ||
| 103 | |||
| 104 | pub const Quic = struct { | ||
| 105 | cl: *quic.Client, | ||
| 106 | /// Bytes the QUIC ring would not take yet. Frames are appended whole | ||
| 107 | /// and handed over a prefix at a time, so a short accept can never | ||
| 108 | /// split one on the wire — the remainder is offered again next pass. | ||
| 109 | /// Lives here and not in the wrappers because the staging is a | ||
| 110 | /// property of the quic link, and it used to exist twice | ||
| 111 | /// (Transport.qout, muxa.sendFrameQuic's stack buffer). | ||
| 112 | qout: std.ArrayList(u8) = .empty, | ||
| 113 | alloc: std.mem.Allocator, | ||
| 114 | }; | ||
| 115 | |||
| 116 | pub fn pollFd(self: *const Link) std.posix.fd_t { | ||
| 117 | return switch (self.*) { | ||
| 118 | .fd => |fd| fd, | ||
| 119 | .pipe => |p| p.r, | ||
| 120 | .quic => |q| q.cl.pollFd(), | ||
| 121 | }; | ||
| 122 | } | ||
| 123 | |||
| 124 | /// Folds ngtcp2's next deadline in, so retransmits and idle timeouts | ||
| 125 | /// happen on time without a second timer. | ||
| 126 | pub fn timeoutMs(self: *Link, cap_ms: i32) i32 { | ||
| 127 | return switch (self.*) { | ||
| 128 | .quic => |*q| q.cl.timeoutMs(cap_ms), | ||
| 129 | .fd, .pipe => cap_ms, | ||
| 130 | }; | ||
| 131 | } | ||
| 132 | |||
| 133 | /// Unconditional: a QUIC connection's timers are the only thing that | ||
| 134 | /// notices a peer which stopped answering. | ||
| 135 | pub fn service(self: *Link) void { | ||
| 136 | switch (self.*) { | ||
| 137 | .quic => |*q| { | ||
| 138 | q.cl.pump(); | ||
| 139 | self.flushQuic(); | ||
| 140 | }, | ||
| 141 | .fd, .pipe => {}, | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | /// Queue-and-offer, never blocking: fd and pipe write through; quic | ||
| 146 | /// appends whole and flushes what the ring takes. A caller that must | ||
| 147 | /// KNOW the bytes left (muxa's verbs) follows with flushWithin. | ||
| 148 | pub fn sendFrame(self: *Link, t: proto.MsgType, payload: []const u8) !void { | ||
| 149 | switch (self.*) { | ||
| 150 | .quic => |*q| { | ||
| 151 | try proto.appendFrame(&q.qout, q.alloc, t, payload); | ||
| 152 | self.flushQuic(); | ||
| 153 | }, | ||
| 154 | .fd => |fd| return proto.writeFrame(fd, t, payload), | ||
| 155 | .pipe => |p| return proto.writeFrame(p.w, t, payload), | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | /// Offer the outbound queue to the ring again. After every write and on | ||
| 160 | /// every service pass, because the room to accept comes from | ||
| 161 | /// acknowledgements, which arrive on their own schedule. | ||
| 162 | fn flushQuic(self: *Link) void { | ||
| 163 | const q = switch (self.*) { | ||
| 164 | .quic => |*q| q, | ||
| 165 | .fd, .pipe => return, | ||
| 166 | }; | ||
| 167 | if (q.qout.items.len == 0) return; | ||
| 168 | const n = q.cl.send(q.qout.items); | ||
| 169 | if (n == 0) return; | ||
| 170 | q.qout.replaceRangeAssumeCapacity(0, n, &.{}); | ||
| 171 | } | ||
| 172 | |||
| 173 | /// Drive the staged bytes out or say why not, within the deadline. A | ||
| 174 | /// no-op for fd/pipe (their sendFrame already either took the bytes or | ||
| 175 | /// failed). The QUIC arm is muxa's old sendFrameQuic loop: the ring is | ||
| 176 | /// full, only the peer's acks empty it, and they arrive through pump — | ||
| 177 | /// polling first keeps this from spinning. | ||
| 178 | pub fn flushWithin(self: *Link, deadline_ms: u32) !void { | ||
| 179 | const q = switch (self.*) { | ||
| 180 | .quic => |*q| q, | ||
| 181 | .fd, .pipe => return, | ||
| 182 | }; | ||
| 183 | const end = std.time.milliTimestamp() + deadline_ms; | ||
| 184 | while (q.qout.items.len != 0) { | ||
| 185 | if (q.cl.dead) return error.ConnectionLost; | ||
| 186 | self.flushQuic(); | ||
| 187 | if (q.qout.items.len == 0) return; | ||
| 188 | if (std.time.milliTimestamp() >= end) return error.SendStalled; | ||
| 189 | var fds = [_]std.posix.pollfd{ | ||
| 190 | .{ .fd = q.cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 191 | }; | ||
| 192 | _ = std.posix.poll(&fds, q.cl.timeoutMs(50)) catch return error.ConnectionLost; | ||
| 193 | q.cl.pump(); | ||
| 194 | } | ||
| 195 | } | ||
| 196 | |||
| 197 | /// The next whole frame, if there is one. (Moved from | ||
| 198 | /// client.Transport.readFrame; see Incoming for why a missing frame is | ||
| 199 | /// not automatically a dead transport.) | ||
| 200 | pub fn readFrame(self: *Link, alloc: std.mem.Allocator) !Incoming { | ||
| 201 | switch (self.*) { | ||
| 202 | .quic => |*q| { | ||
| 203 | // Death is checked after the pump, so bytes that arrived in | ||
| 204 | // the same pass as the close are still delivered before the | ||
| 205 | // tear. | ||
| 206 | const got = proto.takeFrame(alloc, &q.cl.in) catch |err| switch (err) { | ||
| 207 | error.OutOfMemory => return err, // not a transport event | ||
| 208 | else => return .closed, | ||
| 209 | }; | ||
| 210 | if (got) |frame| return .{ .frame = frame }; | ||
| 211 | return if (q.cl.dead) .closed else .incomplete; | ||
| 212 | }, | ||
| 213 | .fd => |fd| { | ||
| 214 | const frame = (proto.readFrame(alloc, fd) catch |err| switch (err) { | ||
| 215 | error.OutOfMemory => return err, | ||
| 216 | else => return .closed, | ||
| 217 | }) orelse return .closed; | ||
| 218 | return .{ .frame = frame }; | ||
| 219 | }, | ||
| 220 | .pipe => |p| { | ||
| 221 | const frame = (proto.readFrame(alloc, p.r) catch |err| switch (err) { | ||
| 222 | error.OutOfMemory => return err, | ||
| 223 | else => return .closed, | ||
| 224 | }) orelse return .closed; | ||
| 225 | return .{ .frame = frame }; | ||
| 226 | }, | ||
| 227 | } | ||
| 228 | } | ||
| 229 | |||
| 230 | /// Wait for one frame of type `want`. THE primitive this row exists | ||
| 231 | /// for; every hand-rolled poll+readFrame loop in the tree is a copy of | ||
| 232 | /// this. Returns null when the deadline runs out (a null deadline waits | ||
| 233 | /// forever), error.Closed when the peer is gone — callers own the | ||
| 234 | /// wording for both (dial.ask maps Closed to its "no answer" null; muxa | ||
| 235 | /// maps it to DaemonGone/ConnectionLost). Non-matching frames go to the | ||
| 236 | /// sink (see Sink for ownership). | ||
| 237 | pub fn awaitFrame( | ||
| 238 | self: *Link, | ||
| 239 | alloc: std.mem.Allocator, | ||
| 240 | want: proto.MsgType, | ||
| 241 | deadline_ms: ?u32, | ||
| 242 | sink: Sink, | ||
| 243 | ) !?proto.Frame { | ||
| 244 | const end: ?i64 = if (deadline_ms) |ms| std.time.milliTimestamp() + ms else null; | ||
| 245 | while (true) { | ||
| 246 | // Deliver what is already in hand before waiting: a QUIC frame | ||
| 247 | // may be whole in cl.in from an earlier pump, and poll would | ||
| 248 | // never fire for bytes already in userspace. | ||
| 249 | switch (try self.readFrame(alloc)) { | ||
| 250 | .frame => |frame| { | ||
| 251 | if (frame.type == want) return frame; | ||
| 252 | if (sink.on) |on| { | ||
| 253 | defer frame.deinit(alloc); | ||
| 254 | try on(sink.ctx, frame); | ||
| 255 | } else frame.deinit(alloc); | ||
| 256 | continue; | ||
| 257 | }, | ||
| 258 | .closed => return error.Closed, | ||
| 259 | .incomplete => {}, | ||
| 260 | } | ||
| 261 | // Nothing whole in hand: wait. The wait is capped at 250ms even | ||
| 262 | // with no caller deadline, because a QUIC link's timers (loss | ||
| 263 | // detection, keepalive) need servicing on schedule rather than | ||
| 264 | // whenever the daemon happens to say something; a plain fd with | ||
| 265 | // no deadline may block indefinitely, which is what a caller | ||
| 266 | // wants when a stopped daemon should be a visible hang. | ||
| 267 | var cap: i32 = undefined; | ||
| 268 | if (end) |e| { | ||
| 269 | const left = e - std.time.milliTimestamp(); | ||
| 270 | if (left <= 0) return null; | ||
| 271 | cap = @intCast(@min(left, 250)); | ||
| 272 | } else cap = switch (self.*) { | ||
| 273 | .quic => 250, | ||
| 274 | .fd, .pipe => -1, | ||
| 275 | }; | ||
| 276 | var fds = [_]std.posix.pollfd{ | ||
| 277 | .{ .fd = self.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 278 | }; | ||
| 279 | _ = std.posix.poll(&fds, self.timeoutMs(cap)) catch return error.Closed; | ||
| 280 | self.service(); | ||
| 281 | // The fd arms block in readFrame on a partial frame even past | ||
| 282 | // the deadline — the price of frames over a stream, same as | ||
| 283 | // dial.askOn always paid; a malformed frame is an error rather | ||
| 284 | // than an absent reply. | ||
| 285 | } | ||
| 286 | } | ||
| 287 | |||
| 288 | /// Idempotent, and it has to be: a re-dial releases the dead link on | ||
| 289 | /// entry, and an abort then closes the same value again through the | ||
| 290 | /// pump's defer. (The pipe-kill ordering is client.Transport.close's, | ||
| 291 | /// moved: stdin first so the command sees EOF and can wind down its | ||
| 292 | /// remote end, then TERM. kill() waitpid()s internally — no zombie.) | ||
| 293 | pub fn close(self: *Link) void { | ||
| 294 | switch (self.*) { | ||
| 295 | .fd => |fd| { | ||
| 296 | if (fd == -1) return; // already released | ||
| 297 | std.posix.close(fd); | ||
| 298 | }, | ||
| 299 | .pipe => |*p| { | ||
| 300 | if (p.child.stdin) |*in| { | ||
| 301 | in.close(); | ||
| 302 | p.child.stdin = null; | ||
| 303 | } | ||
| 304 | _ = p.child.kill() catch {}; | ||
| 305 | }, | ||
| 306 | .quic => |*q| { | ||
| 307 | q.qout.deinit(q.alloc); | ||
| 308 | // Closes the UDP socket with it, so pollFd's value must not | ||
| 309 | // be closed again. | ||
| 310 | q.cl.deinit(); | ||
| 311 | }, | ||
| 312 | } | ||
| 313 | self.* = .{ .fd = -1 }; | ||
| 314 | } | ||
| 315 | }; | ||
| 316 | ``` | ||
| 317 | |||
| 318 | Tests, in the same file (the socketpair shapes are dial.zig's; a fake peer | ||
| 319 | thread plays the daemon). Note the sink test drives a PLURAL stream — two | ||
| 320 | noise frames before the answer — per the plural-fixture rule: | ||
| 321 | |||
| 322 | ```zig | ||
| 323 | const testing = std.testing; | ||
| 324 | |||
| 325 | fn mkPair() ![2]std.posix.fd_t { | ||
| 326 | var pair: [2]i32 = undefined; | ||
| 327 | try testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | ||
| 328 | return .{ pair[0], pair[1] }; | ||
| 329 | } | ||
| 330 | |||
| 331 | test "fd link: sendFrame puts one frame on the wire, readFrame takes it back" { | ||
| 332 | const alloc = testing.allocator; | ||
| 333 | const pair = try mkPair(); | ||
| 334 | var a: Link = .{ .fd = pair[0] }; | ||
| 335 | var b: Link = .{ .fd = pair[1] }; | ||
| 336 | defer a.close(); | ||
| 337 | defer b.close(); | ||
| 338 | try a.sendFrame(.input, "hi"); | ||
| 339 | const inc = try b.readFrame(alloc); | ||
| 340 | const frame = inc.frame; | ||
| 341 | defer frame.deinit(alloc); | ||
| 342 | try testing.expectEqual(proto.MsgType.input, frame.type); | ||
| 343 | try testing.expectEqualStrings("hi", frame.payload); | ||
| 344 | } | ||
| 345 | |||
| 346 | test "close is idempotent on every arm that can be closed twice" { | ||
| 347 | const pair = try mkPair(); | ||
| 348 | std.posix.close(pair[1]); | ||
| 349 | var l: Link = .{ .fd = pair[0] }; | ||
| 350 | l.close(); | ||
| 351 | l.close(); // second close must be a no-op, not EBADF-unreachable | ||
| 352 | } | ||
| 353 | |||
| 354 | test "awaitFrame: null sink drops noise frames and returns the match" { | ||
| 355 | const alloc = testing.allocator; | ||
| 356 | const pair = try mkPair(); | ||
| 357 | var l: Link = .{ .fd = pair[0] }; | ||
| 358 | defer l.close(); | ||
| 359 | // Two noise frames BEFORE the answer: the wait must survive a stream, | ||
| 360 | // not a single-frame fixture. | ||
| 361 | try proto.writeFrame(pair[1], .pty_mode, &.{0}); | ||
| 362 | try proto.writeFrame(pair[1], .delta, "x"); | ||
| 363 | try proto.writeFrame(pair[1], .stats_reply, "ok"); | ||
| 364 | std.posix.close(pair[1]); | ||
| 365 | const got = (try l.awaitFrame(alloc, .stats_reply, 1000, .{})).?; | ||
| 366 | defer got.deinit(alloc); | ||
| 367 | try testing.expectEqualStrings("ok", got.payload); | ||
| 368 | } | ||
| 369 | |||
| 370 | test "awaitFrame: the sink sees every non-match, in order, and may end the wait" { | ||
| 371 | const alloc = testing.allocator; | ||
| 372 | const Seen = struct { | ||
| 373 | types: [8]proto.MsgType = undefined, | ||
| 374 | n: usize = 0, | ||
| 375 | fn on(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void { | ||
| 376 | const self: *@This() = @ptrCast(@alignCast(ctx.?)); | ||
| 377 | self.types[self.n] = frame.type; | ||
| 378 | self.n += 1; | ||
| 379 | if (frame.type == .exit_status) return error.SessionExited; | ||
| 380 | } | ||
| 381 | }; | ||
| 382 | // Leg one: the sink observes and the wait completes. | ||
| 383 | { | ||
| 384 | const pair = try mkPair(); | ||
| 385 | var l: Link = .{ .fd = pair[0] }; | ||
| 386 | defer l.close(); | ||
| 387 | try proto.writeFrame(pair[1], .snapshot, ""); | ||
| 388 | try proto.writeFrame(pair[1], .stats_reply, "ok"); | ||
| 389 | std.posix.close(pair[1]); | ||
| 390 | var seen: Seen = .{}; | ||
| 391 | const got = (try l.awaitFrame(alloc, .stats_reply, 1000, .{ .ctx = &seen, .on = Seen.on })).?; | ||
| 392 | defer got.deinit(alloc); | ||
| 393 | try testing.expectEqual(@as(usize, 1), seen.n); | ||
| 394 | try testing.expectEqual(proto.MsgType.snapshot, seen.types[0]); | ||
| 395 | } | ||
| 396 | // Leg two: the sink's error IS the outcome — an "other" frame that is | ||
| 397 | // an answer, muxa's whole reason for a second copy. | ||
| 398 | { | ||
| 399 | const pair = try mkPair(); | ||
| 400 | var l: Link = .{ .fd = pair[0] }; | ||
| 401 | defer l.close(); | ||
| 402 | try proto.writeFrame(pair[1], .exit_status, &.{7}); | ||
| 403 | std.posix.close(pair[1]); | ||
| 404 | var seen: Seen = .{}; | ||
| 405 | try testing.expectError( | ||
| 406 | error.SessionExited, | ||
| 407 | l.awaitFrame(alloc, .stats_reply, 1000, .{ .ctx = &seen, .on = Seen.on }), | ||
| 408 | ); | ||
| 409 | } | ||
| 410 | } | ||
| 411 | |||
| 412 | test "awaitFrame: silence consumes the deadline and returns null; a closed peer is error.Closed" { | ||
| 413 | const alloc = testing.allocator; | ||
| 414 | { | ||
| 415 | const pair = try mkPair(); | ||
| 416 | defer std.posix.close(pair[1]); | ||
| 417 | var l: Link = .{ .fd = pair[0] }; | ||
| 418 | defer l.close(); | ||
| 419 | const t0 = std.time.milliTimestamp(); | ||
| 420 | try testing.expect((try l.awaitFrame(alloc, .stats_reply, 100, .{})) == null); | ||
| 421 | try testing.expect(std.time.milliTimestamp() - t0 >= 100); | ||
| 422 | } | ||
| 423 | { | ||
| 424 | const pair = try mkPair(); | ||
| 425 | var l: Link = .{ .fd = pair[0] }; | ||
| 426 | defer l.close(); | ||
| 427 | std.posix.close(pair[1]); | ||
| 428 | try testing.expectError(error.Closed, l.awaitFrame(alloc, .stats_reply, 1000, .{})); | ||
| 429 | } | ||
| 430 | } | ||
| 431 | |||
| 432 | test "awaitFrame: no deadline waits out a late reply" { | ||
| 433 | const alloc = testing.allocator; | ||
| 434 | const pair = try mkPair(); | ||
| 435 | var l: Link = .{ .fd = pair[0] }; | ||
| 436 | defer l.close(); | ||
| 437 | const Late = struct { | ||
| 438 | fn run(fd: std.posix.fd_t) void { | ||
| 439 | std.Thread.sleep(150 * std.time.ns_per_ms); | ||
| 440 | proto.writeFrame(fd, .stats_reply, "late") catch {}; | ||
| 441 | std.posix.close(fd); | ||
| 442 | } | ||
| 443 | }; | ||
| 444 | const t = try std.Thread.spawn(.{}, Late.run, .{pair[1]}); | ||
| 445 | defer t.join(); | ||
| 446 | const got = (try l.awaitFrame(alloc, .stats_reply, null, .{})).?; | ||
| 447 | defer got.deinit(alloc); | ||
| 448 | try testing.expectEqualStrings("late", got.payload); | ||
| 449 | } | ||
| 450 | ``` | ||
| 451 | |||
| 452 | - [ ] **Step 2: Wire the row in `build.zig` and verify the tests fail-then-pass** | ||
| 453 | |||
| 454 | In the module table (after the `dial` row at build.zig:169), mirroring dial's | ||
| 455 | comment style: | ||
| 456 | |||
| 457 | ```zig | ||
| 458 | // The live connection itself — fd, pipe or QUIC — and the one wait-for-a- | ||
| 459 | // frame loop. `term` for frames, `quic` for the third arm; policy stays | ||
| 460 | // with the rows that import this one. | ||
| 461 | .{ .name = "link", .path = "src/link.zig", .link_libc = true, .imports = &.{ "term", "quic" }, .quic_tests = true }, | ||
| 462 | ``` | ||
| 463 | |||
| 464 | Add `"link"` to the test-rows list near build.zig:720 (the block naming | ||
| 465 | `script, cliflags, testtmp, spawn, dial, quic, ...`), in whichever sublist | ||
| 466 | `quic` sits in (link links libc through quic). If the build refuses a flag | ||
| 467 | combination, copy the `client` row's flags — it has the same dependency shape. | ||
| 468 | |||
| 469 | Run: `make check` | ||
| 470 | Expected: PASS, with the new link tests listed as run. If `zig build test` | ||
| 471 | prints nothing for minutes, a test wrote to fd 1 — fix that first. | ||
| 472 | |||
| 473 | - [ ] **Step 3: Commit** | ||
| 474 | |||
| 475 | ```bash | ||
| 476 | git add src/link.zig build.zig | ||
| 477 | git commit -m "link: the fd|pipe|quic union and the one await loop, as a row" | ||
| 478 | ``` | ||
| 479 | |||
| 480 | --- | ||
| 481 | |||
| 482 | ### Task 2: `dial.ask` rides the Link; `askOn` dies | ||
| 483 | |||
| 484 | **Files:** | ||
| 485 | - Modify: `src/dial.zig` (delete lines 98–139: `askOn` and its doc comment; rewrite `ask`'s body; the `askOn` socketpair tests at the bottom become `awaitFrame`-through-`ask`-shaped or move their pin to link.zig where Task 1 already covers it) | ||
| 486 | - Modify: `build.zig` (`dial` row gains `"link"` in imports) | ||
| 487 | |||
| 488 | **Interfaces:** | ||
| 489 | - Consumes: `Link.awaitFrame`, `Link.sendFrame`, `Link.close` from Task 1. | ||
| 490 | - Produces: `dial.ask` with its EXACT current signature and contract — | ||
| 491 | `ask(alloc, sock_path, req: proto.MsgType, payload: []const u8, want: proto.MsgType, deadline_ms: ?u32) !?proto.Frame`, | ||
| 492 | still minting `error.NoDaemon` and `error.RequestNotSent`, still returning | ||
| 493 | null for both "deadline ran out" and "peer closed without answering". | ||
| 494 | |||
| 495 | - [ ] **Step 1: Rewrite `ask` over the Link** | ||
| 496 | |||
| 497 | ```zig | ||
| 498 | const link_mod = @import("link"); | ||
| 499 | |||
| 500 | pub fn ask( | ||
| 501 | alloc: std.mem.Allocator, | ||
| 502 | sock_path: []const u8, | ||
| 503 | req: proto.MsgType, | ||
| 504 | payload: []const u8, | ||
| 505 | want: proto.MsgType, | ||
| 506 | deadline_ms: ?u32, | ||
| 507 | ) !?proto.Frame { | ||
| 508 | const s = dial(sock_path) catch return error.NoDaemon; | ||
| 509 | var l: link_mod.Link = .{ .fd = s.handle }; | ||
| 510 | defer l.close(); | ||
| 511 | // A request that could not be delivered is its own answer: the caller | ||
| 512 | // reports a daemon that never heard the question differently from one | ||
| 513 | // that heard it and said nothing. | ||
| 514 | l.sendFrame(req, payload) catch return error.RequestNotSent; | ||
| 515 | // A peer that closed without answering is the same "no answer" as a | ||
| 516 | // deadline that ran out — dial.ask's contract predates the Link and | ||
| 517 | // keeps it; callers that need the distinction hold a Link themselves. | ||
| 518 | return l.awaitFrame(alloc, want, deadline_ms, .{}) catch |e| switch (e) { | ||
| 519 | error.Closed => null, | ||
| 520 | else => e, | ||
| 521 | }; | ||
| 522 | } | ||
| 523 | ``` | ||
| 524 | |||
| 525 | Delete `askOn` (dial.zig:98–139). Keep `dial`, `dialAttach`, | ||
| 526 | `dialAttachNamed`, `detach` untouched. | ||
| 527 | |||
| 528 | - [ ] **Step 2: Re-point dial's askOn-specific tests** | ||
| 529 | |||
| 530 | The tests "ask: a path nothing is bound at is error.NoDaemon" and the | ||
| 531 | attach-helper tests stay verbatim. The two socketpair tests that reached the | ||
| 532 | private `askOn` ("a deadline gives up on silence…") pin behavior Task 1's | ||
| 533 | link tests now pin (the deadline-consumed case and the | ||
| 534 | late-reply-without-deadline case are both in link.zig); delete them here | ||
| 535 | after confirming both link tests exist by symbol. | ||
| 536 | |||
| 537 | - [ ] **Step 3: Wire and gate** | ||
| 538 | |||
| 539 | `build.zig` dial row becomes: | ||
| 540 | |||
| 541 | ```zig | ||
| 542 | .{ .name = "dial", .path = "src/dial.zig", .link_libc = true, .imports = &.{ "term", "link", "quic" } }, | ||
| 543 | ``` | ||
| 544 | |||
| 545 | (`link` pulls `quic`; if the build graph complains about transitive libc, | ||
| 546 | mirror what the `client` row does.) | ||
| 547 | |||
| 548 | Run: `make check` — expected PASS. | ||
| 549 | Then the behavior gate against a REAL daemon, because dial.ask serves the | ||
| 550 | observer verbs: `E2E_ONLY=03 make e2e` (the side-connection group), capture | ||
| 551 | `$?` before any pipe. Expected PASS. | ||
| 552 | |||
| 553 | - [ ] **Step 4: Commit** | ||
| 554 | |||
| 555 | ```bash | ||
| 556 | git add src/dial.zig build.zig | ||
| 557 | git commit -m "dial.ask asks through the Link; askOn's loop retires into awaitFrame" | ||
| 558 | ``` | ||
| 559 | |||
| 560 | --- | ||
| 561 | |||
| 562 | ### Task 3: `client.Transport` wraps a Link | ||
| 563 | |||
| 564 | **Files:** | ||
| 565 | - Modify: `src/client/client.zig` — fields at 342–380; `pipeTransport`/`quicTransport` constructors; delete the bodies of `writeFrame`/`service`/`timeoutMs`/`flushQuic`/`readFrame` (694–766) and the per-arm half of `close` (767–808); `awaitFrames` (1174–1231); `pollFd`. | ||
| 566 | - Modify: `build.zig` (`client` row gains `"link"`). | ||
| 567 | |||
| 568 | **Interfaces:** | ||
| 569 | - Consumes: everything Task 1 produces. | ||
| 570 | - Produces: `Transport`'s public surface UNCHANGED — `open`, `openHandoff`, `openQuicEndpoint`, `pollFd`, `errFd`, `drainErr`, `adopt`, `writeFrame`, `service`, `timeoutMs`, `flushQuic`, `readFrame`, `close` keep their signatures, so wallview/wall_pump/interact/webhub compile untouched. | ||
| 571 | |||
| 572 | - [ ] **Step 1: Swap the fields** | ||
| 573 | |||
| 574 | `Transport.link` becomes `link_mod.Link` (import as `link_mod`, the same | ||
| 575 | shadow-avoidance dance the file already does for `dial`/`dialer`). Delete | ||
| 576 | `qout` (it lives in `Link.Quic` now) and delete `Conn`/`conn` — `pollFd()` | ||
| 577 | returns `self.link.pollFd()`; grep for every `conn.r`/`conn.w` use in | ||
| 578 | client.zig, wallview.zig, wall_pump.zig first (`grep -rn "\.conn\." src/client src/tui`) | ||
| 579 | and re-point each through the Link. If a use needs the write fd specifically, | ||
| 580 | add nothing to Link — `sendFrame` is the only writer by design; a use that | ||
| 581 | wants raw fds is a policy leak to flag in the PR, not to enable. | ||
| 582 | `Transport.close` keeps ONLY its own policy — the `err_fd` close and the | ||
| 583 | release check — then delegates: | ||
| 584 | |||
| 585 | ```zig | ||
| 586 | pub fn close(self: *Transport) void { | ||
| 587 | if (self.link == .fd and self.link.fd == -1) return; // already released | ||
| 588 | if (self.err_fd >= 0) { | ||
| 589 | // Ours, not the child's: openHandoff took stderr off the Child so | ||
| 590 | // kill would leave it alone, so nothing else will close it. | ||
| 591 | std.posix.close(self.err_fd); | ||
| 592 | self.err_fd = -1; | ||
| 593 | } | ||
| 594 | self.link.close(); | ||
| 595 | } | ||
| 596 | ``` | ||
| 597 | |||
| 598 | `writeFrame`/`service`/`timeoutMs`/`flushQuic`/`readFrame` become one-line | ||
| 599 | delegations (keep the public names; wall code calls them). The constructors | ||
| 600 | build Links: `pipeTransport` returns `.{ .link = .{ .pipe = .{ .child = child, .r = child.stdout.?.handle, .w = child.stdin.?.handle } } }`; | ||
| 601 | `quicTransport` returns `.{ .link = .{ .quic = .{ .cl = cl, .alloc = alloc } } }`. | ||
| 602 | `Incoming` becomes a re-export: `pub const Incoming = link_mod.Incoming;` | ||
| 603 | (callers spell `client.Incoming` today and must keep compiling). | ||
| 604 | |||
| 605 | - [ ] **Step 2: `awaitFrames` (client.zig:1174) rides `awaitFrame`** | ||
| 606 | |||
| 607 | Read its body first; it waits during birth/attach. Reshape it as a call to | ||
| 608 | `self.link.awaitFrame` with a sink that captures what the current loop's | ||
| 609 | non-match arms do (the BirthFake test at client.zig:2850 pins the order — | ||
| 610 | pty_mode before snapshot — and must still pass unchanged). | ||
| 611 | |||
| 612 | - [ ] **Step 3: Gate** | ||
| 613 | |||
| 614 | Run: `make check` — PASS. Then `make e2e` (the wall, handoff, QUIC groups | ||
| 615 | all cross this seam) — capture `$?`, expected PASS. The named risks: the | ||
| 616 | close-idempotence dance and the pipe-kill ordering moved in Task 1; if e2e | ||
| 617 | group 04 (handoff) fails, diff `Link.close`'s pipe arm against the old | ||
| 618 | `Transport.close` before touching anything else. | ||
| 619 | |||
| 620 | - [ ] **Step 4: Commit** | ||
| 621 | |||
| 622 | ```bash | ||
| 623 | git add src/client/client.zig build.zig | ||
| 624 | git commit -m "Transport wraps the Link: policy stays, mechanics move" | ||
| 625 | ``` | ||
| 626 | |||
| 627 | --- | ||
| 628 | |||
| 629 | ### Task 4: muxa's `AgentConnection` wraps a Link | ||
| 630 | |||
| 631 | **Files:** | ||
| 632 | - Modify: `src/cli/muxa.zig` — the link union inside `AgentConnection` (~286), `open`/`openQuic`/`close`, `sendFrame` (deadline plumbing), delete `sendFrameQuic` (386–417), `awaitFrameFd` (425–457), `awaitFrameQuic` (458–507), `waitReady` (549–566, if the client's copy is reachable; otherwise keep — check callers), keep `reconnect` but re-point it at the Link's quic arm. | ||
| 633 | - Modify: `build.zig` (`agent` row gains `"link"`). muxa still does NOT import `client`. | ||
| 634 | |||
| 635 | **Interfaces:** | ||
| 636 | - Consumes: `Link.sendFrame` + `flushWithin`, `Link.awaitFrame` with a Sink, `Link.close`. | ||
| 637 | - Produces: `mux a`'s verbs behave identically — every JSON field, every exit code, every `mechanism` string. | ||
| 638 | |||
| 639 | - [ ] **Step 1: The union becomes a Link plus muxa's policy** | ||
| 640 | |||
| 641 | ```zig | ||
| 642 | const AgentConnection = struct { | ||
| 643 | link: link_mod.Link, | ||
| 644 | /// Reconnect policy is muxa's, not the Link's: reuse the original | ||
| 645 | /// address and key so a mid-command redial cannot select rotated | ||
| 646 | /// credentials or a different resolved address. | ||
| 647 | redial: ?struct { | ||
| 648 | addr: std.net.Address, | ||
| 649 | key: quic.Key, | ||
| 650 | idle_ms: u32, | ||
| 651 | connect_ms: i64, | ||
| 652 | reconnected: bool = false, | ||
| 653 | } = null, | ||
| 654 | alloc: std.mem.Allocator, | ||
| 655 | saw_snapshot: bool = false, | ||
| 656 | session_exit: ?u8 = null, | ||
| 657 | reconnect_failure: ?[]const u8 = null, | ||
| 658 | ... | ||
| 659 | ``` | ||
| 660 | |||
| 661 | `sendFrame` becomes: `self.link.sendFrame(t, payload)` then, on the quic arm, | ||
| 662 | `self.link.flushWithin(bounded)` where `bounded` is today's | ||
| 663 | `@min(deadline_ms - now, send_flush_ms)` math converted to relative u32 at | ||
| 664 | this boundary; the BrokenPipe/ConnectionReset/ConnectionLost → | ||
| 665 | `refusalPending` classification stays wrapped around the call exactly as at | ||
| 666 | muxa.zig:358–370. | ||
| 667 | |||
| 668 | - [ ] **Step 2: The three await fns become one sink** | ||
| 669 | |||
| 670 | ```zig | ||
| 671 | fn onOther(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void { | ||
| 672 | const self: *AgentConnection = @ptrCast(@alignCast(ctx.?)); | ||
| 673 | // Skip unrelated snapshots and deltas while waiting. An exit frame ends | ||
| 674 | // the wait as a session outcome rather than a transport error: the | ||
| 675 | // daemon uses the same exit frame for a rejected attach and an ended | ||
| 676 | // session, and a valid attach always sends a snapshot first. | ||
| 677 | if (frame.type == .snapshot) self.saw_snapshot = true; | ||
| 678 | if (frame.type == .exit_status) { | ||
| 679 | if (!self.saw_snapshot) return error.AttachRefused; | ||
| 680 | // A missing status byte still ends the session; its code is unknown | ||
| 681 | // rather than zero. | ||
| 682 | self.session_exit = if (frame.payload.len >= 1) frame.payload[0] else null; | ||
| 683 | return error.SessionExited; | ||
| 684 | } | ||
| 685 | } | ||
| 686 | |||
| 687 | fn awaitFrame(self: *AgentConnection, want: proto.MsgType, deadline_ms: i64) !proto.Frame { | ||
| 688 | const left = deadline_ms - std.time.milliTimestamp(); | ||
| 689 | if (left <= 0) return error.Timeout; | ||
| 690 | const got = self.link.awaitFrame(self.alloc, want, @intCast(left), .{ | ||
| 691 | .ctx = self, | ||
| 692 | .on = onOther, | ||
| 693 | }) catch |e| switch (e) { | ||
| 694 | // Wording is per-arm on purpose: a closed unix socket is a dead | ||
| 695 | // daemon; a dead QUIC connection cannot distinguish daemon exit | ||
| 696 | // from path failure, so it reports ConnectionLost and allows the | ||
| 697 | // one reconnect. | ||
| 698 | error.Closed => return switch (self.link) { | ||
| 699 | .quic => error.ConnectionLost, | ||
| 700 | else => error.DaemonGone, | ||
| 701 | }, | ||
| 702 | else => return e, | ||
| 703 | }; | ||
| 704 | return got orelse error.Timeout; | ||
| 705 | } | ||
| 706 | ``` | ||
| 707 | |||
| 708 | Delete `awaitFrameFd`, `awaitFrameQuic`, `sendFrameQuic`. `reconnect` builds a | ||
| 709 | fresh `quic.Client` from `self.redial.?` and swaps it into `self.link.quic.cl` | ||
| 710 | (deinit the old one first, exactly the current ordering at muxa.zig:504–510). | ||
| 711 | `graceMs` reads `self.redial.?.connect_ms`. | ||
| 712 | |||
| 713 | - [ ] **Step 3: Gate** | ||
| 714 | |||
| 715 | Run: `make check` then `make agent` — capture `$?`, both PASS. The agent | ||
| 716 | suite runs real verbs against a real daemon over both transports; it is the | ||
| 717 | pin for "every JSON field identical". Then | ||
| 718 | `grep -c "awaitFrameFd\|awaitFrameQuic\|sendFrameQuic" src/cli/muxa.zig` | ||
| 719 | must print 0. | ||
| 720 | |||
| 721 | - [ ] **Step 4: Commit** | ||
| 722 | |||
| 723 | ```bash | ||
| 724 | git add src/cli/muxa.zig build.zig | ||
| 725 | git commit -m "muxa rides the Link: the second transport retires" | ||
| 726 | ``` | ||
| 727 | |||
| 728 | --- | ||
| 729 | |||
| 730 | ### Task 5: `src/serve.zig` — bind and teardown get one owner | ||
| 731 | |||
| 732 | **Files:** | ||
| 733 | - Create: `src/serve.zig` | ||
| 734 | - Modify: `build.zig` (new row; `daemon` and `client` rows gain `"serve"`) | ||
| 735 | - Modify: `src/server/server.zig` (init at 505–540: claim+listen+PathId → `serve.bind`; deinit at 815–817: guarded unlink → `Bound.close`; `initFromManifest` at ~628: `serve.adopt`) | ||
| 736 | - Modify: `src/server/server_agent.zig` (bind at 150–176, `AgentSock.release` at 22–34) | ||
| 737 | - Modify: `src/client/askpass.zig` (bind at 145–162, the unlink in `retire` at ~206) | ||
| 738 | - Test: additions in `src/serve.zig` itself, `src/server/server_test_agent.zig`, askpass's tests in `src/client/askpass.zig` | ||
| 739 | |||
| 740 | **Interfaces:** | ||
| 741 | - Consumes: `sockpath.claim`, `sockpath.PathId`. | ||
| 742 | - Produces: | ||
| 743 | - `pub const Policy = enum { refuse_live, clobber_own };` | ||
| 744 | - `pub fn bind(path: []const u8, opts: BindOpts) !Bound` where `BindOpts = struct { policy: Policy, backlog: u31 = 128, cloexec: bool = false }` | ||
| 745 | - `pub fn adopt(fd: std.posix.fd_t, path: []const u8) !Bound` | ||
| 746 | - `pub const Bound = struct { fd: std.posix.fd_t, path_id: sockpath.PathId, pub fn close(self: *Bound, path: []const u8) void; }` | ||
| 747 | |||
| 748 | - [ ] **Step 1: Write `src/serve.zig` with tests** | ||
| 749 | |||
| 750 | ```zig | ||
| 751 | //! The server side of a unix socket path: the right to bind it and the duty | ||
| 752 | //! to unlink it, written once. Three binders used to answer this trio | ||
| 753 | //! independently — the daemon socket, the per-session agent sockets, the | ||
| 754 | //! askpass socket — and only the first carried the guarded unlink that | ||
| 755 | //! sockpath's incident record paid for. Accept loops are NOT here: the | ||
| 756 | //! daemon's slot-table accept, askpass's credential check and the hub's | ||
| 757 | //! thread-per-conn differ for reasons, and a shared loop would be a shape | ||
| 758 | //! they do not fit. | ||
| 759 | const std = @import("std"); | ||
| 760 | const sockpath = @import("sockpath"); | ||
| 761 | |||
| 762 | pub const Policy = enum { | ||
| 763 | /// Never steal a path that answers: sockpath.claim's refusal, the | ||
| 764 | /// daemon-socket rule. | ||
| 765 | refuse_live, | ||
| 766 | /// The name embeds our identity (a pid, a session name in our own | ||
| 767 | /// directory), so a leftover file there is ours — a previous us that | ||
| 768 | /// died without unlinking — and is deleted before the bind. | ||
| 769 | clobber_own, | ||
| 770 | }; | ||
| 771 | |||
| 772 | pub const BindOpts = struct { | ||
| 773 | policy: Policy, | ||
| 774 | backlog: u31 = 128, | ||
| 775 | /// FALSE is load-bearing: `mux d upgrade` execs the candidate over the | ||
| 776 | /// running daemon, and the daemon listener and every agent socket must | ||
| 777 | /// survive that exec. askpass passes true — its process never execs | ||
| 778 | /// over itself and its children must not inherit the prompt socket. | ||
| 779 | cloexec: bool = false, | ||
| 780 | }; | ||
| 781 | |||
| 782 | pub const Bound = struct { | ||
| 783 | fd: std.posix.fd_t, | ||
| 784 | path_id: sockpath.PathId, | ||
| 785 | |||
| 786 | /// Close, then unlink only if the path still names OUR socket: a newer | ||
| 787 | /// owner may have replaced the file, and deleting that one would steal | ||
| 788 | /// its clients. The stat comes after the close because a successor only | ||
| 789 | /// claims once nothing is listening — that narrows the race to the | ||
| 790 | /// stat→unlink gap, the floor Linux gives for deleting by name. | ||
| 791 | pub fn close(self: *Bound, path: []const u8) void { | ||
| 792 | if (self.fd == -1) return; | ||
| 793 | std.posix.close(self.fd); | ||
| 794 | self.fd = -1; | ||
| 795 | if (self.path_id.stillAt(path)) { | ||
| 796 | std.fs.cwd().deleteFile(path) catch {}; | ||
| 797 | } | ||
| 798 | } | ||
| 799 | }; | ||
| 800 | |||
| 801 | /// Bind, listen, and remember which inode is ours. Overlong paths are | ||
| 802 | /// initUnix's refusal (kernel truth, not a re-stated bound) — `mux d`'s | ||
| 803 | /// parse-time `sockpath.tooLong` with its own stderr wording remains the | ||
| 804 | /// one binder-side pre-check, and it lives with `mux d`. | ||
| 805 | pub fn bind(path: []const u8, opts: BindOpts) !Bound { | ||
| 806 | switch (opts.policy) { | ||
| 807 | .refuse_live => try sockpath.claim(path), | ||
| 808 | .clobber_own => std.fs.cwd().deleteFile(path) catch {}, | ||
| 809 | } | ||
| 810 | const addr = try std.net.Address.initUnix(path); | ||
| 811 | const sock_flags: u32 = std.posix.SOCK.STREAM | | ||
| 812 | (if (opts.cloexec) std.posix.SOCK.CLOEXEC else 0); | ||
| 813 | const fd = try std.posix.socket(std.posix.AF.UNIX, sock_flags, 0); | ||
| 814 | errdefer std.posix.close(fd); | ||
| 815 | try std.posix.bind(fd, &addr.any, addr.getOsSockLen()); | ||
| 816 | try std.posix.listen(fd, opts.backlog); | ||
| 817 | return .{ .fd = fd, .path_id = try sockpath.PathId.of(path) }; | ||
| 818 | } | ||
| 819 | |||
| 820 | /// A listener that already exists — the upgrade manifest's adopted fd. The | ||
| 821 | /// PathId is re-stamped from the file as found, which is the manifest rule: | ||
| 822 | /// a watermark belongs to the space that minted it. | ||
| 823 | pub fn adopt(fd: std.posix.fd_t, path: []const u8) !Bound { | ||
| 824 | return .{ .fd = fd, .path_id = try sockpath.PathId.of(path) }; | ||
| 825 | } | ||
| 826 | ``` | ||
| 827 | |||
| 828 | Tests in-file (`test_imports = &.{"testtmp"}` gives `TmpDir`): | ||
| 829 | |||
| 830 | ```zig | ||
| 831 | const TmpDir = @import("testtmp").TmpDir; | ||
| 832 | const testing = std.testing; | ||
| 833 | |||
| 834 | test "refuse_live refuses a path a live listener owns; clobber_own takes its own leftover" { | ||
| 835 | var tmp = try TmpDir.make(); | ||
| 836 | defer tmp.cleanup(); | ||
| 837 | var buf: [280]u8 = undefined; | ||
| 838 | const path = try std.fmt.bufPrint(&buf, "{s}/serve.sock", .{tmp.path()}); | ||
| 839 | |||
| 840 | var first = try bind(path, .{ .policy = .refuse_live }); | ||
| 841 | try testing.expectError(error.AddressInUse, bind(path, .{ .policy = .refuse_live })); | ||
| 842 | |||
| 843 | // Kill the listener but leave the file: the dead-us case. | ||
| 844 | std.posix.close(first.fd); | ||
| 845 | first.fd = -1; | ||
| 846 | var second = try bind(path, .{ .policy = .clobber_own }); | ||
| 847 | second.close(path); | ||
| 848 | } | ||
| 849 | |||
| 850 | test "close unlinks our socket but never a successor's" { | ||
| 851 | var tmp = try TmpDir.make(); | ||
| 852 | defer tmp.cleanup(); | ||
| 853 | var buf: [280]u8 = undefined; | ||
| 854 | const path = try std.fmt.bufPrint(&buf, "{s}/succ.sock", .{tmp.path()}); | ||
| 855 | |||
| 856 | var old = try bind(path, .{ .policy = .clobber_own }); | ||
| 857 | // A successor replaces the FILE while old's listener lives on — deleting | ||
| 858 | // a unix socket's path does not touch the listening fd, which is the | ||
| 859 | // incident shape sockpath.zig:213 records: two owners, one name, and | ||
| 860 | // the displaced one's teardown must not delete by that name. | ||
| 861 | var succ = try bind(path, .{ .policy = .clobber_own }); | ||
| 862 | |||
| 863 | old.close(path); // guard fires: the inode at path is succ's — no unlink | ||
| 864 | _ = try std.fs.cwd().statFile(path); // successor's file survived | ||
| 865 | succ.close(path); // ours: unlinked | ||
| 866 | try testing.expectError(error.FileNotFound, std.fs.cwd().statFile(path)); | ||
| 867 | } | ||
| 868 | ``` | ||
| 869 | |||
| 870 | (The second test's shape: prove `stillAt` is false for the displaced owner, | ||
| 871 | prove the successor's file survives the displaced owner's teardown, prove the | ||
| 872 | successor's own close does unlink. If `error.AddressInUse` is not the exact | ||
| 873 | error `claim` surfaces, read `sockpath.claim` and pin the error it actually | ||
| 874 | returns — the CONTRACT is "the second refuse_live bind fails while the first | ||
| 875 | listener lives".) | ||
| 876 | |||
| 877 | - [ ] **Step 2: Wire the row** | ||
| 878 | |||
| 879 | ```zig | ||
| 880 | .{ .name = "serve", .path = "src/serve.zig", .imports = &.{"sockpath"}, .test_imports = &.{"testtmp"} }, | ||
| 881 | ``` | ||
| 882 | |||
| 883 | Add `"serve"` to the daemon and client rows' imports and to the test-rows | ||
| 884 | list near build.zig:720. Run `make check` — the new tests pass. | ||
| 885 | |||
| 886 | - [ ] **Step 3: Convert the three binders, one commit each** | ||
| 887 | |||
| 888 | (a) **Daemon socket.** `Server.init` (server.zig:505–540): replace | ||
| 889 | `sockpath.claim` + `addr.listen` + `PathId.of` with one `serve.bind(opts.sock_path, .{ .policy = .refuse_live })`; | ||
| 890 | keep `self.listener: std.net.Server` by constructing it from the Bound's fd | ||
| 891 | (`.{ .listen_address = addr, .stream = .{ .handle = bound.fd } }`) or store | ||
| 892 | the Bound and re-point `acceptConn` — whichever touches fewer lines; the | ||
| 893 | deinit block at 815–817 becomes `self.bound.close(self.sock_path)` with the | ||
| 894 | moved comment. `initFromManifest` uses `serve.adopt`. Gate: | ||
| 895 | `E2E_ONLY=03 make e2e` plus the upgrade suite | ||
| 896 | (`server_test_upgrade` runs under `make test`) — the adopted-listener path | ||
| 897 | must still not re-claim. | ||
| 898 | |||
| 899 | (b) **Agent sockets.** `server_agent.zig:150–176` becomes | ||
| 900 | `serve.bind(path, .{ .policy = .clobber_own, .backlog = 8 })` (keep the | ||
| 901 | backlog-8 comment: the only dialler is one session's ssh clients). | ||
| 902 | `AgentSock` carries the `Bound`; `release` becomes `bound.close(path)` + free. | ||
| 903 | New test in `server_test_agent.zig`: two sessions' agent sockets (plural), | ||
| 904 | end one session, assert the OTHER session's socket file still exists and | ||
| 905 | still answers — then assert the ended one's file is gone (ask the fs, not | ||
| 906 | the daemon). | ||
| 907 | |||
| 908 | (c) **askpass.** `askpass.zig:153–162` becomes | ||
| 909 | `serve.bind(path, .{ .policy = .clobber_own, .cloexec = true })`; `retire`'s | ||
| 910 | unlink becomes the guarded close via the stored Bound — BUT retire | ||
| 911 | deliberately does not close the fd (detached pumps may be inside `declined`); | ||
| 912 | split: `retire` calls a new `Bound.unlinkIfOurs(path)` (add it: the guard | ||
| 913 | without the close, two lines, doc comment saying retire is why it exists) and | ||
| 914 | `stop` keeps closing the fd. New test beside the existing Listener tests: a | ||
| 915 | successor Listener binds the same pid-named path (simulate pid reuse by | ||
| 916 | spelling the same path), the old Listener's retire must not unlink the | ||
| 917 | successor's socket. | ||
| 918 | |||
| 919 | Run after each: `make check`. After all three: `make test | tail -30` with | ||
| 920 | `$?` captured, then `E2E_ONLY=07 make e2e` if a group covers askpass | ||
| 921 | (check `ls test/e2e_*` for the askpass/handoff group; run the one that names | ||
| 922 | it, else note "covered by unit suite only" in the commit body). | ||
| 923 | |||
| 924 | - [ ] **Step 4: Commits** (one per binder, plus the module) | ||
| 925 | |||
| 926 | ```bash | ||
| 927 | git commit -m "serve: bind and the guarded unlink get one owner" | ||
| 928 | git commit -m "daemon socket binds through serve" | ||
| 929 | git commit -m "agent sockets bind through serve, and gain the successor guard" | ||
| 930 | git commit -m "askpass binds through serve; retire keeps its no-close shape" | ||
| 931 | ``` | ||
| 932 | |||
| 933 | --- | ||
| 934 | |||
| 935 | ### Task 6: harness `pumpUntil` and the await-copy sweep | ||
| 936 | |||
| 937 | **Files:** | ||
| 938 | - Modify: `src/server/server_test_harness.zig` (add `pumpUntil`; `awaitFrame`/`awaitFrameOn`/`firstStateFrame` become Link wrappers) | ||
| 939 | - Modify: `src/server/server_test_attach.zig`, `server_test_session.zig`, `server_test_modes.zig`, `server_test_clipboard.zig`, `server_test_deliver.zig`, `server_test_await.zig`, `server_test_agent.zig`, `server_test_upgrade.zig`, `server_test_quic.zig` (spin loops → `pumpUntil`; inline poll+readFrame loops → `awaitFrameOn`/sinks; private awaits deleted) | ||
| 940 | - Modify: `build.zig` (`daemon` row's `test_imports` gains `"link"`) | ||
| 941 | |||
| 942 | **Interfaces:** | ||
| 943 | - Consumes: `Link.awaitFrame` + `Sink`. | ||
| 944 | - Produces (test-only): | ||
| 945 | - `pub fn pumpUntil(srv: *Server, deadline_ms: u64, ctx: anytype, comptime pred: fn (@TypeOf(ctx)) bool) !bool` | ||
| 946 | - `awaitFrame`/`awaitFrameOn`/`firstStateFrame`/`awaitGridText` keep their exact current signatures (eight sibling files call them). | ||
| 947 | |||
| 948 | - [ ] **Step 1: `pumpUntil`** | ||
| 949 | |||
| 950 | ```zig | ||
| 951 | /// Pump the daemon until pred says the world arrived, or the deadline says | ||
| 952 | /// it never will. Returns whether pred fired, so a test asserts the | ||
| 953 | /// CONDITION and its failure names what didn't happen — not a guessed | ||
| 954 | /// round count. A false return is an assertable value: the deadline turns | ||
| 955 | /// a wedge into a legible failure instead of a silent hang (a wedged zig | ||
| 956 | /// test prints nothing). | ||
| 957 | pub fn pumpUntil( | ||
| 958 | srv: *Server, | ||
| 959 | deadline_ms: u64, | ||
| 960 | ctx: anytype, | ||
| 961 | comptime pred: fn (@TypeOf(ctx)) bool, | ||
| 962 | ) !bool { | ||
| 963 | var left = deadline_ms; | ||
| 964 | while (true) { | ||
| 965 | if (pred(ctx)) return true; | ||
| 966 | if (left == 0) return false; | ||
| 967 | try srv.pumpOnce(5); | ||
| 968 | left -|= 5; | ||
| 969 | } | ||
| 970 | } | ||
| 971 | ``` | ||
| 972 | |||
| 973 | With a self-test in the harness file: a `TestDaemon`, one dial, and | ||
| 974 | `pumpUntil(td.srv, 2000, td.srv, hasAnyClient)` flips true; a predicate that | ||
| 975 | can never fire returns false in ~deadline time. | ||
| 976 | |||
| 977 | - [ ] **Step 2: The harness awaits become wrappers** | ||
| 978 | |||
| 979 | `awaitFrameOn` keeps its signature and becomes | ||
| 980 | `var l: link_mod.Link = .{ .fd = fd }; defer l.* = .{ .fd = -1 };` — do NOT | ||
| 981 | `close` (the caller owns the fd) — then `l.awaitFrame(alloc, want, timeout, .{})` | ||
| 982 | mapping `error.Closed` to the function's current null/erroring behavior | ||
| 983 | (read its body first and preserve it exactly; the eight siblings' green run | ||
| 984 | is the proof). `firstStateFrame` uses a sink that decodes | ||
| 985 | `readSnapshotPrefix`/`readDeltaHeader` into its `StateFrame` — copying the | ||
| 986 | ints, per the Sink borrow rule. `awaitFrame` (the byte-scanning one at 264 — | ||
| 987 | read it; it may scan a buffer, not an fd) is converted only if it is | ||
| 988 | genuinely the same loop; if it scans captured bytes, it stays and the plan's | ||
| 989 | count table loses those lines — note it in the commit. | ||
| 990 | |||
| 991 | - [ ] **Step 3: The sweep, one test file per commit** | ||
| 992 | |||
| 993 | For each file in the Files list: replace `while (spun < N …) pumpOnce` | ||
| 994 | loops with `try expect(try h.pumpUntil(td.srv, <N*step rounded up to a wall | ||
| 995 | figure>, ctx, pred))`; replace inline poll+readFrame switch loops with | ||
| 996 | `h.awaitFrameOn` + a sink where the loop classified frames (the | ||
| 997 | history-counting loop at server_test_attach.zig:61–78 becomes a sink | ||
| 998 | capturing `history_rows`); delete `awaitSelectionReply` (clipboard:17), | ||
| 999 | `awaitSnapshotSize` (attach:400), `awaitMarkerWithoutSnapshot` (attach:478) | ||
| 1000 | in favor of sinks at their call sites. EVERY replacement must watch its | ||
| 1001 | assertion fire once: before committing each file, break the condition (flip | ||
| 1002 | an expected value), run that file's test, see the new failure message name | ||
| 1003 | the condition, restore. Run `make test | tail -30` (capture `$?`) after each | ||
| 1004 | file. | ||
| 1005 | |||
| 1006 | - [ ] **Step 4: Commit per file, then the gate** | ||
| 1007 | |||
| 1008 | ```bash | ||
| 1009 | git commit -m "harness: pumpUntil and Link-backed awaits" | ||
| 1010 | git commit -m "server_test_attach rides the harness awaits" # ...and so on per file | ||
| 1011 | ``` | ||
| 1012 | |||
| 1013 | Final: `grep -rn "spun < \|rounds < " src/server/` prints nothing; | ||
| 1014 | `grep -rln "posix.poll" src/server/server_test_*.zig` shrinks to files whose | ||
| 1015 | polls are not frame-awaits (inspect each survivor and say why in the commit). | ||
| 1016 | |||
| 1017 | --- | ||
| 1018 | |||
| 1019 | ### Task 7: Delivery gate | ||
| 1020 | |||
| 1021 | **Files:** | ||
| 1022 | - Modify: `docs/superpowers/specs/2026-08-31-connection-primitives-design.md` (replace the estimate table with the real diffstat) | ||
| 1023 | - Modify: `CLAUDE.md` (the layout table: `link` and `serve` join the shared row's list) | ||
| 1024 | |||
| 1025 | **Interfaces:** none new. | ||
| 1026 | |||
| 1027 | - [ ] **Step 1: Success-criteria greps from the spec** | ||
| 1028 | |||
| 1029 | Run each; every miss is unfinished work, not a tolerable gap: | ||
| 1030 | - `grep -rln "posix.poll" src/ --include="*.zig" | xargs grep -ln readFrame` → only `src/link.zig` (plus the wall's event loop files; name them in the delivery note). | ||
| 1031 | - `grep -rn "union(enum)" src/cli/muxa.zig src/client/client.zig | grep -i "fd\|quic"` → no second link union. | ||
| 1032 | - `grep -rn "addr.listen\|posix.listen" src/server/server.zig src/server/server_agent.zig src/client/askpass.zig` → nothing (all through serve). | ||
| 1033 | - `grep -rn "while (spun" src/server/` → nothing. | ||
| 1034 | - `grep -rn "@import(\"link\")\|@import(\"term\")" src/proxy.zig src/quic.zig` → nothing. | ||
| 1035 | |||
| 1036 | - [ ] **Step 2: The full gate** | ||
| 1037 | |||
| 1038 | ```bash | ||
| 1039 | make ci; echo "ci=$?" | ||
| 1040 | make xversion-build && make xversion; echo "xver=$?" | ||
| 1041 | ``` | ||
| 1042 | |||
| 1043 | Both 0. The xversion old side is `../mux-xver-old`; build its prefix first | ||
| 1044 | if stale. No wire change means xversion is a formality — but run it, because | ||
| 1045 | "should be a formality" is an assumption and the gate is the assertion. | ||
| 1046 | |||
| 1047 | - [ ] **Step 3: Re-measure and record** | ||
| 1048 | |||
| 1049 | `git diff --stat <base>..HEAD` and `wc -l src/*.zig src/*/*.zig | tail -1`; | ||
| 1050 | replace the spec's estimate table with the real numbers. Update CLAUDE.md's | ||
| 1051 | module table row for `src/`: add `link` and `serve` to the shared list with | ||
| 1052 | one clause each. | ||
| 1053 | |||
| 1054 | - [ ] **Step 4: Autosquash and finish** | ||
| 1055 | |||
| 1056 | ```bash | ||
| 1057 | git rebase -i --autosquash <base> # NOTE: -i is unsupported in this harness — | ||
| 1058 | # use: GIT_SEQUENCE_EDITOR=true git rebase --autosquash <base> | ||
| 1059 | ``` | ||
| 1060 | |||
| 1061 | Only if no sibling agent holds the old tip (the no-history-rewrite rule). | ||
| 1062 | Then a demo: `try.sh`-style script that starts an isolated daemon | ||
| 1063 | (`XDG_STATE_HOME`/`XDG_RUNTIME_DIR` exported to a scratch dir), runs | ||
| 1064 | `mux d endpoint`, one `mux a status`, and a `mux d stop` — the primitives | ||
| 1065 | exercised end to end by the real binary. | ||
| 1066 | |||
| 1067 | ```bash | ||
| 1068 | git commit -m "docs: link and serve join the table; spec table becomes the diffstat" | ||
| 1069 | ``` | ||