a73x

971a17e9

docs: M18 decisions, field verification, and what the version string cannot tell you

a73x   2026-08-15 12:22

Commit message
docs: M18 decisions, field verification, and what the version string cannot tell you

The locked decisions and, where execution disagreed with the plan, the
measured truth rather than the prediction. Two of those matter beyond
this milestone.

The cross-version limitation is TWO failures, not one, split by
transport: socket/ssh get a clean exit 1 because the pre-M18
serviceObserver drops the connection, while QUIC HANGS because its
handleFrame ignores the frame in silence — a QUIC connection takes a
client slot at quicOnOpen and never goes through the observer path.
Remote boxes are the QUIC ones. A hello frame cannot fix it: MsgType is
non-exhaustive and both arms end in `else => {}`, so an old daemon
answers an unknown frame with exactly the silence that IS the bug. The
banked mitigation probes a verb old daemons already answer.

Field-verified on both boxes, which had been on 0.0.1-3 with no daemon
running after a power cut — a clean upgrade window. Two QUIC dials each
gave `sessions=3 session 0 / a / b` with isolation clean. The note that
will save someone an hour: `muxd --version` still prints 0.0.1-5 after
the upgrade, because M18 is untagged. The version string cannot tell you
whether a box has multi-session; `muxd stats | grep sessions=` can.

Also recorded: why a bare attach joins the zeroth session and never
creates one. init spawns "0" before any client connects, and the M14
handoff plus every reconnect path re-attaches with an empty name — all of
them must land in the same shell. tmux-new semantics would make a handoff
a new login.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

