summaryrefslogtreecommitdiff
path: root/src/serve.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 /src/serve.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 'src/serve.zig')
-rw-r--r--src/serve.zig175
1 files changed, 171 insertions, 4 deletions
diff --git a/src/serve.zig b/src/serve.zig
index 255e2ff..19a8ca2 100644
--- a/src/serve.zig
+++ b/src/serve.zig
@@ -12,6 +12,7 @@ const std = @import("std");
const Io = std.Io;
const fs = @import("fs.zig");
const transport = @import("transport.zig");
+const post = @import("post.zig");
/// Comptime bounds of one runner.
pub const Limits = struct {
@@ -72,6 +73,7 @@ pub fn Runner(comptime Backend: type, comptime opts: fs.Options, comptime limits
};
pub const ListenError = error{TooManyListeners} || Io.net.IpAddress.ListenError || Io.net.UnixAddress.ListenError || Io.net.UnixAddress.InitError || Io.ConcurrentError;
+ pub const ListenPostedError = post.PostError || error{TooManyListeners} || Io.ConcurrentError;
io: Io,
root: u64,
@@ -81,6 +83,14 @@ pub fn Runner(comptime Backend: type, comptime opts: fs.Options, comptime limits
conns: [limits.connections]Conn,
listeners: [limits.listeners]Io.net.Server,
nlisteners: usize,
+ /// The registry socket of a `listenPosted`, zero-terminated;
+ /// `stop()` unlinks it (unpost on stop) — but only while it is
+ /// still this runner's entry (`posted_ino`).
+ posted_path: [transport.sun_path_len + 1]u8 = @splat(0),
+ posted_len: usize = 0,
+ /// The bound registry entry's inode, the ownership proof for
+ /// the unpost in `stop()`.
+ posted_ino: u64 = 0,
/// Accept tasks and connection tasks; `stop()` cancels it.
group: Io.Group,
/// Guards `Conn.used`.
@@ -292,6 +302,7 @@ pub fn Runner(comptime Backend: type, comptime opts: fs.Options, comptime limits
r.seed = o.seed;
r.handler = o.handler;
r.greet_timeout_ms = o.greet_timeout_ms;
+ r.posted_len = 0;
r.nlisteners = 0;
r.group = .init;
r.slots = .init;
@@ -307,15 +318,46 @@ pub fn Runner(comptime Backend: type, comptime opts: fs.Options, comptime limits
pub fn listen(r: *Self, address: transport.Address, backlog: u31) ListenError!Io.net.IpAddress {
if (r.nlisteners == limits.listeners) return error.TooManyListeners;
if (r.stopping.load(.acquire)) return error.TooManyListeners;
+ var server = try transport.listen(r.io, address, backlog);
+ errdefer server.deinit(r.io);
+ try r.startListener(server);
+ return server.socket.address;
+ }
+
+ /// Posts the runner on the registry socket
+ /// `$XDG_RUNTIME_DIR/9p/<name>` and starts accepting on it:
+ /// `post.post` creates the 0o750 registry directory and runs the
+ /// stale protocol (a refused entry is replaced; a live server
+ /// owning the name is `AlreadyPosted`; a non-socket entry is
+ /// never deleted). One posted name per runner: a second
+ /// `listenPosted` is `AlreadyPosted` (its socket would otherwise
+ /// be orphaned in the registry — nothing would unpost it).
+ /// `stop()` unposts — the socket is unlinked when the runner
+ /// stops, as long as the entry is still the runner's own.
+ pub fn listenPosted(r: *Self, env: post.Env, name: []const u8, backlog: u31) ListenPostedError!void {
+ if (r.nlisteners == limits.listeners) return error.TooManyListeners;
+ if (r.stopping.load(.acquire)) return error.TooManyListeners;
+ if (r.posted_len != 0) return error.AlreadyPosted;
+ var p = try post.post(r.io, env, name, backlog, &r.posted_path);
+ errdefer {
+ post.unpost(r.io, p.path, p.inode);
+ p.server.deinit(r.io);
+ }
+ try r.startListener(p.server);
+ r.posted_len = p.path.len;
+ r.posted_ino = p.inode;
+ }
+
+ /// Registers a bound listener and starts its accept task.
+ fn startListener(r: *Self, server: Io.net.Server) Io.ConcurrentError!void {
const i = r.nlisteners;
- r.listeners[i] = try transport.listen(r.io, address, backlog);
+ r.listeners[i] = server;
errdefer r.listeners[i].deinit(r.io);
r.nlisteners += 1;
r.group.concurrent(r.io, acceptLoop, .{ r, i }) catch |err| {
r.nlisteners -= 1;
return err;
};
- return r.listeners[i].socket.address;
}
/// Connections held right now.
@@ -333,13 +375,21 @@ pub fn Runner(comptime Backend: type, comptime opts: fs.Options, comptime limits
for (&r.conns) |*c| if (c.live()) c.close();
}
- /// Stops accepting, hangs every connection up, waits for their tasks
- /// and closes the listeners. Idempotent; the runner is spent after.
+ /// Stops accepting, hangs every connection up, waits for their
+ /// tasks and closes the listeners. A posted listener is unposted
+ /// — its registry socket is unlinked, but only while the entry
+ /// is still the runner's own: a name that was re-posted by
+ /// another server (this one's socket file having been lost)
+ /// survives the stop. Idempotent; the runner is spent after.
pub fn stop(r: *Self) void {
if (r.stopping.swap(true, .acq_rel)) return;
r.group.cancel(r.io);
for (r.listeners[0..r.nlisteners]) |*l| l.deinit(r.io);
r.nlisteners = 0;
+ if (r.posted_len != 0) {
+ post.unpost(r.io, r.posted_path[0..r.posted_len :0], r.posted_ino);
+ r.posted_len = 0;
+ }
}
fn acceptLoop(r: *Self, i: usize) void {
@@ -846,3 +896,120 @@ test "serve: a backend answered from another thread under lock()" {
rig.runner.stop();
try expectHangup(tc.one(.{ .stat = .{ .fid = 0 } }));
}
+
+test "serve: listenPosted serves the registry name and stop() unposts" {
+ if (@import("builtin").os.tag != .linux) return error.SkipZigTest;
+ var rig: Rig = .{ .dir = undefined };
+ const io = testing.io;
+ // A scratch registry: XDG_RUNTIME_DIR is the rig's own temp dir.
+ rig.dir = testing.tmpDir(.{});
+ errdefer rig.dir.cleanup();
+ var real_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const len = try rig.dir.dir.realPath(io, &real_buf);
+ var env_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const value = try std.fmt.bufPrintZ(&env_buf, "XDG_RUNTIME_DIR={s}", .{real_buf[0..len]});
+ const env = [2]?[*:0]const u8{ @ptrCast(value.ptr), null };
+ const envp: post.Env = @ptrCast(&env);
+
+ rig.stub = .{};
+ rig.runner.init(.{ .io = io, .root = Stub.root_node, .handler = .{ .ctx = &rig.stub, .serve = serveNow } });
+ try rig.runner.listenPosted(envp, "posted", 4);
+ var path_buf: [transport.sun_path_len]u8 = undefined;
+ const path = try post.registryPath(envp, "posted", &path_buf);
+
+ // The name is posted, live, and serves a full 9P session.
+ try testing.expect(post.probe(path) == .live);
+ var tc: TestClient = .{ .io = undefined, .stream = undefined };
+ try tc.open(io, .{ .unix = path });
+ defer tc.close();
+ try tc.handshake();
+ try tc.readIndex(1);
+
+ // A second post of the same name is refused while the runner lives.
+ var pbuf: [transport.sun_path_len]u8 = undefined;
+ try testing.expectError(error.AlreadyPosted, post.post(io, envp, "posted", 4, &pbuf));
+
+ // The listing sees it; stop() unposts and the entry disappears.
+ var stage: [512]u8 = undefined;
+ var names = try post.posted(io, envp, &stage);
+ var seen = false;
+ while (names.next()) |n| seen = seen or std.mem.eql(u8, n, "posted");
+ try testing.expect(seen);
+ rig.runner.stop();
+ try testing.expectError(error.FileNotFound, Io.Dir.statFile(.cwd(), io, path, .{}));
+ names = try post.posted(io, envp, &stage);
+ try testing.expect(names.next() == null);
+ rig.dir.cleanup();
+}
+
+test "serve: a second listenPosted is refused; stop() never unposts another's name" {
+ if (@import("builtin").os.tag != .linux) return error.SkipZigTest;
+ var rig: Rig = .{ .dir = undefined };
+ const io = testing.io;
+ rig.dir = testing.tmpDir(.{});
+ errdefer rig.dir.cleanup();
+ var real_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const len = try rig.dir.dir.realPath(io, &real_buf);
+ var env_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const value = try std.fmt.bufPrintZ(&env_buf, "XDG_RUNTIME_DIR={s}", .{real_buf[0..len]});
+ const env = [2]?[*:0]const u8{ @ptrCast(value.ptr), null };
+ const envp: post.Env = @ptrCast(&env);
+
+ rig.stub = .{};
+ rig.runner.init(.{ .io = io, .root = Stub.root_node, .handler = .{ .ctx = &rig.stub, .serve = serveNow } });
+ try rig.runner.listenPosted(envp, "twice", 4);
+ // One posted name per runner: a second would overwrite the first's
+ // path and orphan its socket in the registry.
+ try testing.expectError(error.AlreadyPosted, rig.runner.listenPosted(envp, "twice", 4));
+ try testing.expectError(error.AlreadyPosted, rig.runner.listenPosted(envp, "other", 4));
+
+ var path_buf: [transport.sun_path_len]u8 = undefined;
+ const path = try post.registryPath(envp, "twice", &path_buf);
+ // The runner's socket file is lost behind its back (rm, crash
+ // cleanup), and another server takes the now-free name.
+ try Io.Dir.deleteFileAbsolute(io, path);
+ var thief = try post.post(io, envp, "twice", 4, &path_buf);
+ try testing.expect(post.probe(thief.path) == .live);
+ // stop() unposts only what it still owns: the thief survives.
+ rig.runner.stop();
+ const st = try Io.Dir.statFile(.cwd(), io, thief.path, .{});
+ try testing.expect(st.kind == .unix_domain_socket);
+ try testing.expect(post.probe(thief.path) == .live);
+ post.unpost(io, thief.path, thief.inode);
+ thief.server.deinit(io);
+ rig.dir.cleanup();
+}
+
+test "serve: listenPosted beside listen(): stop unposts only the registry name" {
+ if (@import("builtin").os.tag != .linux) return error.SkipZigTest;
+ var rig: Rig = .{ .dir = undefined };
+ const io = testing.io;
+ rig.dir = testing.tmpDir(.{});
+ errdefer rig.dir.cleanup();
+ var real_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const len = try rig.dir.dir.realPath(io, &real_buf);
+ var env_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const value = try std.fmt.bufPrintZ(&env_buf, "XDG_RUNTIME_DIR={s}", .{real_buf[0..len]});
+ const env = [2]?[*:0]const u8{ @ptrCast(value.ptr), null };
+ const envp: post.Env = @ptrCast(&env);
+
+ rig.stub = .{};
+ rig.runner.init(.{ .io = io, .root = Stub.root_node, .handler = .{ .ctx = &rig.stub, .serve = serveNow } });
+ // A plain Unix listener (the application's path policy) beside the
+ // posted name: both listener slots fill.
+ var unix_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const unix_path = try std.fmt.bufPrintZ(&unix_buf, "{s}/plain.sock", .{real_buf[0..len]});
+ _ = try rig.runner.listen(.{ .unix = unix_path }, 4);
+ try rig.runner.listenPosted(envp, "mixed", 4);
+
+ var path_buf: [transport.sun_path_len]u8 = undefined;
+ const posted_path = try post.registryPath(envp, "mixed", &path_buf);
+ try testing.expect(post.probe(posted_path) == .live);
+ rig.runner.stop();
+ // The posted name is unposted; the plain path is the application's.
+ try testing.expectError(error.FileNotFound, Io.Dir.statFile(.cwd(), io, posted_path, .{}));
+ const plain_st = try Io.Dir.statFile(.cwd(), io, unix_path, .{});
+ try testing.expect(plain_st.kind == .unix_domain_socket);
+ try Io.Dir.deleteFileAbsolute(io, unix_path);
+ rig.dir.cleanup();
+}