a73x

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`.