From 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 6 Sep 2026 18:11:36 -0300 Subject: Refactor panes and filesystem; replace FUSE with 9P Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill. --- src/board9p.zig | 874 -------------------------------------------------------- 1 file changed, 874 deletions(-) delete mode 100644 src/board9p.zig (limited to 'src/board9p.zig') diff --git a/src/board9p.zig b/src/board9p.zig deleted file mode 100644 index b5b18bf2..00000000 --- a/src/board9p.zig +++ /dev/null @@ -1,874 +0,0 @@ -//! 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 ` `, 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//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//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); -} -- cgit v1.3