d7967ffa
fix: the small ones the review found — a hint, a note, a test, an install
a73x 2026-08-29 02:26
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -16,7 +16,7 @@ deps: | |||
| 16 | build: | 16 | build: |
| 17 | $(ZIG) build | 17 | $(ZIG) build |
| 18 | 18 | ||
| 19 | # Only the four user-facing binaries: everything else in zig-out/bin | 19 | # Only the ONE user-facing binary: everything else in zig-out/bin |
| 20 | # (ptyclient, wsclient, rawmode, ...) is a test fixture that would shadow | 20 | # (ptyclient, wsclient, rawmode, ...) is a test fixture that would shadow |
| 21 | # nothing useful on PATH. | 21 | # nothing useful on PATH. |
| 22 | BINDIR ?= $(HOME)/.local/bin | 22 | BINDIR ?= $(HOME)/.local/bin |
| @@ -30,11 +30,15 @@ BINDIR ?= $(HOME)/.local/bin | |||
| 30 | # overwrite zig-out/bin, leaving the dev tree's binaries and test fixtures | 30 | # overwrite zig-out/bin, leaving the dev tree's binaries and test fixtures |
| 31 | # silently not the ones `make test` just built — the same trap xversion-build | 31 | # silently not the ones `make test` just built — the same trap xversion-build |
| 32 | # documents below. | 32 | # documents below. |
| 33 | # The `rm -f` in the recipe takes the three names this binary replaced off | ||
| 34 | # PATH: a box with an older install keeps them forever otherwise, and a | ||
| 35 | # `muxd` still answering `--version` is a daemon someone will start. | ||
| 33 | INSTDIR ?= dist/install | 36 | INSTDIR ?= dist/install |
| 34 | install: | 37 | install: |
| 35 | $(ZIG) build -Doptimize=ReleaseSafe -p $(INSTDIR) | 38 | $(ZIG) build -Doptimize=ReleaseSafe -p $(INSTDIR) |
| 36 | install -d $(BINDIR) | 39 | install -d $(BINDIR) |
| 37 | install -m755 $(INSTDIR)/bin/mux $(BINDIR)/ | 40 | install -m755 $(INSTDIR)/bin/mux $(BINDIR)/ |
| 41 | rm -f $(BINDIR)/muxd $(BINDIR)/muxa $(BINDIR)/muxweb | ||
| 38 | 42 | ||
| 39 | # The artifact a release publishes, built the way every release since | 43 | # The artifact a release publishes, built the way every release since |
| 40 | # v0.0.1-4 has been: static musl so one binary runs on any x86_64 Linux, | 44 | # v0.0.1-4 has been: static musl so one binary runs on any x86_64 Linux, |
README.md
| Old | New | ||
|---|---|---|---|
| @@ -560,6 +560,13 @@ and run`. A session in the middle of being ended (`Ctrl-\` `x`, up to half a | |||
| 560 | second while its shell is being hung up) refuses the upgrade with `session | 560 | second while its shell is being hung up) refuses the upgrade with `session |
| 561 | ending, retry` rather than carrying a shell with no master across the exec. | 561 | ending, retry` rather than carrying a shell with no master across the exec. |
| 562 | 562 | ||
| 563 | Coming from v0.0.1-15 or older is the one upgrade `mux d upgrade` cannot | ||
| 564 | do: that daemon probes a candidate for `muxd <version>` and this binary | ||
| 565 | prints `mux <version>`, so it refuses. `mux d stop` then `mux d start`, | ||
| 566 | once per box. `make install` deletes `muxd`, `muxa` and `muxweb` from the | ||
| 567 | install directory on its way in — they are modes of `mux` now, and a | ||
| 568 | `muxd` left on PATH is a second daemon waiting for someone to start it. | ||
| 569 | |||
| 563 | Copy and paste work through the session: a mux drag and an application's own | 570 | Copy and paste work through the session: a mux drag and an application's own |
| 564 | OSC 52 write both reach your terminal's clipboard (including from a remote box over QUIC, | 571 | OSC 52 write both reach your terminal's clipboard (including from a remote box over QUIC, |
| 565 | where nothing else can), a paste arrives bracketed when the application | 572 | where nothing else can), a paste arrives bracketed when the application |
docs/roadmap.md
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,10 @@ | |||
| 1 | # Roadmap | 1 | # Roadmap |
| 2 | 2 | ||
| 3 | Entries before 2026-08-28 predate the one-binary move and name `muxd`, | ||
| 4 | `muxa` and `muxweb`, which are now the `d`, `a` and `web` modes of `mux`; | ||
| 5 | paths they cite as `src/X.zig` live under a domain folder. They are the | ||
| 6 | record of what was decided and are left as written. | ||
| 7 | |||
| 3 | > The wall became a list of DAEMONS on 2026-08-28 (decisions.md, same date): | 8 | > The wall became a list of DAEMONS on 2026-08-28 (decisions.md, same date): |
| 4 | > every wall-file item below describes the old attach-history file and is | 9 | > every wall-file item below describes the old attach-history file and is |
| 5 | > closed. The hub's move onto `hosts` is phase 2 and is the live item. | 10 | > closed. The hub's move onto `hosts` is phase 2 and is the live item. |
| @@ -24,7 +29,7 @@ being predicted, and shipped as M13. | |||
| 24 | **Target, stated 2026-08-15:** replace tmux as a daily driver. An agent | 29 | **Target, stated 2026-08-15:** replace tmux as a daily driver. An agent |
| 25 | driving a session is a nice-to-have behind that, not a peer. The first | 30 | driving a session is a nice-to-have behind that, not a peer. The first |
| 26 | consequence was immediate — shell integration became opt-in, because its | 31 | consequence was immediate — shell integration became opt-in, because its |
| 27 | two known costs land on the human's shell and only `mux a` reads what they | 32 | two known costs land on the human's shell and only `muxa` reads what they |
| 28 | buy (decisions.md, 2026-08-15). The queue below is ranked against the old | 33 | buy (decisions.md, 2026-08-15). The queue below is ranked against the old |
| 29 | framing and is being re-ranked against this one; the tracker | 34 | framing and is being re-ranked against this one; the tracker |
| 30 | (`git-collab issue list`) carries the live order. | 35 | (`git-collab issue list`) carries the live order. |
| @@ -54,14 +59,14 @@ proven so by a mutation that fired immediately. | |||
| 54 | ## M10 — QUIC ergonomics — complete | 59 | ## M10 — QUIC ergonomics — complete |
| 55 | 60 | ||
| 56 | **Verdict: cleared on the real box.** The whole QUIC story is four | 61 | **Verdict: cleared on the real box.** The whole QUIC story is four |
| 57 | commands — `mux d keygen`, one ssh line placing the key, `ssh HOST 'mux d | 62 | commands — `muxd keygen`, one ssh line placing the key, `ssh HOST 'muxd |
| 58 | start --quic 0.0.0.0'`, `mux quic://HOST` — no port or key path typed | 63 | start --quic 0.0.0.0'`, `mux quic://HOST` — no port or key path typed |
| 59 | anywhere, trust posture unchanged. Kill criterion held on the LAN box: | 64 | anywhere, trust posture unchanged. Kill criterion held on the LAN box: |
| 60 | first-attach success from a clean slate, a marker surviving full ssh | 65 | first-attach success from a clean slate, a marker surviving full ssh |
| 61 | logout, ten attach/detach cycles with exactly one daemon counted by | 66 | logout, ten attach/detach cycles with exactly one daemon counted by |
| 62 | tracked pid (decisions.md, M10). systemd is deleted, not deferred: the | 67 | tracked pid (decisions.md, M10). systemd is deleted, not deferred: the |
| 63 | contrib/ units and `LISTEN_FDS` activation are gone, with the removal | 68 | contrib/ units and `LISTEN_FDS` activation are gone, with the removal |
| 64 | commit as the resurrection reference. En route: a panic on `mux d | 69 | commit as the resurrection reference. En route: a panic on `muxd |
| 65 | start`'s own first-run-mistake path, two plan mutation gates that could | 70 | start`'s own first-run-mistake path, two plan mutation gates that could |
| 66 | not fail, and a third cross-binary constant drift — all fixed, all with | 71 | not fail, and a third cross-binary constant drift — all fixed, all with |
| 67 | the rule recorded ("assert the literal, never the constant the code | 72 | the rule recorded ("assert the literal, never the constant the code |
| @@ -108,13 +113,13 @@ failing with the mutation's own signature — and the rule extracted is | |||
| 108 | **an assertion about order must be an assertion the bytes can actually | 113 | **an assertion about order must be an assertion the bytes can actually |
| 109 | carry**. | 114 | carry**. |
| 110 | 115 | ||
| 111 | ## M13 — trial friction: auto-start, `mux d stop`, error audit — complete | 116 | ## M13 — trial friction: auto-start, `muxd stop`, error audit — complete |
| 112 | 117 | ||
| 113 | **Verdict: cleared, both legs.** The three field findings that headed | 118 | **Verdict: cleared, both legs.** The three field findings that headed |
| 114 | this list are closed. `mux user@host` and `mux d proxy` against a box | 119 | this list are closed. `mux user@host` and `muxd proxy` against a box |
| 115 | with no daemon now start one — one helper, `spawn.ensureForAttach`, at | 120 | with no daemon now start one — one helper, `spawn.ensureForAttach`, at |
| 116 | both call sites, spawning a **bare** `run --sock` (never `--quic`), no | 121 | both call sites, spawning a **bare** `run --sock` (never `--quic`), no |
| 117 | opt-out, and the warm path stays silent by contract. `mux d stop` exists | 122 | opt-out, and the warm path stays silent by contract. `muxd stop` exists |
| 118 | as a protocol verb (`stop_req = 0x07`), armed on both dispatches because | 123 | as a protocol verb (`stop_req = 0x07`), armed on both dispatches because |
| 119 | a connection is an *observer* until it attaches; it is idempotent when | 124 | a connection is an *observer* until it attaches; it is idempotent when |
| 120 | nothing is listening (exit 0) and honest when a daemon is wedged — | 125 | nothing is listening (exit 0) and honest when a daemon is wedged — |
| @@ -130,14 +135,14 @@ predicted them, printing the predicted text (decisions.md, M13, which | |||
| 130 | carries the table and the failure lines). En route: auto-start turns "a | 135 | carries the table and the failure lines). En route: auto-start turns "a |
| 131 | second daemon on another socket" from an explicit act into an ssh side | 136 | second daemon on another socket" from an explicit act into an ssh side |
| 132 | effect, which is why the daemon log is now opened **append-only** and | 137 | effect, which is why the daemon log is now opened **append-only** and |
| 133 | truncation is reserved for `mux d start`; and three review prescriptions | 138 | truncation is reserved for `muxd start`; and three review prescriptions |
| 134 | were rejected with evidence, which is the adversarial flow running in | 139 | were rejected with evidence, which is the adversarial flow running in |
| 135 | the direction it is easier to forget. | 140 | the direction it is easier to forget. |
| 136 | 141 | ||
| 137 | ## M14 — ssh→QUIC handoff — complete | 142 | ## M14 — ssh→QUIC handoff — complete |
| 138 | 143 | ||
| 139 | **Verdict: cleared, both legs.** `mux HOST` no longer chooses a | 144 | **Verdict: cleared, both legs.** `mux HOST` no longer chooses a |
| 140 | transport; it earns one. An ssh to `mux d endpoint` fetches the daemon's | 145 | transport; it earns one. An ssh to `muxd endpoint` fetches the daemon's |
| 141 | QUIC port and key as a mandatory one-line announce, the session moves | 146 | QUIC port and key as a mandatory one-line announce, the session moves |
| 142 | onto QUIC and the coordination ssh is reaped, and the coordinates are | 147 | onto QUIC and the coordination ssh is reaped, and the coordinates are |
| 143 | cached so every later attach dials direct with no ssh process at all. | 148 | cached so every later attach dials direct with no ssh process at all. |
| @@ -209,15 +214,15 @@ and **CONVERGED byte-clean** against the daemon's grid on detach; UDP | |||
| 209 | stayed `up` → rule removed → `up` with the grid content intact across | 214 | stayed `up` → rule removed → `up` with the grid content intact across |
| 210 | the epoch; page + /tiles served through an ssh tunnel (−R; the box has | 215 | the epoch; page + /tiles served through an ssh tunnel (−R; the box has |
| 211 | no ssh route back, so −L was exercised as its mirror image). Two field | 216 | no ssh route back, so −L was exercised as its mirror image). Two field |
| 212 | findings, filed below in the candidates: the hub has ONE `--key` for | 217 | findings, filed below in the candidates: muxweb has ONE `--key` for |
| 213 | all quic tiles (per-box keys cannot mix), and the deployment habit of | 218 | all quic tiles (per-box keys cannot mix), and the deployment habit of |
| 214 | per-box `mux d keygen` had left desktop/LAN/WAN with three different | 219 | per-box `muxd keygen` had left desktop/LAN/WAN with three different |
| 215 | keys — silent mutual-auth refusal presenting as "did not answer" on | 220 | keys — silent mutual-auth refusal presenting as "did not answer" on |
| 216 | every dial. Trial unified the boxes on a fresh key; the CLI observer | 221 | every dial. Trial unified the boxes on a fresh key; the CLI observer |
| 217 | clip also confirmed renderClipped's top-left window is what an | 222 | clip also confirmed renderClipped's top-left window is what an |
| 218 | undersized client sees of a larger grid. | 223 | undersized client sees of a larger grid. |
| 219 | 224 | ||
| 220 | What shipped: `mux web` (localhost-only hub; tiles are argv in mux's own | 225 | What shipped: `muxweb` (localhost-only hub; tiles are argv in mux's own |
| 221 | TARGET spellings; one WebSocket per tile; std.http's upgrade with the | 226 | TARGET spellings; one WebSocket per tile; std.http's upgrade with the |
| 222 | Origin gate ours), `replica.zig` (the replay core extracted from | 227 | Origin gate ours), `replica.zig` (the replay core extracted from |
| 223 | `session()` and unit-tested for the first time), `keymap.zig`, | 228 | `session()` and unit-tested for the first time), `keymap.zig`, |
| @@ -248,9 +253,9 @@ spawn (`shellint.zig` — zsh `ZDOTDIR` shim, bash `--init-file`, fish | |||
| 248 | `vendor_conf.d`), a mux-owned ghostty-vt stream handler that surfaces them | 253 | `vendor_conf.d`), a mux-owned ghostty-vt stream handler that surfaces them |
| 249 | as row-stamped events, a pure command state machine (`cmd.zig`), three | 254 | as row-stamped events, a pure command state machine (`cmd.zig`), three |
| 250 | frame pairs (`cmd_state`, `await_req`/`await_reply`, | 255 | frame pairs (`cmd_state`, `await_req`/`await_reply`, |
| 251 | `status_req`/`status_reply`) that ride `mux d proxy` untouched, server-held | 256 | `status_req`/`status_reply`) that ride `muxd proxy` untouched, server-held |
| 252 | awaits with a pgid edge and a settle floor under the marks path, and | 257 | awaits with a pgid edge and a settle floor under the marks path, and |
| 253 | `mux a` — a fourth binary, five verbs, one JSON object per verb, attaching | 258 | `muxa` — a fourth binary, five verbs, one JSON object per verb, attaching |
| 254 | at 0×0 so an agent never claims the human's grid. e2e is `test/agent.sh`, | 259 | at 0×0 so an agent never claims the human's grid. e2e is `test/agent.sh`, |
| 255 | **9 scenarios**, including a QUIC tear healed mid-await with the command | 260 | **9 scenarios**, including a QUIC tear healed mid-await with the command |
| 256 | proven to have run exactly once. The decisions worth reading before | 261 | proven to have run exactly once. The decisions worth reading before |
| @@ -281,20 +286,20 @@ they are no longer on the default path. See decisions.md, that date.)* | |||
| 281 | - **`--timeout N` can overshoot N** by the client's grace window (2s over a | 286 | - **`--timeout N` can overshoot N** by the client's grace window (2s over a |
| 282 | socket, up to 30s over a slow QUIC handshake): an agent budgeting wall | 287 | socket, up to 30s over a slow QUIC handshake): an agent budgeting wall |
| 283 | clock must add it. | 288 | clock must add it. |
| 284 | - **`mux a` has no idle-timeout flag**, deliberately — `--timeout` is the | 289 | - **`muxa` has no idle-timeout flag**, deliberately — `--timeout` is the |
| 285 | only bound on a wait. | 290 | only bound on a wait. |
| 286 | 291 | ||
| 287 | **Deferred, and named** (the spec's non-goals plus what review added): an | 292 | **Deferred, and named** (the spec's non-goals plus what review added): an |
| 288 | MCP server wrapping `mux a`; read-only or capability-scoped auth (one key = | 293 | MCP server wrapping `muxa`; read-only or capability-scoped auth (one key = |
| 289 | full control still); input attribution; semantic event subscriptions | 294 | full control still); input attribution; semantic event subscriptions |
| 290 | beyond the `cmd_state` push; `mux a drive` as single-shot sugar; the | 295 | beyond the `cmd_state` push; `muxa drive` as single-shot sugar; the |
| 291 | `.zshenv` shim; bash-preexec coexistence; the shim-directory startup | 296 | `.zshenv` shim; bash-preexec coexistence; the shim-directory startup |
| 292 | sweep. The `parseQuicAddr`/`resolveHost` dedup that review endorsed is | 297 | sweep. The `parseQuicAddr`/`resolveHost` dedup that review endorsed is |
| 293 | **paid**, not deferred — one owner in `quic.zig`, landed after the e2e had | 298 | **paid**, not deferred — one owner in `quic.zig`, landed after the e2e had |
| 294 | pinned the spellings. | 299 | pinned the spellings. |
| 295 | 300 | ||
| 296 | **Dropped in execution, not decided — the one item here that nobody chose:** | 301 | **Dropped in execution, not decided — the one item here that nobody chose:** |
| 297 | `mux a capture --diff-since SEQ`. The spec promises it three times and no | 302 | `muxa capture --diff-since SEQ`. The spec promises it three times and no |
| 298 | code was written; the whole-branch review found it. A future one needs a | 303 | code was written; the whole-branch review found it. A future one needs a |
| 299 | protocol field of its own for the live stream seq, because `status_reply`'s | 304 | protocol field of its own for the live stream seq, because `status_reply`'s |
| 300 | seq now carries the return watermark — right for awaits, wrong for a diff | 305 | seq now carries the return watermark — right for awaits, wrong for a diff |
| @@ -302,7 +307,7 @@ to quote (decisions.md). Two coverage narrowings ride with it: the **fish** | |||
| 302 | injection arm is unit-tested only (no fish on this box; zsh and bash are | 307 | injection arm is unit-tested only (no fish on this box; zsh and bash are |
| 303 | driven live), and the spec's **version-skew fixture** was never built (the | 308 | driven live), and the spec's **version-skew fixture** was never built (the |
| 304 | behaviour is pinned through the dead-daemon paths, an old binary is not). | 309 | behaviour is pinned through the dead-daemon paths, an old binary is not). |
| 305 | And `mux a capture` is **the one verb with no automated coverage at all** — | 310 | And `muxa capture` is **the one verb with no automated coverage at all** — |
| 306 | `test/agent.sh` drives run, await, send and status; capture is where | 311 | `test/agent.sh` drives run, await, send and status; capture is where |
| 307 | `--diff-since` would land, so the test and the flag are owed together. | 312 | `--diff-since` would land, so the test and the flag are owed together. |
| 308 | *(Closed in M18: the multi-session e2e block drives `capture --session` | 313 | *(Closed in M18: the multi-session e2e block drives `capture --session` |
| @@ -323,16 +328,16 @@ frame tagging, no QUIC stream surgery, the transport still one bidi | |||
| 323 | stream per connection. Same-host tiles are separate connections to the | 328 | stream per connection. Same-host tiles are separate connections to the |
| 324 | same port. Attach-or-create is tmux-style; a session ends when its shell | 329 | same port. Attach-or-create is tmux-style; a session ends when its shell |
| 325 | exits and the daemon exits with the last one. `status_req`, `await_req` | 330 | exits and the daemon exits with the last one. `status_req`, `await_req` |
| 326 | and `debug_dump` grew the same tail, which is what lets `mux a` and `mux d | 331 | and `debug_dump` grew the same tail, which is what lets `muxa` and `muxd |
| 327 | dump` address one session of several without attaching to it. | 332 | dump` address one session of several without attaching to it. |
| 328 | 333 | ||
| 329 | **Field-verified 2026-08-15 on both real boxes.** They had been on | 334 | **Field-verified 2026-08-15 on both real boxes.** They had been on |
| 330 | **0.0.1-3** — behind the agent surface as well as M18 — and a site power | 335 | **0.0.1-3** — behind the agent surface as well as M18 — and a site power |
| 331 | cut had taken them down, which made the upgrade window a clean one: no | 336 | cut had taken them down, which made the upgrade window a clean one: no |
| 332 | daemon running on either, so nothing to tear down and no session to lose. | 337 | daemon running on either, so nothing to tear down and no session to lose. |
| 333 | Static musl `mux` (stripped, 23MB → 8.6MB) installed to | 338 | Static musl `muxd` (stripped, 23MB → 8.6MB) installed to |
| 334 | `/usr/local/bin`, identical sha256 on both boxes and on the build host, | 339 | `/usr/local/bin`, identical sha256 on both boxes and on the build host, |
| 335 | then `mux d start --quic` on each. On the LAN box over real QUIC, and on | 340 | then `muxd start --quic` on each. On the LAN box over real QUIC, and on |
| 336 | the WAN box over QUIC through the LAN-side UDP forward (the gate carries | 341 | the WAN box over QUIC through the LAN-side UDP forward (the gate carries |
| 337 | TCP only): two dials, `--session a` and `--session b`, produced | 342 | TCP only): two dials, `--session a` and `--session b`, produced |
| 338 | `sessions=3 session 0 / session a / session b` on **both** boxes, with | 343 | `sessions=3 session 0 / session a / session b` on **both** boxes, with |
| @@ -340,9 +345,9 @@ each session holding its own marker and neither holding its neighbour's, | |||
| 340 | and the default session holding neither. The milestone's central claim, | 345 | and the default session holding neither. The milestone's central claim, |
| 341 | on real hardware over a real link. | 346 | on real hardware over a real link. |
| 342 | 347 | ||
| 343 | Two deployment notes worth keeping. `mux d --version` still prints | 348 | Two deployment notes worth keeping. `muxd --version` still prints |
| 344 | **0.0.1-5** after the upgrade — M18 is untagged, so the version string | 349 | **0.0.1-5** after the upgrade — M18 is untagged, so the version string |
| 345 | cannot tell you whether a box has it; `mux d stats | grep sessions=` is | 350 | cannot tell you whether a box has it; `muxd stats | grep sessions=` is |
| 346 | the check that can, which is the same discriminator the banked | 351 | the check that can, which is the same discriminator the banked |
| 347 | capability probe uses. And the trial PSK survived the power cycle | 352 | capability probe uses. And the trial PSK survived the power cycle |
| 348 | identical on both boxes (`ba046148…`), so the M17 key-drift failure did | 353 | identical on both boxes (`ba046148…`), so the M17 key-drift failure did |
| @@ -354,17 +359,17 @@ shell over QUIC as over the unix socket, and two QUIC dials to one port | |||
| 354 | are two shells. Pinned end to end by the QUIC block: two dials into two | 359 | are two shells. Pinned end to end by the QUIC block: two dials into two |
| 355 | sessions, then a socket client joins one of them and both clients' | 360 | sessions, then a socket client joins one of them and both clients' |
| 356 | markers land on one grid with the session count unmoved. That also makes | 361 | markers land on one grid with the session count unmoved. That also makes |
| 357 | a mixed-transport wall of a single host work — `mux web HOST#a | 362 | a mixed-transport wall of a single host work — `muxweb HOST#a |
| 358 | quic://HOST:8787#b` is tile 0 over ssh and tile 1 over QUIC against one | 363 | quic://HOST:8787#b` is tile 0 over ssh and tile 1 over QUIC against one |
| 359 | daemon. (`mux d run --sock PATH --quic ADDR` binds both; ssh needs no | 364 | daemon. (`muxd run --sock PATH --quic ADDR` binds both; ssh needs no |
| 360 | listener of its own, since `mux d proxy` pumps into the same socket.) | 365 | listener of its own, since `muxd proxy` pumps into the same socket.) |
| 361 | 366 | ||
| 362 | **The compat story held**, measured against main @ 5d41e10 (the branch | 367 | **The compat story held**, measured against main @ 5d41e10 (the branch |
| 363 | point) in both directions. An empty name is the default session and | 368 | point) in both directions. An empty name is the default session and |
| 364 | encodes to byte-identical pre-M18 payloads: an M18 client drove a | 369 | encodes to byte-identical pre-M18 payloads: an M18 client drove a |
| 365 | pre-M18 daemon's default session; a pre-M18 client drove an M18 | 370 | pre-M18 daemon's default session; a pre-M18 client drove an M18 |
| 366 | daemon's and created nothing (`sessions=1`, `session 0`); and a pre-M18 | 371 | daemon's and created nothing (`sessions=1`, `session 0`); and a pre-M18 |
| 367 | `mux a`'s empty `status_req` still answered. The known limitation is | 372 | `muxa`'s empty `status_req` still answered. The known limitation is |
| 368 | documented rather than fixed, and turns out to be **two** failures | 373 | documented rather than fixed, and turns out to be **two** failures |
| 369 | split by transport — socket/ssh get a clean exit 1 (the old | 374 | split by transport — socket/ssh get a clean exit 1 (the old |
| 370 | `serviceObserver` drops the connection), **QUIC hangs** (the old | 375 | `serviceObserver` drops the connection), **QUIC hangs** (the old |
| @@ -380,7 +385,7 @@ per predecessor, riding through exec into a shell that had no idea it | |||
| 380 | held it. A master with a second holder never sees its last close, so | 385 | held it. A master with a second holder never sees its last close, so |
| 381 | `Pty.deinit`'s close stopped hanging up, the interactive shell ignored | 386 | `Pty.deinit`'s close stopped hanging up, the interactive shell ignored |
| 382 | the SIGTERM that followed, and the blocking `waitpid` never returned: | 387 | the SIGTERM that followed, and the blocking `waitpid` never returned: |
| 383 | `mux d stop` unlinked the socket, printed `mux d: stopped`, and left the | 388 | `muxd stop` unlinked the socket, printed `muxd: stopped`, and left the |
| 384 | daemon in `do_wait` forever with every session's shell alive. Strictly | 389 | daemon in `do_wait` forever with every session's shell alive. Strictly |
| 385 | unreachable at one session per daemon. The pin closes a master while a | 390 | unreachable at one session per daemon. The pin closes a master while a |
| 386 | later session is live and polls for the hangup with a deadline — the bug | 391 | later session is live and polls for the hangup with a deadline — the bug |
| @@ -391,7 +396,7 @@ is a hang, and a hung `zig build test` step prints nothing at all. | |||
| 391 | and the reverse, each daemon in a container of its own, both sides built | 396 | and the reverse, each daemon in a container of its own, both sides built |
| 392 | static musl so one artifact runs in the container and on the host. Eight | 397 | static musl so one artifact runs in the container and on the host. Eight |
| 393 | legs: both directions on the default session, both halves of the | 398 | legs: both directions on the default session, both halves of the |
| 394 | `--session` limitation, an old `mux a`'s empty `status_req`, the | 399 | `--session` limitation, an old `muxa`'s empty `status_req`, the |
| 395 | `stats_req` version discriminator the banked mitigation would need, and a | 400 | `stats_req` version discriminator the banked mitigation would need, and a |
| 396 | teardown observed rather than claimed. It exists because M18's by-hand | 401 | teardown observed rather than claimed. It exists because M18's by-hand |
| 397 | run found a *documentation* error days after the sentence was written; a | 402 | run found a *documentation* error days after the sentence was written; a |
| @@ -399,7 +404,7 @@ gate finds that on the next run. | |||
| 399 | 404 | ||
| 400 | Two things about it stated rather than left implied. **Containers share | 405 | Two things about it stated rather than left implied. **Containers share |
| 401 | the host kernel**, so nothing there validates pty, poll or fd semantics | 406 | the host kernel**, so nothing there validates pty, poll or fd semantics |
| 402 | against a different one — most of what the daemon is. That needs VMs and is not | 407 | against a different one — most of what muxd is. That needs VMs and is not |
| 403 | claimed. And the rig is **falsifiable on purpose**: point `XVER_OLD_BIN` | 408 | claimed. And the rig is **falsifiable on purpose**: point `XVER_OLD_BIN` |
| 404 | at the new binaries and legs must fail. Doing exactly that caught a false | 409 | at the new binaries and legs must fail. Doing exactly that caught a false |
| 405 | pass in the gate's own first draft — the QUIC arm keyed on the client's | 410 | pass in the gate's own first draft — the QUIC arm keyed on the client's |
| @@ -408,7 +413,7 @@ attached until `timeout` kills it, so 124 meant both things. It now asks | |||
| 408 | the daemon whether a session called `zz` appeared, which only a daemon | 413 | the daemon whether a session called `zz` appeared, which only a daemon |
| 409 | that understood the name can produce. | 414 | that understood the name can produce. |
| 410 | 415 | ||
| 411 | `mux a capture` is **no longer the one verb with no automated coverage**: | 416 | `muxa capture` is **no longer the one verb with no automated coverage**: |
| 412 | the M18 block drives it by name and asserts it returns one session's | 417 | the M18 block drives it by name and asserts it returns one session's |
| 413 | grid and not its neighbour's. `--diff-since` is still owed. | 418 | grid and not its neighbour's. `--diff-since` is still owed. |
| 414 | 419 | ||
| @@ -437,9 +442,9 @@ a seam up there would have recorded the session you started at and none of | |||
| 437 | the ones you visited. It fires on the first STATE, not on a successful dial: | 442 | the ones you visited. It fires on the first STATE, not on a successful dial: |
| 438 | a dial that comes up can still be refused, and a refusal that had already | 443 | a dial that comes up can still be refused, and a refusal that had already |
| 439 | written its line would strand a tile naming a session that never existed. | 444 | written its line would strand a tile naming a session that never existed. |
| 440 | That is why nothing ever needs to un-record. `mux a` and `--via` record | 445 | That is why nothing ever needs to un-record. `muxa` and `--via` record |
| 441 | nothing; `mux wall`'s argv is a view, not an attach; `mux web`'s argv ADDS | 446 | nothing; `mux wall`'s argv is a view, not an attach; `muxweb`'s argv ADDS |
| 442 | rather than replaces, because the file stopped being "the last wall the hub | 447 | rather than replaces, because the file stopped being "the last wall muxweb |
| 443 | was told to show" the moment attaches started writing to it. Forgetting | 448 | was told to show" the moment attaches started writing to it. Forgetting |
| 444 | stays explicit: the page's `×`, the wall's `x`, `mux wall rm`. | 449 | stays explicit: the page's `×`, the wall's `x`, `mux wall rm`. |
| 445 | 450 | ||
| @@ -468,7 +473,7 @@ Shipped 2026-08-16. The three discovery issues are now accounted for: | |||
| 468 | `7c777ec6`'s OSC 52 **set** reaches the host clipboard (the query direction | 473 | `7c777ec6`'s OSC 52 **set** reaches the host clipboard (the query direction |
| 469 | is deliberately refused, with a 64 KiB cap); `ee062dd9`'s bracketed-paste | 474 | is deliberately refused, with a 64 KiB cap); `ee062dd9`'s bracketed-paste |
| 470 | half is mirrored; and `063cec67` now has implementation evidence for its | 475 | half is mirrored; and `063cec67` now has implementation evidence for its |
| 471 | **hub** half: mouse-drag selection reaches daemon scrollback and explicit | 476 | **muxweb** half: mouse-drag selection reaches daemon scrollback and explicit |
| 472 | copy. Native CLI keyboard copy mode remains a separate problem. Window-title | 477 | copy. Native CLI keyboard copy mode remains a separate problem. Window-title |
| 473 | state and bells rode the same delivery mechanisms. | 478 | state and bells rode the same delivery mechanisms. |
| 474 | 479 | ||
| @@ -537,7 +542,7 @@ next one that arrives outranks all of it. | |||
| 537 | 542 | ||
| 538 | The evidence that this costs something is that **the tests cannot read | 543 | The evidence that this costs something is that **the tests cannot read |
| 539 | it either**. `test/e2e.sh` has to disambiguate through a side channel | 544 | it either**. `test/e2e.sh` has to disambiguate through a side channel |
| 540 | (`mux d stats | grep sessions=`), and `test/xversion.sh` had to invent | 545 | (`muxd stats | grep sessions=`), and `test/xversion.sh` had to invent |
| 541 | `assert_name_not_honoured` to ask "did a session appear?" because the | 546 | `assert_name_not_honoured` to ask "did a session appear?" because the |
| 542 | reply itself could not say. When a protocol's own suite has to go | 547 | reply itself could not say. When a protocol's own suite has to go |
| 543 | around the answer to find out what happened, an operator staring at a | 548 | around the answer to find out what happened, an operator staring at a |
| @@ -586,7 +591,7 @@ next one that arrives outranks all of it. | |||
| 586 | layer *and* end to end, and the unit pair failed first and cheapest. | 591 | layer *and* end to end, and the unit pair failed first and cheapest. |
| 587 | - **Log rotation, via `logPathFor`** — created by M13 and its named, | 592 | - **Log rotation, via `logPathFor`** — created by M13 and its named, |
| 588 | accepted cost. Auto-start opens the daemon log append-only, so a user | 593 | accepted cost. Auto-start opens the daemon log append-only, so a user |
| 589 | who never types `mux d start` never truncates it and the file grows | 594 | who never types `muxd start` never truncates it and the file grows |
| 590 | without bound. The seam is already in place; only the policy is owed | 595 | without bound. The seam is already in place; only the policy is owed |
| 591 | (decisions.md, M13). | 596 | (decisions.md, M13). |
| 592 | - **Per-socket daemon logs** — also from M13. Auto-start makes a second | 597 | - **Per-socket daemon logs** — also from M13. Auto-start makes a second |
| @@ -628,7 +633,7 @@ next one that arrives outranks all of it. | |||
| 628 | performance win's clothes (decisions.md, "Banked by M8"). | 633 | performance win's clothes (decisions.md, "Banked by M8"). |
| 629 | 2. **Half-open reaping policy** — handshake-without-attach holds a slot | 634 | 2. **Half-open reaping policy** — handshake-without-attach holds a slot |
| 630 | until idle timeout; bounded, measured at 0 held under a ten-tear loop; | 635 | until idle timeout; bounded, measured at 0 held under a ten-tear loop; |
| 631 | `mux d stats clients=N` is the instrument for deciding the policy. | 636 | `muxd stats clients=N` is the instrument for deciding the policy. |
| 632 | 3. **`idle_ms` split three ways** — death detection, handshake bound, | 637 | 3. **`idle_ms` split three ways** — death detection, handshake bound, |
| 633 | half-open reaping are one knob doing three jobs. | 638 | half-open reaping are one knob doing three jobs. |
| 634 | 4. **`timeoutMs` buffered-frame short-circuit** — a whole frame already | 639 | 4. **`timeoutMs` buffered-frame short-circuit** — a whole frame already |
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -883,14 +883,18 @@ fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) | |||
| 883 | // One refusal gets a translation, because the daemon saying it | 883 | // One refusal gets a translation, because the daemon saying it |
| 884 | // cannot know why. v0.0.1-15 and older probe the candidate for | 884 | // cannot know why. v0.0.1-15 and older probe the candidate for |
| 885 | // `muxd <version>` and this binary answers `mux <version>` — | 885 | // `muxd <version>` and this binary answers `mux <version>` — |
| 886 | // the four binaries became one. If that daemon's wording ever | 886 | // the four binaries became one. Conditional wording, because |
| 887 | // changes the hint simply stops appearing; the verbatim line | 887 | // nothing here knows the daemon's version: a NEW daemon says |
| 888 | // above is what the user acts on either way. | 888 | // the same words about a candidate that prints anything else, |
| 889 | // and telling that user about v15 would be a wrong diagnosis. | ||
| 890 | // If the daemon's wording ever changes the hint simply stops | ||
| 891 | // appearing; the verbatim line above is what the user acts on. | ||
| 889 | if (std.mem.eql(u8, frame.payload[1..], "version: output mismatch")) | 892 | if (std.mem.eql(u8, frame.payload[1..], "version: output mismatch")) |
| 890 | std.debug.print( | 893 | std.debug.print( |
| 891 | "mux d upgrade: a daemon of v0.0.1-15 or older wants a candidate that " ++ | 894 | "mux d upgrade: if that daemon is v0.0.1-15 or older, it wants a " ++ |
| 892 | "prints `muxd <version>`; this one is `mux`. There is no in-place " ++ | 895 | "candidate that prints `muxd <version>` and this one is `mux`. " ++ |
| 893 | "path across that: `mux d stop` then `mux d start`, once.\n", | 896 | "There is no in-place path across that rename: `mux d stop` then " ++ |
| 897 | "`mux d start`, once.\n", | ||
| 894 | .{}, | 898 | .{}, |
| 895 | ); | 899 | ); |
| 896 | return 1; | 900 | return 1; |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -1414,16 +1414,24 @@ test "spawnPipe: the child is exec'd from a copy — an argv freed after spawn s | |||
| 1414 | // duplicating it, that free would be a use-after-free nothing else here | 1414 | // duplicating it, that free would be a use-after-free nothing else here |
| 1415 | // would catch — the child would have exec'd correctly already. | 1415 | // would catch — the child would have exec'd correctly already. |
| 1416 | const alloc = std.testing.allocator; | 1416 | const alloc = std.testing.allocator; |
| 1417 | // The WORDS are duped as well as the array. String literals live for | ||
| 1418 | // the whole program, so freeing an array of them would only have caught | ||
| 1419 | // a std that kept `argv.ptr` — never one that kept `argv[i].ptr`, which | ||
| 1420 | // is the same bug one level down and the one this claim also makes. | ||
| 1417 | const argv = try alloc.alloc([]const u8, 2); | 1421 | const argv = try alloc.alloc([]const u8, 2); |
| 1418 | argv[0] = "/bin/echo"; | 1422 | argv[0] = try alloc.dupe(u8, "/bin/echo"); |
| 1419 | argv[1] = "copied"; | 1423 | argv[1] = try alloc.dupe(u8, "copied"); |
| 1420 | var child = try Transport.spawnPipe(alloc, argv); | 1424 | var child = try Transport.spawnPipe(alloc, argv); |
| 1425 | for (argv) |w| alloc.free(w); | ||
| 1421 | alloc.free(argv); | 1426 | alloc.free(argv); |
| 1422 | // Reuse the freed pages before reading, so a std that kept the pointer | 1427 | // Reuse the freed pages before reading, so a std that kept the pointer |
| 1423 | // is reading somebody else's bytes rather than its own stale ones. | 1428 | // is reading somebody else's bytes rather than its own stale ones. |
| 1424 | const churn = try alloc.alloc([]const u8, 2); | 1429 | const churn = try alloc.alloc([]const u8, 2); |
| 1425 | @memset(churn, "xxxxxxx"); | 1430 | @memset(churn, "xxxxxxx"); |
| 1426 | alloc.free(churn); | 1431 | alloc.free(churn); |
| 1432 | const wchurn = try alloc.alloc(u8, 16); | ||
| 1433 | @memset(wchurn, 'x'); | ||
| 1434 | alloc.free(wchurn); | ||
| 1427 | 1435 | ||
| 1428 | var buf: [64]u8 = undefined; | 1436 | var buf: [64]u8 = undefined; |
| 1429 | const n = try std.posix.read(child.stdout.?.handle, &buf); | 1437 | const n = try std.posix.read(child.stdout.?.handle, &buf); |
src/client/handoff.zig
| Old | New | ||
|---|---|---|---|
| @@ -179,6 +179,11 @@ pub const Recipe = struct { | |||
| 179 | cache_path: ?[]const u8, | 179 | cache_path: ?[]const u8, |
| 180 | 180 | ||
| 181 | pub fn deinit(self: Recipe, alloc: std.mem.Allocator) void { | 181 | pub fn deinit(self: Recipe, alloc: std.mem.Allocator) void { |
| 182 | // Exactly once, by the arena that built it: the wall hands one | ||
| 183 | // recipe to every Tile of a host and a Tile ALIASES both argvs | ||
| 184 | // rather than copying them (`webhub.copyTarget` is the one caller | ||
| 185 | // that dupes). Two levels deep, so a future non-arena owner has two | ||
| 186 | // levels to free and a per-Tile free would be a double one. | ||
| 182 | freeArgv(alloc, self.ssh_argv); | 187 | freeArgv(alloc, self.ssh_argv); |
| 183 | freeArgv(alloc, self.start_argv); | 188 | freeArgv(alloc, self.start_argv); |
| 184 | if (self.cache_path) |c| alloc.free(c); | 189 | if (self.cache_path) |c| alloc.free(c); |