docs/decisions.md
Old New
@@ -3351,3 +3351,151 @@ was fixed the same day, each fix proven by a test that fails without it.
3351 back is the one the up-line named, so every dial targets what was 3351 back is the one the up-line named, so every dial targets what was
3352 bound. Two suites congruent mod 900, or a tuned ephemeral range, no 3352 bound. Two suites congruent mod 900, or a tuned ephemeral range, no
3353 longer collide. 3353 longer collide.
3354
3355 ## 2026-08-15 (M18 — the multi-session daemon)
3356
3357 One daemon, N sessions, one socket path / QUIC port / key. The design was
3358 locked in conversation on 2026-08-13 and executed as eight tasks; what
3359 follows is the locked set, then the places execution disagreed with it.
3360
3361 - **A connection IS a session.** The attach payload grew an optional
3362 UTF-8 name tail after its fixed 20 bytes, and *nothing after attach
3363 changed*: no frame tagging, no per-frame session id, no QUIC stream
3364 surgery. Same-host tiles are separate connections to the same port.
3365 This is the decision every other one falls out of, and it is why the
3366 diff is a name tail plus a `Session` struct rather than a protocol.
3367 - **Empty name means the default session, and that is the compat
3368 story.** `encodeAttachNamed("")` writes the same twenty bytes
3369 `encodeAttach` always did, so an M17 client works against an M18
3370 daemon and an M18 client with no `--session` works against an older
3371 one. `status_req`, `await_req` and `debug_dump` took the same tail for
3372 the same reason — one rule across every session-scoped verb.
3373 - **Known limitation, documented rather than fixed — and it is two
3374 different failures, not one.** The plan predicted a single symptom
3375 ("silently ignored by `decodeAttach catch return`, the client hangs");
3376 the cross-version run measured both arms and they differ by transport,
3377 because a pre-M18 daemon has two attach paths:
3378 - **Unix socket, ssh and `--via`** reach `serviceObserver`, which
3379 DROPS the connection on an unparseable attach. The client says `mux:
3380 connection to muxd lost` and exits 1. A clean, bounded failure.
3381 - **QUIC** reaches `handleFrame`, whose `decodeAttach catch return`
3382 ignores the frame in silence — and there the client really does
3383 hang, measured as an 8s timeout with no output. A plain `quic://`
3384 attach from the same M18 client to the same old daemon works, so it
3385 is the NAME that is fatal, not the transport.
3386
3387 This is the worse half landing on the deployment that most needs it
3388 right: remote boxes are exactly the QUIC ones, so upgrading the client
3389 or the wall ahead of the box turns `--session` into a hang with
3390 nothing printed. Still not worth a capability dance for a flag that
3391 did not exist when that daemon shipped, but a bounded first-snapshot
3392 wait on a NAMED attach — "no answer; that daemon may predate
3393 `--session`" — is a cheap mitigation and is banked as a candidate.
3394 - **Purpose bounds the surface.** The wall showing one host twice is the
3395 whole point, so there is no list verb (the wall names its sessions;
3396 attach-or-create makes discovery unnecessary), no kill verb (a session
3397 ends when its shell exits), no rename, no per-session shell or
3398 scrollback config. `max_sessions = 4`, wall-sized, in the spirit of
3399 `max_clients = 8`.
3400 - **A bare attach joins the zeroth session and never creates one —
3401 seamless handoff is the reason.** The default session is spawned in
3402 `init` (`createSession(..., proto.default_session, ...)`), so `"0"`
3403 exists before any client connects and an empty name always *finds* it.
3404 The alternative — tmux's `new-session` semantics, where an unnamed
3405 invocation makes a fresh one — would break the product's central
3406 trick: the M14 handoff attaches over ssh, re-dials, and re-attaches
3407 over QUIC, and every reconnect and resume path in `client.zig`
3408 re-attaches the same way. All of those carry an empty name, so all of
3409 them must land in the SAME shell. Under create-on-bare-attach a
3410 handoff would be a new login and a network blip would spawn a shell.
3411 The tmux analogy holds once mapped correctly: mux has no separate
3412 "new" verb — `mux` IS the attach verb — so its bare form is `tmux
3413 attach`, not `tmux new`. Naming a session that does not exist is the
3414 explicit request, and that is the arm that creates.
3415 - **An attach is answered, always — there is no wait state.** Both arms
3416 refuse the same way `resolveSession` returns null: `exit_status 1`,
3417 queued on the client path and written-then-dropped on the observer
3418 path. Resolution is synchronous — map `""`→`"0"`, validate, find, else
3419 create, else no. This is worth stating because the pre-M18 hang
3420 documented above looks like a wait and is not one: that daemon's
3421 `decodeAttach catch return` fails to PARSE a named attach and returns
3422 before any resolve-or-refuse logic runs, so it never decides anything.
3423 Silence is not a state this protocol has; it is what a peer that
3424 predates the rule does.
3425 - **Creation requires a real size.** Attach-or-create only creates when
3426 the attach carries nonzero cols×rows. muxa attaches at 0×0 — it makes
3427 no size claim, deliberately — so `muxa send` to a dead session is
3428 refused with `exit_status 1` instead of silently spawning a 0×0 shell.
3429 - **A session name means one session, whichever transport carried the
3430 attach.** The session table belongs to the daemon, not to the listener
3431 that accepted the connection, so `--session a` over QUIC and
3432 `--session a` over the unix socket join the *same* shell — and two
3433 QUIC dials to one port come out as two shells. This is the operator's
3434 half of decision 6 and it is now pinned end to end: the QUIC block
3435 drives two dials into two sessions, then joins one of them from the
3436 socket and asserts both clients' markers on one grid with the session
3437 count unmoved. QUIC is where the claim is least obvious, since a QUIC
3438 client is promoted to a slot when its *handshake* completes — before
3439 any attach — so its slot starts session-less and the name is the only
3440 thing that ever binds it.
3441 - **Command tracking is per-session**, forced by the agent-surface
3442 merge. A tracker fed by two shells would interleave their command
3443 lifecycles into nonsense, so `cmd` and `last_return` live on the
3444 session; `AwaitState` stays on the client slot, because an await is
3445 one client's question.
3446
3447 **Amended in execution — reality disagreeing with the plan.**
3448
3449 - **A `/bin/sh` session has no marks, so the planned `muxa status` phase
3450 assertion could not hold.** The plan's e2e sketch asserted that
3451 `status --session b` names a running command while `--session a`
3452 reports `at_prompt`. `shellint` detects shells by basename and unknown
3453 shells get nothing, so `cmd.phase` in a `/bin/sh` session never leaves
3454 `at_prompt` and the assertion would have been asserting a constant
3455 against itself. Switching the block to bash was rejected for the
3456 reason `e2e.sh` already pins `SHELL=/bin/sh` at the top: `bash_init`
3457 sources `$HOME/.bashrc`, which is arbitrary code on the session under
3458 test. What the e2e asserts instead is what it can honestly observe —
3459 that `status --session` returns a real StatusReply for a named session
3460 and that `capture --session` returns one session's grid and not its
3461 neighbour's. The per-session tracker itself stays pinned where marks
3462 actually exist: `server.zig`'s unit tests, and `agent.sh`'s bash
3463 daemons.
3464 - **`muxa` is now an e2e.sh argument (`$9`).** The plan assumed the
3465 suite could reach it; it could not — muxa went only to `agent.sh`. It
3466 is asked for here rather than there because the verbs under test
3467 address a session *without attaching*, and the sessions they address
3468 can only be CREATED by `mux`, which `agent.sh` has no copy of. The
3469 soak step takes the same argument, since soak *is* the e2e suite run N
3470 times — an argument added to one and not the other makes every soak
3471 run abort on an unbound variable before its first scenario, which is
3472 exactly how it was caught.
3473 - **The scenario detaches before asserting.** The plan's sketch held
3474 both clients open across the assertions; the block as written has each
3475 client plant its marker and leave. A session ends when its shell
3476 exits, not when its last client leaves, so every routing assertion now
3477 runs against sessions with nobody attached — which is also what makes
3478 the answers impossible to explain by a live client holding something
3479 open.
3480
3481 **The one real defect, and the e2e found it rather than review** (see
3482 `fix(pty)`, f25f62c). `forkpty` returns the master without CLOEXEC, so
3483 every session spawned later inherited every earlier session's master —
3484 observed on a three-session daemon as fds 0,1,2,255 in the first shell,
3485 those plus fd 3 in the second, plus fd 3 AND fd 6 in the third. A master
3486 with a second holder never sees its last close, so `Pty.deinit`'s close
3487 stopped hanging up the far side; the shell is interactive and therefore
3488 ignores the SIGTERM that follows; and the blocking `waitpid` after that
3489 never returned. `muxd stop` unlinked the socket and printed `muxd:
3490 stopped` while the daemon sat in `do_wait` forever with every session's
3491 shell alive — and because deinit walks sessions in slot order, wedging on
3492 the first meant none of the others were torn down either.
3493
3494 Two things worth keeping from it. **A fd leak is a shutdown bug, not a
3495 tidiness bug** — the daemon's own sockets were already CLOEXEC, and the
3496 pty master was the single oversight in an otherwise consistent
3497 convention. And **the bug was strictly unreachable at N=1**: nothing in
3498 the unit layer, the review rounds, or fifteen prior milestones could have
3499 surfaced it, because none of them ever spawned a second pty from a
3500 process that already held one. The first test that ran two sessions on
3501 one daemon found it on its first execution.
docs/roadmap.md
Old New
@@ -283,6 +283,112 @@ behaviour is pinned through the dead-daemon paths, an old binary is not).
283 And `muxa capture` is **the one verb with no automated coverage at all** — 283 And `muxa capture` is **the one verb with no automated coverage at all** —
284 `test/agent.sh` drives run, await, send and status; capture is where 284 `test/agent.sh` drives run, await, send and status; capture is where
285 `--diff-since` would land, so the test and the flag are owed together. 285 `--diff-since` would land, so the test and the flag are owed together.
286 *(Closed in M18: the multi-session e2e block drives `capture --session`
287 and asserts it returns one session's grid and not its neighbour's.
288 `--diff-since` is still owed.)*
289
290 ## M18 — multi-session daemon — complete
291
292 Shipped 2026-08-15 (e7285d9..2b1c9bb + close-out). One daemon now holds
293 up to **four** independent sessions — engine, pty, tracker, epoch,
294 command tracker and name each — over **one socket path, one QUIC port,
295 one key**. The wall can finally show the same host twice, which is what
296 the milestone was for.
297
298 **A connection is a session.** The attach payload grew an optional UTF-8
299 name tail after its fixed 20 bytes and nothing after attach changed: no
300 frame tagging, no QUIC stream surgery, the transport still one bidi
301 stream per connection. Same-host tiles are separate connections to the
302 same port. Attach-or-create is tmux-style; a session ends when its shell
303 exits and the daemon exits with the last one. `status_req`, `await_req`
304 and `debug_dump` grew the same tail, which is what lets `muxa` and `muxd
305 dump` address one session of several without attaching to it.
306
307 **Field-verified 2026-08-15 on both real boxes.** They had been on
308 **0.0.1-3** — behind the agent surface as well as M18 — and a site power
309 cut had taken them down, which made the upgrade window a clean one: no
310 daemon running on either, so nothing to tear down and no session to lose.
311 Static musl `muxd` (stripped, 23MB → 8.6MB) installed to
312 `/usr/local/bin`, identical sha256 on both boxes and on the build host,
313 then `muxd start --quic` on each. On the LAN box over real QUIC, and on
314 the WAN box over QUIC through the LAN-side UDP forward (the gate carries
315 TCP only): two dials, `--session a` and `--session b`, produced
316 `sessions=3 session 0 / session a / session b` on **both** boxes, with
317 each session holding its own marker and neither holding its neighbour's,
318 and the default session holding neither. The milestone's central claim,
319 on real hardware over a real link.
320
321 Two deployment notes worth keeping. `muxd --version` still prints
322 **0.0.1-5** after the upgrade — M18 is untagged, so the version string
323 cannot tell you whether a box has it; `muxd stats | grep sessions=` is
324 the check that can, which is the same discriminator the banked
325 capability probe uses. And the trial PSK survived the power cycle
326 identical on both boxes (`ba046148…`), so the M17 key-drift failure did
327 not repeat — fingerprint before blaming the network stays the rule.
328
329 **One name, one session, either door.** The session table is the
330 daemon's, not the accepting listener's, so `--session a` reaches the same
331 shell over QUIC as over the unix socket, and two QUIC dials to one port
332 are two shells. Pinned end to end by the QUIC block: two dials into two
333 sessions, then a socket client joins one of them and both clients'
334 markers land on one grid with the session count unmoved. That also makes
335 a mixed-transport wall of a single host work — `muxweb HOST#a
336 quic://HOST:8787#b` is tile 0 over ssh and tile 1 over QUIC against one
337 daemon. (`muxd run --sock PATH --quic ADDR` binds both; ssh needs no
338 listener of its own, since `muxd proxy` pumps into the same socket.)
339
340 **The compat story held**, measured against main @ 5d41e10 (the branch
341 point) in both directions. An empty name is the default session and
342 encodes to byte-identical pre-M18 payloads: an M18 client drove a
343 pre-M18 daemon's default session; a pre-M18 client drove an M18
344 daemon's and created nothing (`sessions=1`, `session 0`); and a pre-M18
345 `muxa`'s empty `status_req` still answered. The known limitation is
346 documented rather than fixed, and turns out to be **two** failures
347 split by transport — socket/ssh get a clean exit 1 (the old
348 `serviceObserver` drops the connection), **QUIC hangs** (the old
349 `handleFrame` ignores the frame in silence). Remote boxes are the QUIC
350 ones, so a client upgraded ahead of its box hangs with nothing printed;
351 a bounded first-snapshot wait on a named attach is banked as the cheap
352 mitigation. See decisions.md.
353
354 **One real defect, found by the new e2e and not by review** (f25f62c).
355 `forkpty` returns the master without CLOEXEC, so every session spawned
356 later inherited every earlier session's master — one spare `/dev/ptmx`
357 per predecessor, riding through exec into a shell that had no idea it
358 held it. A master with a second holder never sees its last close, so
359 `Pty.deinit`'s close stopped hanging up, the interactive shell ignored
360 the SIGTERM that followed, and the blocking `waitpid` never returned:
361 `muxd stop` unlinked the socket, printed `muxd: stopped`, and left the
362 daemon in `do_wait` forever with every session's shell alive. Strictly
363 unreachable at one session per daemon. The pin closes a master while a
364 later session is live and polls for the hangup with a deadline — the bug
365 is a hang, and a hung `zig build test` step prints nothing at all.
366
367 **The cross-version check stopped being hand-rolled** (`test/xversion.sh`,
368 `make xversion`). This tree's client against a previous version's daemon
369 and the reverse, each daemon in a container of its own, both sides built
370 static musl so one artifact runs in the container and on the host. Eight
371 legs: both directions on the default session, both halves of the
372 `--session` limitation, an old `muxa`'s empty `status_req`, the
373 `stats_req` version discriminator the banked mitigation would need, and a
374 teardown observed rather than claimed. It exists because M18's by-hand
375 run found a *documentation* error days after the sentence was written; a
376 gate finds that on the next run.
377
378 Two things about it stated rather than left implied. **Containers share
379 the host kernel**, so nothing there validates pty, poll or fd semantics
380 against a different one — most of what muxd is. That needs VMs and is not
381 claimed. And the rig is **falsifiable on purpose**: point `XVER_OLD_BIN`
382 at the new binaries and legs must fail. Doing exactly that caught a false
383 pass in the gate's own first draft — the QUIC arm keyed on the client's
384 exit code, and a daemon that HONOURS the name also leaves the client
385 attached until `timeout` kills it, so 124 meant both things. It now asks
386 the daemon whether a session called `zz` appeared, which only a daemon
387 that understood the name can produce.
388
389 `muxa capture` is **no longer the one verb with no automated coverage**:
390 the M18 block drives it by name and asserts it returns one session's
391 grid and not its neighbour's. `--diff-since` is still owed.
286 392
287 ## M16 candidates — and still outranked by trial feedback 393 ## M16 candidates — and still outranked by trial feedback
288 394
@@ -290,6 +396,57 @@ What remains is what was ranked behind M13 and M14 and survived M15,
290 plus the seven items M15 created. Nothing here is a field finding; the 396 plus the seven items M15 created. Nothing here is a field finding; the
291 next one that arrives outranks all of it. 397 next one that arrives outranks all of it.
292 398
399 - **Probe for named-session support before sending a named attach** —
400 M18's measured cross-version gap. `--session NAME` against a pre-M18
401 daemon is dropped cleanly on the socket path but ignored in **silence
402 over QUIC**, where the client hangs with nothing printed. The two
403 differ because a pre-M18 QUIC connection takes a client slot at
404 `quicOnOpen`, so its attach reaches `handleFrame`'s silent
405 `decodeAttach catch return`, while a socket connection is an observer
406 until it attaches and `serviceObserver` closes on a bad one.
407
408 A `hello`/version frame cannot fix this: `MsgType` is non-exhaustive
409 and both switch arms end in `else => {}`, so an old daemon ignores an
410 unknown frame in exactly the silence that is the bug. What works is a
411 probe using a verb old daemons ALREADY answer — `stats_req`, which
412 pre-M18 `handleFrame` serves without an attach, on QUIC included — and
413 reading the reply: M18's stats text carries `sessions=`, pre-M18's
414 does not. Zero protocol change, effective against shipped binaries,
415 and it yields a true message rather than a timeout's guess.
416
417 Gate it on the capability and refuse only the ATTACH, never the
418 connection: an unnamed attach is byte-identical to pre-M18 and works
419 today, so a version-equality check would break configurations that are
420 currently fine. Ranks above the rest of this list for anyone running a
421 wall against boxes that upgrade on their own schedule, which is every
422 wall. A `hello` frame is still worth adding for its own sake — free,
423 safe, and it makes the NEXT version pair a negotiation instead of
424 another archaeology exercise.
425 - **A refused attach should say which refusal it was** — M18 review
426 finding. `resolveSession` returning null is answered with
427 `exit_status 1` for four different causes: a name that failed
428 validation, a full session table, an unknown name with no size to
429 create at, and a spawn that failed. That byte is also what a shell
430 exiting 1 sends, so five distinct outcomes arrive identical. The
431 full-table case was the model — "the same honest no a full client
432 table gives" — and it is honest there, where there is genuinely one
433 answer; it stopped being honest once four causes started sharing it.
434
435 The evidence that this costs something is that **the tests cannot read
436 it either**. `test/e2e.sh` has to disambiguate through a side channel
437 (`muxd stats | grep sessions=`), and `test/xversion.sh` had to invent
438 `assert_name_not_honoured` to ask "did a session appear?" because the
439 reply itself could not say. When a protocol's own suite has to go
440 around the answer to find out what happened, an operator staring at a
441 bare exit code has strictly less to work with.
442
443 Not worth a protocol change on its own — bank it with the probe above,
444 which is the other half of the same story and would land in the same
445 place. The cheap client-side half needs no wire change at all: on a
446 **named** attach that never produced a snapshot, `mux` can print "the
447 daemon refused the attach or the shell exited 1" instead of exiting
448 silently. That one line covers the case an operator actually hits,
449 which is a typo in `--session`.
293 - ~~**`drain()` swallows `ECONNREFUSED`**~~ — paid by M15 (Task 4). Both 450 - ~~**`drain()` swallows `ECONNREFUSED`**~~ — paid by M15 (Task 4). Both
294 socket paths now route the refusal through one verdict, and an 451 socket paths now route the refusal through one verdict, and an
295 unreachable port fails in **1ms** instead of a full deadline 452 unreachable port fails in **1ms** instead of a full deadline
@@ -380,7 +537,14 @@ next one that arrives outranks all of it.
380 6. **`addCSourceFiles` for the QUIC deps**, replacing the build script. 537 6. **`addCSourceFiles` for the QUIC deps**, replacing the build script.
381 7. **Autotools wolfSSL + ticket resumption** — the only route to 0-RTT. 538 7. **Autotools wolfSSL + ticket resumption** — the only route to 0-RTT.
382 8. **Certificates / TOFU · QUIC datagrams for input · multi-session per 539 8. **Certificates / TOFU · QUIC datagrams for input · multi-session per
383 connection · NAT traversal** — parked as a group. 540 *connection* · NAT traversal** — parked as a group. Multi-session
541 **shipped in M18, but per *daemon***: one socket path / port / key
542 holding N sessions, each its own connection. What stays banked here
543 is the narrower thing this line originally meant — several sessions
544 multiplexed inside ONE connection, which needs QUIC stream surgery
545 the M18 design deliberately refused (a connection IS a session).
546 Worth doing only if the connection count per host ever becomes the
547 cost, which on a four-tile wall it is not.
384 9. **Scrollback retention tuning** — policy decided (engine-native 10k 548 9. **Scrollback retention tuning** — policy decided (engine-native 10k
385 ring); making the limit configurable is what remains, and nobody has 549 ring); making the limit configurable is what remains, and nobody has
386 hit the ceiling. 550 hit the ceiling.