summaryrefslogtreecommitdiff
path: root/docs/design.md
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-21 14:13:43 -0300
committerGabriel Schneider <[email protected]>2026-09-21 14:13:43 -0300
commit3a23f6a29e47ace901bd4d82b9db4055fcc12bb9 (patch)
treeb82d6e7c3ebe108434ce00ca75db59cf037917e0 /docs/design.md
parentf1b53c1533539aecbf16ad19fd9156deae091f92 (diff)
downloadcloud9-3a23f6a29e47ace901bd4d82b9db4055fcc12bb9.tar.gz
cloud9-3a23f6a29e47ace901bd4d82b9db4055fcc12bb9.zip
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.
Diffstat (limited to 'docs/design.md')
-rw-r--r--docs/design.md86
1 files changed, 84 insertions, 2 deletions
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/<name>`. 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/<uid>` 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.<pid>.<serial>`, 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)