summaryrefslogtreecommitdiff
path: root/docs/9p.typ
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-30 23:04:26 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commit464b3033ac69f6c8256c2216ac385450144740bf (patch)
tree76c4d2a6ce17e326e4e991a021d0088cb9094134 /docs/9p.typ
parent5e72bfc34cc97d12be5185930135b55c2eb001b4 (diff)
downloadpardes-464b3033ac69f6c8256c2216ac385450144740bf.tar.gz
pardes-464b3033ac69f6c8256c2216ac385450144740bf.zip
Building gathers the platforms, options, tests, release gates and how the core, threads, detached sessions and 9P fit, from the old design notes checked against the code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'docs/9p.typ')
-rw-r--r--docs/9p.typ702
1 files changed, 0 insertions, 702 deletions
diff --git a/docs/9p.typ b/docs/9p.typ
deleted file mode 100644
index 50cec965..00000000
--- a/docs/9p.typ
+++ /dev/null
@@ -1,702 +0,0 @@
-// Second draft. The first is superseded; what changed and why is recorded
-// entry by entry in `docs/registry.typ`, and every §-to-entry link below is
-// live. Build: typst compile docs/9p.typ docs/9p.pdf
-#let app = "PaRDeS"
-
-#set page(
- paper: "us-letter",
- margin: (x: 1.5in, y: 1.2in),
- numbering: "1",
- number-align: center,
-)
-#set text(font: "Libertinus Serif", size: 11pt, lang: "en")
-#set par(justify: true, leading: 0.62em, first-line-indent: 1.5em)
-#show raw: set text(font: "DejaVu Sans Mono", size: 8.5pt)
-#show raw.where(block: true): block.with(inset: (left: 1.5em, y: 0.4em), width: 100%)
-#set table(stroke: none, inset: 0.35em)
-
-#set heading(numbering: "1.")
-#show heading: it => {
- set text(size: 11pt, weight: "bold")
- v(1.1em, weak: true)
- block(counter(heading).display() + h(0.6em) + it.body)
- v(0.4em, weak: true)
-}
-#show heading.where(level: 1): it => {
- set text(size: 11pt, weight: "bold")
- v(1.4em, weak: true)
- block(counter(heading).display() + h(0.6em) + it.body)
- v(0.5em, weak: true)
-}
-
-/// A registry cross-reference. Every contestable claim in this note is an
-/// entry over there, with its evidence and its losing arguments attached.
-#let r(id) = text(size: 9pt)[#raw(id)]
-
-#align(center)[
- #v(0.5em)
- #text(size: 15pt)[A File Interface for an Editor and Terminal Multiplexer]
- #v(0.9em)
- #text(size: 10.5pt, style: "italic")[architecture draft, second revision]
- #v(0.4em)
- #text(size: 10pt)[#datetime.today().display("[year]-[month]-[day]")]
-]
-
-#v(1.2em)
-
-*Historical proposal, superseded by `docs/fs.md`.* Pardes now serves 9P by
-default over Unix sockets, with optional TCP and QUIC. FUSE support and the
-old examples have been removed. The arguments and source references below
-describe the earlier implementation, not the current interface.
-
-#block(inset: (x: 2.5em))[
- #set text(size: 10pt)
- #set par(first-line-indent: 0em)
- #text(weight: "bold")[ABSTRACT] #h(0.8em)
- #app is an editor with attached terminals. It runs as a daemon; clients attach
- to it. It already serves acme's control filesystem, over FUSE, on Linux only.
- This note adds 9P as that filesystem's second transport, and as a carrier for
- the display protocol it already has. The program becomes a file server that
- needs no kernel, so the tree reaches macOS, a browser tab, a microcontroller
- and another machine, none of which FUSE can reach. The same program is also a
- 9P client, which is how it edits a file on a board it cannot mount. We
- describe the interface, what it costs — measured against two shipping
- implementations rather than estimated — and, at greater length, the parts we
- do not build. The first draft of this note proposed replacing FUSE and
- replacing the wire protocol. Both are wrong, for reasons that turn out to be
- cheap to state and expensive to discover.
-]
-
-= What this is for
-
-One sentence, because every other section is downstream of it.
-
-*PaRDeS has no way to ask anything a question.* It can ask the kernel, and that
-is all. Two running instances can do exactly two things to each other: shout —
-`nested.sendLook` formats one line, connects, writes, returns, and never reads,
-and its own test asserts that nothing comes back — or *become*, which is what
-`Attach` does, and `Attach` is not a connection but a replacement: on a
-successful handshake the frontend reaps its pane shells, closes its watches,
-unmounts its filesystem, deinitialises the core, and turns into a thin client of
-somebody else's. Nothing in the tree reads `PARDES_FS`. `Event` has no variant
-for a peer. The 21-method host vtable has no peer method. Reading another
-session's text is possible only by shelling out to `cat` a path derived from a
-pid you must already know, on Linux, if that session was started with `--fs` —
-and impossible against a detached session, which serves no filesystem at all.
-
-9P is a request/response protocol that crosses machines. That property is the
-one thing none of the three private mechanisms can be extended to have, and
-every item on the wanted list is the same feature wearing different clothes:
-scripting from macOS, driving a session over a network, reading the board's
-memory, chaining one instance to another without destroying either. All of it
-is *being able to ask, and get an answer back*.
-
-That is the justification, and it is the only one. In particular it is not
-simplification: §12 puts the honest figure at between 750 and 1,050 lines of
-net growth, and the measurement is in the registry, not in a hope. What does
-fall is the number of ideas — four framings become two, three schemes for
-"which instance?" become two, two version negotiations become one, and six
-concepts currently duplicated across 799 lines become one copy each. Fewer
-ideas, more lines. Anyone selling this as "simpler" has not counted.
-
-= What changed, and why this draft exists
-
-The first draft argued that 9P should replace two things: the FUSE mount, and
-the private protocol on the daemon's socket. Neither survives contact with the
-sources.
-
-It cannot replace the mount. Linux grades mount privilege by a per-filesystem
-flag, and 9P and FUSE are on opposite sides of it. `mount_capable` falls back to
-`capable(CAP_SYS_ADMIN)` — root in the *initial* namespace — for any filesystem
-that does not set `FS_USERNS_MOUNT`; FUSE sets it, v9fs does not. So
-`mount -t 9p` costs real root, a user namespace does not help, and `trans=fd`
-does not help because the syscall is checked before the transport is consulted.
-The 281 lines in `src/fuse.zig` that fork a setuid helper and receive a
-descriptor over `SCM_RIGHTS` are not overhead; they buy an unprivileged
-pathname, which 9P cannot buy at any price on Linux. The usual escape, `9pfuse`,
-is FUSE again in someone else's process — which is exactly what `ad`, the one
-editor that already took this route, ships #cite(<ad>). #r("9P-13")
-
-It cannot replace the wire either, because the wire's payload is a compressed
-cell grid and 9P has no server-initiated message. But the interesting half of
-that finding is that 9P *can carry* the wire unchanged, and that this is how
-remote display has actually shipped for thirty years #cite(<devdraw>). #r("9P-12")
-
-What is left is better than what was proposed, and smaller.
-
-= Two layers
-
-The first draft's mistakes are almost all one layer's property asserted about
-the other, so the layers are worth naming before anything else.
-
-*Layer 1 is the control tree.* Request and response, nine operations, a pure
-transaction over the core: `handle(p, req) Reply` in `src/acmefs.zig`, whose ABI
-already records that "FUSE opcodes, 9P messages and a unit test all reduce to
-these". It is transport-neutral today and unit-tested with no transport at all.
-
-*Layer 2 is the display protocol.* Nineteen message tags in
-`src/detached/wire.zig` carrying run-length-encoded cell grids and input, one
-message per frame, pushed by the server to N frontends that share one screen.
-
-They share a machine and nothing else. The tree is not private and not
-undocumented — it is acme(4) #cite(<acme>). The wire is both, and deliberately:
-it is a codec, and its cost is measured at seven lines per new fact, which is
-cheap for what it does.
-
-9P is Layer 1's second transport and Layer 2's carrier. It is never a
-re-encoding of either. #r("9P-1")
-
-= The interface
-
-A session is a tree. Windows are numbered directories under it.
-
-```
-/
- ctl session control; read lists windows
- index one line per window: id, tag, flags
- new/ walking here creates a window
- 7/
- ctl commands in; id and state out
- tag the tag line
- body the text
- addr an address
- data read and write at addr
- event the interesting one; see below
- errors writes appear in the error window
- pty/
- data the stream
- ctl winsize, raw, cooked, sig, exec
- status dimensions, mode, exit status
-```
-
-This is acme's tree plus `pty/`, minus the plan9 compatibility stubs. Four
-conventions matter, and the first draft got three of them wrong.
-
-*Offsets are honoured everywhere they mean something.* `body`, `tag`, `addr`,
-`ctl`, `index` and `rdsel` are seekable files and are read at the offset the
-message carries. acme does the same, and its entire byte-to-rune cache exists to
-serve a non-zero offset on `body`. Only `data` and `event` ignore offsets, and
-only because they are positioned by `addr` and by the queue respectively. All
-writes ignore offsets: a write to `body` appends, which acme(4) specifies. A
-server that ignored offsets on `body` would make `cat`, `wc`, `diff` and `tail`
-return the first chunk forever, which is the opposite of the whole point.
-
-*Directory reads carry a cookie, not an index.* 9P requires a directory read at
-offset zero or at exactly the byte offset where the previous read ended, and the
-reply must contain whole `stat` entries; anything else is `Ebadoffset`. The core
-counts entries, so the transport keeps a per-fid byte cursor and the one entry
-that did not fit. This is the only genuine offset discontinuity between FUSE and
-9P, and it is about forty lines. #r("9P-5")
-
-*The address belongs to the window.* The first draft claimed per-fid and
-attributed it to acme; acme keeps `Range addr` in `struct Window`, #app keeps it
-per pane, and `ad` keeps it per buffer. Per-fid is a real improvement — two
-scripts could then address one window without colliding — but it is a proposal,
-not a restatement, and it is the only thing in this note that asks the core to
-grow state. It is argued on its own merits or not at all. #r("9P-6")
-
-*`event` is opened by a client, not by a fid.* One controller per window is
-right; enforcing it per fid is not, because a client that opens the file twice
-is not two controllers. `ad` reaches the same conclusion and scopes `QTEXCL` to
-a connection. #r("9P-7")
-
-= Events
-
-A window handles its own mouse and keyboard until a program opens `event`. From
-then on it ships them out: origin, action, the character range, the text. The
-program reads a record, decides, and writes it back to accept it; or handles it
-itself and stays silent.
-
-This is the whole extension mechanism and it already works — `examples/acmefs/`
-has four programs that use it, one of which puts words #app has never heard of
-into a tag and makes them execute. There are no callbacks and no plugin API. A
-program that wants to redefine what the right button does opens a file and reads
-it.
-
-A read blocks. The core does not: it answers `again` — nothing consumed, ask me
-later — and the transport holds the request. That inversion is what lets a
-single-threaded core serve a filesystem at all, and it is worth stating plainly
-because the prior art does not have it. `ad` spends three to four threads per
-connection and then serialises all of them behind one mutex and a blocking
-round trip into the editor thread. #app answers a blocked `event` read in
-twenty-one nanoseconds and allocates nothing.
-
-= Terminals
-
-A pty is a file interface wearing the wrong clothes. Everything one wants to do
-to it is an `ioctl`, and 9P has none in any dialect. They become writes to
-`pty/ctl`:
-
-#v(0.3em)
-#align(center)[
- #set text(size: 9.5pt)
- #table(
- columns: 2,
- align: left,
- column-gutter: 2em,
- row-gutter: 0.3em,
- [`TIOCSWINSZ`], [`winsize 80 24`],
- [`TCSETS`], [`raw`, `cooked`, `echo off`],
- [`kill`], [`sig INT`],
- [`TIOCGWINSZ`], [read `status`],
- [spawn], [`exec /bin/sh`],
- )
-]
-#v(0.3em)
-
-`pty/data` is the stream. A write is input to the process; a read blocks until
-there is output. On exit a read returns zero bytes rather than an error, because
-clients already know what end of file means.
-
-Two things about this section that the first draft did not say. It has *nothing
-to do with 9P*: `push_spawn` and `push_pty_resize` are effects the core already
-has, so `exec` and `winsize` are existing capabilities acquiring a name, and
-`sig INT` is the only new one. Build it in the FUSE tree first and the 9P
-transport inherits it. And it has *no prior art anywhere*: acme has no pty
-files, `ad` has no terminal surface at all. Being first is a reason to keep it
-to three files and stop. #r("9P-8")
-
-Every keystroke in raw mode is a round trip. On a local socket this does not
-matter. Over a slow link it matters a great deal, which is why Plan 9's terminal
-programs do line editing locally and send whole lines. Raw mode should be
-entered deliberately and left promptly.
-
-= Layering: how 9P and the wire meet
-
-Six arrangements were costed. The finding that decides it is that the cost is
-almost the same in all of them: a base-9P2000 codec, a dispatcher onto the
-existing nine operations, and a fid table come to roughly 970 lines that appear
-in every option. Layering moves about 150 lines either way. So this is not a
-cost question. It is a question of reach.
-
-#v(0.3em)
-#align(center)[
- #set text(size: 9pt)
- #table(
- columns: (auto, auto, auto, 1fr),
- align: (left, right, right, left),
- stroke: (y: 0.4pt),
- table.header([], [msgs/frame], [msgs/key], [what decides it]),
- [O1 9P replaces all], [2], [4], [64 park slots needed against 32; a round trip per frame],
- [O2 two listeners], [1], [2], [no routing origin for the 9P descriptor],
- [O3 9P inside the wire], [1], [2], [*works over the board's UART unchanged*],
- [O4 wire inside 9P], [2], [4], [+34 B on a 56 B diff, or +61%],
- [O5 side by side], [1], [2], [cheapest, but cannot reach the board],
- [O6 frontend serves], [2], [4], [core would write and match tags inside `update`],
- )
-]
-#v(0.3em)
-
-That table is superseded, and the correction is the most important thing in
-this note. It was written believing 9P has no way to push a frame, so every
-arrangement that carried frames over 9P paid a round trip. The premise is
-wrong — not because 9P can push, but because *the pushing end should be the
-client.*
-
-Plan 9 has shipped this for thirty years and it needs no invention. `drawterm`
-dials out to a cpu server, writes a shell script, and then calls
-`exportfs(fd, fd)`: the dialer becomes the *server* on the socket it dialed.
-The script the remote runs is `mount -nc /fd/0 /mnt/term`, and the remote is
-the *client*. 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. One descriptor, roles fixed per side, data
-flowing both ways, no server-initiated message anywhere.
-
-So the rule is one role per connection, not one role per message:
-
-#v(0.3em)
-#align(center)[
- #set text(size: 9.5pt)
- #table(
- columns: (auto, auto, 1fr),
- align: (left, left, left),
- column-gutter: 1.2em,
- row-gutter: 0.3em,
- [*display*], [frontend serves], [core `Twrite`s frames to `screen`, blocking-`Tread`s `input`],
- [*session*], [core serves], [scripts and other instances walk `7/body`, `event`, `ctl`],
- [*devices*], [board serves], [core reads `mem/`, `gpio/`, `prof`],
- )
-]
-#v(0.3em)
-
-Three connections, one role each; the program contains both halves and uses
-whichever the link calls for. Do *not* build a link that carries both roles at
-once. 9P permits it — T-messages are even and R-messages odd, so a stream is
-self-demuxing — but nothing has ever done it, and it buys a `Tversion` ordering
-hazard in which each side must answer the peer's version while awaiting its
-own.
-
-Two numbers make the display path credible. Chunking is a non-issue: payload
-per message is 131,072 bytes at Linux's default, 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 the 1.7 MB worst case is thirteen. And the core need not block:
-tags are allocated per outstanding request with no in-order reply requirement,
-which is why `exportfs` can answer out of order at all. Where `exportfs` needs
-a slave process per blocked request, PaRDeS needs none — `Status.again` and the
-park table are the same thing done single-threaded.
-
-Copy the file discipline rather than inventing one: `/dev/draw/N/data` is a
-write-only batched binary command stream with an exclusive `ctl`, and
-`/dev/mouse` is an exclusive single-reader file whose read blocks until
-something happens. That is `screen` and `input` already designed, and it is the
-shape `event` already has.
-
-Frames are never re-represented. Whatever carries them carries `encodeFrame`
-bytes; the existing five-byte length prefix already makes such a message a legal
-9P payload. The counter-example is worth keeping in view: Plan 9's `/dev/screen`
-is an offset-addressed raster a client polls, with no change notification, which
-is precisely why nobody ever ran a remote rio through it.
-
-One scope limit, measured and not negotiable. This applies to *remote* displays
-only. Turning the local host vtable into a tree costs every shell more than it
-saves — tty and gui about +204 lines each, macOS +164, and the web shell +142
-lines of Zig plus roughly 500 of JavaScript, because its `present` today writes
-a flat cell array that JavaScript reads straight out of wasm memory and 9P would
-make that an RLE stream needing a decoder and a codec. The board is worse in
-kind: its host is the same process behind a function pointer, so a message pair
-per frame is overhead against a direct call. `host.VTable` is already the
-abstraction — the design document says the surface *is* the abstraction and
-refuses a layer over the shells — so a 9P host is one more implementation of
-that vtable, chosen when the display is on the other end of a wire, and never
-for the window in front of you. #r("9P-19") #r("9P-23")
-
-= The client half
-
-The same program is a 9P client. This is not symmetry for its own sake: it is
-how one instance shows another instance's windows, and how it edits a file on a
-machine it cannot mount.
-
-The client half needs no mount, and after §1 that is no longer one advantage
-among several — it is the whole of what 9P buys that we cannot otherwise have.
-To edit a file on the board, #app walks to `board/fs/etc/config`, opens, reads,
-edits in a buffer, writes, and clunks. There is no path on the local machine, no
-kernel involvement, and no privilege. That works on macOS, which has no 9P in
-the kernel and no control filesystem at all today; it works in a browser tab,
-which has no filesystem at all; it works on the board, which has no sockets; and
-it works when the operator is not root, which on Linux is now the interesting
-case.
-
-A mount is still worth offering, because `grep`, `make` and the compiler take
-filenames and a filename is the one thing a client library cannot produce. But
-on Linux that mount is `src/fuse.zig`, which we keep, or it is `9pfuse`, which
-is `src/fuse.zig` written by someone else. It is not `mount -t 9p` unless the
-operator is root and chooses to be.
-
-= The board
-
-The microcontroller is the case that motivated this note, and both the first
-draft and its review were wrong about it in opposite directions.
-
-The first draft said the board runs "a 9P server and nothing else". It runs the
-whole editor, as firmware, with two of twenty-one host methods filled. The
-review said a 9P server would not fit, quoting nine kilobytes of free heap. That
-number is two refactors stale: since the shadow grids were reduced to one cell,
-the heap reports around 336 KB free and the binding constraint moved to the
-240 KiB of low memory that holds `.bss`. #r("FIX-2")
-
-Costed from real components, a 9P server on this board is:
-
-#v(0.3em)
-#align(center)[
- #set text(size: 9.5pt)
- #table(
- columns: (auto, auto, 1fr),
- align: (left, right, left),
- column-gutter: 1.2em,
- row-gutter: 0.3em,
- [two `msize` buffers], [8,192 B], [u9fs uses three; a minimal server needs two],
- [fid table, fixed array], [512 B], [32 entries × 16 B, linear scan ≈0.4 µs],
- [codec scratch], [128 B], [one `stat`, `ERRMAX`],
- [#text(weight: "bold")[RAM total]], [#text(weight: "bold")[8,832 B]], [2.5% of free heap],
- [flash, 9P-only image], [≈39 KiB], [2.5% of a 1.5 MB partition],
- )
-]
-#v(0.3em)
-
-The 4,096-byte floor that sizes those buffers is imposed by the Linux kernel and
-by nothing else; Plan 9, plan9port and #app's own client all accept a
-512-byte `msize`, which would halve them. And a 4,096-byte reassembly buffer
-already exists on this exact serial line, with the property measured: that much
-in a single write arrives intact.
-
-So it fits, easily. What does not work is having it both ways: the editor owns
-the one UART bidirectionally and the board's header exposes no second one, so a
-9P server on the P4 is a *second firmware image*, not a second role for this
-one. The build already expresses that shape — the on-die test suite is a
-separate executable with its own image and flash steps in fifty-four lines, and
-it links no editor object. The board is an editor or a filesystem, and saying so
-is better than implying both. #r("9P-11")
-
-Before quoting any latency for it, raise the console to 921600 baud. It is one
-divider write on the existing crystal, the routine already exists, and it takes
-a warm read of a small file from 10.8 ms to 1.35 ms. #r("BOARD-1")
-
-The tree such an image would serve is not invented. The board already exposes
-its whole address space, its pin header and one pad toggle — as *acme words a
-human types into a tag*, capped at four kilobytes per command because of the
-console, with the answer landing in an output pane. Nothing about it is
-machine-readable and nothing is remote. `mem/`, `gpio/pinout` and `prof` are
-backed by functions that exist today; four more files need one new exported
-symbol each; four have no implementation at all. A 9P tree is that capability
-with names instead of verbs, and the inventory above is the honest scope.
-
-= What we do not build
-
-The temptation in this design is to keep going. Each of the following was
-considered and declined.
-
-*A window system.* #app draws in one window. Panes are ours; surfaces are not.
-Becoming a Wayland compositor would buy the ability to put someone else's
-program in a pane, and cost input handling, buffer management, output hotplug,
-scaling, clipboard, and a permanent maintenance obligation against a moving
-protocol. We do not want someone else's program in a pane.
-
-*A namespace.* Per-process namespaces, union directories, `bind`, and walks that
-cross mount points are what make Plan 9's implementation large #cite(<plan9>).
-The distinction is worth defending, because the first union directory brings the
-rest.
-
-*An aggregate.* The first draft's longest technical section described a prefix
-router that re-exports attached instances, and its own final section concluded
-the aggregate is not worth building before a second machine exists. That verdict
-is right and also covers the client half of it. It also understates the work: a
-proxied `Twalk` cannot be answered until the remote `Rwalk` arrives, and the
-core may not block, so it is not a fid map — it is per-tag continuations, tag
-remapping, flush forwarding and fid invalidation on connection death, in a
-daemon that is one `poll(2)` with no worker pool. Deferred with a named
-precondition rather than described as easy. #r("9P-10")
-
-*A file server.* Where a host's files must be exported, we run `u9fs` or `diod`
-and attach as a client #cite(<u9fs>). Real filesystems are where the difficulty
-in 9P actually lives: stable qids, `..` clamped at the export root, symlinks
-that escape, identity mapping, and the full `wstat` surface. Our own trees are
-synthetic, so we invent every file and there are no cases we did not choose.
-Note that `u9fs` serves its requests from a strictly serial loop and its flush
-handler is a `break`, so it is a reference for the tree and not for §10.
-
-*Authentication.* Not now, and not by us. The Unix socket is protected by the
-permissions on the socket. Where a network is involved, the connection is
-tunnelled. Linux's client never sends `Tauth` at all, and Plan 9's `mount` only
-does so without `-N`, so a server that refuses it is not an obstacle to either.
-Real authentication is `Tauth` or TLS, and it can be added without changing the
-tree.
-
-= Protocol notes
-
-We serve base 9P2000 #cite(<intro5>). Plan 9 mounts it natively, plan9port's
-`9p` speaks it, and Linux mounts it with `version=9p2000` #cite(<v9fs>). The
-following are the traps, in the order they will bite. Each is cheap once known
-and each has been shipped wrongly by someone.
-
-*Set `qid.version` to zero on every file.* This is the 9P equivalent of FUSE's
-`FOPEN_DIRECT_IO` and it is server-side, not advice to the operator: Linux's
-client disables both read and write caching for any file whose qid version is
-zero, whatever the cache mode, unless `ignoreqv` is passed explicitly. A
-synthetic tree of live editor state has no business being cached, and this is
-how we say so. The first draft's "mount with `cache=none`" was unnecessary; its
-claim that the default message size is small was simply wrong, since the default
-is 128 KiB and the cap is 1 MB. #r("9P-3")
-
-*Clamp every reply to the client's `count`.* An `Rread` longer than the count
-asked for is a hard `EIO` in the Linux client, not a truncation. This has no
-FUSE analogue, which is why nobody looks for it, and `ad` gets it wrong on
-exactly the file we care about. The existing rule that a read too small for one
-event record is refused rather than split is what makes the clamp safe on
-`event`. #r("9P-17")
-
-*Decide the error ABI deliberately.* `Rerror` in base 9P2000 is a string, and
-Linux recovers an errno by exact match against a fixed table; a miss is not
-`EIO` but `ESERVERFAULT`, which userspace prints as "Unknown error 526". `ad`
-emits nineteen prose strings and not one of them is in that table. Either emit
-the exact strings Linux knows, or serve 9P2000.u and send the number. The second
-also restores `statfs`, which base 9P2000 does not have and which the core's
-operation set includes. #r("9P-4")
-
-*`Tflush` is a park-table lookup.* The common failure is not omitting it — it is
-implementing the message and still hanging, which is what `ad` does: correct
-`Rflush` ordering in thirty-nine lines, and a filesystem hook that defaults to
-doing nothing. #app is structurally better placed than either reference, because
-the thirty-two-slot park table is already keyed per outstanding request and
-already answers `FUSE_INTERRUPT` the same way. Implement flush against the
-table, not against a callback, and the bug is unrepresentable. #r("9P-16")
-
-*Serve requests concurrently.* Several tags may be outstanding on one connection
-and `event` reads block by design. This does not require threads: it requires
-that a blocked request be parked rather than waited on, which is what the core's
-`again` already means.
-
-= Cost, and the order to build in
-
-The honest figure is 1,600 to 1,900 lines of server plus around 500 of tests.
-This is measured, not estimated: `ad`'s 9P2000 server core is 2,321 lines
-(codec 704, stat and mode 437, session and fid and flush 615, loop and handlers
-474) and `u9fs` is 1,838 with an 805-line codec. Two independent
-implementations agree on codec ≈750 and server logic ≈1,700. Roughly 970 of
-those lines are identical under every layering, so none of the estimate is at
-risk from §6. #r("9P-15")
-
-What comes off against it has now been counted too, and it is less than hoped.
-Six concepts are genuinely duplicated across the existing mechanisms —
-endpoint-path derivation in six copies, directory create-and-vet in six, stale
-sweeping in three, bind/listen/accept in three, "which instance?" in three, and
-version negotiation in two — 799 lines in total, of which about 330 collapses
-to one copy. Add `nested.zig`'s socket half and its socket tests, 518 more, and
-the deletion is *≈850 lines*. What cannot be deleted is ≈7,700: `src/fuse.zig`
-by §1, `wire.zig` because frames stay frames, 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, the pty, the
-subprocess pipes, `mkstemp`.
-
-*Net: between 750 and 1,050 lines of growth.* It breaks the house rule that a
-change leaves the file it touches no longer than it found it, §1 forbids
-retiring `src/fuse.zig` in exchange, and there is no arithmetic that rescues
-it. The growth is justified by reach and by §0's reply, or it is not justified.
-
-One cost is still unpriced and should be before any code is written. `wire.zig`
-chooses every protocol tag in one file so that reordering a core enum is a
-compile error, and a test asserts that exactly eleven client tags exist and that
-the other 250 bytes are refused, so a frontend built before a change cannot
-forge a pane's output into a session built after it. 9P's codec validates 9P; it
-cannot validate that a byte string is a legal keystroke. An exhaustive switch
-checked by the compiler becomes a runtime string parse with nothing to bind to.
-The likely answer is to generate the `ctl` grammar from the same enum so the
-compile error survives as a parser generator, but nobody has tried it.
-#r("9P-20") #r("9P-21")
-
-The order matters more than the total. Each step is stated with what it is,
-where it lands, what becomes possible that was not, and how you know it worked.
-Steps 1 to 3 are independent of each other and of 9P; step 4 needs step 2;
-step 6 needs step 5. Any prefix of this list is a reasonable place to stop.
-
-== Step 1 — let a detached session be scripted
-
-A daemon started with `--detach=work` holds the core, and today it serves no
-control filesystem at all: `src/detached/server.zig:566-570` records that it has
-no `push_fs_reply` "because this process mounted no /dev/fuse". It simply never
-calls `fs_service.start`. Nothing prevents it.
-
-The change is a `Source.fuse` variant in the poll union, an `fs` field, the
-`fs_service.start` call, `fsReply` copied verbatim from `src/tty/tty.zig`, one
-vtable entry, a pollfd and its dispatch arm, and the `drain` call. About sixteen
-lines. It is *better* in the daemon than in the desktop hosts: those need a
-background thread to notice a request, and the daemon's own `poll(2)` already
-watches everything, so it can watch `/dev/fuse` too and the thread disappears.
-
-After: `pardes --detach=work` and then `ls $XDG_RUNTIME_DIR/pardes/<pid>/` works,
-and every script in `examples/acmefs/` drives a daemon it previously could not
-see. Proof is those four programs and the existing filesystem snapshots, run
-against a detached session instead of a local one. #r("9P-14")
-
-== Step 2 — name the transport seam
-
-`fs_service.drain` and `step` take a `*fuse.Fs`, but they only ever call three
-methods on it: `retry()`, `next()` and `reply()`. Replace the concrete pointer
-with a context pointer and a three-function vtable, at the three call sites in
-`tty.zig` and `gui.zig`. Forty lines changed, none added, `fuse.Fs` is the first
-implementor, and nothing observable changes.
-
-This is the whole preparation for a second transport, and it is worth doing on
-its own merits: if 9P is never written, the seam is documented in code rather
-than in a comment. #r("9P-2")
-
-== Step 3 — make terminals scriptable
-
-Add `pty/data`, `pty/ctl` and `pty/status` to each pane's directory in
-`src/acmefs.zig`, with `ctl` taking `winsize 80 24`, `raw`, `cooked`,
-`echo off`, `sig INT` and `exec /bin/sh`.
-
-Today a script can write into a terminal that already exists and read its
-rendered scrollback, and that is all — it cannot start one, resize one, or
-signal one. Two of those are free: `push_spawn` and `push_pty_resize` are
-already effects the core emits (`src/host.zig:80-82`), so `exec` and `winsize`
-are existing capabilities acquiring a name. Only `sig INT` is new work; there is
-no `kill` anywhere in `host_io.zig`.
-
-This has nothing to do with 9P. It lands in the FUSE tree, works immediately,
-and any later transport inherits it. Keep it to three files: acme has no pty
-files and neither does `ad`, so there is no prior art to be wrong about, which
-is a reason for restraint rather than ambition. #r("9P-8")
-
-== Step 4 — serve the existing tree over 9P
-
-A new `src/ninep.zig`: base 9P2000 over a unix socket, serving exactly the tree
-`src/acmefs.zig` already defines. No client, no aggregation, no `aname`, and no
-new files — step 3 already added the only ones wanted.
-
-Implement `Tversion`, `Tattach`, `Twalk`, `Topen`, `Tread`, `Twrite`, `Tclunk`,
-`Tstat`, `Twstat` and `Tflush`, and answer `Rerror` to `Tauth`, `Tcreate` and
-`Tremove`. Lift the park table and its `retry`/`take`/`freeSlot`/`findSlot`
-helpers out of `src/fuse.zig` unchanged — `Status.again` means the same thing to
-both transports. What is genuinely new is the codec, the fid table, a per-fid
-directory cursor, and `stat` marshalling. Every trap in §11 applies here and
-nowhere else.
-
-After: macOS and the browser have a control filesystem for the first time, and a
-script on another machine can drive a session over TCP. On Linux the FUSE mount
-stays, because `mount -t 9p` needs root and `fusermount3` does not — the two are
-complementary, not competing. Proof is the existing snapshots run through the
-9P transport unchanged, and `examples/acmefs/` run through `9pfuse`. #r("9P-15")
-
-== Step 5 — become a 9P client
-
-The other half, and the one that moved up the list, because it is the mechanism
-for everything the note is for. A server lets pardes be *talked to*; a client is
-how pardes *asks*.
-
-It is a state machine over a byte stream — walk, open, read, write, clunk — with
-no kernel, no mount and no privilege, and the protocol layer is freestanding-safe:
-no allocator, no threads, no sockets, so it runs on the board's UART and in the
-browser as readily as on a socket.
-
-After: `Look board/fs/etc/config` opens a file that lives on the microcontroller,
-in a pane, with no mount anywhere. And `Attach` gains the sibling it has always
-needed — one that *connects* instead of replacing. Today `Attach` deinitialises
-the local core and turns the process into a thin frontend; the new word leaves
-both cores alive and lets each walk the other's tree. That is the "chaining"
-that is currently impossible. #r("9P-22")
-
-== Step 6 — let a remote display serve the core
-
-A frontend exports `screen`, `input`, `snarf` and `ctl`; the core dials it and
-becomes its client, writing frames to `screen` and taking a blocking read on
-`input`. This is `drawterm` exactly, and the file discipline should be copied
-rather than invented: a write-only batched command stream with an exclusive
-`ctl`, and an exclusive single-reader input file whose read blocks.
-
-The frame bytes are `encodeFrame` output verbatim — the codec does not change,
-only what carries it. *Remote displays only.* The window in front of you keeps
-the direct vtable call, because a tree costs every local shell more than it
-saves.
-
-What this actually unlocks: the ESP32-P4 stops being a shrunken pardes — an
-809 KB image running a subset of the editor in 384 KB of RAM — and becomes a
-terminal for a full core running on the workstation. Roughly 39 KB of firmware,
-and it gains language servers, tree-sitter and PDF because those now run
-somewhere that can afford them. Raise the console to 921600 baud first, or every
-measurement will be eight times worse than it needs to be. #r("9P-19") #r("BOARD-1")
-
-= Status
-
-The tree, the event protocol, the pty control language and the layering are
-settled enough to implement, and the registry holds the arguments that settled
-them. Three questions remain genuinely open: whether the address becomes a
-per-fid property and the core grows state to hold it #r("9P-6"); which error ABI
-we adopt #r("9P-4"); and whether the parked second core on the board could own a
-9P server so the board need not choose between being an editor and being a
-filesystem #r("9P-11").
-
-Steps one through three are worth doing whatever the answer to the rest is,
-which is the best property this plan has.
-
-#v(1.5em)
-#line(length: 30%, stroke: 0.5pt)
-#v(0.5em)
-
-#set text(size: 9.5pt)
-#set par(first-line-indent: 0em)
-
-#bibliography(
- title: none,
- full: false,
- ("refs.yml"),
-)