
# 9P2000.L

**Stateful, root-only, and what a VM host reaches for.**

`mountx/9p` implements 9P2000.L — the Linux kernel's own v9fs dialect — over a stream: a unix socket for a local mount, TCP for anything else. Every message is encoded _and_ decoded, so the whole protocol is testable by a JavaScript client built from the same codecs, exactly like the other two transports.

```ts
import { mount9p } from "mountx/9p";
import { createMemoryDriver } from "mountx/drivers/memory";

await using mounted = await mount9p(createMemoryDriver(), "/mnt/point");
```

Through `mountx/auto` this is what a Linux host gets when FUSE cannot mount but root is available, and its options move under `"9p": {…}`.

## Where it mounts

| host      | root?   | notes                                                                             |
| --------- | ------- | --------------------------------------------------------------------------------- |
| **Linux** | **yes** | `mount(2)` needs `CAP_SYS_ADMIN`; no setuid helper the way FUSE has `fusermount3` |
| macOS     | —       | no v9fs client on any BSD kernel — 9P never appears there                         |
| Windows   | —       | no 9P client, no `mount(8)`                                                       |

**Linux only, unconditionally.** v9fs is a Linux filesystem — no other kernel implements a 9P client — so unlike NFS there is no second platform to fall back to, and unlike FUSE there is no rootless path: `mount(2)` needs `CAP_SYS_ADMIN` and nothing here plays `fusermount3`'s role.

### `p9ClientProbe(platform?)`

```ts
function p9ClientProbe(platform?: NodeJS.Platform): P9ClientProbe;

interface P9ClientProbe {
  usable: boolean;
  platform: "linux" | undefined;
  kernel: boolean; // is `9p` in /proc/filesystems?
  transport: boolean; // is `9pnet_fd` in /sys/module?
  modules: boolean; // is there a module tree for this kernel at all?
  root: boolean;
  reason: string | undefined;
}
```

Synchronous and cheap — it reads `node:fs` and nothing else, for the same reason `nfsClientProbe()` does: `mountx/auto` asks it before deciding what to load, and a mount can fail with a message naming the missing piece instead of `wrong fs type, bad option, bad superblock`.

::note
`transport` (`9pnet_fd`, the module registering `trans=unix`/`tcp`/`fd`) is reported but does **not** decide `usable`. Its absence is ambiguous — a kernel with the code built in looks the same in `/sys/module` as one that will never have it — and `mount(8)`'s own `modprobe` may resolve it as root. `mountx/auto` resolves that doubt the other way for its own no-fallback picker — see [below](/transports/auto#the-9p-module-refusal).
::

## What it costs, and what it buys

9P is **stateful**, the opposite trade NFSv3 makes. The client names a path once, gets a _fid_ for it, and reuses that fid until it clunks it — so the server's state is a fid table that lives exactly as long as the connection, and:

- **A file deleted while open stays readable and writable.** No `ESTALE` — unlinking through the mount, then reading, writing, `fstat`ing and `fsync`ing through the same handle all keep working.
- **`close()` and `fsync()` reach the driver for real**, on `Tclunk` and `Tfsync` — what a `handles: true` driver that buffers writes needs, and the reason 9P beats NFS whenever both need root anyway: a driver holding its own bytes across a session gets a real close, and NFSv3 has no request that means one.
- **A tighter bound on server-side state.** Both servers keep a table, and neither has a `FORGET`. NFS drops a handle entry once its last path is gone — ids are minted from a counter that never repeats, so a client still holding the handle gets `NFS3ERR_STALE`/`NFS4ERR_STALE` from the lookup instead — which leaves one entry per path a client currently has a _name_ for, however long ago it looked. 9P's fid table is bounded by what the client currently has _open_, and a clunked fid is released on the spot.

What it costs in exchange:

