From 6f48508aa08396bcf9dd4da2cab1d221bcc53f78 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 25 Aug 2026 02:07:23 -0300 Subject: acmefs: pardes --fs serves acme's control filesystem over raw Linux FUSE --- docs/acme-fs.md | 302 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 docs/acme-fs.md (limited to 'docs') diff --git a/docs/acme-fs.md b/docs/acme-fs.md new file mode 100644 index 00000000..ede3ed17 --- /dev/null +++ b/docs/acme-fs.md @@ -0,0 +1,302 @@ +# 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). -- cgit v1.3