a73x

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.