- **Root, unconditionally** — see above.
- **Two clients are two fid tables and two sessions, not one.** `createP9Server` accepts more than one connection, but locking is grant-all and a rename is only serialized per connection — see [Serving more than one client](#serving-more-than-one-client).

## `mount9p(driver, mountpoint, options?)`

```ts
function mount9p(driver: FsDriver, mountpoint: string, options?: MountP9Options): Promise<P9Mount>;
```

Serves `driver` over 9P2000.L and puts the kernel's v9fs client in front of it with `mount(8)`. **Linux, and root.** Options sit at the **top level** here — it is `mountx/auto` that nests them under `"9p": {…}`.

Resolves once `mount(8)` has returned successfully, which for 9P means the kernel already completed a `Tversion` and a `Tattach` — a resolved `mount9p()` means the path is usable, not merely that a child exited zero.

```ts
interface P9Mount extends AsyncDisposable {
  readonly mountpoint: string;
  readonly server: P9Server;
  readonly source: string; // the socket path, or the TCP address
  readonly trans: "unix" | "tcp";
  readonly connection: P9Connection; // the kernel's, and the only one this mount cares about
  readonly active: boolean;
  readonly closed: Promise<void>; // resolves once the connection and the server are both gone
  unmount(): Promise<void>;
}
```

`unmount()` is idempotent, concurrency-safe and retryable after a failure. It always settles: within `unmountTimeout` when asking nicely works, and within twice it when the mount has to be forced down instead — `unmountTimeout` bounds each of the two phases rather than their sum, and every step inside a phase is given only what is left of that phase's budget — both the `umount(8)` calls and shutting the server down, which ends in closing every fid the session still holds through the driver that may be the thing that stopped answering.

### `trans=unix`, the local mount

The default, and the reason this path needed no native addon. `mount9p()` starts a unix-socket server in a `mkdtemp` `0700` directory holding a `0600` socket, and mounts with that socket's path as the mount's _source_ argument:

```sh
mount -i -t 9p -o trans=unix,version=9p2000.L,msize=131096,access=client,cache=none,uname=nobody,aname=/ \
  -- /tmp/mountx-9p-XXXXXX/9p.sock /mnt/point
```

`p9_fd_create_unix()` (the kernel's `net/9p/trans_fd.c`) connects to that path itself — this is the shape the kernel's own `Documentation/filesystems/9p.rst` mounts Plan 9 From User Space with — so the whole handoff is a listener this process already knows how to make and a path in argv, with no socketpair and no descriptor to pass. The directory's mode is what keeps the socket private: 9P authenticates nothing, so who can reach the socket is the entire security boundary.

::note
This is why the local mount needed no native addon, unlike unprivileged FUSE. `trans=fd` — a socketpair, with fds resolved in the mounting process's own table — was the original design and stays **deferred**: it would still need a true socketpair, which Node cannot create without native code, and it buys nothing over `trans=unix` here, since `net/9p/trans_fd.c` registers `tcp`, `unix` and `fd` off the one file, same `maxsize`, same machinery. It stays on the table for a relay mode that already holds a descriptor to hand over.
::

::caution
**The socket path has a real limit: it must be at most 107 bytes** (refused at 108, which is `UNIX_PATH_MAX` — the size of the field including its NUL terminator). `p9_fd_create_unix()` refuses anything that does not fit with `ENAMETOOLONG` before it ever connects, and a long `TMPDIR` can push the `mkdtemp` path over it — a real failure mode, not a hypothetical one. `socketPathRefusal(path)` checks it before anything is spawned, the same role `tcpSourceRefusal()` plays for `trans=tcp` below; set a shorter `TMPDIR`, or pass a `server` already listening on a shorter path of your own.
::

### `trans=tcp`, for a VM guest

Pass `port`/`host` (or a `server` of your own already listening on a port) and the mount is told `trans=tcp,port=N` with the address as its source — the shape a VM guest mounting its host over virtio-net or a host-only network uses.

::caution
**Dotted-quad IPv4 only.** `p9_fd_create_tcp()` validates the source with `valid_ipaddr4()` — a `sscanf("%d.%d.%d.%d")` and nothing else. No hostnames (there is no resolver in the kernel to call) and no IPv6: `::1` is a perfectly good loopback address to the _server_ and a mount failure as the _source_ — use `127.0.0.1`. `tcpSourceRefusal(host)` catches this before anything is spawned.
::

On loopback, `trans=tcp` is strictly worse than the unix socket — a port is reachable by every user on the machine — so it is never the default; it exists for the case a socket path cannot reach the client at all.

::note
**Mounting into a QEMU guest is the case this exists for**, and it needs no host networking setup at all: QEMU's default user-mode networking already routes `10.0.2.2` to the host's loopback, so the server stays bound to `127.0.0.1`. See [Virtual machines](/guide/vms#qemu) for the three commands end to end.
::

### `MountP9Options`

Extends [`P9ServerOptions`](#p9serveroptions) (and through it [`P9SessionOptions`](#p9session)), so everything on this page is settable in one object.

| option             | default                           |                                                                                                                                                                                                                                         |
| ------------------ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `server`           | —                                 | serve an existing `P9Server`; its `listen()` is still called — see the caveats on the type                                                                                                                                              |
| `readOnly`         | `false`                           | mount `-o ro` **and** the session's own `readOnly`                                                                                                                                                                                      |
| `mountMsize`       | `131096` (128 KiB + `P9_IOHDRSZ`) | the `msize` this mount asks for, clamped to `[P9_MIN_MSIZE, P9_MAX_MOUNT_MSIZE]` (1 MiB)                                                                                                                                                |
| `access`           | `"client"`                        | v9fs's own default for `.L` — the _kernel_ checks mode bits against `Rgetattr`, the same posture as FUSE's `default_permissions`                                                                                                        |
| `cache`            | `"none"`                          | see [below](#cachenone)                                                                                                                                                                                                                 |
| `uname`            | `"nobody"`                        | the `Tattach` identity a client asserts — decorative; 9P authenticates nothing                                                                                                                                                          |
| `aname`            | `"/"`                             | the tree to attach to; `""` (the kernel's own `V9FS_DEFANAME`) means the same root, and any other name attaches at **that subtree** when it exists and is a directory (`ENOENT` if it is not there, `ENOTDIR` if it is not a directory) |
| `mountOptions`     | none                              | extra `-o` options, appended verbatim, last (so they win)                                                                                                                                                                               |
| `signals`          | `true`                            | unmount on `SIGINT`/`SIGTERM`                                                                                                                                                                                                           |
| `unmountTimeout`   | `10_000`                          | ms each teardown phase may spend; `0`/`Infinity` waits forever                                                                                                                                                                          |
| `onTransportError` | none                              | `(error, peer)` for transport-level failures and forced teardown                                                                                                                                                                        |

::note
**`msize` is invisible in the mount table at the default, and that absence is proof it landed.** `p9_show_client_options()` prints `msize=` only when it differs from the kernel's own `DEFAULT_MSIZE` — which is exactly what this option defaults to. Ask for something else (`mountMsize: 16384`) and `msize=16384` shows up in `/proc/self/mounts`.
::

### `cache=none`

::caution
**Why it stays the default.** 9P has no invalidation channel at all — nothing like FUSE's `notify_inval_inode` — so every cache mode above `none` (`readahead`, `mmap`, `loose`, `fscache`) is a bet that nothing but this mount changes the driver. For a JavaScript filesystem, whose whole point is often that the serving process is also the one changing it, that bet is usually wrong, and a stale read is worse than a slow one. One consequence worth knowing: `cache=none` still supports read-only `mmap`, so executing a binary copied onto the mount works — a shared writable mapping does not.
::

### `p9MountOptions(target, options?)`

```ts
function p9MountOptions(target: P9MountTarget, options?: MountP9Options): string;

interface P9MountTarget {
  trans: "unix" | "tcp";
  port?: number; // for `trans=tcp`; ignored, and omitted, otherwise
}
```

The exact `-o` string `mount9p()` would pass. Pure, so you can see it, log it, or hand it to `mount(8)` yourself.

### `live9pMounts()` / `unmountAll9p()`

Every 9P mount this process has up, in creation order; and unmount them all, never rejecting.

## Serving more than one client

`createP9Server` accepts more than one connection at once, and each gets its own session — its own fid table, its own negotiated `msize`, its own `PathLock`. Two things worth knowing before serving beyond a single local mount:

- **Locks are grant-all, and that is only honest for one client.** `Tlock` always answers success and `Tgetlock` always reports unlocked — correct for the local mount this transport exists for, where the client kernel does its own POSIX-lock bookkeeping and there is exactly one of it. Attach a second client and the grant is still unconditional: byte-range locking between two 9P clients is not implemented.
- **A rename is serialized per connection, not across them.** Each session's `PathLock` keeps that connection's own traffic consistent; two clients renaming the same subtree concurrently race in the driver, the same as two local processes racing a rename on a real filesystem would.

One more behaviour worth knowing regardless of client count: **a `Tlcreate` race is refused, not rolled back.** Two `Tlcreate`s (or a `Tlcreate` racing a `Tlopen`) on the _same fid_ race exactly the way two `Tlopen`s do — the loser gets `EBUSY`, not the ordinary double-open `EINVAL`, because the fid really was claimed by another request in flight rather than by a client that forgot it already had one open. The loser's own `driver.open()` call already ran by the time the race is caught, and that create is not undone. If the two requests named the _same_ child, nothing is orphaned: both hit the same path, and the winner's fid ends up naming exactly what the loser also created. If they named _different_ children, the loser's file is real and has no fid pointing at it — rolling it back would mean guessing whether some other request has opened it since, so it is left alone.

## Serving without mounting

Serving needs no privileges and no `mount(8)`:

```ts
import { createP9Server } from "mountx/9p";
import { createMemoryDriver } from "mountx/drivers/memory";

await using server = await createP9Server(createMemoryDriver()).listen();
// sudo mount -t 9p -o trans=tcp,port=<p>,version=9p2000.L 127.0.0.1 /mnt
```

```ts
interface P9Server extends AsyncDisposable {
  readonly host: string;
  readonly port: number; // 0 before listen(); ephemeral unless you set one
  readonly path: string | undefined; // set for a unix-socket server
  readonly options: P9ServerOptions; // what it was constructed with, as given
  readonly clients: readonly P9Connection[];
  readonly connections: number;
  address(): net.AddressInfo | string | null; // `net.Server.address()`, verbatim
  listen(): Promise<P9Server>; // idempotent
  attach(stream: Duplex, options?: { peer?: string; own?: boolean }): P9Connection;
  close(): Promise<void>; // idempotent; does not wait for clients to leave politely
}
```

`attach()` is for an already-connected stream you hold yourself — a socketpair end, a pipe, a test double — and is what `mount9p()`'s own listener is built on underneath.

::caution
**`close()` is a reset, not a flush.** A mounted 9P client keeps its connection open for as long as the mount exists, so a close that waited politely would never return — every accepted socket is destroyed outright instead. A peer can see `ECONNRESET` with replies still queued.
::

### `P9ServerOptions`

| option                 | default                        |                                                                                             |
| ---------------------- | ------------------------------ | ------------------------------------------------------------------------------------------- |
| `port`                 | `0`                            | `0` means an ephemeral port, which `port` then reports                                      |
| `host`                 | `"127.0.0.1"`                  | address to bind                                                                             |
| `path`                 | —                              | listen on a unix socket instead of TCP; its directory must be `0700`, owned by this process |
| `allowRemote`          | `false`                        | accept TCP connections from non-loopback addresses                                          |
| `socketMode`           | `0o600`                        | mode applied to a unix socket once bound                                                    |
| `allowSharedDirectory` | `false`                        | skip the `0700` directory check                                                             |
| `maxFrame`             | 1 MiB (`P9_DEFAULT_MAX_FRAME`) | largest frame accepted **before** `Tversion` negotiates a real `msize`                      |
| `maxInFlight`          | `16` (`DEFAULT_MAX_IN_FLIGHT`) | requests dispatched at once per connection before the rest wait                             |
| `onTransportError`     | none                           | `(error, peer)` — a framing error, a socket error                                           |

::note
**`maxInFlight` bounds memory, not the protocol.** One TCP delivery can carry thousands of pipelined requests — 2,800 `Tread`s fit in 64 KiB — and dispatching all of them at once would hold up to `msize` bytes of reply per request in memory simultaneously. With the window, replies alive at once are bounded by `maxInFlight × msize`; frames parsed but waiting their turn cost only their wire size. `Tflush` still finds its `oldtag` either in flight or already answered regardless of the window's size — nothing about ordering depends on it. Worth setting deliberately if you tune this transport; there is no benchmark column for it yet.
::

## `P9Session`

```ts
new P9Session(driver: FsDriver, options?: P9SessionOptions)
```

Bytes in, bytes out, no socket anywhere: `handleCall(bytes)` → `Promise<Uint8Array | null>`. Never rejects — every message needing a reply gets exactly one, and a thrown driver error becomes `Rlerror` with a positive Linux errno straight from `src/errors.ts` — the one transport here with no status-mapping layer at all.

```ts
session.driver; // the Loopback wrapping your driver
session.fids; // the FidTable
session.stats; // { requests, replies, errors, dropped, flushed, assertions, messages }
```

### `P9SessionOptions`

| option           | default                 |                                                                                                                                |
| ---------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `useDriverIno`   | `true`                  | identify files by the driver's `(dev, ino)` — changes which files **share** a `qid.path`, not what goes on the wire; see below |
| `msize`          | `DEFAULT_MSIZE` (1 MiB) | ceiling on the negotiated `msize`; the agreed value is `min(client's proposal, this)`                                          |
| `readOnly`       | `false`                 | refuse every mutating request with `EROFS`                                                                                     |
| `claimOwnership` | `true`                  | `chown` a newly created entry to the attaching user                                                                            |
| `debug`          | on outside production   | run the reply-exactly-once assertions                                                                                          |
| `onAssertion`    | collect                 | called when a dev-mode assertion fails                                                                                         |
| `onError`        | none                    | called for every `Rlerror` and every dropped frame                                                                             |

::note
**`Rgetattr` has no `st_ino` field.** v9fs derives the inode number userspace sees from `qid.path` (`v9fs_qid2ino()`), so a driver's own `ino` has nowhere to go on this wire — unlike FUSE and NFS, where `useDriverIno` changes a field that actually reaches the client. Here it only changes which files `FidTable` allocates the _same_ `qid.path` for: on, two hardlinks are one file to the client; off, they are two.
::

## Teardown

The same discipline as [FUSE](/transports/fuse#teardown-and-what-it-cant-do) and [NFS](/transports/nfs#teardown): a plain `umount(8)` first, then `umount -f` and then `umount -l` once the deadline passes, the mount table as the truth rather than any of their exit statuses, every spawned child bounded by that same deadline and abandoned if it outlives it, idempotent and retryable throughout. `umount -f` is not a placebo here — v9fs implements `.umount_begin` (`v9fs_umount_begin()`), which cancels requests in flight, the same role `fuse_abort_conn` plays for FUSE.

One connection per mount, and its end is the unmount: 9P has no `Tdestroy` and no analogue of `FUSE_DESTROY`, so `P9Connection.closed` (session destroyed _and_ the stream closed) is what a mount waits on — for its own `unmount()` and for somebody else's `umount(8)` alike.

A killed server does not wedge the mount the way a killed FUSE daemon can: the first access after fails `ECONNRESET`, every one after that `EIO`, immediately, and a plain `umount` clears it — nothing to force. The one wedge that is real is self-inflicted: see [Troubleshooting](/guide/troubleshooting#dont-spawn-a-binary-that-lives-on-your-own-9p-mount).

## The layers below

Everything except `server.ts` and `mount.ts` runs on any OS with no privileges — which is what makes the protocol testable by a JS client built from the same codecs:

| module         |                                                                                                             |
| -------------- | ----------------------------------------------------------------------------------------------------------- |
| `wire.ts`      | little-endian primitives: bounds-checked reader/writer, `u64` as `bigint`, unpadded length-prefixed strings |
| `constants.ts` | message types and masks, transcribed from the kernel's `include/net/9p/9p.h` (v6.12)                        |
| `protocol.ts`  | every 9P2000.L message both directions, frame assembly, dirent packing                                      |
| `fids.ts`      | the fid table: paths, open state, readdir cursors, qid identity                                             |
| `session.ts`   | `P9Session(driver, options)` — bytes in, bytes out, no socket                                               |
| `probe.ts`     | `p9ClientProbe()` — `node:fs` and nothing else, so `mountx/auto` reaches it without the codec               |
| `server.ts`    | the socket. `mount.ts` — `mount(8)`                                                                         |

`mountx/9p` re-exports the whole protocol layer by name:

- **`wire.ts`** — `P9Reader`/`P9Writer`: little-endian, unaligned, bounds-checked, and copy the bytes they retain.
- **`constants.ts`** — every `P9_T*`/`P9_R*` message type, the `GETATTR`/`SETATTR` masks, lock and qid-type constants.
- **`protocol.ts`** — every message encoded _and_ decoded, `P9FrameAssembler`, `P9DirentPacker`, `messageName()`.
- **`fids.ts`** — `FidTable` and the readdir cursor scheme: the state a stateful protocol needs.

## Not available

- **Legacy 9P2000 opcodes.** `Topen`/`Tcreate`/`Tstat`/`Twstat` answer `Rlerror ENOTSUP` — this server speaks `.L` only, and no real v9fs client sends them (witnessed: none appeared across a full local workload).
- **`Tauth`.** Decoded so the session can refuse it politely, but nothing here is authenticated — the socket (or, for `trans=tcp`, the network path to it) is the entire security boundary.
- **xattr, beyond the probe.** `Txattrwalk`/`Txattrcreate` answer `ENOTSUP` — kept cheap deliberately, since `Txattrwalk` is one per file a real client creates (v9fs probes `security.*` on every `Tlcreate`).

## Next

- [FUSE](/transports/fuse) — the transport that beats this one whenever it can mount.
- [NFSv3](/transports/nfs) — the other root-needing transport, and the stateless trade this one avoids.
- [`mountx/auto` reference](/transports/auto) — the chooser that calls this.
