Skip to content

Commit 5c4b3fe

Browse files
authored
test(9p): run the conformance special-files cases over the wire (#16)
1 parent 0ec60ef commit 5c4b3fe

5 files changed

Lines changed: 93 additions & 35 deletions

File tree

‎.agents/conformance-matrix.md‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@ One conformance suite (`test/conformance.ts`), written against the driver interf
66

77
Generated 2026-07-31 with `pnpm matrix`.
88

9-
| Column | What it runs | Result |
10-
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
11-
| **loopback** | `test/drivers.test.ts` — the driver behind `createLoopback`, no transport | 251 passed, 29 skipped (4 targets: memory; node-fs; unstorage; node:fs/promises (raw)) |
12-
| **FUSE** | `test/fuse/conformance-mount.test.ts` — a real kernel mount, `node:fs` as the client | 130 passed, 10 skipped (2 targets: memory driver, through a FUSE mount; node-fs driver, through a FUSE mount) |
13-
| **9P** | `test/9p/conformance.test.ts` — 9P2000.L through the codecs, the JS client from `test/9p/client.ts` | 192 passed, 18 skipped (3 targets: memory driver, over 9P; memory driver with no handles, over 9P; node-fs oracle, over 9P) |
14-
| **NFSv3** | `test/nfs/v3/conformance.test.ts` — NFSv3 over a TCP socket, the JS client from `test/nfs/v3/client.ts` | 124 passed, 16 skipped (2 targets: memory driver, over NFS; node-fs driver, over NFS) |
15-
| **NFSv4.1** | `test/nfs/v4/conformance.test.ts` — NFSv4.1 over a TCP socket, the JS client from `test/nfs/v4/client.ts` and the driver over it in `test/nfs/v4/driver.ts` | 124 passed, 16 skipped (2 targets: memory driver, over NFSv4.1; node-fs driver, over NFSv4.1) |
16-
| **S3** | `test/s3/conformance.test.ts` — an S3 gateway in process, the JS client from `test/s3/client.ts` | 48 passed, 22 skipped (1 target: memory driver, over S3) |
9+
| Column | What it runs | Result |
10+
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
11+
| **loopback** | `test/drivers.test.ts` — the driver behind `createLoopback`, no transport | 251 passed, 29 skipped (4 targets: memory; node-fs; unstorage; node:fs/promises (raw)) |
12+
| **FUSE** | `test/fuse/conformance-mount.test.ts` — a real kernel mount, `node:fs` as the client | 130 passed, 10 skipped (2 targets: memory driver, through a FUSE mount; node-fs driver, through a FUSE mount) |
13+
| **9P** | `test/9p/conformance.test.ts` — 9P2000.L through the codecs, the JS client from `test/9p/client.ts` | 202 passed, 8 skipped (3 targets: memory driver, over 9P; memory driver with no handles, over 9P; node-fs oracle, over 9P) |
14+
| **NFSv3** | `test/nfs/v3/conformance.test.ts` — NFSv3 over a TCP socket, the JS client from `test/nfs/v3/client.ts` | 124 passed, 16 skipped (2 targets: memory driver, over NFS; node-fs driver, over NFS) |
15+
| **NFSv4.1** | `test/nfs/v4/conformance.test.ts` — NFSv4.1 over a TCP socket, the JS client from `test/nfs/v4/client.ts` and the driver over it in `test/nfs/v4/driver.ts` | 124 passed, 16 skipped (2 targets: memory driver, over NFSv4.1; node-fs driver, over NFSv4.1) |
16+
| **S3** | `test/s3/conformance.test.ts` — an S3 gateway in process, the JS client from `test/s3/client.ts` | 48 passed, 22 skipped (1 target: memory driver, over S3) |
1717

1818
## Capability loss
1919

20-
Derived from the run, not declared here: a requirement counts as unmet in a column when no case that names it passed there. `root` is an environment fact rather than a transport one — it gates the one case that hands a file away, which only root may do — so it is reported in its own column, and _recorded_ there rather than derived: a column can skip that case for want of `symlinks` long before privilege is reached, so the absence of a pass is not evidence about the run's uid. Every column is run with root when root is reachable, including the five that do not need it. A `mountx.*` requirement is left out of this table entirely: the suite calls an extension by name through `fs.mountx`, which only the loopback column has, so a skip everywhere else is a fact about how the case is written and not about what the transport carries (all four sessions do carry `mknod` — see the per-case rows below, and the FUSE column's own `mkfifo`/`mknod`/`bind` case over a real mount).
20+
Derived from the run, not declared here: a requirement counts as unmet in a column when no case that names it passed there. `root` is an environment fact rather than a transport one — it gates the one case that hands a file away, which only root may do — so it is reported in its own column, and _recorded_ there rather than derived: a column can skip that case for want of `symlinks` long before privilege is reached, so the absence of a pass is not evidence about the run's uid. Every column is run with root when root is reachable, including the five that do not need it. A `mountx.*` requirement is left out of this table entirely: the suite calls an extension by name through `fs.mountx`, so a skip is a fact about whether that column's client offers the name, not about what the transport carries. Two columns do offer it — the loopback one directly, and the 9P one because `Tmknod` carries the whole `mode` and `p9Driver` can hand it over unchanged. The rest skip for reasons of their own: FUSE drives a real mount with `node:fs` as the client, and `node:fs` cannot `mknod(2)`; NFSv3 and NFSv4.1 carry the file type in `ftype3`/`nfs_ftype4` rather than in the mode, so a client there could not offer the whole extension without deciding part of it itself; S3 has no way to name a FIFO at all. All four sessions do carry `mknod` — see the per-case rows below, and the FUSE column's own `mkfifo`/`mknod`/`bind` case over a real mount.
2121

