1baf9412
docs: why the upgrade is an exec and not a handover
a73x 2026-08-26 15:17
Commit message
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. | ||