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
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
|
// Second draft. The first is superseded; what changed and why is recorded
// entry by entry in `docs/registry.typ`, and every §-to-entry link below is
// live. Build: typst compile docs/9p.typ docs/9p.pdf
#let app = "PaRDeS"
#set page(
paper: "us-letter",
margin: (x: 1.5in, y: 1.2in),
numbering: "1",
number-align: center,
)
#set text(font: "Libertinus Serif", size: 11pt, lang: "en")
#set par(justify: true, leading: 0.62em, first-line-indent: 1.5em)
#show raw: set text(font: "DejaVu Sans Mono", size: 8.5pt)
#show raw.where(block: true): block.with(inset: (left: 1.5em, y: 0.4em), width: 100%)
#set table(stroke: none, inset: 0.35em)
#set heading(numbering: "1.")
#show heading: it => {
set text(size: 11pt, weight: "bold")
v(1.1em, weak: true)
block(counter(heading).display() + h(0.6em) + it.body)
v(0.4em, weak: true)
}
#show heading.where(level: 1): it => {
set text(size: 11pt, weight: "bold")
v(1.4em, weak: true)
block(counter(heading).display() + h(0.6em) + it.body)
v(0.5em, weak: true)
}
/// A registry cross-reference. Every contestable claim in this note is an
/// entry over there, with its evidence and its losing arguments attached.
#let r(id) = text(size: 9pt)[#raw(id)]
#align(center)[
#v(0.5em)
#text(size: 15pt)[A File Interface for an Editor and Terminal Multiplexer]
#v(0.9em)
#text(size: 10.5pt, style: "italic")[architecture draft, second revision]
#v(0.4em)
#text(size: 10pt)[#datetime.today().display("[year]-[month]-[day]")]
]
#v(1.2em)
*Historical proposal, superseded by `docs/fs.md`.* Pardes now serves 9P by
default over Unix sockets, with optional TCP and QUIC. FUSE support and the
old examples have been removed. The arguments and source references below
describe the earlier implementation, not the current interface.
#block(inset: (x: 2.5em))[
#set text(size: 10pt)
#set par(first-line-indent: 0em)
#text(weight: "bold")[ABSTRACT] #h(0.8em)
#app is an editor with attached terminals. It runs as a daemon; clients attach
to it. It already serves acme's control filesystem, over FUSE, on Linux only.
This note adds 9P as that filesystem's second transport, and as a carrier for
the display protocol it already has. The program becomes a file server that
needs no kernel, so the tree reaches macOS, a browser tab, a microcontroller
and another machine, none of which FUSE can reach. The same program is also a
9P client, which is how it edits a file on a board it cannot mount. We
describe the interface, what it costs — measured against two shipping
implementations rather than estimated — and, at greater length, the parts we
do not build. The first draft of this note proposed replacing FUSE and
replacing the wire protocol. Both are wrong, for reasons that turn out to be
cheap to state and expensive to discover.
]
= What this is for
One sentence, because every other section is downstream of it.
*PaRDeS has no way to ask anything a question.* It can ask the kernel, and that
is all. Two running instances can do exactly two things to each other: shout —
`nested.sendLook` formats one line, connects, writes, returns, and never reads,
and its own test asserts that nothing comes back — or *become*, which is what
`Attach` does, and `Attach` is not a connection but a replacement: on a
successful handshake the frontend reaps its pane shells, closes its watches,
unmounts its filesystem, deinitialises the core, and turns into a thin client of
somebody else's. Nothing in the tree reads `PARDES_FS`. `Event` has no variant
for a peer. The 21-method host vtable has no peer method. Reading another
session's text is possible only by shelling out to `cat` a path derived from a
pid you must already know, on Linux, if that session was started with `--fs` —
and impossible against a detached session, which serves no filesystem at all.
9P is a request/response protocol that crosses machines. That property is the
one thing none of the three private mechanisms can be extended to have, and
every item on the wanted list is the same feature wearing different clothes:
scripting from macOS, driving a session over a network, reading the board's
memory, chaining one instance to another without destroying either. All of it
is *being able to ask, and get an answer back*.
That is the justification, and it is the only one. In particular it is not
simplification: §12 puts the honest figure at between 750 and 1,050 lines of
net growth, and the measurement is in the registry, not in a hope. What does
fall is the number of ideas — four framings become two, three schemes for
"which instance?" become two, two version negotiations become one, and six
concepts currently duplicated across 799 lines become one copy each. Fewer
ideas, more lines. Anyone selling this as "simpler" has not counted.
= What changed, and why this draft exists
The first draft argued that 9P should replace two things: the FUSE mount, and
the private protocol on the daemon's socket. Neither survives contact with the
sources.
It cannot replace the mount. Linux grades mount privilege by a per-filesystem
flag, and 9P and FUSE are on opposite sides of it. `mount_capable` falls back to
`capable(CAP_SYS_ADMIN)` — root in the *initial* namespace — for any filesystem
that does not set `FS_USERNS_MOUNT`; FUSE sets it, v9fs does not. So
`mount -t 9p` costs real root, a user namespace does not help, and `trans=fd`
does not help because the syscall is checked before the transport is consulted.
The 281 lines in `src/fuse.zig` that fork a setuid helper and receive a
descriptor over `SCM_RIGHTS` are not overhead; they buy an unprivileged
pathname, which 9P cannot buy at any price on Linux. The usual escape, `9pfuse`,
is FUSE again in someone else's process — which is exactly what `ad`, the one
editor that already took this route, ships #cite(<ad>). #r("9P-13")
It cannot replace the wire either, because the wire's payload is a compressed
cell grid and 9P has no server-initiated message. But the interesting half of
that finding is that 9P *can carry* the wire unchanged, and that this is how
remote display has actually shipped for thirty years #cite(<devdraw>). #r("9P-12")
What is left is better than what was proposed, and smaller.
= Two layers
The first draft's mistakes are almost all one layer's property asserted about
the other, so the layers are worth naming before anything else.
*Layer 1 is the control tree.* Request and response, nine operations, a pure
transaction over the core: `handle(p, req) Reply` in `src/acmefs.zig`, whose ABI
already records that "FUSE opcodes, 9P messages and a unit test all reduce to
these". It is transport-neutral today and unit-tested with no transport at all.
*Layer 2 is the display protocol.* Nineteen message tags in
`src/detached/wire.zig` carrying run-length-encoded cell grids and input, one
message per frame, pushed by the server to N frontends that share one screen.
They share a machine and nothing else. The tree is not private and not
undocumented — it is acme(4) #cite(<acme>). The wire is both, and deliberately:
it is a codec, and its cost is measured at seven lines per new fact, which is
cheap for what it does.
9P is Layer 1's second transport and Layer 2's carrier. It is never a
re-encoding of either. #r("9P-1")
= The interface
A session is a tree. Windows are numbered directories under it.
```
/
ctl session control; read lists windows
index one line per window: id, tag, flags
new/ walking here creates a window
7/
ctl commands in; id and state out
tag the tag line
body the text
addr an address
data read and write at addr
event the interesting one; see below
errors writes appear in the error window
pty/
data the stream
ctl winsize, raw, cooked, sig, exec
status dimensions, mode, exit status
```
This is acme's tree plus `pty/`, minus the plan9 compatibility stubs. Four
conventions matter, and the first draft got three of them wrong.
*Offsets are honoured everywhere they mean something.* `body`, `tag`, `addr`,
`ctl`, `index` and `rdsel` are seekable files and are read at the offset the
message carries. acme does the same, and its entire byte-to-rune cache exists to
serve a non-zero offset on `body`. Only `data` and `event` ignore offsets, and
only because they are positioned by `addr` and by the queue respectively. All
writes ignore offsets: a write to `body` appends, which acme(4) specifies. A
server that ignored offsets on `body` would make `cat`, `wc`, `diff` and `tail`
return the first chunk forever, which is the opposite of the whole point.
*Directory reads carry a cookie, not an index.* 9P requires a directory read at
offset zero or at exactly the byte offset where the previous read ended, and the
reply must contain whole `stat` entries; anything else is `Ebadoffset`. The core
counts entries, so the transport keeps a per-fid byte cursor and the one entry
that did not fit. This is the only genuine offset discontinuity between FUSE and
9P, and it is about forty lines. #r("9P-5")
*The address belongs to the window.* The first draft claimed per-fid and
attributed it to acme; acme keeps `Range addr` in `struct Window`, #app keeps it
per pane, and `ad` keeps it per buffer. Per-fid is a real improvement — two
scripts could then address one window without colliding — but it is a proposal,
not a restatement, and it is the only thing in this note that asks the core to
grow state. It is argued on its own merits or not at all. #r("9P-6")
*`event` is opened by a client, not by a fid.* One controller per window is
right; enforcing it per fid is not, because a client that opens the file twice
is not two controllers. `ad` reaches the same conclusion and scopes `QTEXCL` to
a connection. #r("9P-7")
= Events
A window handles its own mouse and keyboard until a program opens `event`. From
then on it ships them out: origin, action, the character range, the text. The
program reads a record, decides, and writes it back to accept it; or handles it
itself and stays silent.
This is the whole extension mechanism and it already works — `examples/acmefs/`
has four programs that use it, one of which puts words #app has never heard of
into a tag and makes them execute. There are no callbacks and no plugin API. A
program that wants to redefine what the right button does opens a file and reads
it.
A read blocks. The core does not: it answers `again` — nothing consumed, ask me
later — and the transport holds the request. That inversion is what lets a
single-threaded core serve a filesystem at all, and it is worth stating plainly
because the prior art does not have it. `ad` spends three to four threads per
connection and then serialises all of them behind one mutex and a blocking
round trip into the editor thread. #app answers a blocked `event` read in
twenty-one nanoseconds and allocates nothing.
= Terminals
A pty is a file interface wearing the wrong clothes. Everything one wants to do
to it is an `ioctl`, and 9P has none in any dialect. They become writes to
`pty/ctl`:
#v(0.3em)
#align(center)[
#set text(size: 9.5pt)
#table(
columns: 2,
align: left,
column-gutter: 2em,
row-gutter: 0.3em,
[`TIOCSWINSZ`], [`winsize 80 24`],
[`TCSETS`], [`raw`, `cooked`, `echo off`],
[`kill`], [`sig INT`],
[`TIOCGWINSZ`], [read `status`],
[spawn], [`exec /bin/sh`],
)
]
#v(0.3em)
`pty/data` is the stream. A write is input to the process; a read blocks until
there is output. On exit a read returns zero bytes rather than an error, because
clients already know what end of file means.
Two things about this section that the first draft did not say. It has *nothing
to do with 9P*: `push_spawn` and `push_pty_resize` are effects the core already
has, so `exec` and `winsize` are existing capabilities acquiring a name, and
`sig INT` is the only new one. Build it in the FUSE tree first and the 9P
transport inherits it. And it has *no prior art anywhere*: acme has no pty
files, `ad` has no terminal surface at all. Being first is a reason to keep it
to three files and stop. #r("9P-8")
Every keystroke in raw mode is a round trip. On a local socket this does not
matter. Over a slow link it matters a great deal, which is why Plan 9's terminal
programs do line editing locally and send whole lines. Raw mode should be
entered deliberately and left promptly.
= Layering: how 9P and the wire meet
Six arrangements were costed. The finding that decides it is that the cost is
almost the same in all of them: a base-9P2000 codec, a dispatcher onto the
existing nine operations, and a fid table come to roughly 970 lines that appear
in every option. Layering moves about 150 lines either way. So this is not a
cost question. It is a question of reach.
#v(0.3em)
#align(center)[
#set text(size: 9pt)
#table(
columns: (auto, auto, auto, 1fr),
align: (left, right, right, left),
stroke: (y: 0.4pt),
table.header([], [msgs/frame], [msgs/key], [what decides it]),
[O1 9P replaces all], [2], [4], [64 park slots needed against 32; a round trip per frame],
[O2 two listeners], [1], [2], [no routing origin for the 9P descriptor],
[O3 9P inside the wire], [1], [2], [*works over the board's UART unchanged*],
[O4 wire inside 9P], [2], [4], [+34 B on a 56 B diff, or +61%],
[O5 side by side], [1], [2], [cheapest, but cannot reach the board],
[O6 frontend serves], [2], [4], [core would write and match tags inside `update`],
)
]
#v(0.3em)
That table is superseded, and the correction is the most important thing in
this note. It was written believing 9P has no way to push a frame, so every
arrangement that carried frames over 9P paid a round trip. The premise is
wrong — not because 9P can push, but because *the pushing end should be the
client.*
Plan 9 has shipped this for thirty years and it needs no invention. `drawterm`
dials out to a cpu server, writes a shell script, and then calls
`exportfs(fd, fd)`: the dialer becomes the *server* on the socket it dialed.
The script the remote runs is `mount -nc /fd/0 /mnt/term`, and the remote is
the *client*. When the remote application draws, the bytes cross as the
application's `Twrite` to `/dev/draw/N/data`, and the terminal — the machine
with the screen — executes them. One descriptor, roles fixed per side, data
flowing both ways, no server-initiated message anywhere.
So the rule is one role per connection, not one role per message:
#v(0.3em)
#align(center)[
#set text(size: 9.5pt)
#table(
columns: (auto, auto, 1fr),
align: (left, left, left),
column-gutter: 1.2em,
row-gutter: 0.3em,
[*display*], [frontend serves], [core `Twrite`s frames to `screen`, blocking-`Tread`s `input`],
[*session*], [core serves], [scripts and other instances walk `7/body`, `event`, `ctl`],
[*devices*], [board serves], [core reads `mem/`, `gpio/`, `prof`],
)
]
#v(0.3em)
Three connections, one role each; the program contains both halves and uses
whichever the link calls for. Do *not* build a link that carries both roles at
once. 9P permits it — T-messages are even and R-messages odd, so a stream is
self-demuxing — but nothing has ever done it, and it buys a `Tversion` ordering
hazard in which each side must answer the peer's version while awaiting its
own.
Two numbers make the display path credible. Chunking is a non-issue: payload
per message is 131,072 bytes at Linux's default, so every real frame — 20 B, a
56 B keystroke diff, a 6,298 B full board frame, a 27 KB desktop frame — is one
`Twrite`, and the 1.7 MB worst case is thirteen. And the core need not block:
tags are allocated per outstanding request with no in-order reply requirement,
which is why `exportfs` can answer out of order at all. Where `exportfs` needs
a slave process per blocked request, PaRDeS needs none — `Status.again` and the
park table are the same thing done single-threaded.
Copy the file discipline rather than inventing one: `/dev/draw/N/data` is a
write-only batched binary command stream with an exclusive `ctl`, and
`/dev/mouse` is an exclusive single-reader file whose read blocks until
something happens. That is `screen` and `input` already designed, and it is the
shape `event` already has.
Frames are never re-represented. Whatever carries them carries `encodeFrame`
bytes; the existing five-byte length prefix already makes such a message a legal
9P payload. The counter-example is worth keeping in view: Plan 9's `/dev/screen`
is an offset-addressed raster a client polls, with no change notification, which
is precisely why nobody ever ran a remote rio through it.
One scope limit, measured and not negotiable. This applies to *remote* displays
only. Turning the local host vtable into a tree costs every shell more than it
saves — tty and gui about +204 lines each, macOS +164, and the web shell +142
lines of Zig plus roughly 500 of JavaScript, because its `present` today writes
a flat cell array that JavaScript reads straight out of wasm memory and 9P would
make that an RLE stream needing a decoder and a codec. The board is worse in
kind: its host is the same process behind a function pointer, so a message pair
per frame is overhead against a direct call. `host.VTable` is already the
abstraction — the design document says the surface *is* the abstraction and
refuses a layer over the shells — so a 9P host is one more implementation of
that vtable, chosen when the display is on the other end of a wire, and never
for the window in front of you. #r("9P-19") #r("9P-23")
= The client half
The same program is a 9P client. This is not symmetry for its own sake: it is
how one instance shows another instance's windows, and how it edits a file on a
machine it cannot mount.
The client half needs no mount, and after §1 that is no longer one advantage
among several — it is the whole of what 9P buys that we cannot otherwise have.
To edit a file on the board, #app walks to `board/fs/etc/config`, opens, reads,
edits in a buffer, writes, and clunks. There is no path on the local machine, no
kernel involvement, and no privilege. That works on macOS, which has no 9P in
the kernel and no control filesystem at all today; it works in a browser tab,
which has no filesystem at all; it works on the board, which has no sockets; and
it works when the operator is not root, which on Linux is now the interesting
case.
A mount is still worth offering, because `grep`, `make` and the compiler take
filenames and a filename is the one thing a client library cannot produce. But
on Linux that mount is `src/fuse.zig`, which we keep, or it is `9pfuse`, which
is `src/fuse.zig` written by someone else. It is not `mount -t 9p` unless the
operator is root and chooses to be.
= The board
The microcontroller is the case that motivated this note, and both the first
draft and its review were wrong about it in opposite directions.
The first draft said the board runs "a 9P server and nothing else". It runs the
whole editor, as firmware, with two of twenty-one host methods filled. The
review said a 9P server would not fit, quoting nine kilobytes of free heap. That
number is two refactors stale: since the shadow grids were reduced to one cell,
the heap reports around 336 KB free and the binding constraint moved to the
240 KiB of low memory that holds `.bss`. #r("FIX-2")
Costed from real components, a 9P server on this board is:
#v(0.3em)
#align(center)[
#set text(size: 9.5pt)
#table(
columns: (auto, auto, 1fr),
align: (left, right, left),
column-gutter: 1.2em,
row-gutter: 0.3em,
[two `msize` buffers], [8,192 B], [u9fs uses three; a minimal server needs two],
[fid table, fixed array], [512 B], [32 entries × 16 B, linear scan ≈0.4 µs],
[codec scratch], [128 B], [one `stat`, `ERRMAX`],
[#text(weight: "bold")[RAM total]], [#text(weight: "bold")[8,832 B]], [2.5% of free heap],
[flash, 9P-only image], [≈39 KiB], [2.5% of a 1.5 MB partition],
)
]
#v(0.3em)
The 4,096-byte floor that sizes those buffers is imposed by the Linux kernel and
by nothing else; Plan 9, plan9port and #app's own client all accept a
512-byte `msize`, which would halve them. And a 4,096-byte reassembly buffer
already exists on this exact serial line, with the property measured: that much
in a single write arrives intact.
So it fits, easily. What does not work is having it both ways: the editor owns
the one UART bidirectionally and the board's header exposes no second one, so a
9P server on the P4 is a *second firmware image*, not a second role for this
one. The build already expresses that shape — the on-die test suite is a
separate executable with its own image and flash steps in fifty-four lines, and
it links no editor object. The board is an editor or a filesystem, and saying so
is better than implying both. #r("9P-11")
Before quoting any latency for it, raise the console to 921600 baud. It is one
divider write on the existing crystal, the routine already exists, and it takes
a warm read of a small file from 10.8 ms to 1.35 ms. #r("BOARD-1")
The tree such an image would serve is not invented. The board already exposes
its whole address space, its pin header and one pad toggle — as *acme words a
human types into a tag*, capped at four kilobytes per command because of the
console, with the answer landing in an output pane. Nothing about it is
machine-readable and nothing is remote. `mem/`, `gpio/pinout` and `prof` are
backed by functions that exist today; four more files need one new exported
symbol each; four have no implementation at all. A 9P tree is that capability
with names instead of verbs, and the inventory above is the honest scope.
= What we do not build
The temptation in this design is to keep going. Each of the following was
considered and declined.
*A window system.* #app draws in one window. Panes are ours; surfaces are not.
Becoming a Wayland compositor would buy the ability to put someone else's
program in a pane, and cost input handling, buffer management, output hotplug,
scaling, clipboard, and a permanent maintenance obligation against a moving
protocol. We do not want someone else's program in a pane.
*A namespace.* Per-process namespaces, union directories, `bind`, and walks that
cross mount points are what make Plan 9's implementation large #cite(<plan9>).
The distinction is worth defending, because the first union directory brings the
rest.
*An aggregate.* The first draft's longest technical section described a prefix
router that re-exports attached instances, and its own final section concluded
the aggregate is not worth building before a second machine exists. That verdict
is right and also covers the client half of it. It also understates the work: a
proxied `Twalk` cannot be answered until the remote `Rwalk` arrives, and the
core may not block, so it is not a fid map — it is per-tag continuations, tag
remapping, flush forwarding and fid invalidation on connection death, in a
daemon that is one `poll(2)` with no worker pool. Deferred with a named
precondition rather than described as easy. #r("9P-10")
*A file server.* Where a host's files must be exported, we run `u9fs` or `diod`
and attach as a client #cite(<u9fs>). Real filesystems are where the difficulty
in 9P actually lives: stable qids, `..` clamped at the export root, symlinks
that escape, identity mapping, and the full `wstat` surface. Our own trees are
synthetic, so we invent every file and there are no cases we did not choose.
Note that `u9fs` serves its requests from a strictly serial loop and its flush
handler is a `break`, so it is a reference for the tree and not for §10.
*Authentication.* Not now, and not by us. The Unix socket is protected by the
permissions on the socket. Where a network is involved, the connection is
tunnelled. Linux's client never sends `Tauth` at all, and Plan 9's `mount` only
does so without `-N`, so a server that refuses it is not an obstacle to either.
Real authentication is `Tauth` or TLS, and it can be added without changing the
tree.
= Protocol notes
We serve base 9P2000 #cite(<intro5>). Plan 9 mounts it natively, plan9port's
`9p` speaks it, and Linux mounts it with `version=9p2000` #cite(<v9fs>). The
following are the traps, in the order they will bite. Each is cheap once known
and each has been shipped wrongly by someone.
*Set `qid.version` to zero on every file.* This is the 9P equivalent of FUSE's
`FOPEN_DIRECT_IO` and it is server-side, not advice to the operator: Linux's
client disables both read and write caching for any file whose qid version is
zero, whatever the cache mode, unless `ignoreqv` is passed explicitly. A
synthetic tree of live editor state has no business being cached, and this is
how we say so. The first draft's "mount with `cache=none`" was unnecessary; its
claim that the default message size is small was simply wrong, since the default
is 128 KiB and the cap is 1 MB. #r("9P-3")
*Clamp every reply to the client's `count`.* An `Rread` longer than the count
asked for is a hard `EIO` in the Linux client, not a truncation. This has no
FUSE analogue, which is why nobody looks for it, and `ad` gets it wrong on
exactly the file we care about. The existing rule that a read too small for one
event record is refused rather than split is what makes the clamp safe on
`event`. #r("9P-17")
*Decide the error ABI deliberately.* `Rerror` in base 9P2000 is a string, and
Linux recovers an errno by exact match against a fixed table; a miss is not
`EIO` but `ESERVERFAULT`, which userspace prints as "Unknown error 526". `ad`
emits nineteen prose strings and not one of them is in that table. Either emit
the exact strings Linux knows, or serve 9P2000.u and send the number. The second
also restores `statfs`, which base 9P2000 does not have and which the core's
operation set includes. #r("9P-4")
*`Tflush` is a park-table lookup.* The common failure is not omitting it — it is
implementing the message and still hanging, which is what `ad` does: correct
`Rflush` ordering in thirty-nine lines, and a filesystem hook that defaults to
doing nothing. #app is structurally better placed than either reference, because
the thirty-two-slot park table is already keyed per outstanding request and
already answers `FUSE_INTERRUPT` the same way. Implement flush against the
table, not against a callback, and the bug is unrepresentable. #r("9P-16")
*Serve requests concurrently.* Several tags may be outstanding on one connection
and `event` reads block by design. This does not require threads: it requires
that a blocked request be parked rather than waited on, which is what the core's
`again` already means.
= Cost, and the order to build in
The honest figure is 1,600 to 1,900 lines of server plus around 500 of tests.
This is measured, not estimated: `ad`'s 9P2000 server core is 2,321 lines
(codec 704, stat and mode 437, session and fid and flush 615, loop and handlers
474) and `u9fs` is 1,838 with an 805-line codec. Two independent
implementations agree on codec ≈750 and server logic ≈1,700. Roughly 970 of
those lines are identical under every layering, so none of the estimate is at
risk from §6. #r("9P-15")
What comes off against it has now been counted too, and it is less than hoped.
Six concepts are genuinely duplicated across the existing mechanisms —
endpoint-path derivation in six copies, directory create-and-vet in six, stale
sweeping in three, bind/listen/accept in three, "which instance?" in three, and
version negotiation in two — 799 lines in total, of which about 330 collapses
to one copy. Add `nested.zig`'s socket half and its socket tests, 518 more, and
the deletion is *≈850 lines*. What cannot be deleted is ≈7,700: `src/fuse.zig`
by §1, `wire.zig` because frames stay frames, the daemon's host half, the
158-line ancestor walk that answers a question 9P has no message for, and every
kernel interface that was never a protocol choice — inotify, the pty, the
subprocess pipes, `mkstemp`.
*Net: between 750 and 1,050 lines of growth.* It breaks the house rule that a
change leaves the file it touches no longer than it found it, §1 forbids
retiring `src/fuse.zig` in exchange, and there is no arithmetic that rescues
it. The growth is justified by reach and by §0's reply, or it is not justified.
One cost is still unpriced and should be before any code is written. `wire.zig`
chooses every protocol tag in one file so that reordering a core enum is a
compile error, and a test asserts that exactly eleven client tags exist and that
the other 250 bytes are refused, so a frontend built before a change cannot
forge a pane's output into a session built after it. 9P's codec validates 9P; it
cannot validate that a byte string is a legal keystroke. An exhaustive switch
checked by the compiler becomes a runtime string parse with nothing to bind to.
The likely answer is to generate the `ctl` grammar from the same enum so the
compile error survives as a parser generator, but nobody has tried it.
#r("9P-20") #r("9P-21")
The order matters more than the total. Each step is stated with what it is,
where it lands, what becomes possible that was not, and how you know it worked.
Steps 1 to 3 are independent of each other and of 9P; step 4 needs step 2;
step 6 needs step 5. Any prefix of this list is a reasonable place to stop.
== Step 1 — let a detached session be scripted
A daemon started with `--detach=work` holds the core, and today it serves no
control filesystem at all: `src/detached/server.zig:566-570` records that it has
no `push_fs_reply` "because this process mounted no /dev/fuse". It simply never
calls `fs_service.start`. Nothing prevents it.
The change is a `Source.fuse` variant in the poll union, an `fs` field, the
`fs_service.start` call, `fsReply` copied verbatim from `src/tty/tty.zig`, one
vtable entry, a pollfd and its dispatch arm, and the `drain` call. About sixteen
lines. It is *better* in the daemon than in the desktop hosts: those need a
background thread to notice a request, and the daemon's own `poll(2)` already
watches everything, so it can watch `/dev/fuse` too and the thread disappears.
After: `pardes --detach=work` and then `ls $XDG_RUNTIME_DIR/pardes/<pid>/` works,
and every script in `examples/acmefs/` drives a daemon it previously could not
see. Proof is those four programs and the existing filesystem snapshots, run
against a detached session instead of a local one. #r("9P-14")
== Step 2 — name the transport seam
`fs_service.drain` and `step` take a `*fuse.Fs`, but they only ever call three
methods on it: `retry()`, `next()` and `reply()`. Replace the concrete pointer
with a context pointer and a three-function vtable, at the three call sites in
`tty.zig` and `gui.zig`. Forty lines changed, none added, `fuse.Fs` is the first
implementor, and nothing observable changes.
This is the whole preparation for a second transport, and it is worth doing on
its own merits: if 9P is never written, the seam is documented in code rather
than in a comment. #r("9P-2")
== Step 3 — make terminals scriptable
Add `pty/data`, `pty/ctl` and `pty/status` to each pane's directory in
`src/acmefs.zig`, with `ctl` taking `winsize 80 24`, `raw`, `cooked`,
`echo off`, `sig INT` and `exec /bin/sh`.
Today a script can write into a terminal that already exists and read its
rendered scrollback, and that is all — it cannot start one, resize one, or
signal one. Two of those are free: `push_spawn` and `push_pty_resize` are
already effects the core emits (`src/host.zig:80-82`), so `exec` and `winsize`
are existing capabilities acquiring a name. Only `sig INT` is new work; there is
no `kill` anywhere in `host_io.zig`.
This has nothing to do with 9P. It lands in the FUSE tree, works immediately,
and any later transport inherits it. Keep it to three files: acme has no pty
files and neither does `ad`, so there is no prior art to be wrong about, which
is a reason for restraint rather than ambition. #r("9P-8")
== Step 4 — serve the existing tree over 9P
A new `src/ninep.zig`: base 9P2000 over a unix socket, serving exactly the tree
`src/acmefs.zig` already defines. No client, no aggregation, no `aname`, and no
new files — step 3 already added the only ones wanted.
Implement `Tversion`, `Tattach`, `Twalk`, `Topen`, `Tread`, `Twrite`, `Tclunk`,
`Tstat`, `Twstat` and `Tflush`, and answer `Rerror` to `Tauth`, `Tcreate` and
`Tremove`. Lift the park table and its `retry`/`take`/`freeSlot`/`findSlot`
helpers out of `src/fuse.zig` unchanged — `Status.again` means the same thing to
both transports. What is genuinely new is the codec, the fid table, a per-fid
directory cursor, and `stat` marshalling. Every trap in §11 applies here and
nowhere else.
After: macOS and the browser have a control filesystem for the first time, and a
script on another machine can drive a session over TCP. On Linux the FUSE mount
stays, because `mount -t 9p` needs root and `fusermount3` does not — the two are
complementary, not competing. Proof is the existing snapshots run through the
9P transport unchanged, and `examples/acmefs/` run through `9pfuse`. #r("9P-15")
== Step 5 — become a 9P client
The other half, and the one that moved up the list, because it is the mechanism
for everything the note is for. A server lets pardes be *talked to*; a client is
how pardes *asks*.
It is a state machine over a byte stream — walk, open, read, write, clunk — with
no kernel, no mount and no privilege, and the protocol layer is freestanding-safe:
no allocator, no threads, no sockets, so it runs on the board's UART and in the
browser as readily as on a socket.
After: `Look board/fs/etc/config` opens a file that lives on the microcontroller,
in a pane, with no mount anywhere. And `Attach` gains the sibling it has always
needed — one that *connects* instead of replacing. Today `Attach` deinitialises
the local core and turns the process into a thin frontend; the new word leaves
both cores alive and lets each walk the other's tree. That is the "chaining"
that is currently impossible. #r("9P-22")
== Step 6 — let a remote display serve the core
A frontend exports `screen`, `input`, `snarf` and `ctl`; the core dials it and
becomes its client, writing frames to `screen` and taking a blocking read on
`input`. This is `drawterm` exactly, and the file discipline should be copied
rather than invented: a write-only batched command stream with an exclusive
`ctl`, and an exclusive single-reader input file whose read blocks.
The frame bytes are `encodeFrame` output verbatim — the codec does not change,
only what carries it. *Remote displays only.* The window in front of you keeps
the direct vtable call, because a tree costs every local shell more than it
saves.
What this actually unlocks: the ESP32-P4 stops being a shrunken pardes — an
809 KB image running a subset of the editor in 384 KB of RAM — and becomes a
terminal for a full core running on the workstation. Roughly 39 KB of firmware,
and it gains language servers, tree-sitter and PDF because those now run
somewhere that can afford them. Raise the console to 921600 baud first, or every
measurement will be eight times worse than it needs to be. #r("9P-19") #r("BOARD-1")
= Status
The tree, the event protocol, the pty control language and the layering are
settled enough to implement, and the registry holds the arguments that settled
them. Three questions remain genuinely open: whether the address becomes a
per-fid property and the core grows state to hold it #r("9P-6"); which error ABI
we adopt #r("9P-4"); and whether the parked second core on the board could own a
9P server so the board need not choose between being an editor and being a
filesystem #r("9P-11").
Steps one through three are worth doing whatever the answer to the rest is,
which is the best property this plan has.
#v(1.5em)
#line(length: 30%, stroke: 0.5pt)
#v(0.5em)
#set text(size: 9.5pt)
#set par(first-line-indent: 0em)
#bibliography(
title: none,
full: false,
("refs.yml"),
)
|