a73x

22462519

docs: link and serve join the table; spec table becomes the diffstat

a73x   2026-08-31 22:01

Commit message
docs: link and serve join the table; spec table becomes the diffstat

CLAUDE.md
Old New
@@ -59,7 +59,7 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module.
59 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | 59 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) |
60 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | 60 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` |
61 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) · `spawn` | 61 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) · `spawn` |
62 | `src/` | `xdg` `sockpath` `dial` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket, `term` and nothing else under it | 62 | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them |
63 63
64 The grouping itself is a convention now, not a gate: the table wires the 64 The grouping itself is a convention now, not a gate: the table wires the
65 build and says who owns what, and an import edit is an ordinary diff. What 65 build and says who owns what, and an import edit is an ordinary diff. What
docs/superpowers/specs/2026-08-31-connection-primitives-design.md
Old New
@@ -139,7 +139,7 @@ pub const Policy = enum {
139 pub fn bind(path: []const u8, opts: struct { 139 pub fn bind(path: []const u8, opts: struct {
140 policy: Policy, 140 policy: Policy,
141 backlog: u31 = 128, 141 backlog: u31 = 128,
142 cloexec: bool = false, 142 cloexec: bool = true,
143 }) !Bound; 143 }) !Bound;
144 pub const Bound = struct { 144 pub const Bound = struct {
145 fd: std.posix.fd_t, 145 fd: std.posix.fd_t,
@@ -157,10 +157,19 @@ Decisions inside:
157 worries about. Cost: one fstatat. 157 worries about. Cost: one fstatat.
158 - **`clobber_own` does the pre-bind `deleteFile`** both current sites 158 - **`clobber_own` does the pre-bind `deleteFile`** both current sites
159 hand-roll, written once next to the rationale. 159 hand-roll, written once next to the rationale.
160 - **`cloexec` defaults false and the default is load-bearing:** `mux d 160 - **`cloexec` defaults TRUE.** This spec originally wrote the opposite —
161 upgrade` execs the candidate over the running daemon and the listener fd 161 "defaults false and the default is load-bearing", on the premise that `mux
162 (and the agent sockets) must survive the exec. askpass passes true. The 162 d upgrade` needs the listener and the agent sockets to survive the exec of
163 field's comment names the upgrade path so nobody hardens the default. 163 the candidate. Implementation refuted the premise (Task 5): `std.net`
164 already set CLOEXEC on every listener this change converted, so a false
165 default would have been a silent loosening, not a preserved status quo,
166 and the upgrade path never depended on it. What the upgrade actually does
167 is clear the flag per fd immediately before the exec
168 (`Server.execUpgrade`) and put it back on the far side
169 (`Server.sealAdoptedFds`), so the fds that must cross the exec say so one
170 by one rather than standing open to every forked shell. The default is
171 therefore true, `serve.zig`'s field comment records why, and a unit test
172 pins both settings.
164 - **Length refusal stays split:** `sockpath.tooLong` remains `mux d`'s 173 - **Length refusal stays split:** `sockpath.tooLong` remains `mux d`'s
165 parse-time refusal on the asking client's stderr; everyone else lets 174 parse-time refusal on the asking client's stderr; everyone else lets
166 `initUnix` refuse at bind (the agent relay already documents relying on 175 `initUnix` refuse at bind (the agent relay already documents relying on
@@ -229,38 +238,68 @@ Risks named:
229 238
230 ## Line count: before and after 239 ## Line count: before and after
231 240
232 Measured 2026-08-31 (`wc -l`, whole tree 55,066 Zig lines). Deletion 241 Measured at delivery (`git diff --numstat 1aaa22fe..HEAD`; whole tree now
233 candidates, counted by symbol span: 242 55,943 Zig lines). This replaces the pre-implementation estimate table:
234 243
235 | Region | Lines | 244 | Region | Lines |
236 |---|---| 245 |---|---|
237 | `dial.askOn` + doc | 42 | 246 | `src/link.zig` (new; 184 of it its own tests) | +490 −0 |
238 | muxa `sendFrameQuic` + `awaitFrameFd` + `awaitFrameQuic` + `waitReady` | 133 | 247 | `src/serve.zig` (new; 102 of it its own tests) | +197 −0 |
239 | `client.awaitFrames` | 58 | 248 | `src/dial.zig` | +20 −120 |
240 | `Transport` per-arm write/service/timeout/flush/read + `close` | 115 | 249 | `src/cli/muxa.zig` | +111 −175 |
241 | harness `firstStateFrame` + `awaitFrame` + `awaitFrameOn` | 105 | 250 | `src/client/client.zig` | +115 −179 |
242 | test-private `awaitSelectionReply` / `awaitSnapshotSize` / `awaitMarkerWithoutSnapshot` | 182 | 251 | `src/client/askpass.zig` | +65 −18 |
243 | inline poll+readFrame test loops (attach/session/modes/deliver/await) | ~250 | 252 | `src/server/server.zig` | +94 −58 |
244 | spin loops + `deadline_ms` countdowns | ~60 | 253 | `src/server/server_agent.zig` | +26 −19 |
245 | three binder sites (bind + teardown blocks) | ~50 | 254 | `src/server/server_sessions.zig` | +3 −5 |
246 | **total removed or moved** | **~995** | 255 | `build.zig` | +20 −11 |
247 256 | `src/server/server_test_harness.zig` | +288 −48 |
248 Estimated additions: `link.zig` ~350 (of which ~120 its own tests), 257 | `src/server/server_test_attach.zig` | +239 −366 |
249 `serve.zig` ~180 (of which ~60 tests), `pumpUntil` ~25, harness wrappers 258 | `src/server/server_test_agent.zig` | +155 −87 |
250 ~40, wrapper glue in `Transport` / `AgentConnection` ~60 → **~655**. 259 | `src/server/server_test_upgrade.zig` | +80 −13 |
251 260 | `src/server/server_test_modes.zig` | +78 −32 |
252 Net: **roughly −350 lines**, almost all of it test code. Production is 261 | `src/server/server_test_await.zig` | +69 −29 |
253 approximately flat by design — that code *moves* to one owner rather than 262 | `src/server/server_test_session.zig` | +49 −74 |
254 shrinking. The value is one copy of each loop, not fewer bytes; the test tree 263 | `src/server/server_test_deliver.zig` | +19 −4 |
255 is where consolidation actually deletes. 264 | `src/server/server_test_clipboard.zig` | +13 −15 |
256 265 | `src/tui/wall_test_pump.zig` | +9 −8 |
257 These are estimates against measured spans; re-measure at delivery and 266 | `src/server/server_test_quic.zig` | +7 −0 |
258 replace this table with the real diffstat. 267 | **total (21 files)** | **+2147 −1261, net +886** |
268
269 The estimate was wrong in sign, and the reason is worth recording. It
270 predicted roughly −350 by counting the call sites a shared owner would
271 delete, and it undercounted what the owner itself costs: `link.zig` and
272 `serve.zig` together are +687, of which 286 are their own tests — a unit
273 suite the scattered loops never had, because a loop inlined in a test body
274 is not a thing another test can call. The call sites did shrink about as
275 predicted (`dial` −100 net, `muxa` −64, `client` −64, and
276 `server_test_attach` −127), but the harness grew +240 net: `pumpUntil` and
277 the Link-backed awaits are shared code that used to be copied per test file,
278 so it moved rather than vanished, and it moved into the file that now owns
279 it.
280
281 So: production +556 net, tests +330 net. The value delivered is the one
282 that was claimed all along — one copy of each await loop and one binder,
283 both now directly testable — and not a smaller tree. A consolidation that
284 adds a test suite for the thing it consolidates should be expected to grow
285 the line count; the estimate's mistake was counting only the deletions it
286 could see spans for.
259 287
260 ## Success criteria 288 ## Success criteria
261 289
262 - `grep -rn "posix.poll" src/ | xargs grep -l readFrame` finds only 290 - `grep -rln "posix.poll" src/ --include="*.zig" | xargs grep -ln readFrame`
263 `link.zig` (and the wall's event loop). 291 finds only `link.zig` and files that poll for a reason a single-fd frame
292 await cannot express. Measured at delivery, that set is: `link.zig` itself;
293 the wall's event loop (`wallview.zig`, `wall_pump.zig`) and the hub's
294 (`webhub.zig`), each waiting on a transport, a UI fd and ssh's stderr at
295 once; `client.zig`, whose waits watch the abort fd and ssh's stderr
296 alongside the socket — `Transport.open`'s dial wait, the ErrPipe drain and
297 the handoff announce read; `protocol.zig`, whose two are `POLL.OUT` write
298 backpressure inside `writeFrameBounded`, not reads at all; and the server
299 test files, whose 23 remaining `posix.poll(` call sites are silence
300 assertions and multi-client waits that each carry their reason in-file.
301 What the criterion actually forbids — a hand-rolled "await one frame type
302 on one fd" loop outside `link.zig` — has none left.
264 - One `Link` union in the tree; muxa still does not import `client`. 303 - 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 304 - All three production binders reach the socket through `serve.bind` and the
266 guarded unlink. 305 guarded unlink.
try.sh
Old New
@@ -0,0 +1,59 @@
1 #!/usr/bin/env bash
2 # Demo for the connection-primitives change: src/link.zig (the fd|pipe|quic
3 # connection under dial) and src/serve.zig (the right to bind a socket path and
4 # the duty to unlink it). Everything below goes through them — the daemon's
5 # listener is a serve.bind, and every client ask below is a Link await.
6 #
7 # Run from the repo root: ./try.sh
8 set -u
9
10 cd "$(dirname "$0")" || exit 1
11 ZIG=deps/zig/zig
12 MUX=$PWD/zig-out/bin/mux
13
14 SCRATCH=$(mktemp -d /tmp/mux-try.XXXXXX)
15 SOCK=$SCRATCH/d.sock
16
17 # Isolated state: a bare `mux` records its daemon in $XDG_STATE_HOME/mux/hosts,
18 # and an un-isolated rig leaves a dead --sock line on the user's real wall.
19 export XDG_STATE_HOME=$SCRATCH/state
20 export XDG_RUNTIME_DIR=$SCRATCH/run
21 mkdir -p "$XDG_STATE_HOME" "$XDG_RUNTIME_DIR"
22 chmod 700 "$XDG_RUNTIME_DIR"
23
24 cleanup() {
25 "$MUX" d stop --sock "$SOCK" >/dev/null 2>&1
26 rm -rf "$SCRATCH"
27 }
28 trap cleanup EXIT
29
30 step() { printf '\n=== %s\n' "$*"; }
31
32 step "build (zig-out is not refreshed by make ci)"
33 $ZIG build || exit 1
34 echo "ok: $MUX"
35
36 step "serve.bind: mux d start -d --sock \$SCRATCH/d.sock"
37 "$MUX" d start -d --sock "$SOCK" || exit 1
38 ls -l "$SOCK"
39
40 step "mux d endpoint --sock ... (announce, then it proxies; we take line 1)"
41 timeout 3 "$MUX" d endpoint --sock "$SOCK" 2>&1 | head -1
42
43 step "mux a status --sock ... (a Link ask: one frame out, one frame awaited)"
44 "$MUX" a status --sock "$SOCK"
45
46 step "mux d stats --sock ... (a second ask on a fresh connection)"
47 "$MUX" d stats --sock "$SOCK"
48
49 step "mux d stop --sock ..."
50 "$MUX" d stop --sock "$SOCK"
51
52 step "serve's duty: the path is unlinked on the way out"
53 if [ -e "$SOCK" ]; then echo "FAIL: $SOCK survived the stop"; else echo "ok: socket path gone"; fi
54
55 step "no mux process left behind"
56 pgrep -a -f "$SOCK" || echo "ok: none"
57
58 echo
59 echo "done."