summaryrefslogtreecommitdiff
path: root/docs/acme-fs.md
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /docs/acme-fs.md
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'docs/acme-fs.md')
-rw-r--r--docs/acme-fs.md431
1 files changed, 0 insertions, 431 deletions
diff --git a/docs/acme-fs.md b/docs/acme-fs.md
deleted file mode 100644
index 25cbcc2d..00000000
--- a/docs/acme-fs.md
+++ /dev/null
@@ -1,431 +0,0 @@
-# The control filesystem — pardes against acme
-
-`pardes --fs` serves plan9 [acme(4)](https://man.cat-v.org/plan_9/4/acme)'s control
-filesystem over Linux FUSE: a directory per pane, named by the pane's serial and
-holding `addr`, `body`, `ctl`, `data`, `errors`, `event`, `rdsel`, `tag`, `wrsel`
-and `xdata`, plus a `pty/` directory on a terminal pane, plus `index`, `cons`
-and `new/` at the root (`PaneFile` and
-`TopFile` in `src/acmefs.zig`). A program that opens those files IS an editor
-extension — no plugin API, no embedded interpreter, no rebuild.
-`examples/acmefs/` has four of them: `clock.py`, `eventlog`, `life.py` and
-`pardesctl`.
-
-This document is the **comparison report**: what acme does, what pardes does,
-why they differ, and which one is simpler. acme's C is at
-`/home/goblin/05-genizah/principia-softwarica/editors/acme` — the
-principia-softwarica tree and not plan9port, which matters because the two
-differ in what they serve; every `file:line` below is that checkout's. Every
-claim cites both sides. Where acme is better, it says so.
-
-| | acme | pardes |
-|---|---|---|
-| transport | 9P over a pipe, own implementation | raw `/dev/fuse`, own codec (`src/fuse.zig`) |
-| concurrency | 1 server thread + **one thread per in-flight request** | none in the core; one `poll()` thread in the transport |
-| blocking read | park an `Xfid` in `w->eventx`, wake from `winevent` | `Status.again`, re-asked by the transport |
-| offsets | runes | bytes, grapheme-clamped |
-| node ids | `QID/WIN/FILE` shift macros | `packed struct(u64) { file: u4, serial: u60 }` |
-| errors | 9P error strings (`Ebadctl`, `Edel`, ...) | errno |
-| event queue | `realloc` per record, unbounded | length-framed `ArrayList`, capped, drop-oldest |
-| `ctl` write | applies the good prefix, then reports 0 consumed | validate-all then apply-all |
-
-## 1. The service: threads-and-channels against one transaction
-
-acme runs a dedicated process for the wire (`proccreate(fsysproc)`, `fsys.c:136`)
-whose loop reads a 9P message, borrows an `Xfid` from a pool, and dispatches
-through the `fcall[x->type]` function table (`fsysproc`, `fsys.c:140-193`; the
-table itself at `fsys.c:41`, the dispatch at `fsys.c:190`). Directory reads and
-stats are answered inline; anything that touches a window is handed to that
-`Xfid`'s own thread —
-`sendp(x->c, xfidread)` (`fsys.c:660`) — and `xfidallocthread` creates **one
-thread per `Xfid`** on first use (`acme.c:718-744`), each parked in
-`for(;;){ f = recvp(x->c); (*f)(x); ... }` (`xfidctl`, `xfid.c:42-55`).
-Serialisation is by `QLock`: one on the row (`dat.h:313`), one per window plus an
-owner byte (`dat.h:228` and `dat.h:250`, taken together in `winlock1`,
-`wind.c:131-136`), one on the mount table (`struct Mnt`, `fsys.c:96`, taken at
-`fsys.c:201`).
-
-pardes has none of that. A request is a value, an answer is a value, and the
-whole filesystem is one function:
-
-```zig
-pub fn handle(p: *Pardes, req: Req) Reply // src/acmefs.zig
-```
-
-It arrives as an ordinary `Event.fs_req` and leaves as an ordinary
-`Effect.fs_reply` (`src/pardes.zig`), so the transport is the queue every other
-host↔core message already uses, and the core keeps the single-threaded model it
-had. All the concurrency lives in `src/fuse.zig`: one thread that `poll()`s the
-fd and wakes the loop, and a park table for requests the core answered with
-"not yet". The thread never touches core state, never parses a request, and
-never writes a reply — the same discipline `src/file_watch.zig`'s inotify thread
-already followed.
-
-**Simpler: pardes, by a lot.** No channels, no locks, no thread per request, no
-fid bookkeeping, and the semantics are unit-testable with no scheduler and no
-FUSE anywhere near them (`src/acmefs.zig` has 21 such tests).
-
-**What acme buys, honestly:** isolation. Its request threads mean a slow read
-cannot stall the editor. In pardes `handle` runs on the loop thread, so a
-pathological request — reading the body of a 100 MB file, a `ctl get` that
-re-reads a huge file from disk — is a frame the user waits for. The measured
-numbers say this is theoretical rather than practical, but it is a real property
-of the design and the reason acme's complexity exists.
-
-Every measurement in this document is one run of `zig build fs-bench
--Doptimize=ReleaseFast` on an i7-11700. Each row is `handle` called directly —
-no FUSE, no thread — and its figure is the MEAN over the row's reps: 100 000,
-except 10 000 for the 1 MiB read, 200 for the append and 2000 per keystroke row.
-
-| row | per request | allocations, whole row |
-|---|---|---|
-| `getattr` on a 1 MiB body | 19 ns | 0 |
-| `lookup ctl` | 39 ns | 0 |
-| `read body`, 4 KiB | 25 ns | 0 |
-| `read body`, 1 MiB | 25 ns | 0 |
-| `read ctl` | 404 ns | 0 |
-| `read index` | 631 ns | 0 |
-| `readdir` of the root | 39 ns | 0 |
-| `read event` on an empty queue (`Status.again`) | 21 ns | 0 |
-| `write body`, 1 KiB appended to a body growing from 1 MiB | 3.36 ms | 600 |
-
-Two things in that table are the point of having it. The rows that FORMAT —
-`ctl` and `index`, which `bufPrint` a line of `%11d` fields — cost about twenty
-times a row that hands back a slice, and are still well under a microsecond. And
-a 1 MiB `body` read costs exactly what a 4 KiB one does, because it is
-zero-copy: `Payload.region` is a window onto the pane's live text. Every read
-row allocates nothing at all, which is a property the benchmark exists to check
-rather than a pleasing number — a non-zero count there would mean a read had
-stopped answering out of the live text or out of the staging buffer that is
-cleared and never freed. The one row that allocates is the append, at 600
-allocations across 200 writes, and that is the core's whole-body swap plus its
-undo snapshot rather than anything this filesystem does.
-
-## 2. Blocking reads: a parked thread against a returned value
-
-acme's `event` read blocks: `xfideventread` (`xfid.c:994-1025`) stores its `Xfid`
-in `w->eventx`, unlocks the window and sleeps on a channel (`xfid.c:1008-1010`);
-`winevent` (`wind.c:543-569`) appends the record and wakes it; `windelete`
-(`wind.c:217-225`) wakes it with no data so it can answer "window shut down"
-(`xfid.c:1005`); and `xfidflush` (`xfid.c:58-87`) exists solely to cancel a
-parked reader — it walks every column and every window looking for the tag,
-because a blocked 9P read cannot otherwise be interrupted.
-
-pardes returns `Status.again` — "nothing consumed, ask me again" — and that is
-the entire blocking primitive. The core keeps no waiter, no channel, no cancel
-path. The transport parks the kernel's request (`max_slots = 32`, `src/fuse.zig`)
-and re-offers it once per frame, oldest first, with a per-round flag so a
-permanently blocked reader cannot starve the others (`retry`). A `FUSE_INTERRUPT`
-answers the original with `-EINTR`, which is what keeps a SIGKILLed reader out of
-permanent uninterruptible sleep: after a fatal signal `fuse_dev`'s final
-`wait_event` is not killable, so the process survives its own kill until the
-server replies. Three tests pin that mechanism — "again holds the request, retry
-offers it back once per round", "interrupt answers the original with EINTR and
-drops it", and "a full table answers EAGAIN and keeps the descriptor flowing",
-whose comment records that the tempting alternative, gating the read on a free
-slot, wedges a real mount: the INTERRUPT that would free a slot is never read
-either.
-
-Two deliberate differences:
-
-- acme hands back up to `count` bytes and keeps the remainder, so a small read
- can split a record (`xfid.c:1015-1017`). pardes refuses a read smaller than one
- record with `EINVAL`: half a record is unparseable and silently desynchronises
- a client.
-- acme's parked thread survives the client's death (it stays blocked until the
- window produces an event). pardes has nothing to leak — the kernel drops the
- request.
-
-**Simpler: pardes.** **acme buys** an unbounded number of blocked readers; ours
-are bounded by the park table because each one is a held kernel request.
-
-## 3. Node identity
-
-acme packs a qid with macros: `QID(w,q) ((w<<8)|(q))`, `WIN(q)`, `FILE(q)`
-(`dat.h:463-465`) — 24 bits of window id, 8 of file id, no validation. A window
-that dies while a client holds a file open is detected structurally, by every
-handler remembering to check `if(w->col == nil) respond(..., Edel)`
-(`xfid.c:422-425` in `xfidwrite`, and a dozen more; the string is at
-`xfid.c:19`).
-
-pardes uses the type system:
-
-```zig
-pub const Node = packed struct(u64) { file: u4 = 0, serial: u60 = 0 };
-```
-
-`Node.target(node)` is the ONE place that validates, returning a tagged union of
-"top-level file" or "pane file", so no handler re-decodes and none can forget.
-`serial` is the pane's monotonic identity, never reused, so a stale path can go
-dead but can never come to mean a different pane — and `State.forget`, called
-from `deinitPane`, drops the pane's filesystem state at the moment it dies,
-which is also what stops a dead script's reader count from suppressing button
-actions forever.
-
-**Simpler: pardes.** The packed struct is the same bits with the shifts checked,
-the sentinel-terminated `Dirtab` tables become enums with `name()`/`mode()`
-methods, and `Edel`-by-convention becomes `ENOENT` by construction.
-
-## 4. Addressing: runes against bytes
-
-acme's document is `Rune*`, so every read converts. `xfidutfread`
-(`xfid.c:875`) keeps a byte-to-rune cache per window — `w->utflastqid`,
-`utflastboff`, `utflastq` (`xfid.c:891-897`) — and, when it misses, scans from
-the beginning, carrying the comment `/* BUG: stupid code: scan from beginning */`
-(`xfid.c:895`). The address language lives in `addr.c`: `number()`
-(`addr.c:52`), `regexp()` (`addr.c:119`), `address()` (`addr.c:150`), with
-failure reported through out-parameters (`int *evalp`, `uint *qp`) and patterns
-grown one rune at a time.
-
-pardes is byte-addressed end to end (selections, look spots, LSP offsets), so
-**every offset in this filesystem is a byte offset**, clamped to grapheme
-boundaries — the one deliberate incompatibility with acme(4), stated in
-`src/acmefs.zig`'s header and in `examples/README.md`. For ASCII, which is what
-scripts compute with, the two agree. The address parser is the same left-to-right
-state machine as `addr.c` with the C removed: the expression is a slice, the
-cursor is a field, "did not evaluate" is `?Range`, and `limit=addr` is an
-optional rather than a sentinel `-1`.
-
-**Simpler: pardes** — the entire rune↔byte layer and its cache do not exist.
-**acme buys** rune semantics at every boundary, which is what its own manual
-promises; ours promises bytes.
-
-## 5. Events, and the inversion that makes this a plugin API
-
-The record is the same on both sides, byte for byte: origin char, type char, four
-blank-separated decimals, the text, a newline. acme builds it in two halves —
-`text.c:380` formats `"%c%d %d 0 %d %.*S\n"` and `winevent` prepends the owner
-byte at `wind.c:561` — and pardes builds it in one, `formatRecord`
-(`src/acmefs.zig`).
-
-The rule that matters is the inversion: **while a script holds a pane's `event`
-file open, buttons 2 and 3 in that pane belong to the script.** acme spells it
-`if(!external && t->w!=nil && t->w->nopen[QWevent]>0){ winevent(...); return; }`
-(`exec.c:150`, `look.c:35`); pardes spells it as the return value of
-`noteAction` — "true means the core must not perform it" — checked in
-`dispatchPointerBuiltin`. That is how `examples/acmefs/life.py` puts
-`Step Run Stop Clear Random` in a tag pardes has never heard of and makes them
-work. `test/snapshots/acmefs-event.snap` drives the A/B proof and
-`acmefs-event.golden` records it: with a reader attached, a middle click on the
-word `Newcol` changes nothing on screen and delivers `MX0 6 1 6 Newcol`
-(`acmefs-event.golden:9`); with the reader gone, the same click opens a column.
-
-Differences worth knowing:
-
-- **Write-back takes four fields only** — `origin type q0 q1\n`, type in `xXlL`
- — exactly as `xfideventwrite` demands (`xfid.c:791-872`: it reads the origin
- byte, the type char, two `strtoul`s and a mandatory newline, and its `switch`
- takes `x`, `X`, `l`, `L` and sends everything else to `Rescue`, which is
- `err = Ebadevent`). There is no text field, so a client that wants to run text
- not already on screen appends it to the tag and execs that range;
- `examples/acmefs/pardesctl exec` does precisely this — `printf ' %s' "$text"
- >>$d/tag` and then `printf 'Mx%d %d\n' "$q0" "$q1" >>$d/event` — and it is how
- acme clients have always done it.
-- pardes validates a whole batch of records before performing any of them; acme
- performs them as it parses, which `writeEvent`'s own comment in
- `src/acmefs.zig` names as the reason: a malformed batch is otherwise
- half-applied and unrepeatable.
-- acme records the origin byte the RECORD claimed — `w->owner = *p++;` with
- `/* disgusting */` beside it (`xfid.c:812`) — and stamps it onto every record
- it later produces (`wind.c:561`). pardes sets `State.origin` once per update
- from the event kind (`K` keyboard, `M` mouse, `E` a write to body or tag
- through this filesystem, `F` an action through one of its other files),
- parses the character on the way in and drops it. **acme is arguably better
- here**: its owner byte is per-record provenance and a writer can re-attribute
- an action. Against that, a record saying where it came from is worth nothing
- when the sender picks the answer, which is why pardes does not read it.
-
-## 6. Reporting edits: known ranges against a diff
-
-acme reports from the two functions that make edits, which already know their
-range: `textinsert` emits `I` with `q0, q0+n` and the inserted runes
-(`text.c:377-382`), `textdelete` emits `D` with `q0, q1` (`text.c:482`).
-
-pardes has no such pair — every edit lands in one place as a whole new buffer
-(`file_pane.setContent`) — so the range is recovered by diffing there:
-`acmefs.diffSpan` skips the common prefix and suffix in 64-byte chunks through
-`std.mem.eql`, which lowers to vectorised compares, and `noteReplace` emits the
-deletion then the insertion, the same two records in the same order. The
-vectorising is not premature: its own comment records that the byte-at-a-time
-loop it replaced cost 2.4x per keystroke on a 40 KB body. The whole path is
-behind `p.fs.scripted(id)` — that pane's reader count, not the session-wide
-`listeners` total — so an editor nobody is scripting pays one branch. Measured
-cost of a keystroke on a 32 KiB body: 32.9 µs with no listener against 34.1 µs
-with one, **+3.9%**.
-
-**acme is better here in principle** — a known range beats a scan — and it pays
-for it by routing every mutation through a pair of functions that carry ranges
-everywhere. pardes's single funnel is worth more than the scan costs.
-
-Undo grouping is acme's `mark`/`nomark` on both sides: acme bumps a global
-sequence number and merges an `elog` of edits (`elog.c`; `if(w->nomark == FALSE)
-{ seq++; filemark(t->file); }` at `xfid.c:501-504`); pardes suppresses the
-per-write `pushUndo` snapshot. Same verb, same effect on the user's `u`, much
-less machinery — and the reason it matters is measurable: the benchmark's append
-row is 3.36 ms per 1 KiB write against a body around a megabyte, almost all of
-it the whole-body swap and that snapshot, so a script writing a batch should say
-`nomark` first.
-
-## 7. `ctl`
-
-Both print the same five `%11d` fields — id, tag length, body length, isdir,
-dirty (`winctlprint`, `wind.c:534-535`) — and pardes adds acme's three extras
-(`wind.c:537-538`) with the one honest substitution: width and tab in **cells**,
-because pardes is a character grid where acme has pixels (`Dx(w->body.r)`).
-
-The verb parsers differ in two ways that matter:
-
-- acme matches verbs by **prefix** with `strncmp` and advances by the matched
- length (`xfid.c:602-767`), so the table order is load-bearing: `delete`
- (`xfid.c:697`) must precede `del` (`xfid.c:701`), `nomark` (`xfid.c:738`)
- `mark` (`xfid.c:742`), `nomenu` `menu`, `noscroll` `scroll`. pardes matches a
- whole token through `std.meta.stringToEnum`, which makes that class of bug
- unrepresentable.
-- acme applies verbs as it parses, so a bad verb leaves the good prefix applied
- — the mutations already made stand — and then reports **zero** bytes consumed:
- `err = Ebadctl` (`xfid.c:769`) falls through to `if(err) n = 0; fc.count = n;`
- (`xfid.c:780-782`), and `xfideventwrite` repeats it verbatim at
- `xfid.c:863-865`. So the client learns that it failed but not where, and the
- editor has already been half-changed. pardes validates every verb first and
- then applies them: a short count on a Linux `write(2)` is not read by anybody
- as "the rest failed", and a half-applied batch is unrepeatable. **This is not
- a trade**; the atomic answer is simply the better one.
-
-Verbs pardes cannot honour are refused loudly with a reason —
-`refused_verbs` is `dump`, `dumpdir`, `font`, `lock`, `menu`, `nomenu`,
-`unlock` — rather than silently accepted.
-
-## 8. Errors
-
-acme answers with strings: `Edel` "deleted window", `Ebadctl` "ill-formed control
-message", `Ebadaddr` "bad address syntax", `Eaddr` "address out of range",
-`Ebadevent` "bad event syntax" (`xfid.c:19-24`), handed through
-`respond(x, &fc, err)`. pardes answers with an errno, because that is the only
-channel FUSE has: the client would never see the string. Two acme errors that
-differ in wording collapse to `EINVAL` here, which is a real loss of
-diagnostics — the message row and `PARDES_LOG` carry the detail instead.
-
-## 9. Memory and bounds
-
-acme grows `w->events` with `realloc` and never caps it (`wind.c:560`), and
-re-allocates the remainder on every partial read — `w->events =
-estrdup(w->events+n); free(b);` (`xfid.c:1022-1023`). A client that stops reading
-grows that buffer until `emalloc` fails and acme aborts.
-
-pardes's queue is length-framed (records contain newlines, so a length is the
-only way to hand one back whole), capped at 64 KiB per pane (`queue_cap`), and
-drops the oldest record when full: an editor must not stall or grow without bound
-because a script stopped reading, and a reader that far behind can re-read `body`
-and resynchronise. Formatted answers go into one staging buffer that is cleared
-and never freed, which is why every read in the benchmark reports zero
-allocations. **acme's unbounded buffer is a flaw, not a feature.**
-
-## 10. C-isms Zig removed
-
-Ranked by what they cost when they go wrong:
-
-1. **Threads and channels standing in for a state machine** — one thread per
- in-flight request (`acme.c:718-744`, `xfid.c:42-55`) → a `Status.again` return
- value and a park table in the transport.
-2. **Macro-packed qids** — `QID/WIN/FILE` (`dat.h:463-465`) → `packed
- struct(u64)` with one validating constructor.
-3. **`Rune*` plus a byte-to-rune cache** with a scan-from-zero fallback
- (`xfid.c:891-897`) → byte slices clamped to grapheme boundaries.
-4. **`strtoul` pointer walking with `goto Rescue`** (`xfid.c:810-838`) → a slice
- reader returning `?u32`.
-5. **`longjmp`-ish `error()`** that aborts the process (`util.c:50-55`) → an
- error union and a `Reply` value.
-6. **Manual `realloc` growth** (`wind.c:560`) → `ArrayList` with retained
- capacity.
-7. **Sentinel-terminated tables** (`dirtab`, `fsys.c:62-74`; `dirtabw`,
- `fsys.c:76-91`) → exhaustive enums, so adding a file to the tree does not
- compile until every switch has an answer for it.
-8. **`sprint` into fixed buffers** (`wind.c:534`) → `bufPrint` returning an
- error.
-9. **Ownership by convention** — `fbufalloc`/`fbuffree` pairs the caller must
- match (`fns.h:5-6`) → `defer`, plus two explicit borrow windows
- (`Payload.staged`, `Payload.region`) documented at the seam.
-10. **Prefix-matched command tables** whose order is load-bearing
- (`xfid.c:602-767`) → whole-token enum lookup.
-
-One property comes along with the transport rather than with either design: a
-9P `Twrite` IS a message, so acme never sees a fragment, while a POSIX client
-can call `write(2)` with one byte. A `ctl` verb, an `addr` expression and an
-`event` record must therefore each arrive in a single write here, and a
-fragment is EINVAL rather than state kept in the editor waiting for the rest.
-Every ordinary client already does this (stdio buffers; `echo`, `dd` and
-`os.write` are one call each), and the alternative — a per-pane line buffer —
-would trade a clear error for a half-applied verb that never completes.
-
-## What is not served, and why
-
-`acme`, `consctl`, `draw`, `editout`, `label` — acme's root `dirtab`
-(`fsys.c:62-74`) keeps them for rio and for its own `Edit` language, neither of
-which pardes has, and `editout` appears in the per-window `dirtabw`
-(`fsys.c:76-91`) for the same reason. Everything else in `dirtabw` is here:
-`addr`, `body`, `ctl`, `data`, `errors`, `event`, `rdsel`, `tag`, `wrsel`,
-`xdata`, and `index`, `cons` and `new/` at the root. `log` is NOT — and this is
-the one entry that is not a decision about acme, because this acme's `dirtab`
-has no `log` either; it is a plan9port addition. No example needed it, and a
-script that wants to notice panes it did not open reads `index`, which is what
-acme gives it.
-
-## What is served that acme does not have: `pty/`
-
-One directory, three files, and **no prior art anywhere**: acme has no terminals
-and `ad` — the other editor that serves a control filesystem over 9P — has no
-terminal surface at all (its tree is `{ctl, minibuffer, scratch, log,
-buffers/…}`). So there is nobody's mistakes to learn from and nobody's scripts
-to keep compatible, which is the argument for keeping it to three files and
-stopping.
-
-A pty is a file interface wearing the wrong clothes: everything one wants to do
-to it is an `ioctl`, and neither 9P nor FUSE has one. They become writes.
-
-| ioctl | here |
-|---|---|
-| `TIOCSWINSZ` | `winsize 80 24` → `pty/ctl` |
-| `kill` | `sig INT` → `pty/ctl` |
-| spawn | `exec` → `pty/ctl` |
-| `TIOCGWINSZ` | read `pty/status` |
-| `read`/`write` | `pty/data` |
-
-Two of the three verbs are effects the core already had — `push_spawn` and
-`push_pty_resize` — so `exec` and `winsize` are existing capabilities acquiring
-a name. `sig` is the one new host capability in the whole directory
-(`push_pty_signal`, and there was no `kill` anywhere in `host_io.zig` before
-it); it targets the tty's foreground process group rather than the shell's pid,
-because an interactive shell ignores SIGINT while it waits for a job.
-
-The directory is **absent** on a pane that is not a terminal, rather than
-present and refusing, so `test -d <id>/pty` is how a script asks what kind of
-pane it has. `pty/data`'s read is the only new state: the core keeps no raw pty
-bytes anywhere (they go into the emulator grid, which is a rendering and cannot
-be turned back into a stream), so they are queued as they arrive — gated on a
-reader count exactly as `event` is, so a pane nobody is reading costs one
-branch and no memory, and capped drop-oldest by the same `queue_cap`. Unlike
-`event`, a read smaller than one arrival is SERVED and the remainder kept: raw
-bytes have no record framing to split down the middle.
-
-Three things it deliberately does not have. There is no **exit status**,
-because the core does not track one: a shell's death arrives as `Event.eof`,
-whose whole handler is `removePane`, so by the time anyone could read a status
-the directory is gone. There is no **`raw`/`cooked`**, because the termios
-belongs to the program on the far side of the pty and it never reports one. And
-`exec` takes **no argv**, because `Effect.spawn` carries a pane and a cwd and
-has nowhere to put one — so `exec /bin/sh` is EINVAL rather than an argument
-accepted and silently ignored.
-
-`Node.file` is a `u4`. These four variants (`pty`, `pty_ctl`, `pty_status`,
-`pty_data`) take it to fifteen of sixteen used: **one value is left**, and the
-next file added to a pane's directory needs a wider field and therefore a new
-node-id layout. That is also why `pty/` is a directory rather than three more
-names beside `body` — a subdirectory costs one value and buys a namespace, so
-`ctl` and `data` did not have to be spelled twice.
-
-## Reading order
-
-`src/acmefs.zig` (semantics; start at its header), `src/fuse.zig` (the wire),
-`src/fs_service.zig` (mount lifecycle), `examples/README.md` (the client's view),
-`test/snapshots/acmefs.snap` and `acmefs-event.snap` (the drivers, i.e. what is
-exercised end to end) beside their `.golden` files (what was observed),
-`zig build fs-bench` (what it costs).