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
|
# 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).
|