summaryrefslogtreecommitdiff
path: root/docs/9p.typ
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-27 14:27:05 -0300
committerGabriel Schneider <[email protected]>2026-08-27 15:28:00 -0300
commit3580ef4d7459035d82d38dbc4559cdede3c805ba (patch)
tree53853229a38e8b75cc30512cefac6dbe16a874d2 /docs/9p.typ
parent29ac9be75fdcafbd7d05c15aa9eb8490d74caa98 (diff)
downloadpardes-3580ef4d7459035d82d38dbc4559cdede3c805ba.tar.gz
pardes-3580ef4d7459035d82d38dbc4559cdede3c805ba.zip
docs: a 9P design note and a design registry to argue it in
Diffstat (limited to 'docs/9p.typ')
-rw-r--r--docs/9p.typ697
1 files changed, 697 insertions, 0 deletions
diff --git a/docs/9p.typ b/docs/9p.typ
new file mode 100644
index 00000000..82131b9d
--- /dev/null
+++ b/docs/9p.typ
@@ -0,0 +1,697 @@
+// 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)
+
+#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"),
+)