From 29ac9be75fdcafbd7d05c15aa9eb8490d74caa98 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 26 Aug 2026 18:58:37 -0300 Subject: An edited row keeps its colours, four copies of forkShell become one, and Esc stops recentring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## A terminal row's ANSI colours survive being edited The loudest colour bug this editor had: one keystroke anywhere in a coloured shell row turned EVERY column of it grey. `EditAnchors` anchored a buffer line only when it was BYTE-IDENTICAL to the shell row it stood over, so a single differing byte dropped the whole row's colour projection. Worst shape is invisible: append past the pane's right edge, where the text is clipped, and the row looks the same and only its colour goes. Anchoring is byte-level now. An edit leaves the row's own bytes at both ends, and being the same bytes they keep the same colours; only what was typed has no cell under it, so only that takes none. Live, on real `fastfetch`: a 32-column blue run split into 6 + 26 around one typed character. Three defects underneath it, all found by machinery rather than by reading: * A JOIN removes a buffer line while the buffer's covered span grows, so `lines == covered` and both aligned guesses — Nth line over the Nth covered row, and the same counted from the bottom — resolved to the SAME wrong row. Every untouched row below a join went plain. Anchoring is now a streaming monotone matching: one shell-row cursor that only ever moves forward, advanced once per buffer line, linear in the buffer where the version before it was quadratic. * An EMPTY line is not evidence. Splitting a row makes one, it equals every blank row in the span, and left free to look ahead it claimed the blank row below the last output and took every coloured row in between out of reach of the lines that owned them. * Reflow under a scrolled viewport. `PageList.getTopLeft(.viewport)` returns the viewport pin verbatim, x and all, while `PageList.pin` forces x to 0 — so after a reflow remapped a tracked pin into the middle of a row, the text pass dumped row 0 from that column while the colour pass paired the fragment with the row's FIRST cells. Row 0 wore its left half's colours until the pane snapped back to live output. `bodyText` dumps from column zero now, which is also what ghostty's own renderer draws. Also here: DECSCNM (reverse video) was silently dropped whenever `tty_filter` was off, because the raw path resolved a `.none` colour by role and never consulted the mode. The test that found the first two is the one worth keeping: random editing against an ABSOLUTE oracle — every row's own text names the colour it must have — because the differential oracle it replaced was blind by construction. It skipped the edited row, which is the row the user is complaining about. ## Esc returns to a pane without moving its view Esc in body normal mode runs `Last`, "the pane you were in before this one", and that went through `focusPaneLine`, which recentred a file on the target line unconditionally. So returning to a buffer repainted the whole screen to show a line that was already on it. `focusPaneLine` takes a landing now: `.center` for the three callers going somewhere you have not been (a look target, a path a pane already holds, `@pN:LINE:COL`), `.keep` for Esc. `.keep` leaves the view alone and lets `ensureCursorVisible` — which already existed and already scrolls by the minimum into the `scroll_off` band — be the only thing that may move anything. Not `line = 0`, which `focusPaneLine` already understands as "focus and touch nothing": a background pane's view can move while you are away, because the wheel scrolls the pane under the POINTER and a resize reveals no cursor, so the recorded cursor plus a minimal nudge is what actually gets you back. Ctrl-o and Ctrl-i keep centring, and the asymmetry is structural rather than arbitrary: `Last` only ever CROSSES panes, so the pane it lands on already holds the view you left it with, while `jumpBy` can land in the SAME pane, where a long in-file jump would arrive on the very top or bottom row with `scroll_off` lines of context on one side. Helix splits the same pair the same way — its jumplist centres, its buffer switch does not. One deliberate consequence: under `.keep` a PDF's page is not restored AT ALL, because a page reveal IS that pane's view and a reveal of the page you are already on still snaps `document_scroll_y` to that page's start, discarding where you had read to. When something moved the pane while you were away — the wheel again — Esc leaves it where the wheel left it, and Ctrl-o is how you reach the recorded page. ## host_io.zig: the machine-local half of a host, once `host.zig` is the seam. The part of the answer that is identical on every host with an operating system under it — fork a pane's shell, put bytes on a disk — was written FOUR times: in tty.zig, gui.zig, macos.zig and detached/server.zig. What those copies had in common says what they were for: all four were missing FD_CLOEXEC on the pty master, so in every shell pardes has shipped, a program in one pane could read another pane's terminal. One copy now, and the wire got smaller for it: `ServerMsg.spawn` is gone. A frontend never asked the server to fork anything — the server has an operating system under it and forks through `host_io` like every other host — and `decodeClient` lost the scratch buffer that message needed. --- docs/acme-fs.md | 311 +++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 193 insertions(+), 118 deletions(-) (limited to 'docs/acme-fs.md') diff --git a/docs/acme-fs.md b/docs/acme-fs.md index ede3ed17..15f45c4f 100644 --- a/docs/acme-fs.md +++ b/docs/acme-fs.md @@ -1,15 +1,20 @@ # 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. +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 `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`; every claim below -cites `file:line` on both sides. Where acme is better, it says so. +`/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 | |---|---|---| @@ -20,19 +25,23 @@ cites `file:line` on both sides. Where acme is better, it says so. | 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 | +| `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 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`). +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: @@ -52,37 +61,73 @@ 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). +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 (`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. +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: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, +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 (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). +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:378-380`). pardes refuses a read smaller than one + 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 @@ -95,10 +140,11 @@ 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 +(`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:414-421` and a dozen more). +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: @@ -120,12 +166,14 @@ 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. +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 @@ -142,39 +190,48 @@ 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 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->nopen[QWevent] > 0) { winevent(...); return; }` +`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` 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. +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: -- **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 + — 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. -- 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. + 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 @@ -184,96 +241,110 @@ range: `textinsert` emits `I` with `q0, q0+n` and the inserted runes 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`). +`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`, `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. +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 (`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. +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, 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. + 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: `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. +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 (`xfid.c:578-580`). A client -that stops reading grows that buffer until `emalloc` fails and acme aborts. +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, 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.** +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:744`, `xfid.c:64-74`) → a `Status.again` return + 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:434-436`) → `packed +2. **Macro-packed qids** — `QID/WIN/FILE` (`dat.h:463-465`) → `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 +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`) → an error - union and a `Reply` value. +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** (`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 +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:283+`) → whole-token enum lookup. + (`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 @@ -286,17 +357,21 @@ 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. +`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. ## 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), +`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). -- cgit v1.3