From 3580ef4d7459035d82d38dbc4559cdede3c805ba Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 27 Aug 2026 14:27:05 -0300 Subject: docs: a 9P design note and a design registry to argue it in --- docs/9p.typ | 697 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 697 insertions(+) create mode 100644 docs/9p.typ (limited to 'docs/9p.typ') 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(). #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(). #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(). 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(). +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(). 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(). Plan 9 mounts it natively, plan9port's +`9p` speaks it, and Linux mounts it with `version=9p2000` #cite(). 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//` 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"), +) -- cgit v1.3