a73x

1baf9412

docs: why the upgrade is an exec and not a handover

a73x   2026-08-26 15:17

Commit message
docs: why the upgrade is an exec and not a handover

The 2026-08-19 design passed fds to a new process. Every hard problem in
it was caused by the process changing rather than the binary: waitpid's
ECHILD arm is `unreachable`, the shim and agent dirs are named after the
pid and held open by every live shell, and Zig 0.15.2 has no SCM_RIGHTS to
pass anything with. execve changes only the binary.

The entry records what deliberately does NOT cross and why each one is
safe to drop, the return-watermark bug (a verdict may cross, the
coordinate it was recorded at may not) and the leg shape it dictates, the
two env variables and why neither is a flag, the CONNECTION_CLOSE number
the QUIC leg measures (~60ms against 19948ms without it), and the
cross-version gap: a handover leg needs an old side that already carries
the feature, so the first release with it is the floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

CLAUDE.md
Old New
@@ -114,6 +114,16 @@ real pty), `wsclient` (browser stand-in), `rawmode`, `delaypipe`, `render`.
114 - **Detach is a goodbye, not a tear.** A pump that has sent its detach frame 114 - **Detach is a goodbye, not a tear.** A pump that has sent its detach frame
115 never redials — redial checks `detach_ack` — so the daemon's close after 115 never redials — redial checks `detach_ack` — so the daemon's close after
116 a detach is final, not a network event to recover from. 116 a detach is final, not a network event to recover from.
117 - **An upgrade is an exec, not a restart.** `muxd upgrade` execs the candidate
118 over the RUNNING daemon: same pid, same children, same fds — so `waitpid`,
119 the pid-named shim and agent dirs, and the socket path all survive untouched,
120 and `Server.deinit` never runs on that path. The manifest memfd carries only
121 what cannot be rebuilt (pty fd + child pid, the VT dump and title, the
122 tracker, the QUIC arm's key bytes, the cumulative counters); scrollback,
123 clients, delta trackers, agent channels and per-connection QUIC state are
124 dropped on purpose. A return watermark is RE-STAMPED, never carried — it
125 belongs to the seq space that minted it. Rollback is another exec, of the
126 binary the manifest names, with `MUX_UPGRADE_ROLLBACK` as the loop guard.
117 - No socket stealing: `muxd run` refuses a path another daemon owns. 127 - No socket stealing: `muxd run` refuses a path another daemon owns.
118 128
119 ## Working rules 129 ## Working rules
docs/decisions.md
Old New
@@ -6924,3 +6924,94 @@ pins this latch, the refusal that must keep re-attaching, `setStatus`'s
6924 precedence and both badge words, and each check was watched failing against the 6924 precedence and both badge words, and each check was watched failing against the
6925 mutation that removes its rule. A real-browser pass stays the gate for what only 6925 mutation that removes its rule. A real-browser pass stays the gate for what only
6926 a browser has: rendering, input, and the platform APIs the shell talks to. 6926 a browser has: rendering, input, and the platform APIs the shell talks to.
6927
6928 ## 2026-08-26 — `muxd upgrade`: the pid stays, only the binary changes
6929
6930 Rolling a new muxd was `stop` + `run`, which kills every session's shell. The
6931 2026-08-19 design handed the pty masters and listeners to a NEW process over
6932 `SCM_RIGHTS`. An inventory of the daemon as it stands found that every hard
6933 problem in that design is caused by the PROCESS changing, not by the binary
6934 changing:
6935
6936 - A shell whose pty crossed is still the old daemon's child. The new daemon's
6937 `checkExited` calls `waitpid`, which answers `ECHILD`, which std marks
6938 `unreachable` — a panic on the first pump. (Not a guess: mutating the
6939 adoption's `Pty.adopt(rec.pty_fd, rec.child_pid)` to pass `0` reproduces it
6940 exactly, `posix.zig:4427 .CHILD => unreachable`.) Rebuilding exit detection
6941 on master-EOF loses the shell's real exit code forever after.
6942 - The shell-integration shim dir (`ZDOTDIR`, `--init-file`) and the agent dir
6943 (`SSH_AUTH_SOCK`) are named after the daemon's PID and are held open by every
6944 live shell. `Server.deinit` `deleteTree`s both, unlinks the socket path and
6945 every agent socket, and signals every shell.
6946 - Zig 0.15.2 has no `recvmsg` wrapper, no `cmsghdr`, no `SCM_RIGHTS`, no
6947 `CMSG_*`. All of it would be hand-rolled for this one feature.
6948
6949 `execve` keeps the pid, the children, the cwd, the environment and every fd not
6950 marked close-on-exec. It changes only the binary. So the daemon writes what only
6951 it knows into an anonymous memfd, clears `FD_CLOEXEC` on exactly the fds it
6952 keeps — unix listener, QUIC UDP socket, each pty master, each agent listener,
6953 the memfd — and execs the candidate as `muxd run --resume-fd N`. Nothing in
6954 `Server.deinit` runs on that path: no unlink, no `deleteTree`, no signal. The
6955 process that owns those names never exits.
6956
6957 **What deliberately does not cross.** Scrollback: `Engine.dumpState` is
6958 viewport-only by construction, so a client observes `history_rows` going to 0
6959 and its absolute row space renumbering. Clients: their fds are close-on-exec
6960 and the exec is their EOF; they redial as they do for any tear. Delta trackers
6961 and the `*_sent` latches: rebuilt fresh over the replayed engine, safe only
6962 because the epoch is re-minted, and honest because no client survived to have
6963 been told anything. Agent channels and pending awaits: bound to clients that
6964 are gone. Per-connection QUIC/ngtcp2/TLS state: C state, dropped — which is why
6965 the exec sends CONNECTION_CLOSE first (below).
6966
6967 **The watermark bug, and what it teaches.** The manifest carries `last_return`,
6968 whose `seq` is the RETURN watermark `muxa await --since` compares against. The
6969 adopted session mints a fresh delta tracker at seq 0, so a carried watermark is
6970 a watermark from the future that no post-upgrade return can exceed. Measured on
6971 a live daemon: the FIRST `muxa run` after every upgrade timed out at 30s while
6972 the shell had already answered, and only the second worked. The verdict crosses;
6973 its watermark is re-stamped into the new seq space. The lesson is the one
6974 `awaits-answer-from-snapshots` already paid for, in a new place: a number is
6975 only meaningful inside the space that minted it, and carrying a verdict is not
6976 the same as carrying the coordinate it was recorded at. It is also why the e2e
6977 leg asserts the FIRST await after the exec rather than "an await" — a leg that
6978 ran two would have gone green on the bug.
6979
6980 **The refusals are safety, not authority.** `upgrade_req` execs a path of the
6981 requester's choosing as the daemon's user, which is the same power `stop_req`
6982 already grants anyone who can open the socket, and the socket is the user's own.
6983 The version rule, the absolute-and-executable path check, the candidate's own
6984 `--version`, and the candidate's `run --resume-fd N --check` exist against
6985 mistakes — wrong path, wrong arch, half-copied binary — not against an
6986 adversary who is already inside.
6987
6988 **Rollback is another exec.** If adoption fails before the pump starts, the new
6989 image execs the OLD binary, whose path the manifest carries, with the same
6990 `--resume-fd`. The loop guard is `MUX_UPGRADE_ROLLBACK=1` in the env rather than
6991 a flag: two binaries that both refuse one manifest would exec each other
6992 forever, sessions alive and unreachable, and an unknown VARIABLE is ignored by
6993 any muxd where an unknown FLAG is fatal usage to the older one being rolled back
6994 to. The same reasoning picks `MUX_RESUME_FAIL_AT` as the e2e's handle on the
6995 abort: `execUpgrade` builds a fixed argv, so nothing a leg types can put
6996 `--resume-fail-at` in front of the candidate, but the daemon's environment
6997 crosses the exec untouched.
6998
6999 A rollback is invisible to the operator: `muxd upgrade` prints its verdict the
7000 moment the daemon accepts, and if adoption then fails, the image that would
7001 correct it is gone. The e2e leg reads the daemon's own log for the abort line,
7002 because without it every other assertion in that leg passes just as well on an
7003 upgrade that simply worked. A version field in `stats_reply` for `confirmServing`
7004 to compare is the cheap fix if this ever matters to a human; it is not in v1.
7005
7006 **The QUIC goodbye is worth a number.** Unix clients need nothing. A QUIC peer
7007 cannot tell an exec from a bad network, so every connection gets CONNECTION_CLOSE
7008 before the exec (`quic_server.closeAll`, which drains once — `closeConn` is
7009 deliberately quiet). Measured with the leg's own stopwatch: ~60ms back in
7010 service with the goodbye, 19948ms without it against `--quic-idle-ms 15000`.
7011 That gap is the whole reason `closeAll` exists.
7012
7013 **The xversion gap.** A cross-version handover leg needs an old side that
7014 already carries this feature, so the first release with it is the floor. Until
7015 then the same-binary leg (`--allow-same-version`, a flag whose help text says
7016 it exists for the e2e) is the v1 gate, and the cross-version leg is a documented
7017 gap rather than a test that quietly does not run.