a73x

8a07693c

Design: link, serve, and the one await loop

a73x   2026-08-31 17:57

Commit message
Design: link, serve, and the one await loop

The connection-primitives campaign spec: promote the fd|pipe|quic union
to a shared link row with one awaitFrame, give bind+teardown one owner
in serve.zig, and pumpUntil for the harness. Four audit findings, six
stages, no wire change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWxBL1HBULH1ZwTNzzKTja

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.