diff options
Diffstat (limited to 'docs/registry.typ')
| -rw-r--r-- | docs/registry.typ | 1209 |
1 files changed, 0 insertions, 1209 deletions
diff --git a/docs/registry.typ b/docs/registry.typ deleted file mode 100644 index 4d386f1c..00000000 --- a/docs/registry.typ +++ /dev/null @@ -1,1209 +0,0 @@ -// THE DESIGN REGISTRY — the one place where an idea, a refactor or an open -// question lives between "someone said it" and "the tree does it". -// -// WHY A FILE AND NOT AN ISSUE TRACKER. Every claim in here cites `file:line` -// against this tree, and a tracker cannot be grepped from an editor pane, does -// not diff, and is not there when the network is not. `docs/design.typ` says -// what pardes IS; this file says what is being argued about. When an argument -// settles, its conclusion moves into `design.typ` (or into the code) and the -// entry here becomes `landed` or `rejected` — a tombstone with the reasoning -// still attached, because the expensive part of a decision is the part that -// says why the other option lost. -// -// NO PACKAGES. `design.typ` pins cetz because diagrams are genuinely painful to -// hand-roll; this file has none, so it depends on nothing and builds offline -// forever: -// -// typst compile docs/registry.typ docs/registry.pdf -// -// HOW TO ADD TO IT. Append an `#entry`. Argue inside it with `#note`. Cite with -// `#ev`. Never delete a note — flip the entry's status and let the losing -// argument stand. The dashboard on page one is generated from the entries, so -// there is no index to keep in sync. - -#set page(paper: "a4", margin: (x: 2.2cm, y: 2cm), numbering: "1") -#set text(font: "New Computer Modern", size: 9.6pt) -#set par(justify: true, leading: 0.58em) -#set heading(numbering: none) -#show heading: set block(above: 1.4em, below: 0.7em) -#show heading.where(level: 1): set text(size: 12pt) -#show heading.where(level: 2): set text(size: 10.4pt) - -#show raw.where(block: true): it => block( - width: 100%, - fill: luma(246), - inset: (x: 0.5em, y: 0.45em), - radius: 1pt, - breakable: true, - text(size: 7.8pt, it), -) -#show raw.where(block: false): set text(size: 8.8pt) -#set table(stroke: 0.4pt, inset: 0.4em) - -// --------------------------------------------------------------------------- -// machinery -// --------------------------------------------------------------------------- - -/// The lifecycle. `open` is a question nobody has taken; `investigating` has an -/// agent or a human on it; `decided` has an answer but no code; `landed` and -/// `rejected` are terminal and keep their argument; `deferred` is "correct, but -/// not until X exists" and must say what X is; `blocked` waits on someone else. -#let states = ( - open: (rgb("#8a6d3b"), "OPEN"), - investigating: (rgb("#31708f"), "LOOKING"), - decided: (rgb("#2f6f4f"), "DECIDED"), - landed: (rgb("#3c763d"), "LANDED"), - rejected: (rgb("#a94442"), "REJECTED"), - deferred: (rgb("#6f5499"), "DEFERRED"), - blocked: (rgb("#777777"), "BLOCKED"), -) - -#let chip(state) = { - let (c, label) = states.at(state) - box( - fill: c, - inset: (x: 4.5pt, y: 2.2pt), - outset: (y: 1.5pt), - radius: 2pt, - text(size: 6.4pt, fill: white, weight: "bold", tracking: 0.4pt, label), - ) -} - -/// One argument. `id` is stable forever — notes and code comments cite it, so a -/// renumber is a lie in every file that referenced the old number. -#let entry(id, title, state: "open", tags: (), body) = { - [#metadata((id: id, title: title, state: state, tags: tags)) <reg>] - block(above: 1.5em, below: 0.5em, breakable: true)[ - #block( - width: 100%, - fill: luma(242), - inset: (x: 0.6em, y: 0.45em), - radius: 2pt, - stroke: (left: 2pt + states.at(state).at(0)), - )[ - #text(weight: "bold", size: 9.6pt)[#raw(id) #h(0.5em) #title] - #h(1fr) - #chip(state) - #if tags.len() > 0 [ - #linebreak() - #text(size: 7.4pt, fill: luma(90))[#tags.join(" · ")] - ] - ] - #block(inset: (x: 0.2em, top: 0.5em))[#body] - ] -} - -/// A comment. The thread IS the design discussion; keep them in time order and -/// never edit one in place — reply to it instead. -#let note(who, date, body) = block( - width: 100%, - inset: (left: 0.9em, y: 0.35em), - stroke: (left: 1.6pt + luma(205)), - { - text(size: 7.6pt, weight: "bold", fill: luma(70))[#who] - text(size: 7.6pt, fill: luma(140))[ · #date] - linebreak() - set text(size: 9.2pt) - body - }, -) - -/// Evidence. A claim with a path is a fact; a claim without one is an opinion, -/// and this makes the difference visible at a glance. -#let ev(where, body) = block( - width: 100%, - inset: (left: 0.9em, y: 0.25em), - { - text(size: 8.2pt, fill: rgb("#2f6f4f"))[▸ ] - raw(where) - text(size: 9.2pt)[ — #body] - }, -) - -/// A question with no owner yet. Rendered so it can be found by eye when -/// scanning for something to pick up. -#let q(body) = block( - width: 100%, - inset: (x: 0.7em, y: 0.4em), - fill: rgb("#fdf6e3"), - radius: 2pt, - { text(size: 7.6pt, weight: "bold", fill: rgb("#8a6d3b"))[OPEN QUESTION]; linebreak(); body }, -) - -/// What the entry concluded. One per entry at most, and only when the state is -/// `decided`, `landed` or `rejected`. -#let verdict(body) = block( - width: 100%, - inset: (x: 0.7em, y: 0.4em), - fill: rgb("#eef5ef"), - radius: 2pt, - stroke: 0.4pt + rgb("#2f6f4f"), - { text(size: 7.6pt, weight: "bold", fill: rgb("#2f6f4f"))[VERDICT]; linebreak(); body }, -) - -// --------------------------------------------------------------------------- - -#align(center)[ - #text(size: 15pt, weight: "bold")[The Design Registry] - #v(0.3em) - #text(size: 9pt, style: "italic")[open arguments, their evidence, and how they were settled] - #v(0.2em) - #text(size: 8.5pt)[#datetime.today().display("[year]-[month]-[day]")] -] - -#v(0.8em) - -*Historical decision record.* Entries retain their original source references -and states. For the current filesystem and transport interface, use -`docs/fs.md`; FUSE and the proof-of-concept examples are no longer supported. - -#context { - let es = query(<reg>).map(e => e.value) - let order = ("open", "investigating", "blocked", "decided", "deferred", "landed", "rejected") - let counts = order - .map(s => (s, es.filter(e => e.state == s).len())) - .filter(p => p.at(1) > 0) - .map(p => [#chip(p.at(0)) #text(size: 8.4pt)[#p.at(1)]]) - align(center, counts.join(h(0.9em))) - v(0.6em) - table( - columns: (auto, 1fr, auto), - align: (left + horizon, left + horizon, right + horizon), - table.header( - text(size: 8pt, weight: "bold")[ID], - text(size: 8pt, weight: "bold")[Argument], - text(size: 8pt, weight: "bold")[State], - ), - ..es - .sorted(key: e => order.position(s => s == e.state) * 1000) - .map(e => (raw(e.id), text(size: 8.8pt)[#e.title], chip(e.state))) - .flatten() - ) -} - -#pagebreak() - -= 9P - -Born from the draft note `docs/9p.typ`. The owner's instinct: replace FUSE with -9P, replace the private unix-socket wire with 9P too, and put a small 9P server -on the ESP32-P4 that pardes talks to as a client. The instinct is recorded here -entry by entry so it can be argued with in pieces rather than accepted or -rejected whole. - -#entry("9P-1", "Where does 9P go? Two layers, and 9P is the outward face of one of them", state: "decided", tags: ("architecture", "umbrella"))[ - The umbrella. The draft conflated two things that share a socket and nothing - else. - - #ev("src/acmefs.zig:63-78")[LAYER 1, the acme control tree: request/response, nine operations, and an ABI that already says "FUSE opcodes, 9P messages and a unit test all reduce to these".] - #ev("src/detached/wire.zig:187-232")[LAYER 2, the detached wire: 19 tags carrying RLE cell frames and input, server-push, N frontends on one screen. None of them is a file.] - - #verdict[ - 9P is a TRANSPORT FOR LAYER 1 and a CARRIER FOR LAYER 2 — never a - re-encoding of either. - - Layer 1 gets 9P as a second transport beside FUSE, because the ABI was - built for it and because FUSE cannot leave the machine (`9P-13`). - - Layer 2 keeps `encodeFrame` byte for byte. 9P may carry those bytes - (`9P-12`), but the moment a frame is re-expressed as a file's contents the - design has lost the thing that makes it fast, and there is a thirty-year - demonstration of both answers (`9P-12`, devdraw against `/dev/screen`). - ] - - #note("review", "2026-08-27")[ - The reason this is worth stating as its own entry: nearly every mistake in - the draft is one layer's property asserted about the other. "Offsets are - meaningless" is true of Layer 2 and false of Layer 1. "The protocol is - private and undocumented" is true of Layer 2 and false of Layer 1, which is - acme(4). "Adding a feature means adding a message" is true of Layer 2 — - measured at 7 lines per fact (`src/detached/wire.zig`, `set_clipboard` - occurring 8 times) — and false of Layer 1, where a feature is a file. - ] -] - -#entry("9P-2", "Make the transport seam explicit before writing any 9P", state: "decided", tags: ("refactor", "cheap"))[ - `fs_service` already touches its transport through exactly three methods. - Turning `*fuse.Fs` into a ctx+vtable at three call sites is a no-behaviour - change that makes a second transport possible without deciding anything else. - - #ev("src/fs_service.zig:158-200")[`drain`/`step` call only `retry()`, `next()` and `reply()`.] - #ev("src/tty/tty.zig:1216")[call site 1.] - #ev("src/gui/gui.zig:3738")[call site 2 — and `:3841` for the headless grid harness.] - - #verdict[Do it first, independently of every other entry. ≈40 lines changed, 0 added, `fuse.Fs` is the first implementor. If 9P is never built, this costs nothing and documents the seam.] -] - -#entry("9P-3", "Direct I/O is `qid.version = 0`, not `cache=none`", state: "decided", tags: ("protocol", "correction"))[ - Under FUSE the server asserts `FOPEN_DIRECT_IO` per open and the client - cannot argue. The draft assumed 9P gives this up and compensated with advice - ("mount with `cache=none`"). It does not have to: Linux's client disables - both read and write caching for any file whose qid version is zero. - - #ev("linux/fs/9p/fid.h:52-53")[`(fid->qid.version == 0) && !(s_flags & V9FS_IGNORE_QV)` sets `P9L_DIRECT` — "no read or write cache".] - #ev("linux/fs/9p/v9fs.h:82")[`CACHE_NONE = 0` is the default anyway.] - #ev("linux/fs/9p/v9fs.c:93")[`ignoreqv` is the only opt-out, and it is explicit.] - - #verdict[A synthetic tree reports `qid.vers = 0` on every file. Server-enforced, near-parity with `FOPEN_DIRECT_IO`, and the mount advice in the draft becomes unnecessary. Plan 9 needs nothing: its cache is opt-in via `mount -c`.] -] - -#entry("9P-4", "The error ABI: 9P2000 Rerror is a string", state: "open", tags: ("protocol", "ABI"))[ - The core answers with numbers. 9P2000 answers with prose, and the Linux - client turns prose back into a number by exact string match. A miss is not - `EIO`; it is 526, which userspace prints as "Unknown error 526". - - #ev("src/acmefs.zig:152-165")[`E.PERM..E.NOSYS`, nine numeric values.] - #ev("linux/net/9p/error.c:224-243")[`p9_errstr2errno` hash lookup; on a miss `errno = ESERVERFAULT`.] - #ev("principia-softwarica/editors/acme/xfid.c:19-24")[acme's own strings — `Ebadctl`, `Ebadaddr`, `Ebadevent` — are all misses in that table.] - - Three ways out, and they are not equally good. - - / A: #[Emit Linux `strerror` text verbatim so all nine round-trip. Cheap (≈20 lines), and makes English kernel strings this project's error ABI.] - / B: #[Serve 9P2000.u, whose `Rerror` carries a numeric errno alongside the string. Costs a second dialect in the codec; buys exact errnos and keeps a human-readable string for Plan 9 clients.] - / C: #[Emit acme's strings and accept 526 on Linux. Faithful to acme, hostile to `mount -t 9p`.] - - #q[Which? Note that B also answers `9P-5`'s `statfs` gap for free, and that the draft deferred `.u`/`.L` without noticing either.] - - #note("prior-art", "2026-08-27")[ - `ad` — a Rust acme-like editor that already ships this — chose option C - without noticing. It emits nineteen lowercase prose strings: `"unknown - fid"`, `"permission denied"`, `"file not open"`, `"exclusive file already - open"`, `"invalid offset for read on directory"` - (`~/05-genizah/ad/crates/ninep/src/sansio/server.rs:25-43`). *None* of - them is in Linux's table, so under `mount -t 9p` every single error `ad` - can produce arrives as 526. It also serves `SUPPORTED_VERSION = "9P2000"` - and nothing else (`:46`), so there is no `.u` escape hatch in place. - Real implementations fall into this; it is not a theoretical trap. - ] -] - -#entry("9P-5", "Directory reads need per-fid state the core does not have", state: "decided", tags: ("protocol", "cost"))[ - This is the real offset discontinuity, and the draft missed it while - inventing a false one about `body`. - - 9P requires a directory read at offset 0 or at exactly the byte offset where - the previous read ended, and the reply must contain whole `Dir` entries. - `acmefs` treats the offset as an *entry index* and re-stages the whole - listing each call, which is right for FUSE and wrong here. - - #ev("principia-softwarica/lib_networking/lib9p/srv.c:473")[a dir read whose offset is neither 0 nor `fid->diroffset` is answered `Ebadoffset`.] - #ev("u9fs/u9fs.c:60-64")[the reference server keeps `diroffset`, a cached `dirent` and `direof` per fid.] - #ev("src/acmefs.zig:972")[`var skip = req.off;` — an entry index.] - - #verdict[Transport-side, not core-side: the 9P transport keeps a per-fid byte cursor plus the one entry that did not fit, and calls the existing `readdir` with the entry index it has counted. `acmefs.zig` is untouched. Budget ≈40 lines.] - - #note("prior-art", "2026-08-27")[ - `ad` has no cookie at all: `read_dir` returns the entire `Vec<Stat>` on - every call and the server re-serialises all of it and byte-skips the offset - (`ninep/src/sync/server.rs:203`, `sansio/server.rs:377-401`). Its - `FidMeta` is `{qid, mode}` — no dir state (`:700-703`). So it is O(N) per - read, O(N²) per directory, and `E_INVALID_OFFSET` fires only when the - offset lands mid-entry (`:388-390`), which means an arbitrary offset on an - entry boundary is silently accepted and a changing directory tears. - The 40-line budget above buys correctness `ad` does not have. - ] - - #note("built", "2026-08-27")[ - Built, and the carry-over entry turned out to be unnecessary. u9fs needs one - because `readdir(3)` has already consumed the entry it could not fit; - `acmefs` re-stages the whole listing from an index on every call and says - why (`src/acmefs.zig:1013-1016`), so an entry that does not fit is simply - not counted and the next read asks for it by index. The server keeps a - per-fid PAIR — a byte cursor for the client's rule and an entry index for - the core's — advanced together. That removes ≈300 bytes per fid and a class - of staleness bug. The ≈40-line budget held. - ] -] - -#entry("9P-6", "Is `addr` per-fid or per-window?", state: "open", tags: ("semantics", "divergence"))[ - The draft claimed per-fid and attributed it to acme. acme does the opposite, - and so does pardes today. - - #ev("principia-softwarica/editors/acme/dat.h:239")[`Range addr;` is a field of `struct Window`; every use in `xfid.c` is `w->addr`.] - #ev("src/acmefs.zig:1027-1031")[pardes copied that, deliberately, and records why: there is no fid table because FUSE puts the nodeid on every request.] - - Per-fid genuinely is better — two scripts can address one window without - colliding — but it is a divergence from acme, not a restatement of it, and it - is the one place in the whole draft that asks the *core* to grow state. Under - FUSE there is no fid to hang it on at all. - - #q[Take the divergence and pay for it (per-fid `addr` lives in the 9P transport, and the FUSE transport keeps one per mount), or keep acme's race and document it?] - - #note("prior-art", "2026-08-27")[ - Third independent source against the draft: `ad`'s address is per-BUFFER, - keyed by buffer id (`Req::SetBufferAddr{id, addr}`, - `ad/src/fsys/message.rs:77-80`). acme per-window, pardes per-pane, `ad` - per-buffer. Nobody has ever shipped it per-fid. That is not proof it is - wrong — it is proof it is a proposal, and it should be argued as one. - ] -] - -#entry("9P-7", "One controller per window, or many?", state: "open", tags: ("semantics", "divergence"))[ - The draft says `event` is exclusive-use. acme does not do this, and pardes - currently allows any number of readers. - - #ev("principia-softwarica/editors/acme/xfid.c:603-609")[acme's exclusivity is an advisory `lock` ctl verb setting `w->ctlfid`, cleared on clunk.] - #ev("principia-softwarica/editors/acme/fsys.c:537-563")[`fsysopen` checks permission bits only; a second `event` reader is not refused.] - #ev("src/acmefs.zig")[`pf.readers +|= 1` on open — a count, and the count is what suppresses button actions.] - - `DMEXCL` would make a second open fail with `EAGAIN` under Linux. That is a - tightening with a real cost: two cooperating scripts on one window stop - working, and nothing in `examples/acmefs/` was written expecting it. - - #q[Leave it a count (status quo, acme-compatible), or make it exclusive and lose multi-reader?] - - #note("prior-art", "2026-08-27")[ - `ad` DOES do what the draft describes: `event` is `FileType::EXCLUSIVE`, - i.e. QTEXCL (`ninep/src/sansio/protocol.rs:503`, added in commit - `678fbf5`). Note how it is scoped, though — enforcement is per *ClientId*, - not per fid (`sansio/server.rs:762-764`), so one client may hold two fids - on `event` and two clients may not share one window. That is a third - position between "a count" and "one fid", and it is probably the right one: - it stops two unrelated scripts fighting without breaking a script that - opens the file twice. - ] -] - -#entry("9P-8", "`pty/` files — the best idea in the draft, and unrelated to 9P", state: "open", tags: ("feature", "transport-independent"))[ - A script today can write a terminal pane's `body` (it becomes a pty write) - and read its rendered scrollback. It cannot spawn a terminal, resize one, or - signal one. The draft's `pty/{data,ctl,status}` fixes that, and none of it - needs 9P — it is `ctl` verbs and two new `PaneFile` variants. - - #ev("src/host.zig:80-82")[`push_spawn` and `push_pty_resize` already exist as effects, so `exec` and `winsize` are two existing effects with a name.] - #ev("src/acmefs.zig")[the `Verb` table has no pty verb; `PaneFile` has no pty entry.] - - `sig INT` is the only genuinely new capability — there is no `kill` anywhere - in `host_io.zig`. - - #verdict[Not blocked on anything. Build it in the FUSE tree now; a 9P transport inherits it for free. Sequencing it *after* 9P would be putting the transport before the feature.] - - #note("prior-art", "2026-08-27")[ - Worth knowing before building it: there is *no prior art anywhere*. acme - has no pty files; `ad` has none either — its tree is - `{ctl, minibuffer, scratch, log, buffers/…}` and contains no terminal - surface at all (`ad/src/fsys/mod.rs:17-32`). So `pty/` is a genuinely new - interface, which cuts both ways: nobody has made these mistakes for us, and - nobody's scripts already expect a particular spelling. Being first is a - reason to keep it small — `data`, `ctl`, `status`, and no more. - ] -] - -#entry("9P-9", "Naming sessions: `aname` or a top-level directory", state: "open", tags: ("protocol", "naming"))[ - Verified: `aname` works, and the draft's mount line is valid verbatim. - - #ev("linux/fs/9p/v9fs.c:72-90")[`aname` parses to `Opt_remotename`; `version=9p2000` selects `p9_proto_legacy`.] - #ev("linux/fs/9p/vfs_super.c:340")[`port` defaults to 564, so the draft's command line needs no `port=`.] - #ev("u9fs/u9fs.c:409-420")[u9fs uses `aname` to pick a tree, so there is precedent for exactly this use.] - - Against it: pardes has no multi-session concept in the core at all today — - `--detach=work` names a *socket*, not a tree. A top-level directory needs no - protocol feature and works with clients that ignore `aname`. - - #q[Is this question even live before `9P-12` settles? A per-session socket already names a session; `aname` matters only if one listener serves many.] - - #note("prior-art", "2026-08-27")[ - `ad` does not use `aname` either: `Server::new` installs a single anonymous - root `""` (`ninep/src/sansio/server.rs:91-95`), and multi-session is the - SOCKET name, `ad-<pid>`, with discovery through a `list_open_sessions` - helper (`ad/src/fsys/mod.rs:158-159`). That is exactly pardes's - `--detach=work` shape. Two implementations independently reaching for a - named socket over `aname` is evidence about which one people actually - build. - ] -] - -#entry("9P-10", "Aggregation: the prefix router", state: "deferred", tags: ("architecture", "premature"))[ - The draft's longest technical section, and its own §11 concludes the - aggregate is not worth building before a second machine exists. That verdict - is correct and also covers the client half. - - What the section understates: a proxied `Twalk` cannot be answered until the - remote `Rwalk` arrives, so "the fid table maps our fid to a pair" is not the - entire proxy — it needs per-tag continuations, remote↔local tag remapping, - `Tflush` forwarding and fid invalidation on connection death. - - #ev("src/detached/wire.zig:64-70")[a round trip inside `update` is the one thing the transport must never do.] - #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"))[ - The owner's motivating case, and the entry that changed the most under - measurement. Both the draft and the first review were wrong about it, in - opposite directions. - - The draft was wrong about what the board IS: it describes a machine running - "a 9P server and nothing else", and today the P4 runs the whole editor - (`src/esp32p4.zig:264-300`, 2 of 21 vtable methods at `:930-934`). - - The review was wrong about what the board CAN AFFORD, because it quoted a - stale number. - - #ev("src/esp32p4.zig:470-473")[the "9,128 bytes free at 80×24" comment predates `direct_emit` (`:870`), which sized vaxis's two shadow grids to one cell.] - #ev("05-zig-p4/experiments/report.typ:848-849,877-880")[measured after that change: "the heap now reports 336 KB free at every geometry tried, including ones that used to fail outright… the heap has 336 KB spare while `.bss` runs out". The binding resource is the 240 KiB low L2MEM, not the 384 KiB heap.] - - A 9P server's RAM, costed from real components: two msize buffers at 4,096 - (u9fs uses three — `rxbuf`, `txbuf`, `databuf`, `u9fs.c:1814-1816` — a - minimal server needs two) = 8,192 B; a FIXED-ARRAY fid table of 32 entries × - 16 B (`fid`, `qid.path`, `mode`, `diroffset`) = 512 B; codec scratch with - `P9_ERRMAX` = 128 B. *Total 8,832 B, or 2.5% of free heap.* - - #ev("src/esp32p4/input_rescue.zig:52")[and a 4,096-byte reassembly buffer already exists on this exact UART, measured: "4,096 bytes in a single write arrive intact, and past that the loss is counted rather than silent".] - #ev("linux/net/9p/client.c:840-843,908-910")[the 4,096 floor is imposed by the LINUX KERNEL and by nothing else. Plan 9's devmnt, plan9port's `9p` and pardes's own client accept a 512-byte msize, which halves the buffers to 1 KiB.] - - Flash: the editor image is 809,536 B of a 1,536,000 B partition - (`report.typ:375-376`), leaving 726,464 B. A 9P-only image is ≈23,870 B of - platform plus ≈15 KiB of server ≈ *39 KiB, 2.5% of the partition*. - - #ev("build.zig:1182-1235")[a second board image is ALREADY expressible: `esp32p4-test` builds its own executable, `ImageStep`, `FlashStep` and run step in 54 lines, and deliberately links no pardes object. That is the shape.] - #ev("src/acmefs.zig")[and the semantics layer already compiles for riscv32: `llvm-nm` finds 21,548 B across 17 `acmefs.*` symbols in `zig-out/pardes-esp32p4.o`.] - - #verdict[ - Buildable, and much cheaper than anyone assumed. But it is a SECOND - FIRMWARE IMAGE, not a second role for this one: the editor owns UART0 - bidirectionally (`esp32p4.zig:611`, `uart.zig:43-44,122-130`) and JP1 - exposes no second P4 UART (`board9p.Header`). The board is either - an editor or a filesystem at any one time. Say that plainly rather than - implying both. - ] - - #note("review", "2026-08-27")[ - The reframing the draft misses: the board already exposes `Peek`, `Poke`, - `Hexdump` and `Gpio` as acme words (`src/builtins.zig`, `Board` namespace). - All four cap at 4,096 bytes - per command and the cap's stated reason is the 115200 console. So the whole - 2³² address space is already reachable — by *typing a word into a tag*, - with the answer landing in an output pane. Nothing is machine-readable and - nothing is remote. A 9P tree is that same capability with names instead of - verbs, and `mem/`, `gpio/pinout` and `prof` are backed by functions that - exist today (`builtins.Board.readWord`, `builtins.Board.gpio`, - `pardes_esp32p4_frame_prof` at `esp32p4.zig:985`, already exported). - Four more files need one new C-ABI extern each; four have no - implementation at all. That inventory belongs in the note, not a wishlist. - ] - - #q[Should the parked second RISC-V core own the 9P server? `report.typ:552-563` says it needs four register writes plus a trampoline, shares one L1 D-cache so a lock-free ring needs only fences, and that giving core 1 the UART "eliminates the silent input loss". That is the one arrangement where the board serves 9P *and* keeps the editor. Uncosted.] - - #note("built", "2026-08-27")[ - *The RAM figure above is wrong and the built one is 21,776 B, not 8,832.* - Measured from the real structs: `Fid` is 64 B × 32 = 2,048, `Slot` is 208 B - × 32 = 6,656, and the whole `Server` is 9,488 B before buffers; a 4,096 - msize adds `in` 4,096 + `out` 8,192. - - Three reasons, all of them things the estimate did not know. `out` is TWO - msize — one message being written, one being built — which is what makes - every reply infallible and removes "can I write yet" from the whole file. A - fid entry is 64 B and not 16, because `Rstat` carries a NAME that a node id - does not, plus the open handle and the two-coordinate cursor. And the - estimate did not cost the park table at all, which is 6,656 B of the total. - - It still fits with room: 6.5% of the board's ≈336 KB free heap, and about - 11 KB in total at the 512-byte msize Plan 9 accepts. A test bounds `Fid` and - `Slot` so that a change to either shows up as a diff in the board's budget - rather than as a surprise on the die. - ] -] - -#entry("BOARD-1", "Raise UART0 to 921600 before quoting any board latency", state: "decided", tags: ("board", "cheap", "prerequisite"))[ - Every board 9P latency figure is eight times worse than it needs to be, for - no reason but that the bootloader left the divider alone. - - #ev("src/esp32p4/uart.zig:35-38")[the firmware never programs the divider; 115200 is inherited.] - #ev("05-zig-p4/src/hal/uart.zig:321-333")[`setBaudrate` and `divider` already exist and `reset()`'s refusal of instance 0 does not apply to them.] - #ev("05-zig-p4/experiments/report.typ:540-544")[921600 is one `UART_CLKDIV_SYNC` write on the existing 40 MHz XTAL — int 43, frag 6, +0.064% error. 2 Mbaud is representable but this CH340 is unreliable there, corroborated by the flasher at `build.zig:1136-1138`.] - - #verdict[ - One register write, 86.8 µs → 10.85 µs per byte. A warm - `cat /mnt/board/gpio/2/value` goes from 10.8 ms to 1.35 ms; a 4 KiB `Tread` - from 358 ms to 45 ms. Do it first, and re-measure everything after. - The hazard the same files record: writing the console UART's divider is - adjacent to what bricks the board, and the host must be reopened in step. - ] -] -#entry("9P-12", "Should 9P replace the private wire protocol? Half of it, and not the half you would guess", state: "decided", tags: ("architecture", "the-real-question"))[ - Six layerings were costed. The finding that settles it is that layering is - not a cost decision at all. - - #ev("src/detached/wire.zig")[1705 lines = 1092 code + 613 tests, of which only 303 are FRAME and 789 are TRANSPORT: bounds, tags, message structs, framing, codecs.] - #ev("src/detached/wire.zig:1401,1412")[a 56×14 full frame is 6,298 B; a one-keystroke diff is 56 B. Ratio over 100.] - #ev("src/detached/server.zig:1102-1110")[skip-slow is three lines: `if (c.out.items.len != 0) continue;` — skip the frame and leave the mirror alone, so the next frame diffs against what the client really has.] - - A base-9P2000 codec (≈450), a dispatcher onto `acmefs.Op` (≈400) and a fid - table (≈120) come to ≈970 lines that are IDENTICAL in all six layerings. - Layering moves ±150. So the question is reach and semantics, not lines. - - *Does 9P subsume the wire's job?* The transport half, yes, with shipped - precedent: clipboard becomes `snarf` (`rio/fsys.c:61`), input becomes - `mouse`/`cons` — and rio's `mouse` is exclusive-open with a blocking read and - a flush (`rio/xfid.c:167-173,638-660`), which is the `event` idiom exactly. - The frame half, no: 9P has no server-initiated message. - - *Is "the frame is a file" a category error?* Only if you mean push. Two - shipped counter-forms exist and they disagree with each other, which is the - most useful thing in this entry: - - #ev("plan9/rio/fsys.c:53, lens.c:281-282")[`/dev/screen` is an offset-addressed raster you POLL — `lens` seeks to a scanline and repaints on mouse events because it never learns the screen changed. Real, and precisely why nobody runs a remote rio through it.] - #ev("drawterm-9front/kern/devdraw.c:1574, include/draw.h:55")[`/dev/draw/N/data` is a PRIVATE BINARY COMMAND STREAM batched into 8,000-byte buffers and flushed as one `Twrite`. Real, and how remote display has actually shipped for thirty years.] - #ev("drawterm-9front/cpu.c:149,191,251,359")[and it inverts direction: the DISPLAY exports its devices and the APPLICATION mounts them. The frame travels as a client's `Twrite`, never as a server's push.] - - pardes's codec is already devdraw's shape. `wire.zig`'s 5-byte length prefix - (`:145`, `framed()` `:564`) makes an `encodeFrame` message a legal 9P payload - with zero new framing. - - #verdict[ - *Superseded in part by `9P-19`.* The reasoning below stands; the conclusion - moved. O3's tunnel was chosen because it was the only option that reached - the board without sockets. `9P-19` shows the tunnel is unnecessary: the - link simply carries 9P, with the FRONTEND as the server and the core as the - client, and the frame travels as the client's `Twrite` to `screen`. That is - O1 done the way it has actually shipped for thirty years, and it dissolves - the objection recorded here. - - What survives unchanged: *O4* (wire inside 9P) costs +34 B per frame, +61% - on a 56-byte diff. *O5* (a standalone listener) remains correct and is - independently worth building — it is the scripting face and it composes - with anything. - - What was wrongly rejected: *O6*. "The frontend serves, the core is a - client" was rejected here for making the core write and match tags inside - `update` and for foreclosing the freestanding targets. Both are wrong. The - core need not wait for `Rwrite` — 9P permits many outstanding tags - (`linux/net/9p/client.c:194-199`, `plan9/devmnt.c:783-800`) with no - in-order reply requirement — and the freestanding targets have a byte - stream even though they have no sockets, which is all 9P asks for. - O6 is the destination. See `9P-19`. - ] - - #note("review", "2026-08-27")[ - One honest caveat against O3: `exportfs` carries 9P and nothing else — its - framing is 9P's own 4-byte size prefix (`exportfs.c:434,476`) — so - multiplexing a second protocol beside 9P on one link has *no* Plan 9 - precedent. It is not hard, but we would be first, and "the transport - becomes someone else's problem" stops being true for that link. - ] -] - -#entry("9P-13", "9P cannot replace FUSE on Linux; it can only join it", state: "decided", tags: ("portability", "privilege", "decisive"))[ - The strongest argument for 9P was that it needs no kernel and no privileged - mount helper: 281 non-test lines of `fuse.zig` exist only to obtain a mount - an unprivileged user is not allowed to make. - - #ev("src/fuse.zig:497-703")[207 lines: `_FUSE_COMMFD`, socketpair, `CMSG_*`/`SCM_RIGHTS`, fork/execve/waitpid of the setuid `fusermount3`, environ rebuild.] - #ev("src/fuse.zig:752-773")[22 more for the helper's unmount, `:933-978` 46 for the mount call, `:1718-1723` 6 for `clearCloexec`. 281 total, measured.] - #ev("src/fuse.zig:64")[and all of it is Linux-only, so macOS (14/21 vtable) and web (6/21) have no control filesystem at all.] - - That argument is dead. The kernel grades mount privilege by a per-filesystem - flag, and the two filesystems are on opposite sides of it. - - #ev("linux/fs/super.c:694-700")[`mount_capable`: without `FS_USERNS_MOUNT` the check is `capable(CAP_SYS_ADMIN)` — and `capable()` is `ns_capable(&init_user_ns, …)`, i.e. root in the INITIAL namespace, not the caller's.] - #ev("linux/fs/9p/vfs_super.c:362")[`.fs_flags = FS_RENAME_DOES_D_MOVE` — v9fs does not set `FS_USERNS_MOUNT`.] - #ev("linux/fs/fuse/inode.c:2004")[`.fs_flags = FS_HAS_SUBTYPE | FS_USERNS_MOUNT | FS_ALLOW_IDMAP` — FUSE does.] - - So `mount -t 9p` costs real root, unconditionally: a fresh - `unshare(CLONE_NEWUSER|CLONE_NEWNS)` does not help, because `mount_capable` - falls back to the init namespace for exactly the filesystems that lack the - flag. `trans=fd` does not help either — it lets an unprivileged process own - the connected socket, but `mount(2)` is checked before the transport is ever - consulted (`linux/fs/namespace.c:3838`). And `9pfuse`, the usual escape, - is FUSE: it needs `fusermount` in `PATH` and reimports the whole setuid dance - in someone else's process. - - #verdict[ - The 281 lines are not overhead. They buy an *unprivileged pathname*, which - is a capability 9P has no way to provide on Linux. `fuse.zig` stays. - - This does not weaken 9P; it relocates it. The two are complementary faces - of one `acmefs.handle`: FUSE is the PATHNAME face — local, unprivileged, - Linux, for `grep` and `make`. 9P is the NETWORK AND IPC face — remote, - unprivileged, every platform, for pardes's own client, for the board, for - the daemon, and for anyone with root who wants `mount -t 9p`. - - Everything downstream follows from this split, and `9P-2` is what makes it - cost nothing. - ] - - #note("review", "2026-08-27")[ - Worth being precise about what survived. The draft's §6 has two halves and - only one of them died. "The client half needs no mount… no kernel - involvement, no privilege" is TRUE and remains the best paragraph in the - document. "The mount is for other people's programs… and it is sufficient" - is where the root requirement lands, and it is not sufficient — on Linux - that mount is strictly more privileged than the one we already have. - ] -] - -#pagebreak() - -= Corrections - -Small, certain, and independent of every argument above. - -#entry("FIX-1", "`acmefs.zig` is wrong about 9P truncation", state: "decided", tags: ("comment", "one-line"))[ - The comment justifying `setattr` says 9P has no truncate-on-open and that - nothing in acme answers a `Twstat` carrying a length. Both halves are wrong. - - #ev("src/acmefs.zig:1097-1100")[the claim.] - #ev("u9fs/plan9.h:150")[`#define OTRUNC 16` — base 9P2000, used at `u9fs.c:1578` and `:1682`.] - #ev("u9fs/u9fs.c:1047")[`Twstat` with a length calls `truncate(2)`; that is how 9P truncates.] - - #verdict[Rewrite the comment. The real reason `setattr` exists is that Linux strips `O_TRUNC` from the OPEN when `FUSE_ATOMIC_O_TRUNC` is not negotiated, which is a FUSE fact and stands on its own without the false claim about 9P. Under a 9P transport, `> body` arrives as `Topen` with `OTRUNC` or as `Twstat`, and maps onto the same `Req.truncate`.] -] - -#entry("DOC-1", "`design.typ` says \"No 9P\"", state: "open", tags: ("docs", "consistency"))[ - The project has already recorded a decision against this, under *What is - deliberately absent*, and the draft neither cites nor rebuts it. - - #ev("docs/design.typ:1969-1971")[«No 9P. The library boundary is exactly where acme put the file server, and the FUSE mount is already that server with a Linux transport instead of a 9P one; a sixth shell could serve `Surface` and `Event` over 9P without touching the core.»] - - Read closely, that paragraph is not hostile — it says *not built*, and its - second clause proposes something stronger than the draft does: serving - `Surface` and `Event`, i.e. `9P-12`'s option 1, which the project apparently - considered plausible enough to write down. - - #q[When `9P-1` settles, this paragraph is rewritten rather than deleted — it should say which layer 9P occupies and why the other one was left alone.] -] - -#entry("9P-14", "Do the daemon's filesystem first, with 16 lines and no 9P", state: "decided", tags: ("sequencing", "cheap", "do-first"))[ - The strongest *stated* motive for putting 9P in the daemon is that a - detached session has no control filesystem. That is true, and it is not a - protocol problem. - - #ev("src/detached/server.zig:563-570")[«no `push_fs_reply`, because this process mounted no /dev/fuse». The daemon simply never calls `fs_service.start`.] - - The fix, counted: a `Source.fuse` variant (1 line), an `fs` field (1), - `fs_service.start` (1), `fsReply` copied verbatim from `tty.zig:1455-1458` - (4), a vtable entry (1), a pollfd and dispatch arm (6), `drain` (2). - *≈16 lines.* And it is *better* there than on the desktop hosts: the - daemon's own `poll(2)` covers `/dev/fuse` directly, so it needs no wake - thread at all (`src/fs_service.zig:127-131`). - - #verdict[ - Build this before deciding anything else. It costs sixteen lines, it - delivers the feature people actually want, and it removes a motive from the - 9P argument so that argument is decided on its merits. If it turns out to - be all anyone needed, that is a good outcome, not a wasted one. - ] -] - -#entry("9P-15", "The honest cost of a 9P stack is ≈1,600-1,900 lines, not ≈770", state: "decided", tags: ("cost", "estimate"))[ - Two independent implementations were measured rather than guessed, and they - agree closely. - - #ev("~/05-genizah/ad/crates/ninep")[12,296 lines total; the irreducible 9P2000 SERVER core is 2,321 non-blank non-comment: codec 704, `Stat`/`Perm`/`Mode`/`WStat` 437, session+fid+flush 615, loop+handlers 474. Fid table alone is 57 lines; the server loop 141; the flush path ≈58; directory reads 50; tests 3,669, i.e. 31%.] - #ev("~/05-genizah/u9fs/u9fs.c")[1,838 lines — NOT the 6,149 the first review quoted, which counted a whole plan9-libc substitute. Its codec, `convM2S` + `convS2M`, is 805.] - - Codec ≈700-800 and server logic ≈1,500-1,900, from both. The earlier - ≈770-line figure was the size of one component. - - #verdict[ - Budget ≈1,600-1,900 core lines plus ≈500 of tests, and say so out loud, - because it *breaks the house rule* (`docs/design.typ:1957-1960`: a change - leaves the file it touches no longer than it found it) unless something is - retired in exchange. `9P-13` says `fuse.zig` cannot be the thing retired. - So this is net growth, and it has to be justified by reach — the board, the - network, macOS, web — not by simplification. - ] - - #note("review", "2026-08-27")[ - Two mitigations that are real. First, ≈970 of those lines are identical in - every layering (`9P-12`), so none of it is at risk from the layering - decision. Second, 296 of ninep's 704 codec lines are macro-generated - (`protocol.rs:809-1154`); Zig `comptime` over an exhaustive message enum - should compress that at least as well, and the message set is the one part - of 9P that never changes. Against that: `ad` needed `UnsafeCell` plus - `unsafe impl Send/Sync` to get zero-allocation parsing - (`protocol.rs:67-76`), which Zig gives for free — so the *hard* part of - their codec is not a cost we inherit. - ] -] - -#entry("9P-16", "Tflush is a park-table lookup, and this is where we beat the prior art", state: "decided", tags: ("protocol", "advantage"))[ - The draft says `Tflush` is the most common omission in hand-written 9P - servers. It understates the problem: the common failure is having it and - still hanging. - - #ev("~/05-genizah/ad/crates/ninep/src/sansio/server.rs:767-819")[`ad` gets the ORDERING right in 39 lines — an Rflush for an in-flight tag is chained and sent only after the original replies, matching `lib9p/srv.c:241-266`.] - #ev("~/05-genizah/ad/crates/ninep/src/sync/server.rs:163-164")[and then `Serve9p::flush` defaults to a no-op, which `ad` never overrides. A client flushing a blocked `event` read gets no Rflush until an unrelated editor event happens to arrive. Interrupts and unmounts hang exactly as the draft warns, *despite* Tflush being "implemented".] - - #verdict[ - pardes is structurally better placed than either reference. The 32-slot - park table (`src/fuse.zig:789`) is already keyed per outstanding request, so - `Tflush` is a lookup, a reply to the original, and a reply to the flush — - the same path `FUSE_INTERRUPT` already takes, which is tested - (`src/fuse.zig`: "interrupt answers the original with EINTR and drops it"). - Implement it against the table, not against a filesystem callback, and the - class of bug `ad` shipped is unrepresentable. - ] -] - -#entry("9P-17", "Clamp Rread to the client's count, or Linux hard-fails the read", state: "decided", tags: ("protocol", "correctness", "trap"))[ - A trap with no analogue in FUSE, which is why nobody looks for it. - - #ev("linux/net/9p/client.c:1475-1479")[`if (rsize < received) { pr_err("bogus RREAD count"); *err = -EIO; }` — an Rread longer than the `count` asked for is a HARD ERROR, not a truncation.] - #ev("~/05-genizah/ad/crates/ninep/src/fsys/event.rs:116-122")[`ad` clamps replies to `msize` (`protocol.rs:1011-1018`) but never to `count`, and its `event` file concatenates every drained event and returns the lot. Under a kernel mount that is `-EIO`.] - - #verdict[ - Every reply path clamps to `min(count, msize - 11)`. This interacts with a - decision already made the other way: `acmefs` refuses a read smaller than - one event record with `EINVAL`, on the grounds that half a record silently - desynchronises a client (`docs/acme-fs.md`). That rule stays and is now - load-bearing for a second reason — it is what makes "clamp to count" - safe on `event`, because a record is either whole or refused. - ] -] - -#entry("9P-18", "The spec-bug checklist, taken from someone else's git history", state: "open", tags: ("testing", "gift"))[ - `ad` shipped and then fixed each of these. They are the test list for - `src/ninep.zig`, in roughly the order they will bite. - - #ev("~/05-genizah/ad/crates/ninep/src/sansio/server.rs:335-338")[the scar they left in the source: «Spec: first element failure must be Rerror, not Rwalk with zero qids».] - - Partial walks (`09334ce`); `Tcreate` of `".."` (`bcb05d9`); write-then-remove - (`1c8ae96`); create permission masking (`3ccc461`); open with truncate - (`f0c71be`); remove-on-close (`6471cd3`); the root qid's name being `"/"` - (`64f2f4b`); the default `aname` (`b3a2c8b`, `ce7ebc6`); exclusive files - (`135190c`); clearing the fid cache on clunk (`113b053`); fid open state - (`22041ab`); flush tracking (`7170e6a`, `5a6b5bf`). - - #q[Most of these are `Tcreate`/`Tremove` bugs, and a synthetic tree could simply answer `Rerror` to both — the draft's §9 already argues our trees are invented and have no cases we did not choose. Does `new/` need real `Tcreate`, or is walk-to-create enough as it is under FUSE?] - - #note("built", "2026-08-27")[ - *Answered, and most of the list is moot.* `new/` creates a pane on WALK - (`src/acmefs.zig:901-919`), so the capability exists and is not spelled - `Tcreate`. The server answers `Rerror` to both `Tcreate` and `Tremove`, - which takes the create-permission-masking, create-`..`, remove-on-close and - write-then-remove bugs off the table entirely — four of `ad`'s twelve. - `Tremove` still clunks the fid first, because remove(5) says the fid is - invalid even if the remove fails. - - Of the rest, the partial-walk rule and the flush tracking are implemented - and tested by name; the root qid's name is `"/"`; the fid cache is cleared - on clunk and the release the core is owed is issued there. - ] -] - -#entry("FIX-2", "The board's free-heap comment is two refactors out of date", state: "decided", tags: ("comment", "board", "one-line"))[ - #ev("src/esp32p4.zig:470-473")[says 9,128 bytes free at 80×24. That was measured before `direct_emit` (`:870`) sized vaxis's two shadow grids to one cell.] - #ev("05-zig-p4/experiments/report.typ:848-849,877-880")[the heap now reports ≈336 KB free, and `.bss` in the 240 KiB low L2MEM is the binding constraint instead.] - - #verdict[ - Fix the comment and name the new constraint, because the stale number was - load-bearing in an architecture review — it is the reason a 9P server on - the board looked impossible when it costs 2.5% of free heap. A stale - measurement in a comment is worse than none: it gets quoted. - ] - - #q[The `report.typ` figure of "336 KB at every geometry tried" cannot be literally true across the 62,480-byte swing between 56×14 and 80×24 at 55 B/cell. One boot and one `MARK PARDES_HEAP` line closes it.] -] - -#pagebreak() - -= Direction - -The four entries below were written after the owner rejected the first -synthesis as directionless and stated the goal himself: *"make it overall -simpler, and more transparent to scripting and integrating over the network, -and that could unlock some interesting things like chaining a pardes to -another — right now these kinds of things are complex and ad hoc."* - -Three of those four are achievable. One is not, and `9P-20` says which. - -#entry("9P-19", "Bidirectional 9P is one role per CONNECTION, not one role per message", state: "decided", tags: ("protocol", "mechanism", "correction"))[ - The owner's objection to the first review: *"you said the server can't - initiate a message, but since the daemon and the app both could have a client - and a server, they could communicate bidirectionally."* He is right, and the - mechanism is better than either of us described. - - The tempting reading is a double-role link: both ends run a server and a - client on one descriptor, demuxing on the message-type parity, which 9P makes - possible for free. - - #ev("u9fs/fcall.h")[`Tversion = 100, Rversion, Tauth = 102, Rauth, …` — T is even, R is odd, so a receiver can always tell a request it must serve from a reply it is owed.] - - That reading is available and *nobody has ever built it*. - - #ev("drawterm-9front/exportfs/io.c:132-163")[`io()` is a pure server loop: read a T-message, dispatch through `fcalls[type]`, reply. It never emits a message that is not a reply. Plan 9's `exportfs` is the same (`exportfs.c:510-515`), u9fs is the same, and v9fs and `devmnt` are pure clients. No implementation anywhere demuxes two roles on one descriptor.] - - What ships instead is better, because it needs no invention at all: - - #ev("drawterm-9front/cpu.c:119-192")[drawterm DIALS OUT to the cpu server, writes a shell script, and then calls `exportfs(fd, fd)` — *the dialer becomes the server on the socket it dialed*.] - #ev("principia-softwarica/networking/misc/cpu.c:448,459-463")[the script the remote runs is `mount -nc /fd/0 /mnt/term`, then it opens `/mnt/term/dev/cons` as its stdin and stdout. The remote is the CLIENT.] - #ev("drawterm-9front/kern/devdraw.c:1323-1326")[so when the remote application draws, the bytes cross as *the application's `Twrite`* to `/dev/draw/N/data`, and the terminal — the machine with the screen — executes them as a server.] - - One descriptor, roles fixed per side, data flowing both ways. There is no - server push because none is needed: *the frame producer is the client.* - - #verdict[ - The rule for pardes, and it is the whole architecture in three lines: - - / display: #[the FRONTEND is the server (it owns the screen), the CORE is the client. The core `Twrite`s frames to `screen` and blocking-`Tread`s `input`. This is `cpu` verbatim.] - / session: #[the CORE is the server (it owns the windows), scripts and other instances are clients.] - / devices: #[the BOARD is the server (it owns the memory and the pads), the core is the client.] - - Three connections, one role each, and the program contains both halves. Do - NOT build a double-role single-descriptor link: no precedent, a `Tversion` - ordering hazard where each side must answer the peer's version while - awaiting its own, and `trans=fd` wants separate read and write descriptors - anyway. - ] - - #note("mechanism", "2026-08-27")[ - Two numbers that make the display path credible, both of which the first - review got wrong by assuming a push. Chunking is a non-issue: payload per - message is `msize − P9_IOHDRSZ` = 131,072 B at Linux's default - (`client.h:23`, `9p.h:364`), so every real frame — 20 B, a 56 B keystroke - diff, a 6,298 B full board frame, a 27 KB desktop frame — is ONE `Twrite`, - and even the 1,703,936 B worst case is 13. And the core need not block: - tags are allocated per outstanding request with no in-order reply - requirement (`client.c:194-199`, `devmnt.c:783-800`, and `exportfs` - deliberately answers out of order via slave procs, `exportsrv.c:408-520`). - pardes needs no slave procs — `Status.again` plus the park table is the - same thing done single-threaded. - ] - - #note("mechanism", "2026-08-27")[ - Copy the file discipline rather than inventing one. `/dev/draw/N/data` is a - write-only batched binary command stream (`devdraw.c:1323-1326`) with an - exclusive `ctl` (`:1047-1051`); `/dev/mouse` is an exclusive single-reader - file whose read blocks until something happens (`devmouse.c:96-98,150-158`). - That is exactly `screen` and `input`, already designed, already debugged, - and already the shape `event` has in `acmefs`. - ] -] - -#entry("9P-20", "\"Simpler\" is false in lines and true in concepts — argue reach instead", state: "decided", tags: ("cost", "honesty", "framing"))[ - The owner's first criterion was that 9P make the codebase *overall simpler*. - Measured, it does not. This entry exists so that nobody argues otherwise - later, including us. - - The baseline: *nine* IPC mechanisms, *four* framings, *three* discovery - schemes, *two* version-negotiation schemes and one mechanism with none. - - #ev("src/nested.zig")[743 lines to deliver ONE line. By region: 255 transport, 158 ancestor discovery, 43 `sendLook`, 232 tests, 30 header — and the FEATURE is ≈12 lines (`:301-306` format, `:507` filter). Ratio feature-to-transport ≈ 1:24.] - #ev("src/detached/wire.zig")[contains ZERO syscalls — no `socket`, `bind`, `accept`, `poll`, `read` or `write` anywhere. It is a pure codec into caller buffers, so calling 789 of its lines "transport" was wrong: they are the definition of what may be said. 9P replaces its 5-byte length prefix with a 4-byte one.] - - Six concepts are genuinely duplicated across the mechanisms, and the - duplication is real: endpoint-path derivation 82 lines across 6 copies; - directory create-and-vet 105 across 6; stale sweeping 112 across 3; - bind/listen/accept 213 across 3; "which instance?" 231 across 3; version - negotiation 56 across 2. *799 lines, of which ≈330 is recoverable.* - - #ev("src/fs_service.zig:37-55")[the comment there is right on the facts and wrong on the conclusion: `parentFrom` and `nested.socketDir` differ ONLY by appending `/pardes` under `$XDG_RUNTIME_DIR` and coincide verbatim under the HOME fallback. And there are two liveness oracles for one question — `kill(0)`/ESRCH at `nested.zig:401` and `fuse.zig:742`, `connect`/ECONNREFUSED at `server.zig:1940-1942`.] - - #verdict[ - Could delete ≈850 lines (`nested.zig`'s transport and its socket tests, - 518; the net collapse of duplicated plumbing, ≈330). Could not delete - ≈7,700 — `fuse.zig` 2,709 (`9P-13`), `wire.zig` 1,705, the daemon's host - half, the 158-line ancestor walk that answers a question 9P has no message - for, and every kernel interface that was never a protocol choice (inotify, - pty, pipes, `mkstemp`). - - Against a 1,600-1,900-line 9P stack (`9P-15`): *net growth of +750 to - +1,050 lines.* - - So: strike "simpler" from every argument. What IS true, and is worth - saying, is that the CONCEPT count falls — four framings to two, three - discovery schemes to two, two version schemes to one, six duplicated - concepts to one copy each. Fewer ideas, more lines. Say exactly that. - ] - - #note("contra", "2026-08-27")[ - The strongest form of the objection, and it should stay on the record: the - three mechanisms do not share a problem. `nested`'s format is twelve lines - *because* it reuses a language pardes already speaks — the file says so at - `:14-16`, "no serialization, nothing to version". `wire`'s codec is 1,167 - lines because it carries a 1.6 MiB worst-case RLE grid at one message per - frame. `acmefs` is 2,324 because it is acme's semantics. The general thing - costs ≈970 lines before saying anything specific to pardes, and each of the - three still needs its specific part afterwards. - ] -] - -#entry("9P-21", "The lost compile-time invariant, which nothing else prices", state: "open", tags: ("cost", "correctness", "unpriced"))[ - The best objection found against the whole direction, and no other entry - costs it. - - #ev("src/detached/wire.zig:23-27")[«every union and every enum gets a tag chosen HERE … so reordering `Event` or `CellStyle.ul` cannot silently redefine the protocol. The mapping switches are exhaustive: adding a variant to the core is a compile error in this file, which is the point of them.»] - #ev("src/detached/wire.zig:1199-1265")[and a test enumerates the eleven legal client tags by name under the rule "A HUMAN DID IT", asserts the enum holds exactly those, and asserts the other 250 tag bytes answer `BadTag` — so a frontend built before a change cannot forge a pane's output into a session built after it.] - - Under a `ctl`-file grammar, input arrives as `Twrite` payload bytes. 9P's - codec validates 9P; it cannot validate that a byte string is a legal `key`. - An exhaustive switch checked by the compiler and a 250-byte refusal test - become a runtime string parse with nothing to bind to. - - #ev("src/host.zig:44-52")[a related loss: fourteen of twenty-one vtable methods are `push_` and return nothing, because "a push with an answer would have N answers and no way to pick one". 9P answers every T with an R, so each acquires a tag, a reply, and a matching obligation.] - - #q[Three candidate answers, none costed yet. (a) Keep the typed wire for input and use 9P only for control and text — accepts two protocols forever. (b) Make the `ctl` grammar generated from the same enum, so the exhaustive switch survives as a parser generator and adding a variant is still a compile error. (c) Accept the loss and buy it back with a fuzz test over the grammar. (b) looks best and nobody has tried it.] -] - -#entry("9P-22", "The real gap: pardes cannot ask another pardes anything", state: "decided", tags: ("direction", "motivating-case"))[ - This is the entry the whole file was missing, and it is the owner's own - criterion stated precisely. - - Two instances of pardes can do exactly two things to each other today. - - / Shout: #[`nested.sendLook` formats `Look <path>`, connects, writes, returns `true`, and NEVER READS (`src/nested.zig:294-330`). The receiver filters for the one legal verb and closes (`:466-510`). The test at `:652-702` exercises the whole protocol and asserts that nothing comes back.] - / Become: #[`Attach` is not a connection, it is a replacement. Only on a successful handshake does the frontend reap its pane shells, close its watches, unmount `--fs`, *deinit the core*, and enter the thin loop (`src/detached/client.zig`, `docs/detached.md:207-213`). The local core is destroyed, not linked.] - - Everything else is out of reach. Nothing in `src/` reads `PARDES_FS`; `Event` - has no variant for a peer; `Host.VTable`'s 21 methods have no peer method. - Reading another session's text is possible only by shelling out — - `Exec cat /run/user/1000/pardes/<other-pid>/1/body` — which is Linux-only, - opt-in behind `--fs`, requires already knowing the pid, and is mediated - entirely by `/bin/sh`. And it fails outright against a detached session, - which serves no filesystem at all (`src/detached/server.zig:566-570`). - - #verdict[ - *The direction is a reply.* - - pardes has no request/response channel to anything that is not the kernel. - `nested` is one-way by construction. The detached wire forbids a round trip - inside `update` by design (`wire.zig:64-70`). `acmefs` has request/response - and cannot leave the machine (`fuse.zig:64`, `9P-13`). - - 9P is a request/response protocol that crosses machines, and that single - property is what the other three cannot be extended to have. Every good - thing on the list — scripting from macOS, driving a session over a network, - reading the board's memory, chaining one instance to another without - destroying either — is the same feature: *being able to ask, and get an - answer back.* - - That is the justification. Not simplification (`9P-20` forbids it), not - elegance, and not symmetry. - ] - - #note("direction", "2026-08-27")[ - The measure of success, so this can be checked rather than admired: after - the work, `Attach` should have a sibling that CONNECTS instead of - replacing — two live cores, each able to walk the other's tree — and the - twelve-line `Look` sentence should be a `Twrite` to the other instance's - `new/` on a connection that can also answer. If those two things are not - true at the end, the protocol was adopted for its own sake. - ] -] - -#entry("9P-23", "The host vtable stays; 9P is one implementation of it, for remote hosts only", state: "decided", tags: ("architecture", "scope-limit"))[ - The grand version of the direction was that `Host.VTable` becomes a tree the - core mounts, so that the downward seam and the outward seam are the same kind - of thing. Measured, that is wrong for every LOCAL host and right only for - remote ones. - - #ev("src/host.zig:52-140")[the seam is 21 methods: 15 `push_` and 6 `pull_`, with the prefix compiler-enforced (`isPull`, `:265-274`) — a `push_` returning non-void does not build.] - - The blocking objection turns out to be nearly empty. Of the six `pull_` - methods, three are ALREADY asynchronous — `pull_read_clipboard`, `pull_lsp` - and `pull_pipe` return `void` and their answers arrive as `Event.paste`, - `.lsp_resp`, `.pipe_resp` (`src/pardes.zig:6896-6907`). `pull_wait_input` - cannot become an event because it is how events arrive. That leaves two: - `pull_gpio_toggle`, whose answer is only used to set a message row - (`builtins.Board.gpio`), so deferring it a frame is invisible; and - `pull_tty_taken`, which is reached from `takesCommandLine` - (`pardes.zig:6465-6469`) at four call sites, two of which loop over all 16 - panes — 16 probes per call, collapsing to ONE read if `proc` is a single file - of sixteen lines rather than `proc/N/status`. - - So the core could be a 9P client without blocking. It should not be, locally: - - #ev("per-host measurement")[bodies replaced by an estimated 9P server, per host: tty 345 → ≈225 but *+204* with the tree and `ctl` grammars; gui *+204*; detached server *+182*; macos *+164*; web *+142 Zig plus ≈500 lines of JavaScript*; board *+59* and 8,832 B of RAM. *No host gets simpler.*] - #ev("src/web.zig:356-391")[and web gets actively worse than the count shows: `present` today writes a flat `WebCell` array that JavaScript reads *directly out of wasm memory* across four `extern` declarations (`:330-340`). Under 9P that zero-copy array becomes an RLE stream JS must decode, and wasm32 has no sockets, so JS must carry a 9P codec too.] - #ev("src/esp32p4.zig:127")[the board is worse still in kind: its "host" is the same process behind a C-ABI function pointer, so a `Twrite`/`Rwrite` pair per frame is pure protocol overhead against a direct call.] - - #verdict[ - `Host.VTable` is already the abstraction — `docs/design.typ:1964-1965` says - the surface IS the abstraction and refuses a render layer over the shells. - A tree would be that layer. - - So: a 9P host is *one more implementation of the existing vtable*, chosen - when the display is on the other end of a wire, exactly as `9P-2` makes a - 9P transport one more implementation of the filesystem seam. Local hosts - keep the direct call and pay nothing. The two seams stay two seams; what - they gain is a second backend each. - - That also settles `9P-19`'s scope: the core is a 9P client of its display - only when the display is REMOTE — a detached frontend, or a board acting as - a terminal for a full core elsewhere. Never for the window in front of you. - ] - - #note("correction", "2026-08-27")[ - Fact correction for anything quoting the earlier tally: `tty` and `gui` - fill *19* of 21, not 20 — `src/detached/server.zig:558-559` already says - "NINETEEN each". The 20 figure was wrong by one and is superseded here. - ] -] - -#entry("9P-24", "A dead descriptor left in a poll set is a whole core, forever", state: "landed", tags: ("bug", "lesson"))[ - Found by an adversarial pass over step 1 after it was committed and after the - happy path had been demonstrated with the project's own example clients. It is - recorded because the shape recurs, not because the fix was hard. - - Step 1 put `/dev/fuse` in the daemon's `poll(2)` set whenever the mount - existed, and gave `Source.fuse` an empty arm on the grounds that being in the - set was the whole point. - - #ev("linux/fs/fuse/dev.c")[`fuse_dev_poll` answers `EPOLLERR` once the connection is gone — and POSIX reports `POLLERR` whatever the `events` mask asked for, so an empty arm cannot decline it.] - #ev("src/fuse.zig")[`pollLoop`, the mount's own poll thread, has carried `if (revents & (ERR|HUP|NVAL) != 0) return;` all along. That is why the tty and SDL shells never showed this and the daemon did: they never poll the descriptor themselves.] - - So an external `fusermount3 -u`, a sysfs abort, or systemd taking - `/run/user/$UID` away at final logout — *exactly the moment a detached session - is supposed to keep running* — made `poll(2)` return instantly and forever. - Measured on the committed change: 0 CPU ticks over 10 s idle, then 1000 ticks - over the next 10 s. After the fix, 0 ticks over 8 s in the same scenario, with - the process alive and in state `S`. - - #verdict[ - Gate the insertion on `!f.dead` and let the arm consume `POLLERR`/`POLLHUP`/ - `POLLNVAL` by marking the mount dead. Both, not either: the gate is what - ends the spin, and the arm is what saves the one spinning round before - `Fs.next`'s first failed read would have set the flag anyway. - - The general rule, which is the reason this entry exists: *a descriptor - added to a shared poll set needs an error arm even when it needs no data - arm.* An empty arm is a decision about `POLLIN` only, and the kernel does - not ask permission before reporting `POLLERR`. - ] - - #note("review", "2026-08-27")[ - Worth noticing how it was caught. The feature was demonstrated working — - `pardesctl panes`, `new`, `send`, `body`, `del` against a live daemon — and - the bug was nowhere near the happy path. It took an adversary told to - *assume the happy path works and look elsewhere*, who then went and measured - `/proc/<pid>/stat` before and after an unmount. Four of that pass's other - seven findings were false comments rather than false code, which in this - 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.] -] |
