a73x

d7967ffa

fix: the small ones the review found — a hint, a note, a test, an install

a73x   2026-08-29 02:26

Commit message
fix: the small ones the review found — a hint, a note, a test, an install

The upgrade hint said "a daemon of v0.0.1-15 or older wants…" about any
`version: output mismatch`; nothing on this side knows the daemon's
version, and a new daemon refusing a wrapper script says the same words.
Conditional now, so the sentence is true either way.

`Recipe.deinit` says that Tiles alias both argvs, which is why the arena
frees them once. The spawnPipe copy test dupes its two words and frees
them: literals live for the whole program, so the old body could only
have caught a std that kept `argv.ptr`, never one that kept `argv[i]`.

`make install` removes muxd/muxa/muxweb, and README's upgrade section
says the old names go and that a v0.0.1-15 daemon cannot be upgraded in
place. docs/roadmap.md goes back to what it said — its history was
mass-renamed into claims it never made ("`mux a` — a fourth binary",
a 23MB measurement of `muxd` relabelled `mux`) — under the same epoch
header decisions.md carries.

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);