a73x

90394750

refactor: the small modules say what they are for

a73x   2026-08-30 20:13

Commit message
refactor: the small modules say what they are for

85 essays to ~40 across twenty files. Module headers lead with what the
module does; the rules that survive name a failure — a symlinked shim
directory sourced by the session shell, a socket path claimed out from
under a live daemon, a wasm view detached by a growing allocator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XSUFuYHqU9wr4J5NC8EkWV

.task8-report.md
Old New
@@ -0,0 +1,145 @@
1 # Task 8 — evidence-only silence
2
3 Branch `shape-first`, on top of 5f608210.
4
5 **The rule delivered.** A SILENT host never takes its pending panes. They ride
6 it out indefinitely wearing `unreachable` on their bars. Only EVIDENCE moves a
7 pane: a host's own reachable list either binds it (`bindTile` → `.connecting`)
8 or, by not naming it, vanishes it. The 2 s settle no longer collapses anything —
9 it gates the picker's auto-open on an empty wall and nothing else.
10
11 ## Changed, per file
12
13 ### `src/tui/wallview.zig`
14 - `State` gained `@"unreachable"` (spelled with `@""` — `unreachable` is a Zig
15 keyword; the bare field did not parse, which is the build break that was
16 handed over). `word()` returns `"unreachable"`, byte-identical to the picker
17 row's word, so a pane and its row read the same.
18 - `EndKey.waiting` removed, and the `if (t.pending) return .waiting;` line in
19 `endKey` with it: a pending pane has no pump at all, so `x` falls through to
20 `.drop` and closes the tile locally. The key-loop's `.waiting` arm (a refusal
21 notice) is gone.
22 - `run()`'s settle block reduced to `settled = true;` — the `collapsePending`
23 call, its `setFocus` repair and its `relayout` are deleted. Settle comment
24 rewritten to say it gates only the picker's auto-open.
25
26 ### `src/tui/wall_layout.zig`
27 - `collapsePending` deleted (13 lines). Nothing calls it.
28
29 ### `src/tui/wall_host.zig`
30 - New private `dressSilent(w, hi)`: under `paint_mu`, every present PENDING tile
31 owned by host `hi` that is not already dark gets `state = .@"unreachable"`,
32 then one `paintDeadBarsLocked` if anything changed. Tiles of other hosts, and
33 non-pending tiles, are untouched.
34 - `applyHostList`'s `if (reachable) { ...diff... }` gained `else dressSilent(w, hi);`.
35 - Verified in `client.zig`: `SessionPoll.run` stores `list_ready = true` on BOTH
36 the reachable and the failed path, so `applyReadyLists` does reach
37 `applyHostList` for a failed poll and `dressSilent` is live in production —
38 not only in the unit fixture. The e2e mutation below proves it end to end.
39
40 ### Tests
41 - `src/tui/wall_test_wall.zig` — the endKey pending test renamed and rewritten
42 for the new claim: `x` on a pending pane is `.drop`, dark or merely waiting.
43 - `src/tui/wall_test_layout.zig` — the two `collapsePending` tests deleted (77
44 lines). "a wall left before its hosts answered saves the shape it was given"
45 kept.
46 - `src/tui/wall_test_host.zig` — new: "a silent host's saved panes survive its
47 failed poll and wear unreachable". Two hosts, three pending panes (2 on host
48 0, 1 on host 1), off-origin rects from a real `relayout`, host 0's poll failed
49 TWICE (a pane that rides out one poll and goes on the next has ridden nothing
50 out). Asserts: all three still present, still pending, rects unmoved; host 0's
51 two read `.@"unreachable"`; host 1's still reads `.waiting`; the word is
52 `"unreachable"`.
53
54 ### `test/e2e_09_hosts.sh`
55 The "a daemon that goes and comes back re-creates NOTHING" leg. The down-host
56 half used to assert ZERO occurrences of the down daemon's socket path on the
57 settled grid. It now asserts the opposite claim:
58 - `--sock $SOCKH2#0 [unreachable]` and `--sock $SOCKH2#c [unreachable]` are both
59 on the settled grid (the two panes the previous wall saved);
60 - no line naming `$SOCKH2` reads `[up]` — nothing dialled a daemon that is not
61 there;
62 - unchanged: the reborn daemon's `mux d stats` reports `sessions=1`, and
63 `--sock $SOCKH2#c` is absent from the reborn wall's grid — its own list took
64 the pane, which is exactly the evidence rule.
65 The leg's block comment and its `ok` line were rewritten to match.
66
67 ### `CLAUDE.md`
68 - Wall bullet: added "Only a host's OWN list may take a pane: a saved pane on a
69 host that answers nothing stays, wearing `unreachable`, for as long as the box
70 is dark — an eight-pane setup is not worth one quiet machine."
71 - `Ctrl-\ x` bullet: "a tile that has never come up" now says "a saved pane on a
72 dark host included", and names the reason a pending pane qualifies (it has no
73 pump at all). The bullet already ended with "birthing onto an `unreachable`
74 row is a designed path", which stays true.
75 - Layout-sidecar bullet: untouched. "Healing is per-leaf against the hosts' live
76 lists" is already the evidence rule stated positively.
77
78 ### `docscheck.budget` / `docscheck.blocks`
79 **Neither file changed.** The first `zig build check` failed with
80 `wallview.zig: 64 comment blocks of 5+ lines, baseline 63` — the new settle
81 comment was five lines. It was shortened to three rather than raising the
82 baseline, which puts wallview back at exactly 63. `doc-report` confirms the
83 figures already in the files: blocks `wall_host.zig 7`, `wall_layout.zig 2`,
84 `wallview.zig 63`; budget `wallview.zig 0`, `wall_host.zig 0`,
85 `wall_layout.zig 890`. Nothing went up, so nothing needed signing.
86
87 ## Mutation verification
88
89 Every new assertion was broken once and watched fail, then restored with the
90 inverse edit (never `git checkout --`).
91
92 | # | Mutation | Assertion that fired |
93 |---|---|---|
94 | A | drop `t.state = .@"unreachable";` from `dressSilent` | `DarkHostsPanesDoNotSayWhichSetupIsDark` |
95 | B | drop the `ownedBy(t, hi)` filter (via `hi == 99`) | `OneHostsSilenceSpreadToAnother` |
96 | C | `if (reachable)` → `if (true)`, so silence drives the diff | `SilentHostTookThePanesTheUserSaved` |
97 | D | `if (t.pending) return .none;` in `endKey` | `WaitingPaneRefusedTheKeyThatCloseIt` |
98 | E | `if (t.state == .@"unreachable") return .none;` in `endKey` | `SilentHostsPaneCannotBeClosed` |
99 | F | delete `else dressSilent(w, hi);` (e2e) | `e2e FAIL: hosts restart: the pane the user saved on a dark daemon is gone, or does not say the machine is` |
100
101 A first attempt at B (deleting the `ownedBy` term outright) was caught by the
102 COMPILER as an unused parameter, not by the test — re-done as `hi == 99` so the
103 parameter stays used and the test is the thing that catches it.
104
105 ## Gates
106
107 - `deps/zig/zig build` — rc 0.
108 - `deps/zig/zig build test` — rc 0, 1067/1067.
109 - `deps/zig/zig build check` — rc 0 after the settle comment was shortened.
110 - `make check` — rc 0.
111 - `E2E_ONLY=09_hosts make e2e` — rc 0, 12 scenarios.
112 - `E2E_ONLY=12_panes make e2e` — rc 0, 10 scenarios.
113 - `E2E_ONLY=13_birth make e2e` — rc 0, 5 scenarios.
114 - `E2E_ONLY=01_boot make e2e` — rc 0, 12 scenarios.
115 - `make ci` — **rc 0**. `check` + e2e **100 scenarios, 38 convergence points** +
116 the agent suite + throughput (client 200000 lines attached 36ms against a 60ms
117 target; repaint 15097856 bytes detached 90ms against 200ms). Read out of
118 `/tmp/t8_ci.log`, not off a pipe's exit code.
119 - `make check` re-run on the COMMITTED tree — rc 0. The full CI run had started
120 about a minute before a final two-line comment reword in `endKey` landed, so
121 the committed bytes were re-gated rather than assumed. The reword is
122 comment-only and same-length, and `docscheck` is the gate that could have
123 noticed it.
124
125 ## Commit
126
127 One commit on `shape-first`:
128
129 06756d09 feat: a silent host keeps its panes, and only its own list may take one
130
131 The syntax fix was not split out — it is one line of the feature the branch was
132 mid-way through, not a change with a story of its own.
133
134 Not pushed, not merged, not installed.
135
136 ## Deviations
137
138 - The variant is `@"unreachable"`, not a rename to `dark` — the smallest diff, as
139 preferred in the brief. It appears at four use sites and reads fine.
140 - `CLAUDE.md`'s `Ctrl-\ x` bullet needed one clarifying phrase rather than only a
141 consistency check: "a tile that has never come up, which names no session to
142 end" was arguably false of a pending pane, which DOES name a session (the
143 sidecar's). The reason it drops is that it has no pump, so that is what the
144 bullet now says.
145 - The layout-sidecar bullet was left alone; it did not contradict anything.
RETRO.md
Old New
@@ -0,0 +1,138 @@
1 - 2026-08-22 agent-chan-timeout: the webhub filter read as contradicting the hub's own -A use case until the phase-2 issue showed the hub authors those frames; and the e2e leg's expect matched the pty ECHO of the typed needle, which only shows up once a wait is long enough to run the script ahead.
2 - 2026-08-22: wall "full of stuff" was not a config split but 45 dead tiles from hand-run perf/repro rigs using the real HOME; the make scripts isolate XDG_STATE_HOME, the scratchpad habit did not.
3 - 2026-08-22 in-band resize: three false oracles in a row — `wc -l` on a dump (trims blank rows), `stty size` as proof of SIGWINCH delivery (asks the kernel, not the app), and a stale `zig-out/bin` after `make ci` (which builds into .zig-cache) — plus `git checkout --` erasing the uncommitted fix with the mutation, exactly as the memory said it would.
4 - 2026-08-23 socket-path guess: e2e pins scenario ordinals by file position, so a leg added near the top renumbered eight narrative sentences — add legs at the end. Two background `make ci` runs reported exit 0 while make had failed: the notification reads the LAST command in the compound, not make.
5 - 2026-08-23 doc gate: the first budget shape (flag counts) passed green on a mutation that grew an already-flagged block — a count cannot see prose growing inside a flag it already knows about. Two hours later the same lesson landed twice: a `//!` header rule that skips headers, and a report nobody is accountable for, both "check that never ran" wearing new hats. Also lost a mutation test to a badly chosen target — mutating a decl that was ALREADY the file's only flag proves nothing, and read as "the gate is broken" for a minute.
6 - 2026-08-23 flake: the failing assert was buried under interleaved stderr from other tests, so the first read blamed a missing HOME that had nothing to do with it — read the stack line, not the nearest error text. Root cause took one termios dump: bash's readline owns the tty and eats `ESC [` as a numeric argument.
7 - Burning down a doc block is cheap; deciding what the *decl span* can afford
8 is the slow part. Drafting to a hand-counted byte target was wasted work —
9 the loop that paid was write-all, run `doc-report`, trim the three that
10 missed by under 20 bytes.
11 - The fan-out's reports never arrived — eight agents, two rounds of asking,
12 only idle notifications. The fragment FILES they were told to write carried
13 the entire usable result. Next fan-out: the deliverable is a path, not a
14 return value.
15 - I rebuilt docscheck into the scratchpad so agents could measure without a
16 build, and left `build.zig` out of the index that build.zig:492 explicitly
17 adds. It reported a tier-1 failure the real gate never would, and I relayed
18 it as a defect. A second implementation of the checker is a second thing
19 that can be wrong.
20 - The burn-down and the drift review are different passes and I nearly
21 conflated them. Making 279 comments shorter left all 31 lies in place —
22 including one crediting the symlink-unsafe xdg.makePrivateDir for code that
23 calls makeNewPrivateDir. Length is not truth.
24 - Verifying findings caught two bad ones: a 7-vs-8 byte bound that was only
25 right because param() maxes at 8, and a reviewer's own replacement text
26 claiming a torn cache leaves a permanently cold host when readCache refuses
27 it and the cold path rewrites it. Verify the FIX, not just the finding.
28 - I dispatched seven fix assignments for eight reviewers and left drift-proto
29 idle with two confirmed findings. Count the fan-out back in.
30 - 2026-08-23 predict local gate: worktree sandbox refused every compound Bash line (&&, pipes into tail), so each build/test went to a log file and a second call to read it; e2e needed three full runs for a count pin and a /tmp cleanup list that only the trap knows about.
31 - 2026-08-23 lost-session triage: reconstructed the session table from /proc and
32 per-name muxa probes because I never ran `muxd --help`, which lists `muxd
33 stats` — one no-attach query that prints every live session by name. Probing
34 by guess twice is the signal a capability exists; ask the binary before
35 building a workaround. README files it under "Everything else" as wire stats,
36 so the capability is indexed under the wrong question.
37 - 2026-08-23 01ef82fa: two grunt runs (opencode lost on server restart; pi sandboxed with no egress/tmp so it could not build) — sandboxed grunts write diffs, they cannot run this repo's gates; verify RED/GREEN locally by mutation.
38 - 2026-08-23 chunk1 e2e rounds 7-11: two days of green legs were luck, not health — the CLI-wall expect bet on a paint order the new painter never promised, and the dead-tile leg only ran once earlier failures stopped masking it; a suite that stops at first failure hides its own tail.
39 - 2026-08-24 chunk2 tasks 1-10: the brief "make check is your gate, leave e2e alone" pre-broke the suite for the next task, and the hand-written "N scenarios" echo hid a one-behind drift for four commits — a per-task gate must cover what the task can break, and a display restating a counter is a comment that rots; derive it.
40 - 2026-08-25 chunk3 delivery tail: the hands-on re-test stalled on my own demo command — argv on `mux wall` demotes it to an authored one-tile wall, so the user never saw the restore they were testing; the command you hand a tester is part of the deliverable, run it yourself first.
41 - 2026-08-25 add-tile prompt: two reviewers returned bare idle notifications (no report) — the first dispatch lacked the file-drop contract I already knew to demand; docscheck's span for a multi-line fn signature is the signature alone, and it bit two implementers before the brief said so; a scratchpad socket path is 112 bytes > sun_path, which cost one smoke run.
42 - 2026-08-25 max_sessions 4→32: the brief said "batch 7 under max_clients=8" — wrong table; dials contend for max_observers=4 and only the grunt running the helper against a real daemon caught it. A concurrency figure in a brief is a claim; have the implementer measure it.
43 - 2026-08-25 dead-tile marker: the user tested the fix before it was installed — I held `make install` for `make ci`. When the user is waiting to test, install first (reversible), gate before landing.
44 - 2026-08-25 landing: chained `rebase && branch -f && make ci` ran past a rebase conflict — CI started on a half-rebased tree and a branch got force-moved. Never chain past a rebase; one command, read its status, then act. Fixups written on later commits conflict on context when autosquashed early: fold the story instead.
45 - 2026-08-26 demo prep: the rig script assumed `muxa` could birth a session; it attaches at 0×0 and a 0×0 claim is a JOIN, so a named session came back "shell exited with 1" — the invariant was in CLAUDE.md, I had not connected it to setup; e2e's `fill_sessions` (piped `` into a headless `mux`) was the answer one grep away.
46 - 2026-08-26 web-restore: a worktree symlink (deps/zig) slipped into the first commit because .gitignore spells it `deps/zig/` — the trailing slash matches a directory, never a symlink. `git add -A` in a worktree is not safe on this repo; stage paths, or diff --stat against the ORIGINAL base before every commit.
47 - 2026-08-26 web-restore fix round: I asserted "one birth per connection" from reading my own code, never from watching a refusal — the daemon closes the fd on every refused attach, so the flag I cleared on .closed bounded nothing and `exit` forked a new shell. A comment stating a bound is a claim; run the path that would violate it before writing it down.
48 - 2026-08-26 web-restore land: merged on green gates + closed review; the user asked for a manual browser test AFTER the merge and it found a resurrection the suite cannot see — wsclient attaches 0×0 and never runs mux.js, so every mux.js behavior is untested by `make ci`. Drive the real client once per surface before merge; a fixture pins the protocol, not the page.
49 - 2026-08-26 web-exit-hold: swept the worktree symlinks into a commit AGAIN one round after writing a retro line about it — a retro line is not a guard. The fix is in .gitignore (`deps/zig/` with a trailing slash cannot match a symlink), not in my attention.
50 - 2026-08-26 upgrade chunk E: a mutation whose BUILD failed ran the previous binary and the leg went green — capture the build's exit before trusting any mutation result, and rebuild after every revert. Same family as checks-that-fail-green.
51 - 2026-08-26 upgrade autosquash: a fixup written at the tip spanned two commits (it changed a signature AND a later commit's call sites), so autosquash conflicted on the earlier target. Re-target to the LATER commit rather than resolving the conflict — the fixup is really a correction to the last thing it touches.
52 - 2026-08-26 upgrade demo: the full gate (80 e2e scenarios, unit tests, ci) was green on a feature that renamed every session but the last across an upgrade — every test upgraded a daemon holding ONE session, and one session cannot alias its neighbour. A by-value `slot.* orelse continue` made `name()` slice a dying stack copy. Demo before merge, always; and when a test fixture has N=1, the aliasing bugs are invisible by construction.
53 - relayout H1/H2: two rewrites of the H1 race test passed against the BUG — an invariant that only fires at quiescence had to replace one that never fired while relayouts were continuous. Phrase a race assertion for the quiet moment, not the busy one.
54 - 2026-08-26 review Mediums: four agents in worktrees, all green — but the brief's shared .zig-cache symlink cost an hour: one agent's ln -sfn ran with cwd=main and turned main's .zig-cache and deps/quic/{out,work} into self-links (85G cache gone, every build SymLinkLoop), a peer's GC evicted muxd under a running e2e, and one mutation went falsely green; plus the worktree tool cut branches 95 commits behind main. Per-worktree cache, ff-only first, merge-base check before pick.
55 - 2026-08-26 hygiene: `cd ../worktree && git merge` merged a branch into itself twice in one day and each time printed a believable tip; a merge is verified by a file existing in main, not by the log line. Also: make coverage had never finished — a green gate nobody runs is not a gate.
56 - 2026-08-27 cli-flags: a test that called usageExit wedged zig build test for 20 min — stdout is the build runner's IPC; hunt hangs by progress delta, not elapsed time
57 - 2026-08-27 cli-flags mux: a bare idle notification is not a report — the file drop carried it; e2e rc read from the log, not the task exit
58 - 2026-08-27 cli-flags rest: `git add -A` on a step commit swallowed the untracked RETRO.md into a refactor branch; stage the files the step names, never the tree
59 - 2026-08-27 cli-flags rest: rewriting a branch with rm/mv of the user's untracked file is classifier-blocked — do history surgery in a scratch worktree
60 - 2026-08-27 cli-flags reduce: the refactor was +141 src lines before the reduction pass took −60; measure per step, and budget the shrink as its own pass
61 - cliflags loop 2: the brief's home for `IdleMs` (`client.zig`) was unreachable — muxd sits at client's own layer and muxa below it. Reading `build.zig`'s layer table BEFORE writing the type would have saved a build round; the existing quic -> quic_client -> client re-export chain was the answer all along.
62 - 2026-08-27 cliflags loop 3: two comment edits blew the docscheck byte budget (mux_main 319b, main.zig 249b) and cost a rebuild each — the budget is bytes of doc block vs bytes of DECL, so a refactor that shortens a function body shrinks its comment allowance at the same time. Write the shorter comment first.
63 - 2026-08-27 cli-flags loops: three survey-driven reduction loops (−60/−21/−40) paid back a +141 refactor; the third survey rejected 12 of 19 candidates on numbers — that is the signal the loops are dry
64 - 2026-08-27 v0.0.1-15: smoked dist/release/bin that does not exist — RELBIN is dist/vX; read the Makefile var before hand-smoking
65 - 2026-08-27 daemon wedge: the blocking readFrame in the pump was named as a hazard in a comment (server.zig POLLOUT arm) long before it bit — a comment that says "this would hang" is a test that was never written; and the plan put setCloexec inside initFromManifest, which broke rollback (old binary re-exec'd without its fds) — the right placement was found by running the upgrade e2e group, not by reading
66 - 2026-08-27 nonblocking frames: the review's 'two hand-kept fd lists' finding cost more to argue than to fix — close_range in the child retired the class in 6 lines; ask 'what needs no list' before adding a second list.
67 - 2026-08-27 (task 4, wall-of-hosts): an unanchored inverse sed reverting a mutation rewrote six unrelated `return true;` lines — anchor the revert on the whole function body, not on the mutated line alone.
68 - 2026-08-27 (task 4 fix round): a mutation revert built on str.replace with an EMPTY replacement inserted two lines at byte 0 and silently broke three later mutants; snapshot the file's bytes and write them back, never reverse-replace.
69 - Task 5 (wall of hosts entry): the pipe-captured wall never paints a second host's stripe, so a headless capture read as 'otherHosts did not run' — three runs lost before a real pty (ptyclient) settled it in one. Reach for the pty fixture the moment a wall claim is about layout.
70 - Task 6 (wall-of-hosts chords): the two-commit split cost a full extra `make check` cycle because the compiler forced one deletion (`forget_err` unused) into the feature commit; filtering the diff into two patch files and verifying commit 1 under `git stash --keep-index` was the only way to know it built. Splitting by hunk after the fact is slower than sequencing the edits in commit order up front.
71 - Task 7 (wall-of-hosts e2e): a whole afternoon went on inferring TWO product bugs from failing legs before measuring them. The decisive move both times was cheap and late — run the SAME e2e group against the installed v0.0.1-15 client (pass) and the branch client (fail), then bisect the difference with a 30-line hand rig. Reach for the last known-good binary as the second step after a leg fails, not the twentieth.
72 - Task 7 (rulings round): the lead's "bisect, don't hypothesise" was right for the WRONG reason I expected — bisecting the SYMPTOM (a quic snapshot count) is confounded by every other change on the branch, but bisecting the ROOT behaviour I had already measured ("does a piped mux write a layout sidecar?") took four steps and named the commit. Pick the bisect predicate from what you have measured, not from what failed.
73 - Task 7 (fix round 1): a hand rig ran against a stale zig-out left by git bisect and attached three times to the developer's LIVE daemon, because bare `mux` on a binary without `hosts` falls through to $XDG_RUNTIME_DIR/muxd.sock. Two habits, not one: `zig build` before any rig (make ci builds .zig-cache, not zig-out), and export XDG_RUNTIME_DIR to scratch in every rig, not just XDG_STATE_HOME — the fallback path is the one that finds the real daemon.
74 2026-08-28 fix wave: wallview's docscheck budget is EXACT and only falls — a one-line struct decl is exempt from tier 3, so widening HostSpec into a multi-line struct with a field doc cost 163 bytes I then had to win back. Check the shape of a decl before adding a field to it.
75 - 2026-08-28 wall-of-hosts: 8 tasks, 10 fix rounds, 23 rulings; what slowed us was the plan carrying line numbers and code that drifted under each task (three plan defects found by reviewers, one by the compiler), and messages crossing with implementers mid-round — the ledger, not memory, is what kept it straight.
76 - 2026-08-28 demo rig: a unix socket path caps at 107 bytes, so the scratchpad is too deep for XDG_RUNTIME_DIR; and test/ptyclient execs argv[0] verbatim (absolute path, no PATH) — two silent exits before the wall opened.
77 - Task 1 (stripes go): docscheck's tier 3 counts only blocks HEAVIER than their decl, so deleting a field's checks LOWERED a decl and pushed a fine comment over budget — the figure went up, not down. Deleting code can raise a prose budget; check doc-report before assuming which way it moves.
78 - Task 1 fix round: inserting a function directly ABOVE another silently gave my new fn the neighbour's doc comment and left the old one undocumented — docscheck caught it as a 561-byte block, not as the lost rationale it really was. Insert below, or move the comment with the function.
79 - Task 2 (daemon lives until muxd stop): the only single-threaded way to observe "the pump did NOT end" is the pump's own return value — which the change deletes. Cost a rethink to land on run() on a thread with the assertions made over the socket.
80 2026-08-28 host picker: two full e2e runs (~50 min each) because a late fix landed mid-run — the suite's cost makes 'edit while it runs' a false economy; and the popup's 1Hz repaint silently broke every ptyclient `settle` until the frame was hashed.
81 2026-08-28 picker fix round 1: two review findings (clear the auto flag on a user close / keep the popup once on an empty wall) shared one boolean and cancelled each other — the e2e caught it in 40s, the review could not. Split the flag, name the failure in the test.
82 2026-08-28 picker fix round 2: my round-1 guard read a flag the other thread owns instead of asking the object whose state it was — and armed a retry with no lifetime. Both invisible to check + two e2e groups. Guards belong in a named function with a mutation-checked test, not inline in a thread loop.
83 2026-08-28 picker fix round 3: my round-2 guard ran only on the claim's REFUSAL, so it missed the arm that mattered — the one retried after the popup closed. Two rounds lost to guarding a sub-case. And a guard inline in a detached thread's loop cannot be mutation-checked at all: making claimFocus the one door was what made the pin bite.
84 - 2026-08-28 fixwave: the m3 control assertion failed first time because the notice
85 survives the call by design — the test had to consume it the way the pump does.
86 - 2026-08-28 host picker: three fix rounds on one focus-claim guard, every round green on every gate — a guard inside a detached thread's loop is neither reviewable nor testable; what stuck was one door (claimFocus) plus a mutation check on the production line, not a better comment.
87 - 2026-08-28 no-autostart: docscheck tier 3 bounced three commits before I
88 internalised that it weighs a `///` block against the decl's CODE bytes only
89 — comments in the body are free, so rationale belongs there and the doc
90 block has to stay under ~2 short lines for a small fn. Second drag: renaming
91 `report_fallback` to `asked` was not a rename, because `pumpTile` cleared it
92 before the FIRST dial; the flag had to be re-plumbed (spent by `dial`, unset
93 on the stored spec) before a picker Enter could reach `openHandoff` at all.
94 - 2026-08-28 no-autostart: the review found the bug moved (poll → picker-born tile redial) because 'asked' was spent on a copy no redial reads; a flag cleared downstream is one a new road misses — clear at the construction site.
95 - 2026-08-28 no-autostart demo: I recorded the user's finishing answer as "merge" when it is "demo" — a standing answer is a memory to confirm, not infer; the demo rig's first run died on a 120-byte socket path (scratchpad paths exceed the 107-byte unix cap — short XDG_RUNTIME_DIR, always).
96 - 2026-08-28 client fix-round: 20 min lost to two 10-minute `zig build test`
97 hangs. A unit test asserted `hostsMain(… "--help") == 0`, and `cliflags.help`
98 writes the usage to STDOUT_FILENO — which under the build runner IS the test
99 runner's protocol stream, so the runner waited forever. A wedged test step
100 prints nothing, so it reads as "slow build". mux_main's own `printOut`
101 comment already records the rule; I did not read it before writing the test.
102 Assertions about anything a binary prints to fd 1 belong in e2e.
103 - Server fix-round: proving H1 red cost a 7-minute wedged `zig build test` (RC=124, zero output) — the only signal a blocking-write hang gives. Worth budgeting a `timeout` around any red run whose failure mode is a hang, not an assertion.
104 - 2026-08-28 tests fix-round: the review's cheap-looking L1 (`[refused]` e2e) cost three runs to place — a picker birth is refused by the WALL (rows/aspect) long before the daemon ever sees it, so the leg only exists on a tall narrow terminal against a full CLIENT table. Read which layer refuses before writing the fixture.
105 - step2a folder move: the folder rule the brief specified is broken by the folder map the same brief fixed — client.zig imports interact.zig, and both placements were binding. Cost a round of thinking to resolve as a signed exemption rather than a silent weakening; a brief that states a rule and a layout should say which one yields.
106 step2b (wallview split): Zig refuses a local that shadows a file-level decl, which is what made a mechanical name-qualifier safe — but NOT for a nested container: a struct method named `run` became `fn wv.run`. Guard declaration sites, and prove the generator by round-tripping it to a byte-identical original before trusting one line of its output.
107 - 2026-08-28 (one binary): the fork-into-run mechanism the brief specified cost ~2h — it crashed every Debug daemon through a private pid cache in std.debug.MemoryAccessor, and the panic ate its own stack trace, so the diagnosis needed strace + coredumpctl rather than the log. What would have saved it: running the product by hand ONCE right after the change, before the e2e told me at scenario 21.
108 - 2026-08-29 09_hosts flake: two defects hid behind one symptom line; the byte ORDER of a failing capture (grep -aob) named the race in minutes, the src diff read could not — and a loop script that globs /tmp residue without a pid key moves other agents evidence (twice).
109 - Step 4 (ssh as argv): the doc gate caught three dead `handoff.sshLine` citations I would have shipped; grepping for the symbol before renaming would have been cheaper than the check round-trip.
110 - 2026-08-29 fix round: two comment-budget stalls cost a check cycle each — a new `///` block outweighs a small decl and a header edit crossed the 1024b cap; rationale that belongs to a mechanism goes in `//` inside the body, and the doc gate says so before I do.
111 - 2026-08-29 simplify: slowed by (1) a stale .xversion/new prefix — `make xversion` never rebuilds, one false red; (2) fork-into-run crashing on std.debug's cached pid, found only after a daemon died minutes in; (3) a pre-existing wall paint race the one-binary timing exposed as a 3/12 flake; (4) src LOC went UP under a 'reduce LOC' commission — every rule, pin and split costs preamble, and the only big win was docs.
112 2026-08-29 chunk B: docscheck tier 3 fired three times on decls that SHRANK (a shorter qualifier, a folded body) — the budget is a ratio in disguise, so every fold costs a prose cut nobody planned for; and the one mutation that proves the blocked-stream retry wedges the runner for 10 minutes instead of failing, so verifying it costs a full timeout.
113 2026-08-29 chunk C: docscheck tier 3 measures a decl by its SIGNATURE when the params are one-per-line — so a multi-line signature caps its own doc at ~110 bytes however long the body is, and three folds had to trim rationale that the body would have paid for on one line; and a blind `&w` -> `w` regex silently ate the address-of in `&w.tiles[at]` and rewrote ten comments, which only the compiler caught — substitute inside a function body by code line, never file-wide.
114 - chunk D1: docscheck's tier-3 span stops at a multi-line signature's last argument, so a doc block over a wrapped `pub fn` is measured against ~99 bytes of declaration; the fix that keeps the prose is a single-line signature, and I lost a round finding that out. dup2.py's product/test split is also wrong for webhub.zig — unbalanced braces inside JSON string literals keep its depth counter off zero, so every test in that file counts as product.
115 2026-08-29 dedup: slowed by (1) docscheck tier-3 shaping code — three fix rounds were about prose relocated to dodge an exact byte budget, and the gate measures a wrapped signature wrong (issue 0025a395); (2) a mechanical rename rewrote four user-visible notices and stayed green through ci+xversion — no notice was byte-pinned; (3) area-partitioned finders double-counted (1,780 claimed → 730 verified) until a collator grepped the whole tree; (4) a background `make ci` was SIGTERMed mid-e2e — foreground with a 600s timeout is the only reliable gate.
116 - 2026-08-29 D2 (retire wall file): the brief contradicted the invariant on a stopped daemon (step 7 vs 'unreachable removes nothing') — write the e2e steps FROM the invariant list, not from intuition; the implementer caught it, a reviewer would have too, but a self-consistent brief costs nothing.
117 - 2026-08-29 collab triage: 48 issues, 7 dead — every one named a symbol the src/ split or D2 removed; a title that names a file path rots on the next move, name the behaviour
118 - 2026-08-29 handoff --start: two mutations passed GREEN for reasons that had nothing to do with the code. (1) `grep -oE "^error: '[^']*' failed"` misses every test whose NAME contains an apostrophe — three mutation rounds read as "the test does not bite" when it did; grep for the test name, not for a quoted pattern. (2) `std.testing.expectError` renders the success value with `{any}`, and a live `Transport` holds an allocator vtable that formatting FAULTS on: the runner died inside its own message, named no test, and took every later test in that binary with it — a caught mutation that looks like an unrelated crash. Also: a one-line usage addition broke an e2e panic-bound 11 groups away (complaint + usage <= 15 lines); the usage literal is load-bearing, grep the suite for it before growing it.
119 - 2026-08-29 handoff --start fix round: a leg's cheap first assertion (exit code) MASKED its OS-side ones under mutation — "the mutation is caught" is not "the oracle bites". To find out which assertion owns a claim, relax the ones above it and re-run; here rc, then (a) pid, then (c) default-socket each fired in turn, and only (c) named the actual defect.
120 - 2026-08-29 handoff --start round 2: deleting a guard silently invalidated a comment two decls away — `announceOrStart`'s `start_argv.len == 0` check was what made "empty means nothing to start" TRUE, and the fold took the guard and left the sentence. When a fold deletes a condition, grep for the prose that condition was holding up. Also: widening a rule (`quiet` from one spawn to every spawn) does not widen the test that pins it — the loop has to gain the dimension too.
121 - 2026-08-29 handoff --start: the brief spelled the ensure's argv as a literal (`&.{}`) instead of naming the existing helper's shape (ensureForAttach forwards --sock); the implementer built the literal faithfully and it was a stray-daemon bug — name the sibling, not the bytes
122 - 2026-08-29 handoff step table: the SCENARIO pin and the CONVERGENCE pin are two numbers in the same file, and adding one leg moves both — `E2E_ONLY=04_handoff` never checks either (it prints "the pin is the whole suite's"), so a group-scoped run went green and the full `make ci` cost 7 minutes to say "38, the pin says 37". Bump both pins the moment an `assert_converged` lands. Also: an `s.field.?` in a test PANICS rather than failing, which kills the binary and hides every later test that names the same defect — `x != null and eql(x.?, …)` keeps the catch legible (this is the same shape as last round's expectError fault). And a policy extracted into a table is not free: the driver grew 27 product lines while the table cost 135, so "roughly flat" was wrong by 162 — testability was the purchase, not brevity.
123 - 2026-08-29 handoff step table, fix round: (1) `git rebase -i --autosquash` on a THROWAWAY branch also rewrote the real branch — git >=2.38 `rebase.updateRefs` moves every ref pointing inside the rebased range, and the branch I was protecting pointed at the temp branch's own tip; verify with `git branch -v` after any rebase, not just `git log`. (2) A `--fixup` cannot target a commit whose successor uses the fixed-up lines as diff CONTEXT: two of six comment blocks had to be demoted from the table commit to the row commit, and the first autosquash found it as a `UU` conflict, not the review. (3) The review's "delete 33 comment lines to save 33 product lines" was arithmetic against the wrong model — `dup2.py` skips every `//` line, so the comment count and the product count are disjoint; when a size finding cites a tool's number, read that tool before believing the projection.
124 - 2026-08-29 handoff table: the hands-on rig named its host 'box', so the warm QUIC dial failed DNS and read as a product bug for one run — a demo host must resolve (127.0.0.1), the e2e already knew
125 - 2026-08-29 handoff demo: rigs isolated XDG_STATE_HOME and XDG_RUNTIME_DIR but not XDG_CACHE_HOME — the per-host QUIC cache leaked 4 throwaway entries into ~/.cache/mux/hosts and a demo 'cold' attach warm-hit another rig's daemon; isolate every XDG dir mux reads (state, runtime, config, cache)
126 - 2026-08-29 publish: server already held v16 (published by this account 76 s earlier, not this session) — `release publish` refused; had to download the tarball by guessing the URL and cmp the binary to prove it was the same build. A `git collab release fetch` verb, or `publish` printing the server sha of the BINARY, would have made it one command.
127 - 2026-08-29 askpass: two hours went to an e2e whose tiles came up over QUIC. The first poll's `mux d endpoint` announced a port and key, the client cached it, and every dial after that was a UDP handshake with no ssh in it — so the fixture was green about a prompt that was never raised. A fixture that exercises the ssh path has to make the remote unable to announce; "the shim ran once" was the only symptom, and it read as a broken shim for far too long.
128 - 2026-08-29 askpass fix round: a mutation that does not fire can mean the LEG is switched off, not that the code is right — `tiles[0].pre = null` passed green because the entry-dial leg ran on a pipe, where no listener exists at all. A leg asserting that a mechanism was NOT used has to run where the mechanism is armed, or it agrees with itself.
129 - 2026-08-29 ssh-reason/askpass: three demo failures were all rig, not product (no session until an attach; keys before first attach lost; config-home-as-dir gave the remote a key and silently removed the prompt path); the e2e report had warned about the last one — read the report's CONCERNS before writing a rig. pgrep -f self-match killed the tool shell a second time: ps|grep -F|grep -v grep, always.
130 - 2026-08-30 git-collab: `issue open` prints auto-sync ref names, not the new id; I read an archive ref's prefix as the id and labeled a closed issue twice (4 syncs to undo). Take the id from `issue list | grep -F <title>`.
131 - 2026-08-30 retro: responsibility bled (sun_path rule in 5 places, 3 daemon starters for 2 callers) because we never named who OWNS a noun before changing code; review checked each hunk's correctness, never its shape. Trial: next feature in 3 worktrees — control / ownership-map-first / inline-no-subagents — judged on net lines, pub names ±, rule-sites, user's read.
132 - 2026-08-30 ways-of-working trial: map-first caught the 2nd fork + log mode the brief missed; a map row that asserts code ("same connect") shipped a bug — verify map rows like review findings
133 - 2026-08-30 ownership map adopted as the method; the map row itself is the thing to verify first (one false row = one shipped bug)
134 - 2026-08-30 ssh-reason+askpass rebase: the only conflicts were prose and one test argv, but I still checked out the branch before reading its CLAUDE.md — the file swapping under me (`d run` vs `d start -d`) cost a re-read. Diff the doc bullets across the two tips first, then rebase.
135 - Task 8 (comment-essay ratchet): the tool's own //! header sat 5 bytes under its 1024 cap, so documenting a new tier meant trimming two sentences first — a gate whose contract has no room left to state the gate.
136 - 2026-08-30 module-collapse: two plan claims died on contact with the compiler (probe grep wording; relative imports cannot leave a module root dirname) — spike one-file probes of toolchain claims before writing them into step text.
137 - 2026-08-30 shape-first: a stale .zig-cache mux binary reproduced the OLD behavior perfectly in the hands-on rig; and rail_cols reads a pane's label bar as a rail — both cost a debug loop each
138 2026-08-30 evidence-only silence: `unreachable` is a Zig keyword, so the handed-over State field would not parse — `@"unreachable"` fixed it in one sed, but the 5-minute lesson cost more later: the settle's replacement comment was five lines, which tripped docscheck's block ratchet at the END of the run rather than when it was written. Bigger drag was serial verification: six mutations, each a full `zig build test` or a 2-minute e2e group, then a ~35-minute `make ci` I polled instead of trusting the monitor.
docs/superpowers/plans/2026-08-30-module-collapse.md
Old New
@@ -0,0 +1,442 @@
1 # Module-Table Collapse Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Collapse mux's 41-entry build module table to ~15: six domain modules whose child files are relative imports, shared leaves stay tabled.
6
7 **Architecture:** A module in the table = an independently owned component; a file consumed by one domain only becomes a relative `@import("file.zig")` child of that domain's root, reachable from outside (when needed) via `pub const` re-exports on the root. Zig's one-file-one-module rule then blocks cross-domain file grabs at compile time. One file moves (`predict.zig` → `src/tui/`), making "prediction is an overlay" structural: `term` cannot import `wall`.
8
9 **Tech Stack:** Zig 0.15.2 (vendored: `deps/zig/zig`; system zig will NOT build this). Gates: `make check` per task, `make ci` + `make xversion` at the end.
10
11 **Spec:** This plan is its own spec. The agreed end state:
12
13 | Module | Root | Children (leave the table, become relative imports) |
14 |---|---|---|
15 | `term` | `src/engine/term.zig` (new) | `protocol` `engine` `delta` `replica` |
16 | `daemon` | `src/server/server.zig` | `quic_server` `cmd` `shellint` `upgrade` (+ `server_agent` `server_sessions`, already children) |
17 | `client` | `src/client/client.zig` | `client_core` `hosts` `handoff` `layout` `keymap` `askpass` |
18 | `wall` | `src/tui/wallview.zig` | `interact` `paint` `select` `predict` (+ `wall_*`, already children) |
19 | `agent` | `src/cli/muxa.zig` | — |
20 | `mux` | `src/cli/mux.zig` | `main.zig` `mux_main.zig` `webhub_main.zig` `webhub.zig` |
21
22 Shared leaves stay tabled (≥2 domains consume them): `quic` `proxy` `xdg` `sockpath` `spawn` `pty` `cliflags` `testtmp`. Test fixtures stay tabled: `script` `rawmode` `delaypipe` `render` `ptyclient` `wsclient`.
23
24 Final production import lists (this IS the layer graph):
25 - `term` → nothing (plus the `ghostty-vt` dep edge, wired outside the table)
26 - `daemon` → `term` `pty` `quic` `proxy` `xdg` `sockpath`
27 - `client` → `term` `quic` `xdg` `sockpath`
28 - `wall` → `term` `client` `spawn` `proxy` (hosts/handoff/layout/askpass/keymap/core are reached as `@import("client").X`; proxy for ignoreSigpipe — ruled in final review, the header had omitted what Task 1's own row spec included)
29 - `agent` → `term` `quic` `xdg` `sockpath` `cliflags`
30 - `mux` → `term` `daemon` `client` `wall` `agent` `quic` `proxy` `xdg` `sockpath` `spawn` `cliflags`
31
32 ## Global Constraints
33
34 - Build ONLY with `deps/zig/zig` (the Makefile already points at it). Never system zig.
35 - `make check` green before every commit; capture `$?` before piping (`make check; echo rc=$?`).
36 - NO directory reorganization. One `git mv`: `src/engine/predict.zig` → `src/tui/predict.zig`. Every other file stays put.
37 - `docscheck.budget` keys are basenames — the predict move needs no budget edit. New files (`term.zig`) need a budget line; if `zig build check` reports a mismatch after comment edits, run `deps/zig/zig build doc-report 2>&1 | tail -20` and set the line to the reported figure. Lowering is free; raising an existing file's figure is forbidden without user sign-off.
38 - Comments say *why*, not *how*. When editing import blocks, keep existing comments attached to their imports. `zig build check` verifies comment symbol refs resolve.
39 - `test_order` in build.zig must cover every `mod_table` row exactly once (comptime-enforced) — every row you delete from the table must also leave `test_order`.
40 - The layer check is comptime: imports point strictly downward. When a row gains an import, its `.layer` must exceed the dep's. Renumber only in the touched rows; final renumbering happens in Task 6.
41 - Commit per task, message style matches the repo (`refactor: <narrative subject>`).
42 - A unit test that writes to stdout wedges `zig build test` silently — if `make check` hangs with no output for minutes, suspect that first.
43
44 ---
45
46 ### Task 0: Branch
47
48 - [ ] **Step 0.1:** `git -C /home/xanderle/code/rad/mux checkout -b modules` (from main, which must be clean apart from RETRO.md).
49
50 ---
51
52 ### Task 1: Wall collapse
53
54 `interact`, `paint`, `select` become child files of the `wallview` module; `predict.zig` moves to `src/tui/` and becomes a child too. Rename the module `wallview` → `wall`.
55
56 **Files:**
57 - Move: `src/engine/predict.zig` → `src/tui/predict.zig` (git mv)
58 - Modify: `src/tui/wallview.zig`, `src/tui/interact.zig`, `src/tui/wall_picker.zig`, `src/tui/wall_layout.zig`, `src/tui/wall_pump.zig`, `src/tui/wall_test_pump.zig`, `src/tui/wall_test_wall.zig`, `src/tui/wall_test_picker.zig`, `src/cli/mux_main.zig` (imports `wallview` by name), `build.zig`
59 - Also check: `grep -rn '@import("wallview")' src test` and rewrite every hit to `@import("wall")`.
60
61 **Interfaces:**
62 - Consumes: current table rows `interact`, `paint`, `select`, `predict`, `wallview` (build.zig:220–294 region).
63 - Produces: table row `.{ .name = "wall", .path = "src/tui/wallview.zig", ... }`; no re-exports needed (nothing outside tui consumes interact/paint/select/predict).
64
65 - [ ] **Step 1.1: Move predict**
66
67 ```bash
68 git mv src/engine/predict.zig src/tui/predict.zig
69 ```
70
71 - [ ] **Step 1.2: Rewrite name-imports to relative imports inside src/tui/**
72
73 Every tui file importing a now-child sibling switches to the `.zig` relative form. Exact current sites (re-grep before editing; line numbers drift):
74
75 ```bash
76 grep -rn '@import("interact")\|@import("paint")\|@import("select")\|@import("predict")' src/tui/
77 ```
78
79 In each hit, change `@import("interact")` → `@import("interact.zig")`, `@import("paint")` → `@import("paint.zig")`, `@import("select")` → `@import("select.zig")`, `@import("predict")` → `@import("predict.zig")`. Sites as of writing: wallview.zig:29,30,35; interact.zig:26,30,32; wall_picker.zig:12; wall_layout.zig:8; wall_pump.zig:10; wall_test_pump.zig:5; wall_test_wall.zig:5; wall_test_picker.zig:5.
80
81 - [ ] **Step 1.3: Chain the new children's tests from the root**
82
83 `src/tui/wallview.zig` ends with a `test { ... }` block (currently ~line 2416) listing `wall_test_*`. Add the four new children so their `test` blocks stay in the suite:
84
85 ```zig
86 test {
87 _ = @import("interact.zig");
88 _ = @import("paint.zig");
89 _ = @import("select.zig");
90 _ = @import("predict.zig");
91 _ = @import("wall_test_harness.zig");
92 // ... existing lines stay ...
93 }
94 ```
95
96 - [ ] **Step 1.4: Edit the module table**
97
98 In `build.zig`:
99 1. Delete rows `interact` (line ~248), `paint` (~220), `select` (~231), `predict` (~170).
100 2. Rename the `wallview` row to `wall` and merge in the deleted rows' import needs (interact consumed `replica`, `client_core`, `keymap`; paint consumed `engine`, `protocol`). New row:
101
102 ```zig
103 .{ .name = "wall", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "hosts", "handoff", "proxy", "engine", "replica", "client_core", "keymap", "layout", "askpass", "spawn" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
104 ```
105
106 3. Remove `interact`, `paint`, `select`, `predict` from `test_order` (~line 816); rename `wallview` → `wall` there.
107 4. `grep -n 'idxOf("wallview")\|idxOf("interact")\|idxOf("paint")\|idxOf("select")\|idxOf("predict")\|"wallview"' build.zig` — rewrite every named-handle reference to the surviving names (`idxOf("wall")`); delete wiring that only served deleted rows.
108 5. `src/cli/mux_main.zig` row (`client_main`, ~307): change `"wallview"` → `"wall"` in its imports; change the source line `@import("wallview")` → `@import("wall")` in `src/cli/mux_main.zig`.
109
110 - [ ] **Step 1.5: Gate**
111
112 ```bash
113 make check; echo rc=$?
114 ```
115 Expected: `rc=0`. If docscheck complains about `predict.zig`'s folder flags, re-read the error — budget keys are basenames, so a failure here is something else; fix what it names.
116
117 - [ ] **Step 1.6: Commit** (BEFORE the mutation probe — a probe against an uncommitted tree cannot be reverted safely; the baseline must be committed first)
118
119 ```bash
120 git add -A && git commit -m "refactor: the wall owns its interaction loop, painter, selector and overlay as child files"
121 ```
122
123 - [ ] **Step 1.7: Watch the new guard fail once (mutation check, never committed)**
124
125 The collapse's safety claim: cross-domain file grabs are compiler-blocked. Prove it fires:
126
127 ```bash
128 echo 'const sneak = @import("../client/hosts.zig");' >> src/tui/interact.zig
129 make check 2>&1 | grep -i 'file exists in modules' ; echo probe=$?
130 git checkout -- src/tui/interact.zig
131 git status --porcelain src/tui/interact.zig
132 ```
133 Expected: the grep finds Zig's file-in-multiple-modules error (probe=0), and the final status prints nothing (mutation gone). If probe=1, the guard did NOT fire — stop and investigate before proceeding; do not continue on an unproven safety claim.
134
135 ---
136
137 ### Task 2: Daemon collapse
138
139 `cmd`, `shellint`, `upgrade`, `quic_server` become children of the `server` module; rename it `daemon`. `pty` stays tabled (the `ptyclient` fixture consumes it). The root re-exports `quic_server` and `upgrade` for `src/cli/main.zig`.
140
141 **Files:**
142 - Modify: `src/server/server.zig`, `src/server/server_test_quic.zig`, `server_test_agent.zig`, `server_test_harness.zig`, `server_test_session.zig`, `server_test_upgrade.zig`, `server_test_await.zig`, `src/cli/main.zig`, `build.zig`
143 - Also re-grep: `grep -rn '@import("server")\|@import("cmd")\|@import("shellint")\|@import("upgrade")\|@import("quic_server")' src test`
144
145 **Interfaces:**
146 - Produces: table row `daemon` rooted at `src/server/server.zig`; re-exports on the root:
147
148 ```zig
149 // Re-exported for the daemon's own main (src/cli/main.zig) — the only
150 // consumer outside this folder; nobody else may know these exist.
151 pub const quic_server = @import("quic_server.zig");
152 pub const upgrade = @import("upgrade.zig");
153 ```
154
155 - [ ] **Step 2.1: Rewrite src/server/ name-imports to relative**
156
157 Sites as of writing: server.zig:16 (`cmd`→`cmd.zig`, keep the local alias `cmdmod`), server.zig:17 (`shellint`), server.zig:23 (`quic_server`), server.zig:25 (`upgrade`); server_test_quic.zig:5, server_test_agent.zig:4, server_test_harness.zig:6, server_test_session.zig:5 (all `quic_server`); server_test_upgrade.zig:3 (`upgrade`); server_test_await.zig:3 (`shellint`). Same transformation as Task 1: `@import("X")` → `@import("X.zig")`.
158
159 - [ ] **Step 2.2: Add the two re-exports to server.zig** (code block above, near the existing top-of-file imports) **and chain the children's tests** into server.zig's closing `test {}` block (~line 3600):
160
161 ```zig
162 _ = @import("cmd.zig");
163 _ = @import("shellint.zig");
164 _ = @import("upgrade.zig");
165 _ = @import("quic_server.zig");
166 ```
167
168 - [ ] **Step 2.3: Point main.zig at the re-exports**
169
170 `src/cli/main.zig`: line ~12 `const quic_server = @import("quic_server");` → `const quic_server = @import("daemon").quic_server;`; line ~18 `const upgrade = @import("upgrade");` → `const upgrade = @import("daemon").upgrade;`; and its `@import("server")` → `@import("daemon")`.
171
172 - [ ] **Step 2.4: Edit the module table**
173
174 1. Delete rows `cmd` (~193), `shellint` (~211), `upgrade` (~204), `quic_server` (~165); remove the four from `test_order`.
175 2. Rename row `server` → `daemon`; its children's deps fold in (cmd: `engine`+`protocol` — already present; shellint: `xdg` — present; upgrade: `protocol` — present; quic_server: `quic` — present). Drop `cmd`, `shellint`, `upgrade`, `quic_server` from its imports:
176
177 ```zig
178 .{ .name = "daemon", .path = "src/server/server.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "pty", "protocol", "delta", "sockpath", "quic", "xdg", "proxy" }, .test_imports = &.{ "replica", "testtmp" }, .quic_tests = true },
179 ```
180
181 3. `daemon_main` row (~279): imports drop `quic_server`, `upgrade`; `"server"` → `"daemon"`.
182 4. `grep -n '"server"\|idxOf("server")\|idxOf("quic_server")\|idxOf("upgrade")\|idxOf("cmd")\|idxOf("shellint")' build.zig` — fix every handle. Note build.zig:594's test-sibling group table names `src/server/server.zig` by path — unchanged.
183
184 - [ ] **Step 2.5: Gate and commit**
185
186 ```bash
187 make check; echo rc=$?
188 git add -A && git commit -m "refactor: the daemon owns its command surface, shell integration, upgrade and QUIC arm as child files"
189 ```
190
191 ---
192
193 ### Task 3: Client collapse
194
195 `client_core`, `hosts`, `handoff`, `layout`, `keymap`, `askpass` become children of `client`; the root re-exports all six (every one has an outside consumer). The wasm canary and `wasm_core.zig` switch to relative imports; build.zig's wasm wiring drops the two dead twins.
196
197 **Files:**
198 - Modify: `src/client/client.zig`, `src/client/wasm_core.zig`, `src/client/client_core_wasm_check.zig`, `src/tui/wallview.zig`, `wall_picker.zig`, `wall_layout.zig`, `wall_host.zig`, `wall_pump.zig`, `interact.zig`, `wall_test_layout.zig`, `wall_test_harness.zig`, `wall_test_host.zig`, `wall_test_pump.zig`, `wall_test_picker.zig`, `src/cli/mux.zig`, `src/cli/main.zig`, `src/cli/mux_main.zig`, `src/cli/webhub_main.zig`, `build.zig`
199
200 **Interfaces:**
201 - Produces on `src/client/client.zig`:
202
203 ```zig
204 // The client link's public seams: the wall, the hub and the mains reach
205 // these as client.X — the table stays one row, the files stay children.
206 pub const hosts = @import("hosts.zig");
207 pub const handoff = @import("handoff.zig");
208 pub const layout = @import("layout.zig");
209 pub const keymap = @import("keymap.zig");
210 pub const askpass = @import("askpass.zig");
211 pub const core = @import("client_core.zig");
212 ```
213
214 (`core`, not `client_core` — `client.client_core` stutters; every outside consumer spells `@import("client").core`.)
215
216 - [ ] **Step 3.1: Inside src/client/: name → relative**
217
218 client.zig:20 `keymap`, :24 `handoff`, :25 `askpass`, :26 `hosts` → `.zig` forms. wasm_core.zig:23 `keymap`, :25 `client_core` → `.zig` forms. client_core_wasm_check.zig:1 `client_core` → `client_core.zig`. Re-grep first:
219
220 ```bash
221 grep -rn '@import("client_core")\|@import("hosts")\|@import("handoff")\|@import("layout")\|@import("keymap")\|@import("askpass")' src
222 ```
223
224 - [ ] **Step 3.2: Outside consumers → re-exports**
225
226 For every hit OUTSIDE `src/client/` from the same grep, rewrite (adding `const client = @import("client");` to the file's import block if absent — wall files: it's the module the wall already imports):
227 - `@import("hosts")` → `@import("client").hosts`
228 - `@import("handoff")` → `@import("client").handoff`
229 - `@import("layout")` → `@import("client").layout`
230 - `@import("askpass")` → `@import("client").askpass`
231 - `@import("keymap")` → `@import("client").keymap` (interact.zig:35 imports `.detach_key` — becomes `@import("client").keymap.detach_key`)
232 - `@import("client_core")` → `@import("client").core`
233
234 Sites as of writing: wallview.zig:23,24,25,36; wall_picker.zig:10,11,13; wall_layout.zig:7,9; wall_host.zig:9,10; wall_pump.zig:9; wall_test_layout.zig:4,5; wall_test_harness.zig:7; wall_test_host.zig:5; wall_test_pump.zig:7; wall_test_picker.zig:6; interact.zig:27,31,35; mux.zig:17; main.zig:16; mux_main.zig:22,25; webhub_main.zig:16.
235
236 - [ ] **Step 3.3: client.zig root additions**
237
238 Add the re-export block (Interfaces above) and chain child tests into client.zig's `test {}` block (grep `test {` in client.zig; create the block at file end if none):
239
240 ```zig
241 _ = @import("client_core.zig");
242 _ = @import("hosts.zig");
243 _ = @import("handoff.zig");
244 _ = @import("layout.zig");
245 _ = @import("keymap.zig");
246 _ = @import("askpass.zig");
247 ```
248
249 - [ ] **Step 3.4: Edit the module table**
250
251 1. Delete rows `client_core` (~161), `hosts` (~199), `handoff` (~180), `askpass` (~185), `layout` (~224), `keymap` (~131); remove all six from `test_order`.
252 2. `client` row (~272): drop `keymap`, `handoff`, `hosts`, `askpass` from imports (now children); keep `protocol`, `replica`, `quic`, `xdg`, `sockpath`; keep `test_imports = &.{"testtmp"}` (hosts/handoff/askpass tests used testtmp — it must stay reachable in the test twin).
253 3. Rows that imported the deleted names: `wall` (drop `hosts` `handoff` `client_core` `keymap` `layout` `askpass` — it has `client`), `daemon_main` (drop `handoff`), `client_main` (drop `handoff` `hosts`), `hub_main` (drop `hosts`), `mux` (drop `askpass`), `agent_main`/`interact` — interact row is gone; agent_main never had them. Re-grep the table: `grep -n '"hosts"\|"handoff"\|"layout"\|"keymap"\|"client_core"\|"askpass"' build.zig`.
254 4. wasm wiring (~line 1001–1030): the table-driven twin loop now only covers `protocol` `engine` `replica` (still tabled with `.wasm`). Hand edits: delete `const client_core_wasm_mod = ...` handle; `wasm_core_mod` keeps `engine`/`protocol`/`replica` addImports, DROP the `client_core` and `keymap` addImports (now relative children of wasm_core's own module). The canary: `client_core_wasm_check_mod.addImport("client_core", ...)` → replace with `client_core_wasm_check_mod.addImport("protocol", wasm_mods[comptime idxOf("protocol")].?);` (client_core.zig, now its relative child, imports `protocol` by name).
255
256 - [ ] **Step 3.5: Gate and commit**
257
258 ```bash
259 make check; echo rc=$?
260 git add -A && git commit -m "refactor: the client link owns its core, hosts file, handoff, layout, keymap and askpass as child files behind pub seams"
261 ```
262
263 ---
264
265 ### Task 4: Executable collapse
266
267 The four mains and `webhub` become children of the `mux` dispatcher module. `agent` (muxa) stays its own module — `cliflags` therefore stays a tabled leaf.
268
269 **Files:**
270 - Modify: `src/cli/mux.zig`, `src/cli/webhub_main.zig`, `build.zig`
271 - Rename module `agent_main` → `agent` (grep `@import("agent_main")` — only mux.zig:14).
272
273 **Interfaces:**
274 - Consumes: `@import("client").askpass` (already rewritten in Task 3).
275 - Produces: one row `mux` whose children are the mains; `agent` row unchanged but renamed.
276
277 - [ ] **Step 4.1: mux.zig dispatch → relative children**
278
279 mux.zig:13–16: `@import("daemon_main")` → `@import("main.zig")`, `@import("agent_main")` → `@import("agent")` (module — stays a name import), `@import("hub_main")` → `@import("webhub_main.zig")`, `@import("client_main")` → `@import("mux_main.zig")`.
280
281 - [ ] **Step 4.2: webhub becomes a child too**
282
283 webhub_main.zig:15: `@import("webhub")` → `@import("../client/webhub.zig")`. (Legal: relative imports may traverse up; webhub.zig joins the mux module. Its own `@import("protocol")`/`@import("client")` resolve against the mux row's imports.)
284
285 - [ ] **Step 4.3: Edit the module table**
286
287 1. Delete rows `daemon_main` (~279), `client_main` (~307), `hub_main` (~301), `webhub` (~284); remove from `test_order`; rename `agent_main` → `agent` (row ~260).
288 2. New `mux` row = union of the deleted rows' imports minus dead names:
289
290 ```zig
291 .{ .name = "mux", .path = "src/cli/mux.zig", .layer = 6, .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "protocol", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
292 ```
293
294 3. The anonymous imports (~line 1053): `hub_main_mod.addAnonymousImport("index.html", ...)` etc. — the handle `hub_main_mod` is gone; attach all three to `mux_mod` instead (webhub_main.zig is now a mux child; `@embedFile` resolves against its module). The comment about sequencing the wasm build before the hub's stays true — keep it.
295 4. Fix named handles: `grep -n 'idxOf("daemon_main")\|idxOf("client_main")\|idxOf("hub_main")\|idxOf("agent_main")\|idxOf("webhub")\|hub_main_mod\|daemon_main\|client_main' build.zig`. The `mux` exe wiring (~line 914–960) already builds from `idxOf("mux")` — unchanged.
296
297 - [ ] **Step 4.4: Chain the mains' tests from mux.zig**
298
299 Grep each of main.zig / mux_main.zig / webhub_main.zig / webhub.zig for `test `. Add to (or create at the end of) mux.zig:
300
301 ```zig
302 test {
303 _ = @import("main.zig");
304 _ = @import("mux_main.zig");
305 _ = @import("webhub_main.zig");
306 _ = @import("../client/webhub.zig");
307 }
308 ```
309
310 - [ ] **Step 4.5: Gate and commit**
311
312 ```bash
313 make check; echo rc=$?
314 git add -A && git commit -m "refactor: the one binary's mains and the hub page are children of the dispatcher"
315 ```
316
317 ---
318
319 ### Task 5: Term collapse
320
321 The big sed: `protocol`, `engine`, `delta`, `replica` fold under a new root `src/engine/term.zig`. Every consumer spells `@import("term").X`; the four files import each other relatively. ghostty-vt moves to the term module (native and wasm).
322
323 **Files:**
324 - Create: `src/engine/term.zig`
325 - Modify: every file the greps below hit (~25 files), `build.zig`, `docscheck.budget`
326
327 **Interfaces:**
328 - Produces `src/engine/term.zig`:
329
330 ```zig
331 //! The terminal component: the wire contract, the authoritative engine,
332 //! the delta minting that feeds replicas, and the one replay core. One
333 //! table row — the four files are one owner, and only this root is a seam.
334 pub const protocol = @import("protocol.zig");
335 pub const engine = @import("engine.zig");
336 pub const delta = @import("delta.zig");
337 pub const replica = @import("replica.zig");
338
339 test {
340 _ = protocol;
341 _ = engine;
342 _ = delta;
343 _ = replica;
344 }
345 ```
346
347 - [ ] **Step 5.1: Inside src/engine/: name → relative**
348
349 delta.zig imports `engine`+`protocol`; replica.zig imports `engine`+`protocol`; check engine.zig and protocol.zig too:
350
351 ```bash
352 grep -rn '@import("engine")\|@import("protocol")\|@import("delta")\|@import("replica")' src/engine/
353 ```
354 Rewrite each to the `.zig` relative form.
355
356 - [ ] **Step 5.2: Everywhere else: name → term.X**
357
358 ```bash
359 grep -rln '@import("protocol")\|@import("engine")\|@import("delta")\|@import("replica")' src test
360 ```
361 In every hit (~25 files, none under src/engine/ after 5.1), apply:
362 - `@import("protocol")` → `@import("term").protocol`
363 - `@import("engine")` → `@import("term").engine`
364 - `@import("delta")` → `@import("term").delta`
365 - `@import("replica")` → `@import("term").replica`
366
367 This is sed-able per file; verify with a final grep that zero name-form sites remain outside src/engine/. Keep local alias names as they are (`const protocol = @import("term").protocol;` — call sites don't change).
368
369 - [ ] **Step 5.3: Edit the module table**
370
371 1. Add the row (and to `test_order`):
372
373 ```zig
374 .{ .name = "term", .path = "src/engine/term.zig", .layer = 0, .wasm = true },
375 ```
376
377 2. Delete rows `protocol`, `engine`, `delta`, `replica`; remove from `test_order`.
378 3. Every row importing any of the four: replace with a single `"term"` (dedup!). As of Task 4's end that is: `daemon` (had engine, protocol, delta), `client` (protocol, replica), `wall` (protocol, engine, replica), `agent` (protocol), `mux` (protocol), `render` (engine), `wsclient` (engine, replica, protocol), `paint`/`cmd`/etc. are already children. `daemon`'s `test_imports` drops `replica` (term is a production import now). Re-grep: `grep -n '"protocol"\|"engine"\|"delta"\|"replica"' build.zig` until only comments remain.
379 4. ghostty native (~line 930): `engine_mod.addImport("ghostty-vt", ...)` — the handle comes from `idxOf("engine")`; change to `idxOf("term")` / `term_mod`. The import name `"ghostty-vt"` is consumed by engine.zig, now a term child — attaching it to the term module is exactly right.
380 5. wasm (~line 1001+): the `.wasm` twin loop now instantiates only `term`. Replace the explicit engine/protocol/replica handles: `term_wasm_mod = wasm_mods[comptime idxOf("term")].?`; ghostty wasm import attaches to `term_wasm_mod`; `wasm_core_mod` drops addImports `engine`/`protocol`/`replica`/`client_core`/`keymap` (the latter two died in Task 3) and gains `wasm_core_mod.addImport("term", term_wasm_mod);`. The canary swaps its `protocol` addImport for `term`: `client_core_wasm_check_mod.addImport("term", term_wasm_mod);` (client_core.zig now spells `@import("term").protocol`).
381 6. src/client/wasm_core.zig source: its `@import("engine")` etc. were already rewritten to `term.X` by Step 5.2 — confirm.
382 7. The wasm-closure comptime check (~line 346) still holds (term has no imports); leave it.
383
384 - [ ] **Step 5.4: Budget line for the new file**
385
386 `zig build check` will demand a `term.zig` figure. Run `deps/zig/zig build doc-report 2>&1 | grep term` and add the exact reported line to `docscheck.budget` (alphabetical position matching the file's ordering convention — read the neighboring lines).
387
388 - [ ] **Step 5.5: Gate and commit**
389
390 ```bash
391 make check; echo rc=$?
392 git add -A && git commit -m "refactor: one term component — wire contract, engine, delta and replay core behind a single seam"
393 ```
394
395 ---
396
397 ### Task 6: Layers, folder rules, docs
398
399 Renumber layers to the real ranks, audit the folder rules against the shrunken table, update CLAUDE.md and decisions.md.
400
401 **Files:**
402 - Modify: `build.zig`, `CLAUDE.md`, `docs/decisions.md`
403
404 - [ ] **Step 6.1: Renumber `.layer`**
405
406 Final ranks — leaves 0, domains by depth:
407 - 0: `term` `quic` `proxy` `xdg` `sockpath` `pty` `cliflags` `testtmp` `spawn` `script` `rawmode` `delaypipe`
408 - 1: `daemon` `client` `agent` `render` `ptyclient` `wsclient`
409 - 2: `wall` `webhub` (webhub survived Task 4 as a row: Zig forbids relative imports outside the module root's dirname, so src/client/webhub.zig cannot be a child of the src/cli/ dispatcher without a file move; ruled kept-as-row)
410 - 3: `mux`
411
412 (`spawn` at 0 assumes its row still lists no imports — verify before renumbering.)
413
414 Adjust every row; the comptime check (imports strictly downward) is the verifier — `make check` fails loudly on any wrong rank. Also update the comment at the comptime block (~line 329) that says "32-row, 76-edge table" to the new true counts (count them: `grep -c '.name =' build.zig` minus non-table hits).
415
416 - [ ] **Step 6.2: Folder-rules audit**
417
418 `checkFolderRules` (build.zig:402) iterates table rows — it still compiles unchanged, but verify the three domain rules still have teeth by reading what edges remain: rule 3 (client imports no tui) now guards the `client` row; rule 2 (server imports no client/tui) guards `daemon`; rule 1 guards `term`+`client`. The rules 4–6 source bans are path-keyed and untouched — but rule 4's file set: `predict.zig` moved OUT of src/engine/ into src/tui/, where the VT-byte ban does not apply. Check `folder_exemptions` (should stay empty) and the `source_bans` file lists for any literal `predict` path; fix if named. Run the mutation probe from Task 1 Step 1.6 once more if any rule text was edited.
419
420 - [ ] **Step 6.3: CLAUDE.md**
421
422 Rewrite the Layout section: the folder table (module column now names roots + child files), the layer table (new 0–3 ranks above), and the sentence "Layers are enforced in the same module table". Update the module names in prose (`server`→`daemon` module, `wallview`→`wall`; file paths unchanged — say so). The reading-guide file sizes are unchanged (no content moved except predict) — leave them. Update the line "grep `.layer =` for the graph" only if still true (it is).
423
424 - [ ] **Step 6.4: decisions.md entry**
425
426 Append (matching the file's dated-entry format — read the last entry for shape) a decision: module = owned component, not compilable file; table 41→~15; children are relative imports; cross-domain grabs now die on Zig's one-file-one-module error where they used to die on a missing table edge; predict lives with the wall so term structurally cannot see it.
427
428 - [ ] **Step 6.5: Gate and commit**
429
430 ```bash
431 make check; echo rc=$?
432 git add -A && git commit -m "refactor: layers renumbered to the six-component graph; docs follow"
433 ```
434
435 ---
436
437 ### Task 7: Delivery gates
438
439 - [ ] **Step 7.1:** `make ci; echo rc=$?` — expected rc=0 (check + e2e + agent + throughput). E2e runs real binaries; nothing in this refactor touches the wire or behavior, so any red here is a genuine wiring mistake — debug, don't waive.
440 - [ ] **Step 7.2:** `XVER_OLD_WORKTREE=../mux-xver-old make xversion-build xversion; echo rc=$?` — expected rc=0 (12/12). The old worktree must exist at `../mux-xver-old`; a stale prefix reads as a failed gate — rebuild first, per the target's own recipe.
441 - [ ] **Step 7.3:** Review the branch story: `git log --oneline main..HEAD` — 7-ish commits, each a component. Squash fixups if any accumulated (`git rebase -i` is unavailable; use `git commit --fixup` + `GIT_SEQUENCE_EDITOR=true git rebase --autosquash main` only if needed).
442 - [ ] **Step 7.4:** STOP. Do not merge to main, push, or install — report completion with the gate outputs and wait for the user.
docscheck.blocks
Old New
@@ -1,30 +1,30 @@
1 askpass.zig 3 1 askpass.zig 3
2 client_core_wasm_check.zig 0 2 client_core_wasm_check.zig 0
3 client_core.zig 1 3 client_core.zig 0
4 client.zig 15 4 client.zig 15
5 cmd.zig 2 5 cmd.zig 0
6 delta.zig 2 6 delta.zig 2
7 docscheck.zig 4 7 docscheck.zig 0
8 engine.zig 5 8 engine.zig 5
9 flags.zig 2 9 flags.zig 0
10 handoff.zig 6 10 handoff.zig 6
11 hosts.zig 5 11 hosts.zig 1
12 interact.zig 24 12 interact.zig 24
13 keymap.zig 1 13 keymap.zig 0
14 layout.zig 1 14 layout.zig 1
15 main.zig 7 15 main.zig 7
16 muxa.zig 1 16 muxa.zig 1
17 mux_main.zig 1 17 mux_main.zig 1
18 mux.zig 2 18 mux.zig 0
19 paint.zig 1 19 paint.zig 1
20 predict.zig 5 20 predict.zig 5
21 protocol.zig 9 21 protocol.zig 9
22 proxy.zig 1 22 proxy.zig 0
23 pty.zig 4 23 pty.zig 4
24 quic_server.zig 3 24 quic_server.zig 3
25 quic.zig 6 25 quic.zig 6
26 replica.zig 3 26 replica.zig 2
27 select.zig 4 27 select.zig 0
28 server_agent.zig 3 28 server_agent.zig 3
29 server_sessions.zig 2 29 server_sessions.zig 2
30 server_test_agent.zig 6 30 server_test_agent.zig 6
@@ -38,15 +38,15 @@ server_test_quic.zig 6
38 server_test_session.zig 18 38 server_test_session.zig 18
39 server_test_upgrade.zig 1 39 server_test_upgrade.zig 1
40 server.zig 17 40 server.zig 17
41 shellint.zig 7 41 shellint.zig 1
42 sockpath.zig 5 42 sockpath.zig 1
43 spawn.zig 0 43 spawn.zig 0
44 term.zig 0 44 term.zig 0
45 testtmp.zig 1 45 testtmp.zig 0
46 upgrade.zig 2 46 upgrade.zig 1
47 wall_host.zig 7 47 wall_host.zig 1
48 wall_layout.zig 2 48 wall_layout.zig 1
49 wall_picker.zig 6 49 wall_picker.zig 1
50 wall_pump.zig 0 50 wall_pump.zig 0
51 wall_test_harness.zig 1 51 wall_test_harness.zig 1
52 wall_test_host.zig 0 52 wall_test_host.zig 0
@@ -55,7 +55,7 @@ wall_test_picker.zig 4
55 wall_test_pump.zig 9 55 wall_test_pump.zig 9
56 wall_test_wall.zig 7 56 wall_test_wall.zig 7
57 wallview.zig 0 57 wallview.zig 0
58 wasm_core.zig 5 58 wasm_core.zig 1
59 webhub_main.zig 4 59 webhub_main.zig 0
60 webhub.zig 4 60 webhub.zig 4
61 xdg.zig 4 61 xdg.zig 1
src/cli/flags.zig
Old New
@@ -1,10 +1,8 @@
1 //! A flag parser that reads its table off a struct: the field's TYPE is the 1 //! A flag parser that reads its table off a struct: a field's TYPE is its
2 //! flag's arity and the field's NAME is its spelling, so adding a field adds 2 //! flag's arity and its NAME is the spelling, so adding a field adds a flag and
3 //! a flag and there is no second list to keep in step. The grammar is 3 //! there is no second list. The grammar is `--flag VALUE`, with a bare `--`
4 //! `--flag VALUE`, space-separated, with a bare `--` ending the flags and 4 //! ending the flags. What a flag MEANS stays with the caller — in post-checks,
5 //! everything after it payload. What a flag MEANS stays with the caller, in 5 //! or in a field type declaring `parseCLI`, which no new caller can forget.
6 //! post-checks over the parsed struct, or in a field TYPE that declares
7 //! `parseCLI` — a rule one type owns is a rule no new caller can forget.
8 // folder rule 5 exemption: `/bin/sh` here is a shell NAME the daemon execs 6 // folder rule 5 exemption: `/bin/sh` here is a shell NAME the daemon execs
9 // as argv[0] when nothing else names one, not a `-c` line for a shell to 7 // as argv[0] when nothing else names one, not a `-c` line for a shell to
10 // parse. 8 // parse.
@@ -76,11 +74,10 @@ fn Bare(comptime F: type) type {
76 return if (@typeInfo(F) == .optional) @typeInfo(F).optional.child else F; 74 return if (@typeInfo(F) == .optional) @typeInfo(F).optional.child else F;
77 } 75 }
78 76
79 /// A flag given twice: the last wins. A word that is not a flag is offered 77 /// A flag given twice: the last wins. A bare word goes to `T.positional` when T
80 /// to `T.positional` when T declares one; without that decl there are no 78 /// declares one, and without that decl there are no positional arguments — a
81 /// positional arguments, because a bare word here could only be a typo. A 79 /// bare word could only be a typo. A flag-shaped word this table does not own
82 /// flag-shaped word this table does not own is offered to `T.extra`, which 80 /// goes to `T.extra`, which answers with the words it consumed, or 0.
83 /// answers with the number of words it consumed, or 0 to refuse.
84 pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome { 81 pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome {
85 const fields = @typeInfo(T).@"struct".fields; 82 const fields = @typeInfo(T).@"struct".fields;
86 comptime for (fields) |f| { 83 comptime for (fields) |f| {
@@ -97,13 +94,9 @@ pub fn parse(comptime T: type, dst: *T, args: []const [:0]const u8) Outcome {
97 @compileError("cliflags: alias `" ++ pair[0] ++ "` names no field `" ++ pair[1] ++ "`"); 94 @compileError("cliflags: alias `" ++ pair[0] ++ "` names no field `" ++ pair[1] ++ "`");
98 }; 95 };
99 96
100 // Asked for before anything is read, so that a `--help` sitting where a 97 // Asked before anything is read, so a `--help` sitting where a value belongs
101 // value belongs still answers with the usage instead of being eaten. 98 // still answers with the usage. It stops at `--`, because past there the
102 // `--version` shares the pass for the same reason: asking a binary its 99 // words are PAYLOAD: `mux a send -- --help` types `--help` at a session.
103 // version must answer a line that would otherwise be refused.
104 // It stops at `--` because past that point the words are payload: a
105 // `mux a send -- --help` types `--help` AT a session, and a usage page
106 // instead of the keystrokes would be the parser answering for the user.
107 for (args) |a| { 100 for (args) |a| {
108 if (std.mem.eql(u8, a, "--")) break; 101 if (std.mem.eql(u8, a, "--")) break;
109 if (isHelp(a)) return .help; 102 if (isHelp(a)) return .help;
src/cli/mux.zig
Old New
@@ -1,14 +1,11 @@
1 //! mux — the one binary. The first word picks a mode: `d` is the daemon, 1 //! mux — the one binary. The first word picks a mode: `d` the daemon, `a` the
2 //! `a` the JSON agent surface, `web` the browser hub, and anything else 2 //! JSON agent surface, `web` the browser hub, anything else the client. This
3 //! (including nothing) is the client. Each mode's own parser then reads the 3 //! file owns the mode word and nothing else — no second flag grammar, no
4 //! rest, so this file owns the mode word and nothing else: there is no 4 //! argv[0] dispatch, no alias. A word that is not a mode is a TARGET: `mux run`
5 //! second flag grammar here, no argv[0] dispatch and no alias — the four 5 //! names a host called "run".
6 //! programs became four words, not four names for one file. A word that is
7 //! not a mode is a TARGET: `mux run` names a host called "run".
8 //! 6 //!
9 //! The fifth mode has no word, because ssh gives it none: `SSH_ASKPASS` is 7 //! The fifth mode has no word, because ssh gives it none: `SSH_ASKPASS` is
10 //! exec'd with the prompt as argv[1] and nothing else. Its variable is the 8 //! exec'd with the prompt as argv[1]. Its VARIABLE is the word instead.
11 //! word instead, and it is set in exactly one process tree.
12 const std = @import("std"); 9 const std = @import("std");
13 const daemon = @import("main.zig"); 10 const daemon = @import("main.zig");
14 const agent = @import("agent"); 11 const agent = @import("agent");
@@ -53,11 +50,9 @@ pub fn main() !u8 {
53 .agent => agent.main(args[1..]), 50 .agent => agent.main(args[1..]),
54 .hub => hub.main(args[1..]), 51 .hub => hub.main(args[1..]),
55 .client => client.main(args), 52 .client => client.main(args),
56 // ssh reads the answer off this fd and logs in with it, so nothing 53 // ssh reads the answer off this fd and logs in with it, so nothing else
57 // else may ever be written there. The second variable is ssh's own 54 // may be written there. The second variable is ssh's own word for WHAT
58 // word for WHAT it is asking, which decides whether the wall stars 55 // it is asking, which decides whether the wall stars the answer.
59 // the answer; both are read here, where the environment is the
60 // subject.
61 .askpass => askpass.helperMain(args[1], ask_sock.?, ask_kind, std.posix.STDOUT_FILENO), 56 .askpass => askpass.helperMain(args[1], ask_sock.?, ask_kind, std.posix.STDOUT_FILENO),
62 }; 57 };
63 } 58 }
@@ -90,14 +85,10 @@ test "modeOf: the helper is named by ssh's variable, and a mode word still wins"
90 } 85 }
91 86
92 test "modeOf: `run` is a host, and no daemon verb has a top-level alias" { 87 test "modeOf: `run` is a host, and no daemon verb has a top-level alias" {
93 // There WAS a bare `run` here, as the bridge for a daemon of v0.0.1-15 88 // No bare `run` bridge for a v0.0.1-15 daemon's upgrade exec: that daemon
94 // or older exec'ing its upgrade candidate as `<binary> run --resume-fd 89 // demands `muxd <version>` from the candidate first, which this binary does
95 // N`. It is gone because it can never be reached: that daemon runs 90 // not print, so it refuses before any exec. The rule has no exception — a
96 // `<candidate> --version` first and demands `muxd <version>`, which this 91 // word that is not a mode is a transport the user named.
97 // binary does not print, so it refuses the candidate before any exec.
98 // What is left is the rule with no exception — a word that is not a mode
99 // is a transport the user named, and `mux run --resume-fd 5` is refused
100 // by the client's own parser with the page that lists the modes.
101 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run" }, null)); 92 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run" }, null));
102 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run", "--resume-fd", "5" }, null)); 93 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run", "--resume-fd", "5" }, null));
103 } 94 }
src/cli/webhub_main.zig
Old New
@@ -1,14 +1,10 @@
1 //! `mux web` — the hub mode. `mux web [HOST ...] [--port N]`: serves the 1 //! `mux web` — the hub mode: serves the wall page on 127.0.0.1 and pumps one
2 //! wall page on 127.0.0.1 and pumps one WebSocket per tile. 2 //! WebSocket per tile.
3 //! 3 //!
4 //! The wall is the HOSTS FILE, exactly as it is for the CLI: each HOST is 4 //! The wall is the HOSTS FILE, exactly as for the CLI. A HOST on argv is
5 //! a daemon (`HOST` | `--sock PATH` | `quic://HOST[:PORT]`, with --key / 5 //! RECORDED into the file, the same thing `mux HOST` does, and then the FILE is
6 //! MUX_KEY_FILE as in mux), and the tiles are whatever those daemons have 6 //! the wall. `#SESSION` is refused: nothing here may name a session, because
7 //! live. A HOST on argv is RECORDED into the file, the same thing `mux 7 //! nothing here may resurrect one.
8 //! HOST` does, and then the FILE is the wall. `#SESSION` is refused:
9 //! nothing here may name a session, because nothing here may resurrect
10 //! one. The standing non-goal (no config file) survives — the state file
11 //! is written by the program, never by hand.
12 8
13 const std = @import("std"); 9 const std = @import("std");
14 const client = @import("client"); 10 const client = @import("client");
@@ -36,11 +32,10 @@ const usage =
36 \\ 32 \\
37 ; 33 ;
38 34
39 /// The command line, read off the struct: the field's type is the flag's 35 /// The command line, read off the struct: a field's type is its flag's arity
40 /// arity and its name is the flag's spelling. What a flag MEANS stays in 36 /// and its name is the spelling. What a flag MEANS stays in the post-checks
41 /// the post-checks below. This IS the parse result — a second struct to 37 /// below. This IS the parse result — a second struct would be three fields to
42 /// copy the same three fields into bought nothing but three places to 38 /// forget one of.
43 /// forget one.
44 const Parsed = struct { 39 const Parsed = struct {
45 port: u16 = webhub.default_port, 40 port: u16 = webhub.default_port,
46 key: ?[]const u8 = null, 41 key: ?[]const u8 = null,
@@ -114,12 +109,9 @@ pub fn main(args: []const [:0]const u8) !u8 {
114 }; 109 };
115 defer parsed.deinit(); 110 defer parsed.deinit();
116 111
117 // An arena, because every string built here lives exactly as long as 112 // An arena, because every string built here lives exactly as long as the hub
118 // the hub does — the state path, the wall it starts from, and the 113 // does and nothing is ever freed early. The process exits from inside the
119 // Hub's own allocations — so nothing is ever freed early and the 114 // accept loop, so "as long as the hub" is "until exit".
120 // hand-rolled list of pointers-to-free was a lifetime nobody needed to
121 // track. The process exits from inside the accept loop, so "as long as
122 // the hub" is "until exit".
123 var arena_state = std.heap.ArenaAllocator.init(alloc); 115 var arena_state = std.heap.ArenaAllocator.init(alloc);
124 defer arena_state.deinit(); 116 defer arena_state.deinit();
125 const arena = arena_state.allocator(); 117 const arena = arena_state.allocator();
@@ -232,12 +224,9 @@ test "parse: zero hosts, bad flags, and flag-beats-env" {
232 defer r.deinit(); 224 defer r.deinit();
233 try std.testing.expectEqual(@as(usize, 0), r._argv.list.items.len); 225 try std.testing.expectEqual(@as(usize, 0), r._argv.list.items.len);
234 } 226 }
235 // Every other refusal arrives as error.Usage — and the testing 227 // Every other refusal arrives as `error.Usage`, and the testing allocator is
236 // allocator is the other half of this pin: a refusal that leaked the 228 // the other half of the pin: a refusal that leaked the owned host list would
237 // host list (whose strings are now owned) would fail the test that 229 // fail the test that provoked it.
238 // provoked it, which is what the single errdefer guarantees.
239 //
240 // A flag with no value is a usage mistake, not a host.
241 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "--sock" }, null)); 230 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "--sock" }, null));
242 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--port", "x" }, null)); 231 try std.testing.expectError(error.Usage, parseArgs(alloc, &[_][:0]const u8{ "web", "h", "--port", "x" }, null));
243 // The refusals that had a host on the list already, so the cleanup is 232 // The refusals that had a host on the list already, so the cleanup is
@@ -292,10 +281,8 @@ test "hosts: the spelling reaches the file verbatim" {
292 281
293 test "hosts: a '#SESSION' is refused at parse, in every spelling" { 282 test "hosts: a '#SESSION' is refused at parse, in every spelling" {
294 const alloc = std.testing.allocator; 283 const alloc = std.testing.allocator;
295 // The wall lists DAEMONS. A `#NAME` here would name a session the hub 284 // The wall lists DAEMONS, so a `#NAME` is refused at argv altitude rather
296 // must never resurrect, so it is refused at argv altitude rather than 285 // than surfacing as a line the strict loader will not take. The `mux web:`
297 // surfacing later as a line the strict loader will not take. These
298 // print a line naming the host before returning, so the `mux web:`
299 // lines in this test's output are the point, not noise. 286 // lines in this test's output are the point, not noise.
300 for ([_][]const u8{ "host#b", "host#has space", "host#", "a#b#c", "quic://h:1#b" }) |bad| { 287 for ([_][]const u8{ "host#b", "host#has space", "host#", "a#b#c", "quic://h:1#b" }) |bad| {
301 var argv = [_][:0]const u8{ "web", undefined }; 288 var argv = [_][:0]const u8{ "web", undefined };
src/client/client_core.zig
Old New
@@ -176,12 +176,10 @@ test "client core accepts every clipboard target boundary" {
176 176
177 test "client core refuses invalid clipboard targets" { 177 test "client core refuses invalid clipboard targets" {
178 var core = ClientCore{}; 178 var core = ClientCore{};
179 // 0x58 is the 'X' a probe of `ESC]52;X;aGk=BEL` actually produces; ';' 179 // 'X' is what a probe of `ESC]52;X;aGk=BEL` produces; ';' would close the
180 // would close the field early and NUL is the empty byte a sloppy emitter 180 // field early and NUL is what a sloppy emitter leaves. 'C' catches a
181 // leaves behind. 'C' catches a case-insensitive widening; '8' pins the 181 // case-insensitive widening, '8' and '/' pin the ends of the '0'..'7' range
182 // top of the '0'...'7' range and '/' pins its bottom — and '/' is the 182 // — and '/' is the likeliest accident, being IN the base64 alphabet.
183 // likeliest accident of the lot because it is IN the base64 alphabet, so
184 // an emitter that miscounts its fields lands exactly there.
185 const targets = [_]u8{ 'X', 0x00, ';', 'C', 'x', '8', '/', 0x07 }; 183 const targets = [_]u8{ 'X', 0x00, ';', 'C', 'x', '8', '/', 0x07 };
186 for (targets) |target| { 184 for (targets) |target| {
187 const payload = [_]u8{ 0, target, 'A' }; 185 const payload = [_]u8{ 0, target, 'A' };
src/client/hosts.zig
Old New
@@ -1,19 +1,12 @@
1 //! The wall: an ordered list of DAEMONS, one per line of 1 //! The wall: an ordered list of DAEMONS, one per line of
2 //! `$XDG_STATE_HOME/mux/hosts` — `--sock PATH` | `HOST` | 2 //! `$XDG_STATE_HOME/mux/hosts`. Tiles are whatever those daemons have live, so
3 //! `quic://HOST[:PORT]`. Tiles are whatever those daemons have live, so 3 //! nothing here names a session or can resurrect one. Strict on load, because
4 //! nothing here names a session and nothing here can resurrect one. 4 //! a host line is authored intent, and ONE grammar for argv, the file and the
5 //! Strict on load: a host line is authored intent. 5 //! picker alike.
6 //! 6 //!
7 //! ONE grammar for every mouth — argv, the file, the picker's `a` — so a 7 //! Every write is an unlocked read-modify-write over an atomic rename: no
8 //! user who learns a refusal in one reads it in the others, and one atomic 8 //! reader tears, but two concurrent writers lose one update. Nothing fsyncs,
9 //! writer for every state file mux keeps. 9 //! so no crash-durability claim is made.
10 //!
11 //! Every write is an unlocked read-modify-write over an atomic rename, so
12 //! no reader tears — but two writers lose one update, and every attach now
13 //! writes. What fails: an attach recording its daemon at the same moment as
14 //! a `mux hosts add` elsewhere drops one of the two lines. "Atomic" is
15 //! writer-vs-writer only: nothing here fsyncs, so a crash can still leave
16 //! the rename torn. No crash-durability claim is made.
17 const std = @import("std"); 10 const std = @import("std");
18 const xdg = @import("xdg"); 11 const xdg = @import("xdg");
19 12
@@ -26,12 +19,9 @@ pub const Spec = union(enum) {
26 19
27 pub const ParseError = error{ HasSession, EmptySpec, BadByte, BadSpelling }; 20 pub const ParseError = error{ HasSession, EmptySpec, BadByte, BadSpelling };
28 21
29 /// The bytes a HOST spelling may not hold. The word is ONE argv element — 22 /// The bytes a HOST spelling may not hold. The word is ONE argv element and the
30 /// `handoff.recipeFor` hands it to a `ssh` the client execs — and the wall 23 /// wall grammar is whitespace-separated, so a space here is a second host and
31 /// grammar is whitespace-separated, so a space here is a second host and 24 /// the rest is punctuation no resolver answers. `[user@]host` needs none of it.
32 /// the rest are punctuation no resolver will ever answer. `[user@]host`
33 /// needs none of them; `--via` is where an arbitrary command is the
34 /// contract, and it is not spellable as a host.
35 const unspellable = " \t;&|`$()<>'\"\\*?{}[]!~"; 25 const unspellable = " \t;&|`$()<>'\"\\*?{}[]!~";
36 26
37 pub fn hasBadSpelling(word: []const u8) bool { 27 pub fn hasBadSpelling(word: []const u8) bool {
@@ -74,12 +64,9 @@ pub fn spellingFromArgv(
74 if (i + 1 >= args.len) return error.MissingSockPath; 64 if (i + 1 >= args.len) return error.MissingSockPath;
75 return .{ .spelling = try std.fmt.allocPrint(alloc, "--sock {s}", .{args[i + 1]}), .consumed = 2 }; 65 return .{ .spelling = try std.fmt.allocPrint(alloc, "--sock {s}", .{args[i + 1]}), .consumed = 2 };
76 } 66 }
77 // A wall takes hosts, and no host starts with a dash: bare `--sock` 67 // A wall takes hosts, and no host starts with a dash. Left to fall through,
78 // took its path and returned above, and the one-piece `--sock PATH` 68 // `mux hosts add -A box` becomes a host named `-A` that fails to resolve far
79 // dialect is `flagLike`'s exemption. Left to fall through, `mux hosts 69 // from the typo — and a wall has no per-host agent flag to have meant.
80 // add -A box` became a host named `-A` that failed to resolve somewhere
81 // far from the typo — and a wall has no per-host agent flag to have
82 // meant, `-A` belonging to a single attach.
83 if (flagLike(args[i])) return error.FlagLikeTarget; 70 if (flagLike(args[i])) return error.FlagLikeTarget;
84 return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 }; 71 return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 };
85 } 72 }
@@ -170,16 +157,11 @@ pub fn forgetMany(
170 ) !void { 157 ) !void {
171 // `gone` is the caller's, one slot per spelling, and is only ever set. 158 // `gone` is the caller's, one slot per spelling, and is only ever set.
172 std.debug.assert(gone.len == spellings.len); 159 std.debug.assert(gone.len == spellings.len);
173 // Verbatim, not through `load`: the one command whose job is removing a 160 // Verbatim, not through `load`: the one command whose job is removing a line
174 // line has to reach a hand-edited line the grammar refuses. `record` 161 // has to reach a hand-edited one the grammar refuses. ONE read-modify-write,
175 // keeps reading strictly — growing a file whose content is not 162 // so an IO error on the third of four leaves none applied; EVERY copy,
176 // understood re-saves the garbage as if it had been read. 163 // because `load` folds duplicates and a first-match `rm` exits 0 with the
177 // 164 // host still polled.
178 // ONE read-modify-write, because `hostsAdd`'s rule is the file's rule:
179 // an IO error on the third of four must not leave the first two
180 // applied. EVERY copy, because `load` folds duplicate lines into one
181 // wall entry — stopping at the first match let a `rm` exit 0, print
182 // nothing, and leave the host still polled.
183 var lines = try loadLines(alloc, path); 165 var lines = try loadLines(alloc, path);
184 defer freeLines(alloc, &lines); 166 defer freeLines(alloc, &lines);
185 var removed = false; 167 var removed = false;
@@ -233,15 +215,10 @@ pub fn saveLines(lines: []const []const u8, path: []const u8) !void {
233 try saveBytes(path, joined.items); 215 try saveBytes(path, joined.items);
234 } 216 }
235 217
236 /// Every line of the file, verbatim, with NO grammar applied. 218 /// Every line of the file, verbatim, with NO grammar applied. `load` refuses a
237 /// 219 /// line that no longer parses, which leaves a hand-edited one unrepairable by
238 /// `load` refuses a line that no longer parses: silently dropping a host 220 /// the command whose job is removing a line — so removal reads with this.
239 /// the user wrote down is worse. But that left one hand-edited line 221 /// NOT for `record` or `add`: growing an unread file re-saves the garbage.
240 /// unrepairable by the command whose whole job is removing a line, so
241 /// removal reads with this instead.
242 ///
243 /// NOT for `record` or `Hosts.add`: growing a file whose content is not
244 /// understood builds on garbage and re-saves it as if it had been read.
245 pub fn loadLines(alloc: std.mem.Allocator, path: []const u8) !std.ArrayList([]u8) { 222 pub fn loadLines(alloc: std.mem.Allocator, path: []const u8) !std.ArrayList([]u8) {
246 var lines: std.ArrayList([]u8) = .empty; 223 var lines: std.ArrayList([]u8) = .empty;
247 errdefer freeLines(alloc, &lines); 224 errdefer freeLines(alloc, &lines);
@@ -453,14 +430,10 @@ test "hosts.forget removes EVERY copy, so a rm cannot report success and change
453 } 430 }
454 431
455 test "hosts.forgetMany: several names leave in ONE read-modify-write, and the survivors keep file order" { 432 test "hosts.forgetMany: several names leave in ONE read-modify-write, and the survivors keep file order" {
456 // `hostsAdd`'s rule, which the `rm` loop broke by calling `forget` once 433 // One load, one save, so an IO error on the third of four leaves none
457 // per spelling: an IO error on the third of four must not leave the 434 // applied and every name's verdict comes from the same pass. The removed `a`
458 // first two applied. One load, one save, and every name's verdict comes 435 // sits BETWEEN the two survivors, so an unordered remove would swap the tail
459 // back from the same pass. 436 // into its slot and re-order the wall.
460 //
461 // The removed `a` sits BETWEEN the two survivors, so an unordered
462 // remove would swap the tail line into its slot and the wall would
463 // re-order itself under a `rm` of some other host.
464 const alloc = std.testing.allocator; 437 const alloc = std.testing.allocator;
465 var tmp = try TmpDir.make(); 438 var tmp = try TmpDir.make();
466 defer tmp.cleanup(); 439 defer tmp.cleanup();
src/client/keymap.zig
Old New
@@ -1,15 +1,10 @@
1 //! Normalized key events → VT byte sequences. The portable half of input: 1 //! Normalized key events → VT byte sequences. Each shell produces the
2 //! each shell (browser KeyboardEvent, later xkb) produces the normalized 2 //! normalized form and this module owns every byte a key event turns into, so
3 //! form; this module owns every byte a key event turns into, so the 3 //! the tables unit-test natively with no browser in the loop.
4 //! tables are unit-tested natively with no browser in the loop — they
5 //! arrived for the browser client and were never allowed to depend on it.
6 //! 4 //!
7 //! v1 scope (spec): printable input, control characters, arrows and nav 5 //! Scope: printable input, control characters, arrows and nav keys, function
8 //! keys, function keys, the xterm modifier-encoded CSI variants, and 6 //! keys, the xterm modifier-encoded CSI variants, bracketed paste. Deferred:
9 //! bracketed paste. Explicitly deferred: kitty/CSI-u. 7 //! kitty/CSI-u. Platform-free — this must compile for wasm32-freestanding.
10 //!
11 //! Deliberately platform-free — no posix, no fds, no clocks — this module
12 //! must compile for wasm32-freestanding.
13 // folder rule 4 exemption: turning a key event into VT bytes is this module's whole contract, and it does so with no terminal in sight. 8 // folder rule 4 exemption: turning a key event into VT bytes is this module's whole contract, and it does so with no terminal in sight.
14 9
15 const std = @import("std"); 10 const std = @import("std");
@@ -97,11 +92,9 @@ pub fn encode(ev: Event, buf: []u8) []const u8 {
97 n = 1; 92 n = 1;
98 } 93 }
99 if (ev.mods.ctrl) { 94 if (ev.mods.ctrl) {
100 // The terminal tradition: Ctrl clears bits 6-5 of the 95 // The terminal tradition: Ctrl clears bits 6-5 of the ASCII
101 // ASCII column, so ctrl-a..z are 0x01..0x1a, ctrl-@ (and 96 // column, so ctrl-a..z are 0x01..0x1a and ctrl-[ is ESC. Anything
102 // ctrl-space) are NUL, ctrl-[ is ESC. Lowercase folds to 97 // outside the foldable range falls through and sends plain.
103 // the same control byte as its column; anything outside
104 // the foldable range falls through and sends plain.
105 const c = ev.cp; 98 const c = ev.cp;
106 if (c == ' ' or (c >= '@' and c <= '_') or (c >= 'a' and c <= 'z')) { 99 if (c == ' ' or (c >= '@' and c <= '_') or (c >= 'a' and c <= 'z')) {
107 buf[n] = @intCast(c & 0x1f); 100 buf[n] = @intCast(c & 0x1f);
src/client/wasm_core.zig
Old New
@@ -1,20 +1,14 @@
1 //! The browser replica core: Engine + Replica + ClientCore + keymap 1 //! The browser replica core: Engine + Replica + ClientCore + keymap compiled
2 //! compiled to wasm32-freestanding. The JS shell is glue; every 2 //! to wasm32-freestanding. The JS shell is glue; every decision is on this side.
3 //! decision lives on this side of the boundary.
4 //! 3 //!
5 //! FRAME-driven, not byte-driven: the host stages one mux protocol frame 4 //! FRAME-driven, not byte-driven: the host stages one payload and calls
6 //! payload and calls mux_apply_frame(type, len), so the core sees the same 5 //! `mux_apply_frame`, so the core sees the same replay the CLI client does.
7 //! replay the CLI client does (replica.zig) and can track damage from the
8 //! delta row headers itself.
9 //! 6 //!
10 //! THE JS GOTCHA THAT WILL BITE (from the feasibility spike): the wasm 7 //! THE JS GOTCHA: the wasm allocator grows linear memory, and growth DETACHES
11 //! allocator grows linear memory, and growth DETACHES every cached 8 //! every cached ArrayBuffer view. JS must re-read `exports.memory.buffer` after
12 //! ArrayBuffer view. JS must re-read exports.memory.buffer after every 9 //! every call that can allocate — which is any of them.
13 //! call that can allocate — which is any of them — and never cache a view.
14 //! 10 //!
15 //! Wire safety: this module trusts nothing it is fed. Payloads come from 11 //! Wire safety: a bad length or type is a return code, never a trap.
16 //! the daemon via the hub, but a bad length or type is a return code, not
17 //! a trap.
18 12
19 const std = @import("std"); 13 const std = @import("std");
20 const builtin = @import("builtin"); 14 const builtin = @import("builtin");
@@ -192,12 +186,9 @@ export fn mux_input_cap() u32 {
192 return input_buf.len; 186 return input_buf.len;
193 } 187 }
194 188
195 /// Apply one staged frame payload. `msg_type` is the wire byte; only 189 /// Apply one staged frame payload; only snapshot and delta are replay frames.
196 /// snapshot (0x81) and delta (0x87) are replay frames — the host routes 190 /// 0 painted, 1 RESYNC-NEEDED (re-attach quoting 0,0), -1 uninitialized,
197 /// everything else itself. 191 /// -2 over the staging cap, -3 not a replay frame or a grid the core refuses.
198 /// Returns 0 painted, 1 RESYNC-NEEDED (send a fresh attach quoting 0,0),
199 /// -1 uninitialized, -2 length over the staging cap, -3 not a replay
200 /// frame or a grid the core refuses.
201 export fn mux_apply_frame(msg_type: u32, len: u32) i32 { 192 export fn mux_apply_frame(msg_type: u32, len: u32) i32 {
202 const c = core orelse return -1; 193 const c = core orelse return -1;
203 clearBorrowedInputResults(c); 194 clearBorrowedInputResults(c);
@@ -214,13 +205,10 @@ export fn mux_apply_frame(msg_type: u32, len: u32) i32 {
214 // Engine resize failure: the grid the daemon named is beyond us. 205 // Engine resize failure: the grid the daemon named is beyond us.
215 else => return -3, 206 else => return -3,
216 }; 207 };
217 // Drain and DROP. Replaying can make the replica answer for itself 208 // Drain and DROP: replaying can make the replica answer for itself, but the
218 // (DSR and friends), but the daemon is authoritative and answers the 209 // daemon already answers the application — forwarding would double every
219 // application already — forwarding these would double every reply, so 210 // reply. Unread they accumulate with no bound, so each engine drains where
220 // nothing in the browser may ever read them. Unread, they are an 211 // it feeds.
221 // accumulation with no bound; each engine drains where it feeds —
222 // here for the live replica, in mux_scroll_feed for the scratch.
223 // (engine.zig pins the replies themselves, where they matter.)
224 c.rep.eng.clearPtyOutput(); 212 c.rep.eng.clearPtyOutput();
225 if (applied == .resync) return 1; 213 if (applied == .resync) return 1;
226 214
@@ -232,15 +220,11 @@ export fn mux_apply_frame(msg_type: u32, len: u32) i32 {
232 const old_dirty = c.dirty; 220 const old_dirty = c.dirty;
233 const old_list = c.dirty_list; 221 const old_list = c.dirty_list;
234 allocGridBufs(c, c.rep.grid.cols, c.rep.grid.rows) catch { 222 allocGridBufs(c, c.rep.grid.cols, c.rep.grid.rows) catch {
235 // Nothing here is stale — allocGridBufs is all-or-nothing and 223 // Nothing here is stale — `allocGridBufs` is all-or-nothing, so the
236 // publishes no buffer until it has them all, so the Core is 224 // Core is self-consistent at the OLD geometry. What broke is the
237 // still self-consistent at the OLD geometry. What broke is 225 // agreement with the HOST: `mux_cols`/`mux_rows` have moved while the
238 // the agreement with the HOST: mux_cols/mux_rows report 226 // readout buffer has not, so painting through the -1 builds a
239 // rep.grid, which has moved, while the readout buffer is the 227 // DataView over a smaller allocation. -1 is fatal: re-init.
240 // old grid's. A host that paints through the -1 builds a
241 // DataView of cols*rows*16 bytes over a smaller allocation.
242 // Hence the contract: -1 here is fatal, re-init and re-attach
243 // (never retry in place).
244 return -1; 228 return -1;
245 }; 229 };
246 alloc.free(old_viewport); 230 alloc.free(old_viewport);
@@ -384,12 +368,9 @@ export fn mux_mark_all_dirty() void {
384 // --------------------------------------------------------------------- 368 // ---------------------------------------------------------------------
385 369
386 /// The whole 20-byte attach payload into the output buffer, so JS never 370 /// The whole 20-byte attach payload into the output buffer, so JS never
387 /// hand-assembles a u64. `cols`/`rows` are what this client claims — an 371 /// hand-assembles a u64. `cols`/`rows` are what this client CLAIMS, encoded as
388 /// unzoomed wall tile passes 0x0 and claims nothing (the passivity 372 /// given — a size refused here would be a second opinion on server.zig's rule.
389 /// contract), the zoomed tile its real size. Encoded as given: a size 373 /// Quotes the replica's resume coordinates; `fresh=1` quotes (0,0) instead.
390 /// this refused would be a second opinion on a rule server.zig owns.
391 /// Quotes the replica's resume coordinates; pass fresh=1 to
392 /// quote (0,0) instead (the resync re-attach).
393 export fn mux_attach_payload(cols: u32, rows: u32, fresh: u32) i32 { 374 export fn mux_attach_payload(cols: u32, rows: u32, fresh: u32) i32 {
394 const c = core orelse return -1; 375 const c = core orelse return -1;
395 if (cols > 0xffff or rows > 0xffff) return -3; 376 if (cols > 0xffff or rows > 0xffff) return -3;
@@ -450,11 +431,10 @@ export fn mux_scroll_start(pages_up: u32, view_rows: u32) u32 {
450 // Input encoding 431 // Input encoding
451 // --------------------------------------------------------------------- 432 // ---------------------------------------------------------------------
452 433
453 /// keymap.Key by @intFromEnum — JS mirrors this table (char=0, enter=1, 434 /// `keymap.Key` by `@intFromEnum`, a table JS mirrors: char=0, enter=1, tab=2,
454 /// tab=2, backspace=3, escape=4, up=5, down=6, left=7, right=8, home=9, 435 /// backspace=3, escape=4, up=5, down=6, left=7, right=8, home=9, end=10,
455 /// end=10, insert=11, delete=12, page_up=13, page_down=14, f1..f12=15..26). 436 /// insert=11, delete=12, page_up=13, page_down=14, f1..f12=15..26. `mods` is
456 /// mods: bit0 shift, bit1 alt, bit2 ctrl. Returns the byte length written 437 /// bit0 shift, bit1 alt, bit2 ctrl. Returns bytes written, -3 on an unknown key.
457 /// to the output buffer (0 = nothing to send), -3 on an unknown key.
458 export fn mux_key_encode(key: u32, cp: u32, mods: u32) i32 { 438 export fn mux_key_encode(key: u32, cp: u32, mods: u32) i32 {
459 const k = std.meta.intToEnum(keymap.Key, key) catch return -3; 439 const k = std.meta.intToEnum(keymap.Key, key) catch return -3;
460 if (cp > 0x10ffff) return -3; 440 if (cp > 0x10ffff) return -3;
src/engine/replica.zig
Old New
@@ -1,17 +1,11 @@
1 //! The replay core: what a mux client does to mirror a daemon's session. 1 //! The replay core: what a mux client does to mirror a daemon's session.
2 //! Applies snapshot and delta frames to a local engine, tracks the resume 2 //! Applies snapshot and delta frames to a local engine, tracks the resume
3 //! coordinates (`last_seq`/`session_epoch`), and follows the authoritative 3 //! coordinates, and follows the authoritative grid — ONE implementation for the
4 //! grid. Extracted from client.zig's session() when the browser client 4 //! CLI client, the wasm core and the server's test fixtures.
5 //! landed, so the CLI client, the wasm core, and the server's test fixtures
6 //! replay through one implementation instead of three.
7 //! 5 //!
8 //! Deliberately platform-free: no posix, no fds, no clocks — this module 6 //! Deliberately platform-free: no posix, no fds, no clocks, since this must
9 //! must compile for wasm32-freestanding. Anything that needs a terminal, 7 //! compile for wasm32-freestanding. The Replica BORROWS its engine; the caller
10 //! a timestamp, or a transport belongs to the caller. 8 //! owns that lifetime.
11 //!
12 //! The Replica BORROWS its engine. The caller owns the Engine's lifetime
13 //! (interact's Core and the server's tests already do); a Replica is
14 //! bookkeeping around it, not a second owner.
15 9
16 const std = @import("std"); 10 const std = @import("std");
17 const Engine = @import("engine.zig").Engine; 11 const Engine = @import("engine.zig").Engine;
@@ -34,23 +28,20 @@ pub const Replica = struct {
34 /// with a delta when it can still reach us from there. 28 /// with a delta when it can still reach us from there.
35 last_seq: u64 = 0, 29 last_seq: u64 = 0,
36 history_rows: u32 = 0, 30 history_rows: u32 = 0,
37 /// Whether state has arrived *on the current attach*. A refusal 31 /// Whether state has arrived ON THE CURRENT ATTACH. A refusal arrives as
38 /// (session full) arrives as exit_status before anything else; without 32 /// `exit_status` before anything else, which without this is
39 /// this the caller could not tell it apart from the shell exiting 1. 33 /// indistinguishable from the shell exiting 1. Any replay frame's arrival
40 /// Set here by any replay frame's arrival — a delta's arrival alone 34 /// sets it — even a delta that fails to decode proves we were admitted —
41 /// proves we were admitted, even one that fails to decode — and 35 /// and the CALLER clears it, since only the caller knows about a re-attach.
42 /// cleared by the CALLER on every re-attach, because only the caller
43 /// knows when one happened.
44 state_since_attach: bool = false, 36 state_since_attach: bool = false,
45 37
46 pub const Applied = enum { 38 pub const Applied = enum {
47 /// The frame landed; the engine reflects it. 39 /// The frame landed; the engine reflects it.
48 painted, 40 painted,
49 /// A delta that could not be trusted (rejected by composeDelta). 41 /// A delta that could not be trusted; the engine was not touched. The
50 /// The engine was not touched. The caller must re-attach with 42 /// caller re-attaches with `have_seq=0`, since quoting a seq invites the
51 /// have_seq=0 — quoting a seq would invite the delta that cannot 43 /// delta that cannot fix us. NOT the reconnect path: the transport is
52 /// fix us — and this is deliberately NOT the reconnect path: the 44 /// alive and the replica is what is suspect.
53 /// transport is alive, it is the replica that is suspect.
54 resync, 45 resync,
55 }; 46 };
56 47
@@ -62,18 +53,14 @@ pub const Replica = struct {
62 }; 53 };
63 } 54 }
64 55
65 /// Consume one replay frame. Only .snapshot and .delta are replay 56 /// Consume one replay frame; only `.snapshot` and `.delta` are replay
66 /// frames; callers dispatch on type before calling. 57 /// frames.
67 ///
68 /// .snapshot: a short/garbled prefix is error.BadPayload with nothing
69 /// consumed and `state_since_attach` untouched — a short snapshot
70 /// proves nothing. A good prefix adopts seq/epoch/history, resizes the
71 /// engine when the grid moved, then reset+feed: an authoritative
72 /// rebuild. Resize failure (OOM) propagates.
73 /// 58 ///
74 /// .delta: arrival alone sets `state_since_attach`. A payload 59 /// `.snapshot`: a short prefix is `error.BadPayload` with nothing consumed
75 /// composeDelta rejects returns .resync (see Applied); a good one 60 /// and `state_since_attach` untouched, because a short snapshot proves
76 /// advances history_rows/last_seq and feeds the composed paint. 61 /// nothing. A good one adopts seq/epoch/history, resizes when the grid
62 /// moved, then reset+feed. `.delta`: arrival alone sets
63 /// `state_since_attach`; a rejected payload is `.resync`.
77 pub fn apply(self: *Replica, t: proto.MsgType, payload: []const u8) !Applied { 64 pub fn apply(self: *Replica, t: proto.MsgType, payload: []const u8) !Applied {
78 switch (t) { 65 switch (t) {
79 .snapshot => { 66 .snapshot => {
src/proxy.zig
Old New
@@ -1,8 +1,7 @@
1 //! `mux d proxy`: a bidirectional byte pump between stdio and the local 1 //! `mux d proxy`: a bidirectional byte pump between stdio and the local daemon
2 //! daemon socket. Deliberately frame-agnostic — it contains no protocol 2 //! socket. Deliberately frame-agnostic — the transport thesis is that if an
3 //! knowledge at all. That is the transport thesis: if an opaque byte 3 //! opaque byte pipe carries the protocol, transport is a swap and not a
4 //! pipe suffices to carry the protocol over SSH, transport is a swap, 4 //! redesign. Keep this file's import list empty of `protocol`.
5 //! not a redesign. Keep this file's import list empty of `protocol`.
6 const std = @import("std"); 5 const std = @import("std");
7 const TmpDir = @import("testtmp").TmpDir; 6 const TmpDir = @import("testtmp").TmpDir;
8 7
@@ -53,11 +52,9 @@ pub fn pump(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t, sock_path: []const u8
53 if (fds[1].revents != 0) { 52 if (fds[1].revents != 0) {
54 const n = std.posix.read(sock, &buf) catch return 1; 53 const n = std.posix.read(sock, &buf) catch return 1;
55 // The daemon hung up: exit, never reconnect. A delta stream cannot 54 // The daemon hung up: exit, never reconnect. A delta stream cannot
56 // outlive the daemon instance that opened it (see the epoch note at 55 // outlive the daemon instance that opened it, so a reconnecting proxy
57 // src/engine/protocol.zig), so a reconnecting proxy would silently rebind a 56 // would rebind a client to a different session instead of letting it
58 // client to a different session — or to a restarted one — instead of 57 // learn its own died.
59 // letting it learn its session died. Honest failure beats a pipe
60 // that heals into a lie.
61 if (n == 0) return 0; 58 if (n == 0) return 0;
62 writeAll(out_fd, buf[0..n]) catch return 1; 59 writeAll(out_fd, buf[0..n]) catch return 1;
63 } 60 }
src/server/cmd.zig
Old New
@@ -1,10 +1,7 @@
1 //! The session's command state machine: MarkEvents in, transitions out. 1 //! The session's command state machine: MarkEvents in, transitions out. Pure —
2 //! Pure — no I/O, no clock, no seq. The server stamps seqs and decides who 2 //! no I/O, no clock, no seq. The trust rule: a D only counts if it closes a
3 //! hears about a transition; this module only decides what the marks mean. 3 //! seen C, and a stray D resets to `at_prompt` rather than being believed. A
4 //! Trust rule (spec): a D only counts if it closes a seen C; a stray D 4 //! nested C is believed wholesale, and a stray A at idle is a no-op.
5 //! resets to at_prompt rather than being believed. A nested C is believed
6 //! wholesale — latest wins, no attempt to detect the nesting — and a stray
7 //! A at idle is a no-op.
8 const std = @import("std"); 5 const std = @import("std");
9 const proto = @import("term").protocol; 6 const proto = @import("term").protocol;
10 const Engine = @import("term").engine.Engine; 7 const Engine = @import("term").engine.Engine;
@@ -28,12 +25,9 @@ pub const Tracker = struct {
28 self.marks_seen = true; 25 self.marks_seen = true;
29 self.phase = .running; 26 self.phase = .running;
30 self.start_row = ev.row; 27 self.start_row = ev.row;
31 // The new command has not ended, so it has no end row — and 28 // The new command has no end row, and the PREVIOUS command's
32 // the PREVIOUS command's would be a lie about this one, 29 // would read as a span running backwards. Collapsing it to the
33 // read as a span running backwards (end < start) by anyone 30 // start row says the span is empty until a D closes it.
34 // who fetched it mid-run. Collapsing it to the start row
35 // says what is true under the end<=start convention: the
36 // span is empty until a D closes it.
37 self.end_row = ev.row; 31 self.end_row = ev.row;
38 self.exit_code = null; 32 self.exit_code = null;
39 return .running; 33 return .running;
@@ -121,11 +115,9 @@ test "back-to-back commands: second C reopens cleanly" {
121 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 4, .exit_code = null })); 115 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 4, .exit_code = null }));
122 try std.testing.expectEqual(@as(u32, 4), t.start_row); 116 try std.testing.expectEqual(@as(u32, 4), t.start_row);
123 try std.testing.expectEqual(@as(?u8, null), t.exit_code); 117 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
124 // Nothing of the finished command survives into the running one. The 118 // Nothing of the finished command survives into the running one — the END
125 // end row especially: left at 2 it would describe a span ending BEFORE 119 // ROW especially, which left alone describes a span ending before it starts.
126 // it starts, which is what a mid-command status_reply hands an agent. 120 // Equal rows are the empty span consumers already read as "no output yet".
127 // Equal rows are the empty span every consumer already reads as "no
128 // output yet".
129 try std.testing.expectEqual(@as(u32, 4), t.end_row); 121 try std.testing.expectEqual(@as(u32, 4), t.end_row);
130 try std.testing.expect(t.end_row <= t.start_row); 122 try std.testing.expect(t.end_row <= t.start_row);
131 } 123 }
src/server/shellint.zig
Old New
@@ -17,12 +17,9 @@ const precmd_fn =
17 \\ 17 \\
18 ; 18 ;
19 19
20 /// Pointing ZDOTDIR at the shim silently costs the user their ~/.zshenv: 20 /// Pointing ZDOTDIR at the shim silently costs the user their ~/.zshenv: zsh
21 /// zsh looks for .zshenv under $ZDOTDIR, and the shim directory has none, 21 /// looks for it under $ZDOTDIR and the shim directory has none, so a config
22 /// so a config kept there (PATH edits, and anything else zsh is expected to 22 /// kept there stops being read. The .zshrc is handed back below.
23 /// read for non-interactive shells too) stops being read for the session.
24 /// The .zshrc is handed back below, which is the common case; a .zshenv
25 /// shim that restores ZDOTDIR the way ghostty's does is the roadmap fix.
26 pub const zsh_zshrc = 23 pub const zsh_zshrc =
27 \\# mux shell integration (zsh): OSC 133 marks. Sourced via a ZDOTDIR 24 \\# mux shell integration (zsh): OSC 133 marks. Sourced via a ZDOTDIR
28 \\# shim; restores the user's ZDOTDIR (or unsets it) then runs their rc. 25 \\# shim; restores the user's ZDOTDIR (or unsets it) then runs their rc.
@@ -42,11 +39,9 @@ pub const zsh_zshrc =
42 \\ 39 \\
43 ; 40 ;
44 41
45 /// The DEBUG trap here silently REPLACES any DEBUG trap the session already 42 /// The DEBUG trap here silently REPLACES any the session already had — bash
46 /// had — bash-preexec, atuin and iTerm2's integration each install one, and 43 /// allows exactly one, and bash-preexec and atuin each install one. mux wins
47 /// bash allows exactly one. mux wins and the other goes quiet, with no 44 /// and the other goes quiet, with no diagnostic anywhere.
48 /// diagnostic anywhere. Coexisting by detecting bash-preexec and registering
49 /// with it instead is a roadmap item, not a thing this version does.
50 pub const bash_init = 45 pub const bash_init =
51 \\# mux shell integration (bash): OSC 133 marks. Passed via --init-file; 46 \\# mux shell integration (bash): OSC 133 marks. Passed via --init-file;
52 \\# sources the user's normal rc first so their config still runs. 47 \\# sources the user's normal rc first so their config still runs.
@@ -135,13 +130,9 @@ pub const Injection = struct {
135 /// for env-only injections (zsh, fish) and for .other. 130 /// for env-only injections (zsh, fish) and for .other.
136 extra_argv: []const [:0]const u8, 131 extra_argv: []const [:0]const u8,
137 env: []const EnvPair, 132 env: []const EnvPair,
138 /// The shim directory, set EXACTLY when this call created one — an 133 /// The shim directory, set EXACTLY when this call created one. The caller
139 /// unknown shell writes nothing and reports null. The caller deletes 134 /// deletes it at teardown, so a path reported but never created is a cleanup
140 /// it at teardown, so a path reported here that was never created 135 /// claiming work it did not do, and one created but not reported is litter.
141 /// would be a cleanup claiming work it did not do, and a path created
142 /// but not reported would be litter left in the runtime directory.
143 /// Reported from the one place that knows, rather than re-derived by
144 /// the caller from a second `detect` of the same shell.
145 dir: ?[]const u8 = null, 136 dir: ?[]const u8 = null,
146 }; 137 };
147 138
@@ -157,16 +148,12 @@ pub fn install(
157 parent_dir: []const u8, 148 parent_dir: []const u8,
158 shell_path: []const u8, 149 shell_path: []const u8,
159 ) Injection { 150 ) Injection {
160 // The pid keeps two daemons sharing one runtime directory legible in a 151 // The pid keeps two daemons in one runtime directory legible; the random
161 // listing; the random half is not decoration. `parent_dir` is the 152 // half is not decoration. `parent_dir` is a shared `/tmp` without
162 // socket's directory, a shared `/tmp` when `$XDG_RUNTIME_DIR` is unset, 153 // `$XDG_RUNTIME_DIR` and a pid is guessable, so another user could
163 // and a pid is guessable: another user could pre-create the exact name 154 // pre-create the name as a symlink and the shim files the shell SOURCES
164 // as a symlink to a directory of ours, and the shim files the session 155 // would land through it. It also ends the collision with a dead
165 // shell then SOURCES would land through it. `prepare` creating the 156 // predecessor's leftover name.
166 // directory exclusively closes that; the random half keeps the attempt
167 // from being cheap to aim. It also ends the mundane collision, where a
168 // predecessor SIGKILLed before teardown left its name behind for a
169 // later daemon drawing that pid.
170 const dir = std.fmt.allocPrint( 157 const dir = std.fmt.allocPrint(
171 arena, 158 arena,
172 "{s}/mux-shellint-{d}-{x:0>12}", 159 "{s}/mux-shellint-{d}-{x:0>12}",
@@ -202,11 +189,9 @@ pub fn prepare(
202 switch (detect(shell_path)) { 189 switch (detect(shell_path)) {
203 .zsh => { 190 .zsh => {
204 try xdg.makeNewPrivateDir(dir); 191 try xdg.makeNewPrivateDir(dir);
205 // An injection either lands whole or leaves nothing for 192 // An injection lands whole or leaves nothing: `dir` only reaches
206 // teardown to guess about: `dir` only reaches `Injection.dir` 193 // `Injection.dir` on the success return, so any failure between here
207 // on the success return below, so any failure between here and 194 // and there must take the directory back out with it.
208 // there must take the directory back out with it rather than
209 // leak it unreported.
210 errdefer std.fs.cwd().deleteTree(dir) catch {}; 195 errdefer std.fs.cwd().deleteTree(dir) catch {};
211 const rc_path = try std.fs.path.join(arena, &.{ dir, ".zshrc" }); 196 const rc_path = try std.fs.path.join(arena, &.{ dir, ".zshrc" });
212 try writeFilePrivate(rc_path, zsh_zshrc); 197 try writeFilePrivate(rc_path, zsh_zshrc);
@@ -478,12 +463,9 @@ test "prepare zsh: a failure after the directory exists leaves no orphan" {
478 var t = try TmpPath.make(); 463 var t = try TmpPath.make();
479 defer t.deinit(); 464 defer t.deinit();
480 465
481 // The failure has to land AFTER the directory exists, and the shim root 466 // The failure has to land AFTER the directory exists, and the shim root is
482 // is now created exclusively — so nothing can be planted inside it in 467 // created exclusively — so nothing can be planted inside it in advance. An
483 // advance, and planting the root itself would fail the step before the 468 // allocator that refuses its first request fails at the same step.
484 // one under test. An allocator that refuses its first request is what is
485 // left, and it fails exactly where the old planted `.zshrc` did: at the
486 // first step after the mkdir.
487 const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" }); 469 const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
488 var failing = std.testing.FailingAllocator.init(arena.allocator(), .{ .fail_index = 0 }); 470 var failing = std.testing.FailingAllocator.init(arena.allocator(), .{ .fail_index = 0 });
489 471
@@ -503,13 +485,10 @@ test "prepare refuses a shim path it did not create, and writes nothing through
503 var t = try TmpPath.make(); 485 var t = try TmpPath.make();
504 defer t.deinit(); 486 defer t.deinit();
505 487
506 // The attack, in three lines. `parent_dir` is the socket's directory, 488 // The attack in three lines: on a box with no XDG_RUNTIME_DIR another user
507 // which is a shared /tmp on any box with no XDG_RUNTIME_DIR, so another 489 // can create an entry at the name this daemon is about to pick. A symlink is
508 // user can create an entry at the name this daemon is about to pick — 490 // the costly version — `makePath` tolerates it, the chmod re-modes the
509 // and a symlink is the version that costs the victim something: the old 491 // TARGET, and the .zshrc lands somewhere this daemon does not own.
510 // makePath tolerated it, the chmod that followed re-moded the TARGET to
511 // 0700, and the .zshrc landed inside a directory this daemon does not
512 // own, to be sourced by a shell running as its user.
513 const victim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "victim" }); 492 const victim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "victim" });
514 try std.fs.cwd().makePath(victim); 493 try std.fs.cwd().makePath(victim);
515 var vd = try std.fs.cwd().openDir(victim, .{ .iterate = true }); 494 var vd = try std.fs.cwd().openDir(victim, .{ .iterate = true });
src/server/upgrade.zig
Old New
@@ -1,9 +1,8 @@
1 //! The upgrade vocabulary: the version rule and the manifest an exec-ing 1 //! The upgrade vocabulary: the version rule, and the manifest an exec-ing
2 //! daemon leaves for its replacement. The manifest crosses inside one 2 //! daemon leaves for its replacement. It crosses inside ONE process, yet it is
3 //! process (a memfd surviving execve), yet it is encoded as if for a 3 //! encoded as if for a stranger — length-prefixed sections, unknown tags
4 //! stranger — length-prefixed sections, unknown tags skipped — because the 4 //! skipped — because the reader IS one: a newer binary, or an older one on
5 //! reader IS a stranger: a newer binary, or on rollback an older one, and 5 //! rollback, and neither may be held to this build's struct layout.
6 //! neither may be held to this build's struct layout.
7 6
8 const std = @import("std"); 7 const std = @import("std");
9 const proto = @import("term").protocol; 8 const proto = @import("term").protocol;
@@ -77,20 +76,15 @@ pub const Counters = struct {
77 snapshot_equiv_bytes: u64 = 0, 76 snapshot_equiv_bytes: u64 = 0,
78 /// Every `.attach` this daemon ACCEPTED, cumulative and monotonic. 77 /// Every `.attach` this daemon ACCEPTED, cumulative and monotonic.
79 /// 78 ///
80 /// A counter, deliberately, where `clients=` is a gauge. The gauge 79 /// A counter where `clients=` is a gauge: the gauge cannot answer "did
81 /// answers "who is watching right now" and cannot answer "did anyone 80 /// anyone attach since I last looked", because a client that attaches and
82 /// attach at all since I last looked": a client that attaches and 81 /// leaves between two samples is invisible to it. The wall's in-place zoom
83 /// leaves between two samples is invisible to it, and so is one that 82 /// is what needs that answered — the proof is that this number did NOT move
84 /// closes as another opens. The CLI wall's in-place zoom is exactly 83 /// across a zoom and an unzoom.
85 /// the claim that needs the second question answered — it promotes a
86 /// connection it already holds, so the honest proof is that this
87 /// number did not move across a zoom, a skip and an unzoom.
88 /// 84 ///
89 /// Counted where an attach SUCCEEDS (a session resolved and the client 85 /// Counted where an attach SUCCEEDS, not where the frame arrives: a refusal
90 /// is seated), not where the frame arrives: a refusal attached nobody. 86 /// attached nobody. Both arms count, since a socket client's first attach
91 /// Both arms count, because a socket client's first attach promotes an 87 /// promotes an observer and every later one arrives established.
92 /// observer and every later one — a QUIC first attach included —
93 /// arrives on an established connection.
94 attaches: u64 = 0, 88 attaches: u64 = 0,
95 agent_refused_no_offer: u64 = 0, 89 agent_refused_no_offer: u64 = 0,
96 agent_refused_full: u64 = 0, 90 agent_refused_full: u64 = 0,
@@ -143,10 +137,9 @@ pub const SessionRec = struct {
143 }; 137 };
144 138
145 // ---- encoding ---- 139 // ---- encoding ----
146 // magic ++ u16 manifest_version, then sections: u8 tag ++ u32 LE len ++ 140 // magic ++ u16 manifest_version, then sections: u8 tag ++ u32 LE len ++ bytes.
147 // bytes. Strings are u32 LE len ++ bytes; optionals are u8 present ++ 141 // Strings are u32 LE len ++ bytes; optionals are u8 present ++ payload. An
148 // payload. An unknown tag is skipped by its length — the whole 142 // unknown tag is skipped by its length — the whole forward-compat story.
149 // forward-compat story.
150 143
151 fn writeInt(w: anytype, comptime T: type, v: T) !void { 144 fn writeInt(w: anytype, comptime T: type, v: T) !void {
152 var buf: [@sizeOf(T)]u8 = undefined; 145 var buf: [@sizeOf(T)]u8 = undefined;
src/sockpath.zig
Old New
@@ -1,12 +1,8 @@
1 //! The socket path's identity and the right to bind it: who owns a path 1 //! The socket path's identity and the right to bind it: who owns a path before
2 //! before a daemon starts, and whether the file sitting there at teardown 2 //! a daemon starts, and whether the file there at teardown is still the one
3 //! is still the one that daemon created. 3 //! that daemon created. One half refuses to steal a live daemon's path, the
4 //! 4 //! other refuses to delete a successor's socket. Nothing here knows a Server
5 //! Both halves answer the same field incident from opposite ends — one 5 //! exists; a path is all it takes.
6 //! refuses to steal a live daemon's path, the other refuses to delete a
7 //! successor's socket — so they belong together and nowhere near the rest
8 //! of the daemon. Nothing here knows a Server exists; a path is all it
9 //! takes.
10 const std = @import("std"); 6 const std = @import("std");
11 7
12 /// The usable bytes of `sockaddr_un.sun_path`: the field is 108 and the 8 /// The usable bytes of `sockaddr_un.sun_path`: the field is 108 and the
@@ -60,16 +56,10 @@ fn sockPathFrom(alloc: std.mem.Allocator, xdg_runtime: ?[]const u8) ![]const u8
60 return std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir}); 56 return std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir});
61 } 57 }
62 58
63 /// A socket file's identity at the moment it was bound, so teardown can 59 /// A socket file's identity when it was bound, so teardown can tell our socket
64 /// tell our socket from one that replaced it. 60 /// from one that replaced it. The PATH's dev+ino, never the listening
65 /// 61 /// descriptor's: a bound socket's fd lives in sockfs while the path resolves to
66 /// The PATH's dev+ino, deliberately, not the listening descriptor's: a 62 /// an ordinary inode, so comparing the two can never be equal.
67 /// bound unix socket's descriptor lives in sockfs (dev 10 here) while
68 /// the path resolves to an ordinary filesystem inode (dev 38), so
69 /// comparing the two could never be equal. The guard read as careful
70 /// and was unconditionally false, which meant the daemon never unlinked
71 /// its socket on a clean exit at all — masked ever since by the stale
72 /// socket recovery in `claim` cleaning up on the next start.
73 pub const PathId = struct { 63 pub const PathId = struct {
74 dev: u64, 64 dev: u64,
75 ino: u64, 65 ino: u64,
@@ -97,39 +87,30 @@ pub fn answers(path: []const u8) bool {
97 return true; 87 return true;
98 } 88 }
99 89
100 /// Make the socket path ours to bind, or refuse it. Field incident this 90 /// Make the socket path ours to bind, or refuse it. Without this, daemons
101 /// exists for: three daemons were started against one path, each 91 /// started against one path each unlink and bind fresh: every one keeps running
102 /// unlinking it and binding fresh. Every one of them kept running with 92 /// with its sessions intact, but only the newest is reachable and the rest are
103 /// its sessions intact, but only the newest was reachable — the older 93 /// stranded holding shells nobody can get back to.
104 /// two were stranded, invisible, holding shells nobody could get back
105 /// to, and two terminals "in the same session" were really in two
106 /// different ones.
107 /// 94 ///
108 /// So: unlink only what answers ECONNREFUSED *and* is a socket. 95 /// So: unlink only what answers ECONNREFUSED *and* is a socket.
109 /// - something answers → a live daemon owns this path. Refuse. 96 /// - something answers → a live daemon owns this path. Refuse.
110 /// - nothing there → bind, nothing to clean up. 97 /// - nothing there → bind, nothing to clean up.
111 /// - a dead socket file → a daemon that died without deinit's 98 /// - a dead socket file → ours to clear.
112 /// unlink running. Ours to clear. 99 /// - anything else → propagate; a path we cannot positively call a
113 /// - anything else → propagate. A path we cannot positively 100 /// dead daemon's leftover is not ours to delete.
114 /// identify as a dead daemon's leftover is not
115 /// something we may delete.
116 pub fn claim(path: []const u8) !void { 101 pub fn claim(path: []const u8) !void {
117 if (std.net.connectUnixSocket(path)) |probe| { 102 if (std.net.connectUnixSocket(path)) |probe| {
118 probe.close(); 103 probe.close();
119 return error.DaemonAlreadyRunning; 104 return error.DaemonAlreadyRunning;
120 } else |err| switch (err) { 105 } else |err| switch (err) {
121 error.FileNotFound => return, // free path; bind straight away 106 error.FileNotFound => return, // free path; bind straight away
122 // Nobody is listening — which is NOT yet proof of a stale socket: 107 // Nobody is listening, which is NOT yet proof of a stale socket: Linux
123 // Linux answers ECONNREFUSED for a regular file at the path 108 // answers ECONNREFUSED for a regular file exactly as for a dead socket,
124 // exactly as it does for a dead socket, so the connect alone 109 // so only the stat below separates a daemon from `--sock notes.txt`.
125 // cannot tell a dead daemon from `mux d start --sock notes.txt`.
126 // The stat below is what separates them.
127 error.ConnectionRefused => {}, 110 error.ConnectionRefused => {},
128 // Every other errno — EACCES on the socket or on a directory 111 // Every other errno names a path this process cannot positively call a
129 // above it, a name too long for `sun_path` — names a path this 112 // dead daemon's leftover, so it propagates BY NAME and the daemon's log
130 // process cannot positively call a dead daemon's leftover, so it 113 // says which one refused the bind.
131 // is propagated by name and the daemon's log says which one
132 // refused the bind.
133 else => |e| return e, 114 else => |e| return e,
134 } 115 }
135 116
@@ -227,12 +208,9 @@ test "PathId: names the file it was taken from, not the path, and not a successo
227 var listener = try addr.listen(.{}); 208 var listener = try addr.listen(.{});
228 const id = try PathId.of(sock_path); 209 const id = try PathId.of(sock_path);
229 210
230 // A live bound socket answers for itself — which is the whole reason 211 // A live bound socket answers for itself, which is why this type takes the
231 // this type takes the PATH's dev+ino. The guard it replaced stat'd the 212 // PATH's dev+ino: a guard comparing the LISTENING DESCRIPTOR against the
232 // LISTENING DESCRIPTOR, which lives in sockfs, against the path's 213 // path's inode is never equal, so the daemon silently stops unlinking.
233 // ordinary filesystem inode: never equal, so this case was false too,
234 // and the daemon silently stopped unlinking its own socket on every
235 // clean exit (6090604).
236 try std.testing.expect(id.stillAt(sock_path)); 214 try std.testing.expect(id.stillAt(sock_path));
237 215
238 // Nothing at the path: the stat fails, and the answer callers need is 216 // Nothing at the path: the stat fails, and the answer callers need is
src/testtmp.zig
Old New
@@ -1,18 +1,13 @@
1 //! Temp directories with SHORT paths, for tests that bind unix sockets. 1 //! Temp directories with SHORT paths, for tests that bind unix sockets.
2 //! 2 //!
3 //! `std.testing.tmpDir` puts its directory under `.zig-cache/tmp`, which 3 //! `std.testing.tmpDir` puts its directory under `.zig-cache/tmp`, so the path
4 //! makes the path as long as wherever the repository happens to be checked 4 //! is as long as wherever the repository is checked out — and a unix socket
5 //! out. A unix socket address caps its path at 108 bytes (`sun_path`), so a 5 //! caps at 108 bytes, so a deep checkout turns every socket-binding test into a
6 //! checkout a couple of directories deeper than usual turns every 6 //! `NameTooLong` that surfaces from inside `std.net` and reads like a bug in
7 //! socket-binding test in this project into a `NameTooLong` failure — and 7 //! the code under test.
8 //! that failure surfaces from inside `std.net`, so it reads like a bug in
9 //! the code under test rather than a fact about the checkout. It has now
10 //! cost three people a gate cycle each, which is three more than a hazard
11 //! that is this cheap to remove should cost anyone.
12 //! 8 //!
13 //! Same shape as `std.testing.tmpDir` — `.dir` and `.cleanup()` — plus 9 //! Same shape as `std.testing.tmpDir`, plus `path()`. A directory here is ~21
14 //! `path()`, which is what socket paths get built from. A directory here is 10 //! characters wherever the repository lives.
15 //! ~21 characters regardless of where the repository lives.
16 const std = @import("std"); 11 const std = @import("std");
17 12
18 pub const TmpDir = struct { 13 pub const TmpDir = struct {
@@ -51,15 +46,9 @@ pub const TmpDir = struct {
51 if (self.len == 0) return; 46 if (self.len == 0) return;
52 const p = self.path(); 47 const p = self.path();
53 self.dir.close(); 48 self.dir.close();
54 // Said out loud rather than swallowed. A `catch {}` here has the 49 // Said out loud rather than swallowed: a `catch {}` makes a directory
55 // shape this project has been bitten by before: a failure whose 50 // that could not be removed look exactly like one that was. Non-fatal —
56 // false branch is indistinguishable from success, so a directory 51 // a passing test must not be failed by its own tidying — but not silent.
57 // that could not be removed looks exactly like one that was. It
58 // stays non-fatal — a test that passed must not be failed by its own
59 // tidying — but it stops being silent, because the difference
60 // between "the suite leaves nothing behind" and "the suite leaves
61 // things behind and never mentions it" is a whole afternoon of
62 // hunting when something else starts leaking.
63 std.fs.cwd().deleteTree(p) catch |err| { 52 std.fs.cwd().deleteTree(p) catch |err| {
64 std.debug.print("testtmp: could not remove {s}: {t}\n", .{ p, err }); 53 std.debug.print("testtmp: could not remove {s}: {t}\n", .{ p, err });
65 }; 54 };
src/tui/select.zig
Old New
@@ -1,22 +1,14 @@
1 //! The drag behind text selection, as pure state: press, motion, release, 1 //! The drag behind text selection, as pure state: press, motion, release, and
2 //! and the span of columns highlighted on one line of one session. 2 //! the span of columns highlighted on one line of one session.
3 //! 3 //!
4 //! It is deliberately ignorant of everything around it. No tty, no 4 //! No tty, no transport, no engine, no allocation beyond its own struct — a
5 //! transport, no engine, no allocation beyond its own struct — a driver 5 //! driver resolves a mouse report into `Hit` and this owns what a SEQUENCE of
6 //! resolves a mouse report into `Hit` (which tile, which line, which 6 //! those means, which is what lets two drivers in different layers share one
7 //! column) and this owns what a sequence of those MEANS. That is what 7 //! meaning. Resolving a terminal row to a session line stays with the layout.
8 //! lets two drivers in different layers share one meaning: a zoomed
9 //! `interact.Core` at layer 2 and the wall's keyboard loop at layer 4.
10 //! 8 //!
11 //! It must not know about `wallview.Tile`, layer 4, which would invert 9 //! Rows are ABSOLUTE, counted from the oldest row the daemon retains: a drag
12 //! the graph. Resolving a terminal row to a session line stays with 10 //! held while the session scrolls must keep naming the lines it started over,
13 //! whoever owns the layout. 11 //! and a terminal row renames itself the moment output moves the window.
14 //!
15 //! Rows are ABSOLUTE — counted from the oldest row the daemon still
16 //! retains, the coordinate space `protocol.SelectionReq` speaks. A drag
17 //! held while the session scrolls must keep naming the lines it was
18 //! started over, and a terminal row silently renames itself the moment
19 //! output moves the window under it.
20 const std = @import("std"); 12 const std = @import("std");
21 13
22 /// One place in one session, as a driver has resolved a report. 14 /// One place in one session, as a driver has resolved a report.
@@ -29,11 +21,9 @@ pub const Hit = struct {
29 col: u16, 21 col: u16,
30 }; 22 };
31 23
32 /// A zero-based terminal cell, which is a different thing from a `Hit`: 24 /// A zero-based terminal cell, which is not a `Hit`: it is where the POINTER
33 /// it is where the POINTER is, not what is under it. The two part company 25 /// is, not what is under it. The two part company when the session scrolls, and
34 /// exactly when the session scrolls, and telling a click from a drag is 26 /// telling a click from a drag is the pointer's question.
35 /// the pointer's question — a hand resting still over moving output has
36 /// not dragged anything.
37 pub const Cell = struct { row: u16, col: u16 }; 27 pub const Cell = struct { row: u16, col: u16 };
38 28
39 /// Inclusive columns, the same convention `engine.extractSelection` and 29 /// Inclusive columns, the same convention `engine.extractSelection` and
@@ -46,14 +36,10 @@ pub const Range = struct {
46 from: Hit, 36 from: Hit,
47 to: Hit, 37 to: Hit,
48 38
49 /// On `Range` rather than `Drag` because two of them get compared: a 39 /// On `Range` rather than `Drag` because two get compared: a drag that moved
50 /// drag that moved repaints the rows whose span CHANGED, which needs 40 /// repaints the rows whose span CHANGED. `cols` is the GRID's width, and the
51 /// the span of a selection that is no longer the live one. 41 /// clamp is not decoration — a grid narrower than its terminal has columns a
52 /// 42 /// pointer can reach with no cell behind them.
53 /// `cols` is the GRID's width and the clamp is not decoration: a grid
54 /// narrower than the terminal it is painted on has columns a pointer
55 /// can reach and no cell behind them, and an out-of-range column is
56 /// what makes the daemon answer `.invalid` at the other end.
57 pub fn span(self: Range, tile: usize, row: u32, cols: u16) ?Span { 43 pub fn span(self: Range, tile: usize, row: u32, cols: u16) ?Span {
58 if (self.from.tile != tile) return null; 44 if (self.from.tile != tile) return null;
59 if (row < self.from.row or row > self.to.row) return null; 45 if (row < self.from.row or row > self.to.row) return null;
@@ -80,12 +66,9 @@ pub const Release = union(enum) {
80 selection: Range, 66 selection: Range,
81 }; 67 };
82 68
83 /// One button's worth of drag. 69 /// One button's worth of drag. Four states, because a press is not yet a
84 /// 70 /// selection and a release is not the end of one: `.down` may still turn out to
85 /// The states are four because a press is not yet a selection and a 71 /// be a click, and `.held` is a finished selection still on screen.
86 /// release is not the end of one: `.down` is a press that may still turn
87 /// out to be a click, and `.held` is a finished selection with the button
88 /// up and the highlight still on screen.
89 pub const Drag = struct { 72 pub const Drag = struct {
90 const Phase = enum { idle, down, dragging, held }; 73 const Phase = enum { idle, down, dragging, held };
91 74
@@ -107,12 +90,9 @@ pub const Drag = struct {
107 self.* = .{ .phase = .down, .at = cell, .anchor = h, .active = h }; 90 self.* = .{ .phase = .down, .at = cell, .anchor = h, .active = h };
108 } 91 }
109 92
110 /// Cell, not pixel: `?1002h` reports a CELL change, and a hand 93 /// Cell, not pixel: `?1002h` reports a CELL change, and a hand trembling
111 /// trembling inside one cell still points at one line. Once it IS 94 /// inside one cell still points at one line. Once it IS a drag it stays one.
112 /// a drag it stays one, even back over the press cell. 95 /// Confined to its starting tile, or it would ask the wrong session.
113 ///
114 /// A drag is confined to its starting tile; off it the active end
115 /// stops moving, since a leak would ask the wrong session for text.
116 pub fn motion(self: *Drag, cell: Cell, hit: ?Hit) void { 96 pub fn motion(self: *Drag, cell: Cell, hit: ?Hit) void {
117 switch (self.phase) { 97 switch (self.phase) {
118 .idle, .held => return, 98 .idle, .held => return,
src/tui/wall_host.zig
Old New
@@ -1,8 +1,7 @@
1 //! The wall's daemons: the hosts file's lines resolved to targets, the 1 //! The wall's daemons: the hosts file's lines resolved to targets, the table
2 //! table that holds them, and the poller that asks each one for its live 2 //! that holds them, and the poller that asks each for its live sessions once a
3 //! sessions once a second. A host contributes tiles, never a tile of its 3 //! second. A host contributes TILES, never a tile of its own — `applyHostList`
4 //! own — `applyHostList` is where a poll's answer becomes births and 4 //! is where a poll's answer becomes births and vanishings.
5 //! vanishings on the wall the root owns.
6 const std = @import("std"); 5 const std = @import("std");
7 const proto = @import("term").protocol; 6 const proto = @import("term").protocol;
8 const client = @import("client"); 7 const client = @import("client");
@@ -97,13 +96,10 @@ pub fn addHost(
97 badHost(shared, error.FlagLikeTarget); 96 badHost(shared, error.FlagLikeTarget);
98 return .refused; 97 return .refused;
99 } 98 }
100 // Ahead of the dupe and the resolve, both of which allocate: a wall 99 // Ahead of the dupe and the resolve, both of which allocate: a full wall is
101 // that is full is full whatever the spelling turns out to mean, and a 100 // full whatever the spelling means, and a re-opened prompt must not leak a
102 // prompt the user keeps re-opening must not leak a copy per refusal. 101 // copy per refusal. A forgotten slot whose poller has left is FREE, or a
103 // 102 // table that only grew would spend the wall after 32 edits.
104 // A forgotten slot whose poller has left is free — `a` and `x` are what
105 // the picker is FOR, and a table that only grew spent the wall after 32
106 // of them however few rows were showing.
107 const reuse = freeHostSlot(host_table[0..hosts_live.*]); 103 const reuse = freeHostSlot(host_table[0..hosts_live.*]);
108 if (reuse == null and hosts_live.* >= host_table.len) { 104 if (reuse == null and hosts_live.* >= host_table.len) {
109 wv.setNotice(shared, "[no room on the wall for another host]"); 105 wv.setNotice(shared, "[no room on the wall for another host]");
@@ -119,11 +115,9 @@ pub fn addHost(
119 badHost(shared, err); 115 badHost(shared, err);
120 return .refused; 116 return .refused;
121 }; 117 };
122 // The file before the table: a host the user is looking at and a host 118 // The file before the table: a host the user is looking at and one they get
123 // they get back next time are the same host, and `mux hosts rm` is the 119 // back next time are the same host. The host is this wall's either way — a
124 // only way out of either. 120 // file that will not take the line costs the NEXT wall, not this one.
125 // The host is this wall's either way: a file that will not take the
126 // line costs the user the NEXT wall, not this one.
127 if (path) |p| if (recordHost(alloc, spec.target, spec.spelling, p)) |err| { 121 if (path) |p| if (recordHost(alloc, spec.target, spec.spelling, p)) |err| {
128 var nb: [128]u8 = undefined; 122 var nb: [128]u8 = undefined;
129 wv.setNotice(shared, std.fmt.bufPrint( 123 wv.setNotice(shared, std.fmt.bufPrint(
@@ -229,13 +223,10 @@ pub fn planHostDiff(
229 t.missed_once = false; 223 t.missed_once = false;
230 continue; 224 continue;
231 } 225 }
232 // A LIVE pump gets one list's grace. The daemon drains a session's 226 // A LIVE pump gets one list's grace: the daemon drains `exit_status`
233 // exit_status before it clears the slot, but the poll's connect and 227 // before clearing the slot, but the poll takes milliseconds the pump can
234 // its pass through the daemon take milliseconds the pump can be 228 // be descheduled for — and vanishing the tile first loses the shell's
235 // descheduled for — and on a wall of one, vanishing the tile first 229 // exit code. A dead pump has no code left to lose.
236 // loses the shell's exit code, because `endedTile` skips a tile that
237 // is no longer present and mux stays up on an empty wall. A dead
238 // pump has no code left to lose, so it goes at once.
239 if (t.alive.load(.acquire) and !t.missed_once) { 230 if (t.alive.load(.acquire) and !t.missed_once) {
240 t.missed_once = true; 231 t.missed_once = true;
241 continue; 232 continue;
@@ -254,12 +245,9 @@ pub const Host = struct {
254 /// The session poll itself, shared with the browser hub: its list, its 245 /// The session poll itself, shared with the browser hub: its list, its
255 /// `reachable`, its `poke` and its 50 ms slices. 246 /// `reachable`, its `poke` and its 50 ms slices.
256 poll: client.SessionPoll = .{}, 247 poll: client.SessionPoll = .{},
257 /// Whether a list of this host's has reached the WALL. Here rather than 248 /// Whether a list of this host's has reached the WALL. Here rather than an
258 /// in an array beside the table, because the picker's `a` grows the 249 /// array beside the table, which the picker's `a` would index out of bounds.
259 /// table and an array sized when the wall opened is one index out of 250 /// Keyboard-thread only, so no lock.
260 /// bounds per added host. Keyboard-thread only, so no lock: a poller flag read
261 /// between its own two stores would restore over a wall still missing
262 /// that host's sessions.
263 applied: bool = false, 251 applied: bool = false,
264 /// Forgotten in the picker: off the file, off the rows, and its poller 252 /// Forgotten in the picker: off the file, off the rows, and its poller
265 /// exits for good. The SLOT stays — a poller thread holds this pointer 253 /// exits for good. The SLOT stays — a poller thread holds this pointer
@@ -359,17 +347,13 @@ pub fn applyHostList(w: Wall, hi: usize) void {
359 changed = true; 347 changed = true;
360 } 348 }
361 var placed: usize = 0; 349 var placed: usize = 0;
362 // The tile the NEXT birth sits beside: the focus for the first, then 350 // The tile the NEXT birth sits beside: the focus first, then the one just
363 // the one just born. `insert` puts a new leaf immediately after its 351 // born. `insert` puts a leaf immediately after its anchor, so anchoring
364 // anchor, so anchoring every birth at the focus would lay a list of 352 // every birth at the focus lays {b, c} out as c, b.
365 // {b, c} out as c, b — a wall reading back-to-front against the
366 // order its daemon reported, and against the digits the chords use.
367 var anchor = wall_layout.anchorTile(w.livePresent(), w.shared.sel); 353 var anchor = wall_layout.anchorTile(w.livePresent(), w.shared.sel);
368 // Stops at the FIRST refusal rather than retrying each name: the 354 // Stops at the FIRST refusal rather than retrying each name: the wall
369 // wall refuses for a reason that holds for the whole list (no slot, 355 // refuses for a reason that holds for the whole list, and this list comes
370 // no room to cut), and this list comes back every second — a 356 // back every second — a per-name retry is a flatten and an undo forever.
371 // per-name retry is an insert, a flatten and an undo per name per
372 // poll, forever.
373 while (placed < births.len) : (placed += 1) { 357 while (placed < births.len) : (placed += 1) {
374 const at = wv.birthTile(w, .{ 358 const at = wv.birthTile(w, .{
375 // Joins, never creates: the daemon already has this session, 359 // Joins, never creates: the daemon already has this session,
@@ -423,16 +407,11 @@ pub fn recordHost(
423 spelling: []const u8, 407 spelling: []const u8,
424 path: []const u8, 408 path: []const u8,
425 ) ?anyerror { 409 ) ?anyerror {
426 // Both doors — `mux HOST` and the picker's `a` — write the line on the 410 // Both doors write the line on the USER's word: a daemon that never answers
427 // user's word: a daemon that never answers is a host the file still remembers, 411 // is a host the file still remembers. The failure comes BACK rather than
428 // rather than a line missing from it. 412 // being printed, because where it may be said differs by door — stderr
429 // 413 // before the wall takes the screen, a notice after. `--via` has no form in
430 // The failure comes BACK rather than being printed, because where it may 414 // the host grammar, so it records nothing and does so silently.
431 // be SAID differs by door: stderr before the wall takes the screen, a
432 // notice after.
433 //
434 // `--via` has no form in the host grammar — an arbitrary command is not
435 // an address — so an attach over one records nothing, and silently.
436 if (target == .via) return null; 415 if (target == .via) return null;
437 _ = hosts.record(alloc, path, spelling) catch |err| return err; 416 _ = hosts.record(alloc, path, spelling) catch |err| return err;
438 return null; 417 return null;
src/tui/wall_layout.zig
Old New
@@ -145,14 +145,11 @@ pub fn relayout(w: Wall, sel: usize) void {
145 const fs_arg: ?u8 = if (w.shared.fullscreen) @intCast(sel) else null; 145 const fs_arg: ?u8 = if (w.shared.fullscreen) @intCast(sel) else null;
146 var cut = w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(live), fs_arg); 146 var cut = w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(live), fs_arg);
147 if (cut) |_| {} else |e| { 147 if (cut) |_| {} else |e| {
148 // A split, an insert, a resize key: those are OPERATIONS, the user 148 // A split or a resize key is an OPERATION the user asked for, and
149 // asked, and refusing leaves the screen exactly as it was. A 149 // refusing leaves the screen as it was. A SIGWINCH is not: the terminal
150 // SIGWINCH is neither — the terminal has ALREADY shrunk, so 150 // has ALREADY shrunk, so refusing leaves every rect pointing past the
151 // refusing leaves every rect pointing past the bottom of a screen 151 // bottom of a cleared screen. Degrade to the focused tile whole and the
152 // that was just cleared. Degrade instead, to the view the wall 152 // rest at 0x0. The TREE is untouched, so growing back re-cuts.
153 // already has for a terminal that holds one pane: the focused tile
154 // whole, the rest at 0x0, which paints nothing and claims no size.
155 // The tree is untouched, so growing back re-cuts every pane.
156 if (e == error.TooSmall and fs_arg == null) { 153 if (e == error.TooSmall and fs_arg == null) {
157 if (w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(live), @intCast(sel))) |only| { 154 if (w.shared.tree.flatten(w.alloc, w.shared.size.rows, w.shared.size.cols, wallFloors(live), @intCast(sel))) |only| {
158 cut = only; 155 cut = only;
@@ -183,10 +180,8 @@ pub fn relayout(w: Wall, sel: usize) void {
183 if (p) wv.ring(t); 180 if (p) wv.ring(t);
184 } 181 }
185 // ...except the tiles with no pump left to hear it: their bars are the 182 // ...except the tiles with no pump left to hear it: their bars are the
186 // keyboard's, the same rule `setFocus` paints a focus move by. No 183 // keyboard's. No `label_rows` guard, unlike `setFocus` — the screen was just
187 // `label_rows` guard here, unlike there: the screen was just cleared, 184 // cleared, so on a one-tile wall that bar is all that says the target refused.
188 // so on a one-tile wall of a dead tile that bar is the only thing left
189 // to say the target refused.
190 wv.paintDeadBarsLocked(w.liveTiles()); 185 wv.paintDeadBarsLocked(w.liveTiles());
191 } 186 }
192 187
src/tui/wall_picker.zig
Old New
@@ -1,9 +1,7 @@
1 //! `Ctrl-\ s`: the host picker's popup. A MODE of the wall's prefix 1 //! `Ctrl-\ s`: the host picker's popup. A MODE of the wall's prefix filter, so
2 //! filter, so every byte typed here is the popup's and none reaches a 2 //! every byte typed here is the popup's and none reaches a session. Rows are
3 //! session. Rows are the hosts file's daemons in file order with the 3 //! the hosts file's daemons in file order with the poller's last answer beside
4 //! poller's last answer beside each; Enter births, `x` forgets, `a` is the 4 //! each; Enter births, `x` forgets, `a` is the spelling editor.
5 //! spelling editor. Paints under `paint_mu` while `Shared.picker_open`
6 //! holds every tile off the terminal.
7 const std = @import("std"); 5 const std = @import("std");
8 const proto = @import("term").protocol; 6 const proto = @import("term").protocol;
9 const client = @import("client"); 7 const client = @import("client");
@@ -307,12 +305,9 @@ pub fn isPickAction(a: interact.PrefixFilter.Action) bool {
307 /// Null when nothing was made. 305 /// Null when nothing was made.
308 pub fn pickBirth(w: Wall, sel: usize) ?usize { 306 pub fn pickBirth(w: Wall, sel: usize) ?usize {
309 // The tile creates on attach exactly as a chord-born one does: no side 307 // The tile creates on attach exactly as a chord-born one does: no side
310 // connection, and no second road onto the wall to keep in step. 308 // connection, no second road onto the wall. A FORGOTTEN host's poller has
311 // A forgotten host is off the rows and out of the file, and its poller 309 // exited, so a tile born on one could never be confirmed or vanished — it
312 // has exited: born on one, a tile no list can ever confirm or vanish 310 // would sit there naming a machine the user just removed.
313 // would sit on the wall naming a machine the user has just removed.
314 // `pickerNearest` cannot save this — with every row gone it returns the
315 // selection unchanged.
316 if (sel >= w.hosts.len or w.hosts[sel].forgotten.load(.acquire)) { 311 if (sel >= w.hosts.len or w.hosts[sel].forgotten.load(.acquire)) {
317 // The one key the footer advertises, on a wall with nothing to 312 // The one key the footer advertises, on a wall with nothing to
318 // birth on: an Enter that closes the popup and does nothing reads 313 // birth on: an Enter that closes the popup and does nothing reads
@@ -321,11 +316,10 @@ pub fn pickBirth(w: Wall, sel: usize) ?usize {
321 return null; 316 return null;
322 } 317 }
323 const h = &w.hosts[sel]; 318 const h = &w.hosts[sel];
324 // Enter IS the ask, and this copy is where that is written down: the 319 // Enter IS the ask, and this COPY is where that is written down: the row is
325 // row it lands on is often the one the poller calls unreachable, and 320 // often the one the poller calls unreachable, and starting that daemon is
326 // starting that machine's daemon is what choosing it means. The SPEC 321 // what choosing it means. The spec is untouched, since the poller re-dials
327 // is untouched — the poller re-dials off it every second, and a wall 322 // off it every second and must not resurrect a stopped daemon.
328 // must not resurrect a daemon whose owner just stopped it.
329 var target = h.spec.target; 323 var target = h.spec.target;
330 if (target == .hand) target.hand.asked = true; 324 if (target == .hand) target.hand.asked = true;
331 var list_buf: [proto.sessions_text_max]u8 = undefined; 325 var list_buf: [proto.sessions_text_max]u8 = undefined;
@@ -373,13 +367,10 @@ pub fn pickForget(w: Wall, sel: usize, path: ?[]const u8) void {
373 if (sel >= w.hosts.len) return; 367 if (sel >= w.hosts.len) return;
374 const h = &w.hosts[sel]; 368 const h = &w.hosts[sel];
375 if (h.forgotten.load(.acquire)) return; 369 if (h.forgotten.load(.acquire)) return;
376 // The file first, for `addHost`'s reason: the wall the user is looking 370 // The file first, for `addHost`'s reason. What the file SAID is what the
377 // at and the wall they get back next time are the same wall. 371 // notice says: a `false` is a line that was not there to remove, and the
378 // 372 // tiles go either way — so a flat `[forgot ...]` would leave the wall and
379 // What the file said is what the notice says. A `false` is a line that 373 // the file disagreeing with nobody told.
380 // was not there to remove — a hand-edited file, or a host this wall took
381 // off argv — and the tiles go either way, so a flat `[forgot ...]` would
382 // leave the wall and the file disagreeing with nobody told.
383 var gone_from_file = true; 374 var gone_from_file = true;
384 var why: ?anyerror = null; 375 var why: ?anyerror = null;
385 if (path) |p| { 376 if (path) |p| {
@@ -403,11 +394,9 @@ pub fn pickForget(w: Wall, sel: usize, path: ?[]const u8) void {
403 wv.setNotice(w.shared, said); 394 wv.setNotice(w.shared, said);
404 } 395 }
405 396
406 /// The popup. Painted by the KEYBOARD thread, which is the only one that 397 /// The popup, painted by the KEYBOARD thread — the only one that knows the
407 /// knows the picker exists: on open, on every key, and on every list that 398 /// picker exists — on open, on every key, and on every list that lands under
408 /// lands under it, so the state column is live while the user reads it. 399 /// it, so the state column is live while the user reads it.
409 ///
410 /// `line` is the spelling editor's text when `a` has it open.
411 pub fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]const u8) void { 400 pub fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]const u8) void {
412 if (!shared.is_tty) return; 401 if (!shared.is_tty) return;
413 // The flag is set before the lock and READ under it (`tilePaintBegin`), 402 // The flag is set before the lock and READ under it (`tilePaintBegin`),
@@ -438,11 +427,9 @@ pub fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]con
438 if (hi == sel) sel_row = i; 427 if (hi == sel) sel_row = i;
439 } 428 }
440 if (shown > 0 and sel_row >= shown) first = sel_row - shown + 1; 429 if (shown > 0 and sel_row >= shown) first = sel_row - shown + 1;
441 // The notice wins the footer over the legend and over the editor's own 430 // The notice wins the footer over the legend and the editor's line: a
442 // line: a refusal the user just earned is the one sentence that cannot 431 // refusal just earned cannot wait for the next keystroke. PEEKED, because
443 // wait for the next keystroke. 432 // the stamp below can still refuse the frame; taken once the bytes are out.
444 // PEEKED: the notice is part of the frame, and the stamp below can
445 // still refuse to write it. Taken only once the bytes are out.
446 var notice_buf: [96]u8 = undefined; 433 var notice_buf: [96]u8 = undefined;
447 const notice = peekNoticeLocked(shared, &notice_buf); 434 const notice = peekNoticeLocked(shared, &notice_buf);
448 const foot: []const u8 = if (notice.len > 0) 435 const foot: []const u8 = if (notice.len > 0)
@@ -571,19 +558,11 @@ pub fn pickerRows(body: *PickerBody, host_table: []Host, sel: usize, cols: u16)
571 const wide = listed >= 10; 558 const wide = listed >= 10;
572 for (host_table, 0..) |*h, hi| { 559 for (host_table, 0..) |*h, hi| {
573 if (h.forgotten.load(.acquire)) continue; 560 if (h.forgotten.load(.acquire)) continue;
574 // Wide enough for the longest thing `hostState` can say: an 561 // Wide enough for an `unreachable` carrying a whole `handoff.Reason`.
575 // `unreachable` carrying a whole `handoff.Reason`. 562 // The state gets what the row can SPARE, never all it could fill:
576 // The state gets what the row can SPARE, never all it could fill. 563 // `pickerRow` cuts the spelling and never the state, so an unbudgeted
577 // `pickerRow` cuts the spelling and never the state, and the state 564 // 71-byte reason leaves three columns of the host name at 80 columns.
578 // now carries a whole ssh sentence where it used to carry a word: 565 // The floor is the bare word, which is what a narrow row always had.
579 // unbudgeted, a 71-byte `unreachable: ...` leaves three columns of
580 // the host name on an 80-column terminal — evicting the one column
581 // that says which machine Enter would start a session on.
582 //
583 // The floor is the longest word the state could say BEFORE this
584 // change, so a row too narrow for a reason is exactly the row it
585 // always was: `hostState` answers a buffer that small with the
586 // bare `unreachable`.
587 var state_buf: ["unreachable: ".len + handoff.reason_max]u8 = undefined; 566 var state_buf: ["unreachable: ".len + handoff.reason_max]u8 = undefined;
588 const spare = @as(usize, cols) -| rowHeadLen(wide) -| h.spec.spelling.len -| 2; 567 const spare = @as(usize, cols) -| rowHeadLen(wide) -| h.spec.spelling.len -| 2;
589 const room = @max("unreachable".len, @min(spare, state_buf.len)); 568 const room = @max("unreachable".len, @min(spare, state_buf.len));
src/xdg.zig
Old New
@@ -1,10 +1,7 @@
1 //! XDG-derived paths shared by both binaries, plus key file creation. 1 //! XDG-derived paths shared by every mode, plus key file creation. The `*From`
2 //! 2 //! variants are PURE — environment handed in, nothing read — because that is
3 //! The `*From` variants are pure — environment handed in, nothing read — 3 //! what makes them testable without setenv, which Zig tests cannot safely do.
4 //! because that is what makes them testable without setenv, which Zig 4 //! The un-suffixed wrappers are one line each.
5 //! tests cannot safely do in-process. The un-suffixed wrappers read the
6 //! real environment and are one line each, thin enough to trust by
7 //! inspection.
8 const std = @import("std"); 5 const std = @import("std");
9 6
10 /// The one place the default key location is spelled; mux d keygen writes 7 /// The one place the default key location is spelled; mux d keygen writes
@@ -105,42 +102,28 @@ pub fn hostCachePathFrom(
105 return pathFrom(alloc, xdg_cache_home, home, ".cache", tail); 102 return pathFrom(alloc, xdg_cache_home, home, ".cache", tail);
106 } 103 }
107 104
108 /// Create `dir` and everything above it, then tighten `dir` itself to 105 /// Create `dir` and everything above it, then tighten `dir` itself to 0700 —
109 /// 0700. The directories this project creates UNDER THE USER'S OWN HOME — 106 /// what the directories under the user's OWN HOME want.
110 /// the key file's parent and the handoff cache's — want exactly this, so
111 /// the policy and the two subtle parts of it live here rather than in a
112 /// copy per caller.
113 /// 107 ///
114 /// Adopts a directory already at `dir`, and resolves the path through any 108 /// It adopts a directory already there and follows symlinks, both right under
115 /// symlink on the way, both of which are right under `~` (an existing 109 /// `~` (an existing `~/.config/mux` must not make `keygen` refuse) and wrong
116 /// `~/.config/mux` must not make `keygen` refuse, and a user who symlinked 110 /// anywhere a stranger can create entries. Those callers want
117 /// their config elsewhere meant it) and wrong anywhere a stranger can 111 /// `makeNewPrivateDir`.
118 /// create entries. Those callers want `makeNewPrivateDir`.
119 pub fn makePrivateDir(dir: []const u8) !void { 112 pub fn makePrivateDir(dir: []const u8) !void {
120 try std.fs.cwd().makePath(dir); 113 try std.fs.cwd().makePath(dir);
121 // makePath leaves 0755, which does not expose a contained file's 114 // `makePath` leaves 0755, which exposes that a file exists and what it is
122 // contents — that is 0600 — but does expose that it exists and what it 115 // called. Only THIS component is tightened — the parents are the user's own
123 // is called. ssh's answer for the analogous directory is 0700 and 116 // business. `.iterate = true` is not optional: `Dir.chmod` fchmods the
124 // there is no reason to be looser. Only THIS component is tightened: 117 // directory's fd, which is opened O_PATH without it.
125 // the parents on the way (`~`, `~/.config`, the runtime directory) are
126 // the user's own business and are not ours to re-permission.
127 // `.iterate = true` is not optional here: Dir.chmod fchmods the
128 // directory's own fd, and without it the fd is opened O_PATH, which
129 // fchmod refuses.
130 var d = try std.fs.cwd().openDir(dir, .{ .iterate = true }); 118 var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
131 defer d.close(); 119 defer d.close();
132 try d.chmod(0o700); 120 try d.chmod(0o700);
133 } 121 }
134 122
135 /// `makePrivateDir`'s 0700 policy where the parent is not ours: 123 /// `makePrivateDir`'s 0700 policy where the parent is NOT ours. So it refuses an
136 /// `$XDG_RUNTIME_DIR`, or a shared `/tmp` when that is unset. So it refuses 124 /// existing entry rather than adopting it: one pre-created as a symlink would
137 /// an existing entry instead of adopting it: one pre-created by another 125 /// take the chmod to the link's TARGET. The mode goes to `mkdir` so the
138 /// user as a symlink would take the chmod to the link's TARGET, leaving the 126 /// directory is never briefly 0755, and the chmod after undoes the umask.
139 /// caller writing inside a directory it does not own.
140 ///
141 /// The mode goes to `mkdir` so the directory is never briefly 0755; the
142 /// chmod after it undoes the umask, which on 0500 would lock this daemon
143 /// out.
144 pub fn makeNewPrivateDir(dir: []const u8) !void { 127 pub fn makeNewPrivateDir(dir: []const u8) !void {
145 std.posix.mkdir(dir, 0o700) catch |err| switch (err) { 128 std.posix.mkdir(dir, 0o700) catch |err| switch (err) {
146 // Not ours. Named separately from the other errors because it is 129 // Not ours. Named separately from the other errors because it is
@@ -352,12 +335,9 @@ test "writeNewKey: creates 0600 with 32 bytes, refuses to overwrite" {
352 var first: [32]u8 = undefined; 335 var first: [32]u8 = undefined;
353 try std.testing.expectEqual(@as(usize, 32), try f.preadAll(&first, 0)); 336 try std.testing.expectEqual(@as(usize, 32), try f.preadAll(&first, 0));
354 337
355 // Refusal leaves the file byte-identical: a credential is never 338 // Refusal leaves the file byte-identical: a credential is never silently
356 // silently replaced. Re-opened by PATH rather than read through `f`, 339 // replaced. Re-opened by PATH, because a kept fd follows the INODE — an
357 // because the kept fd follows the inode: an implementation that 340 // implementation that unlinked and rewrote would still pass through `f`.
358 // unlinked and rewrote the key would still hand the old fd the old
359 // bytes, and this assertion would pass while the credential on disk
360 // had been replaced.
361 try std.testing.expectError(error.KeyExists, writeNewKey(path)); 341 try std.testing.expectError(error.KeyExists, writeNewKey(path));
362 const f2 = try std.fs.cwd().openFile(path, .{}); 342 const f2 = try std.fs.cwd().openFile(path, .{});
363 defer f2.close(); 343 defer f2.close();
tools/docscheck.zig
Old New
@@ -58,13 +58,9 @@ fn commentBody(line: []const u8) ?[]const u8 {
58 } 58 }
59 59
60 /// The code half of a line, for the corpus: literal spans are blanked and 60 /// The code half of a line, for the corpus: literal spans are blanked and
61 /// everything from an unquoted `//` onward is cut off. A word that exists 61 /// everything from an unquoted `//` on is cut off. A word that exists only
62 /// only inside quotes is no evidence that a symbol exists — `zig` resolved 62 /// inside quotes is no evidence a symbol exists. Blanking IN PLACE is
63 /// for exactly that reason, off `b.path("src/cli/main.zig")` in build.zig, and 63 /// deliberate — the corpus stores slices of these buffers.
64 /// `zig` is the tail of every module citation.
65 ///
66 /// Blanking IN PLACE is deliberate: the corpus stores slices of these
67 /// buffers, so a stripped copy would hand it keys that dangle one line later.
68 fn codeOf(line: []u8) []u8 { 64 fn codeOf(line: []u8) []u8 {
69 // A `\\` line is multiline-string content to its end, quotes and `//` 65 // A `\\` line is multiline-string content to its end, quotes and `//`
70 // included. There is no code on it to keep. 66 // included. There is no code on it to keep.
@@ -161,15 +157,10 @@ const Finding = struct {
161 text: []const u8, 157 text: []const u8,
162 }; 158 };
163 159
164 /// Tier 2's codename patterns. Each returns the matched span's length at `i`, 160 /// Tier 2's codename patterns; each returns the matched span's length at `i`,
165 /// or 0. Measured against the corpus before being turned on: 161 /// or 0. `M<digits>` is uppercase-only, because lowercase `m8` would be a coin
166 /// 162 /// flip. `Phase N` / `Task N` require the NUMBER: bare "phase" is domain
167 /// `M<digits>` 13 occurrences in src/, every one a milestone name, zero 163 /// vocabulary here, since `cmd.phase` is a live field.
168 /// collisions — uppercase M only, because that is the
169 /// convention and lowercase `m8` would be a coin flip.
170 /// `Phase N` / `Task N` the NUMBER is what makes it a codename. Bare
171 /// "phase" is domain vocabulary here — `cmd.phase` is a live
172 /// field with 10 comment mentions — so the digit is required.
173 fn codenameAt(s: []const u8, i: usize) usize { 164 fn codenameAt(s: []const u8, i: usize) usize {
174 // The browser-client milestone spells its letter with a word after the 165 // The browser-client milestone spells its letter with a word after the
175 // dash, so both ends are checked or a real word would read as a codename. 166 // dash, so both ends are checked or a real word would read as a codename.
@@ -198,11 +189,9 @@ fn codenameAt(s: []const u8, i: usize) usize {
198 return 0; 189 return 0;
199 } 190 }
200 191
201 /// Where a `.zig` reference sits: the basename, and the directory prefix that 192 /// Where a `.zig` reference sits: the basename, and the directory prefix before
202 /// precedes it (empty when there is none). `replica.zig/wasm_core.zig` — the 193 /// it. `replica.zig/wasm_core.zig` yields an empty prefix for the second name,
203 /// repo's way of writing "both of these" — yields an empty prefix for the 194 /// because a prefix segment that is itself a .zig file is not a directory.
204 /// second name, because a prefix segment that is itself a .zig file is not a
205 /// directory.
206 const ZigRef = struct { name: []const u8, prefix: []const u8 }; 195 const ZigRef = struct { name: []const u8, prefix: []const u8 };
207 196
208 fn zigRefAt(s: []const u8, dot: usize) ?ZigRef { 197 fn zigRefAt(s: []const u8, dot: usize) ?ZigRef {
@@ -309,15 +298,10 @@ pub fn main() !u8 {
309 const src = try f.readToEndAlloc(alloc, 64 * 1024 * 1024); 298 const src = try f.readToEndAlloc(alloc, 64 * 1024 * 1024);
310 try bufs.append(alloc, src); 299 try bufs.append(alloc, src);
311 try basenames.put(alloc, std.fs.path.basename(p), {}); 300 try basenames.put(alloc, std.fs.path.basename(p), {});
312 // CODE only, and `codeOf` is what makes that true rather than 301 // CODE only, and `codeOf` is what makes that exact: a citation that
313 // approximate. Comment LINES were excluded from the start — found by 302 // resolved against the very comment making it would pass every invented
314 // the red test, where a citation resolved against the very comment 303 // name. Iterated by hand rather than with `splitScalar`, because
315 // that made it and every invented name passed. Trailing comments and 304 // `codeOf` blanks in place and the lines must stay mutable.
316 // string literals were the same bug wearing a different hat, and they
317 // survived a year longer.
318 //
319 // Iterated by hand rather than with splitScalar: `codeOf` blanks in
320 // place, so the lines have to stay mutable.
321 var ls: usize = 0; 305 var ls: usize = 0;
322 while (ls <= src.len) { 306 while (ls <= src.len) {
323 const nl = std.mem.indexOfScalarPos(u8, src, ls, '\n') orelse src.len; 307 const nl = std.mem.indexOfScalarPos(u8, src, ls, '\n') orelse src.len;
try.sh
Old New
@@ -0,0 +1,25 @@
1 #!/bin/sh
2 # Feel the shape-first restore in your own terminal, fully isolated from
3 # your live daemon and wall. Run it twice: the second run IS the demo —
4 # the wall opens already wearing the beside cut you left, no stripes, no
5 # 2 s rearrange, even though two dead ssh hosts are on the wall.
6 set -eu
7 cd "$(dirname "$0")"
8 deps/zig/zig build
9 S="${TMPDIR:-/tmp}/mux-try-shape"
10 mkdir -p "$S/state/mux" "$S/rt"
11 chmod 700 "$S/rt"
12 export XDG_STATE_HOME="$S/state" XDG_RUNTIME_DIR="$S/rt"
13 export XDG_CONFIG_HOME="$S/cfg" XDG_CACHE_HOME="$S/cache"
14 SOCK="$S/d.sock"
15 zig-out/bin/mux d start --sock "$SOCK" >/dev/null 2>&1 || true
16 {
17 echo "--sock $SOCK"
18 echo "alex@203.0.113.7" # TEST-NET: never answers, like a box that is off
19 echo "ubuntu@203.0.113.8"
20 } > "$S/state/mux/hosts"
21 echo "try: one local daemon + two dead ssh hosts on the wall."
22 echo "try: split with Ctrl-\\ | , resize with Ctrl-\\ r, leave with Ctrl-\\ d,"
23 echo "try: then run me again — the wall must open ALREADY in your layout."
24 echo
25 exec zig-out/bin/mux