diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-27 14:27:05 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-27 15:28:00 -0300 |
| commit | 3580ef4d7459035d82d38dbc4559cdede3c805ba (patch) | |
| tree | 53853229a38e8b75cc30512cefac6dbe16a874d2 /docs/registry.typ | |
| parent | 29ac9be75fdcafbd7d05c15aa9eb8490d74caa98 (diff) | |
| download | pardes-3580ef4d7459035d82d38dbc4559cdede3c805ba.tar.gz pardes-3580ef4d7459035d82d38dbc4559cdede3c805ba.zip | |
docs: a 9P design note and a design registry to argue it in
Diffstat (limited to 'docs/registry.typ')
| -rw-r--r-- | docs/registry.typ | 986 |
1 files changed, 986 insertions, 0 deletions
diff --git a/docs/registry.typ b/docs/registry.typ new file mode 100644 index 00000000..597e930f --- /dev/null +++ b/docs/registry.typ @@ -0,0 +1,986 @@ +// 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) + +#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. + ] +] + +#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.] +] + +#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 (`board_memory.zig:382-383`). 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/board_memory.zig:306-349,410-472`, + registered `src/builtins.zig:1009,1025,1038`). 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 (`board_memory.readWord:136`, `:368-390`, + `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.] +] + +#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?] +] + +#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 + (`board_memory.zig:422-428`), 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. + ] +] |
