Skip to content

Commit 08fc00e

Browse files
committed
chore: reorganize roadmap
1 parent 55a8b60 commit 08fc00e

2 files changed

Lines changed: 117 additions & 100 deletions

File tree

‎.agents/roadmap.md‎

Lines changed: 116 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ constraint the remaining work runs into; the entry is deleted the moment nothing
1414
is left to run into it. A reader here is looking for what to pick up, and every
1515
sentence 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.

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ install && pnpm dev` **from inside it**.
125125
| `architecture.md` | the per-file map and the design reasoning behind it |
126126
| `invariants.md` | the list above, in full, with the "why" |
127127
| `testing.md` | tiers, the conformance matrix, per-area test layout, known gaps |
128-
| `roadmap.md` | open work only: the decisions still binding, deferred work |
128+
| `roadmap.md` | open work only: the decisions still binding, then open work by area |
129129
| `environment.md` | verified host facts (FUSE, 9P, VM guests, rclone, macOS) and wedge recovery |
130130
| `conformance-matrix.md` | generated per-transport conformance table (`pnpm matrix`) |
131131
| `benchmarks.md` | generated performance numbers and their interpretation (`pnpm bench`) |

0 commit comments

Comments
 (0)