1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
|
# 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 `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.
## 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).
|