summaryrefslogtreecommitdiff
path: root/src/9p.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/9p.zig')
-rw-r--r--src/9p.zig1237
1 files changed, 1228 insertions, 9 deletions
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);
}