a73x

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