From 3a23f6a29e47ace901bd4d82b9db4055fcc12bb9 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 21 Sep 2026 14:13:43 -0300 Subject: post registry + 9ns --mntgen: the /srv translation cloud9.post: servers post their socket under a name in $XDG_RUNTIME_DIR/9p (post/unpost, posted, dial, Watch) and serve.Runner.listenPosted posts a server by name, unposting on stop. Names are budget-checked against the 108-byte socket path; a claim binds+listens at a private temp path and takes the name with atomic renames under flock (RENAME_NOREPLACE for free names, RENAME_EXCHANGE grab-verify-commit for stale ones): the registry path is never unlinked by a claim, live names refuse with AlreadyPosted, foreign files with NotSocket, and unpost removes only the caller's inode-matched entry. Watch surfaces inotify overflow and a replaced registry dir. 9ns --mntgen [--mount DIR] -- PROGRAM: one FUSE mount at /mnt/9p whose synthetic root lists the posted registry (no connection made); a walk into an unmounted name dials it and runs the existing bridge dispatch in a per-server worker thread, routed by mount index in the node id's top bits (ordinals never reused, cap 4096); a dead server answers EIO on its subtree and is re-dialed on the next walk. The dial watches stop_fd through Tversion (connectWatched). All existing 9ns forms are unchanged. 9proc's unix listener no longer blind-unlinks its path: a foreign non-socket is refused (Occupied), a live server is refused (AlreadyListening), only a refused socket is cleared, and stop() unlinks only the listener's own inode-matched socket. Hardened by adversarial review (GLM 5.3 x2 + DeepSeek V4.1 Flash, all high-thinking): double-bind races on one name (0 in 180k rounds), foreign-file TOCTOU deletions (0 in 4M flips), a 255-byte-name listing panic, inotify queue overflow silently dropped, listenPosted silently overwriting, dial-time Tversion hangs wedging the dispatcher, --debug silently ignored in mntgen, and xattr/statx probes answering EPERM on the synthetic root (broke `ls -l /mnt/9p`). Tests: root 80/80, 9ns 47/47, 9proc 60/60, integration 88/88 + mntgen 37/37, adversarial 213/0, freestanding riscv32 gate green. --- docs/design.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 2 deletions(-) (limited to 'docs') diff --git a/docs/design.md b/docs/design.md index f34a3ea..e68530f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -170,8 +170,90 @@ still yields through `serve`, closes the socket and frees the slot. `stop()` cancels the group (`Io.Group.cancel` interrupts the blocking accepts and reads), joins every task and closes the listeners; a `serve` that waits on another thread must stop waiting once `stop()` has begun. -Unix socket paths are the application's: the runner neither unlinks, -chmods nor removes them. +Unix paths bound by `listen()` are the application's: the runner neither +unlinks, chmods nor removes them. `listenPosted(env, name, backlog)` is the +exception with a rule of its own: it posts through the registry (below) — +one posted name per runner, a second `listenPosted` is `error.AlreadyPosted` +(its socket would be orphaned in the registry, nothing left to unpost it) — +and `stop()` unposts, but only while the entry is still the runner's own +(`posted_ino`, below): a name re-posted by another server after this +runner's socket file was lost survives the stop. + +# Post registry + +`post` is the `/srv` translation: a server posts itself under a name in one +per-user registry directory and clients list the names and dial them, the +shape of Plan 9's `devsrv.c` and plan9port's `post9pservice`. The registry +is `$XDG_RUNTIME_DIR/9p/` (per-user tmpfs, the right lifetime); a name's +socket lives at `$XDG_RUNTIME_DIR/9p/`. There is no union daemon and +no per-server directory: mounting and namespace policy are the client's +(9ns `--mntgen` lists the registry for its synthetic root and dials on +walk). + +The library never reads the process environment: the environment comes in +as the raw block `main` receives (`post.Env`, scanned by `post.getenv`, +the same threading 9ns uses). `registryDir`/`registryPath` build paths +into caller buffers, zero-terminated, and refuse when `XDG_RUNTIME_DIR` is +unset — there is no `/tmp` fallback. A name is legal when it would be a +legal file name for the engine (`legalName` in fs.zig: non-empty, not "." +or "..", no '/' or NUL) *and* fits the Unix socket path budget +(`transport.sun_path_len`); path traversal through a posted name is the +attack the caps exist for, and `max_name_len` derives from that budget +against a conforming 32-byte `/run/user/` prefix, with the real +prefix checked again per call. + +`post.post(io, env, name, backlog, path_buf)` posts: the registry directory +is created 0o750 (already present is fine) and the claim runs. The claiming +socket is bound and **listening at a private temp path first** — +`$XDG_RUNTIME_DIR/.post.sock..`, beside the registry, never +inside it, so no listing ever sees it — and only then is the name taken, +entirely by atomic `renameat2` calls under an advisory `flock` on +`$XDG_RUNTIME_DIR/.post.lock` (the kernel drops the lock if the poster +dies; the dotfile persists, empty): a free name is claimed with +`RENAME_NOREPLACE`, which is the arbiter between two posts racing on one +name — exactly one ends up bound, the loser re-runs into +`error.AlreadyPosted`; a stale entry (a socket that refuses a connect) is +grabbed with `RENAME_EXCHANGE` against a private dummy file +(`.post.tmp.*`, also beside the registry) and verified by inode *and* a +fresh probe before the dead socket is removed, so a live server that came +up in between is swapped back untouched. **`post` never unlinks the +registry path**: what a claim displaces is parked under private temp names +and deleted only when provably its own dummy or the verified dead entry — +anything a foreign hand put in their place is left alone, intact. An +entry that is not a socket is `error.NotSocket` and is never touched at +all, learned the hard way in zmx. The lone exception is the legacy +fallback on filesystems without the `renameat2` flags: there the stale +entry is removed by an inode-checked in-place unlink (the replace window +that costs is confined to such filesystems). + +The claimed entry's inode comes back in `Posted{server, path, inode}`, and +`post.unpost(io, path, inode)` unlinks only that entry: a path whose +content has been replaced (the socket file lost, the name re-posted by +another server) is left strictly alone, so one server's late stop can +never unpost another's live name. Idempotent, errors swallowed. On the +server side, `serve.Runner.listenPosted` posts and `stop()` unposts. + +Probing is a nonblocking raw-syscall connect (`post.probe`: +`.none`/`.stale`/`.live`; uncertainty counts live), because `std.Io`'s +Unix connect does not promise ECONNREFUSED. The kernel answers a connect +to a non-socket entry with the same ECONNREFUSED as to a dead server's +socket, so `.stale` covers both — `post` tells them apart by stat before +anything is removed; callers that must know use `Io.Dir.statFile`. + +`post.posted(io, env, out)` lists the raw entries into a caller buffer as +`len:u8 name` staged records (the engine's readdir staging pattern; no +allocation, no connection made — a missing registry lists as empty) and +returns a `Names` iterator; staleness is the caller's concern, and a +buffer too small for the listing is `error.NoSpace`, never a silent drop. +`post.dial(io, env, name)` connects and answers `error.NotPosted` (no +entry) and `error.Stale` (entry present, connection refused) distinctly. +`post.Watch` is a Linux inotify watcher on the registry directory for +hosts that cache its listing (`init`, `add`, `next`, `deinit`; hosted +Linux only — everything else in the module is platform-neutral, and +`next` yields `added` and `removed` per name and, with an empty name, the +two signals a caching consumer must not miss: `overflow` — the kernel +dropped events, rescan — and `gone` — the watch itself ended (the +directory was removed); re-`add` and rescan. # Multiplexer (9web) -- cgit v1.3