docs/superpowers/specs/2026-09-05-daemon-socket-lifecycle-design.md
Ref: Size: 6.6 KiB History
# The daemon's socket path: a trail in the log, and a way back
Issues `04b3019d` (the daemon logs nothing about its socket) and `145807a2`
(a daemon whose socket file is deleted can never be reached by path again).
Both from the 2026-09-04 incident: a live daemon's `muxd.sock` vanished from
`/run/user/1000`, the daemon kept its three sessions on an unlinked inode, a
second daemon auto-started onto the same path, and nothing in the log said
any of it happened.
## What the code does today
- `sockpath.claim` decides whether a path is ours to bind (nothing there /
dead leftover cleared / live daemon refused / not a socket) and returns
only `void` or an error — the branch it took is not observable.
- `serve.bind` binds and stamps `PathId` (dev+ino of the PATH). `Bound.close`
unlinks only if `stillAt` — and says nothing either way.
- `Server.acceptConn` accepts off the listener fd and never looks at the path.
- The daemon's log IS its stderr (`forkDetached` hands it the xdg log,
O_APPEND); every line is a `std.debug.print("mux d: ...")`. There is no
`std.log`; this design adds none.
- `handleDaemonVerb` serves `stats_req`, `sessions_req`, `endpoint_req`,
`debug_dump`, `end_req` and `stop_req` for CLIENT slots as well as
observers, so a QUIC peer can already ask them. `upgrade_req` is
observer-only (unix socket). `mux d stop/dump/stats` take `--sock` only
(`main.oneShotQuery`, `stopCmd`, over `dial.ask`).
- The wall's local entry (`mux_main.startLocalDaemon`) runs
`mux d start -d --sock PATH` when `sockpath.answers` says no, and records
nothing about why the dial failed.
## Design
### 1. The log trail (04b3019d)
One line each, `mux d: socket ...`, on the daemon's stderr:
| Event | Line |
|---|---|
| claim, at `Server.init` | `socket PATH: claimed (free)` / `claimed (cleared a dead leftover)`; refusals already propagate by error name and `main.run` prints them — add the path and the branch to that print so the log says WHICH errno refused |
| bind | `socket PATH: bound dev=D ino=I` (from `Bound.path_id`); an adopted listener logs `adopted dev=D ino=I` from `initFromManifest` |
| loss | `socket PATH: no longer names our listener (was dev=D ino=I, now missing)` or `(now dev=D2 ino=I2 — a successor)` — once per transition, from the watch in §2 |
| recovery | `socket PATH: re-bound dev=D ino=I` or `socket PATH: cannot re-bind: <errname>` |
| unlink at exit | `socket PATH: unlinked` / `left in place: not ours (dev=D2 ino=I2)` / `left in place: already gone` |
`sockpath.claim` returns a `Claimed` enum (`.free`, `.cleared_leftover`)
instead of `void`; `serve.Bound.close` and `unlinkIfOurs` return an
`Unlink` enum (`.unlinked`, `.spared_successor`, `.already_gone`). The
shared modules print nothing — `serve` is also askpass's binder inside a
wall, where a print lands on somebody's pane — the daemon does.
The wall's auto-start: `mux_main.startLocalDaemon` appends ONE line to the
xdg log before it forks — `mux: auto-starting a daemon on PATH: dial said
<errname>` — where the errname is what `sockpath.connectSocket` answered
(`FileNotFound` for a missing path, `ConnectionRefused` for a dead socket
file). `sockpath.answers` stays a bool; a sibling `sockpath.probe` returns
the error name, and `answers` is `probe == null`. This is the line that
would have dated the second daemon's birth against the first one's loss.
### 2. Watching the path and taking it back (145807a2, option a)
`Server.pumpOnce` gains a once-a-second stat of `sock_path` against
`bound.path_id` (`Server.sock_watch_interval_ms`, same shape as the agent relay's timers).
Cost: one `fstatat` per second per daemon.
On loss:
- path missing → `serve.bind(path, .refuse_live)` a fresh listener, swap
it into `self.bound` (the old fd is closed; accepted connections are
their own fds and are untouched; anything in the old backlog was never
reachable by name anyway), log `re-bound`.
- something else at the path → `claim` refuses it (live successor:
`DaemonAlreadyRunning`; a non-socket: `SockPathNotASocket`; a dead
leftover it CLEARS and binds, which is the one case where the deleted
path came back as junk). Log the refusal by name, keep the watch running
— a successor that later stops unlinks its file and the next tick takes
the path back.
The rule "no socket stealing" holds unchanged: the re-bind goes through
the same `claim` a start does. The window in which a wall can auto-start a
second daemon shrinks from forever to one tick.
`mux d upgrade` carries the listener fd across the exec via the manifest;
a re-bound fd is just the current `bound.fd`, so nothing there changes.
`upgrade_req` is refused while a re-bind is pending? No pending state
exists: the re-bind is synchronous inside one tick.
### 3. Admin verbs by QUIC (145807a2, option b) — taken, 2026-09-05
`mux d stop|dump|stats --quic HOST:PORT [--key PATH]` for a daemon whose
path is gone AND whose slot the successor now holds. Client-side only:
`oneShotQuery` and `stopCmd` take a `Target` (`sock | quic`) and open a
`link.Link` of the matching arm; the daemon already answers those verbs on
a client slot. `stopCmd`'s "did it die" wait polls the QUIC endpoint
instead of `sockpath.answers`. `upgrade` stays `--sock` (observer-only
verb; the manifest is local to the box either way).
Not in scope: an admin socket of its own, a signal handler.
## Tests
- `sockpath`: `claim` returns `.free` on nothing, `.cleared_leftover` on a
dead socket file; `probe` names `FileNotFound` / `ConnectionRefused`.
- `serve`: `close` returns `.unlinked` / `.spared_successor` /
`.already_gone` (extends the existing three tests).
- `server_test_session` (harness `TestDaemon`): delete the socket path
under a running daemon, pump past one tick, dial the path again and get
a `sessions_reply`; then the successor case — bind a second listener at
the path, pump, assert the first did NOT unlink or replace it, unbind
the second, pump, assert the first has the path back.
- e2e (`e2e_03_side` or a new `e2e_NN_socket`): `rm` the socket of a live
daemon, `sleep 2`, `mux d stats --sock PATH` answers, and the log carries
the loss line and the re-bound line in that order; `mux d stop` then
unlinks and logs `unlinked`.
- Log lines: as built, `logSocket` writes stderr like the rest of the
daemon's diagnostics (fd 2, never fd 1, so the fd-1 wedge rule is not in
play) and the trail is graded off the real log by the e2e leg in the boot
group; the unit test grades the watch's STATE (`sock_watch.lost`,
`sock_watch.refused`, the bound inode), not the wording. The wall's
auto-start note has a unit test of its own on `xdg.appendLogLineTo`.