From 464b3033ac69f6c8256c2216ac385450144740bf Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 23:04:26 -0300 Subject: 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 --- docs/9p.typ | 702 ------------------------------------------------------------ 1 file changed, 702 deletions(-) delete mode 100644 docs/9p.typ (limited to 'docs/9p.typ') 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(). #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