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
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
|
// THE DESIGN REGISTRY — the one place where an idea, a refactor or an open
// question lives between "someone said it" and "the tree does it".
//
// WHY A FILE AND NOT AN ISSUE TRACKER. Every claim in here cites `file:line`
// against this tree, and a tracker cannot be grepped from an editor pane, does
// not diff, and is not there when the network is not. `docs/design.typ` says
// what pardes IS; this file says what is being argued about. When an argument
// settles, its conclusion moves into `design.typ` (or into the code) and the
// entry here becomes `landed` or `rejected` — a tombstone with the reasoning
// still attached, because the expensive part of a decision is the part that
// says why the other option lost.
//
// NO PACKAGES. `design.typ` pins cetz because diagrams are genuinely painful to
// hand-roll; this file has none, so it depends on nothing and builds offline
// forever:
//
// typst compile docs/registry.typ docs/registry.pdf
//
// HOW TO ADD TO IT. Append an `#entry`. Argue inside it with `#note`. Cite with
// `#ev`. Never delete a note — flip the entry's status and let the losing
// argument stand. The dashboard on page one is generated from the entries, so
// there is no index to keep in sync.
#set page(paper: "a4", margin: (x: 2.2cm, y: 2cm), numbering: "1")
#set text(font: "New Computer Modern", size: 9.6pt)
#set par(justify: true, leading: 0.58em)
#set heading(numbering: none)
#show heading: set block(above: 1.4em, below: 0.7em)
#show heading.where(level: 1): set text(size: 12pt)
#show heading.where(level: 2): set text(size: 10.4pt)
#show raw.where(block: true): it => block(
width: 100%,
fill: luma(246),
inset: (x: 0.5em, y: 0.45em),
radius: 1pt,
breakable: true,
text(size: 7.8pt, it),
)
#show raw.where(block: false): set text(size: 8.8pt)
#set table(stroke: 0.4pt, inset: 0.4em)
// ---------------------------------------------------------------------------
// machinery
// ---------------------------------------------------------------------------
/// The lifecycle. `open` is a question nobody has taken; `investigating` has an
/// agent or a human on it; `decided` has an answer but no code; `landed` and
/// `rejected` are terminal and keep their argument; `deferred` is "correct, but
/// not until X exists" and must say what X is; `blocked` waits on someone else.
#let states = (
open: (rgb("#8a6d3b"), "OPEN"),
investigating: (rgb("#31708f"), "LOOKING"),
decided: (rgb("#2f6f4f"), "DECIDED"),
landed: (rgb("#3c763d"), "LANDED"),
rejected: (rgb("#a94442"), "REJECTED"),
deferred: (rgb("#6f5499"), "DEFERRED"),
blocked: (rgb("#777777"), "BLOCKED"),
)
#let chip(state) = {
let (c, label) = states.at(state)
box(
fill: c,
inset: (x: 4.5pt, y: 2.2pt),
outset: (y: 1.5pt),
radius: 2pt,
text(size: 6.4pt, fill: white, weight: "bold", tracking: 0.4pt, label),
)
}
/// One argument. `id` is stable forever — notes and code comments cite it, so a
/// renumber is a lie in every file that referenced the old number.
#let entry(id, title, state: "open", tags: (), body) = {
[#metadata((id: id, title: title, state: state, tags: tags)) <reg>]
block(above: 1.5em, below: 0.5em, breakable: true)[
#block(
width: 100%,
fill: luma(242),
inset: (x: 0.6em, y: 0.45em),
radius: 2pt,
stroke: (left: 2pt + states.at(state).at(0)),
)[
#text(weight: "bold", size: 9.6pt)[#raw(id) #h(0.5em) #title]
#h(1fr)
#chip(state)
#if tags.len() > 0 [
#linebreak()
#text(size: 7.4pt, fill: luma(90))[#tags.join(" · ")]
]
]
#block(inset: (x: 0.2em, top: 0.5em))[#body]
]
}
/// A comment. The thread IS the design discussion; keep them in time order and
/// never edit one in place — reply to it instead.
#let note(who, date, body) = block(
width: 100%,
inset: (left: 0.9em, y: 0.35em),
stroke: (left: 1.6pt + luma(205)),
{
text(size: 7.6pt, weight: "bold", fill: luma(70))[#who]
text(size: 7.6pt, fill: luma(140))[ · #date]
linebreak()
set text(size: 9.2pt)
body
},
)
/// Evidence. A claim with a path is a fact; a claim without one is an opinion,
/// and this makes the difference visible at a glance.
#let ev(where, body) = block(
width: 100%,
inset: (left: 0.9em, y: 0.25em),
{
text(size: 8.2pt, fill: rgb("#2f6f4f"))[▸ ]
raw(where)
text(size: 9.2pt)[ — #body]
},
)
/// A question with no owner yet. Rendered so it can be found by eye when
/// scanning for something to pick up.
#let q(body) = block(
width: 100%,
inset: (x: 0.7em, y: 0.4em),
fill: rgb("#fdf6e3"),
radius: 2pt,
{ text(size: 7.6pt, weight: "bold", fill: rgb("#8a6d3b"))[OPEN QUESTION]; linebreak(); body },
)
/// What the entry concluded. One per entry at most, and only when the state is
/// `decided`, `landed` or `rejected`.
#let verdict(body) = block(
width: 100%,
inset: (x: 0.7em, y: 0.4em),
fill: rgb("#eef5ef"),
radius: 2pt,
stroke: 0.4pt + rgb("#2f6f4f"),
{ text(size: 7.6pt, weight: "bold", fill: rgb("#2f6f4f"))[VERDICT]; linebreak(); body },
)
// ---------------------------------------------------------------------------
#align(center)[
#text(size: 15pt, weight: "bold")[The Design Registry]
#v(0.3em)
#text(size: 9pt, style: "italic")[open arguments, their evidence, and how they were settled]
#v(0.2em)
#text(size: 8.5pt)[#datetime.today().display("[year]-[month]-[day]")]
]
#v(0.8em)
*Historical decision record.* Entries retain their original source references
and states. For the current filesystem and transport interface, use
`docs/fs.md`; FUSE and the proof-of-concept examples are no longer supported.
#context {
let es = query(<reg>).map(e => e.value)
let order = ("open", "investigating", "blocked", "decided", "deferred", "landed", "rejected")
let counts = order
.map(s => (s, es.filter(e => e.state == s).len()))
.filter(p => p.at(1) > 0)
.map(p => [#chip(p.at(0)) #text(size: 8.4pt)[#p.at(1)]])
align(center, counts.join(h(0.9em)))
v(0.6em)
table(
columns: (auto, 1fr, auto),
align: (left + horizon, left + horizon, right + horizon),
table.header(
text(size: 8pt, weight: "bold")[ID],
text(size: 8pt, weight: "bold")[Argument],
text(size: 8pt, weight: "bold")[State],
),
..es
.sorted(key: e => order.position(s => s == e.state) * 1000)
.map(e => (raw(e.id), text(size: 8.8pt)[#e.title], chip(e.state)))
.flatten()
)
}
#pagebreak()
= 9P
Born from the draft note `docs/9p.typ`. The owner's instinct: replace FUSE with
9P, replace the private unix-socket wire with 9P too, and put a small 9P server
on the ESP32-P4 that pardes talks to as a client. The instinct is recorded here
entry by entry so it can be argued with in pieces rather than accepted or
rejected whole.
#entry("9P-1", "Where does 9P go? Two layers, and 9P is the outward face of one of them", state: "decided", tags: ("architecture", "umbrella"))[
The umbrella. The draft conflated two things that share a socket and nothing
else.
#ev("src/acmefs.zig:63-78")[LAYER 1, the acme control tree: request/response, nine operations, and an ABI that already says "FUSE opcodes, 9P messages and a unit test all reduce to these".]
#ev("src/detached/wire.zig:187-232")[LAYER 2, the detached wire: 19 tags carrying RLE cell frames and input, server-push, N frontends on one screen. None of them is a file.]
#verdict[
9P is a TRANSPORT FOR LAYER 1 and a CARRIER FOR LAYER 2 — never a
re-encoding of either.
Layer 1 gets 9P as a second transport beside FUSE, because the ABI was
built for it and because FUSE cannot leave the machine (`9P-13`).
Layer 2 keeps `encodeFrame` byte for byte. 9P may carry those bytes
(`9P-12`), but the moment a frame is re-expressed as a file's contents the
design has lost the thing that makes it fast, and there is a thirty-year
demonstration of both answers (`9P-12`, devdraw against `/dev/screen`).
]
#note("review", "2026-08-27")[
The reason this is worth stating as its own entry: nearly every mistake in
the draft is one layer's property asserted about the other. "Offsets are
meaningless" is true of Layer 2 and false of Layer 1. "The protocol is
private and undocumented" is true of Layer 2 and false of Layer 1, which is
acme(4). "Adding a feature means adding a message" is true of Layer 2 —
measured at 7 lines per fact (`src/detached/wire.zig`, `set_clipboard`
occurring 8 times) — and false of Layer 1, where a feature is a file.
]
]
#entry("9P-2", "Make the transport seam explicit before writing any 9P", state: "decided", tags: ("refactor", "cheap"))[
`fs_service` already touches its transport through exactly three methods.
Turning `*fuse.Fs` into a ctx+vtable at three call sites is a no-behaviour
change that makes a second transport possible without deciding anything else.
#ev("src/fs_service.zig:158-200")[`drain`/`step` call only `retry()`, `next()` and `reply()`.]
#ev("src/tty/tty.zig:1216")[call site 1.]
#ev("src/gui/gui.zig:3738")[call site 2 — and `:3841` for the headless grid harness.]
#verdict[Do it first, independently of every other entry. ≈40 lines changed, 0 added, `fuse.Fs` is the first implementor. If 9P is never built, this costs nothing and documents the seam.]
]
#entry("9P-3", "Direct I/O is `qid.version = 0`, not `cache=none`", state: "decided", tags: ("protocol", "correction"))[
Under FUSE the server asserts `FOPEN_DIRECT_IO` per open and the client
cannot argue. The draft assumed 9P gives this up and compensated with advice
("mount with `cache=none`"). It does not have to: Linux's client disables
both read and write caching for any file whose qid version is zero.
#ev("linux/fs/9p/fid.h:52-53")[`(fid->qid.version == 0) && !(s_flags & V9FS_IGNORE_QV)` sets `P9L_DIRECT` — "no read or write cache".]
#ev("linux/fs/9p/v9fs.h:82")[`CACHE_NONE = 0` is the default anyway.]
#ev("linux/fs/9p/v9fs.c:93")[`ignoreqv` is the only opt-out, and it is explicit.]
#verdict[A synthetic tree reports `qid.vers = 0` on every file. Server-enforced, near-parity with `FOPEN_DIRECT_IO`, and the mount advice in the draft becomes unnecessary. Plan 9 needs nothing: its cache is opt-in via `mount -c`.]
]
#entry("9P-4", "The error ABI: 9P2000 Rerror is a string", state: "open", tags: ("protocol", "ABI"))[
The core answers with numbers. 9P2000 answers with prose, and the Linux
client turns prose back into a number by exact string match. A miss is not
`EIO`; it is 526, which userspace prints as "Unknown error 526".
#ev("src/acmefs.zig:152-165")[`E.PERM..E.NOSYS`, nine numeric values.]
#ev("linux/net/9p/error.c:224-243")[`p9_errstr2errno` hash lookup; on a miss `errno = ESERVERFAULT`.]
#ev("principia-softwarica/editors/acme/xfid.c:19-24")[acme's own strings — `Ebadctl`, `Ebadaddr`, `Ebadevent` — are all misses in that table.]
Three ways out, and they are not equally good.
/ A: #[Emit Linux `strerror` text verbatim so all nine round-trip. Cheap (≈20 lines), and makes English kernel strings this project's error ABI.]
/ B: #[Serve 9P2000.u, whose `Rerror` carries a numeric errno alongside the string. Costs a second dialect in the codec; buys exact errnos and keeps a human-readable string for Plan 9 clients.]
/ C: #[Emit acme's strings and accept 526 on Linux. Faithful to acme, hostile to `mount -t 9p`.]
#q[Which? Note that B also answers `9P-5`'s `statfs` gap for free, and that the draft deferred `.u`/`.L` without noticing either.]
#note("prior-art", "2026-08-27")[
`ad` — a Rust acme-like editor that already ships this — chose option C
without noticing. It emits nineteen lowercase prose strings: `"unknown
fid"`, `"permission denied"`, `"file not open"`, `"exclusive file already
open"`, `"invalid offset for read on directory"`
(`~/05-genizah/ad/crates/ninep/src/sansio/server.rs:25-43`). *None* of
them is in Linux's table, so under `mount -t 9p` every single error `ad`
can produce arrives as 526. It also serves `SUPPORTED_VERSION = "9P2000"`
and nothing else (`:46`), so there is no `.u` escape hatch in place.
Real implementations fall into this; it is not a theoretical trap.
]
]
#entry("9P-5", "Directory reads need per-fid state the core does not have", state: "decided", tags: ("protocol", "cost"))[
This is the real offset discontinuity, and the draft missed it while
inventing a false one about `body`.
9P requires a directory read at offset 0 or at exactly the byte offset where
the previous read ended, and the reply must contain whole `Dir` entries.
`acmefs` treats the offset as an *entry index* and re-stages the whole
listing each call, which is right for FUSE and wrong here.
#ev("principia-softwarica/lib_networking/lib9p/srv.c:473")[a dir read whose offset is neither 0 nor `fid->diroffset` is answered `Ebadoffset`.]
#ev("u9fs/u9fs.c:60-64")[the reference server keeps `diroffset`, a cached `dirent` and `direof` per fid.]
#ev("src/acmefs.zig:972")[`var skip = req.off;` — an entry index.]
#verdict[Transport-side, not core-side: the 9P transport keeps a per-fid byte cursor plus the one entry that did not fit, and calls the existing `readdir` with the entry index it has counted. `acmefs.zig` is untouched. Budget ≈40 lines.]
#note("prior-art", "2026-08-27")[
`ad` has no cookie at all: `read_dir` returns the entire `Vec<Stat>` on
every call and the server re-serialises all of it and byte-skips the offset
(`ninep/src/sync/server.rs:203`, `sansio/server.rs:377-401`). Its
`FidMeta` is `{qid, mode}` — no dir state (`:700-703`). So it is O(N) per
read, O(N²) per directory, and `E_INVALID_OFFSET` fires only when the
offset lands mid-entry (`:388-390`), which means an arbitrary offset on an
entry boundary is silently accepted and a changing directory tears.
The 40-line budget above buys correctness `ad` does not have.
]
#note("built", "2026-08-27")[
Built, and the carry-over entry turned out to be unnecessary. u9fs needs one
because `readdir(3)` has already consumed the entry it could not fit;
`acmefs` re-stages the whole listing from an index on every call and says
why (`src/acmefs.zig:1013-1016`), so an entry that does not fit is simply
not counted and the next read asks for it by index. The server keeps a
per-fid PAIR — a byte cursor for the client's rule and an entry index for
the core's — advanced together. That removes ≈300 bytes per fid and a class
of staleness bug. The ≈40-line budget held.
]
]
#entry("9P-6", "Is `addr` per-fid or per-window?", state: "open", tags: ("semantics", "divergence"))[
The draft claimed per-fid and attributed it to acme. acme does the opposite,
and so does pardes today.
#ev("principia-softwarica/editors/acme/dat.h:239")[`Range addr;` is a field of `struct Window`; every use in `xfid.c` is `w->addr`.]
#ev("src/acmefs.zig:1027-1031")[pardes copied that, deliberately, and records why: there is no fid table because FUSE puts the nodeid on every request.]
Per-fid genuinely is better — two scripts can address one window without
colliding — but it is a divergence from acme, not a restatement of it, and it
is the one place in the whole draft that asks the *core* to grow state. Under
FUSE there is no fid to hang it on at all.
#q[Take the divergence and pay for it (per-fid `addr` lives in the 9P transport, and the FUSE transport keeps one per mount), or keep acme's race and document it?]
#note("prior-art", "2026-08-27")[
Third independent source against the draft: `ad`'s address is per-BUFFER,
keyed by buffer id (`Req::SetBufferAddr{id, addr}`,
`ad/src/fsys/message.rs:77-80`). acme per-window, pardes per-pane, `ad`
per-buffer. Nobody has ever shipped it per-fid. That is not proof it is
wrong — it is proof it is a proposal, and it should be argued as one.
]
]
#entry("9P-7", "One controller per window, or many?", state: "open", tags: ("semantics", "divergence"))[
The draft says `event` is exclusive-use. acme does not do this, and pardes
currently allows any number of readers.
#ev("principia-softwarica/editors/acme/xfid.c:603-609")[acme's exclusivity is an advisory `lock` ctl verb setting `w->ctlfid`, cleared on clunk.]
#ev("principia-softwarica/editors/acme/fsys.c:537-563")[`fsysopen` checks permission bits only; a second `event` reader is not refused.]
#ev("src/acmefs.zig")[`pf.readers +|= 1` on open — a count, and the count is what suppresses button actions.]
`DMEXCL` would make a second open fail with `EAGAIN` under Linux. That is a
tightening with a real cost: two cooperating scripts on one window stop
working, and nothing in `examples/acmefs/` was written expecting it.
#q[Leave it a count (status quo, acme-compatible), or make it exclusive and lose multi-reader?]
#note("prior-art", "2026-08-27")[
`ad` DOES do what the draft describes: `event` is `FileType::EXCLUSIVE`,
i.e. QTEXCL (`ninep/src/sansio/protocol.rs:503`, added in commit
`678fbf5`). Note how it is scoped, though — enforcement is per *ClientId*,
not per fid (`sansio/server.rs:762-764`), so one client may hold two fids
on `event` and two clients may not share one window. That is a third
position between "a count" and "one fid", and it is probably the right one:
it stops two unrelated scripts fighting without breaking a script that
opens the file twice.
]
]
#entry("9P-8", "`pty/` files — the best idea in the draft, and unrelated to 9P", state: "open", tags: ("feature", "transport-independent"))[
A script today can write a terminal pane's `body` (it becomes a pty write)
and read its rendered scrollback. It cannot spawn a terminal, resize one, or
signal one. The draft's `pty/{data,ctl,status}` fixes that, and none of it
needs 9P — it is `ctl` verbs and two new `PaneFile` variants.
#ev("src/host.zig:80-82")[`push_spawn` and `push_pty_resize` already exist as effects, so `exec` and `winsize` are two existing effects with a name.]
#ev("src/acmefs.zig")[the `Verb` table has no pty verb; `PaneFile` has no pty entry.]
`sig INT` is the only genuinely new capability — there is no `kill` anywhere
in `host_io.zig`.
#verdict[Not blocked on anything. Build it in the FUSE tree now; a 9P transport inherits it for free. Sequencing it *after* 9P would be putting the transport before the feature.]
#note("prior-art", "2026-08-27")[
Worth knowing before building it: there is *no prior art anywhere*. acme
has no pty files; `ad` has none either — its tree is
`{ctl, minibuffer, scratch, log, buffers/…}` and contains no terminal
surface at all (`ad/src/fsys/mod.rs:17-32`). So `pty/` is a genuinely new
interface, which cuts both ways: nobody has made these mistakes for us, and
nobody's scripts already expect a particular spelling. Being first is a
reason to keep it small — `data`, `ctl`, `status`, and no more.
]
]
#entry("9P-9", "Naming sessions: `aname` or a top-level directory", state: "open", tags: ("protocol", "naming"))[
Verified: `aname` works, and the draft's mount line is valid verbatim.
#ev("linux/fs/9p/v9fs.c:72-90")[`aname` parses to `Opt_remotename`; `version=9p2000` selects `p9_proto_legacy`.]
#ev("linux/fs/9p/vfs_super.c:340")[`port` defaults to 564, so the draft's command line needs no `port=`.]
#ev("u9fs/u9fs.c:409-420")[u9fs uses `aname` to pick a tree, so there is precedent for exactly this use.]
Against it: pardes has no multi-session concept in the core at all today —
`--detach=work` names a *socket*, not a tree. A top-level directory needs no
protocol feature and works with clients that ignore `aname`.
#q[Is this question even live before `9P-12` settles? A per-session socket already names a session; `aname` matters only if one listener serves many.]
#note("prior-art", "2026-08-27")[
`ad` does not use `aname` either: `Server::new` installs a single anonymous
root `""` (`ninep/src/sansio/server.rs:91-95`), and multi-session is the
SOCKET name, `ad-<pid>`, with discovery through a `list_open_sessions`
helper (`ad/src/fsys/mod.rs:158-159`). That is exactly pardes's
`--detach=work` shape. Two implementations independently reaching for a
named socket over `aname` is evidence about which one people actually
build.
]
]
#entry("9P-10", "Aggregation: the prefix router", state: "deferred", tags: ("architecture", "premature"))[
The draft's longest technical section, and its own §11 concludes the
aggregate is not worth building before a second machine exists. That verdict
is correct and also covers the client half.
What the section understates: a proxied `Twalk` cannot be answered until the
remote `Rwalk` arrives, so "the fid table maps our fid to a pair" is not the
entire proxy — it needs per-tag continuations, remote↔local tag remapping,
`Tflush` forwarding and fid invalidation on connection death.
#ev("src/detached/wire.zig:64-70")[a round trip inside `update` is the one thing the transport must never do.]
#ev("src/detached/server.zig:238-241")[the daemon is one `poll(2)` over 50 slots, and `:565-569` records that it has no worker pool.]
#verdict[Deferred until a second machine exists AND `9P-12` has settled, because the proxy's shape depends entirely on which layer 9P occupies. Reopen with a named use case, not with an architecture.]
#note("unblocked", "2026-08-27")[
*Reopen this.* The deferral rested on one objection and steps 4 and 5
dissolved it without meaning to.
The objection was that a proxied `Twalk` cannot be answered until the remote
`Rwalk` arrives, so the router needs per-tag continuations, tag remapping,
flush forwarding and fid invalidation — machinery a core that must not block
has nowhere to put. Three of those four now exist as shipped primitives:
#ev("src/9p.zig")[`Server.retry()` re-offers a parked request oldest-first and `reply()` RE-PARKS it when the answer is still `.again`. The park table is therefore a continuation store that already survives across frames, and it is the one the FUSE mount has used all along.]
#ev("src/9p.zig")[`Client` is sans-io: `submit()` hands back a tag and never waits, `take()` returns a completed operation or null. Its tag table is indexed BY the tag, so attributing an out-of-order reply is one bounds check — which is the remapping the objection was about.]
So a proxy is now a loop, not a subsystem: `Server.next()` gives a request,
`Client.submit()` forwards it, the answer is `.again`, and each frame
`Server.retry()` offers it back until `Client.take()` completes and the real
reply goes out. Nothing blocks, nothing is added to the core, and the
daemon's poll set grows by one descriptor per upstream.
What is still genuinely missing is fid invalidation on a connection that
dies mid-walk, and a decision about whether `Tflush` forwards or is answered
locally. Both are small and neither is architectural.
Re-cost before building: the "several hundred lines" the draft claimed was
wrong in the other direction too. Measure it against the ≈200 lines this
loop looks like, and against `src/fs9_client.zig`'s 622, which already does
the connect-and-pump half.
]
]
#entry("9P-11", "A 9P server on the ESP32-P4 — real, cheap, and a second firmware image", state: "decided", tags: ("board", "motivating-case"))[
The owner's motivating case, and the entry that changed the most under
measurement. Both the draft and the first review were wrong about it, in
opposite directions.
The draft was wrong about what the board IS: it describes a machine running
"a 9P server and nothing else", and today the P4 runs the whole editor
(`src/esp32p4.zig:264-300`, 2 of 21 vtable methods at `:930-934`).
The review was wrong about what the board CAN AFFORD, because it quoted a
stale number.
#ev("src/esp32p4.zig:470-473")[the "9,128 bytes free at 80×24" comment predates `direct_emit` (`:870`), which sized vaxis's two shadow grids to one cell.]
#ev("05-zig-p4/experiments/report.typ:848-849,877-880")[measured after that change: "the heap now reports 336 KB free at every geometry tried, including ones that used to fail outright… the heap has 336 KB spare while `.bss` runs out". The binding resource is the 240 KiB low L2MEM, not the 384 KiB heap.]
A 9P server's RAM, costed from real components: two msize buffers at 4,096
(u9fs uses three — `rxbuf`, `txbuf`, `databuf`, `u9fs.c:1814-1816` — a
minimal server needs two) = 8,192 B; a FIXED-ARRAY fid table of 32 entries ×
16 B (`fid`, `qid.path`, `mode`, `diroffset`) = 512 B; codec scratch with
`P9_ERRMAX` = 128 B. *Total 8,832 B, or 2.5% of free heap.*
#ev("src/esp32p4/input_rescue.zig:52")[and a 4,096-byte reassembly buffer already exists on this exact UART, measured: "4,096 bytes in a single write arrive intact, and past that the loss is counted rather than silent".]
#ev("linux/net/9p/client.c:840-843,908-910")[the 4,096 floor is imposed by the LINUX KERNEL and by nothing else. Plan 9's devmnt, plan9port's `9p` and pardes's own client accept a 512-byte msize, which halves the buffers to 1 KiB.]
Flash: the editor image is 809,536 B of a 1,536,000 B partition
(`report.typ:375-376`), leaving 726,464 B. A 9P-only image is ≈23,870 B of
platform plus ≈15 KiB of server ≈ *39 KiB, 2.5% of the partition*.
#ev("build.zig:1182-1235")[a second board image is ALREADY expressible: `esp32p4-test` builds its own executable, `ImageStep`, `FlashStep` and run step in 54 lines, and deliberately links no pardes object. That is the shape.]
#ev("src/acmefs.zig")[and the semantics layer already compiles for riscv32: `llvm-nm` finds 21,548 B across 17 `acmefs.*` symbols in `zig-out/pardes-esp32p4.o`.]
#verdict[
Buildable, and much cheaper than anyone assumed. But it is a SECOND
FIRMWARE IMAGE, not a second role for this one: the editor owns UART0
bidirectionally (`esp32p4.zig:611`, `uart.zig:43-44,122-130`) and JP1
exposes no second P4 UART (`board9p.Header`). The board is either
an editor or a filesystem at any one time. Say that plainly rather than
implying both.
]
#note("review", "2026-08-27")[
The reframing the draft misses: the board already exposes `Peek`, `Poke`,
`Hexdump` and `Gpio` as acme words (`src/builtins.zig`, `Board` namespace).
All four cap at 4,096 bytes
per command and the cap's stated reason is the 115200 console. So the whole
2³² address space is already reachable — by *typing a word into a tag*,
with the answer landing in an output pane. Nothing is machine-readable and
nothing is remote. A 9P tree is that same capability with names instead of
verbs, and `mem/`, `gpio/pinout` and `prof` are backed by functions that
exist today (`builtins.Board.readWord`, `builtins.Board.gpio`,
`pardes_esp32p4_frame_prof` at `esp32p4.zig:985`, already exported).
Four more files need one new C-ABI extern each; four have no
implementation at all. That inventory belongs in the note, not a wishlist.
]
#q[Should the parked second RISC-V core own the 9P server? `report.typ:552-563` says it needs four register writes plus a trampoline, shares one L1 D-cache so a lock-free ring needs only fences, and that giving core 1 the UART "eliminates the silent input loss". That is the one arrangement where the board serves 9P *and* keeps the editor. Uncosted.]
#note("built", "2026-08-27")[
*The RAM figure above is wrong and the built one is 21,776 B, not 8,832.*
Measured from the real structs: `Fid` is 64 B × 32 = 2,048, `Slot` is 208 B
× 32 = 6,656, and the whole `Server` is 9,488 B before buffers; a 4,096
msize adds `in` 4,096 + `out` 8,192.
Three reasons, all of them things the estimate did not know. `out` is TWO
msize — one message being written, one being built — which is what makes
every reply infallible and removes "can I write yet" from the whole file. A
fid entry is 64 B and not 16, because `Rstat` carries a NAME that a node id
does not, plus the open handle and the two-coordinate cursor. And the
estimate did not cost the park table at all, which is 6,656 B of the total.
It still fits with room: 6.5% of the board's ≈336 KB free heap, and about
11 KB in total at the 512-byte msize Plan 9 accepts. A test bounds `Fid` and
`Slot` so that a change to either shows up as a diff in the board's budget
rather than as a surprise on the die.
]
]
#entry("BOARD-1", "Raise UART0 to 921600 before quoting any board latency", state: "decided", tags: ("board", "cheap", "prerequisite"))[
Every board 9P latency figure is eight times worse than it needs to be, for
no reason but that the bootloader left the divider alone.
#ev("src/esp32p4/uart.zig:35-38")[the firmware never programs the divider; 115200 is inherited.]
#ev("05-zig-p4/src/hal/uart.zig:321-333")[`setBaudrate` and `divider` already exist and `reset()`'s refusal of instance 0 does not apply to them.]
#ev("05-zig-p4/experiments/report.typ:540-544")[921600 is one `UART_CLKDIV_SYNC` write on the existing 40 MHz XTAL — int 43, frag 6, +0.064% error. 2 Mbaud is representable but this CH340 is unreliable there, corroborated by the flasher at `build.zig:1136-1138`.]
#verdict[
One register write, 86.8 µs → 10.85 µs per byte. A warm
`cat /mnt/board/gpio/2/value` goes from 10.8 ms to 1.35 ms; a 4 KiB `Tread`
from 358 ms to 45 ms. Do it first, and re-measure everything after.
The hazard the same files record: writing the console UART's divider is
adjacent to what bricks the board, and the host must be reopened in step.
]
]
#entry("9P-12", "Should 9P replace the private wire protocol? Half of it, and not the half you would guess", state: "decided", tags: ("architecture", "the-real-question"))[
Six layerings were costed. The finding that settles it is that layering is
not a cost decision at all.
#ev("src/detached/wire.zig")[1705 lines = 1092 code + 613 tests, of which only 303 are FRAME and 789 are TRANSPORT: bounds, tags, message structs, framing, codecs.]
#ev("src/detached/wire.zig:1401,1412")[a 56×14 full frame is 6,298 B; a one-keystroke diff is 56 B. Ratio over 100.]
#ev("src/detached/server.zig:1102-1110")[skip-slow is three lines: `if (c.out.items.len != 0) continue;` — skip the frame and leave the mirror alone, so the next frame diffs against what the client really has.]
A base-9P2000 codec (≈450), a dispatcher onto `acmefs.Op` (≈400) and a fid
table (≈120) come to ≈970 lines that are IDENTICAL in all six layerings.
Layering moves ±150. So the question is reach and semantics, not lines.
*Does 9P subsume the wire's job?* The transport half, yes, with shipped
precedent: clipboard becomes `snarf` (`rio/fsys.c:61`), input becomes
`mouse`/`cons` — and rio's `mouse` is exclusive-open with a blocking read and
a flush (`rio/xfid.c:167-173,638-660`), which is the `event` idiom exactly.
The frame half, no: 9P has no server-initiated message.
*Is "the frame is a file" a category error?* Only if you mean push. Two
shipped counter-forms exist and they disagree with each other, which is the
most useful thing in this entry:
#ev("plan9/rio/fsys.c:53, lens.c:281-282")[`/dev/screen` is an offset-addressed raster you POLL — `lens` seeks to a scanline and repaints on mouse events because it never learns the screen changed. Real, and precisely why nobody runs a remote rio through it.]
#ev("drawterm-9front/kern/devdraw.c:1574, include/draw.h:55")[`/dev/draw/N/data` is a PRIVATE BINARY COMMAND STREAM batched into 8,000-byte buffers and flushed as one `Twrite`. Real, and how remote display has actually shipped for thirty years.]
#ev("drawterm-9front/cpu.c:149,191,251,359")[and it inverts direction: the DISPLAY exports its devices and the APPLICATION mounts them. The frame travels as a client's `Twrite`, never as a server's push.]
pardes's codec is already devdraw's shape. `wire.zig`'s 5-byte length prefix
(`:145`, `framed()` `:564`) makes an `encodeFrame` message a legal 9P payload
with zero new framing.
#verdict[
*Superseded in part by `9P-19`.* The reasoning below stands; the conclusion
moved. O3's tunnel was chosen because it was the only option that reached
the board without sockets. `9P-19` shows the tunnel is unnecessary: the
link simply carries 9P, with the FRONTEND as the server and the core as the
client, and the frame travels as the client's `Twrite` to `screen`. That is
O1 done the way it has actually shipped for thirty years, and it dissolves
the objection recorded here.
What survives unchanged: *O4* (wire inside 9P) costs +34 B per frame, +61%
on a 56-byte diff. *O5* (a standalone listener) remains correct and is
independently worth building — it is the scripting face and it composes
with anything.
What was wrongly rejected: *O6*. "The frontend serves, the core is a
client" was rejected here for making the core write and match tags inside
`update` and for foreclosing the freestanding targets. Both are wrong. The
core need not wait for `Rwrite` — 9P permits many outstanding tags
(`linux/net/9p/client.c:194-199`, `plan9/devmnt.c:783-800`) with no
in-order reply requirement — and the freestanding targets have a byte
stream even though they have no sockets, which is all 9P asks for.
O6 is the destination. See `9P-19`.
]
#note("review", "2026-08-27")[
One honest caveat against O3: `exportfs` carries 9P and nothing else — its
framing is 9P's own 4-byte size prefix (`exportfs.c:434,476`) — so
multiplexing a second protocol beside 9P on one link has *no* Plan 9
precedent. It is not hard, but we would be first, and "the transport
becomes someone else's problem" stops being true for that link.
]
]
#entry("9P-13", "9P cannot replace FUSE on Linux; it can only join it", state: "decided", tags: ("portability", "privilege", "decisive"))[
The strongest argument for 9P was that it needs no kernel and no privileged
mount helper: 281 non-test lines of `fuse.zig` exist only to obtain a mount
an unprivileged user is not allowed to make.
#ev("src/fuse.zig:497-703")[207 lines: `_FUSE_COMMFD`, socketpair, `CMSG_*`/`SCM_RIGHTS`, fork/execve/waitpid of the setuid `fusermount3`, environ rebuild.]
#ev("src/fuse.zig:752-773")[22 more for the helper's unmount, `:933-978` 46 for the mount call, `:1718-1723` 6 for `clearCloexec`. 281 total, measured.]
#ev("src/fuse.zig:64")[and all of it is Linux-only, so macOS (14/21 vtable) and web (6/21) have no control filesystem at all.]
That argument is dead. The kernel grades mount privilege by a per-filesystem
flag, and the two filesystems are on opposite sides of it.
#ev("linux/fs/super.c:694-700")[`mount_capable`: without `FS_USERNS_MOUNT` the check is `capable(CAP_SYS_ADMIN)` — and `capable()` is `ns_capable(&init_user_ns, …)`, i.e. root in the INITIAL namespace, not the caller's.]
#ev("linux/fs/9p/vfs_super.c:362")[`.fs_flags = FS_RENAME_DOES_D_MOVE` — v9fs does not set `FS_USERNS_MOUNT`.]
#ev("linux/fs/fuse/inode.c:2004")[`.fs_flags = FS_HAS_SUBTYPE | FS_USERNS_MOUNT | FS_ALLOW_IDMAP` — FUSE does.]
So `mount -t 9p` costs real root, unconditionally: a fresh
`unshare(CLONE_NEWUSER|CLONE_NEWNS)` does not help, because `mount_capable`
falls back to the init namespace for exactly the filesystems that lack the
flag. `trans=fd` does not help either — it lets an unprivileged process own
the connected socket, but `mount(2)` is checked before the transport is ever
consulted (`linux/fs/namespace.c:3838`). And `9pfuse`, the usual escape,
is FUSE: it needs `fusermount` in `PATH` and reimports the whole setuid dance
in someone else's process.
#verdict[
The 281 lines are not overhead. They buy an *unprivileged pathname*, which
is a capability 9P has no way to provide on Linux. `fuse.zig` stays.
This does not weaken 9P; it relocates it. The two are complementary faces
of one `acmefs.handle`: FUSE is the PATHNAME face — local, unprivileged,
Linux, for `grep` and `make`. 9P is the NETWORK AND IPC face — remote,
unprivileged, every platform, for pardes's own client, for the board, for
the daemon, and for anyone with root who wants `mount -t 9p`.
Everything downstream follows from this split, and `9P-2` is what makes it
cost nothing.
]
#note("review", "2026-08-27")[
Worth being precise about what survived. The draft's §6 has two halves and
only one of them died. "The client half needs no mount… no kernel
involvement, no privilege" is TRUE and remains the best paragraph in the
document. "The mount is for other people's programs… and it is sufficient"
is where the root requirement lands, and it is not sufficient — on Linux
that mount is strictly more privileged than the one we already have.
]
]
#pagebreak()
= Corrections
Small, certain, and independent of every argument above.
#entry("FIX-1", "`acmefs.zig` is wrong about 9P truncation", state: "decided", tags: ("comment", "one-line"))[
The comment justifying `setattr` says 9P has no truncate-on-open and that
nothing in acme answers a `Twstat` carrying a length. Both halves are wrong.
#ev("src/acmefs.zig:1097-1100")[the claim.]
#ev("u9fs/plan9.h:150")[`#define OTRUNC 16` — base 9P2000, used at `u9fs.c:1578` and `:1682`.]
#ev("u9fs/u9fs.c:1047")[`Twstat` with a length calls `truncate(2)`; that is how 9P truncates.]
#verdict[Rewrite the comment. The real reason `setattr` exists is that Linux strips `O_TRUNC` from the OPEN when `FUSE_ATOMIC_O_TRUNC` is not negotiated, which is a FUSE fact and stands on its own without the false claim about 9P. Under a 9P transport, `> body` arrives as `Topen` with `OTRUNC` or as `Twstat`, and maps onto the same `Req.truncate`.]
]
#entry("DOC-1", "`design.typ` says \"No 9P\"", state: "open", tags: ("docs", "consistency"))[
The project has already recorded a decision against this, under *What is
deliberately absent*, and the draft neither cites nor rebuts it.
#ev("docs/design.typ:1969-1971")[«No 9P. The library boundary is exactly where acme put the file server, and the FUSE mount is already that server with a Linux transport instead of a 9P one; a sixth shell could serve `Surface` and `Event` over 9P without touching the core.»]
Read closely, that paragraph is not hostile — it says *not built*, and its
second clause proposes something stronger than the draft does: serving
`Surface` and `Event`, i.e. `9P-12`'s option 1, which the project apparently
considered plausible enough to write down.
#q[When `9P-1` settles, this paragraph is rewritten rather than deleted — it should say which layer 9P occupies and why the other one was left alone.]
]
#entry("9P-14", "Do the daemon's filesystem first, with 16 lines and no 9P", state: "decided", tags: ("sequencing", "cheap", "do-first"))[
The strongest *stated* motive for putting 9P in the daemon is that a
detached session has no control filesystem. That is true, and it is not a
protocol problem.
#ev("src/detached/server.zig:563-570")[«no `push_fs_reply`, because this process mounted no /dev/fuse». The daemon simply never calls `fs_service.start`.]
The fix, counted: a `Source.fuse` variant (1 line), an `fs` field (1),
`fs_service.start` (1), `fsReply` copied verbatim from `tty.zig:1455-1458`
(4), a vtable entry (1), a pollfd and dispatch arm (6), `drain` (2).
*≈16 lines.* And it is *better* there than on the desktop hosts: the
daemon's own `poll(2)` covers `/dev/fuse` directly, so it needs no wake
thread at all (`src/fs_service.zig:127-131`).
#verdict[
Build this before deciding anything else. It costs sixteen lines, it
delivers the feature people actually want, and it removes a motive from the
9P argument so that argument is decided on its merits. If it turns out to
be all anyone needed, that is a good outcome, not a wasted one.
]
]
#entry("9P-15", "The honest cost of a 9P stack is ≈1,600-1,900 lines, not ≈770", state: "decided", tags: ("cost", "estimate"))[
Two independent implementations were measured rather than guessed, and they
agree closely.
#ev("~/05-genizah/ad/crates/ninep")[12,296 lines total; the irreducible 9P2000 SERVER core is 2,321 non-blank non-comment: codec 704, `Stat`/`Perm`/`Mode`/`WStat` 437, session+fid+flush 615, loop+handlers 474. Fid table alone is 57 lines; the server loop 141; the flush path ≈58; directory reads 50; tests 3,669, i.e. 31%.]
#ev("~/05-genizah/u9fs/u9fs.c")[1,838 lines — NOT the 6,149 the first review quoted, which counted a whole plan9-libc substitute. Its codec, `convM2S` + `convS2M`, is 805.]
Codec ≈700-800 and server logic ≈1,500-1,900, from both. The earlier
≈770-line figure was the size of one component.
#verdict[
Budget ≈1,600-1,900 core lines plus ≈500 of tests, and say so out loud,
because it *breaks the house rule* (`docs/design.typ:1957-1960`: a change
leaves the file it touches no longer than it found it) unless something is
retired in exchange. `9P-13` says `fuse.zig` cannot be the thing retired.
So this is net growth, and it has to be justified by reach — the board, the
network, macOS, web — not by simplification.
]
#note("review", "2026-08-27")[
Two mitigations that are real. First, ≈970 of those lines are identical in
every layering (`9P-12`), so none of it is at risk from the layering
decision. Second, 296 of ninep's 704 codec lines are macro-generated
(`protocol.rs:809-1154`); Zig `comptime` over an exhaustive message enum
should compress that at least as well, and the message set is the one part
of 9P that never changes. Against that: `ad` needed `UnsafeCell` plus
`unsafe impl Send/Sync` to get zero-allocation parsing
(`protocol.rs:67-76`), which Zig gives for free — so the *hard* part of
their codec is not a cost we inherit.
]
]
#entry("9P-16", "Tflush is a park-table lookup, and this is where we beat the prior art", state: "decided", tags: ("protocol", "advantage"))[
The draft says `Tflush` is the most common omission in hand-written 9P
servers. It understates the problem: the common failure is having it and
still hanging.
#ev("~/05-genizah/ad/crates/ninep/src/sansio/server.rs:767-819")[`ad` gets the ORDERING right in 39 lines — an Rflush for an in-flight tag is chained and sent only after the original replies, matching `lib9p/srv.c:241-266`.]
#ev("~/05-genizah/ad/crates/ninep/src/sync/server.rs:163-164")[and then `Serve9p::flush` defaults to a no-op, which `ad` never overrides. A client flushing a blocked `event` read gets no Rflush until an unrelated editor event happens to arrive. Interrupts and unmounts hang exactly as the draft warns, *despite* Tflush being "implemented".]
#verdict[
pardes is structurally better placed than either reference. The 32-slot
park table (`src/fuse.zig:789`) is already keyed per outstanding request, so
`Tflush` is a lookup, a reply to the original, and a reply to the flush —
the same path `FUSE_INTERRUPT` already takes, which is tested
(`src/fuse.zig`: "interrupt answers the original with EINTR and drops it").
Implement it against the table, not against a filesystem callback, and the
class of bug `ad` shipped is unrepresentable.
]
]
#entry("9P-17", "Clamp Rread to the client's count, or Linux hard-fails the read", state: "decided", tags: ("protocol", "correctness", "trap"))[
A trap with no analogue in FUSE, which is why nobody looks for it.
#ev("linux/net/9p/client.c:1475-1479")[`if (rsize < received) { pr_err("bogus RREAD count"); *err = -EIO; }` — an Rread longer than the `count` asked for is a HARD ERROR, not a truncation.]
#ev("~/05-genizah/ad/crates/ninep/src/fsys/event.rs:116-122")[`ad` clamps replies to `msize` (`protocol.rs:1011-1018`) but never to `count`, and its `event` file concatenates every drained event and returns the lot. Under a kernel mount that is `-EIO`.]
#verdict[
Every reply path clamps to `min(count, msize - 11)`. This interacts with a
decision already made the other way: `acmefs` refuses a read smaller than
one event record with `EINVAL`, on the grounds that half a record silently
desynchronises a client (`docs/acme-fs.md`). That rule stays and is now
load-bearing for a second reason — it is what makes "clamp to count"
safe on `event`, because a record is either whole or refused.
]
]
#entry("9P-18", "The spec-bug checklist, taken from someone else's git history", state: "open", tags: ("testing", "gift"))[
`ad` shipped and then fixed each of these. They are the test list for
`src/ninep.zig`, in roughly the order they will bite.
#ev("~/05-genizah/ad/crates/ninep/src/sansio/server.rs:335-338")[the scar they left in the source: «Spec: first element failure must be Rerror, not Rwalk with zero qids».]
Partial walks (`09334ce`); `Tcreate` of `".."` (`bcb05d9`); write-then-remove
(`1c8ae96`); create permission masking (`3ccc461`); open with truncate
(`f0c71be`); remove-on-close (`6471cd3`); the root qid's name being `"/"`
(`64f2f4b`); the default `aname` (`b3a2c8b`, `ce7ebc6`); exclusive files
(`135190c`); clearing the fid cache on clunk (`113b053`); fid open state
(`22041ab`); flush tracking (`7170e6a`, `5a6b5bf`).
#q[Most of these are `Tcreate`/`Tremove` bugs, and a synthetic tree could simply answer `Rerror` to both — the draft's §9 already argues our trees are invented and have no cases we did not choose. Does `new/` need real `Tcreate`, or is walk-to-create enough as it is under FUSE?]
#note("built", "2026-08-27")[
*Answered, and most of the list is moot.* `new/` creates a pane on WALK
(`src/acmefs.zig:901-919`), so the capability exists and is not spelled
`Tcreate`. The server answers `Rerror` to both `Tcreate` and `Tremove`,
which takes the create-permission-masking, create-`..`, remove-on-close and
write-then-remove bugs off the table entirely — four of `ad`'s twelve.
`Tremove` still clunks the fid first, because remove(5) says the fid is
invalid even if the remove fails.
Of the rest, the partial-walk rule and the flush tracking are implemented
and tested by name; the root qid's name is `"/"`; the fid cache is cleared
on clunk and the release the core is owed is issued there.
]
]
#entry("FIX-2", "The board's free-heap comment is two refactors out of date", state: "decided", tags: ("comment", "board", "one-line"))[
#ev("src/esp32p4.zig:470-473")[says 9,128 bytes free at 80×24. That was measured before `direct_emit` (`:870`) sized vaxis's two shadow grids to one cell.]
#ev("05-zig-p4/experiments/report.typ:848-849,877-880")[the heap now reports ≈336 KB free, and `.bss` in the 240 KiB low L2MEM is the binding constraint instead.]
#verdict[
Fix the comment and name the new constraint, because the stale number was
load-bearing in an architecture review — it is the reason a 9P server on
the board looked impossible when it costs 2.5% of free heap. A stale
measurement in a comment is worse than none: it gets quoted.
]
#q[The `report.typ` figure of "336 KB at every geometry tried" cannot be literally true across the 62,480-byte swing between 56×14 and 80×24 at 55 B/cell. One boot and one `MARK PARDES_HEAP` line closes it.]
]
#pagebreak()
= Direction
The four entries below were written after the owner rejected the first
synthesis as directionless and stated the goal himself: *"make it overall
simpler, and more transparent to scripting and integrating over the network,
and that could unlock some interesting things like chaining a pardes to
another — right now these kinds of things are complex and ad hoc."*
Three of those four are achievable. One is not, and `9P-20` says which.
#entry("9P-19", "Bidirectional 9P is one role per CONNECTION, not one role per message", state: "decided", tags: ("protocol", "mechanism", "correction"))[
The owner's objection to the first review: *"you said the server can't
initiate a message, but since the daemon and the app both could have a client
and a server, they could communicate bidirectionally."* He is right, and the
mechanism is better than either of us described.
The tempting reading is a double-role link: both ends run a server and a
client on one descriptor, demuxing on the message-type parity, which 9P makes
possible for free.
#ev("u9fs/fcall.h")[`Tversion = 100, Rversion, Tauth = 102, Rauth, …` — T is even, R is odd, so a receiver can always tell a request it must serve from a reply it is owed.]
That reading is available and *nobody has ever built it*.
#ev("drawterm-9front/exportfs/io.c:132-163")[`io()` is a pure server loop: read a T-message, dispatch through `fcalls[type]`, reply. It never emits a message that is not a reply. Plan 9's `exportfs` is the same (`exportfs.c:510-515`), u9fs is the same, and v9fs and `devmnt` are pure clients. No implementation anywhere demuxes two roles on one descriptor.]
What ships instead is better, because it needs no invention at all:
#ev("drawterm-9front/cpu.c:119-192")[drawterm DIALS OUT to the cpu server, writes a shell script, and then calls `exportfs(fd, fd)` — *the dialer becomes the server on the socket it dialed*.]
#ev("principia-softwarica/networking/misc/cpu.c:448,459-463")[the script the remote runs is `mount -nc /fd/0 /mnt/term`, then it opens `/mnt/term/dev/cons` as its stdin and stdout. The remote is the CLIENT.]
#ev("drawterm-9front/kern/devdraw.c:1323-1326")[so 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 as a server.]
One descriptor, roles fixed per side, data flowing both ways. There is no
server push because none is needed: *the frame producer is the client.*
#verdict[
The rule for pardes, and it is the whole architecture in three lines:
/ display: #[the FRONTEND is the server (it owns the screen), the CORE is the client. The core `Twrite`s frames to `screen` and blocking-`Tread`s `input`. This is `cpu` verbatim.]
/ session: #[the CORE is the server (it owns the windows), scripts and other instances are clients.]
/ devices: #[the BOARD is the server (it owns the memory and the pads), the core is the client.]
Three connections, one role each, and the program contains both halves. Do
NOT build a double-role single-descriptor link: no precedent, a `Tversion`
ordering hazard where each side must answer the peer's version while
awaiting its own, and `trans=fd` wants separate read and write descriptors
anyway.
]
#note("mechanism", "2026-08-27")[
Two numbers that make the display path credible, both of which the first
review got wrong by assuming a push. Chunking is a non-issue: payload per
message is `msize − P9_IOHDRSZ` = 131,072 B at Linux's default
(`client.h:23`, `9p.h:364`), 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 even the 1,703,936 B worst case is 13. And the core need not block:
tags are allocated per outstanding request with no in-order reply
requirement (`client.c:194-199`, `devmnt.c:783-800`, and `exportfs`
deliberately answers out of order via slave procs, `exportsrv.c:408-520`).
pardes needs no slave procs — `Status.again` plus the park table is the
same thing done single-threaded.
]
#note("mechanism", "2026-08-27")[
Copy the file discipline rather than inventing one. `/dev/draw/N/data` is a
write-only batched binary command stream (`devdraw.c:1323-1326`) with an
exclusive `ctl` (`:1047-1051`); `/dev/mouse` is an exclusive single-reader
file whose read blocks until something happens (`devmouse.c:96-98,150-158`).
That is exactly `screen` and `input`, already designed, already debugged,
and already the shape `event` has in `acmefs`.
]
]
#entry("9P-20", "\"Simpler\" is false in lines and true in concepts — argue reach instead", state: "decided", tags: ("cost", "honesty", "framing"))[
The owner's first criterion was that 9P make the codebase *overall simpler*.
Measured, it does not. This entry exists so that nobody argues otherwise
later, including us.
The baseline: *nine* IPC mechanisms, *four* framings, *three* discovery
schemes, *two* version-negotiation schemes and one mechanism with none.
#ev("src/nested.zig")[743 lines to deliver ONE line. By region: 255 transport, 158 ancestor discovery, 43 `sendLook`, 232 tests, 30 header — and the FEATURE is ≈12 lines (`:301-306` format, `:507` filter). Ratio feature-to-transport ≈ 1:24.]
#ev("src/detached/wire.zig")[contains ZERO syscalls — no `socket`, `bind`, `accept`, `poll`, `read` or `write` anywhere. It is a pure codec into caller buffers, so calling 789 of its lines "transport" was wrong: they are the definition of what may be said. 9P replaces its 5-byte length prefix with a 4-byte one.]
Six concepts are genuinely duplicated across the mechanisms, and the
duplication is real: endpoint-path derivation 82 lines across 6 copies;
directory create-and-vet 105 across 6; stale sweeping 112 across 3;
bind/listen/accept 213 across 3; "which instance?" 231 across 3; version
negotiation 56 across 2. *799 lines, of which ≈330 is recoverable.*
#ev("src/fs_service.zig:37-55")[the comment there is right on the facts and wrong on the conclusion: `parentFrom` and `nested.socketDir` differ ONLY by appending `/pardes` under `$XDG_RUNTIME_DIR` and coincide verbatim under the HOME fallback. And there are two liveness oracles for one question — `kill(0)`/ESRCH at `nested.zig:401` and `fuse.zig:742`, `connect`/ECONNREFUSED at `server.zig:1940-1942`.]
#verdict[
Could delete ≈850 lines (`nested.zig`'s transport and its socket tests,
518; the net collapse of duplicated plumbing, ≈330). Could not delete
≈7,700 — `fuse.zig` 2,709 (`9P-13`), `wire.zig` 1,705, 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,
pty, pipes, `mkstemp`).
Against a 1,600-1,900-line 9P stack (`9P-15`): *net growth of +750 to
+1,050 lines.*
So: strike "simpler" from every argument. What IS true, and is worth
saying, is that the CONCEPT count falls — four framings to two, three
discovery schemes to two, two version schemes to one, six duplicated
concepts to one copy each. Fewer ideas, more lines. Say exactly that.
]
#note("contra", "2026-08-27")[
The strongest form of the objection, and it should stay on the record: the
three mechanisms do not share a problem. `nested`'s format is twelve lines
*because* it reuses a language pardes already speaks — the file says so at
`:14-16`, "no serialization, nothing to version". `wire`'s codec is 1,167
lines because it carries a 1.6 MiB worst-case RLE grid at one message per
frame. `acmefs` is 2,324 because it is acme's semantics. The general thing
costs ≈970 lines before saying anything specific to pardes, and each of the
three still needs its specific part afterwards.
]
]
#entry("9P-21", "The lost compile-time invariant, which nothing else prices", state: "open", tags: ("cost", "correctness", "unpriced"))[
The best objection found against the whole direction, and no other entry
costs it.
#ev("src/detached/wire.zig:23-27")[«every union and every enum gets a tag chosen HERE … so reordering `Event` or `CellStyle.ul` cannot silently redefine the protocol. The mapping switches are exhaustive: adding a variant to the core is a compile error in this file, which is the point of them.»]
#ev("src/detached/wire.zig:1199-1265")[and a test enumerates the eleven legal client tags by name under the rule "A HUMAN DID IT", asserts the enum holds exactly those, and asserts the other 250 tag bytes answer `BadTag` — so a frontend built before a change cannot forge a pane's output into a session built after it.]
Under a `ctl`-file grammar, input arrives as `Twrite` payload bytes. 9P's
codec validates 9P; it cannot validate that a byte string is a legal `key`.
An exhaustive switch checked by the compiler and a 250-byte refusal test
become a runtime string parse with nothing to bind to.
#ev("src/host.zig:44-52")[a related loss: fourteen of twenty-one vtable methods are `push_` and return nothing, because "a push with an answer would have N answers and no way to pick one". 9P answers every T with an R, so each acquires a tag, a reply, and a matching obligation.]
#q[Three candidate answers, none costed yet. (a) Keep the typed wire for input and use 9P only for control and text — accepts two protocols forever. (b) Make the `ctl` grammar generated from the same enum, so the exhaustive switch survives as a parser generator and adding a variant is still a compile error. (c) Accept the loss and buy it back with a fuzz test over the grammar. (b) looks best and nobody has tried it.]
]
#entry("9P-22", "The real gap: pardes cannot ask another pardes anything", state: "decided", tags: ("direction", "motivating-case"))[
This is the entry the whole file was missing, and it is the owner's own
criterion stated precisely.
Two instances of pardes can do exactly two things to each other today.
/ Shout: #[`nested.sendLook` formats `Look <path>`, connects, writes, returns `true`, and NEVER READS (`src/nested.zig:294-330`). The receiver filters for the one legal verb and closes (`:466-510`). The test at `:652-702` exercises the whole protocol and asserts that nothing comes back.]
/ Become: #[`Attach` is not a connection, it is a replacement. Only on a successful handshake does the frontend reap its pane shells, close its watches, unmount `--fs`, *deinit the core*, and enter the thin loop (`src/detached/client.zig`, `docs/detached.md:207-213`). The local core is destroyed, not linked.]
Everything else is out of reach. Nothing in `src/` reads `PARDES_FS`; `Event`
has no variant for a peer; `Host.VTable`'s 21 methods have no peer method.
Reading another session's text is possible only by shelling out —
`Exec cat /run/user/1000/pardes/<other-pid>/1/body` — which is Linux-only,
opt-in behind `--fs`, requires already knowing the pid, and is mediated
entirely by `/bin/sh`. And it fails outright against a detached session,
which serves no filesystem at all (`src/detached/server.zig:566-570`).
#verdict[
*The direction is a reply.*
pardes has no request/response channel to anything that is not the kernel.
`nested` is one-way by construction. The detached wire forbids a round trip
inside `update` by design (`wire.zig:64-70`). `acmefs` has request/response
and cannot leave the machine (`fuse.zig:64`, `9P-13`).
9P is a request/response protocol that crosses machines, and that single
property is what the other three cannot be extended to have. Every good
thing on the list — scripting from macOS, driving a session over a network,
reading the board's memory, chaining one instance to another without
destroying either — is the same feature: *being able to ask, and get an
answer back.*
That is the justification. Not simplification (`9P-20` forbids it), not
elegance, and not symmetry.
]
#note("direction", "2026-08-27")[
The measure of success, so this can be checked rather than admired: after
the work, `Attach` should have a sibling that CONNECTS instead of
replacing — two live cores, each able to walk the other's tree — and the
twelve-line `Look` sentence should be a `Twrite` to the other instance's
`new/` on a connection that can also answer. If those two things are not
true at the end, the protocol was adopted for its own sake.
]
]
#entry("9P-23", "The host vtable stays; 9P is one implementation of it, for remote hosts only", state: "decided", tags: ("architecture", "scope-limit"))[
The grand version of the direction was that `Host.VTable` becomes a tree the
core mounts, so that the downward seam and the outward seam are the same kind
of thing. Measured, that is wrong for every LOCAL host and right only for
remote ones.
#ev("src/host.zig:52-140")[the seam is 21 methods: 15 `push_` and 6 `pull_`, with the prefix compiler-enforced (`isPull`, `:265-274`) — a `push_` returning non-void does not build.]
The blocking objection turns out to be nearly empty. Of the six `pull_`
methods, three are ALREADY asynchronous — `pull_read_clipboard`, `pull_lsp`
and `pull_pipe` return `void` and their answers arrive as `Event.paste`,
`.lsp_resp`, `.pipe_resp` (`src/pardes.zig:6896-6907`). `pull_wait_input`
cannot become an event because it is how events arrive. That leaves two:
`pull_gpio_toggle`, whose answer is only used to set a message row
(`builtins.Board.gpio`), so deferring it a frame is invisible; and
`pull_tty_taken`, which is reached from `takesCommandLine`
(`pardes.zig:6465-6469`) at four call sites, two of which loop over all 16
panes — 16 probes per call, collapsing to ONE read if `proc` is a single file
of sixteen lines rather than `proc/N/status`.
So the core could be a 9P client without blocking. It should not be, locally:
#ev("per-host measurement")[bodies replaced by an estimated 9P server, per host: tty 345 → ≈225 but *+204* with the tree and `ctl` grammars; gui *+204*; detached server *+182*; macos *+164*; web *+142 Zig plus ≈500 lines of JavaScript*; board *+59* and 8,832 B of RAM. *No host gets simpler.*]
#ev("src/web.zig:356-391")[and web gets actively worse than the count shows: `present` today writes a flat `WebCell` array that JavaScript reads *directly out of wasm memory* across four `extern` declarations (`:330-340`). Under 9P that zero-copy array becomes an RLE stream JS must decode, and wasm32 has no sockets, so JS must carry a 9P codec too.]
#ev("src/esp32p4.zig:127")[the board is worse still in kind: its "host" is the same process behind a C-ABI function pointer, so a `Twrite`/`Rwrite` pair per frame is pure protocol overhead against a direct call.]
#verdict[
`Host.VTable` is already the abstraction — `docs/design.typ:1964-1965` says
the surface IS the abstraction and refuses a render layer over the shells.
A tree would be that layer.
So: a 9P host is *one more implementation of the existing vtable*, chosen
when the display is on the other end of a wire, exactly as `9P-2` makes a
9P transport one more implementation of the filesystem seam. Local hosts
keep the direct call and pay nothing. The two seams stay two seams; what
they gain is a second backend each.
That also settles `9P-19`'s scope: the core is a 9P client of its display
only when the display is REMOTE — a detached frontend, or a board acting as
a terminal for a full core elsewhere. Never for the window in front of you.
]
#note("correction", "2026-08-27")[
Fact correction for anything quoting the earlier tally: `tty` and `gui`
fill *19* of 21, not 20 — `src/detached/server.zig:558-559` already says
"NINETEEN each". The 20 figure was wrong by one and is superseded here.
]
]
#entry("9P-24", "A dead descriptor left in a poll set is a whole core, forever", state: "landed", tags: ("bug", "lesson"))[
Found by an adversarial pass over step 1 after it was committed and after the
happy path had been demonstrated with the project's own example clients. It is
recorded because the shape recurs, not because the fix was hard.
Step 1 put `/dev/fuse` in the daemon's `poll(2)` set whenever the mount
existed, and gave `Source.fuse` an empty arm on the grounds that being in the
set was the whole point.
#ev("linux/fs/fuse/dev.c")[`fuse_dev_poll` answers `EPOLLERR` once the connection is gone — and POSIX reports `POLLERR` whatever the `events` mask asked for, so an empty arm cannot decline it.]
#ev("src/fuse.zig")[`pollLoop`, the mount's own poll thread, has carried `if (revents & (ERR|HUP|NVAL) != 0) return;` all along. That is why the tty and SDL shells never showed this and the daemon did: they never poll the descriptor themselves.]
So an external `fusermount3 -u`, a sysfs abort, or systemd taking
`/run/user/$UID` away at final logout — *exactly the moment a detached session
is supposed to keep running* — made `poll(2)` return instantly and forever.
Measured on the committed change: 0 CPU ticks over 10 s idle, then 1000 ticks
over the next 10 s. After the fix, 0 ticks over 8 s in the same scenario, with
the process alive and in state `S`.
#verdict[
Gate the insertion on `!f.dead` and let the arm consume `POLLERR`/`POLLHUP`/
`POLLNVAL` by marking the mount dead. Both, not either: the gate is what
ends the spin, and the arm is what saves the one spinning round before
`Fs.next`'s first failed read would have set the flag anyway.
The general rule, which is the reason this entry exists: *a descriptor
added to a shared poll set needs an error arm even when it needs no data
arm.* An empty arm is a decision about `POLLIN` only, and the kernel does
not ask permission before reporting `POLLERR`.
]
#note("review", "2026-08-27")[
Worth noticing how it was caught. The feature was demonstrated working —
`pardesctl panes`, `new`, `send`, `body`, `del` against a live daemon — and
the bug was nowhere near the happy path. It took an adversary told to
*assume the happy path works and look elsewhere*, who then went and measured
`/proc/<pid>/stat` before and after an unmount. Four of that pass's other
seven findings were false comments rather than false code, which in this
codebase is the same severity: the comments are how the next change is made.
]
]
#pagebreak()
= After the build
Five steps shipped, and the shape of what is left is not the shape the note
predicted. These entries are written from the tree as built, not from the plan.
#entry("9P-25", "The tree behind the server is pluggable, and that was not planned", state: "decided", tags: ("architecture", "windfall"))[
`Server` is `pub fn Server(comptime fs: type) type`, duck-typed on exactly
`fs.Req`, `fs.Reply` and `fs.Reply.Attr`. That shape was chosen for a boring
reason — importing `acmefs.zig` drags `pardes.zig` into `zig test` — and it
turned out to be the most useful thing in the file.
#ev("src/acmefs.zig")[filesystem one: the acme control tree, 9 ops.]
#ev("src/board9p.zig")[filesystem two: 867 lines that re-declare the same `Op`, `Status`, `Req`, `Reply` and `handle`, and are served by the same `Server` with no translation layer at all.]
So "serve X over 9P" is no longer a protocol question. It is: write a
`handle()` over nine operations, and get a wire, a fid table, directory
cursors, `Tflush`, error strings and both freestanding targets for free.
#verdict[
Treat `Server(fs)` as the extension point it accidentally became, and say so
where someone will look. Candidates that are now cheap and were never on a
list: the LSP surface as a tree, a session dump as a tree, the config as a
tree. None of them needs a line of 9P.
The discipline that keeps this from becoming a plugin system: nine
operations and no tenth. A filesystem that wants a tenth wants an API.
]
]
#entry("9P-26", "What is cheap now, ranked, and step 6 is not first", state: "open", tags: ("sequencing", "next"))[
Step 6 — a remote display serving `screen` and `input` while the core is its
client — is cheaper than it was, because its one hard prerequisite is done:
`Client` exists, is 152 bytes, and blocks nowhere. And `board9p` proved that
writing a second filesystem behind `Server(fs)` is a day's work, which is
exactly what a `host` tree would be.
But four things are now cheaper than step 6 AND serve the stated goals more
directly. Ranked by value over cost:
/ A serial transport for the client: #[the board image exists and speaks 9P on UART0; `src/fs9_client.zig` speaks unix sockets. One transport away from the motivating case being real, and it is the smallest item here.]
/ TCP: #[`fs9_service` and `fs9_client` are `AF_UNIX` only. "Integrating over the network" was one of the four stated goals and it is currently a socket family, not a design problem.]
/ macOS and the browser: #[step 4's entire portability argument — that a 9P server needs no kernel — is UNEXERCISED. Nobody has run `9pfuse` against the socket on macOS, and the web build has no client. This is the payoff that justified the growth in `9P-20`, and it is closer to zero code than anything else on the list.]
/ Aggregation: #[unblocked, see `9P-10`'s note. Serves "chaining a pardes to another", which is the goal `9P-22` named as the whole point.]
#q[Step 6 also changed SHAPE, and the note should be rewritten before it is built. It was sold as "the board stops being a shrunken pardes and becomes a terminal for a full core". What got built is the INVERSE: the board is a 9P server of its own devices and the desktop is the client. Both are useful and they are different images. Which one is step 6 — and is a board that shows a remote core's screen worth an image, now that a board that exposes its pins is already flashed?]
#note("scope", "2026-08-27")[
Unchanged by any of this: `9P-23`'s measurement. Local hosts keep the direct
vtable call, because a tree costs tty and gui about +204 lines each and the
web shell +142 Zig plus ≈500 JavaScript. Step 6 is remote displays only, and
the moment it is argued for the window in front of you, that number is the
answer.
]
]
#entry("9P-27", "What three adversaries found in steps 3, 4 and 5", state: "landed", tags: ("bug", "lesson", "review"))[
Steps 1 and 2 were reviewed and the pass found a 100%-CPU spin in the smallest
change of the chain (`9P-24`). Steps 3, 4 and 5 — 1,005, 9,158 and 3,535 line
diffs — were verified on the happy path and shipped unreviewed. Three agents,
one per step, told to assume the happy path works and look elsewhere.
Eight defects. Six fixed, five reproduced with measurements before and after.
/ Remote crash of the whole daemon: #[`Server.push` takes nothing once `startFrame` gives up on the framing, and `fs9_service.fill` asserted it took everything. One `size[4]` of zero plus one later byte reached `unreachable` — every pane, every frontend and the FUSE mount gone. The same stuck buffer separately made `room == 0` return without reading while poll reported ready forever: *99.7% of a core*, in one `write(2)`, from any process with the uid.]
/ A 177-second freeze of the editor: #[`fs9_client.connect` ran `connect(2)` on a still-BLOCKING socket, before `setNonblock` and before the deadline existed. On AF_UNIX a full accept backlog waits in `unix_wait_for_peer` for `sk_sndtimeo`, which is forever, and the core is single-threaded. Measured at *177.3 s*, ending only because the peer was killed. Now 2.03 s, the budget.]
/ A 64 KiB pty read wiping the queue: #[`queue_cap` is 65536 and a record must fit in `queue_cap - 4`, but every host reads a pty master with a 64 KiB buffer and a single read really returns 65536 on Linux. The eviction loop then emptied the queue and dropped the new record too, silently. Deterministic, not a race.]
/ Four silent sockets denying `--fs9` forever: #[nothing took a slot back, and there are four. `version(5)` requires `Tversion` first and `msize == 0` already meant "has not versioned", so the frontend transport's own five-second rule applied unchanged.]
/ EMFILE spinning a core: #[`accept` treated every failure as EAGAIN, but EMFILE leaves the connection in the backlog and poll is level triggered. *99.8% of one core.*]
/ `max_fids = 32` refuting the step's own acceptance clause: #[a mounting client keeps a fid per cached inode, and `docs/9p.typ` §12.4 makes `9pfuse` the proof. At 32, `find` produced *57 consecutive `Rerror`s*. Now 256 for a host and `board_fids` 32 for the board.]
#verdict[
Three of the six are the SAME defect as `9P-24`: a descriptor left in a
level-triggered poll set with no error arm, or a wait with no deadline. That
is now four instances across five steps, every one of them a whole core or
a frozen editor, and every one written by someone who had just read the
previous one.
So make it a rule rather than a lesson: *every descriptor this project adds
to a poll set needs an error arm, and every wait needs a deadline set before
the thing it is waiting on can begin.* Both are checkable by reading, and
both were missed by authors who knew the rule.
]
#note("method", "2026-08-27")[
Two observations about the reviewing, worth more than the bugs.
The instruction that worked was *"assume the happy path works — it has been
demonstrated live — and look everywhere else."* Every finding came from
somewhere the demonstration could not reach: a peer that stalls, a mount
that is torn down, a buffer at exactly its limit. The demonstrations were
all real and all passed, and none of them would ever have found any of this.
And the reviewers were told to *measure*. Every serious finding arrived with
`/proc/<pid>/stat` before and after, or a wall-clock figure, or a count of
consecutive errors. A report saying "this could spin" would have been argued
with; "998 jiffies per 10 s against a 0-jiffy baseline, here is the script"
could not be. The cost of asking for that was a reviewer that took forty
minutes instead of ten.
]
#q[Two findings are NOT fixed and should be. A parked `pty/data` read whose client is gone keeps consuming the queue destructively — measured as 34% of a pane's output going nowhere on a long-running daemon, with the trigger not isolated in 23 attempts. And a `Tclunk` does not sweep park slots on the fid it retires, so a `close(2)` with a read in flight strands the tag forever; 32 of those and the connection can never serve a blocking read again. Both are reachable by ordinary client behaviour.]
]
|