90394750
refactor: the small modules say what they are for
a73x 2026-08-30 20:13
Commit message
.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, ¬ice_buf); | 434 | const notice = peekNoticeLocked(shared, ¬ice_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 | ||