2222
**What this direction of derivation cannot check.** A column declares its own capabilities (`THROUGH_FUSE`, `THROUGH_9P`/`THROUGH_9P_REOPENED`, `THROUGH_NFS`, `THROUGH_NFS4`, `THROUGH_S3`), and declaring one `false` skips every case that needs it — which is exactly what a real loss looks like from here. So a capability the transport _does_ carry, wrongly declared lost, is reported as a loss with nothing to contradict it; the evidence only ever runs the other way, from a passing case to a capability that must be present. Every entry below is therefore a claim the transport's own test file makes and the run did not refute, and the comment at each declaration is where the reasoning for it lives.
2323

@@ -131,13 +131,13 @@ Derived from the run, not declared here: a requirement counts as unmet in a colu
131131

132132
### special files
133133

134-
| Case | Needs | loopback | FUSE | 9P | NFSv3 | NFSv4.1 | S3 |
135-
| -------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------ | ------------------ | ------------------ | ------------------ | ------------------ | ------------------ |
136-
| creates a FIFO and a socket that stat and readdir both name | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
137-
| carries the device number of a character and a block device | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
138-
| creates a regular file from a mode naming one, or naming no type | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
139-
| is an ordinary name once it exists: rename, unlink, stat again | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
140-
| refuses an existing name, a missing directory and a type with its own call | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
134+
| Case | Needs | loopback | FUSE | 9P | NFSv3 | NFSv4.1 | S3 |
135+
| -------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------ | ------------------ | ------------------ |
136+
| creates a FIFO and a socket that stat and readdir both name | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
137+
| carries the device number of a character and a block device | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
138+
| creates a regular file from a mode naming one, or naming no type | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
139+
| is an ordinary name once it exists: rename, unlink, stat again | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
140+
| refuses an existing name, a missing directory and a type with its own call | `mountx.mknod` | pass (memory), skip (node-fs), skip (unstorage), skip (node:fs/promises (raw)) | skip: mountx.mknod | pass (memory driver, over 9P), pass (memory driver with no handles, over 9P), skip (node-fs oracle, over 9P) | skip: mountx.mknod | skip: mountx.mknod | skip: mountx.mknod |
141141

142142
### metadata
143143

‎.agents/testing.md‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,16 @@ through its Tier-1 JS client, and FUSE contributes a real-mount column.
5252
passed a case naming it, not every one — the drivers sharing a column need not have
5353
the same capabilities now that `unstorage` runs beside `memory`. A `mountx.*`
5454
requirement is dropped from the "capabilities lost" table (not from the per-case
55-
rows): the suite reaches an extension by name through `fs.mountx`, which only the
56-
loopback column has, so a skip in a transport column is about how the case is
57-
written and not about what the wire carries — all four sessions carry `mknod`.
55+
rows): the suite reaches an extension by name through `fs.mountx`, and whether a
56+
column's client offers that name is a fact about the client. Two do — the loopback
57+
column directly, and the 9P one because `Tmknod` carries the whole `mode` and
58+
`p9Driver.mountx.mknod` hands it over unchanged, so the special-files cases run
59+
there against the memory targets. The rest skip for their own reasons: FUSE's
60+
client is `node:fs`, which cannot `mknod(2)` (its column covers special files by
61+
its own case and by pjdfstest instead); NFSv3 and NFSv4.1 put the file type in
62+
`ftype3`/`nfs_ftype4` rather than in the mode, so a client there cannot offer the
63+
whole extension without deciding part of it itself; S3 cannot name a FIFO. All
64+
four sessions carry `mknod` either way.
5865

5966
## Per area
6067

