# 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 holding `addr`, `body`, `ctl`, `data`, `event`, `tag`, ..., plus `index`, `new/` and `cons` at the root. A program that opens those files IS an editor extension — no plugin API, no embedded interpreter, no rebuild. `examples/acmefs/` has four of them. 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`; every claim below cites `file:line` on 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 | 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 a function table (`fsys.c:152-201`). 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:355`) — and `xfidallocthread` creates **one thread per `Xfid`** on first use (`acme.c:744`), each parked in `for(;;){ f = recvp(x->c); (*f)(x); ... }` (`xfid.c:64-74`). Serialisation is by `QLock`: one on the row (`dat.h:329`), one per window plus an owner byte (`wind.c:135`), one on the mount table (`fsys.c:270`). 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 28 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 (`zig build fs-bench`: 20-52 ns per request, and a 1 MiB `body` read costs the same 25 ns as a 4 KiB one because it is zero-copy), but it is a real property of the design and the reason acme's complexity exists. ## 2. Blocking reads: a parked thread against a returned value acme's `event` read blocks: `xfideventread` (`xfid.c:553-582`) stores its `Xfid` in `w->eventx`, unlocks the window and sleeps on a channel; `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"; and `xfidflush` (`xfid.c:77-102`) exists solely to cancel a parked reader, 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 (32 slots, `src/fuse.zig`) and re-submits it once per frame; a `FUSE_INTERRUPT` answers the original with `-EINTR`, which is what keeps a SIGKILLed reader from sitting in uninterruptible sleep forever (verified live: 40 concurrent blocked readers, all reaped). Two deliberate differences: - acme hands back up to `count` bytes and keeps the remainder, so a small read can split a record (`xfid.c:378-380`). 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:434-436`) — 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:414-421` and a dozen more). 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` keeps a byte↔rune cache per window and, when it misses, scans from the beginning — carrying the comment `/* BUG: stupid code: scan from beginning */` (`xfid.c:855`). The address language lives in `addr.c`: `address()`, `number()`, `regexp()`, with failure reported through two out-parameters 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 (`wind.c:543-569` + `text.c:377-382`; `formatRecord` in `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->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` is the A/B proof: with a reader attached, a middle click on the word `Newcol` changes nothing on screen and delivers `MX0 6 1 6 Newcol`; with the reader gone, the same click opens a column. Differences worth knowing: - **Keyboard equivalents are not suppressed** (acme has none to suppress). A scripted pane stays editable, and a script that dies mid-run cannot leave you unable to execute anything in it. - **Write-back takes four fields only** — `origin type q0 q1\n`, type in `xXlL` — exactly as `xfideventwrite` demands (`xfid.c:791-830`, which rejects anything else with `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, 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. - acme records the origin byte the window's lock owner claimed; pardes sets `State.origin` once per update from the event kind (`K` keyboard, `M` mouse, `E`/`F` a filesystem write). Same fidelity for every real case, one field instead of a lock argument. **acme is arguably better here**: its owner byte is per-record provenance, and a writer can re-attribute an action. ## 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 through vectorised compares, and `noteReplace` emits the deletion then the insertion, the same two records in the same order. It is behind `p.fs.listeners != 0`, so an editor nobody is scripting pays one branch. Measured cost of a keystroke with a listener attached on a 32 KiB body: **+1.6%** (`zig build fs-bench`). **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`, `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: an append to a 1 MiB body costs 25 ms because of that snapshot, so a script writing a batch should say `nomark` first. ## 7. `ctl` Both print the same five `%11d` fields (`winctlprint`, `wind.c:532-537`), and pardes adds acme's three extras with the one honest substitution: width and tab in **cells**, because pardes is a character grid where acme has pixels. The verb parsers differ in two ways that matter: - acme matches verbs by **prefix** with `strncmp` and advances by the matched length, so the table order is load-bearing (`delete` before `del`, `nomark` before `mark`). pardes matches a whole token through `std.meta.stringToEnum`, which makes that class of bug unrepresentable. - acme applies verbs as it parses and reports the byte count it consumed, so a bad verb leaves the good prefix applied (`xfid.c:778-781`). pardes validates every verb first and then applies them, because a short count on a Linux `write(2)` is not read by anybody as "the rest failed". **acme is more expressive here** — a 9P client can stream verbs and learn where it stopped — and pardes trades that for atomicity. Verbs pardes cannot honour are refused loudly with a reason (`dump`, `dumpdir`, `font`, `menu`, `nomenu`, `lock`, `unlock`) rather than silently accepted. ## 8. Errors acme answers with strings: `Ebadctl` "ill-formed control message", `Ebadaddr` "bad address syntax", `Eaddr` "address out of range", `Edel` "deleted window" (`xfid.c:20-30`), 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 (`xfid.c:578-580`). 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, 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:744`, `xfid.c:64-74`) → a `Status.again` return value and a park table in the transport. 2. **Macro-packed qids** — `QID/WIN/FILE` (`dat.h:434-436`) → `packed struct(u64)` with one validating constructor. 3. **`Rune*` plus a byte↔rune cache** with a scan-from-zero fallback (`xfid.c:855`) → byte slices clamped to grapheme boundaries. 4. **`strtoul` pointer walking with `goto Rescue`** (`xfid.c:791-830`) → a slice reader returning `?u32`. 5. **`longjmp`-ish `error()`** that aborts the process (`util.c`) → an error union and a `Reply` value. 6. **Manual `realloc` growth** (`wind.c:560`) → `ArrayList` with retained capacity. 7. **Sentinel-terminated tables** (`fsys.c:57-73`) → 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:533`) → `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:283+`) → 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`, `draw`, `consctl`, `label`, `editout` — acme keeps them for rio and for its own `Edit` language, neither of which pardes has. `xdata`, `rdsel`, `wrsel`, `index`, `cons` and `new/` are all here. `log` is NOT: it is a plan9port addition this acme's `dirtab` does not have, no example needed it, and a script that wants to notice panes it did not open reads `index` — which is what acme gives it. It cost a second queue, a focus hook in the core and ~100 lines, and it went out in review. ## 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` (what is proven end to end), `zig build fs-bench` (what it costs).