summaryrefslogtreecommitdiff
path: root/docs/registry.typ
diff options
context:
space:
mode:
Diffstat (limited to 'docs/registry.typ')
-rw-r--r--docs/registry.typ1209
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.]
-]