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. --- 9proc/src/linux/probe.zig | 50 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) (limited to '9proc') diff --git a/9proc/src/linux/probe.zig b/9proc/src/linux/probe.zig index 4db277d..c2f1026 100644 --- a/9proc/src/linux/probe.zig +++ b/9proc/src/linux/probe.zig @@ -67,6 +67,10 @@ pub const Error = error{ TooManyProviders, PathTooLong, BadAddress, + /// The unix path holds a live server; nothing is deleted or taken. + AlreadyListening, + /// The unix path holds a foreign non-socket entry; never deleted. + Occupied, /// A syscall failed; `last_errno` says which error. Syscall, }; @@ -128,6 +132,8 @@ pub fn Probe(comptime Srv: type) type { wake_fd: i32 = -1, unix_path: [108]u8 = undefined, unix_len: usize = 0, + /// The bound entry's inode; `stop` unlinks only its own socket. + unix_ino: u64 = 0, thread: ?std.Thread = null, thread_tid: std.atomic.Value(u32) = .init(0), nclients: std.atomic.Value(u32) = .init(0), @@ -233,7 +239,11 @@ pub fn Probe(comptime Srv: type) type { p.listen_fd = -1; } if (p.unix_len > 0) { - _ = linux.unlink(@ptrCast(&p.unix_path)); + // Only our own entry: a late stop must never unlink a + // name another server has since claimed. + if (unixIno(@ptrCast(&p.unix_path))) |ino| { + if (p.unix_ino != 0 and ino == p.unix_ino) _ = linux.unlink(@ptrCast(&p.unix_path)); + } p.unix_len = 0; } if (p.wake_fd >= 0) { @@ -522,14 +532,48 @@ pub fn Probe(comptime Srv: type) type { try p.check(rc); const lfd: i32 = @intCast(rc); errdefer _ = linux.close(lfd); - // No libc, so no "is it still listening" probe: unlink a stale socket and bind. - _ = linux.unlink(@ptrCast(&sa.path)); + // Nothing foreign is deleted: a non-socket entry at the path + // is refused (Occupied), a live server is refused + // (AlreadyListening), and only a socket that refuses a + // connect — a corpse — is unlinked. (A hand racing the swap + // between probe and unlink is the documented residual of this + // cheap protocol; cloud9.post claims names atomically when + // that matters.) + const st = unixStat(@ptrCast(&sa.path)) catch return error.Occupied; + if (st) |s| { + if (s.mode & linux.S.IFMT != linux.S.IFSOCK) return error.Occupied; + if (cloud9.post.probe(@ptrCast(&sa.path)) != .stale) return error.AlreadyListening; + _ = linux.unlink(@ptrCast(&sa.path)); + } try p.check(linux.bind(lfd, @ptrCast(&sa), @sizeOf(linux.sockaddr.un))); try p.check(linux.listen(lfd, 128)); p.listen_fd = lfd; p.own_listener = true; p.unix_path = sa.path; p.unix_len = path.len; + // Our entry's inode, so `stop` never unlinks a name another + // server has since claimed. + p.unix_ino = if (unixStat(@ptrCast(&p.unix_path)) catch null) |s| s.ino else 0; + } + + /// statx(2) of one path: null when it does not exist, and an + /// error when the kernel cannot say — the caller refuses rather + /// than guesses. + fn unixStat(path: [*:0]const u8) !?linux.Statx { + var stx: linux.Statx = undefined; + const rc = linux.statx(linux.AT.FDCWD, path, 0, .{ .TYPE = true, .INO = true }, &stx); + const s: isize = @bitCast(rc); + if (s == 0) return stx; + const noent: isize = @intCast(@intFromEnum(linux.E.NOENT)); + if (s == -noent) return null; + return error.StatFailed; + } + + /// The socket at `path`, by inode; null when it is gone or + /// unreadable. + fn unixIno(path: [*:0]const u8) ?u64 { + const stx = unixStat(path) catch return null; + return if (stx) |s| s.ino else null; } fn listenTcp(p: *Self, text: []const u8) Error!void { -- cgit v1.3