summaryrefslogtreecommitdiff
path: root/docs/registry.typ
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-27 16:42:15 -0300
committerGabriel Schneider <[email protected]>2026-08-27 22:07:32 -0300
commit147ebd4a36ec7199074ba05bcfb79d4a656c0b74 (patch)
tree400441fc7b103152cd741aec7ee42b82b943aed8 /docs/registry.typ
parentdef843b2f59b867ee9b1d501f559f59fb335d4cc (diff)
downloadpardes-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 'docs/registry.typ')
-rw-r--r--docs/registry.typ133
1 files changed, 133 insertions, 0 deletions
diff --git a/docs/registry.typ b/docs/registry.typ
index 3db77c65..40af056a 100644
--- a/docs/registry.typ
+++ b/docs/registry.typ
@@ -423,6 +423,34 @@ rejected whole.
#ev("src/detached/server.zig:238-241")[the daemon is one `poll(2)` over 50 slots, and `:565-569` records that it has no worker pool.]
#verdict[Deferred until a second machine exists AND `9P-12` has settled, because the proxy's shape depends entirely on which layer 9P occupies. Reopen with a named use case, not with an architecture.]
+
+ #note("unblocked", "2026-08-27")[
+ *Reopen this.* The deferral rested on one objection and steps 4 and 5
+ dissolved it without meaning to.
+
+ The objection was that a proxied `Twalk` cannot be answered until the remote
+ `Rwalk` arrives, so the router needs per-tag continuations, tag remapping,
+ flush forwarding and fid invalidation — machinery a core that must not block
+ has nowhere to put. Three of those four now exist as shipped primitives:
+
+ #ev("src/9p.zig")[`Server.retry()` re-offers a parked request oldest-first and `reply()` RE-PARKS it when the answer is still `.again`. The park table is therefore a continuation store that already survives across frames, and it is the one the FUSE mount has used all along.]
+ #ev("src/9p.zig")[`Client` is sans-io: `submit()` hands back a tag and never waits, `take()` returns a completed operation or null. Its tag table is indexed BY the tag, so attributing an out-of-order reply is one bounds check — which is the remapping the objection was about.]
+
+ So a proxy is now a loop, not a subsystem: `Server.next()` gives a request,
+ `Client.submit()` forwards it, the answer is `.again`, and each frame
+ `Server.retry()` offers it back until `Client.take()` completes and the real
+ reply goes out. Nothing blocks, nothing is added to the core, and the
+ daemon's poll set grows by one descriptor per upstream.
+
+ What is still genuinely missing is fid invalidation on a connection that
+ dies mid-walk, and a decision about whether `Tflush` forwards or is answered
+ locally. Both are small and neither is architectural.
+
+ Re-cost before building: the "several hundred lines" the draft claimed was
+ wrong in the other direction too. Measure it against the ≈200 lines this
+ loop looks like, and against `src/fs9_client.zig`'s 622, which already does
+ the connect-and-pump half.
+ ]
]
#entry("9P-11", "A 9P server on the ESP32-P4 — real, cheap, and a second firmware image", state: "decided", tags: ("board", "motivating-case"))[
@@ -1070,3 +1098,108 @@ Three of those four are achievable. One is not, and `9P-20` says which.
codebase is the same severity: the comments are how the next change is made.
]
]
+
+#pagebreak()
+
+= After the build
+
+Five steps shipped, and the shape of what is left is not the shape the note
+predicted. These entries are written from the tree as built, not from the plan.
+
+#entry("9P-25", "The tree behind the server is pluggable, and that was not planned", state: "decided", tags: ("architecture", "windfall"))[
+ `Server` is `pub fn Server(comptime fs: type) type`, duck-typed on exactly
+ `fs.Req`, `fs.Reply` and `fs.Reply.Attr`. That shape was chosen for a boring
+ reason — importing `acmefs.zig` drags `pardes.zig` into `zig test` — and it
+ turned out to be the most useful thing in the file.
+
+ #ev("src/acmefs.zig")[filesystem one: the acme control tree, 9 ops.]
+ #ev("src/board9p.zig")[filesystem two: 867 lines that re-declare the same `Op`, `Status`, `Req`, `Reply` and `handle`, and are served by the same `Server` with no translation layer at all.]
+
+ So "serve X over 9P" is no longer a protocol question. It is: write a
+ `handle()` over nine operations, and get a wire, a fid table, directory
+ cursors, `Tflush`, error strings and both freestanding targets for free.
+
+ #verdict[
+ Treat `Server(fs)` as the extension point it accidentally became, and say so
+ where someone will look. Candidates that are now cheap and were never on a
+ list: the LSP surface as a tree, a session dump as a tree, the config as a
+ tree. None of them needs a line of 9P.
+
+ The discipline that keeps this from becoming a plugin system: nine
+ operations and no tenth. A filesystem that wants a tenth wants an API.
+ ]
+]
+
+#entry("9P-26", "What is cheap now, ranked, and step 6 is not first", state: "open", tags: ("sequencing", "next"))[
+ Step 6 — a remote display serving `screen` and `input` while the core is its
+ client — is cheaper than it was, because its one hard prerequisite is done:
+ `Client` exists, is 152 bytes, and blocks nowhere. And `board9p` proved that
+ writing a second filesystem behind `Server(fs)` is a day's work, which is
+ exactly what a `host` tree would be.
+
+ But four things are now cheaper than step 6 AND serve the stated goals more
+ directly. Ranked by value over cost:
+
+ / A serial transport for the client: #[the board image exists and speaks 9P on UART0; `src/fs9_client.zig` speaks unix sockets. One transport away from the motivating case being real, and it is the smallest item here.]
+ / TCP: #[`fs9_service` and `fs9_client` are `AF_UNIX` only. "Integrating over the network" was one of the four stated goals and it is currently a socket family, not a design problem.]
+ / macOS and the browser: #[step 4's entire portability argument — that a 9P server needs no kernel — is UNEXERCISED. Nobody has run `9pfuse` against the socket on macOS, and the web build has no client. This is the payoff that justified the growth in `9P-20`, and it is closer to zero code than anything else on the list.]
+ / Aggregation: #[unblocked, see `9P-10`'s note. Serves "chaining a pardes to another", which is the goal `9P-22` named as the whole point.]
+
+ #q[Step 6 also changed SHAPE, and the note should be rewritten before it is built. It was sold as "the board stops being a shrunken pardes and becomes a terminal for a full core". What got built is the INVERSE: the board is a 9P server of its own devices and the desktop is the client. Both are useful and they are different images. Which one is step 6 — and is a board that shows a remote core's screen worth an image, now that a board that exposes its pins is already flashed?]
+
+ #note("scope", "2026-08-27")[
+ Unchanged by any of this: `9P-23`'s measurement. Local hosts keep the direct
+ vtable call, because a tree costs tty and gui about +204 lines each and the
+ web shell +142 Zig plus ≈500 JavaScript. Step 6 is remote displays only, and
+ the moment it is argued for the window in front of you, that number is the
+ answer.
+ ]
+]
+
+#entry("9P-27", "What three adversaries found in steps 3, 4 and 5", state: "landed", tags: ("bug", "lesson", "review"))[
+ Steps 1 and 2 were reviewed and the pass found a 100%-CPU spin in the smallest
+ change of the chain (`9P-24`). Steps 3, 4 and 5 — 1,005, 9,158 and 3,535 line
+ diffs — were verified on the happy path and shipped unreviewed. Three agents,
+ one per step, told to assume the happy path works and look elsewhere.
+
+ Eight defects. Six fixed, five reproduced with measurements before and after.
+
+ / Remote crash of the whole daemon: #[`Server.push` takes nothing once `startFrame` gives up on the framing, and `fs9_service.fill` asserted it took everything. One `size[4]` of zero plus one later byte reached `unreachable` — every pane, every frontend and the FUSE mount gone. The same stuck buffer separately made `room == 0` return without reading while poll reported ready forever: *99.7% of a core*, in one `write(2)`, from any process with the uid.]
+ / A 177-second freeze of the editor: #[`fs9_client.connect` ran `connect(2)` on a still-BLOCKING socket, before `setNonblock` and before the deadline existed. On AF_UNIX a full accept backlog waits in `unix_wait_for_peer` for `sk_sndtimeo`, which is forever, and the core is single-threaded. Measured at *177.3 s*, ending only because the peer was killed. Now 2.03 s, the budget.]
+ / A 64 KiB pty read wiping the queue: #[`queue_cap` is 65536 and a record must fit in `queue_cap - 4`, but every host reads a pty master with a 64 KiB buffer and a single read really returns 65536 on Linux. The eviction loop then emptied the queue and dropped the new record too, silently. Deterministic, not a race.]
+ / Four silent sockets denying `--fs9` forever: #[nothing took a slot back, and there are four. `version(5)` requires `Tversion` first and `msize == 0` already meant "has not versioned", so the frontend transport's own five-second rule applied unchanged.]
+ / EMFILE spinning a core: #[`accept` treated every failure as EAGAIN, but EMFILE leaves the connection in the backlog and poll is level triggered. *99.8% of one core.*]
+ / `max_fids = 32` refuting the step's own acceptance clause: #[a mounting client keeps a fid per cached inode, and `docs/9p.typ` §12.4 makes `9pfuse` the proof. At 32, `find` produced *57 consecutive `Rerror`s*. Now 256 for a host and `board_fids` 32 for the board.]
+
+ #verdict[
+ Three of the six are the SAME defect as `9P-24`: a descriptor left in a
+ level-triggered poll set with no error arm, or a wait with no deadline. That
+ is now four instances across five steps, every one of them a whole core or
+ a frozen editor, and every one written by someone who had just read the
+ previous one.
+
+ So make it a rule rather than a lesson: *every descriptor this project adds
+ to a poll set needs an error arm, and every wait needs a deadline set before
+ the thing it is waiting on can begin.* Both are checkable by reading, and
+ both were missed by authors who knew the rule.
+ ]
+
+ #note("method", "2026-08-27")[
+ Two observations about the reviewing, worth more than the bugs.
+
+ The instruction that worked was *"assume the happy path works — it has been
+ demonstrated live — and look everywhere else."* Every finding came from
+ somewhere the demonstration could not reach: a peer that stalls, a mount
+ that is torn down, a buffer at exactly its limit. The demonstrations were
+ all real and all passed, and none of them would ever have found any of this.
+
+ And the reviewers were told to *measure*. Every serious finding arrived with
+ `/proc/<pid>/stat` before and after, or a wall-clock figure, or a count of
+ consecutive errors. A report saying "this could spin" would have been argued
+ with; "998 jiffies per 10 s against a 0-jiffy baseline, here is the script"
+ could not be. The cost of asking for that was a reviewer that took forty
+ minutes instead of ten.
+ ]
+
+ #q[Two findings are NOT fixed and should be. A parked `pty/data` read whose client is gone keeps consuming the queue destructively — measured as 34% of a pane's output going nowhere on a long-running daemon, with the trigger not isolated in 23 attempts. And a `Tclunk` does not sweep park slots on the fid it retires, so a `close(2)` with a read in flight strands the tag forever; 32 of those and the connection can never serve a blocking read again. Both are reachable by ordinary client behaviour.]
+]