@@ -14,6 +14,11 @@ constraint the remaining work runs into; the entry is deleted the moment nothing
1414is left to run into it. A reader here is looking for what to pick up, and every
1515sentence about something already done is a sentence they have to rule out first.
1616
17+ ** Open work is grouped by area** — core, perf, then one section per transport,
18+ then platforms. Nothing in those sections blocks a release; each is a real gap
19+ worth picking up deliberately rather than by accident. An entry lives in the
20+ area whose code it changes, not the area that motivated it.
21+
1722## Decisions still binding
1823
1924- ** Layout:** single ` mountx ` package with subpath exports; the pnpm workspace
@@ -56,10 +61,72 @@ sentence about something already done is a sentence they have to rule out first.
5661- ** Subagent models:** implementation work SHOULD use ` opus ` ; docs work SHOULD
5762 use ` sonnet ` .
5863
59- ## Future / deferred
64+ ## Core and drivers
65+
66+ - ** A driver → transport "path X changed underneath you" channel.** The driver
67+ end is what is missing: nothing in ` FsDriver ` can say a path changed, so the
68+ only caller of ` notifyInvalInode() ` / ` notifyInvalEntry() `
69+ (` src/fuse/session.ts ` , ` src/fuse/mount.ts ` , encoders in ` src/fuse/notify.ts ` )
70+ is code that already knows because it made the change itself. It is ** not** a
71+ ` mountx.* ` member: every extension there is a path-shaped call the session
72+ makes, and this is an event the driver raises unprompted, so it wants a
73+ ` watch ` -shaped surface on ` FsDriver ` rather than a method in the namespace.
74+ The translation costs nothing — ` InodeTable.byPath ` turns the path into the
75+ ` ino ` a notification needs, and a path the kernel never looked up has nothing
76+ cached, which is the natural no-op.
77+ ** FUSE is the only transport that could consume it today, and the only one
78+ that can without new protocol work.** 9P has no invalidation message at all
79+ (see the 9P section). NFSv3 is request/response only: client caching is
80+ bounded by attribute timeouts and nothing server-initiated exists. NFSv4.1
81+ does have a back channel — delegation recall and ` CB_NOTIFY ` — but this server
82+ grants no delegations, never sets ` CREATE_SESSION4_FLAG_CONN_BACK_CHAN ` ,
83+ refuses a ` CDFC4_BACK ` ` BIND_CONN_TO_SESSION ` with ` NFS4ERR_INVAL ` and answers
84+ ` BACKCHANNEL_CTL ` the same way, so reaching it means building the callback
85+ program, delegations, and ` GET_DIR_DELEGATION ` for the directory case — far
86+ more work than the notification it would carry. The S3 gateway has neither a
87+ channel nor a client cache to invalidate.
88+ - ** A ` mknod ` polyfill for drivers that cannot store a special file
89+ themselves.** A driver without ` mountx.mknod ` answers ` ENOTSUP ` ; the fallback
90+ would be an overlay holding the node in memory only, writing nothing through
91+ to the backing store — a small union filesystem, because it has to win on
92+ every creating call (` mkdir ` /` symlink ` /` link ` /` open(O_CREAT) ` /a ` rename `
93+ destination must answer ` EEXIST ` against an overlaid name) and not merely on
94+ the three that read types. Opt-in if it is built, not default: making
95+ ` resolveCapabilities(node:fs/promises) ` report ` extensions: ["mknod"] ` would
96+ claim an extension whose nodes live in one process's heap, and on a shared
97+ backing store the FIFO would be real to the mounting process and absent to
98+ every other one.
99+ - ** xattr and the rest of the ` mountx.* ` extension namespace** (byte-range
100+ locks, ` fallocate ` /` lseek ` ) — ` ENOTSUP ` until a real user needs one.
101+ Cache-invalidation ` notify ` is not an extension at all; it has its own entry
102+ above.
103+ - ** ` mountx/drivers/s3 ` ** — an ` FsDriver ` over a real S3-compatible bucket,
104+ mounting one rather than serving one; the natural follow-up to the S3 gateway
105+ transport. It would reuse ` test/s3/client.ts ` 's signing client instead of
106+ writing a second one.
107+
108+ ## Performance
109+
110+ - ** ` FUSE_READDIRPLUS_AUTO ` default.** Benchmarked as actively costing the
111+ readdirplus win: a cold 1000-entry ` ls -l ` gets 9.6k entries/s with it on vs.
112+ 25.0k (the predicted 2.4×, measured 2.6×) with it off, at a 1.56× cost to a
113+ names-only ` readdir ` . Measured on two trees in one sitting (2.6× and 2.47×),
114+ so the finding replicates; the cost side grew from 1.32× because ` 7185f33 `
115+ made the plain-` READDIR ` path 1.20× faster and left the plus path alone. Not
116+ changed yet — see ` .agents/benchmarks.md ` for the numbers — but it is the
117+ best-supported open question in the repo.
118+ - ** Whether ` DEFAULT_MAX_IN_FLIGHT ` should be higher.** The 9P column has numbers
119+ behind both of that transport's knobs, and they point opposite ways. The
120+ dispatch window pays: 64 against the shipped 16 is 1.34–1.35× on stats 64 deep,
121+ and closing it to 1 costs 0.87–0.94×, so the default sits below where it stops
122+ earning. ` msize ` is settled the other way — 1 MiB buys 1.29× over the shipped
123+ default for eight times the per-request memory, and the default is already
124+ 3.0–3.2× the 16 KiB the kernel used to ship. Changing a shipped default wants a
125+ second sitting on another host before the one measurement becomes a decision.
126+ - ** An NFSv4.1 bench column.** The last transport that is in the conformance
127+ matrix and not in the benchmarks.
60128
61- Nothing here blocks a release; each is a real gap worth picking up deliberately
62- rather than by accident.
129+ ## FUSE
63130
64131- ** FUSE reader concurrency: one mode worth building, two not.** Async
65132 main-thread mode is all there is.
@@ -98,6 +165,25 @@ rather than by accident.
98165 and use the mount from another. ` bench/drive.ts ` does exactly that in reverse,
99166 which is why no benchmark column has ever wedged.
100167
168+ - ** Set-gid inheritance on the FUSE session.** ` src/ownership.ts ` holds the rule
169+ (` inode_init_owner() ` ) and the two NFS sessions apply it from the parent
170+ ` stat ` they take anyway for ` wcc_data ` / ` change_info4 ` . 9P needs nothing —
171+ its client computes both halves (` v9fs_get_fsgid_for_create() ` ) and they
172+ arrive on the wire. FUSE is the gap, and ` #claim ` in ` src/fuse/session.ts `
173+ says why it is not simply mirrored: nothing on that path reads the parent, so
174+ the rule would add an ` lstat ` to every create for every caller — including the
175+ daemon-is-the-caller case that costs no driver call at all — and the
176+ membership half needs ` FUSE_CREATE_SUPP_GROUP ` (7.38), which
177+ ` src/fuse/init.ts ` does not ask for. Both are decisions to take deliberately,
178+ not omissions.
179+ - ** FUSE over io_uring** (Linux 6.14+) — would replace the read/reply loop with
180+ a shared ring and obsolete the threadpool discussion entirely; the transport
181+ layer should stay swappable so this is one file, not a rewrite.
182+ - ** ` FUSE_PASSTHROUGH ` ** (Linux 6.9+) — lets the kernel bypass the daemon for
183+ read/write on a backing fd; relevant for overlay-shaped drivers.
184+
185+ ## 9P
186+
101187- ** 9P ` trans=fd ` .** Reachable today with no relay and no native code, and an
102188 ordinary option to be judged on its own merits: Node _ can_ create a socketpair
103189 without native code — verified on this host, libuv's ` stdio: "pipe" ` on POSIX
@@ -107,16 +193,6 @@ rather than by accident.
107193 facts need one root mount each first: whether ` mount(8) ` keeps an inherited
108194 socket fd open across ` mount(2) ` , and whether v9fs's ` trans=fd ` drives an
109195 ` AF_UNIX ` stream.
110- - ** An NFSv4.1 bench column.** The last transport that is in the conformance
111- matrix and not in the benchmarks.
112- - ** Whether ` DEFAULT_MAX_IN_FLIGHT ` should be higher.** The 9P column has numbers
113- behind both of that transport's knobs, and they point opposite ways. The
114- dispatch window pays: 64 against the shipped 16 is 1.34–1.35× on stats 64 deep,
115- and closing it to 1 costs 0.87–0.94×, so the default sits below where it stops
116- earning. ` msize ` is settled the other way — 1 MiB buys 1.29× over the shipped
117- default for eight times the per-request memory, and the default is already
118- 3.0–3.2× the 16 KiB the kernel used to ship. Changing a shipped default wants a
119- second sitting on another host before the one measurement becomes a decision.
120196- ** Cross-session fid remap, and a ` PathLock ` that spans connections.** A rename
121197 moves the byte ranges with the file (` P9LockTable.remap ` ) but rewrites only the
122198 renaming connection's fid table, because ` FidTable.remap ` is per connection —
@@ -130,28 +206,6 @@ rather than by accident.
130206 covered at Tier 0/1 — two connections on one ` createP9Server ` , including the
131207 release when one drops — but two _ real_ mounts of one server, a guest and its
132208 host or two guests, is not, and needs a host that can produce them.
133- - ** A driver → transport "path X changed underneath you" channel.** The driver
134- end is what is missing: nothing in ` FsDriver ` can say a path changed, so the
135- only caller of ` notifyInvalInode() ` / ` notifyInvalEntry() `
136- (` src/fuse/session.ts ` , ` src/fuse/mount.ts ` , encoders in ` src/fuse/notify.ts ` )
137- is code that already knows because it made the change itself. It is ** not** a
138- ` mountx.* ` member: every extension there is a path-shaped call the session
139- makes, and this is an event the driver raises unprompted, so it wants a
140- ` watch ` -shaped surface on ` FsDriver ` rather than a method in the namespace.
141- The translation costs nothing — ` InodeTable.byPath ` turns the path into the
142- ` ino ` a notification needs, and a path the kernel never looked up has nothing
143- cached, which is the natural no-op.
144- ** FUSE is the only transport that could consume it today, and the only one
145- that can without new protocol work.** 9P has no invalidation message at all
146- (below). NFSv3 is request/response only: client caching is bounded by
147- attribute timeouts and nothing server-initiated exists. NFSv4.1 does have a
148- back channel — delegation recall and ` CB_NOTIFY ` — but this server grants no
149- delegations, never sets ` CREATE_SESSION4_FLAG_CONN_BACK_CHAN ` , refuses a
150- ` CDFC4_BACK ` ` BIND_CONN_TO_SESSION ` with ` NFS4ERR_INVAL ` and answers
151- ` BACKCHANNEL_CTL ` the same way, so reaching it means building the callback
152- program, delegations, and ` GET_DIR_DELEGATION ` for the directory case — far
153- more work than the notification it would carry. The S3 gateway has neither a
154- channel nor a client cache to invalidate.
155209- ** 9P has no FUSE-style invalidation channel.** ` notify_inval_inode ` /
156210 ` notify_inval_entry ` have no 9P analogue — nothing in the protocol lets a
157211 server tell a client that something it cached has changed — which is why
@@ -160,6 +214,33 @@ rather than by accident.
160214 but the mount itself changes the driver, with no way to walk that bet back
161215 after the fact the way FUSE's ` notifyInvalInode() ` can. Worth revisiting only
162216 alongside a concrete driver that wants the tradeoff.
217+
218+ ## NFS
219+
220+ - ** A default for ` maxHandles ` .** The cap is opt-in, and what makes picking a
221+ default a judgement call rather than a number is that ** the cap is soft** : an
222+ entry with live NFSv4.1 state is pinned and never a victim, so when every
223+ candidate is pinned the table exceeds ` maxHandles ` rather than breaking a
224+ share reservation, and it does not evict its way back down until the opens
225+ close. A default is therefore a hint about the working set, not a memory
226+ ceiling, and the honest one depends on a workload nobody has measured here
227+ yet. Two smaller things belong with it: nothing enforces a floor relative to a
228+ READDIRPLUS page (at ` maxHandles: 8 ` one 40-entry page returns 40 handles of
229+ which 33 are stale before the reply leaves — it converges, and it is
230+ documented rather than clamped), and ` MNT ` binds the export root through an
231+ ordinary lookup, so a _ subdirectory_ export's mount root is evictable and a v3
232+ client has no name above it to recover with. A directly constructed
233+ ` Nfs3Session ` / ` Nfs4Session ` also builds its own table and ignores the
234+ option; every public path goes through the router, so this is the tests and
235+ the CLI only.
236+ - ** ` suppattr_exclcreat ` (75) is unadvertised** in ` src/nfs/v4/attr.ts ` 's
237+ ` SUPPORTED_ATTRS ` . The exclusive-create verifier lives beside the file rather
238+ than in an attribute, so the honest value is the full settable set — the
239+ attribute's "deliberately absent, it is the OPEN step's call" note predates
240+ the OPEN step making that call.
241+
242+ ## Platforms
243+
163244- ** AppleDouble sidecars are undocumented in ` docs/ ` .** macOS tags every new
164245 file with ` com.apple.provenance ` and NFSv3 has no xattr procedure, so the
165246 client writes a ` ._name ` companion per file and a driver mounted there
@@ -178,67 +259,3 @@ rather than by accident.
178259- ** WebDAV and Windows support.** Not designed against; WebDAV is the
179260 unprivileged, zero-native-code path for macOS/Windows. Windows also has no
180261 ` mount(8) ` , so it stays out of the NFS transport's platform switch.
181- - ** A ` mknod ` polyfill for drivers that cannot store a special file
182- themselves.** A driver without ` mountx.mknod ` answers ` ENOTSUP ` ; the fallback
183- would be an overlay holding the node in memory only, writing nothing through
184- to the backing store — a small union filesystem, because it has to win on
185- every creating call (` mkdir ` /` symlink ` /` link ` /` open(O_CREAT) ` /a ` rename `
186- destination must answer ` EEXIST ` against an overlaid name) and not merely on
187- the three that read types. Opt-in if it is built, not default: making
188- ` resolveCapabilities(node:fs/promises) ` report ` extensions: ["mknod"] ` would
189- claim an extension whose nodes live in one process's heap, and on a shared
190- backing store the FIFO would be real to the mounting process and absent to
191- every other one.
192- - ** xattr and the rest of the ` mountx.* ` extension namespace** (byte-range
193- locks, ` fallocate ` /` lseek ` ) — ` ENOTSUP ` until a real user needs one.
194- Cache-invalidation ` notify ` is not an extension at all; it has its own entry
195- above.
196- - ** ` suppattr_exclcreat ` (75) is unadvertised** in ` src/nfs/v4/attr.ts ` 's
197- ` SUPPORTED_ATTRS ` . The exclusive-create verifier lives beside the file rather
198- than in an attribute, so the honest value is the full settable set — the
199- attribute's "deliberately absent, it is the OPEN step's call" note predates
200- the OPEN step making that call.
201- - ** FUSE over io_uring** (Linux 6.14+) — would replace the read/reply loop with
202- a shared ring and obsolete the threadpool discussion entirely; the transport
203- layer should stay swappable so this is one file, not a rewrite.
204- - ** ` FUSE_PASSTHROUGH ` ** (Linux 6.9+) — lets the kernel bypass the daemon for
205- read/write on a backing fd; relevant for overlay-shaped drivers.
206- - ** ` FUSE_READDIRPLUS_AUTO ` default.** Benchmarked as actively costing the
207- readdirplus win: a cold 1000-entry ` ls -l ` gets 9.6k entries/s with it on vs.
208- 25.0k (the predicted 2.4×, measured 2.6×) with it off, at a 1.56× cost to a
209- names-only ` readdir ` . Measured on two trees in one sitting (2.6× and 2.47×),
210- so the finding replicates; the cost side grew from 1.32× because ` 7185f33 `
211- made the plain-` READDIR ` path 1.20× faster and left the plus path alone. Not
212- changed yet — see ` .agents/benchmarks.md ` for the numbers — but it is the
213- best-supported open question in the repo.
214- - ** ` mountx/drivers/s3 ` ** — an ` FsDriver ` over a real S3-compatible bucket,
215- mounting one rather than serving one; the natural follow-up to the S3 gateway
216- transport. It would reuse ` test/s3/client.ts ` 's signing client instead of
217- writing a second one.
218- - ** A default for ` maxHandles ` .** The cap is opt-in, and what makes picking a
219- default a judgement call rather than a number is that ** the cap is soft** : an
220- entry with live NFSv4.1 state is pinned and never a victim, so when every
221- candidate is pinned the table exceeds ` maxHandles ` rather than breaking a
222- share reservation, and it does not evict its way back down until the opens
223- close. A default is therefore a hint about the working set, not a memory
224- ceiling, and the honest one depends on a workload nobody has measured here
225- yet. Two smaller things belong with it: nothing enforces a floor relative to a
226- READDIRPLUS page (at ` maxHandles: 8 ` one 40-entry page returns 40 handles of
227- which 33 are stale before the reply leaves — it converges, and it is
228- documented rather than clamped), and ` MNT ` binds the export root through an
229- ordinary lookup, so a _ subdirectory_ export's mount root is evictable and a v3
230- client has no name above it to recover with. A directly constructed
231- ` Nfs3Session ` / ` Nfs4Session ` also builds its own table and ignores the
232- option; every public path goes through the router, so this is the tests and
233- the CLI only.
234- - ** Set-gid inheritance on the FUSE session.** ` src/ownership.ts ` holds the rule
235- (` inode_init_owner() ` ) and the two NFS sessions apply it from the parent
236- ` stat ` they take anyway for ` wcc_data ` / ` change_info4 ` . 9P needs nothing —
237- its client computes both halves (` v9fs_get_fsgid_for_create() ` ) and they
238- arrive on the wire. FUSE is the gap, and ` #claim ` in ` src/fuse/session.ts `
239- says why it is not simply mirrored: nothing on that path reads the parent, so
240- the rule would add an ` lstat ` to every create for every caller — including the
241- daemon-is-the-caller case that costs no driver call at all — and the
242- membership half needs ` FUSE_CREATE_SUPP_GROUP ` (7.38), which
243- ` src/fuse/init.ts ` does not ask for. Both are decisions to take deliberately,
244- not omissions.
0 commit comments