diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-27 16:42:15 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-27 22:07:32 -0300 |
| commit | 147ebd4a36ec7199074ba05bcfb79d4a656c0b74 (patch) | |
| tree | 400441fc7b103152cd741aec7ee42b82b943aed8 /src/board9p.zig | |
| parent | def843b2f59b867ee9b1d501f559f59fb335d4cc (diff) | |
| download | pardes-147ebd4a36ec7199074ba05bcfb79d4a656c0b74.tar.gz pardes-147ebd4a36ec7199074ba05bcfb79d4a656c0b74.zip | |
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/<n>/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.
Diffstat (limited to 'src/board9p.zig')
| -rw-r--r-- | src/board9p.zig | 874 |
1 files changed, 874 insertions, 0 deletions
diff --git a/src/board9p.zig b/src/board9p.zig new file mode 100644 index 00000000..b5b18bf2 --- /dev/null +++ b/src/board9p.zig @@ -0,0 +1,874 @@ +//! THE BOARD AS A FILESYSTEM, generated from a comptime table of what the board can do. +//! +//! The ESP32-P4 already exposes its pads and its address space — by TYPING A WORD into a tag. +//! `Gpio 20` flips a pin and prints `GPIO 20: 0->1`, `Gpio` alone draws JP1, `Peek`, `Poke` and +//! `Hexdump` reach all 2³² addresses (`src/board_memory.zig`), and every one of the four caps at +//! 4,096 bytes because the answer has to fit down a 115200-baud console. Nothing about that is +//! machine-readable and nothing about it is remote: the answer lands in an output pane, for a person +//! to read (`docs/registry.typ` `9P-11`, review note). +//! +//! This file is that same capability WITH NAMES INSTEAD OF VERBS. `cat gpio/pinout` is `Gpio`; +//! `echo 1 > gpio/20/value` is `Gpio 20`, except that it says which level it wants instead of asking +//! for whichever one it is not. A shell pipeline can do it, a script on a laptop can do it over the +//! UART, and neither needs a terminal emulator or a pane. +//! +//! WHY A TABLE, which is the whole design and not a flourish. A hand-written tree is a `Node` +//! packing, a `lookup`, a `getattr`, a `readdir`, a `read` and a `write` — six places that have to +//! agree about what exists — and the cost of adding `uptime` to it is an edit to all six plus a new +//! node id nobody else is using. The board's capabilities are a LIST, they will grow, and the entry +//! that describes one should be the only place it is described. So `caps` below is the tree: the +//! directories, the files, the per-pin fan-out, the permissions, the handlers and even the size of +//! the answer buffer are all derived from it at comptime, and the six functions at the bottom read +//! the derived table and know nothing about GPIO at all. +//! +//! WHAT A SECOND CAPABILITY COSTS, entry by entry, because "extensible" is a claim and this is the +//! evidence for it. Not implemented here — none of them is needed to serve a pin — but each is one +//! `Cap` and its handlers, and NO tree code: +//! +//! * `mem/` — `peek` and `poke` over `board_memory.readWord`/`writeWord` +//! (`src/board_memory.zig:136-144`), which are four lines of `*allowzero volatile` and already +//! compile for this target. `poke` is a WRITE handler that parses `<hex addr> <hex value>`, so +//! it needs `Fault.Malformed` and nothing else; `peek` needs an address to read, which a +//! stateless file cannot carry, so it is either a write-then-read pair (`echo 4ff40000 > addr; +//! cat word`, one more file and one `u32` of state) or a fan over a comptime list of interesting +//! registers. The second is free: `fan` below already generates a directory per key. +//! * `hexdump` — the same, with `scratch = 4096`: the one field that makes the shared answer +//! buffer grow, and the reason that field is in the table rather than a constant at the top. +//! * `prof` — three cycle counts from `pardes_esp32p4_frame_prof`, which the editor object already +//! exports (`src/esp32p4.zig:985`). It is the one capability that is NOT available in this +//! image: that symbol lives in the pardes object and the 9P image links none, so serving it +//! would mean either linking the editor or moving the counters. Worth saying out loud rather +//! than listing it as cheap. +//! * `uptime` and `heap` — `hal.systimer` and the heap's own free count, both of which the +//! runtime (`src/esp32p4_9p.zig`) can reach today. Two read handlers, `scratch = 24`, one +//! `Cap` each. These are the cheapest of the four and the reason the table's `board` parameter +//! is a TYPE rather than a pair of function pointers: adding `board.uptimeMs()` to the seam +//! adds a capability without changing anything here but the table. +//! +//! THE ABI IS `acmefs`'s, VERBATIM — `Op`, `Status`, `Req`, `Reply`, `Reply.Attr` with the same +//! fields and the same meanings — so `src/9p.zig`'s `Server` serves this tree with no translation +//! layer, exactly as it serves the editor's. That is the point of `Server` being a generic over the +//! filesystem rather than an importer of one (`src/9p.zig:1994-2010`), and it is what makes a board +//! image possible at all: `acmefs.zig` reaches `pardes.zig` and the whole core, and this file +//! reaches `std` and one leaf table. +//! +//! NO ALLOCATOR, NO OS, ONE REQUEST AT A TIME. Same rules as `acmefs`: `handle(req) -> Answer` is a +//! pure transaction, the answer's bytes are either `.rodata` or the one shared buffer, and they are +//! borrowed until the next call. Nothing here blocks, so `Status.again` never appears — the board +//! has no `event` file and no reader to park. +const std = @import("std"); +const board_pins = @import("board_pins.zig"); + +/// The errno values this tree returns. `acmefs.E`'s subset — the four a tree with no panes, no +/// blocking and no allocation can produce — with the same numbers, because they are Linux's and a +/// second spelling would be a second thing to check against `9p.errString`. +pub const E = struct { + pub const NOENT: u16 = 2; + pub const IO: u16 = 5; + pub const NOTDIR: u16 = 20; + pub const INVAL: u16 = 22; +}; + +/// How a HANDLER refuses, as against how the tree refuses. The tree answers ENOENT and ENOTDIR +/// itself, out of the table, before any handler runs; this is the set of things only the handler can +/// know. +/// +/// One variant today, and it is the honest count: a pad takes `0` or `1` and nothing else. A +/// capability that can refuse for a second reason adds a variant here and a prong to `errnoOf`, +/// which is the whole of what "another kind of no" costs. +pub const Fault = error{ + /// the bytes offered are not a value this file takes + Malformed, +}; + +/// The one place a `Fault` becomes a number. +fn errnoOf(f: Fault) u16 { + return switch (f) { + error.Malformed => E.INVAL, + }; +} + +/// A file's two halves, as POINTERS rather than function types: the derived table below is an +/// ordinary runtime array, and a struct holding a bare `fn` is comptime-only. +/// +/// `key` says which pad, address or counter the call is about, and `out` is the slice of the shared +/// answer buffer this file's table entry declared — exactly `scratch` bytes, so a handler cannot +/// write past its own budget. A read may also ignore `out` entirely and answer out of `.rodata`, +/// which is what the JP1 drawing does. +const ReadFn = *const fn (key: u16, out: []u8) Fault![]const u8; +const WriteFn = *const fn (key: u16, bytes: []const u8) Fault!u32; + +/// One FILE in the table. `key` is not here: it comes from the directory the file is generated +/// into, which is what makes one entry serve eleven pins. +/// +/// The MODE is derived, never declared: a file with both handlers is 0o600, a read handler alone is +/// 0o400, a write handler alone is 0o200, and neither is a compile error. A declared mode is a +/// fourth thing that can disagree with the three that decide it. +pub const FileSpec = struct { + name: []const u8, + read: ?ReadFn = null, + write: ?WriteFn = null, + /// Bytes of the shared answer buffer this file's read needs. ZERO when the read answers out of + /// `.rodata` and copies nothing, which is what `gpio/pinout` does — the JP1 drawing is 468 + /// bytes of static text and there is no reason to stage it. The largest `scratch` in the table + /// is one of the two numbers that size `Tree.out`. + scratch: u32 = 0, +}; + +/// One generated subdirectory of a capability, and its KEY: the pad, address or counter every file +/// inside it is about. `gpio/20/value` is `key = 20`. +pub const FanDir = struct { key: u16, name: []const u8 }; + +/// A capability's fan-out: one directory per key, each holding the same files. THE REASON the tree +/// has exactly the pins this board has — the dirs are collected from `board_pins.gpio_pins`, which +/// is collected from the JP1 rows, which are the schematic. +pub const Fan = struct { dirs: []const FanDir, files: []const FileSpec }; + +/// ONE CAPABILITY = ONE DIRECTORY under the root. Always a directory, even for a capability with a +/// single file: a flat root would put every capability's names in one u4 (see `block` below) and +/// would make `ls /` a list of files whose grouping a reader has to infer. `ls /` here is the list +/// of things this board can do. +pub const Cap = struct { + name: []const u8, + files: []const FileSpec = &.{}, + fan: ?Fan = null, +}; + +/// One node of the derived tree. Flat, because a table of fifteen entries scanned linearly is +/// faster than any structure with pointers in it and is the same shape `src/9p.zig`'s own test stub +/// uses — and because a scan cannot disagree with itself about what the tree contains. +const Entry = struct { + node: u64, + /// Where `..` goes. See `block`: this is also the value `src/9p.zig`'s `parentOf` derives from + /// the node id, for every entry but a fan leaf, and the test at the bottom asserts it. + parent: u64, + name: []const u8, + dir: bool, + mode: u16, + /// the pad this file is about, or zero + key: u16 = 0, + read: ?ReadFn = null, + write: ?WriteFn = null, + scratch: u32 = 0, +}; + +/// THE NODE ID PACKING, and it is not ours: it is `acmefs.Node`'s, `{ file: u4, serial: u60 }`, +/// because `src/9p.zig:1955` `parentOf` READS node ids to answer `..` and has that packing built in. +/// A tree that numbered its nodes freely would get a wrong answer to `cd ..` and no diagnostic. +/// +/// The rule, restated as arithmetic: a node's parent is the node rounded down to a multiple of 16, +/// except that a node already at a multiple of 16 — or below 16 — is a child of the root. +/// +/// * the root is 1: serial 0, so `..` is itself, which is POSIX's rule and `intro(5)`'s. +/// * a capability directory is its own BLOCK BASE, `(index + 1) * 16`, so its `..` is the root. +/// * everything inside a capability — its files AND its fan directories — is a member of that +/// block, `base + 1 .. base + 15`, so their `..` is the capability directory. Correct, which is +/// what matters for the one `..` a client actually performs: `cd /gpio/20; cd ..`. +/// * a fan LEAF (`gpio/20/value`) cannot be expressed. Its parent is a block member, and +/// `parentOf` can only produce block bases. So leaves get blocks of their own, above every +/// capability's, and `..` from one lands on an unallocated block base, which this tree answers +/// ENOENT. That is the honest failure: a walk that cannot be expressed is refused rather than +/// silently landing on a different file. No client does it — `..` from a file requires having +/// walked INTO a file, and a file is not a directory — and the fix, if one is ever wanted, is a +/// `parent` hook on `Server` so a filesystem deeper than two levels answers `..` itself. That +/// is exactly the wall `parentOf`'s own doc comment says it is (`src/9p.zig:1950-1954`), and +/// `acmefs`'s `pty/` subtree stands on the same side of it today. +const block: u64 = 16; + +/// The root, and the value the runtime hands `Server.init` as `Options.root`. One, for the same +/// reason `acmefs.TopFile.root` is one: node 0 is `{ file: 0, serial: 0 }` and cannot be a root +/// (`src/9p.zig:2190-2192`). +pub const root: u64 = 1; + +/// Every key the GPIO fan generates, re-exported for the RUNTIME's benefit: `src/esp32p4_9p.zig` +/// checks at comptime that each one is a pad `hal.gpio` will accept, which is the one thing this +/// file cannot check for itself — `max_pin` is a property of the chip package and lives in the +/// toolchain repository, and importing it here would make the tree unbuildable on a host. +pub const pins = board_pins.gpio_pins; + +/// The board's own tree, over a `board` seam the runtime supplies. +/// +/// GENERIC over the board for exactly the reason `Server` is generic over the filesystem: the pads +/// are four register files behind `hal.gpio` in the toolchain package, which exists only for +/// riscv32, and a tree that imported it could not be tested on a host at all. The seam is two +/// functions, both about the level the board is DRIVING: +/// +/// * `board.level(pin: u8) u1` +/// * `board.drive(pin: u8, level: u1) void` +/// +/// `src/esp32p4_9p.zig` implements them over `hal.gpio`, in the same four calls +/// `src/esp32p4/app.zig:200-209` uses for the `Gpio` word — the same seam, a second caller, not a +/// second copy of the register sequence. The tests below implement them over a recording stub, the +/// way `src/9p.zig`'s server tests implement a filesystem. +pub fn Tree(comptime board: type) type { + return struct { + const Self = @This(); + + // -- the ABI, which is `acmefs`'s ------------------------------------ + // + // A MIRROR, not a redefinition: `Server(acmefs)` is the instantiation that proves the + // shape, and a field that drifts from it is a compile error the moment `Server(Tree(...))` + // is built — which the tests at the bottom do. + + pub const Op = enum(u8) { lookup, getattr, setattr, open, read, write, release, readdir, statfs }; + + /// `again` is here because the ABI has it, and it never occurs: nothing on this board + /// blocks. The board's answer to "what is this pin at" is a register read. + pub const Status = enum(u8) { ok, again, err }; + + pub const Req = struct { + tag: u64, + op: Op, + node: u64, + handle: u32 = 0, + off: u64 = 0, + size: u32 = 0, + data: []const u8 = &.{}, + truncate: bool = false, + }; + + pub const Reply = struct { + tag: u64, + status: Status = .ok, + errno: u16 = 0, + attr: Attr = .{}, + handle: u32 = 0, + written: u32 = 0, + + pub const Attr = struct { + node: u64 = 0, + dir: bool = false, + size: u64 = 0, + mode: u16 = 0o600, + }; + + /// `acmefs.Reply.fail`'s twin, so a refusal is one expression here as it is there. + pub fn fail(tag: u64, e: u16) Reply { + return .{ .tag = tag, .status = .err, .errno = e }; + } + }; + + /// A reply and the bytes it points at, borrowed until the next `handle`. `Server.reply` + /// takes exactly this pair. + pub const Answer = struct { reply: Reply, bytes: []const u8 = "" }; + + // -- the handlers ---------------------------------------------------- + + /// `gpio/pinout` — JP1, as the `Gpio` word draws it, TO THE BYTE. The same + /// `board_pins.jp1_text` the word prints (`src/board_memory.zig:364`), returned out of + /// `.rodata` rather than staged, so this read costs no buffer and no copy. + fn readPinout(_: u16, _: []u8) Fault![]const u8 { + return board_pins.jp1_text; + } + + /// `gpio/<n>/value` — the level this board is DRIVING on pad `n`, as `0` or `1` and a + /// newline. + /// + /// THE DRIVEN LEVEL and not the pad's, for the reason `src/esp32p4/app.zig:196-199` gives: + /// the pad's own level is what the outside world says, and on an unconnected header pin that + /// is noise. The driven level is defined for every pin, which is what a file that a script + /// reads in a loop needs. + /// + /// The trailing newline is not decoration: `cat gpio/20/value` in a terminal and `$(cat + /// ...)` in a script both want it, and the write side accepts it back, so `cp` of one pin's + /// value onto another's is a legal round trip. + fn readValue(key: u16, out: []u8) Fault![]const u8 { + out[0] = '0' + @as(u8, board.level(@intCast(key))); + out[1] = '\n'; + return out[0..2]; + } + + /// `gpio/<n>/value` — drive pad `n` to `0` or `1`. + /// + /// WRITING THE OPPOSITE OF THE CURRENT LEVEL IS THE `Gpio` WORD'S TOGGLE, through the same + /// seam; writing the level it is already at is not a no-op, because the FIRST write to a pad + /// is what makes it an output at all (`hal.gpio.configureOutput`, four register files). So + /// this always drives, and `echo 0 > value` on a fresh boot is a meaningful command: it + /// takes the pad off whatever the IO MUX had it pointed at and holds it low. + /// + /// `0`, `1`, `0\n` and `1\n` are the whole language. Anything else is EINVAL, including + /// `true`, `high`, `01` and the empty write — a file whose only two values are one character + /// each has no room for a spelling debate, and guessing at `on` would be the beginning of + /// one. + fn writeValue(key: u16, bytes: []const u8) Fault!u32 { + const want = try oneBit(bytes); + board.drive(@intCast(key), want); + // The whole write is consumed, trailing newline included: a short count would make + // `echo` retry the tail and drive the pin a second time. + return @intCast(bytes.len); + } + + /// `0` or `1`, with at most one trailing newline (and the `\r` a Windows-ish client may put + /// in front of it). Nothing else. + fn oneBit(bytes: []const u8) Fault!u1 { + var end = bytes.len; + while (end > 0 and (bytes[end - 1] == '\n' or bytes[end - 1] == '\r')) end -= 1; + if (end != 1) return error.Malformed; + return switch (bytes[0]) { + '0' => 0, + '1' => 1, + else => error.Malformed, + }; + } + + // -- THE TABLE ------------------------------------------------------- + + /// The pin directories, one per P4 GPIO the header brings out, named by the pin number in + /// DECIMAL — the number the schematic, the silkscreen and the datasheet all use, and the one + /// literal in `board_memory.zig` that is not hex (`:394-399`). Generated from + /// `board_pins.gpio_pins`, so this list cannot contain a pin JP1 does not have. + const gpio_dirs = dirs: { + var out: [board_pins.gpio_pins.len]FanDir = undefined; + for (board_pins.gpio_pins, 0..) |pin, i| out[i] = .{ + .key = pin, + .name = std.fmt.comptimePrint("{d}", .{pin}), + }; + break :dirs out; + }; + + /// EVERYTHING THIS BOARD OFFERS, and the only place any of it is described. The tree, the + /// permissions, the handlers, the node ids and the answer buffer all come out of here. + const caps = [_]Cap{ + .{ + .name = "gpio", + .files = &.{ + .{ .name = "pinout", .read = readPinout }, + }, + .fan = .{ + .dirs = &gpio_dirs, + .files = &.{ + .{ .name = "value", .read = readValue, .write = writeValue, .scratch = 2 }, + }, + }, + }, + }; + + /// How many nodes the table generates, counted separately because it is an array length. + const node_count = count: { + var n: usize = 1; // the root + for (caps) |c| { + n += 1 + c.files.len; + if (c.fan) |f| n += f.dirs.len * (1 + f.files.len); + } + break :count n; + }; + + /// THE DERIVED TREE. Built once at comptime and `const`, so it lands in `.rodata` and costs + /// the image its bytes and the board's RAM nothing. + const table: [node_count]Entry = build: { + var out: [node_count]Entry = undefined; + out[0] = .{ .node = root, .parent = root, .name = "/", .dir = true, .mode = 0o500 }; + var at: usize = 1; + // Blocks 1..caps.len are the capability directories; fan leaves take the ones above, + // which is what keeps a leaf's unexpressible parent from landing on a real node. + var next_block: u64 = caps.len + 1; + for (caps, 0..) |c, ci| { + const dir_node = (ci + 1) * block; + out[at] = .{ .node = dir_node, .parent = root, .name = c.name, .dir = true, .mode = 0o500 }; + at += 1; + // The u4 in the node id, spent one per name inside this capability. Directories and + // files come out of the same fifteen, which is the wall `acmefs.PaneFile`'s doc + // comment describes from the other side. + var slot: u64 = 1; + for (c.files) |f| { + out[at] = fileEntry(dir_node + slot, dir_node, f, 0); + at += 1; + slot += 1; + } + if (c.fan) |fan| for (fan.dirs) |d| { + const fan_node = dir_node + slot; + slot += 1; + out[at] = .{ .node = fan_node, .parent = dir_node, .name = d.name, .dir = true, .mode = 0o500 }; + at += 1; + const leaf_base = next_block * block; + next_block += 1; + for (fan.files, 0..) |f, l| { + out[at] = fileEntry(leaf_base + 1 + l, fan_node, f, d.key); + at += 1; + } + }; + if (slot >= block) @compileError( + "capability '" ++ c.name ++ + "' has more than 15 names in it, and a node id has four bits for them:" ++ + " `acmefs.Node.file` is a u4 and `9p.parentOf` reads it. Split it into two" ++ + " capabilities, or widen the packing in acmefs.zig, 9p.zig and here at once.", + ); + } + break :build out; + }; + + /// One file's entry, with the mode derived from which handlers it has. + fn fileEntry(node: u64, parent: u64, f: FileSpec, key: u16) Entry { + const mode: u16 = if (f.read != null and f.write != null) + 0o600 + else if (f.read != null) + 0o400 + else if (f.write != null) + 0o200 + else + @compileError("file '" ++ f.name ++ "' has no read and no write, so it is a name and not a file"); + return .{ + .node = node, + .parent = parent, + .name = f.name, + .dir = false, + .mode = mode, + .key = key, + .read = f.read, + .write = f.write, + .scratch = f.scratch, + }; + } + + /// THE ONE BUFFER, and both numbers that size it come out of the table: the largest + /// `scratch` any read declares, and the widest directory's worth of staged entries. Never + /// both at once — one request is in flight at a time — so one buffer serves both, and the + /// board pays for the larger. + const out_max = size: { + var most: usize = 0; + for (table) |e| most = @max(most, e.scratch); + for (table) |d| { + if (!d.dir) continue; + var n: usize = 0; + for (table) |e| if (e.parent == d.node and e.node != d.node) { + n += dirent_fixed + e.name.len; + }; + most = @max(most, n); + } + break :size most; + }; + + /// `node[8] dir[1] namelen[1]` — `acmefs`'s staging format for a readdir + /// (`acmefs.zig:942-957`), which is what `Server` decodes. Ten bytes and then the name. + const dirent_fixed = 8 + 1 + 1; + + /// Formatted answers and staged directory entries. Valid until the next `handle`, which is + /// the borrow window `Server.reply` documents. + out: [out_max]u8 = undefined, + + /// Every request the board has been asked, for the runtime's own diagnostics. Not a + /// protocol counter — `Server` keeps those — and not a statistic anybody has to read: it is + /// the one number that distinguishes "nothing is arriving" from "everything is being + /// refused" on a board with no second console to ask. + calls: u32 = 0, + + fn find(node: u64) ?*const Entry { + for (&table) |*e| if (e.node == node) return e; + return null; + } + + fn attrOf(t: *Self, e: *const Entry) Reply.Attr { + return .{ .node = e.node, .dir = e.dir, .mode = e.mode, .size = t.sizeOf(e) }; + } + + /// A file's size is WHAT ITS READ ANSWERS, asked rather than declared. That means a + /// `getattr` of `gpio/20/value` reads the pad's output register, which is a load from a + /// peripheral and nothing more; the alternative is a second declaration in the table that + /// can disagree with the handler, on a tree whose whole claim is that there is one place per + /// fact. A write-only file has no size and reports zero, which is what `acmefs` reports for + /// every file it cannot cheaply measure. + fn sizeOf(t: *Self, e: *const Entry) u64 { + const read = e.read orelse return 0; + const bytes = read(e.key, t.out[0..e.scratch]) catch return 0; + return bytes.len; + } + + /// ONE OPERATION, and the whole of what this filesystem is. Pure: no allocation, no + /// blocking, no state but `out` and the counter. + pub fn handle(t: *Self, req: Req) Answer { + t.calls += 1; + const e = find(req.node) orelse return .{ .reply = .fail(req.tag, E.NOENT) }; + switch (req.op) { + .lookup => { + if (!e.dir) return .{ .reply = .fail(req.tag, E.NOTDIR) }; + for (&table) |*c| { + if (c.parent != req.node or c.node == req.node) continue; + if (!std.mem.eql(u8, c.name, req.data)) continue; + return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(c) } }; + } + return .{ .reply = .fail(req.tag, E.NOENT) }; + }, + .getattr => return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(e) } }, + // The only `setattr` that reaches here is a truncate, from `Topen` with `OTRUNC` + // (`src/9p.zig:2816-2822`) — which is what `echo 1 > gpio/20/value` opens with. + // Every file here is a fixed-length register view, so there is nothing to truncate + // and nothing to refuse either: answering EINVAL would make the shell's own + // redirection fail on a pin that is perfectly writable. + .setattr => { + if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) }; + return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(e) } }; + }, + // No per-open state, so one handle for every open. `Server` checks the mode against + // the fid's cached permissions before it gets here (`src/9p.zig:2075-2079`). + .open => return .{ .reply = .{ .tag = req.tag, .handle = 1 } }, + .release => return .{ .reply = .{ .tag = req.tag } }, + .read => { + if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) }; + const read = e.read orelse return .{ .reply = .fail(req.tag, E.INVAL) }; + const all = read(e.key, t.out[0..e.scratch]) catch |f| { + return .{ .reply = .fail(req.tag, errnoOf(f)) }; + }; + // Past the end is the empty read every client uses to stop, not an error. + if (req.off >= all.len) return .{ .reply = .{ .tag = req.tag } }; + const from = all[@intCast(req.off)..]; + return .{ .reply = .{ .tag = req.tag }, .bytes = from[0..@min(from.len, req.size)] }; + }, + .write => { + if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) }; + const write = e.write orelse return .{ .reply = .fail(req.tag, E.INVAL) }; + // A REGISTER IS NOT A STREAM. Every file here is one value, so the only offset + // that means anything is zero; a client that seeks and writes is describing an + // edit to a byte range this file does not have. `echo`, `9p write` and + // `cat > file` all write at zero. + if (req.off != 0) return .{ .reply = .fail(req.tag, E.INVAL) }; + const n = write(e.key, req.data) catch |f| { + return .{ .reply = .fail(req.tag, errnoOf(f)) }; + }; + return .{ .reply = .{ .tag = req.tag, .written = n } }; + }, + .readdir => { + if (!e.dir) return .{ .reply = .fail(req.tag, E.NOTDIR) }; + return .{ .reply = .{ .tag = req.tag }, .bytes = t.stage(req.node, req.off) }; + }, + // 9P2000 has no `Tstatfs` — that is a `.L` message (`src/9p.zig:24-28`) — so + // nothing reaches this. It is answered rather than `unreachable` because the ABI + // names it and a panic in a server is worse than an empty answer. + .statfs => return .{ .reply = .{ .tag = req.tag } }, + } + } + + /// A directory's children in `acmefs`'s staging format, from an ENTRY INDEX rather than a + /// byte offset — `Server` does that coordinate change and advances both cursors + /// (`src/9p.zig:2836-2849`). The whole of the widest directory fits `out` by construction, + /// so this never stages a short list for want of room; `Server` still takes only what one + /// reply holds and asks again. + fn stage(t: *Self, node: u64, skip: u64) []const u8 { + var n: usize = 0; + var seen: u64 = 0; + for (&table) |*e| { + if (e.parent != node or e.node == node) continue; + if (seen < skip) { + seen += 1; + continue; + } + std.mem.writeInt(u64, t.out[n..][0..8], e.node, .little); + t.out[n + 8] = @intFromBool(e.dir); + t.out[n + 9] = @intCast(e.name.len); + @memcpy(t.out[n + dirent_fixed ..][0..e.name.len], e.name); + n += dirent_fixed + e.name.len; + } + return t.out[0..n]; + } + }; +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- +// +// A RECORDING STUB FOR THE PADS, exactly as `src/9p.zig`'s server tests use a stub filesystem: the +// seam is two functions, so the test can hold the pads still and check what was asked of them. Every +// claim below is one a host can answer — the tree's shape, the bytes of an answer, which pin the +// seam was called with — and the one claim it cannot is stated as such: whether `hal.gpio` drives +// the pad, which only the die knows. + +const testing = std.testing; + +/// The pads, faked. `driven` is the board's output register. +const StubPads = struct { + var driven: [64]u1 = @splat(0); + var log: [16]Call = undefined; + var log_len: usize = 0; + + const Call = struct { pin: u8, level: u1 }; + + fn reset() void { + driven = @splat(0); + log_len = 0; + } + + fn level(pin: u8) u1 { + return driven[pin]; + } + + fn drive(pin: u8, want: u1) void { + driven[pin] = want; + log[log_len] = .{ .pin = pin, .level = want }; + log_len += 1; + } +}; + +const Board = Tree(StubPads); + +/// The tree, walked by name the way a client walks it: `lookup` after `lookup` from the root, which +/// is the only way to find out what the generated table actually offers. +fn walk(t: *Board, path: []const []const u8) !Board.Reply.Attr { + var at: u64 = root; + var attr: Board.Reply.Attr = .{ .node = root, .dir = true, .mode = 0o500 }; + for (path) |name| { + const a = t.handle(.{ .tag = 1, .op = .lookup, .node = at, .data = name }); + if (a.reply.status == .err) return switch (a.reply.errno) { + E.NOENT => error.NoEntry, + E.NOTDIR => error.NotDirectory, + else => error.Refused, + }; + attr = a.reply.attr; + at = attr.node; + } + return attr; +} + +fn readAll(t: *Board, node: u64) !Board.Answer { + const open = t.handle(.{ .tag = 1, .op = .open, .node = node }); + try testing.expectEqual(Board.Status.ok, open.reply.status); + return t.handle(.{ .tag = 2, .op = .read, .node = node, .handle = open.reply.handle, .size = 65535 }); +} + +test "board9p: the generated tree has exactly the header's pins, and nothing else" { + var t: Board = .{}; + + // The capability directory, and its one hand-written file. + try testing.expect((try walk(&t, &.{"gpio"})).dir); + try testing.expect(!(try walk(&t, &.{ "gpio", "pinout" })).dir); + + // Every pin JP1 brings out is a directory with a `value` in it. Eleven of them, generated. + for (board_pins.gpio_pins) |pin| { + var name: [4]u8 = undefined; + const dir = try std.fmt.bufPrint(&name, "{d}", .{pin}); + try testing.expect((try walk(&t, &.{ "gpio", dir })).dir); + const value = try walk(&t, &.{ "gpio", dir, "value" }); + try testing.expect(!value.dir); + try testing.expectEqual(@as(u16, 0o600), value.mode); + } + + // And a pin the board does not bring out is not there. 6 and 21 are real ESP32-P4 GPIOs that + // JP1 simply does not route, which is the distinction the table exists to keep: the tree has + // the pins the BOARD has, not the pins the CHIP has. + try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "6" })); + try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "21" })); + try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "20", "level" })); + try testing.expectError(error.NoEntry, walk(&t, &.{"mem"})); +} + +test "board9p: a read of gpio/pinout is the bytes the Gpio word draws" { + var t: Board = .{}; + const at = try walk(&t, &.{ "gpio", "pinout" }); + // `board_memory.zig:364`'s `pinout` IS this declaration, so this is the word's own output and + // not a copy of it. The bytes themselves are pinned by `board_pins.zig`'s golden test. + const a = try readAll(&t, at.node); + try testing.expectEqualStrings(board_pins.jp1_text, a.bytes); + // The size a client is told matches what it gets, which is what makes `cat` stop in one read. + try testing.expectEqual(board_pins.jp1_text.len, at.size); + // Read-only: the drawing is the header's, not the client's. + try testing.expectEqual(@as(u16, 0o400), at.mode); + const w = t.handle(.{ .tag = 3, .op = .write, .node = at.node, .data = "x" }); + try testing.expectEqual(E.INVAL, w.reply.errno); +} + +test "board9p: writing 1 then 0 drives the pad twice, through the seam" { + StubPads.reset(); + var t: Board = .{}; + const at = try walk(&t, &.{ "gpio", "20", "value" }); + + // A fresh pad reads 0 — the level the board is DRIVING, which is defined before anybody has + // written anything. + const before = try readAll(&t, at.node); + try testing.expectEqualStrings("0\n", before.bytes); + + const one = t.handle(.{ .tag = 4, .op = .write, .node = at.node, .data = "1" }); + try testing.expectEqual(Board.Status.ok, one.reply.status); + try testing.expectEqual(@as(u32, 1), one.reply.written); + try testing.expectEqualStrings("1\n", (try readAll(&t, at.node)).bytes); + + // `echo 0 > value`, newline and all: the whole write is consumed, so the shell does not retry + // the tail and drive the pin a second time. + const zero = t.handle(.{ .tag = 5, .op = .write, .node = at.node, .data = "0\n" }); + try testing.expectEqual(@as(u32, 2), zero.reply.written); + try testing.expectEqualStrings("0\n", (try readAll(&t, at.node)).bytes); + + // TWO CALLS, the right pin, the right levels, in order. This is the whole of what the host can + // check about the seam; that `hal.gpio` then moves the pad is the die's to answer. + try testing.expectEqual(@as(usize, 2), StubPads.log_len); + try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 1 }, StubPads.log[0]); + try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 0 }, StubPads.log[1]); +} + +test "board9p: a pad takes 0 and 1 and refuses everything else, without touching the pads" { + StubPads.reset(); + var t: Board = .{}; + const at = try walk(&t, &.{ "gpio", "45", "value" }); + + for ([_][]const u8{ "2", "", "01", "x", "true", "high", "\n", "1 ", " 1", "10" }) |bad| { + const a = t.handle(.{ .tag = 6, .op = .write, .node = at.node, .data = bad }); + try testing.expectEqual(Board.Status.err, a.reply.status); + try testing.expectEqual(E.INVAL, a.reply.errno); + } + // A refused write is a pad that was never driven, which is the part that matters: a half-parsed + // command must not leave the board in a state nobody asked for. + try testing.expectEqual(@as(usize, 0), StubPads.log_len); + + // A register is one value, so a write at an offset is refused too, and refused before the pads. + const off = t.handle(.{ .tag = 7, .op = .write, .node = at.node, .off = 1, .data = "1" }); + try testing.expectEqual(E.INVAL, off.reply.errno); + try testing.expectEqual(@as(usize, 0), StubPads.log_len); +} + +test "board9p: every node's parent is the one 9p.parentOf derives, or an unallocated block" { + // THE ENCODING'S OWN TEST, and it defends the one thing this file cannot see: `src/9p.zig` + // answers `..` from the node id alone, by the rule restated at `block` above. A node numbered + // outside that rule would make `cd ..` land somewhere else with no diagnostic, so the rule is + // applied here to every generated node and compared against the table's own `parent`. + for (&Board.table) |*e| { + const serial = e.node >> 4; + const file = e.node & 0xF; + const derived: u64 = if (e.node == root or serial == 0 or file == 0) root else serial << 4; + if (derived == e.parent) continue; + // The one exception, and it must be exactly the one documented: a fan leaf, whose parent is + // a block MEMBER and therefore unexpressible. Its derived parent has to be a node that does + // not exist, so the walk is refused rather than landing on the wrong file. + try testing.expectEqualStrings("value", e.name); + var t: Board = .{}; + const a = t.handle(.{ .tag = 8, .op = .getattr, .node = derived }); + try testing.expectEqual(E.NOENT, a.reply.errno); + } +} + +test "board9p: a directory read lists what the table generated, in table order" { + var t: Board = .{}; + + // The root is the capability list, and today that is one name. + try testing.expectEqualStrings("gpio", (try names(&t, root, 0))[0]); + try testing.expectEqual(@as(usize, 1), (try names(&t, root, 0)).len); + + const gpio = (try walk(&t, &.{"gpio"})).node; + const listing = try names(&t, gpio, 0); + try testing.expectEqual(board_pins.gpio_pins.len + 1, listing.len); + try testing.expectEqualStrings("pinout", listing[0]); + for (board_pins.gpio_pins, 0..) |pin, i| { + var buf: [4]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&buf, "{d}", .{pin}), listing[i + 1]); + } + + // The cursor is an ENTRY INDEX, which is what `Server` advances between reads of a directory + // bigger than one reply. + const rest = try names(&t, gpio, 5); + try testing.expectEqual(board_pins.gpio_pins.len + 1 - 5, rest.len); + try testing.expectEqualStrings("5", rest[0]); +} + +/// The names in one staged directory read, decoded out of `acmefs`'s `node[8] dir[1] namelen[1] +/// name[]` records — the same decode `Server` does. +var name_slots: [32][]const u8 = undefined; +fn names(t: *Board, node: u64, skip: u64) ![][]const u8 { + const a = t.handle(.{ .tag = 9, .op = .readdir, .node = node, .off = skip, .size = 65535 }); + try testing.expectEqual(Board.Status.ok, a.reply.status); + var n: usize = 0; + var i: usize = 0; + while (i < a.bytes.len) { + const len = a.bytes[i + 9]; + name_slots[n] = a.bytes[i + 10 ..][0..len]; + n += 1; + i += 10 + len; + } + return name_slots[0..n]; +} + +test "board9p: the whole tree costs one buffer, and the table says how big" { + // The two numbers the board's RAM budget is quoted from. `out` is the ONLY buffer this + // filesystem has, and both of its bounds come out of the table: the widest directory's staged + // entries (gpio's twelve) and the largest read scratch (a pin's two bytes). + try testing.expectEqual(@as(usize, 143), Board.out_max); + try testing.expect(@sizeOf(Board) <= 160); + // The JP1 drawing is not in it, and that is the point of `scratch = 0`: 468 bytes of static + // text are served straight out of `.rodata`. + try testing.expect(board_pins.jp1_text.len > Board.out_max); +} + +// The proof that the ABI claim in this file's header is true, and the only place the two halves meet +// on the host: `Server` is a generic over exactly `Op`, `Status`, `Req`, `Reply` and `Reply.Attr`, +// so a field that drifts from `acmefs`'s is a compile error HERE, and a real client's bytes are what +// comes out. +// +// A PATH IMPORT, and it took two goes to get here. The first was +// `@import("9p.zig")`, which did not compile while `src/9p.zig` was also the +// ROOT of a named `ninep` module in the same link — a file belongs to exactly +// one module, and it was both. The second was a named module, declared twice in +// `build.zig`; that compiled and made this file unbuildable by anyone but +// `build.zig`, which is what broke the board image the moment the firmware +// link moved to the toolchain repository and stopped injecting modules. +// +// The path form works now because nothing declares `src/9p.zig` as a module +// root any more: `fs9_service.zig` and `fs9_client.zig` reach it by path too, +// so every link that contains it contains it once. The gain is that this file +// and `src/esp32p4_9p.zig` are self-contained — `zig test src/board9p.zig` +// works with no flags, and any builder can root an image at `nine.zig` without +// being told what modules to inject. +const ninep = @import("9p.zig"); + +test "board9p: a real 9P client reads a pin's value off this tree" { + StubPads.reset(); + const Server = ninep.Server(Board); + // The board's own buffers, at the board's own msize. See `src/esp32p4_9p.zig` for why 1024. + var in: [1024]u8 = undefined; + var out: [2048]u8 = undefined; + var fsys: Board = .{}; + var srv = Server.init(.{ .in = &in, .out = &out, .root = root }); + + var scratch: [256]u8 = undefined; + const send = struct { + fn call(s: *Server, f: *Board, buf: []u8, tag: u16, msg: ninep.Msg) !void { + const bytes = try ninep.encode(msg, tag, buf); + try testing.expectEqual(bytes.len, s.push(bytes)); + while (s.retry()) |req| { + const a = f.handle(req); + s.reply(&a.reply, a.bytes); + } + while (s.next()) |req| { + const a = f.handle(req); + s.reply(&a.reply, a.bytes); + } + } + }.call; + const reap = struct { + fn call(s: *Server) !ninep.Decoded { + const queued = s.output(); + const len = ninep.frameLen(queued) orelse return error.NoReply; + const got = try ninep.decode(queued[0..len]); + s.wrote(len); + return got; + } + }.call; + + try send(&srv, &fsys, &scratch, ninep.notag, .{ .tversion = .{ .msize = 8192, .version = "9P2000" } }); + const v = try reap(&srv); + // Clamped to what the board's buffers hold, which is the number the RAM budget was chosen for. + try testing.expectEqual(@as(u32, 1024), v.msg.rversion.msize); + + try send(&srv, &fsys, &scratch, 1, .{ .tattach = .{ .fid = 0, .afid = ninep.nofid, .uname = "goblin", .aname = "" } }); + try testing.expectEqual(root, (try reap(&srv)).msg.rattach.qid.path); + + var wname: [ninep.max_welem][]const u8 = @splat(""); + wname[0] = "gpio"; + wname[1] = "20"; + wname[2] = "value"; + try send(&srv, &fsys, &scratch, 2, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 3, .wname = wname } }); + try testing.expectEqual(@as(u16, 3), (try reap(&srv)).msg.rwalk.nwqid); + + try send(&srv, &fsys, &scratch, 3, .{ .topen = .{ .fid = 1, .mode = ninep.ordwr } }); + _ = try reap(&srv); + + // `echo 1 > /mnt/board/gpio/20/value`, as bytes on a wire. + try send(&srv, &fsys, &scratch, 4, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "1\n" } }); + try testing.expectEqual(@as(u32, 2), (try reap(&srv)).msg.rwrite.count); + try testing.expectEqual(@as(u1, 1), StubPads.driven[20]); + + // ...and `cat` of the same file. + try send(&srv, &fsys, &scratch, 5, .{ .tread = .{ .fid = 1, .offset = 0, .count = 512 } }); + try testing.expectEqualStrings("1\n", (try reap(&srv)).msg.rread.data); + + // The refusal reaches the client as an error STRING, which is 9P's only channel for "no": EINVAL + // becomes the wording `9p.errString` gives it, and the pad is not touched. + try send(&srv, &fsys, &scratch, 6, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "on" } }); + try testing.expectEqualStrings(ninep.errString(E.INVAL), (try reap(&srv)).msg.rerror.ename); + try testing.expectEqual(@as(usize, 1), StubPads.log_len); +} |
