summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/acme-fs.md302
1 files changed, 302 insertions, 0 deletions
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).