From def843b2f59b867ee9b1d501f559f59fb335d4cc Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 27 Aug 2026 15:32:02 -0300 Subject: 9p: serve the acme tree over 9P2000 on a unix socket, beside the mount Step 4 of the 9P chain (docs/9p.typ 12.4, docs/registry.typ 9P-15/16/17/4/5). src/9p.zig is a base 9P2000 codec and a SANS-IO server: it never touches a descriptor, takes no allocator, starts no thread, and builds for wasm32-freestanding and riscv32-freestanding. That is what lets the same code serve a unix socket here and a UART on the board later. Server(comptime fs: type) duck-typed on fs.Req/fs.Reply/fs.Reply.Attr, so it never imports acmefs and acmefs never learns 9P init{ in, out, root } the caller owns the buffers; msize is derived retry/next/reply the three fs_service.Transport ops, by name push/output/wrote/hangup bytes in, bytes out, partial writes supported next() is a PUMP, not one-message-one-request: a 3-element Twalk is three lookups, Topen|OTRUNC is a setattr then an open, Tversion is none at all. Decisions that were open and are now taken, each recorded in the file: * qid.version is ALWAYS 0, which makes Linux set P9L_DIRECT and skip its cache -- the 9P equivalent of the FOPEN_DIRECT_IO fuse.zig relies on. * Every Rread is clamped to the client's count. An over-long one is a hard -EIO in Linux, not a truncation. * Rerror carries Linux's exact strerror text (registry 9P-4 option A), so a mount recovers the errno instead of ESERVERFAULT. Asserted as literals, because a typo there is 'Unknown error 526' on every mount. * `.` and `..` are resolved BY THE SERVER. Under FUSE the kernel does it and acmefs says so; 9P has no kernel, and forwarding `..` as a lookup would break every client that normalises a path. * Topen checks the perm bits itself. Under FUSE the kernel enforced them; over 9P nobody is above the server, and `errors` would have been readable. * Tcreate and Tremove are Rerror: `new/` creates a pane on WALK, so the capability exists and is not spelled Tcreate. THE INTEGRATION BUG, which was not in the protocol: the daemon's push_fs_reply sent every reply to the FUSE mount, whose park table has no 9P tag, so it dropped it -- Tversion worked (no core involved) and Tattach hung forever. That is exactly the 'no routing origin for the 9P descriptor' cell in the layering table of docs/9p.typ. Session.fs_origin now carries the transport that asked. Proved with plan9port against a live daemon serving BOTH transports at once: 9p ls / and /1, read index/ctl/tag, write /1/body, stat, a walk through /1/../index, pane creation through `new/body`, and the two refusals arriving as strings -- 'permission denied' and 'No such file or directory' -- confirmed on the raw wire as Rerror text rather than numbers. A write over 9P reads back through FUSE and a write through FUSE reads back over 9P. msize 8192, 34,072 bytes per connection (Server 9,488 + in 8,192 + out 16,384, out being two msize so that every reply is infallible), four connections. zig build unit-test: 468 tests before, 503 after. --- src/fs9_service.zig | 496 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 src/fs9_service.zig (limited to 'src/fs9_service.zig') diff --git a/src/fs9_service.zig b/src/fs9_service.zig new file mode 100644 index 00000000..f02e179e --- /dev/null +++ b/src/fs9_service.zig @@ -0,0 +1,496 @@ +//! `pardes --fs9`: what a native HOST has to decide to serve acme's control +//! filesystem over 9P2000 on a unix socket. +//! +//! `src/fs_service.zig`'s sibling, and deliberately a separate file: that one +//! is three decisions about a MOUNT (where to mount, when to drain, what a +//! pane shell is told), and this one is a listener with connections, buffers +//! and a socket path. They share the seam and nothing else — `Transport`, +//! `drain` and `Drained` all live there and are used verbatim here, which is +//! the whole point of `9P-2`: a second answer to the same three functions. +//! +//! WHAT IS HERE: a bound listening socket, a small fixed table of connections, +//! and the four things a non-blocking byte stream needs — accept, read into +//! `push`, `output` out through `write` and back through `wrote`, and hangup. +//! Everything above that is `src/9p.zig`, which is freestanding and knows +//! about neither sockets nor `acmefs.zig`; the instantiation +//! `ninep.Server(pardes.acmefs)` happens here and nowhere else. +//! +//! WHAT IS NOT HERE: the drain. One connection is one `Transport`, and the +//! host calls `fs_service.drain` per connection per frame at the same point in +//! the frame it drains the mount — see `drainAll`, and see +//! `detached/server.zig`'s `Source.ninep` for why a filesystem request must +//! not be served from inside a poll dispatch. +//! +//! Linux and darwin, like every other unix socket in the tree. On anything +//! else `open` returns null and the flag is quietly off. +const std = @import("std"); +const libc = std.c; +const nested = @import("nested.zig"); +const ninep = @import("9p.zig"); +const pardes = @import("pardes.zig"); +const fs_service = @import("fs_service.zig"); + +/// Diagnostics land where `fs_service`'s do and for its reason: stderr IS the +/// screen in the tty shell, so this is the `PARDES_LOG=1` copy. +const log = std.log.scoped(.fs9); + +/// Unix sockets, which is all this needs. `nested.supported` also demands a +/// way to name an arbitrary pid's executable, which no part of this asks. +pub const supported = builtin_unix; +const builtin_unix = @import("builtin").os.tag == .linux or nested.darwin; + +/// `sun_path`, from the kernel's struct. See `nested.sun_path_len`. +const sun_path_len = nested.sun_path_len; + +/// A THIRD prefix in the one per-user directory, beside nested.zig's +/// `pardes-.sock` and detached/server.zig's `pardes-detached-.sock`, +/// for the reason `nested.zig:39-44` gives: one directory vetted by different +/// predicates is exactly the divergence that naming prevents. That file's +/// sweeper unlinks any name whose digits name a dead pid, and this socket must +/// never look like one; the detached transport's `vetted` accepts only its own +/// prefix, so a 9P socket cannot be dialled by a frontend expecting `wire.zig` +/// either. +const prefix = "pardes-9p-"; + +/// The msize this host serves, and the ONE number both buffers are sized from. +/// +/// 8 KiB, which is `9P-17`'s clamp applied to the thing a client actually +/// reads: the largest single answer this tree produces is one pane's `body`, +/// and a client reading a megabyte of it does so in msize-sized `Tread`s +/// whatever this number is. So the only thing a larger msize buys is fewer +/// round trips on a LOCAL socket, and the only thing it costs is resident +/// memory in a daemon nobody is talking to. 8 KiB is two `Tread`s per screen +/// of text and comfortably above `ninep.min_msize` (4096), which is the floor +/// below which plan9port's `9p` and Linux's `v9fs` start refusing mounts with +/// `EINVAL` and no message. +pub const msize: u32 = 8192; + +/// Connections one session serves at once. +/// +/// FOUR, and it is not a guess about load: a 9P client here is a SCRIPT, and +/// the thing a script does is walk, read and clunk. What holds a connection +/// open for minutes is a blocked reader on `event` or `cons` — one per script +/// that is watching the editor — and beyond a handful of those the honest +/// answer is that somebody is using the wrong tool. The number is small on +/// purpose because a connection costs its buffers whether it is busy or idle: +/// MEASURED at 34,072 B each (a `Server` of 9,488 B plus `msize` in and twice +/// `msize` out) for 136,408 B of table, which is the whole of what `--fs9` +/// adds to a daemon's resident memory. A refused connect is also a diagnostic +/// a script author sees immediately, where a silently queued one is not. The +/// detached transport's `max_clients` is 32 because a frontend is a human's +/// window; this is not that. +pub const max_conns = 4; + +/// The 9P server, over the filesystem ABI `acmefs.zig` defines. This +/// instantiation is the only coupling between the freestanding protocol file +/// and the core, and it is a type parameter rather than an import for the +/// reason `9p.zig`'s `Server` doc comment gives. +const Srv = ninep.Server(pardes.acmefs); + +/// One connection: a socket, a server, and the server's two buffers. +/// +/// THE BUFFERS ARE FIELDS HERE, which is what `Srv`'s "no allocator" means +/// from the caller's side: `srv.in` and `srv.out` are slices INTO this struct, +/// so a `Conn` must never be moved or copied once `srv` is initialised. That +/// is why `Listener` is heap-allocated by `open` and held by pointer, and why +/// nothing below takes a `Conn` by value. +/// +/// `out` is twice `msize` because `Srv` requires it: one reply being written +/// out and one being built, which is what lets a reply be encoded the moment +/// the core answers with no "can I write yet" question anywhere in `9p.zig`. +const Conn = struct { + /// Non-negative exactly while the peer is connected. It goes to -1 the + /// moment the connection ends, which is BEFORE this slot is free: see + /// `draining`. + fd: c_int = -1, + /// The peer has gone and the server still owes the core `release` calls + /// for the fids it held. A dropped `event` fid without one leaves the + /// pane's reader count high forever (`acmefs.zig:1053-1061`), so the slot + /// stays occupied, with no descriptor, until `next()` runs dry. See + /// `Srv.hangup` and `drainAll`. + draining: bool = false, + /// Undefined until `accept` initialises it in place, which it may only do + /// through a pointer to this exact storage. + srv: Srv = undefined, + in: [msize]u8 = undefined, + out: [2 * msize]u8 = undefined, + + /// This connection's answer to `fs_service.Transport`. Thunked exactly + /// like `fuse.Fs.transport()`, and for its reason: a `*Conn` is not an + /// `*anyopaque` and a vtable cannot hold the typed function. + fn transport(c: *Conn) fs_service.Transport { + return .{ .ctx = c, .vtable = &transport_vtable }; + } + + const transport_vtable: fs_service.Transport.VTable = .{ + .retry = transportRetry, + .next = transportNext, + .reply = transportReply, + }; + + fn transportRetry(ctx: *anyopaque) ?pardes.acmefs.Req { + const c: *Conn = @ptrCast(@alignCast(ctx)); + return c.srv.retry(); + } + + fn transportNext(ctx: *anyopaque) ?pardes.acmefs.Req { + const c: *Conn = @ptrCast(@alignCast(ctx)); + return c.srv.next(); + } + + fn transportReply(ctx: *anyopaque, r: *const pardes.acmefs.Reply, bytes: []const u8) void { + const c: *Conn = @ptrCast(@alignCast(ctx)); + c.srv.reply(r, bytes); + } +}; + +/// The listening socket and its connections. Heap-allocated because a `Conn` +/// holds slices into itself (see there) and because at three buffers per +/// connection this is ≈100 KiB, which does not belong in a host's struct. +pub const Listener = struct { + fd: c_int = -1, + /// The bound path, kept so teardown unlinks exactly what was created — + /// guarded on the fd, like nested.zig's and detached/server.zig's + /// `unlisten`. + path_buf: [sun_path_len]u8 = undefined, + path_len: usize = 0, + conns: [max_conns]Conn = @splat(.{}), + + /// The socket path, so a host can tell the operator where to dial. + pub fn path(l: *const Listener) []const u8 { + return l.path_buf[0..l.path_len]; + } + + /// Accept whatever is waiting, bounded. + /// + /// BOUNDED for detached/server.zig's `accept` reason: `poll` is level + /// triggered, so an unaccepted backlog reports ready forever and a peer + /// dialling in a loop would otherwise hold the core in here. And always + /// accepting, even with a full table, for the same reason — the surplus is + /// accepted and closed rather than left to spin the poll. + pub fn accept(l: *Listener) void { + if (comptime !supported) return; + for (0..max_conns + 1) |_| { + const fd = libc.accept(l.fd, null, null); + if (fd < 0) return; // EAGAIN is this loop's ordinary exit + nested.setCloexec(fd); + setNonblock(fd); + if (comptime nested.darwin) { + // linux says MSG_NOSIGNAL per write, darwin once per socket. A + // script that dies mid-reply must not take the editor down. + const on: c_int = 1; + _ = libc.setsockopt(fd, libc.SOL.SOCKET, libc.SO.NOSIGPIPE, &on, @sizeOf(c_int)); + } + const c = for (&l.conns) |*cand| { + if (cand.fd < 0 and !cand.draining) break cand; + } else { + // No slot. Closing is the whole refusal: a 9P client that + // reads EOF instead of an `Rversion` reports a dial failure, + // which is the honest thing for it to say. + log.debug("--fs9: refusing a connection, all {d} slots busy", .{max_conns}); + _ = libc.close(fd); + continue; + }; + c.fd = fd; + c.draining = false; + c.srv = .init(.{ + .in = &c.in, + .out = &c.out, + .root = @intFromEnum(pardes.acmefs.TopFile.root), + }); + } + } + + /// Read one chunk off connection `i` and hand it to the server. + /// + /// ONE read per connection per round, which is detached/server.zig's + /// `receive` rule: a script in a `while true` loop gets one turn and then + /// the loop moves on to the other connections and to the frame. + /// + /// Sized to what the server can TAKE rather than to the socket, because + /// `push` returns short on back-pressure and bytes read past that point + /// would have nowhere to go. Zero room is not an error and not a hangup: + /// the buffer holds a message the core has not finished with, and the next + /// drain frees it. + pub fn fill(l: *Listener, i: u8) void { + if (comptime !supported) return; + const c = &l.conns[i]; + const room = c.srv.in.len - c.srv.in_len; + if (room == 0) return; + // A frame-local staging buffer rather than a read straight into the + // server's tail: advancing `in_len` is `push`'s business, and reaching + // past it to do it here would make this file a second author of + // `9p.zig`'s invariants for the sake of one memcpy per 8 KiB. + var buf: [msize]u8 = undefined; + const got = libc.read(c.fd, &buf, @min(room, buf.len)); + if (got == 0) return l.drop(i); // clean EOF: the script left + if (got < 0) return switch (libc.errno(got)) { + .INTR, .AGAIN => {}, + else => l.drop(i), + }; + const n = c.srv.push(buf[0..@intCast(got)]); + // Cannot happen — the read was clamped to the room — and it is + // asserted rather than ignored because silently dropping wire bytes + // desynchronises the stream, which is the one failure 9P cannot + // resynchronise from. + std.debug.assert(n == @as(usize, @intCast(got))); + } + + /// Push what the kernel will take of what this connection owes, and leave + /// the rest for a POLLOUT. detached/server.zig's `flush` on a 9P byte + /// FIFO instead of an `ArrayList`, and the rule it exists for is the same: + /// a peer that will not read must never block the editor. + pub fn flush(l: *Listener, i: u8) void { + if (comptime !supported) return; + const c = &l.conns[i]; + if (c.fd < 0) return; + while (true) { + const bytes = c.srv.output(); + if (bytes.len == 0) return; + const n = libc.send(c.fd, bytes.ptr, bytes.len, nosignal); + if (n < 0) switch (libc.errno(n)) { + .INTR => continue, + .AGAIN => return, + else => return l.drop(i), + }; + // No progress and no error. Looping on it is a spin, and a spin in + // here is the whole session at 100% of a core with no syscall for + // a signal to interrupt — host_io.zig's `writeFd` rule. + if (n == 0) return; + c.srv.wrote(@intCast(n)); + } + } + + /// Whether this connection wants POLLOUT: only while it owes bytes, which + /// is the same rule and the same reason as a client's and a pty's — asking + /// for it unconditionally makes every idle socket a ready descriptor and + /// turns the poll into a spin. + pub fn owes(l: *const Listener, i: u8) bool { + return l.conns[i].srv.output().len != 0; + } + + /// True when this slot has a live descriptor to poll. + pub fn live(l: *const Listener, i: u8) bool { + return l.conns[i].fd >= 0; + } + + /// This connection is over: out of the poll set, out of the process — but + /// NOT out of the table, because the server still owes the core a + /// `release` per open fid. See `Conn.draining`. + pub fn drop(l: *Listener, i: u8) void { + const c = &l.conns[i]; + if (c.fd >= 0) { + _ = libc.close(c.fd); + c.fd = -1; + } + if (c.draining) return; + c.srv.hangup(); + c.draining = true; + } + + /// This connection's `Transport`, or null when the slot has no work: the + /// peer never arrived, or it left and its fids are already released. + /// + /// THE HOST DRAINS, not this file, and that is not a style choice. A reply + /// reaches a transport through the host's `push_fs_reply`, so the host has + /// to know WHICH transport the request being served came from — the + /// "routing origin" docs/9p.typ's layering table (`O2 two listeners`) + /// names as the thing a second listener costs. Handing the transport out + /// here and taking the `Drained` back in `settle` is that origin made + /// explicit: the host sets it, calls `fs_service.drain`, clears it. A + /// `drainAll` that hid the loop in this file could not, and every 9P reply + /// went to the FUSE mount instead — measured, as a `Tattach` that never + /// came back. + pub fn transport(l: *Listener, i: u8) ?fs_service.Transport { + if (comptime !supported) return null; + const c = &l.conns[i]; + if (c.fd < 0 and !c.draining) return null; + return c.transport(); + } + + /// What one connection's drain came to: write the replies, and reclaim the + /// slot when a hung-up peer's last fid is released. Returns whether this + /// connection still owes work, which the caller or's into the flag that + /// keeps the loop from sleeping. + /// + /// The flush is HERE rather than left to a POLLOUT, so a reply the core + /// produced this frame is on the wire this frame; what the kernel would not + /// take waits for POLLOUT as usual. + pub fn settle(l: *Listener, i: u8, d: fs_service.Drained) bool { + if (comptime !supported) return false; + const c = &l.conns[i]; + if (c.draining) { + // A hung-up connection has no descriptor and therefore no event of + // its own: its orphaned fids are pumped out over successive frames, + // and the loop must stay hot until the pump runs dry. It does run + // dry — the debt is one release per fid. + if (d.count == 0) { + c.draining = false; + return false; + } + return true; + } + l.flush(i); + return d.pending; + } + + /// Every connection down, the socket closed, and the path unlinked. + /// + /// The orphaned fids are NOT pumped here: `deinit` runs when the session is + /// being torn down, and the core it would report the releases to is going + /// with it. `Fs.deinit` makes the same choice about the mount. + pub fn deinit(l: *Listener, gpa: std.mem.Allocator) void { + for (0..max_conns) |i| l.drop(@intCast(i)); + if (l.fd >= 0) { + _ = libc.close(l.fd); + l.fd = -1; + var z: [sun_path_len:0]u8 = undefined; + @memcpy(z[0..l.path_len], l.path_buf[0..l.path_len]); + z[l.path_len] = 0; + _ = libc.unlink(z[0..l.path_len :0]); + } + gpa.destroy(l); + } +}; + +/// `/pardes-9p-.sock`. A name is one path component and nothing +/// clever, for detached/server.zig's `socketPath` reason: a `/` would put the +/// socket somewhere else entirely and a NUL would truncate the address. The +/// buffer is `sun_path`-sized, so a name that does not fit is no address at +/// all rather than a truncated one pointing somewhere else. +pub fn socketPath(buf: *[sun_path_len]u8, dir: []const u8, name: []const u8) ?[:0]const u8 { + if (name.len == 0) return null; + if (std.mem.indexOfAny(u8, name, "/\x00") != null) return null; + return std.fmt.bufPrintSentinel(buf, "{s}/" ++ prefix ++ "{s}.sock", .{ dir, name }, 0) catch null; +} + +/// Bind, listen, and hand back a listener — or null, which is the same answer +/// for "this platform has no unix sockets", "there is no runtime directory" +/// and "the bind failed". That is `fs_service.start`'s posture and +/// nested.zig's: a transport that will not come up must cost the operator +/// their scripting, never their session. +/// +/// `named` is `Options.fs9`: EMPTY means a bare `--fs9`, so `fallback` names +/// it (the session name in a daemon, this pid anywhere else), and anything +/// else is the name the user gave, which wins — scripts need a path they can +/// predict. +pub fn open(gpa: std.mem.Allocator, named: []const u8, fallback: []const u8) ?*Listener { + if (comptime !supported) return null; + var dir_buf: [sun_path_len:0]u8 = undefined; + const dir = nested.socketDir(&dir_buf) orelse { + log.warn("--fs9: no runtime directory for the socket", .{}); + return null; + }; + if (!nested.ensureSocketDir(dir)) return null; + const l = gpa.create(Listener) catch return null; + l.* = .{}; + // No sweep of the directory, unlike detached/server.zig's `listen`. That + // sweeper connects to every socket of its OWN prefix to retire dead ones; + // this prefix has no handshake to probe with, so the only stale file worth + // removing is the one this bind collides with, immediately below. + const p = socketPath(&l.path_buf, dir, if (named.len != 0) named else fallback) orelse { + gpa.destroy(l); + return null; + }; + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + @memcpy(addr.path[0 .. p.len + 1], p[0 .. p.len + 1]); + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); + if (fd < 0) { + gpa.destroy(l); + return null; + } + nested.setCloexec(fd); + // `bind` IS the exclusive create, so it and nothing else decides who owns + // a name — detached/server.zig's rule, and its reason: unlinking + // unconditionally is how a second daemon takes a live one's socket away. + // The one case that is not a collision is a session killed rather than + // quit, whose file outlived it, and `alive` is the only thing allowed to + // say so. + if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { + if (alive(p)) { + log.warn("--fs9: something is already listening on {s}", .{p}); + _ = libc.close(fd); + gpa.destroy(l); + return null; + } + _ = libc.unlink(p); + if (libc.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { + _ = libc.close(fd); + gpa.destroy(l); + return null; + } + } + // Owner-only, and BEFORE listen(2), which is the first moment anyone could + // connect. The directory is already 0700; this is the second wall, and + // this socket can write into every pane of a live editor. + _ = libc.chmod(p, 0o600); + if (libc.listen(fd, max_conns) != 0) { + _ = libc.close(fd); + gpa.destroy(l); + return null; + } + setNonblock(fd); + l.fd = fd; + l.path_len = p.len; + log.info("--fs9: serving 9P2000 on {s}", .{p}); + return l; +} + +/// Whether a socket file at `path` has a listener behind it. Only ever asked +/// about a bind that failed, and it answers on the CONNECT: a refusal means +/// the file outlived its process and may be unlinked, and anything else — +/// including a success — means somebody is there. +fn alive(path: [:0]const u8) bool { + var addr: libc.sockaddr.un = .{ .path = @splat(0) }; + if (path.len + 1 > sun_path_len) return true; // cannot ask; assume occupied + @memcpy(addr.path[0 .. path.len + 1], path[0 .. path.len + 1]); + const fd = libc.socket(libc.AF.UNIX, libc.SOCK.STREAM, 0); + if (fd < 0) return true; + defer _ = libc.close(fd); + return libc.connect(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) == 0; +} + +/// Every descriptor here is non-blocking, for detached/server.zig's reason: +/// the core must never park on a peer. Its `setNonblock` is not reused because +/// importing the daemon into a module the tty and GUI shells may also serve +/// from would make a frontend transport a dependency of a filesystem. +fn setNonblock(fd: c_int) void { + const flags = libc.fcntl(fd, libc.F.GETFL, @as(c_int, 0)); + if (flags < 0) return; + var o: libc.O = @bitCast(@as(u32, @bitCast(flags))); + o.NONBLOCK = true; + _ = libc.fcntl(fd, libc.F.SETFL, @as(c_int, @bitCast(@as(u32, @bitCast(o))))); +} + +/// A dead script must never kill the editor. linux says it per write, darwin +/// once per socket (see `accept`). +const nosignal: u32 = if (nested.darwin) 0 else libc.MSG.NOSIGNAL; + +const testing = std.testing; + +test "the socket name is a third prefix in the shared directory" { + // Asserted rather than described: nested.zig's sweeper unlinks any name + // whose digits name a dead pid, and the detached transport's `vetted` + // accepts only its own prefix. A 9P socket must be invisible to both. + var buf: [sun_path_len]u8 = undefined; + const p = socketPath(&buf, "/run/user/1000", "t9srv").?; + try testing.expectEqualStrings("/run/user/1000/pardes-9p-t9srv.sock", p); + try testing.expect(!std.mem.startsWith(u8, std.fs.path.basename(p), "pardes-detached-")); +} + +test "a name that is not one path component is no address at all" { + var buf: [sun_path_len]u8 = undefined; + try testing.expect(socketPath(&buf, "/run", "") == null); + try testing.expect(socketPath(&buf, "/run", "a/b") == null); + try testing.expect(socketPath(&buf, "/run", "a\x00b") == null); +} + +test "one connection's buffers are sized from the one msize constant" { + // The `Srv` asserts both of these at `init`, where a violation is a panic + // in a live daemon; here it is a build failure instead. + try testing.expect(msize >= ninep.min_msize); + const c: Conn = .{}; + try testing.expectEqual(@as(usize, msize), c.in.len); + try testing.expectEqual(@as(usize, 2 * msize), c.out.len); +} + -- cgit v1.3