‎test/9p/client.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1103,9 +1103,35 @@ export function p9Driver(client: P9Client, root: number): FsDriver {
11031103

11041104
return {
11051105
// A fid is server-side open state that outlives the name it was opened
1106-
// from, and `Trenameat` is one server operation.
1106+
// from, and `Trenameat` is one server operation. `extensions` is inferred
1107+
// from the keys of `mountx` below, so it is not here.
11071108
capabilities: { handles: true, atomicRename: true },
11081109

1110+
mountx: {
1111+
/**
1112+
* `Tmknod`, which is the one place this adapter offers a `mountx.*`
1113+
* member by name.
1114+
*
1115+
* It is not the extension crossing the wire — it is the wire operation
1116+
* that already exists wearing the name the driver interface has for it.
1117+
* 9P2000.L carries the whole `mode`, type bits included, and
1118+
* `P9Session.#mknod` hands it to `mountx.mknod` unchanged, so every
1119+
* answer the case sees is the far side's: the memory driver's own
1120+
* `EEXIST`, `ENOENT` and `EPERM`-for-a-directory, arriving as `Rlerror`.
1121+
* Nothing is decided here.
1122+
*
1123+
* `dev` comes apart the way `P9Session` puts it back together — one
1124+
* 8-bit split across the project — which is what makes the round trip
1125+
* through `major`/`minor` and back out of `Rgetattr`'s single `rdev[8]`
1126+
* worth testing at all.
1127+
*/
1128+
async mknod(path, mode, dev) {
1129+
await withParent(path, "mknod", (fid, name) =>
1130+
client.mknod(fid, name, { mode, major: dev >>> 8, minor: dev & 0xff }),
1131+
);
1132+
},
1133+
},
1134+
11091135
async stat(path) {
11101136
return withFid(path, true, async (fid) => statsOf(await client.getattr(fid)));
11111137
},

‎test/9p/conformance.test.ts‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,22 @@ import { P9Client, p9Driver } from "./client.ts";
4343
* closes it — so a file unlinked while open stays readable through the fid
4444
* that holds it, with no silly-rename anywhere. This is the capability the
4545
* plan's `["fuse", "9p", "nfs"]` preference order is *about*.
46-
* - **`extensions: []`**, as in both other transports: the `mountx.*` namespace
47-
* is a driver-to-session channel with no wire representation. `mountx.mknod`
48-
* and `mountx.utimens` are reachable from `Tmknod` and `Tsetattr`, but a
49-
* client cannot ask for them by name and the suite's extension cases are not
50-
* about what a transport happens to route.
46+
* - **`extensions: ["mknod"]`**, which the other transport columns do not
47+
* claim. 9P2000.L has an operation for it and carries the whole `mode`, type
48+
* bits included: `p9Driver`'s `mountx.mknod` is a `Tmknod`,
49+
* `P9Session.#mknod` passes the mode to the driver's extension unchanged, and
50+
* every refusal the cases assert — `EEXIST`, `ENOENT`, `EPERM` for a
51+
* directory — is the far side's answer arriving as `Rlerror`. That is the
52+
* capability *carried*, not declared over: a client offering a by-name handle
53+
* onto a wire operation is what a driver adapter is. `utimens` stays off the
54+
* list: `Tsetattr` carries the nanoseconds, but this client spends them
55+
* through `utimes`/`lutimes` and never asks for the extension by name.
56+
*
57+
* What the extension cases buy over `session.test.ts`'s dispatch tests is the
58+
* rest of the surface: `stat` and `readdir` agreeing on the seven type
59+
* predicates off the wire's own encoding, `rdev` surviving the `major`/`minor`
60+
* split and `Rgetattr`'s single `rdev[8]` rejoining it, and a FIFO behaving
61+
* as an ordinary name under `rename` and `unlink` afterwards.
5162
*
5263
* Everything else — hardlinks, symlinks, permissions, times, truncate, atomic
5364
* rename, `statfs` — crosses intact, and each of those is a message of its own
@@ -71,7 +82,7 @@ const THROUGH_9P: ResolvedCapabilities = {
7182
caseSensitive: true,
7283
statfs: true,
7384
readOnly: false,
74-
extensions: [],
85+
extensions: ["mknod"],
7586
};
7687

7788
/**
@@ -137,7 +148,15 @@ describe("over a 9P2000.L session", () => {
137148

138149
conformance({
139150
name: "node-fs oracle, over 9P",
140-
capabilities: THROUGH_9P,
151+
/*
152+
* `THROUGH_9P`, minus the one entry that is the *driver's* to answer rather
153+
* than the transport's: the oracle implements no `mountx.mknod`, so
154+
* `#mknod` answers `ENOSYS` for every type but a regular file, exactly as
155+
* it should. The column carries the extension; this target has none to
156+
* carry, and declaring one here is the difference between a capability and
157+
* a claim.
158+
*/
159+
capabilities: { ...THROUGH_9P, extensions: [] },
141160
/*
142161
* The oracle forwards the host kernel's errors, so the *code* is pinned and
143162
* the *number* is allowed to be either family's.

0 commit comments

Comments
 (0)