docs/superpowers/specs/2026-08-31-connection-primitives-design.md
Ref: Size: 14.9 KiB History
# Connection primitives: link, serve, and the one await loop
2026-08-31. Status: approved design, pre-implementation.
## Problem
`dial.zig` states the product's thesis — connecting a client to a daemon is
*the* operation, so it is a callable primitive — and then the thesis stops at
dial's own edge. An audit (this date) found four places the codebase re-answers
a question a primitive should own:
1. **"Wait for one frame of type X"** exists ~10 times: `dial.askOn` (private
on purpose), `muxa.awaitFrameFd` / `awaitFrameQuic`, `client.awaitFrames`,
the server test harness's `awaitFrame` / `awaitFrameOn` / `firstStateFrame`,
and private copies plus inline loops across the `server_test_*` files.
`dial.zig`'s own comment ("private on purpose: this loop DROPS every frame")
is the causal record: the drop policy was sound, making it private forced
every caller with a different policy to write the loop again.
2. **Binding a socket you own** has three production answers: the daemon
(`sockpath.claim` + guarded unlink), the agent relay (unguarded unlink),
askpass (raw socket/bind/listen, unguarded unlink). Only the daemon carries
the delete-by-name-races-a-successor protection that `sockpath.zig` records
buying from an incident.
3. **Driving a test daemon to a condition** is 13 hand-rolled
`while (spun < N …) pumpOnce(step)` loops with guessed round counts, plus
hand-rolled `deadline_ms -|= 100` countdowns in three files.
4. **`muxa.AgentConnection` is a second `client.Transport`**: two independent
answers to "a connection that is either an fd or a QUIC client", each with
its own send, await, close, and QUIC staging.
Checked and NOT gaps: the daemon's frame dispatch (`handleDaemonVerb` / `Peer`
is properly factored), and the one-replay-core invariant (replica / client_core
/ webhub / wasm_core are four different concerns, not four copies).
## Decisions (settled in brainstorm)
- **One campaign, four gaps, staged.** One spec; implementation lands in
stages, each independently green.
- **The Link is promoted to a shared row.** The fd | pipe | quic union moves
out of `client.Transport` into a new `src/link.zig`. The await/send
primitives are written once against it. `mux a` never imports `client`, so
the agent row keeps its isolated suite.
- **`serve.zig` owns bind + teardown only.** Accept loops stay with their
owners — the daemon's nonblocking slot-table accept, askpass's
credential-checking serve, webhub's thread-per-conn are real differences,
not duplication.
- **`pumpUntil` goes in the server test harness**, between `pumpOnce` and
`serverThread`.
## Section 1 — the `link` row
New shared root `src/link.zig`, a table row beside `dial` in the "what both
sides link" tier. It is the live connection to a daemon, however reached, and
the verbs every holder duplicates today:
```zig
pub const Link = union(enum) {
fd: std.posix.fd_t, // unix socket: one fd, read and write
pipe: Pipe, // --via / handoff ssh: the child + its stdio fds
quic: Quic, // *quic.Client + the qout staging buffer
};
// sendFrame(self, t, payload)
// readFrame(self, alloc) -> Incoming { frame, incomplete, closed }
// awaitFrame(self, alloc, want, deadline_ms, sink) -> ?Frame (section 2)
// pollFd, timeoutMs, service, close
```
What it is NOT: policy. Redial/backoff (`client.nextBackoffMs`, handoff,
muxa's one-reconnect and its `addr`/`key`/`reconnected` state), attach
semantics, and interpretation of `Incoming` stay with the owners. `Transport`
keeps `err_fd`, `reason`, and narrate — the handoff story wraps a Link.
Boundary consequences (load-bearing):
- `link.zig` imports `term` (frames) and `quic`. **`proxy.zig` and the QUIC
modules must never import it** — proxy stays byte-blind per the invariant.
- **The QUIC send-staging buffer moves into the link.** `Transport.qout` and
muxa's `sendFrameQuic` deadline-flush are two answers to "the ring would not
take my bytes"; the buffer is a property of the quic link and lives there
once.
- `dial.zig` keeps its public contract. `dial.ask` becomes dial →
`Link{.fd}` → `awaitFrame` → close; the private `askOn` is deleted, with
`awaitFrame` as its public successor.
- wasm untouched: `wasm_core` / `client_core` never link this row.
`Transport` and `AgentConnection` become wrappers holding `link: Link` plus
their own policy fields. Deleted with the merge: muxa's `awaitFrameFd`,
`awaitFrameQuic`, `sendFrameQuic`, `waitReady`, and Transport's per-arm
switches in `writeFrame` / `readFrame` / `service` / `flushQuic` / `close`.
## Section 2 — the `awaitFrame` contract
Non-matching frames go to a sink, and the sink may end the wait:
```zig
pub const Sink = struct {
ctx: ?*anyopaque = null,
on: ?*const fn (ctx: ?*anyopaque, frame: proto.Frame) anyerror!void = null,
};
pub fn awaitFrame(self: *Link, alloc, want: proto.MsgType,
deadline_ms: ?u32, sink: Sink) !?proto.Frame
```
- `sink.on == null` → drop-and-deinit: exactly `dial.askOn` today, so
`dial.ask` callers change nothing.
- muxa's classification (snapshot → `saw_snapshot`; exit_status →
`AttachRefused` / `SessionExited`) becomes its sink; a sink error propagates
out of the wait. This is why `on` returns `anyerror!void`: muxa's "other"
frames are answers, not noise, and that was its whole reason for a second
copy.
- **Ownership rule, stated at the declaration:** a sink that returns normally
means keep waiting; frames handed to the sink are the sink's to deinit only
if it keeps them, otherwise `awaitFrame` deinits after the call returns.
- **Deadline is relative `?u32`, null waits forever** (dial's convention).
muxa converts from its absolute i64 at its own boundary.
- Returns `null` = deadline ran out; `error.*` = peer closed, corrupt frame,
or the sink's error. The QUIC arm folds `cl.pump()` + `takeFrame` +
`cl.dead` into the same loop.
- `error.NoDaemon` / `error.RequestNotSent` stay dial's: they are about the
ask round trip, not the wait.
Converts: `dial.ask` (internals only), muxa's three await fns,
`client.awaitFrames`, harness `awaitFrame` / `awaitFrameOn` /
`firstStateFrame` (the last via a StateFrame-decoding sink), and the
clipboard/attach/session/modes test loops. Does NOT convert: the wall's pump
loop — a multiplexed event loop over doorbell + socket, not a
wait-for-one-type; wrong altitude.
## Section 3 — the `serve` row: bind + teardown
New shared root `src/serve.zig`. (askpass — a client module — binds too, so
it cannot live under `src/server/`.)
```zig
pub const Policy = enum {
refuse_live, // daemon socket: sockpath.claim — never steal a path that answers
clobber_own, // agent relay, askpass: the name embeds our identity; a leftover is ours
};
pub fn bind(path: []const u8, opts: struct {
policy: Policy,
backlog: u31 = 128,
cloexec: bool = true,
}) !Bound;
pub const Bound = struct {
fd: std.posix.fd_t,
path_id: sockpath.PathId,
pub fn close(self: *Bound, path: []const u8) void; // stillAt-guarded unlink, always
};
```
Decisions inside:
- **All three binders get the `stillAt` guard at teardown.** A behavior
change for the agent relay and askpass, and the point: delete-by-name
racing a successor's bind applies wherever a name can be re-bound, and
askpass's pid-named path is exactly the pid-reuse case its own comment
worries about. Cost: one fstatat.
- **`clobber_own` does the pre-bind `deleteFile`** both current sites
hand-roll, written once next to the rationale.
- **`cloexec` defaults TRUE.** This spec originally wrote the opposite —
"defaults false and the default is load-bearing", on the premise that `mux
d upgrade` needs the listener and the agent sockets to survive the exec of
the candidate. Implementation refuted the premise (Task 5): `std.net`
already set CLOEXEC on every listener this change converted, so a false
default would have been a silent loosening, not a preserved status quo,
and the upgrade path never depended on it. What the upgrade actually does
is clear the flag per fd immediately before the exec
(`Server.execUpgrade`) and put it back on the far side
(`Server.sealAdoptedFds`), so the fds that must cross the exec say so one
by one rather than standing open to every forked shell. The default is
therefore true, `serve.zig`'s field comment records why, and a unit test
pins both settings.
- **Length refusal stays split:** `sockpath.tooLong` remains `mux d`'s
parse-time refusal on the asking client's stderr; everyone else lets
`initUnix` refuse at bind (the agent relay already documents relying on
that).
- **`sockpath.zig` stays** as serve's engine room; `claim` / `PathId` /
`answers` / `tooLong` keep their homes. Whether `claim` stays `pub` is an
implementation-time call.
Converted: the daemon listener (`server.zig` init), the agent relay
(`server_agent.zig`), askpass's listener. Test-local listeners (proxy tests,
harness fakes) stay on bare `addr.listen` — they own no path anyone could
race.
## Section 4 — the harness: `pumpUntil`
Test-only, in `server_test_harness.zig`:
```zig
pub fn pumpUntil(srv: *Server, deadline_ms: u64, ctx: anytype,
pred: fn (@TypeOf(ctx)) bool) !bool;
```
- Fixed 5 ms pump step inside; callers state a wall-clock deadline, not a
round count. The 13 spin loops with guessed rounds×step become one honest
"within N ms".
- Returning `false` is an assertable value, so no test wedges silently — the
deadline turns a hang into a named failure (a wedged `zig test` prints
nothing; the pin must be able to fire).
- Harness `awaitFrame` / `awaitFrameOn` / `firstStateFrame` become thin
wrappers over `Link.awaitFrame`; `awaitGridText` keeps its shape but its
frame loop goes through the same call; the private copies in
`server_test_attach` / `session` / `modes` / `clipboard` are deleted.
## Section 5 — staging, gates, risk
Six stages, each a green `make check` with its own commit story; `make ci`
at delivery. **No wire change anywhere** — every byte on every socket is
identical before and after. The xversion gate should be a formality; run it
once at the end.
1. **`link.zig` lands** with unit tests: socketpair for the fd arm (dial's
tests are the template), `quicTestServer` for the quic arm, the sink
ownership rule pinned. Table row added; nothing converts.
2. **`dial.ask` over it**; `askOn` deleted. dial's tests pass unchanged —
they are the contract.
3. **`Transport` wraps a Link** (qout moves in). Gate: e2e + wall tests.
4. **`AgentConnection` wraps a Link**; the three await/send arms deleted.
Gate: `make agent`.
5. **`serve.zig` lands**; three binders convert. The guarded-unlink behavior
change gets a test each in `server_test_agent` and askpass's suite,
modeled on the existing successor-socket test in `server_test_session`.
6. **Harness sweep**: `pumpUntil`, wrapper awaits, delete the private
copies. Largest diff, zero production bytes.
Stages 3–4 are independent of 5–6 and may swap if one stalls.
Risks named:
- Stage 3 is the dangerous one. `Transport.close`'s idempotence (the EBADF /
double-close panic note) and the pipe-kill ordering must move
byte-for-byte, and the wall's redial paths (`detach_ack`, `asked`) must
not notice the wrapper.
- Stage 4: reconnect classification (`refusalPending` on BrokenPipe et al)
stays muxa policy; only the loops move.
- Stage 5's behavior change is deliberate and tested, not incidental.
## Line count: before and after
Measured at delivery (`git diff --numstat 1aaa22fe..HEAD`; whole tree now
55,943 Zig lines). This replaces the pre-implementation estimate table:
| Region | Lines |
|---|---|
| `src/link.zig` (new; 184 of it its own tests) | +490 −0 |
| `src/serve.zig` (new; 102 of it its own tests) | +197 −0 |
| `src/dial.zig` | +20 −120 |
| `src/cli/muxa.zig` | +111 −175 |
| `src/client/client.zig` | +115 −179 |
| `src/client/askpass.zig` | +65 −18 |
| `src/server/server.zig` | +94 −58 |
| `src/server/server_agent.zig` | +26 −19 |
| `src/server/server_sessions.zig` | +3 −5 |
| `build.zig` | +20 −11 |
| `src/server/server_test_harness.zig` | +288 −48 |
| `src/server/server_test_attach.zig` | +239 −366 |
| `src/server/server_test_agent.zig` | +155 −87 |
| `src/server/server_test_upgrade.zig` | +80 −13 |
| `src/server/server_test_modes.zig` | +78 −32 |
| `src/server/server_test_await.zig` | +69 −29 |
| `src/server/server_test_session.zig` | +49 −74 |
| `src/server/server_test_deliver.zig` | +19 −4 |
| `src/server/server_test_clipboard.zig` | +13 −15 |
| `src/tui/wall_test_pump.zig` | +9 −8 |
| `src/server/server_test_quic.zig` | +7 −0 |
| **total (21 files)** | **+2147 −1261, net +886** |
The estimate was wrong in sign, and the reason is worth recording. It
predicted roughly −350 by counting the call sites a shared owner would
delete, and it undercounted what the owner itself costs: `link.zig` and
`serve.zig` together are +687, of which 286 are their own tests — a unit
suite the scattered loops never had, because a loop inlined in a test body
is not a thing another test can call. The call sites did shrink about as
predicted (`dial` −100 net, `muxa` −64, `client` −64, and
`server_test_attach` −127), but the harness grew +240 net: `pumpUntil` and
the Link-backed awaits are shared code that used to be copied per test file,
so it moved rather than vanished, and it moved into the file that now owns
it.
So: production +556 net, tests +330 net. The value delivered is the one
that was claimed all along — one copy of each await loop and one binder,
both now directly testable — and not a smaller tree. A consolidation that
adds a test suite for the thing it consolidates should be expected to grow
the line count; the estimate's mistake was counting only the deletions it
could see spans for.
## Success criteria
- `grep -rln "posix.poll" src/ --include="*.zig" | xargs grep -ln readFrame`
finds only `link.zig` and files that poll for a reason a single-fd frame
await cannot express. Measured at delivery, that set is: `link.zig` itself;
the wall's event loop (`wallview.zig`, `wall_pump.zig`) and the hub's
(`webhub.zig`), each waiting on a transport, a UI fd and ssh's stderr at
once; `client.zig`, whose waits watch the abort fd and ssh's stderr
alongside the socket — `Transport.open`'s dial wait, the ErrPipe drain and
the handoff announce read; `protocol.zig`, whose two are `POLL.OUT` write
backpressure inside `writeFrameBounded`, not reads at all; and the server
test files, whose 23 remaining `posix.poll(` call sites are silence
assertions and multi-client waits that each carry their reason in-file.
What the criterion actually forbids — a hand-rolled "await one frame type
on one fd" loop outside `link.zig` — has none left.
- One `Link` union in the tree; muxa still does not import `client`.
- All three production binders reach the socket through `serve.bind` and the
guarded unlink.
- No `while (spun < N)` pump loops remain in `src/server/`.
- `make ci` green; xversion gate green; wire bytes unchanged.