docs/daemon-contracts.md
Ref: Size: 11.3 KiB History
# Daemon, transport and upgrade contracts Read when changing these components. Implementation history retained from CLAUDE.md; the current code and regression tests are authoritative. - **A QUIC client says goodbye.** Every QUIC connection waits in a bounded, expiring role table after the handshake, then its first complete frame claims either one of the daemon's `max_clients` terminal slots, a dedicated forwarding slot, or a one-shot observer operation. The wall polls each QUIC host once a second on a connection of its own. `quic.Client.deinit` therefore writes CONNECTION_CLOSE before it closes the socket; a teardown that only dropped the socket left the daemon to learn from its 15 s idle timer, so the polls filled the table at a slot a second and every real attach after that was refused — on a laptop whose only clients were another wall's polls, when the daemon had eight slots and so took eight seconds (2026-09-02). The table is 32 deep now, which buys time and fixes nothing: the goodbye is what bounds it. A slot is spent per ATTACH, not per session, so 32 matches `max_sessions` and `wallview.max_tiles` — one full wall of tiles fits exactly, and a second wall on the same daemon is refused. The listener's `max_conns` stays above the admitted terminal and forwarding population, and the provisional role table can hold every listener connection (a connection exists from the handshake and only then asks for a role, and a peer that finds no connection is dropped silently rather than refused), pinned by a test because a transport file does not read the daemon's tables. And the listener OUTLIVES the slots that close through it: a QUIC sink closes its connection via the listener, so a borrowed listener's `deinit` is registered AFTER the server's. - **A pid-named leftover is reaped by its successor, never by a signal handler.** The daemon's `mux-agent-PID-*` and `mux-shellint-PID-*` directories and a wall's `mux-ask-PID.sock` are unlinked by their owner on the normal way out; a SIGKILL or a closed terminal window runs nothing, so the next daemon or wall to create one in that directory calls `xdg.reapDeadPid` first and removes every entry whose pid the OS no longer has. A live pid's entry stays even when it is no longer a mux. - **A daemon lives until `mux d stop`; emptiness is not an exit.** `x` ends a session, never a box: `reap` and `pumpOnce` answer nothing, a shell's code reaches that shell's own clients as `exit_status`, and an emptied daemon keeps serving so a birth can take the default name back. One idle process per machine is the accepted cost. `mux d start` exits 0 on every shutdown — a supervisor reads nonzero on a clean stop as a crash — and nonzero only for the boot failures `main.run` picks before anything binds. - **A daemon starts only when someone asks; a read never starts one, and the ask is a WORD.** `mux d start -d`, `mux d endpoint --start`, or the local client's own entry ask are the only starters. Bare `mux d endpoint` and `mux d proxy` answer what is on the box and exit 1 when nothing is — the wall polls `endpoint` over ssh once a second per host, and a poll that started daemons undid every remote `mux d stop` within a second (found on a live box). The dial a user ASKED for — the `mux HOST` entry dial, picker Enter — runs `ssh HOST 'mux d endpoint --start'` ONCE, and that run's announce IS the redial: the remote ensures the daemon and announces on the same stdout (`HandoffTarget.asked`, spent after the first attempt). The poll, `mux hosts` and every redial spell the bare verb and read `unreachable` instead, so the rule holds by argv rather than by a client branch. `--start` is `endpoint`'s alone; every other verb refuses it. `asked` DEFAULTS to false: a new dial path that forgets the field starts nothing, because a missing start says so on screen while a spurious one is a daemon on someone else's box that nothing reports. - **The daemon starts itself, and it execs THIS image.** `mux d start` IS the daemon in the foreground; `-d` is the flag that forks one, and `forkDetached` is the only `posix.fork` under `src/` — one spelling per OS arm, `server_os_linux.forkDetached` and `server_os_macos.forkDetached` — and `main.forkDaemon` is its one caller, through the `server_os` root. build.zig's folder rule 6 names those arm files in `except`, so a third file that forks is still caught. Every other starter spells the argv and execs: `endpoint --start` calls in-process, the local client's entry runs `mux d start -d --sock PATH` with its own fd 2 lent to it, so the daemon's refusals reach the user in the daemon's own words. The fork execs `spawn.selfExe` — the running image, `/proc/self/exe` read THROUGH to the file it names on Linux and `std.fs.selfExePath` on Darwin, which has no such link — with argv `mux d start …`. No `execvp`, no PATH walk, so an auto-start can only run the binary that is already running. (It used to exec a `muxd` off PATH, and an e2e leg whose daemon had died graded an installed v0.0.1-10 with no agent code in it.) The resolution is not cosmetic: `comm` is the basename of the filename handed to execve, so exec'ing the link itself names every daemon `exe` and hides it from `pgrep mux` and `killall mux`. Both pins are in `e2e_03_side`, and they ask through `test/os_oracle.sh` (`pid_exe` for the image, `pid_comm` for the name, `pid_args` for the argv the attach may say and no more) — which is `readlink /proc/PID/exe` and the two other `/proc` reads on Linux, and `ps` plus `lsof` on Darwin, where no such directory exists. The detached child's log is APPENDED: one xdg log serves every socket on the box, so truncating would zero a daemon still writing to it. Running the daemon IN the fork instead was tried and crashes every Debug build: `std.debug.MemoryAccessor` caches the pid it reads memory through, so the child's first DebugAllocator stack trace calls `process_vm_readv` on the parent and hits `unreachable // own pid is always valid` (decisions.md, 2026-08-28). - **Remote is still ssh, and still by name.** `client.handoff` spells `ssh HOST 'mux d endpoint'` / `'mux d endpoint --start'`, so a remote running an installation of v0.0.1-15 or older reads `[unreachable]` until it is upgraded — ≤15 knows neither the mode word nor the flag, and answers a usage page on stderr with rc 2 and no announce (measured against the released v0.0.1-15 tarball). There is no in-place upgrade across the rename either: a ≤15 daemon probes the candidate for `muxd <version>` and this binary answers `mux <version>`, so that one migration is `mux d stop` then `mux d start -d` — as is 16 to the next release, whose daemon execs its candidate as `d run` and finds no such verb. That ssh's STDERR is a pipe mux reads, never a writer on somebody's screen: its last line is the dial's `client.handoff.Reason` — quoted by the picker row (`unreachable: <reason>`) and by the entry dial's failure line — and only the entry dial (`HandoffTarget.narrate`) relays the bytes onward, to its own fd 2, and only until the wall takes the screen. - **`mux d upgrade` execs the candidate over the RUNNING daemon**: same pid, same children, same fds — so `waitpid`, the pid-named shim and agent dirs, and the socket path all survive untouched, and `Server.deinit` never runs on that path. `mux d upgrade HOST` is the remote spelling: a preflight ssh whose LINE COUNT is the verdict (`uname -m && command -v mux && mux d endpoint`, the bare read verb), the running image streamed into an atomic rename over the installed mux, then that box's own `mux d upgrade` — so the version rule, manifest and serving check stay daemon-side and are never reimplemented client-side. No daemon on the box is install-only success; no mux on the box is a refusal, because a push replaces an install rather than inventing one. The manifest carrier (a memfd on Linux, an unlinked `mkstemp` file at 0600 on Darwin) carries only what cannot be rebuilt (pty fd + child pid, the VT dump and title, the tracker, the QUIC arm's key bytes, the cumulative counters); scrollback, clients, delta trackers, agent channels and per-connection QUIC state are dropped on purpose. A return watermark is RE-STAMPED, never carried — it belongs to the seq space that minted it. Rollback is another exec, of the binary the manifest names, with `MUX_UPGRADE_ROLLBACK` as the loop guard. - **The sun_path bound belongs to the one binder.** `sockpath.max_sun_path` is private and `mux d` is `tooLong`'s only caller: it refuses at parse, before any fork, on the stderr the asking client lent it. Everyone else dials and reads the kernel's `NameTooLong`, which truncates nothing. `sockpath.runtimeDir` is the one place that names the DIRECTORY those paths go in, so the daemon, the client and the askpass listener agree by construction. `$XDG_RUNTIME_DIR` wins on every OS, because that is how every isolated rig keeps its sockets away from the user's. Linux has NO fallback — a guess cannot make two binaries agree on one daemon, so the caller names it with `--sock` — and Darwin falls back to `/tmp/mux-<uid>`, created 0700 and re-checked on every ask for owner, exact mode and no symlink in the last step, as tmux checks `/tmp/tmux-UID`. Not `$TMPDIR` and not `~/Library/Caches`: the longest name mux creates is the directory plus 68 bytes plus the pid's digits, and neither of those directories leaves room for it in 103 (measured 2026-09-03, docs/decisions.md). - No socket stealing: `mux d start` refuses a path another daemon owns. - **A dial the observer table cannot hold waits in the kernel backlog; it is never accepted and closed.** Every new connection lands in one of four observer slots until its attach frame promotes it, and eight tile pumps dial within 150 µs of each other, so the accept loop used to fill the four with peers whose frame was still in flight and close the fifth — one tile of an eight-tile wall lost at birth with nothing painted, one run in four (2026-09-05). `Server.freeObserverSlot` is asked before the accept and `pumpOnce` polls the listener only while it answers. The harness's three-at-a-time fill rule in `test/e2e_lib.sh` was the workaround and is now only a habit. - **A deleted socket path is taken back within a second, and the log says so.** A unix listener outlives its path: delete the file and the daemon keeps every session on an inode nothing can reach by name (2026-09-04, a live box, three sessions stranded and a second daemon auto-started on the path). `Server.watchSockPath` stats the path once a second against the bound inode and re-binds a missing one through the same `serve.bind(.refuse_live)` a start uses — so a successor already holding the path is refused, not stolen from, and its own stop hands the path back on the next tick. Every socket event is one `mux d: socket PATH:` line on the daemon's stderr (claim branch, bind dev+ino, loss, re-bind or its refusal by name, unlink verdict at exit), and the wall appends the errno its dial saw before it auto-starts. For the daemon a successor keeps path-less, `mux d stop|dump|stats --quic HOST[:PORT] [--key]` is the other door — the daemon has always served those verbs on a QUIC client slot. `upgrade` stays `--sock`.