diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-21 14:13:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-21 14:13:43 -0300 |
| commit | 3a23f6a29e47ace901bd4d82b9db4055fcc12bb9 (patch) | |
| tree | b82d6e7c3ebe108434ce00ca75db59cf037917e0 /src/serve.zig | |
| parent | f1b53c1533539aecbf16ad19fd9156deae091f92 (diff) | |
| download | cloud9-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.zig | 175 |
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(); +} |
