From 147ebd4a36ec7199074ba05bcfb79d4a656c0b74 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 27 Aug 2026 16:42:15 -0300 Subject: 9p: the client half, and a board that serves its own tree over the UART Step 5 of the 9P chain (docs/9p.typ 12.5, docs/registry.typ 9P-22, 9P-11, BOARD-1). THE CLIENT. `Client` in src/9p.zig is the mirror of `Server` and the same shape: sans-io, no allocator, no threads, no descriptor, caller-owned buffers, and it builds freestanding. 152 bytes of struct against the server's 9,488, because a client owns neither a fid table nor a park table -- the far end does. The API is submit / push+output+wrote / take. Completion is a PULL: a callback would fire inside push, inside the transport's read, inside the host's poll dispatch, which is exactly where fs9_service says filesystem work must not happen. `take()` returns the next completed operation or null, which is `Server.next()`'s loop-until-null contract read from the other side. Tags are a fixed 16-entry table indexed BY the tag, so an out-of-order reply -- which 9P allows and both reference clients rely on -- costs one bounds check. The reply's TYPE is checked against the request's op, because a tag is only as good as the table behind it. A `Done` borrows the input buffer and is valid until the next call; `take()` releases the previous frame on entry, so the rule is mechanical rather than remembered, and read data and error strings are zero-copy. And one real caller, so this is not a library with no user: the `9p` word takes a dial and a path, walks another instance's tree, and opens the bytes in a pane like any other `Look`. THE BOARD. A SECOND image, not a second role: the console runtime keeps UART0 bidirectionally and is behaviourally untouched. On the new one the UART carries 9P AND NOTHING ELSE -- no ANSI, no vaxis, no allocator, no heap module. The loop is uart.read -> push / retry+next -> handle -> reply / output -> writeSome -> wrote. `writeSome` is new and additive: `write`'s bounded spin DROPS bytes on a stalled transmitter, which on a protocol stream truncates a reply mid-message and desynchronises for good, where a short count cannot. BOARD-1's one divider write raises the line to 921600. 88,000 B text, 49,424 B bss, an 88,080-byte image -- 5.7% of the 1,536,000 B partition, against the console image's 809,536 B. THE COMPTIME BRIDGE, which is the part worth reading. `board9p.caps` is the ONLY place the GPIO tree is described; node ids, parents, names, permissions, handlers, buffer size and the per-pin directories are all derived from it, and `fan.dirs` makes `gpio//value` one table entry serving eleven pins. Modes are derived from which handlers a file has rather than declared. A second capability is a table entry, not new tree code. JP1 became a real table in the new leaf `src/board_pins.zig`, with the ASCII drawing RENDERED from it at comptime and the pin list COLLECTED from it -- the 9P image links no core and so cannot import board_memory.zig, and copying the table was not acceptable. A golden test pins the drawing byte for byte, the console's own shape test still passes, and the identical bytes are present in all three artifacts. PROVED. Two daemons: B read A's `/1/body` through the `9p` word into a pane, byte-identical to plan9port's `9p read` of the same path. Both board images build. No hardware was attached, so nothing about the board is claimed beyond what builds and what the host tests cover. zig build unit-test 585/585. fs-bench unchanged and still zero allocations on every read row. --- REVIEW FIXES FOLDED IN. Steps 3, 4 and 5 were verified on the happy path and then adversarially reviewed by three agents; eight defects, six fixed here, five of them reproduced with measurements before and after. Full writeup in docs/registry.typ `9P-27`. In brief: * a remote crash of the WHOLE daemon: one `size[4]` of zero plus one byte hit `unreachable` in `fs9_service.fill`. Also 99.7% of a core when the stuck buffer made `room == 0` return without reading. Now `srv.dead` is a hangup, checked before the room guard. * the editor froze 177 s on a dial: `connect(2)` ran on a still-BLOCKING socket before the deadline existed, and a full accept backlog waits forever. Now non-blocking with the wait spent against the budget. After: 2.03 s. * a 64 KiB pty read is exactly `queue_cap` and wiped every unread byte AND dropped itself. `notePtyOutput` splits at half the cap. Deterministic. * four silent sockets denied `--fs9` forever; connections now expire on the same five-second rule the frontend transport already had. * EMFILE spun a core; the listener pauses and leaves the poll set, as the frontend listener does. * `max_fids = 32` made `find` over `9pfuse` fail with 57 consecutive `Rerror`s -- refuting this step's own acceptance clause. 256 for a host, `board_fids` 32 for the microcontroller. Found clean and worth recording: `sig` reaches the foreground process group; the two-namespace pty lookup is right over both transports; `PaneFile`'s u4 wall is guarded; reader counts release on every abrupt-death path; `fs_origin` routing and the reply arithmetic hold under probing. --- src/9p.zig | 1237 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 1228 insertions(+), 9 deletions(-) (limited to 'src/9p.zig') diff --git a/src/9p.zig b/src/9p.zig index 8f78ed6b..068d0740 100644 --- a/src/9p.zig +++ b/src/9p.zig @@ -1834,12 +1834,31 @@ pub const orclose: u8 = 64; // --------------------------------------------------------------------------- /// Fids one connection may hold at once. A FIXED ARRAY and not a map, costed -/// in `docs/registry.typ` `9P-11`: a linear scan of thirty-two is about 0.4 µs -/// and the wire is slower than that by orders of magnitude, so the map would -/// buy nothing and cost an allocator this file does not have. +/// in `docs/registry.typ` `9P-11`: a linear scan is far cheaper than the wire, +/// so the map would buy nothing and cost an allocator this file does not have. +/// +/// 256 AND NOT 32, which is what it was, and the difference is a MOUNT. A +/// script that opens one file at a time never needs more than a handful; a +/// mounting client keeps one fid per cached inode, and this tree is three +/// top-level entries plus fourteen files per pane, so seven panes already pass +/// thirty-two and a full sixteen-pane session wants over two hundred. At the +/// old number `find` over a `9pfuse` mount failed with fifty-seven consecutive +/// `Rerror`s once the table filled — and `9pfuse` is the proof clause +/// `docs/9p.typ` §12.4 sets for this step, so the number was refuting its own +/// acceptance test. +/// +/// A `Fid` is about 64 bytes, so this is ≈16 KiB per connection against the +/// ≈34 KiB `fs9_service.zig` already budgets for one. The BOARD keeps thirty-two +/// by passing its own value: see `board_fids`, and `9P-11`'s RAM line, which is +/// costed for a microcontroller serving its own small tree and nothing else. /// /// Overflow is a refusal (`e_too_many_fids`), not a queue. -pub const max_fids: usize = 32; +pub const max_fids: usize = 256; + +/// What a microcontroller uses instead. Named here rather than spelled at the +/// call site so that the two numbers, and the reason they differ, stay next to +/// each other. +pub const board_fids: usize = 32; /// Requests that may be outstanding at once — in flight, or parked because the /// core answered `.again`. In practice this counts BLOCKED READERS: one slot @@ -4339,12 +4358,1212 @@ test "9p server: the fid table and the park table are what the board was costed // not, plus the open handle and the two-coordinate directory cursor. The // numbers are asserted here so that a change to `Fid` shows up as a diff // in the board's budget rather than as a surprise on the board. + // + // TWO budgets, because there are now two numbers. `max_fids` is the desktop + // one and it is sized for a MOUNT, which keeps a fid per cached inode; + // `board_fids` is what a microcontroller serving its own small tree uses, + // and it is the one `9P-11` costed. const S = Server(StubFs); - const fids = @sizeOf(S.Fid) * max_fids; + const entry = @sizeOf(S.Fid); const slots = @sizeOf(S.Slot) * max_slots; - try testing.expect(fids <= 3 * 1024); try testing.expect(slots <= 8 * 1024); - // Two buffers at a 4,096-byte msize — `in` and `out`'s two — plus the two - // tables, against 336 KB of free heap on the P4. - try testing.expect(3 * 4096 + fids + slots <= 24 * 1024); + + // The board: two buffers at a 4,096-byte msize — `in` and `out`'s two — + // plus the two tables, against 336 KB of free heap on the P4. + const board = entry * board_fids; + try testing.expect(board <= 3 * 1024); + try testing.expect(3 * 4096 + board + slots <= 24 * 1024); + + // The desktop, against the ≈34 KiB per connection `fs9_service` budgets. + // Eight times the fids is ≈16 KiB, and it is the price of `find` working + // over a `9pfuse` mount — see `max_fids`. + try testing.expect(entry * max_fids <= 24 * 1024); +} + +// --------------------------------------------------------------------------- +// the client +// --------------------------------------------------------------------------- + +/// Tags one client may have outstanding at once. +/// +/// SIXTEEN, and the reasoning is `max_fids`': a fixed array with no allocator, +/// scanned rather than mapped, and a refusal rather than a queue when it fills. +/// The protocol's tag space is 0..0xFFFE — `notag` is 0xFFFF and belongs to the +/// handshake — so this uses the bottom sixteen of sixty-five thousand and never +/// a number above them. THE TAG IS ITS OWN INDEX, which is what makes matching +/// a reply O(1) with no search and no bookkeeping: see `tags`. +/// +/// MANY OUTSTANDING REQUESTS ARE LEGAL and the prior art imposes no bound at +/// all: Linux's client takes a tag per request out of an IDR +/// (`net/9p/client.c:194-199`) and Plan 9's devmnt keeps an `Mntrpc` per +/// request on a free list (`devmnt.c:783-800`), because on both the outstanding +/// count is «one per process blocked in an I/O», which the kernel already +/// bounds elsewhere. Here the count is "one per thing pardes is fetching", and +/// a screen does not hold sixteen remote panes. Overflow is `error.NoTags` at +/// the moment of asking — the caller collects an answer and asks again — and +/// never a silent wait, because a client that blocks is the one thing this +/// design does not have anywhere to put. +pub const max_tags: usize = 16; + +/// `Twrite`'s own header: `size[4] type[1] tag[2] fid[4] offset[8] count[4]`. +/// What `maxWrite` subtracts from the msize. +/// +/// NOT `iohdrsz`. That number (24) is the slack a SERVER quotes in `iounit` and +/// is deliberately larger than any real header; a client sizing its own request +/// against it leaves a byte on the table on every write forever. +const twrite_header: usize = header_len + 4 + 8 + 4; + +/// `Rread`'s header: `size[4] type[1] tag[2] count[4]`. What `maxRead` +/// subtracts, and the reason a client's `count` is not simply the msize — the +/// reply has to carry a header too, and a `count` of msize is a reply eleven +/// bytes too long for the connection that asked for it. +const rread_header: usize = header_len + 4; + +comptime { + assert(twrite_header == 23); + assert(rread_header == 11); + // The tag IS the index, so the table's length is the tag space in use, and + // the handshake's `notag` must fall outside it or a `Rversion` would land + // on somebody's slot. + assert(max_tags <= notag); + // At the smallest msize this file will agree to, a read and a write must + // both still be able to carry a byte, or a connection could be negotiated + // that cannot do any I/O at all. + assert(msize_min > rread_header); + assert(msize_min > twrite_header); +} + +/// Every way `submit` can refuse, and each is a different thing for the caller +/// to do about it. +pub const ClientError = error{ + /// All `max_tags` are outstanding. Collect an answer and ask again. + NoTags, + /// The out queue has no room for this request. Write `output()` out and + /// ask again. THE ONLY BACK-PRESSURE a sans-io client has. + NoSpace, + /// The request cannot fit the negotiated msize: a `Twrite` past + /// `maxWrite()`, a `Tread` asking past `maxRead()`, a sixteen-element walk + /// of long names. REFUSED AND NOT CLAMPED, because `submit` answers with a + /// tag and nothing else — a silent clamp would leave the caller to guess + /// how much of its buffer went, and guess wrong about the offset to + /// continue from. `maxRead` and `maxWrite` are how a caller chunks first. + TooLarge, + /// A request before `Rversion` has landed, or a second `Tversion` while + /// requests are outstanding. + Handshake, + /// The stream is not 9P any more and this connection is finished. See + /// `dead`. + Dead, + /// A request no encoding of 9P admits: `nofid` as a fid, an empty walk + /// element or one with a separator in it, more than `max_welem` elements. + /// A bug in the caller, caught here rather than spent as a round trip. + BadRequest, +}; + +/// A 9P2000 client for one connection. +/// +/// THE MIRROR OF `Server`, and deliberately the same shape: caller-owned `in` +/// and `out` buffers, no allocator, no threads, no descriptor, `std` for +/// `readInt`/`writeInt` and nothing else. So it compiles for the board's +/// `riscv32-freestanding` and runs over `src/esp32p4/uart.zig`'s non-blocking +/// receive and bounded-spin transmit exactly as it runs over a unix socket in +/// `src/fs9_client.zig` — which is the whole reason for the shape, because a +/// client that owned its descriptor would be a client that could not. +/// +/// THE API IS A STATE MACHINE AND NOT `fn read() []u8`, because the core is +/// single-threaded and never blocks (docs/9p.typ §12.5). The three moving parts +/// are: +/// +/// 1. `submit(Request)` ENCODES a T-message into the out queue and hands +/// back its tag. It never waits and never touches a descriptor; a full +/// queue or a full tag table is a refusal the caller can act on. +/// 2. `push`/`output`/`wrote` move bytes, in whatever sizes the transport +/// manages, in whatever order they arrive. +/// 3. `take()` answers with the next COMPLETED operation, or null when there +/// is not a whole reply buffered yet. A caller's frame is +/// `while (client.take()) |done| ...`, which is `Server.next()`'s own +/// loop-until-null contract read from the other side. +/// +/// WHY COMPLETION IS A PULL AND NOT A CALLBACK: a callback would run inside +/// `push`, which is inside the transport's read, which is inside the host's +/// poll dispatch — and `src/fs9_service.zig` already states why filesystem +/// work must not happen there. Pulling puts the caller's own code back on the +/// caller's own stack. +/// +/// WHY THERE IS NO PER-TAG RESULT QUEUE: one frame completes exactly one +/// operation, and `take` returns it immediately, so there is never a completed +/// answer nobody has collected. That is what keeps a tag slot two bytes wide +/// instead of an msize wide, and it is why `Done` may borrow `in` (see there). +/// +/// REPLIES MAY ARRIVE IN ANY ORDER and this client does not care: the tag is +/// its own index into `tags`, so attribution is one bounds check and one +/// array read, with no assumption about arrival order anywhere in the file. +/// The reply's TYPE is checked against the request's `Op` as well, because a +/// tag is only as good as the table behind it. +/// +/// MEMORY: the two buffers, and `@sizeOf(Client)` for everything else — a +/// sixteen-entry tag table of eight-byte entries plus nine scalars, asserted at +/// the bottom of this file. Nothing here grows and nothing here is allocated. +pub const Client = struct { + /// Reply bytes the caller has pushed. `in[0..frame]` is the reply most + /// recently returned by `take`, and every slice a `Done` holds points into + /// it — which is why nothing compacts this buffer until the next `take`. + in: []u8, + /// Encoded requests, oldest first, as a byte FIFO. Every 9P message + /// carries its own length, so the queue needs no side table. + out: []u8, + + in_len: usize = 0, + frame: u32 = 0, + out_len: usize = 0, + out_off: usize = 0, + + /// Negotiated by the handshake; ZERO means "not on a protocol yet", and + /// nothing but `version` may be submitted in that state. It is also zero + /// after an `Rversion` of "unknown", which is a completed handshake with + /// no dialect in common. + msize: u32 = 0, + /// What our own `Tversion` offered, kept only so that `Rversion` can be + /// checked against it: «the server responds with its own maximum, which + /// must be less than or equal to the client's». + asked: u32 = 0, + /// A `Tversion` is outstanding. Its tag is `notag`, so it cannot live in + /// the table below — and it does not need to, because the protocol allows + /// nothing else to be outstanding beside it. + versioning: bool = false, + /// The stream is not 9P and there is no resynchronising from it. Write-once, + /// like `Server.dead`: a reply that cannot be attributed is worse than a + /// closed connection, because the caller would wait on it forever. + dead: bool = false, + + /// THE TAG TABLE, indexed BY THE TAG. `tags[t].op` is null when tag `t` is + /// free, which makes claiming a tag a scan of sixteen and matching a reply + /// a single index — and it means a caller may keep its own per-request + /// state in a plain sixteen-entry array of its own, keyed the same way, + /// with no map on either side. + tags: [max_tags]Slot = @splat(.{}), + + /// What is remembered about one outstanding request, which is as little as + /// the protocol lets us get away with: what it was, and — for a read — + /// what it asked for, because `read(5)` bounds the reply by it and a + /// server that ignores that bound is handing back bytes at offsets we + /// never asked about. + const Slot = struct { + op: ?Op = null, + count: u32 = 0, + }; + + /// What a client asked for. The tag names of `Request` and of `Result`'s + /// answers are these, so nothing maps one to the other by hand. + /// + /// EIGHT OPERATIONS AND NOT THIRTEEN, and the five absences are decisions: + /// + /// * `Tauth`: there is no authentication in this design and the server half + /// refuses it by name (`e_no_auth`). The socket's permissions are the + /// protection. + /// * `Tcreate`/`Tremove`: the server refuses both, because the shape of the + /// tree follows the pane list. Walking into `new/` is how a client creates + /// a pane, and that is a `walk`. + /// * `Twstat`: the one wstat the tree honours is a truncate, and a client + /// that wants to empty a file opens it `OTRUNC` in the same round trip. + /// * `Tflush`: nothing here has a cancel button. A flush costs a second tag + /// and brings a reply-ORDER rule with it — «the Rflush must come after the + /// original reply» — which is a rule nobody exercises if no caller can + /// change its mind, and an unexercised ordering rule in a protocol client + /// is a bug waiting for its first user. + pub const Op = enum { version, attach, walk, open, read, write, clunk, stat }; + + /// One request, as its caller states it. A `union(Op)` rather than eight + /// functions so that `submit` is one entry point with one refusal path: every + /// bound this client has — the tag table, the out queue, the msize — applies to + /// all eight identically, and a ninth operation cannot forget one of them. + /// + /// NO TAG FIELD: the tag is what `submit` HANDS BACK. A caller that chose its + /// own tags would be maintaining the table this file already maintains. + pub const Request = union(Op) { + /// The handshake. `msize` is the largest message this client will send or + /// accept, and ZERO means "as much as my buffers hold", which is the + /// answer a caller with no opinion wants. Clamped to the buffers either + /// way; see `beginVersion`. + version: struct { msize: u32 = 0 }, + /// `afid` is not a parameter: it is always `nofid`, because this client + /// never sends `Tauth`. + attach: struct { fid: u32, uname: []const u8, aname: []const u8 = "" }, + /// The path elements, already split. A SLICE OF SLICES rather than the + /// codec's fixed `[max_welem]` array, because a caller has a path and not + /// an array: the copy into the fixed array happens once, in `submit`, + /// where the `nwname` bound is checked anyway. An empty list is the legal + /// zero-element walk, which clones `fid` onto `newfid`. + walk: struct { fid: u32, newfid: u32, names: []const []const u8 }, + open: struct { fid: u32, mode: u8 }, + /// `count` is refused rather than clamped above `maxRead()`; see there. + read: struct { fid: u32, offset: u64, count: u32 }, + /// `data` is COPIED into the out queue by `submit` and is not borrowed + /// afterwards, which is what lets a caller write out of a buffer it is + /// about to reuse. + write: struct { fid: u32, offset: u64, data: []const u8 }, + clunk: struct { fid: u32 }, + stat: struct { fid: u32 }, + }; + + /// What one request came to. The answer's SHAPE, which is what a caller acts + /// on; `Done.op` says which request it belongs to and `Done.tag` says which + /// one of several. + pub const Result = union(enum) { + /// The server said no: `Rerror`'s string, and the only variant that can + /// answer ANY of the eight. Borrows the input buffer — see `Done`. + fail: []const u8, + /// `version` is "9P2000", or the literal "unknown", which is a SUCCESSFUL + /// reply meaning no dialect in common. `Client.msize` is nonzero only in + /// the first case, so the second leaves a connection on which nothing can + /// be submitted and the caller hangs up. + version: struct { msize: u32, version: []const u8 }, + attach: Qid, + /// `nwqid` may be SHORTER than the walk's element count: a partial walk is + /// a success with fewer qids, and only a failure on the FIRST element is + /// an `Rerror`. So a caller MUST compare `nwqid` against what it asked for + /// before believing its fid landed anywhere. + /// + /// The whole array is carried rather than only the last qid, because the + /// last one is the only thing THIS tree's clients want and the + /// intermediate ones are what a caching client caches against + /// (`Qid.version`). Two hundred and eight bytes, on a value the caller + /// consumes and drops. + walk: struct { nwqid: u16, wqid: [max_welem]Qid }, + open: struct { qid: Qid, iounit: u32 }, + /// The bytes, borrowing the input buffer — see `Done`. SHORTER than the + /// requested count is normal and is not the end of the file; ZERO bytes is + /// the end of the file. + read: []const u8, + /// The count actually written, which may be short — the caller advances + /// its offset by this and not by what it asked. + write: u32, + clunk: void, + /// Borrows the input buffer for its four strings — see `Done`. + stat: Stat, + }; + + /// One completed operation. + /// + /// BORROWS THE INPUT BUFFER, and this is the whole lifetime rule: a `Done` is + /// valid until the next call to anything on the `Client` that produced it. The + /// `fail` string, the `read` bytes and the `stat` strings all point into + /// `Client.in`, exactly as `decode`'s do and for the same reason — the + /// alternative is a per-tag copy of every payload, which on the board is + /// sixteen msizes of static RAM to save a caller one `@memcpy` it may not even + /// want. `take` releases the previous answer's frame on entry, so the rule is + /// enforced by construction rather than by hope: a caller that keeps a `Done` + /// across a second `take` is reading bytes the next reply has been decoded + /// into. + pub const Done = struct { + /// The tag `submit` handed out, or `notag` for the handshake. FREE again + /// the moment this is returned, so a caller that indexes its own + /// sixteen-entry table by tag must read this entry out before submitting + /// anything else. + tag: u16, + /// Which of the eight this answers. Needed beside `result` because + /// `Rerror` answers all of them and carries no hint of which. + op: Op, + result: Result, + }; + + pub const Options = struct { + /// Room for one whole reply. Caps the msize with `out`. + in: []u8, + /// Room for one whole request, at least. MORE room is what buys + /// pipelining: sixteen outstanding `Tread`s are sixteen small messages + /// that all have to fit here at once, and `submit` answers + /// `error.NoSpace` rather than blocking when they do not. + out: []u8, + }; + + /// The buffers are the caller's, which is what "no allocator" means from + /// this side: the board hands over two static arrays, a host hands over + /// two heap slices, and this file cannot tell the difference. The msize + /// follows from them and from the server's `Rversion`. + pub fn init(opts: Options) Client { + assert(opts.in.len >= msize_min); + assert(opts.out.len >= msize_min); + return .{ .in = opts.in, .out = opts.out }; + } + + /// The connection went away, or the caller is done with it. Unlike + /// `Server.hangup` there is no debt to pay: a client owes the far end + /// nothing on the way out — its fids are the server's to clean up when the + /// stream closes, which is exactly what `Server.hangup` is for. + pub fn hangup(c: *Client) void { + c.dead = true; + c.tags = @splat(.{}); + c.versioning = false; + c.msize = 0; + c.asked = 0; + c.in_len = 0; + c.frame = 0; + c.out_len = 0; + c.out_off = 0; + } + + // -- bytes in, bytes out --------------------------------------------- + // + // The four `Server` has, written out again rather than shared. They look + // identical and they are not the same three lines: `Server.push` refuses + // once dead, and `Server.hasRoom` reserves a whole msize before a request + // is handed to the core so that no reply can fail to be written. A client + // reserves nothing — it refuses at `submit`, where the caller is standing + // right there — so a shared FIFO would be one struct with two callers and + // two exceptions, which is more to read than this is. + + /// Take as much of `bytes` as there is room for, and answer how much. A + /// short answer is not a loss: it is back-pressure, and the caller + /// re-offers the tail after `take`ing what it can. Bytes are APPENDED, so + /// the reply currently being borrowed by a `Done` does not move. + pub fn push(c: *Client, bytes: []const u8) usize { + if (c.dead) return 0; + const n = @min(bytes.len, c.in.len - c.in_len); + @memcpy(c.in[c.in_len..][0..n], bytes[0..n]); + c.in_len += n; + return n; + } + + /// The requests waiting to go, oldest first, as one contiguous run of + /// whole 9P messages. Valid until the next call to anything else here. + pub fn output(c: *const Client) []const u8 { + return c.out[c.out_off..c.out_len]; + } + + /// How many of `output()`'s bytes actually left. A partial write is normal + /// on a UART and on a full socket, and the remainder stays put. + pub fn wrote(c: *Client, n: usize) void { + assert(n <= c.out_len - c.out_off); + c.out_off += n; + if (c.out_off == c.out_len) { + c.out_off = 0; + c.out_len = 0; + } + } + + /// Slide the unwritten tail down. Called only when room is wanted, so the + /// common case — a fully written queue, reset to empty by `wrote` — never + /// moves a byte. + fn compact(c: *Client) void { + assert(c.out_off <= c.out_len); + const n = c.out_len - c.out_off; + std.mem.copyForwards(u8, c.out[0..n], c.out[c.out_off..c.out_len]); + c.out_off = 0; + c.out_len = n; + } + + /// Release the reply `take` last returned and slide the rest of the input + /// down. One message-long move per message; a ring buffer would let a + /// decoded reply straddle the wrap and stop being one slice. + fn dropFrame(c: *Client) void { + assert(c.frame != 0); + assert(c.frame <= c.in_len); + const n = c.frame; + std.mem.copyForwards(u8, c.in[0 .. c.in_len - n], c.in[n..c.in_len]); + c.in_len -= n; + c.frame = 0; + } + + // -- what a caller may ask for --------------------------------------- + + /// The largest `Tread.count` this connection can answer, which is the + /// msize less `Rread`'s own header. Zero before the handshake. + pub fn maxRead(c: *const Client) u32 { + if (c.msize == 0) return 0; + return c.msize - @as(u32, @intCast(rread_header)); + } + + /// The most bytes one `Twrite` can carry, which is the msize less + /// `Twrite`'s own header. Zero before the handshake. A caller with more + /// than this chunks; see `ClientError.TooLarge` for why it is not clamped. + pub fn maxWrite(c: *const Client) u32 { + if (c.msize == 0) return 0; + return c.msize - @as(u32, @intCast(twrite_header)); + } + + /// Requests outstanding, the handshake included. What a caller's loop + /// tests to know whether there is anything left to wait for. + pub fn pending(c: *const Client) usize { + var n: usize = @intFromBool(c.versioning); + for (c.tags) |t| n += @intFromBool(t.op != null); + return n; + } + + // -- asking ------------------------------------------------------------ + + /// Encode one request into the out queue and hand back its tag. Never + /// blocks, never waits, never touches a descriptor. + /// + /// The refusals are in one order on purpose: what is wrong with the + /// REQUEST first, then what is wrong with this client's tables, so a + /// caller's bad argument never costs a tag and never half-fills the queue. + pub fn submit(c: *Client, req: Request) ClientError!u16 { + if (c.dead) return error.Dead; + if (req == .version) return c.beginVersion(req.version.msize); + // «The client must communicate the version before any other messages» + // — and until `Rversion` has landed there is no msize to bound + // anything by, which is the same gate `Server.startFrame` applies from + // the other side. + if (c.msize == 0 or c.versioning) return error.Handshake; + + const msg: Msg = switch (req) { + .version => unreachable, // handled above + .attach => |m| blk: { + if (m.fid == nofid) return error.BadRequest; + break :blk .{ .tattach = .{ + .fid = m.fid, + .afid = nofid, + .uname = m.uname, + .aname = m.aname, + } }; + }, + .walk => |m| blk: { + if (m.fid == nofid or m.newfid == nofid) return error.BadRequest; + // `MAXWELEM` is a hard protocol bound and not a buffer size: + // every implementation refuses a seventeen-element walk, so a + // caller with a deeper path splits it into two walks. + if (m.names.len > max_welem) return error.BadRequest; + var w: [max_welem][]const u8 = @splat(""); + for (m.names, 0..) |n, i| { + // An empty element, or one with a separator in it, is a + // caller that has not split its path. `Twalk` has no + // encoding for either and a server answers the first one + // `illegal name` — a round trip spent on a bug that was + // visible from here. + if (n.len == 0) return error.BadRequest; + if (std.mem.indexOfAny(u8, n, "/\x00") != null) return error.BadRequest; + w[i] = n; + } + break :blk .{ .twalk = .{ + .fid = m.fid, + .newfid = m.newfid, + .nwname = @intCast(m.names.len), + .wname = w, + } }; + }, + .open => |m| blk: { + if (m.fid == nofid) return error.BadRequest; + break :blk .{ .topen = .{ .fid = m.fid, .mode = m.mode } }; + }, + .read => |m| blk: { + if (m.fid == nofid) return error.BadRequest; + // The one bound the request's own length does not express: + // what comes BACK has to fit the connection too. + if (m.count > c.maxRead()) return error.TooLarge; + break :blk .{ .tread = .{ .fid = m.fid, .offset = m.offset, .count = m.count } }; + }, + .write => |m| blk: { + if (m.fid == nofid) return error.BadRequest; + break :blk .{ .twrite = .{ .fid = m.fid, .offset = m.offset, .data = m.data } }; + }, + .clunk => |m| blk: { + if (m.fid == nofid) return error.BadRequest; + break :blk .{ .tclunk = .{ .fid = m.fid } }; + }, + .stat => |m| blk: { + if (m.fid == nofid) return error.BadRequest; + break :blk .{ .tstat = .{ .fid = m.fid } }; + }, + }; + // ONE ceiling for every request, which is what makes a `Twrite` and a + // sixteen-element `Twalk` obey the same rule: the msize is «the + // maximum length, in bytes, ... including the size field», and a + // client that sends more is a client the server closes on. + const need = totalLen(msg) catch return error.TooLarge; + if (need > c.msize) return error.TooLarge; + + const op = std.meta.activeTag(req); + const tag = c.claim(op) orelse return error.NoTags; + errdefer c.tags[tag] = .{}; + try c.emit(tag, msg); + if (op == .read) c.tags[tag].count = req.read.count; + return tag; + } + + /// `Tversion`, which is the one exchange with no tag and no msize behind + /// it. + /// + /// A SECOND ONE IS A CONNECTION RESET — «all fids are clunked and any + /// outstanding I/O is abandoned» (`version(5)`) — and abandoning somebody + /// else's request is not this function's decision to make. So it is + /// refused while anything is outstanding, and a caller that means to reset + /// collects its answers or hangs up first. + fn beginVersion(c: *Client, want: u32) ClientError!u16 { + if (c.pending() != 0) return error.Handshake; + // Two ceilings and the smaller wins: what one reply buffer holds, and + // what one request buffer holds. A caller with no opinion passes zero + // and gets both. + const cap: u32 = @intCast(@min(c.in.len, c.out.len, std.math.maxInt(u32))); + const m = @min(if (want == 0) cap else want, cap); + // Below the floor there is a connection that cannot carry an `Rwalk`, + // which is to say no connection at all. `init` asserts the buffers + // clear it, so this can only be a `want` the caller chose. + if (m < msize_min) return error.BadRequest; + try c.emit(notag, .{ .tversion = .{ .msize = m, .version = "9P2000" } }); + c.msize = 0; + c.asked = m; + c.versioning = true; + return notag; + } + + /// The lowest free tag, marked used. Lowest rather than round-robin so + /// that a client with one request outstanding always uses tag 0, which + /// makes a wire trace readable by eye. + fn claim(c: *Client, op: Op) ?u16 { + for (&c.tags, 0..) |*t, i| { + if (t.op != null) continue; + t.* = .{ .op = op }; + return @intCast(i); + } + return null; + } + + /// Queue one request. The only way this fails is room: `totalLen` has + /// already refused every other way `encode` can, which is why the error + /// set collapses to one value here. + fn emit(c: *Client, tag: u16, msg: Msg) ClientError!void { + if (c.out_off != 0) c.compact(); + const bytes = encode(msg, tag, c.out[c.out_len..]) catch return error.NoSpace; + c.out_len += bytes.len; + } + + // -- collecting -------------------------------------------------------- + + /// The next completed operation, or null when there is not a whole reply + /// buffered yet. Call in a loop until null, once per frame. + /// + /// A `Done` BORROWS the input buffer and is valid until the next call + /// here: the previous reply's frame is released on entry, which is what + /// makes that rule mechanical instead of a note somebody has to remember. + pub fn take(c: *Client) ?Done { + if (c.frame != 0) c.dropFrame(); + if (c.dead) return null; + const len = frameLen(c.in[0..c.in_len]) orelse return null; + // A `size` no encoder produced, or one this connection could never + // buffer: either way the stream is not 9P and waiting for the rest of + // it is waiting forever. + if (len < header_len or len > c.in.len) return c.die(); + // And a server that sends past the msize it agreed to has stopped + // speaking the protocol it agreed to. + if (c.msize != 0 and len > c.msize) return c.die(); + if (len > c.in_len) return null; + c.frame = len; + // A body this codec refuses is not a message we can attribute to a + // tag, so there is nobody to report it to. The connection ends. + const got = decode(c.in[0..len]) catch return c.die(); + return c.consume(got); + } + + /// The stream is finished. Returns null so that every refusal in `take` + /// and `consume` is one expression. + fn die(c: *Client) ?Done { + c.dead = true; + return null; + } + + /// One decoded reply onto the request it answers. + fn consume(c: *Client, got: Decoded) ?Done { + // A CLIENT READS R-MESSAGES. A T-message here is the other end of the + // connection talking, or the double-role link docs/9p.typ §7 tells us + // not to build — the exact mirror of `Server.startFrame`'s refusal, + // and the encoding's own parity does the work in both directions. + if (isT(got.msg.msgType())) return c.die(); + if (got.msg == .rversion) return c.version(got); + // Nothing may arrive before a `Tversion` has been answered, and + // nothing but the `Rversion` while one is outstanding. + if (c.versioning or c.msize == 0) return c.die(); + // The tag is its own index, so this bounds check IS the lookup. + if (got.tag >= max_tags) return c.die(); + const slot = &c.tags[got.tag]; + const op = slot.op orelse return c.die(); + const result: Result = switch (got.msg) { + // `Rerror` answers ANY of the eight, which is exactly why `Done` + // reports the op beside it: the string does not say what failed. + .rerror => |m| .{ .fail = m.ename }, + .rattach => |m| if (op != .attach) return c.die() else .{ .attach = m.qid }, + .rwalk => |m| if (op != .walk) return c.die() else .{ + .walk = .{ .nwqid = m.nwqid, .wqid = m.wqid }, + }, + .ropen => |m| if (op != .open) return c.die() else .{ + .open = .{ .qid = m.qid, .iounit = m.iounit }, + }, + .rread => |m| blk: { + if (op != .read) return c.die(); + // «count ... indicates the number of bytes returned», and + // read(5) makes it no more than what was asked. Linux calls a + // longer one a hard `-EIO` (`net/9p/client.c:1475-1479`); here + // it ends the connection, because the byte after the ones we + // asked for is a byte we have no offset to put anywhere. + if (m.data.len > slot.count) return c.die(); + break :blk .{ .read = m.data }; + }, + .rwrite => |m| if (op != .write) return c.die() else .{ .write = m.count }, + .rclunk => if (op != .clunk) return c.die() else .clunk, + .rstat => |m| if (op != .stat) return c.die() else .{ .stat = m.stat }, + // The rest are replies to requests this client does not send — + // `Rauth`, `Rcreate`, `Rremove`, `Rwstat`, `Rflush` — and one is + // not an answer at all (`Rerror`'s illegal twin `Terror`, already + // refused by parity above). A reply to a request nobody made means + // the tag space is not what we think it is. + else => return c.die(), + }; + slot.* = .{}; + return .{ .tag = got.tag, .op = op, .result = result }; + } + + /// `Rversion`: the msize handshake, from the client's side. + fn version(c: *Client, got: Decoded) ?Done { + if (!c.versioning) return c.die(); + // «Rversion ... carries the same tag», and that tag is `notag`, + // because tags do not mean anything yet. + if (got.tag != notag) return c.die(); + const m = got.msg.rversion; + // «The server responds with its own maximum, which must be less than + // or equal to the client's» — `version(5)`. A larger one is a message + // we cannot buffer, and Linux refuses it for that reason + // (`net/9p/client.c:840-843`). + if (m.msize > c.asked or m.msize < msize_min) return c.die(); + c.versioning = false; + if (std.mem.eql(u8, m.version, "9P2000")) { + c.msize = m.msize; + } else if (!std.mem.eql(u8, m.version, "unknown")) { + // The reply must be a version the client offered, or "unknown". + // Anything else — "9P2000.u", "9P2000.L", a typo — is a server + // answering a question we did not ask, and agreeing to a dialect + // this file does not implement is how a client sends a `Tattach` + // whose layout the other end reads differently. + return c.die(); + } + // "unknown" leaves `msize` at zero: a completed handshake with no + // dialect in common, on which nothing can be submitted. The CALLER + // decides whether that is worth hanging up over, which is the honest + // place for it — a fallback ladder of dialects is a policy and this is + // a codec. + return .{ .tag = notag, .op = .version, .result = .{ + .version = .{ .msize = m.msize, .version = m.version }, + } }; + } +}; + +// --------------------------------------------------------------------------- +// client tests +// --------------------------------------------------------------------------- +// +// Driven against the SERVER IN THIS FILE, in process, over two pairs of +// buffers. That is the strongest test available here and it needs no socket: +// every byte the client encodes is a byte the server decodes and vice versa, +// so a disagreement about a layout, a length or a tag fails a test rather than +// waiting for a live daemon. The stub filesystem is the server tests' own, so +// the tree the client walks is the tree those tests already pin down. + +/// One connection with a client at each end of it. Four buffers, because each +/// side owns its own two and neither may see the other's. +/// +/// `srv.out` is twice the msize because `Server` requires it; `cli.out` is not, +/// because a client reserves nothing — see `Client.Options`. +const Pair = struct { + srv_in: [4096]u8 = undefined, + srv_out: [8192]u8 = undefined, + cli_in: [4096]u8 = undefined, + cli_out: [4096]u8 = undefined, + fsys: StubFs = .{}, + srv: Srv = undefined, + cli: Client = undefined, + + /// The buffers are fields, so neither end can be built until the pair has + /// an address. + fn start(p: *Pair) void { + p.srv = Srv.init(.{ .in = &p.srv_in, .out = &p.srv_out, .root = 1 }); + p.cli = Client.init(.{ .in = &p.cli_in, .out = &p.cli_out }); + } + + fn answer(p: *Pair, req: StubFs.Req) void { + const a = p.fsys.handle(req); + p.srv.reply(&a.reply, a.bytes); + } + + /// THE WIRE: every byte both ways, and each side given every chance to + /// work, until nothing moves. A real transport does this a chunk at a time + /// in a poll loop; the tests that care about that drip bytes by hand. + fn wire(p: *Pair) void { + var moved = true; + while (moved) { + moved = false; + while (p.cli.output().len != 0) { + const n = p.srv.push(p.cli.output()); + if (n == 0) break; + p.cli.wrote(n); + moved = true; + } + while (p.srv.retry()) |req| { + p.answer(req); + moved = true; + } + while (p.srv.next()) |req| { + p.answer(req); + moved = true; + } + while (p.srv.output().len != 0) { + const n = p.cli.push(p.srv.output()); + if (n == 0) break; + p.srv.wrote(n); + moved = true; + } + } + } + + /// Submit one request, run the wire, and collect the one answer it + /// produced. The tag and the op are checked here so that no test below has + /// to repeat it. + fn one(p: *Pair, req: Client.Request) !Client.Done { + const tag = try p.cli.submit(req); + p.wire(); + const done = p.cli.take() orelse return error.NoReply; + try testing.expectEqual(tag, done.tag); + try testing.expectEqual(std.meta.activeTag(req), done.op); + // One request, one reply, and nothing left outstanding: the invariant + // that makes `pending()` usable as a loop condition. + try testing.expectEqual(@as(usize, 0), p.cli.pending()); + return done; + } + + /// `Tversion` and `Tattach`, leaving the root on fid 0 — the client-side + /// twin of `Harness.handshake`. + fn handshake(p: *Pair) !void { + p.start(); + const v = try p.one(.{ .version = .{} }); + try testing.expectEqualStrings("9P2000", v.result.version.version); + try testing.expectEqual(@as(u16, notag), v.tag); + const a = try p.one(.{ .attach = .{ .fid = 0, .uname = "goblin" } }); + try testing.expectEqual(@as(u64, 1), a.result.attach.path); + try testing.expectEqual(qtdir, a.result.attach.type); + } +}; + +test "9p client: a whole session against the server in this file" { + var p: Pair = .{}; + try p.handshake(); + // Both ends agreed the same number, and it came off the buffers rather + // than out of the air. + try testing.expectEqual(@as(u32, 4096), p.cli.msize); + try testing.expectEqual(@as(u32, 4096 - 11), p.cli.maxRead()); + try testing.expectEqual(@as(u32, 4096 - 23), p.cli.maxWrite()); + + // A two-element walk onto pane 1's `body`. `nwqid` equals what was asked, + // which is the only thing that says the fid landed where we wanted. + const w = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ "1", "body" } } }); + try testing.expectEqual(@as(u16, 2), w.result.walk.nwqid); + try testing.expectEqual(@as(u64, 16), w.result.walk.wqid[0].path); + try testing.expectEqual(@as(u64, 18), w.result.walk.wqid[1].path); + try testing.expectEqual(qtfile, w.result.walk.wqid[1].type); + + const o = try p.one(.{ .open = .{ .fid = 1, .mode = ordwr } }); + try testing.expectEqual(@as(u64, 18), o.result.open.qid.path); + try testing.expectEqual(@as(u32, 4096 - iohdrsz), o.result.open.iounit); + + const r = try p.one(.{ .read = .{ .fid = 1, .offset = 0, .count = 64 } }); + try testing.expectEqualStrings("hello, body\n", r.result.read); + // Past the end is zero bytes and not an error: 9P has no EOF flag, and a + // short read is how a client learns it is done. + const eof = try p.one(.{ .read = .{ .fid = 1, .offset = 12, .count = 64 } }); + try testing.expectEqual(@as(usize, 0), eof.result.read.len); + + const wr = try p.one(.{ .write = .{ .fid = 1, .offset = 0, .data = "abc" } }); + try testing.expectEqual(@as(u32, 3), wr.result.write); + try testing.expectEqualStrings("abc", p.fsys.writes[0..p.fsys.writes_len]); + + const st = try p.one(.{ .stat = .{ .fid = 1 } }); + try testing.expectEqualStrings("body", st.result.stat.name); + try testing.expectEqual(@as(u64, 12), st.result.stat.length); + try testing.expectEqualStrings("goblin", st.result.stat.uid); + + _ = try p.one(.{ .clunk = .{ .fid = 1 } }); + // The clunk paid the core its release, which is the half of a clunk a + // client cannot see and the server tests pin down from the other side. + try testing.expectEqual(@as(u32, 1), p.fsys.releases); + // Nothing outstanding, nothing buffered, nothing owed. + try testing.expectEqual(@as(usize, 0), p.cli.pending()); + try testing.expectEqual(@as(usize, 0), p.cli.output().len); + try testing.expect(p.cli.take() == null); + try testing.expect(!p.cli.dead); +} + +/// Move the server's queued replies to the client LAST FIRST. 9P permits it — +/// nothing in the protocol orders replies against each other — and both +/// reference clients allocate a tag per outstanding request with no in-order +/// assumption anywhere (`linux/net/9p/client.c:194-199`, +/// `plan9/devmnt.c:783-800`). A client that quietly relies on order works +/// until the day the server answers a cached stat before a blocked read. +fn deliverReversed(p: *Pair) !void { + var scratch: [4096]u8 = undefined; + const out = p.srv.output(); + try testing.expect(out.len <= scratch.len); + @memcpy(scratch[0..out.len], out); + const total = out.len; + p.srv.wrote(total); + + var at: [max_tags]usize = undefined; + var lens: [max_tags]u32 = undefined; + var count: usize = 0; + var i: usize = 0; + while (i < total) { + const len = frameLen(scratch[i..total]) orelse return error.ShortReply; + at[count] = i; + lens[count] = len; + count += 1; + i += len; + } + try testing.expect(count >= 2); + var k = count; + while (k > 0) { + k -= 1; + const f = scratch[at[k]..][0..lens[k]]; + try testing.expectEqual(f.len, p.cli.push(f)); + } +} + +test "9p client: replies out of order are matched by tag and not by arrival" { + var p: Pair = .{}; + try p.handshake(); + const w = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"index"} } }); + try testing.expectEqual(@as(u16, 1), w.result.walk.nwqid); + + // Two stats outstanding at once, on two different files. + const root_tag = try p.cli.submit(.{ .stat = .{ .fid = 0 } }); + const index_tag = try p.cli.submit(.{ .stat = .{ .fid = 1 } }); + try testing.expectEqual(@as(u16, 0), root_tag); + try testing.expectEqual(@as(u16, 1), index_tag); + try testing.expectEqual(@as(usize, 2), p.cli.pending()); + + // Both requests to the server, both replies produced, then handed back in + // the wrong order. + while (p.cli.output().len != 0) { + const n = p.srv.push(p.cli.output()); + p.cli.wrote(n); + } + while (p.srv.next()) |req| p.answer(req); + try deliverReversed(&p); + + // The SECOND request answers first, and it is recognised by its tag. + const first = p.cli.take() orelse return error.NoReply; + try testing.expectEqual(index_tag, first.tag); + try testing.expectEqualStrings("index", first.result.stat.name); + const second = p.cli.take() orelse return error.NoReply; + try testing.expectEqual(root_tag, second.tag); + try testing.expectEqualStrings("/", second.result.stat.name); + try testing.expectEqual(@as(usize, 0), p.cli.pending()); + try testing.expect(!p.cli.dead); +} + +test "9p client: an Rerror answers one operation and the session carries on" { + var p: Pair = .{}; + try p.handshake(); + + // A walk failing on its FIRST element is an `Rerror` rather than a short + // `Rwalk` — the one asymmetry in walk(5), and the reason `Done` reports + // the op beside the string. + const bad = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"nope"} } }); + try testing.expectEqual(Client.Op.walk, bad.op); + try testing.expectEqualStrings(errString(2), bad.result.fail); + + // The failed tag is free again and the connection is untouched: an error + // is an answer, not a fault. + try testing.expectEqual(@as(usize, 0), p.cli.pending()); + try testing.expect(!p.cli.dead); + const st = try p.one(.{ .stat = .{ .fid = 0 } }); + try testing.expectEqualStrings("/", st.result.stat.name); + + // A refusal that comes from the server's own table rather than the core's, + // spelled the way Linux's error table holds it. + const stale = try p.one(.{ .stat = .{ .fid = 9 } }); + try testing.expectEqualStrings(e_unknown_fid, stale.result.fail); + try testing.expect(!p.cli.dead); +} + +test "9p client: a reply arriving a byte at a time is taken when its last byte lands" { + var p: Pair = .{}; + try p.handshake(); + const tag = try p.cli.submit(.{ .stat = .{ .fid = 0 } }); + + // The request out, the reply produced, and then held on this side of the + // wire so it can be dripped in. + while (p.cli.output().len != 0) { + const n = p.srv.push(p.cli.output()); + p.cli.wrote(n); + } + while (p.srv.next()) |req| p.answer(req); + var scratch: [512]u8 = undefined; + const out = p.srv.output(); + try testing.expect(out.len > 4 and out.len <= scratch.len); + @memcpy(scratch[0..out.len], out); + const reply = scratch[0..out.len]; + p.srv.wrote(reply.len); + + // Every byte but the last leaves nothing to collect — including the first + // four, where `frameLen` becomes readable and still says "wait". + for (reply[0 .. reply.len - 1]) |b| { + try testing.expectEqual(@as(usize, 1), p.cli.push(&.{b})); + try testing.expect(p.cli.take() == null); + try testing.expect(!p.cli.dead); + } + try testing.expectEqual(@as(usize, 1), p.cli.push(reply[reply.len - 1 ..])); + const done = p.cli.take() orelse return error.NoReply; + try testing.expectEqual(tag, done.tag); + try testing.expectEqualStrings("/", done.result.stat.name); +} + +test "9p client: sixteen tags outstanding, and the seventeenth is refused" { + var p: Pair = .{}; + try p.handshake(); + + // Nothing is wired, so nothing is answered and every tag stays out. + var tags: [max_tags]u16 = undefined; + for (&tags, 0..) |*t, i| { + t.* = try p.cli.submit(.{ .stat = .{ .fid = 0 } }); + // Lowest free tag first, which is what makes a trace readable. + try testing.expectEqual(@as(u16, @intCast(i)), t.*); + } + try testing.expectEqual(max_tags, p.cli.pending()); + try testing.expectError(error.NoTags, p.cli.submit(.{ .stat = .{ .fid = 0 } })); + // A refused submit costs nothing: no tag, and not a byte in the queue. + const owed = p.cli.output().len; + try testing.expectError(error.NoTags, p.cli.submit(.{ .clunk = .{ .fid = 0 } })); + try testing.expectEqual(owed, p.cli.output().len); + + // Drained, every tag comes back, and the seventeenth request now fits. + p.wire(); + var seen: [max_tags]bool = @splat(false); + for (0..max_tags) |_| { + const done = p.cli.take() orelse return error.NoReply; + try testing.expectEqual(Client.Op.stat, done.op); + try testing.expect(!seen[done.tag]); + seen[done.tag] = true; + } + for (seen) |s| try testing.expect(s); + try testing.expectEqual(@as(usize, 0), p.cli.pending()); + _ = try p.one(.{ .stat = .{ .fid = 0 } }); +} + +test "9p client: what a caller may not ask for is refused before a tag is spent" { + var p: Pair = .{}; + p.start(); + + // Nothing before the handshake, and `Tversion` is the only exception. + try testing.expectError(error.Handshake, p.cli.submit(.{ .stat = .{ .fid = 0 } })); + try p.handshake(); + + // `NOFID` is not a fid a client may name. + try testing.expectError(error.BadRequest, p.cli.submit(.{ .stat = .{ .fid = nofid } })); + try testing.expectError(error.BadRequest, p.cli.submit(.{ .clunk = .{ .fid = nofid } })); + try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = nofid, .names = &.{} } })); + + // A path that has not been split, and one longer than the protocol admits. + try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"1/body"} } })); + try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{""} } })); + const seventeen: [max_welem + 1][]const u8 = @splat("x"); + try testing.expectError(error.BadRequest, p.cli.submit(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &seventeen } })); + + // Both I/O bounds, each one byte past what the msize can carry. + try testing.expectError(error.TooLarge, p.cli.submit(.{ .read = .{ + .fid = 0, + .offset = 0, + .count = p.cli.maxRead() + 1, + } })); + var big: [4096]u8 = @splat('x'); + try testing.expectError(error.TooLarge, p.cli.submit(.{ .write = .{ + .fid = 0, + .offset = 0, + .data = big[0 .. p.cli.maxWrite() + 1], + } })); + // And exactly at the bound, both fit — a cap that is off by one is a cap + // that costs a round trip on every large transfer. Each one on an EMPTY + // queue, which is what `Options.out` means by "room for one whole + // request": a maximum-size `Twrite` IS the msize, so it fits beside + // nothing at all. + p.cli.wrote(p.cli.output().len); + _ = try p.cli.submit(.{ .read = .{ .fid = 0, .offset = 0, .count = p.cli.maxRead() } }); + p.cli.wrote(p.cli.output().len); + _ = try p.cli.submit(.{ .write = .{ .fid = 0, .offset = 0, .data = big[0..p.cli.maxWrite()] } }); + + // A second `Tversion` resets the connection, so it is refused while + // anything is outstanding rather than abandoning it. + try testing.expectError(error.Handshake, p.cli.submit(.{ .version = .{} })); + // And with the queue full of that one write there is nowhere to put even + // an eleven-byte `Tstat`: the out queue is the only back-pressure a + // sans-io client has, and it lands at `submit` where the caller is + // standing right there. + try testing.expectError(error.NoSpace, p.cli.submit(.{ .stat = .{ .fid = 0 } })); +} + +test "9p client: an msize below the floor, and one the server tried to raise" { + var in: [512]u8 = undefined; + var out: [512]u8 = undefined; + var buf: [64]u8 = undefined; + + // A caller asking for less than an `Rwalk` is asking for a connection that + // cannot be served. + var c = Client.init(.{ .in = &in, .out = &out }); + try testing.expectError(error.BadRequest, c.submit(.{ .version = .{ .msize = msize_min - 1 } })); + // Zero means "whatever the buffers hold", which is the smaller of the two. + _ = try c.submit(.{ .version = .{} }); + try testing.expectEqual(@as(u32, 512), c.asked); + + // A server answering with MORE than the client offered is a server whose + // next message will not fit the buffer that has to hold it. + _ = c.push(try encode(.{ .rversion = .{ .msize = 1024, .version = "9P2000" } }, notag, &buf)); + try testing.expect(c.take() == null); + try testing.expect(c.dead); + + // "unknown" is a SUCCESSFUL reply with no dialect in common: the handshake + // completes, `msize` stays zero, and nothing more can be submitted. + var c2 = Client.init(.{ .in = &in, .out = &out }); + _ = try c2.submit(.{ .version = .{} }); + _ = c2.push(try encode(.{ .rversion = .{ .msize = 512, .version = "unknown" } }, notag, &buf)); + const done = c2.take() orelse return error.NoReply; + try testing.expectEqualStrings("unknown", done.result.version.version); + try testing.expect(!c2.dead); + try testing.expectEqual(@as(u32, 0), c2.msize); + try testing.expectError(error.Handshake, c2.submit(.{ .stat = .{ .fid = 0 } })); + + // A dialect we never offered is neither: agreeing to it would be agreeing + // to a layout this file does not implement. + var c3 = Client.init(.{ .in = &in, .out = &out }); + _ = try c3.submit(.{ .version = .{} }); + _ = c3.push(try encode(.{ .rversion = .{ .msize = 512, .version = "9P2000.u" } }, notag, &buf)); + try testing.expect(c3.take() == null); + try testing.expect(c3.dead); +} + +test "9p client: what is not an answer to one of our requests ends the connection" { + var buf: [64]u8 = undefined; + // Each case gets a fresh connection past the handshake, because every one + // of them is fatal by design. + const Case = struct { + fn armed(in: []u8, out: []u8, scratch: []u8) !Client { + var c = Client.init(.{ .in = in, .out = out }); + _ = try c.submit(.{ .version = .{} }); + c.wrote(c.output().len); + _ = c.push(try encode(.{ .rversion = .{ .msize = 512, .version = "9P2000" } }, notag, scratch)); + _ = c.take() orelse return error.NoReply; + _ = try c.submit(.{ .stat = .{ .fid = 0 } }); + c.wrote(c.output().len); + return c; + } + }; + var in: [512]u8 = undefined; + var out: [512]u8 = undefined; + + // A T-message. A client reads R-messages, and the parity says so with no + // table: this is `Server.startFrame`'s refusal read from the other end. + { + var c = try Case.armed(&in, &out, &buf); + _ = c.push(try encode(.{ .tstat = .{ .fid = 0 } }, 0, &buf)); + try testing.expect(c.take() == null); + try testing.expect(c.dead); + } + // A reply on a tag nobody claimed. + { + var c = try Case.armed(&in, &out, &buf); + _ = c.push(try encode(.rclunk, 3, &buf)); + try testing.expect(c.take() == null); + try testing.expect(c.dead); + } + // A tag outside the table entirely, which no reply to us can carry. + { + var c = try Case.armed(&in, &out, &buf); + _ = c.push(try encode(.rclunk, 900, &buf)); + try testing.expect(c.take() == null); + try testing.expect(c.dead); + } + // The right tag and the WRONG SHAPE: an `Rclunk` where an `Rstat` was + // asked for. A tag is only as good as the table behind it. + { + var c = try Case.armed(&in, &out, &buf); + _ = c.push(try encode(.rclunk, 0, &buf)); + try testing.expect(c.take() == null); + try testing.expect(c.dead); + } + // A reply to a request this client never sends. + { + var c = try Case.armed(&in, &out, &buf); + _ = c.push(try encode(.rwstat, 0, &buf)); + try testing.expect(c.take() == null); + try testing.expect(c.dead); + } + // A `size` no encoder produced, and one past the negotiated msize. Both + // are streams that will never resynchronise. + { + var c = try Case.armed(&in, &out, &buf); + _ = c.push(&.{ 3, 0, 0, 0 }); + try testing.expect(c.take() == null); + try testing.expect(c.dead); + } + { + var c = try Case.armed(&in, &out, &buf); + _ = c.push(&.{ 0, 4, 0, 0 }); + try testing.expect(c.take() == null); + try testing.expect(c.dead); + } + // And a body the codec refuses: the type byte is fine, the payload is not. + { + var c = try Case.armed(&in, &out, &buf); + _ = c.push(&.{ 8, 0, 0, 0, @intFromEnum(Type.rstat), 0, 0, 0 }); + try testing.expect(c.take() == null); + try testing.expect(c.dead); + } +} + +test "9p client: an Rread longer than the Tread asked for is refused" { + // The one bound a client cannot check from the frame alone, which is why + // `Slot` keeps the count: a server handing back more than was asked has + // given us bytes at offsets we never named. Linux calls it `-EIO`. + var p: Pair = .{}; + try p.handshake(); + _ = try p.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ "1", "body" } } }); + _ = try p.one(.{ .open = .{ .fid = 1, .mode = oread } }); + + const tag = try p.cli.submit(.{ .read = .{ .fid = 1, .offset = 0, .count = 4 } }); + p.cli.wrote(p.cli.output().len); + var buf: [64]u8 = undefined; + _ = p.cli.push(try encode(.{ .rread = .{ .data = "hello, body\n" } }, tag, &buf)); + try testing.expect(p.cli.take() == null); + try testing.expect(p.cli.dead); + + // Exactly the count asked for is fine, and so is anything shorter. + var q: Pair = .{}; + try q.handshake(); + _ = try q.one(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{ "1", "body" } } }); + _ = try q.one(.{ .open = .{ .fid = 1, .mode = oread } }); + const short = try q.one(.{ .read = .{ .fid = 1, .offset = 0, .count = 4 } }); + try testing.expectEqualStrings("hell", short.result.read); +} + +test "9p client: hangup and a dead connection refuse everything after" { + var p: Pair = .{}; + try p.handshake(); + p.cli.hangup(); + try testing.expectEqual(@as(usize, 0), p.cli.pending()); + try testing.expectEqual(@as(usize, 0), p.cli.output().len); + try testing.expectEqual(@as(usize, 0), p.cli.push("anything")); + try testing.expect(p.cli.take() == null); + try testing.expectError(error.Dead, p.cli.submit(.{ .stat = .{ .fid = 0 } })); + try testing.expectError(error.Dead, p.cli.submit(.{ .version = .{} })); +} + +test "9p client: one session is a hundred and change bytes plus its buffers" { + // The number the board is costed against, and the whole reason the client + // is shaped the way it is: sixteen eight-byte tag slots and nine scalars, + // with every payload borrowed out of the input buffer rather than copied + // into a per-tag one. A `Server` on the same connection is 9,488 B because + // it owns a fid table and a park table; a client owns neither, because the + // far end does. + try testing.expect(@sizeOf(Client.Slot) <= 8); + try testing.expect(@sizeOf(Client) <= 256); + // Two buffers at the 8,192-byte msize `src/fs9_service.zig` serves, plus + // the client itself: what one `9p` word costs while it is running. + try testing.expect(2 * 8192 + @sizeOf(Client) <= 17 * 1024); + // And at the protocol floor, which is what a board would negotiate: two + // buffers of 217 bytes each is a 9P client in under 700 bytes of RAM. + try testing.expect(2 * msize_min + @sizeOf(Client) <= 700); } -- cgit v1.3