summaryrefslogtreecommitdiff
path: root/9proc/src/linux/probe.zig
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 /9proc/src/linux/probe.zig
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 '9proc/src/linux/probe.zig')
-rw-r--r--9proc/src/linux/probe.zig50
1 files changed, 47 insertions, 3 deletions
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 {