8a07693c
Design: link, serve, and the one await loop
a73x 2026-08-31 17:57
Commit message
docs/superpowers/specs/2026-08-31-connection-primitives-design.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,268 @@ | |||
| 1 | # Connection primitives: link, serve, and the one await loop | ||
| 2 | |||
| 3 | 2026-08-31. Status: approved design, pre-implementation. | ||
| 4 | |||
| 5 | ## Problem | ||
| 6 | |||
| 7 | `dial.zig` states the product's thesis — connecting a client to a daemon is | ||
| 8 | *the* operation, so it is a callable primitive — and then the thesis stops at | ||
| 9 | dial's own edge. An audit (this date) found four places the codebase re-answers | ||
| 10 | a question a primitive should own: | ||
| 11 | |||
| 12 | 1. **"Wait for one frame of type X"** exists ~10 times: `dial.askOn` (private | ||
| 13 | on purpose), `muxa.awaitFrameFd` / `awaitFrameQuic`, `client.awaitFrames`, | ||
| 14 | the server test harness's `awaitFrame` / `awaitFrameOn` / `firstStateFrame`, | ||
| 15 | and private copies plus inline loops across the `server_test_*` files. | ||
| 16 | `dial.zig`'s own comment ("private on purpose: this loop DROPS every frame") | ||
| 17 | is the causal record: the drop policy was sound, making it private forced | ||
| 18 | every caller with a different policy to write the loop again. | ||
| 19 | 2. **Binding a socket you own** has three production answers: the daemon | ||
| 20 | (`sockpath.claim` + guarded unlink), the agent relay (unguarded unlink), | ||
| 21 | askpass (raw socket/bind/listen, unguarded unlink). Only the daemon carries | ||
| 22 | the delete-by-name-races-a-successor protection that `sockpath.zig` records | ||
| 23 | buying from an incident. | ||
| 24 | 3. **Driving a test daemon to a condition** is 13 hand-rolled | ||
| 25 | `while (spun < N …) pumpOnce(step)` loops with guessed round counts, plus | ||
| 26 | hand-rolled `deadline_ms -|= 100` countdowns in three files. | ||
| 27 | 4. **`muxa.AgentConnection` is a second `client.Transport`**: two independent | ||
| 28 | answers to "a connection that is either an fd or a QUIC client", each with | ||
| 29 | its own send, await, close, and QUIC staging. | ||
| 30 | |||
| 31 | Checked and NOT gaps: the daemon's frame dispatch (`handleDaemonVerb` / `Peer` | ||
| 32 | is properly factored), and the one-replay-core invariant (replica / client_core | ||
| 33 | / webhub / wasm_core are four different concerns, not four copies). | ||
| 34 | |||
| 35 | ## Decisions (settled in brainstorm) | ||
| 36 | |||
| 37 | - **One campaign, four gaps, staged.** One spec; implementation lands in | ||
| 38 | stages, each independently green. | ||
| 39 | - **The Link is promoted to a shared row.** The fd | pipe | quic union moves | ||
| 40 | out of `client.Transport` into a new `src/link.zig`. The await/send | ||
| 41 | primitives are written once against it. `mux a` never imports `client`, so | ||
| 42 | the agent row keeps its isolated suite. | ||
| 43 | - **`serve.zig` owns bind + teardown only.** Accept loops stay with their | ||
| 44 | owners — the daemon's nonblocking slot-table accept, askpass's | ||
| 45 | credential-checking serve, webhub's thread-per-conn are real differences, | ||
| 46 | not duplication. | ||
| 47 | - **`pumpUntil` goes in the server test harness**, between `pumpOnce` and | ||
| 48 | `serverThread`. | ||
| 49 | |||
| 50 | ## Section 1 — the `link` row | ||
| 51 | |||
| 52 | New shared root `src/link.zig`, a table row beside `dial` in the "what both | ||
| 53 | sides link" tier. It is the live connection to a daemon, however reached, and | ||
| 54 | the verbs every holder duplicates today: | ||
| 55 | |||
| 56 | ```zig | ||
| 57 | pub const Link = union(enum) { | ||
| 58 | fd: std.posix.fd_t, // unix socket: one fd, read and write | ||
| 59 | pipe: Pipe, // --via / handoff ssh: the child + its stdio fds | ||
| 60 | quic: Quic, // *quic.Client + the qout staging buffer | ||
| 61 | }; | ||
| 62 | // sendFrame(self, t, payload) | ||
| 63 | // readFrame(self, alloc) -> Incoming { frame, incomplete, closed } | ||
| 64 | // awaitFrame(self, alloc, want, deadline_ms, sink) -> ?Frame (section 2) | ||
| 65 | // pollFd, timeoutMs, service, close | ||
| 66 | ``` | ||
| 67 | |||
| 68 | What it is NOT: policy. Redial/backoff (`client.nextBackoffMs`, handoff, | ||
| 69 | muxa's one-reconnect and its `addr`/`key`/`reconnected` state), attach | ||
| 70 | semantics, and interpretation of `Incoming` stay with the owners. `Transport` | ||
| 71 | keeps `err_fd`, `reason`, and narrate — the handoff story wraps a Link. | ||
| 72 | |||
| 73 | Boundary consequences (load-bearing): | ||
| 74 | |||
| 75 | - `link.zig` imports `term` (frames) and `quic`. **`proxy.zig` and the QUIC | ||
| 76 | modules must never import it** — proxy stays byte-blind per the invariant. | ||
| 77 | - **The QUIC send-staging buffer moves into the link.** `Transport.qout` and | ||
| 78 | muxa's `sendFrameQuic` deadline-flush are two answers to "the ring would not | ||
| 79 | take my bytes"; the buffer is a property of the quic link and lives there | ||
| 80 | once. | ||
| 81 | - `dial.zig` keeps its public contract. `dial.ask` becomes dial → | ||
| 82 | `Link{.fd}` → `awaitFrame` → close; the private `askOn` is deleted, with | ||
| 83 | `awaitFrame` as its public successor. | ||
| 84 | - wasm untouched: `wasm_core` / `client_core` never link this row. | ||
| 85 | |||
| 86 | `Transport` and `AgentConnection` become wrappers holding `link: Link` plus | ||
| 87 | their own policy fields. Deleted with the merge: muxa's `awaitFrameFd`, | ||
| 88 | `awaitFrameQuic`, `sendFrameQuic`, `waitReady`, and Transport's per-arm | ||
| 89 | switches in `writeFrame` / `readFrame` / `service` / `flushQuic` / `close`. | ||
| 90 | |||
| 91 | ## Section 2 — the `awaitFrame` contract | ||
| 92 | |||
| 93 | Non-matching frames go to a sink, and the sink may end the wait: | ||
| 94 | |||
| 95 | ```zig | ||
| 96 | pub const Sink = struct { | ||
| 97 | ctx: ?*anyopaque = null, | ||
| 98 | on: ?*const fn (ctx: ?*anyopaque, frame: proto.Frame) anyerror!void = null, | ||
| 99 | }; | ||
| 100 | pub fn awaitFrame(self: *Link, alloc, want: proto.MsgType, | ||
| 101 | deadline_ms: ?u32, sink: Sink) !?proto.Frame | ||
| 102 | ``` | ||
| 103 | |||
| 104 | - `sink.on == null` → drop-and-deinit: exactly `dial.askOn` today, so | ||
| 105 | `dial.ask` callers change nothing. | ||
| 106 | - muxa's classification (snapshot → `saw_snapshot`; exit_status → | ||
| 107 | `AttachRefused` / `SessionExited`) becomes its sink; a sink error propagates | ||
| 108 | out of the wait. This is why `on` returns `anyerror!void`: muxa's "other" | ||
| 109 | frames are answers, not noise, and that was its whole reason for a second | ||
| 110 | copy. | ||
| 111 | - **Ownership rule, stated at the declaration:** a sink that returns normally | ||
| 112 | means keep waiting; frames handed to the sink are the sink's to deinit only | ||
| 113 | if it keeps them, otherwise `awaitFrame` deinits after the call returns. | ||
| 114 | - **Deadline is relative `?u32`, null waits forever** (dial's convention). | ||
| 115 | muxa converts from its absolute i64 at its own boundary. | ||
| 116 | - Returns `null` = deadline ran out; `error.*` = peer closed, corrupt frame, | ||
| 117 | or the sink's error. The QUIC arm folds `cl.pump()` + `takeFrame` + | ||
| 118 | `cl.dead` into the same loop. | ||
| 119 | - `error.NoDaemon` / `error.RequestNotSent` stay dial's: they are about the | ||
| 120 | ask round trip, not the wait. | ||
| 121 | |||
| 122 | Converts: `dial.ask` (internals only), muxa's three await fns, | ||
| 123 | `client.awaitFrames`, harness `awaitFrame` / `awaitFrameOn` / | ||
| 124 | `firstStateFrame` (the last via a StateFrame-decoding sink), and the | ||
| 125 | clipboard/attach/session/modes test loops. Does NOT convert: the wall's pump | ||
| 126 | loop — a multiplexed event loop over doorbell + socket, not a | ||
| 127 | wait-for-one-type; wrong altitude. | ||
| 128 | |||
| 129 | ## Section 3 — the `serve` row: bind + teardown | ||
| 130 | |||
| 131 | New shared root `src/serve.zig`. (askpass — a client module — binds too, so | ||
| 132 | it cannot live under `src/server/`.) | ||
| 133 | |||
| 134 | ```zig | ||
| 135 | pub const Policy = enum { | ||
| 136 | refuse_live, // daemon socket: sockpath.claim — never steal a path that answers | ||
| 137 | clobber_own, // agent relay, askpass: the name embeds our identity; a leftover is ours | ||
| 138 | }; | ||
| 139 | pub fn bind(path: []const u8, opts: struct { | ||
| 140 | policy: Policy, | ||
| 141 | backlog: u31 = 128, | ||
| 142 | cloexec: bool = false, | ||
| 143 | }) !Bound; | ||
| 144 | pub const Bound = struct { | ||
| 145 | fd: std.posix.fd_t, | ||
| 146 | path_id: sockpath.PathId, | ||
| 147 | pub fn close(self: *Bound, path: []const u8) void; // stillAt-guarded unlink, always | ||
| 148 | }; | ||
| 149 | ``` | ||
| 150 | |||
| 151 | Decisions inside: | ||
| 152 | |||
| 153 | - **All three binders get the `stillAt` guard at teardown.** A behavior | ||
| 154 | change for the agent relay and askpass, and the point: delete-by-name | ||
| 155 | racing a successor's bind applies wherever a name can be re-bound, and | ||
| 156 | askpass's pid-named path is exactly the pid-reuse case its own comment | ||
| 157 | worries about. Cost: one fstatat. | ||
| 158 | - **`clobber_own` does the pre-bind `deleteFile`** both current sites | ||
| 159 | hand-roll, written once next to the rationale. | ||
| 160 | - **`cloexec` defaults false and the default is load-bearing:** `mux d | ||
| 161 | upgrade` execs the candidate over the running daemon and the listener fd | ||
| 162 | (and the agent sockets) must survive the exec. askpass passes true. The | ||
| 163 | field's comment names the upgrade path so nobody hardens the default. | ||
| 164 | - **Length refusal stays split:** `sockpath.tooLong` remains `mux d`'s | ||
| 165 | parse-time refusal on the asking client's stderr; everyone else lets | ||
| 166 | `initUnix` refuse at bind (the agent relay already documents relying on | ||
| 167 | that). | ||
| 168 | - **`sockpath.zig` stays** as serve's engine room; `claim` / `PathId` / | ||
| 169 | `answers` / `tooLong` keep their homes. Whether `claim` stays `pub` is an | ||
| 170 | implementation-time call. | ||
| 171 | |||
| 172 | Converted: the daemon listener (`server.zig` init), the agent relay | ||
| 173 | (`server_agent.zig`), askpass's listener. Test-local listeners (proxy tests, | ||
| 174 | harness fakes) stay on bare `addr.listen` — they own no path anyone could | ||
| 175 | race. | ||
| 176 | |||
| 177 | ## Section 4 — the harness: `pumpUntil` | ||
| 178 | |||
| 179 | Test-only, in `server_test_harness.zig`: | ||
| 180 | |||
| 181 | ```zig | ||
| 182 | pub fn pumpUntil(srv: *Server, deadline_ms: u64, ctx: anytype, | ||
| 183 | pred: fn (@TypeOf(ctx)) bool) !bool; | ||
| 184 | ``` | ||
| 185 | |||
| 186 | - Fixed 5 ms pump step inside; callers state a wall-clock deadline, not a | ||
| 187 | round count. The 13 spin loops with guessed rounds×step become one honest | ||
| 188 | "within N ms". | ||
| 189 | - Returning `false` is an assertable value, so no test wedges silently — the | ||
| 190 | deadline turns a hang into a named failure (a wedged `zig test` prints | ||
| 191 | nothing; the pin must be able to fire). | ||
| 192 | - Harness `awaitFrame` / `awaitFrameOn` / `firstStateFrame` become thin | ||
| 193 | wrappers over `Link.awaitFrame`; `awaitGridText` keeps its shape but its | ||
| 194 | frame loop goes through the same call; the private copies in | ||
| 195 | `server_test_attach` / `session` / `modes` / `clipboard` are deleted. | ||
| 196 | |||
| 197 | ## Section 5 — staging, gates, risk | ||
| 198 | |||
| 199 | Six stages, each a green `make check` with its own commit story; `make ci` | ||
| 200 | at delivery. **No wire change anywhere** — every byte on every socket is | ||
| 201 | identical before and after. The xversion gate should be a formality; run it | ||
| 202 | once at the end. | ||
| 203 | |||
| 204 | 1. **`link.zig` lands** with unit tests: socketpair for the fd arm (dial's | ||
| 205 | tests are the template), `quicTestServer` for the quic arm, the sink | ||
| 206 | ownership rule pinned. Table row added; nothing converts. | ||
| 207 | 2. **`dial.ask` over it**; `askOn` deleted. dial's tests pass unchanged — | ||
| 208 | they are the contract. | ||
| 209 | 3. **`Transport` wraps a Link** (qout moves in). Gate: e2e + wall tests. | ||
| 210 | 4. **`AgentConnection` wraps a Link**; the three await/send arms deleted. | ||
| 211 | Gate: `make agent`. | ||
| 212 | 5. **`serve.zig` lands**; three binders convert. The guarded-unlink behavior | ||
| 213 | change gets a test each in `server_test_agent` and askpass's suite, | ||
| 214 | modeled on the existing successor-socket test in `server_test_session`. | ||
| 215 | 6. **Harness sweep**: `pumpUntil`, wrapper awaits, delete the private | ||
| 216 | copies. Largest diff, zero production bytes. | ||
| 217 | |||
| 218 | Stages 3–4 are independent of 5–6 and may swap if one stalls. | ||
| 219 | |||
| 220 | Risks named: | ||
| 221 | |||
| 222 | - Stage 3 is the dangerous one. `Transport.close`'s idempotence (the EBADF / | ||
| 223 | double-close panic note) and the pipe-kill ordering must move | ||
| 224 | byte-for-byte, and the wall's redial paths (`detach_ack`, `asked`) must | ||
| 225 | not notice the wrapper. | ||
| 226 | - Stage 4: reconnect classification (`refusalPending` on BrokenPipe et al) | ||
| 227 | stays muxa policy; only the loops move. | ||
| 228 | - Stage 5's behavior change is deliberate and tested, not incidental. | ||
| 229 | |||
| 230 | ## Line count: before and after | ||
| 231 | |||
| 232 | Measured 2026-08-31 (`wc -l`, whole tree 55,066 Zig lines). Deletion | ||
| 233 | candidates, counted by symbol span: | ||
| 234 | |||
| 235 | | Region | Lines | | ||
| 236 | |---|---| | ||
| 237 | | `dial.askOn` + doc | 42 | | ||
| 238 | | muxa `sendFrameQuic` + `awaitFrameFd` + `awaitFrameQuic` + `waitReady` | 133 | | ||
| 239 | | `client.awaitFrames` | 58 | | ||
| 240 | | `Transport` per-arm write/service/timeout/flush/read + `close` | 115 | | ||
| 241 | | harness `firstStateFrame` + `awaitFrame` + `awaitFrameOn` | 105 | | ||
| 242 | | test-private `awaitSelectionReply` / `awaitSnapshotSize` / `awaitMarkerWithoutSnapshot` | 182 | | ||
| 243 | | inline poll+readFrame test loops (attach/session/modes/deliver/await) | ~250 | | ||
| 244 | | spin loops + `deadline_ms` countdowns | ~60 | | ||
| 245 | | three binder sites (bind + teardown blocks) | ~50 | | ||
| 246 | | **total removed or moved** | **~995** | | ||
| 247 | |||
| 248 | Estimated additions: `link.zig` ~350 (of which ~120 its own tests), | ||
| 249 | `serve.zig` ~180 (of which ~60 tests), `pumpUntil` ~25, harness wrappers | ||
| 250 | ~40, wrapper glue in `Transport` / `AgentConnection` ~60 → **~655**. | ||
| 251 | |||
| 252 | Net: **roughly −350 lines**, almost all of it test code. Production is | ||
| 253 | approximately flat by design — that code *moves* to one owner rather than | ||
| 254 | shrinking. The value is one copy of each loop, not fewer bytes; the test tree | ||
| 255 | is where consolidation actually deletes. | ||
| 256 | |||
| 257 | These are estimates against measured spans; re-measure at delivery and | ||
| 258 | replace this table with the real diffstat. | ||
| 259 | |||
| 260 | ## Success criteria | ||
| 261 | |||
| 262 | - `grep -rn "posix.poll" src/ | xargs grep -l readFrame` finds only | ||
| 263 | `link.zig` (and the wall's event loop). | ||
| 264 | - One `Link` union in the tree; muxa still does not import `client`. | ||
| 265 | - All three production binders reach the socket through `serve.bind` and the | ||
| 266 | guarded unlink. | ||
| 267 | - No `while (spun < N)` pump loops remain in `src/server/`. | ||
| 268 | - `make ci` green; xversion gate green; wire bytes unchanged. | ||