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 /docs/registry.typ | |
| 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 'docs/registry.typ')
| -rw-r--r-- | docs/registry.typ | 133 |
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.] +] |
