547a0911
docs: record the socket-path watch, its log trail and the QUIC admin door
a73x 2026-09-05 19:04
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -447,6 +447,21 @@ native session panes; it is built only by the native steps and is never installe | |||
| 447 | plus 68 bytes plus the pid's digits, and neither of those directories | 447 | plus 68 bytes plus the pid's digits, and neither of those directories |
| 448 | leaves room for it in 103 (measured 2026-09-03, docs/decisions.md). | 448 | leaves room for it in 103 (measured 2026-09-03, docs/decisions.md). |
| 449 | - No socket stealing: `mux d start` refuses a path another daemon owns. | 449 | - No socket stealing: `mux d start` refuses a path another daemon owns. |
| 450 | - **A deleted socket path is taken back within a second, and the log says | ||
| 451 | so.** A unix listener outlives its path: delete the file and the daemon | ||
| 452 | keeps every session on an inode nothing can reach by name (2026-09-04, a | ||
| 453 | live box, three sessions stranded and a second daemon auto-started on | ||
| 454 | the path). `Server.watchSockPath` stats the path once a second against | ||
| 455 | the bound inode and re-binds a missing one through the same | ||
| 456 | `serve.bind(.refuse_live)` a start uses — so a successor already holding | ||
| 457 | the path is refused, not stolen from, and its own stop hands the path | ||
| 458 | back on the next tick. Every socket event is one `mux d: socket PATH:` | ||
| 459 | line on the daemon's stderr (claim branch, bind dev+ino, loss, re-bind | ||
| 460 | or its refusal by name, unlink verdict at exit), and the wall appends | ||
| 461 | the errno its dial saw before it auto-starts. For the daemon a successor | ||
| 462 | keeps path-less, `mux d stop|dump|stats --quic HOST[:PORT] [--key]` is | ||
| 463 | the other door — the daemon has always served those verbs on a QUIC | ||
| 464 | client slot. `upgrade` stays `--sock`. | ||
| 450 | 465 | ||
| 451 | ## Working rules | 466 | ## Working rules |
| 452 | 467 | ||
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -9220,3 +9220,78 @@ also lets a new client attach to snapshots retained by an existing daemon. | |||
| 9220 | Terminal consumers still never receive raw control bytes from those cells. | 9220 | Terminal consumers still never receive raw control bytes from those cells. |
| 9221 | ASCII runs containing controls and structurally malformed rows remain | 9221 | ASCII runs containing controls and structurally malformed rows remain |
| 9222 | errors; the snapshot corruption checks are unchanged. | 9222 | errors; the snapshot corruption checks are unchanged. |
| 9223 | |||
| 9224 | ## 2026-09-05 — a deleted socket path: a trail in the log, a re-bind, and a QUIC door | ||
| 9225 | |||
| 9226 | Issues 04b3019d and 145807a2, from one incident on 2026-09-04: a daemon's | ||
| 9227 | `muxd.sock` vanished from `/run/user/1000` between 13:21 and 13:45. The | ||
| 9228 | daemon (three days up, upgraded in place that morning) kept its three | ||
| 9229 | sessions and kept listening on the unlinked inode; nothing could reach it | ||
| 9230 | by name; the next `mux` auto-started a second daemon on the same path; and | ||
| 9231 | the day's log held ghostty stream warnings and nothing else. Who deleted | ||
| 9232 | the file was never established. | ||
| 9233 | |||
| 9234 | ### What was decided | ||
| 9235 | |||
| 9236 | **The daemon logs its socket.** One `mux d: socket PATH: ...` line per | ||
| 9237 | event on stderr, which `forkDetached` points at the xdg log: the claim's | ||
| 9238 | branch (`nothing there` / `cleared a dead daemon's leftover`), the bind | ||
| 9239 | with dev+ino, an adopt across an upgrade, the loss of the path (`now | ||
| 9240 | missing` or `now dev=D ino=I` for a replacement), a re-bind or its refusal | ||
| 9241 | by error name, and the unlink guard's verdict at exit (`unlinked` / `left | ||
| 9242 | in place: it names another daemon's socket now` / `left in place: already | ||
| 9243 | gone`). The shared modules stay silent — `serve` is also askpass's binder | ||
| 9244 | inside a wall, where a print lands on a pane — so `sockpath.claim` returns | ||
| 9245 | a `Claimed`, `serve.Bound.close` and `unlinkIfOurs` return an `Unlink`, | ||
| 9246 | and the daemon prints. The wall's `startLocalDaemon` appends ONE line to | ||
| 9247 | the same log before it forks, naming what its dial answered | ||
| 9248 | (`FileNotFound` for an absent path, `ConnectionRefused` for a dead socket | ||
| 9249 | file): that line is what would have dated the second daemon against the | ||
| 9250 | first one's loss. `sockpath.probe` is `answers` with the error kept. | ||
| 9251 | |||
| 9252 | **The daemon takes the path back.** `Server.watchSockPath`, once a second | ||
| 9253 | from `pumpOnce`: one `fstatat` against `bound.path_id`. A missing path is | ||
| 9254 | re-bound through `serve.bind(.refuse_live)` — the same claim a start makes, | ||
| 9255 | so "no socket stealing" holds by construction — and the old listener fd is | ||
| 9256 | closed (accepted connections are their own fds; the old backlog held | ||
| 9257 | nothing reachable by name). A successor holding the path is | ||
| 9258 | `DaemonAlreadyRunning` from claim, logged once per reason, and the watch | ||
| 9259 | keeps looking: the successor's own `mux d stop` unlinks its file and the | ||
| 9260 | next tick reclaims. The window for a wall to auto-start a second daemon | ||
| 9261 | shrinks from forever to one tick. Rejected: a stat on every accept (a | ||
| 9262 | path-less daemon receives no accepts by path, so it would never fire — the | ||
| 9263 | timer is the only place the check can live) and re-binding without the | ||
| 9264 | claim (that IS socket stealing). | ||
| 9265 | |||
| 9266 | **The admin verbs get a second door.** `mux d stop|dump|stats --quic | ||
| 9267 | HOST[:PORT] [--key FILE]`, for the daemon a successor keeps path-less. The | ||
| 9268 | daemon has served those three verbs on a QUIC client slot since `mux a | ||
| 9269 | --quic` existed (`handleDaemonVerb` runs before the client-slot switch), so | ||
| 9270 | this is client-side only: `AdminTarget` in main.zig, the key by the same | ||
| 9271 | three-way rule as `mux a`. A QUIC ask is bounded (5 s) where the socket's | ||
| 9272 | is not, because UDP has no "nothing listening" errno. Stop's verdict over | ||
| 9273 | QUIC is the daemon's CONNECTION_CLOSE (`Server.deinit` tears client slots | ||
| 9274 | down before anything else), and a port that never answered is rc 1 — the | ||
| 9275 | socket's idempotent 0 is honest only where the errno says "absent", and | ||
| 9276 | silence over UDP may be a firewall or the wrong key. `--sock` with `--quic` | ||
| 9277 | on those verbs is a parse refusal; `start` still takes both. `upgrade` stays | ||
| 9278 | `--sock`: `upgrade_req` is served to observers only, and its manifest is | ||
| 9279 | local to the box. | ||
| 9280 | |||
| 9281 | ### Measured | ||
| 9282 | |||
| 9283 | - Unit: the server test deletes the path, sees the re-bind within one tick, | ||
| 9284 | dials it and gets a `sessions_reply`; binds a successor, sees | ||
| 9285 | `DaemonAlreadyRunning` recorded and the successor's inode untouched; | ||
| 9286 | closes the successor without unlinking and sees the leftover cleared and | ||
| 9287 | the path reclaimed. | ||
| 9288 | - e2e, boot group: a real detached daemon, `rm` of its socket, `mux d stats | ||
| 9289 | --sock` answering again within the budget, the loss and re-bind lines in | ||
| 9290 | the real log with the inode read off the bind line, the marker still on | ||
| 9291 | the grid and the pid unchanged. | ||
| 9292 | - e2e, session group: the `--quic` daemon with its socket deleted; stats, | ||
| 9293 | dump and stop over UDP; the stop graded by the pid going and the file | ||
| 9294 | gone; a second stop on the dead port rc 1. | ||
| 9295 | - `make check` flaked once on `Pty.adopt`'s 5 s exit-code wait while a | ||
| 9296 | sibling worktree's `make ci` was loading the box; green on the re-run, | ||
| 9297 | and unrelated to any file this branch touched. | ||