9P2000.L
9P is a stateful, root-only transport. Use it to serve a virtual machine (VM) guest.
mountx/9p implements 9P2000.L, the Linux kernel v9fs dialect. It uses a stream: a unix socket for a local mount, TCP for anything else. mountx encodes and decodes every message. A JavaScript client built from the same codecs can test the complete protocol.
import { mount9p } from "mountx/9p";
import { createMemoryDriver } from "mountx/drivers/memory";
await using mounted = await mount9p(createMemoryDriver(), "/mnt/point");mountx/auto selects 9P on Linux when FUSE cannot mount and 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) |
9P mounting supports Linux only. v9fs is a Linux filesystem, and no other kernel implements a 9P client. Therefore, 9P has no second platform like NFS. It also has no rootless path like FUSE. mount(2) needs CAP_SYS_ADMIN, and 9P has no helper such as fusermount3.
#p9ClientProbe(platform?)
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;
}This function is synchronous and small. It reads only node:fs, as nfsClientProbe() does. Therefore, mountx/auto can call it before selecting code to load. Its result lets a mount name the missing requirement instead of returning 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. In /sys/module, a kernel with built-in code looks like a kernel that cannot provide the module. Also, mount(8) can run modprobe as root. Because mountx/auto cannot fall back, it treats this uncertainty as a refusal. See The 9P module refusal.
#What it costs, and what it buys
9P is stateful. NFSv3 is stateless. The client names a path once and receives a fid. It reuses that fid until it clunks it. The server keeps the fid table for the lifetime of the connection. This design has the following effects:
- A file deleted while open stays readable and writable. It does not return
ESTALE. After unlink, the same handle still supports read, write,fstat, andfsync. close()andfsync()reach the driver, throughTclunkandTfsync. Ahandles: truedriver that buffers writes needs these calls. This is why mountx prefers 9P over NFS when both need root. A 9P driver that holds bytes across a session receives a real close. NFSv3 has no equivalent request.- A tighter bound on server-side state. Both servers keep a table, and neither has a
FORGET. NFS drops a handle entry after its last path disappears. Handle IDs come from a counter that does not repeat. Therefore, a client that still holds the handle receivesNFS3ERR_STALEorNFS4ERR_STALE. The table keeps one entry for each path that a client can still name, regardless of lookup age. 9P's fid table is bounded by what the client currently has open, and a clunked fid is released on the spot.
This design has these costs:
- Root, unconditionally: see above.
- Two clients are two fid tables and two sessions, not one.
createP9Serveraccepts multiple connections and shares byte-range locks across them. However, it serializes rename only within each connection. See Serving more than one client.
#mount9p(driver, mountpoint, options?)
function mount9p(driver: FsDriver, mountpoint: string, options?: MountP9Options): Promise<P9Mount>;This function serves driver over 9P2000.L. It uses mount(8) to attach the kernel v9fs client. Linux, and root. Options are at the top level here. mountx/auto nests them under "9p": {…}.
It resolves after mount(8) succeeds. At that point, the kernel has completed Tversion and Tattach. Therefore, a resolved mount9p() means that the path is usable, not only that a child returned zero.
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.
The promise always settles. A normal unmount settles within unmountTimeout. A forced unmount settles within twice that time because the timeout applies separately to each phase.
Each step receives only the time that remains in its phase. This includes umount(8) calls and server shutdown. Shutdown closes every fid that the session still holds. These closes can call the same driver that stopped responding.
#trans=unix, the local mount
This is the default. It does not need a 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:
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/pointThe kernel function p9_fd_create_unix() in net/9p/trans_fd.c connects to that path. The kernel documentation in Documentation/filesystems/9p.rst uses the same form for Plan 9 From User Space.
The handoff needs only a listener that Node can create and a path in the argument list. It does not need a socket pair or descriptor passing.
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. The original design used trans=fd and a socket pair. File descriptors would resolve in the mounting process table. This design remains deferred because Node cannot create a true socket pair without native code.
It also provides no benefit over trans=unix here. The kernel file net/9p/trans_fd.c registers tcp, unix, and fd with the same maxsize and machinery.
It stays on the table for a relay mode that already holds a descriptor to hand over.
Caution
The socket path must be at most 107 bytes. A length of 108 bytes is refused. UNIX_PATH_MAX is the field size, including its NUL terminator. Before connecting, p9_fd_create_unix() returns ENAMETOOLONG for a path that does not fit. A long TMPDIR can make the mkdtemp path exceed this limit. socketPathRefusal(path) checks the limit before mountx starts a child process. tcpSourceRefusal() performs the corresponding check for trans=tcp. If the path is too long, set a shorter TMPDIR. You can also pass a server that already listens on a shorter path.
#trans=tcp, for a VM guest
Pass port and host, or pass a server that already listens on a port. The mount then receives trans=tcp,port=N and uses the address as its source. A VM guest uses this form to mount its host over virtio-net or a host-only network.
Caution
Dotted-quad IPv4 only. p9_fd_create_tcp() validates the source with valid_ipaddr4(). It accepts only the sscanf("%d.%d.%d.%d") form.
Do not use hostnames because the kernel has no resolver for this source. Do not use Internet Protocol version 6 (IPv6). The server can bind the ::1 loopback address, but the mount cannot use it as the source. Use 127.0.0.1. tcpSourceRefusal(host) catches this before anything is spawned.
On loopback, a Unix socket is safer than trans=tcp because every user on the host can reach a port. Therefore, TCP is not the default. Use it when a socket path cannot reach the client.
Note
This option primarily supports a Quick Emulator (QEMU) guest. It needs no host network setup. QEMU user-mode networking routes 10.0.2.2 to the host loopback by default. Therefore, the server can remain bound to 127.0.0.1. See Virtual machines for the three commands end to end.
#MountP9Options
Extends P9ServerOptions (and through it P9SessionOptions), 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" | the safe mode, and the most expensive thing about this transport — see below |
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 DEFAULT_MSIZE. This option uses that default.
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 such as FUSE notify_inval_inode. Therefore, every mode above none assumes that only this mount changes the driver. These modes are readahead, mmap, loose, and fscache.
For a JavaScript filesystem, the server process often changes the driver directly. This violates the cache assumption. A stale read is less safe than a slow read.
cache=none still supports read-only mmap. Therefore, a binary copied to the mount can execute. A shared writable mapping does not work.
Cost of the default. cache=none is the largest cost for this transport on a metadata-heavy workload. No page cache is the smaller part of that.
The absence of a directory-entry cache has the larger cost. Each system call must walk every named path component again. Walking and clunking already cause most v9fs traffic.
The durable measurement is a message count rather than a clock.
The same bun install was run over the same driver in each mode. The default sent about 1.5 times as many 9P messages: 237,580 instead of 155,314 with cache=loose. Each of three runs produced the same message count. Therefore, this difference is protocol behavior rather than timing variation.
It lands exactly where a missing dentry cache predicts: Tclunk 56,849 → 15,292, Twalk 75,268 → 49,906, Tgetattr 41,560 → 27,490.
Note
Those counts come from bench/bun-install.ts on one host and rank this option. They do not come from a published benchmark. .agents/benchmarks.md, which is where every performance number on this site comes from, has no cache= column at all.
Read the ratio, and do not infer a wall clock from it.
#cache=loose
Use cache=loose only if all these conditions are true:
- Every change to the driver arrives through this mount while the mount is active. This includes bytes, attributes, and names.
- The process serving the mount does not write to its own driver once it is mounted. This is the one people miss: a driver the server keeps updating has a second writer, and that second writer is you.
- There is only one mount of that server. Two mounts are two caches, and neither can invalidate the other.
A violation causes stale data without a warning.
One write outside the client can cause a silent stale read. The client receives old bytes without an error. There is no timeout to wait for and no invalidation function to call.
FUSE caching has a time limit because attrTimeout expires. You can also revoke it with notifyInvalInode(). 9P caching has neither control. Therefore, none is the default. Select loose only after you understand this risk.
Good uses include a build or install target, a scratch tree, and a tree served read-only to a VM guest. Do not use it when the server process continues to update the driver. JavaScript filesystems often have this shape, so this mode is not the default.
// Only with the three conditions above satisfied.
await mount9p(driver, "/mnt/point", { cache: "loose" });Note
The mount table does not show the mode. The same rule hides msize. v9fs prints cache= only when it differs from the kernel's own default, and prints it as a hex bitmask rather than the name you passed.
Therefore, /proc/self/mounts does not show cache=none, and it shows cache=loose as cache=0xf. On kernel 6.12, the complete mapping is:
readahead→cache=0x1mmap→cache=0x5loose→cache=0xffscache→cache=0x8f
The mount table does not name the option that most affects the 9P message count. When you diagnose a slow 9P mount, check the mounting code instead of the mount table.
#p9MountOptions(target, options?)
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()
live9pMounts() returns active 9P mounts in creation order. unmountAll9p() unmounts all of them and never rejects.
#Serving more than one client
createP9Server accepts multiple concurrent connections, and each gets a separate session with its own fid table, negotiated msize, and PathLock. Before serving multiple clients, note these two rules:
- Byte-range locks are shared across clients. One
createP9Serverkeeps one lock table. ATlockfrom one connection is refused when another connection holds a conflicting range.Tgetlockreports the owner. Closing a connection releases all its locks. The client puts a(client_id, proc_id)owner pair on the wire. The pair identifies a process on a host. Therefore, two mounts from one kernel can have one owner. As in POSIX, a process does not conflict with itself. The server returnsBLOCKEDfor a conflict and does not wait.fs/9pconverts this result toEAGAINforF_SETLK. It polls forF_SETLKW, so the client performs the wait. Files are keyed by path, so a rename moves the ranges with the file and a removal drops them. - A rename is serialized per connection, not across them. Each session has a
PathLockthat keeps its connection consistent. Two clients can race when they rename the same subtree concurrently. This is equivalent to two local processes racing a rename on a real filesystem.
One more behaviour worth knowing regardless of client count: a Tlcreate race is refused, not rolled back. Two Tlcreate requests on the same fid race in the same way as two Tlopen requests. The same rule applies when Tlcreate races Tlopen. The losing request receives EBUSY, not the normal double-open EINVAL. Another request in flight claimed the fid; the client did not reuse an already-open fid.
The loser's own driver.open() call already ran by the time the race is caught, and that create is not undone.
If both requests name the same child, they use the same path. The winner's fid names the object that the losing request also created. No object is orphaned.
If the requests name different children, the losing request creates a real file without a fid. The server does not remove it because another request might have opened it. Removal could delete an object that another request now uses.
#Serving without mounting
Serving does not need privileges or mount(8):
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 /mntinterface 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
}Use attach() with an already-connected stream that you control. Examples include one end of a socket pair, a pipe, and a test double. The mount9p() listener also uses attach() internally.
Caution
close() is a reset, not a flush. A mounted 9P client keeps its connection open for the lifetime of the mount. Therefore, a close that waited for the client would not return. The server destroys each accepted socket 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 contain thousands of pipelined requests. For example, 2,800 Tread requests fit in 64 KiB. Dispatching all requests together could hold up to msize bytes of reply memory for each request.
With the window, replies alive at once are bounded by maxInFlight × msize; frames parsed but waiting their turn cost only their wire size.
Regardless of the window size, Tflush finds its oldtag in flight or already answered. The window does not affect ordering.
Set this option deliberately if you tune this transport. There is no benchmark column for it yet.
#P9Session
new P9Session(driver: FsDriver, options?: P9SessionOptions)P9Session converts bytes to bytes without a socket: handleCall(bytes) → Promise<Uint8Array | null>.
handleCall() never rejects. Every message that needs a reply gets exactly one. A thrown driver error becomes Rlerror with a positive Linux errno from src/errors.ts. This transport has no status-mapping layer.
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 userspace inode number from qid.path through v9fs_qid2ino(). The wire has no field for the driver's ino. FUSE and NFS differ because useDriverIno changes a field that reaches their clients.
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
Teardown follows the same rules as FUSE and NFS. It first runs plain umount(8). After the deadline, it tries umount -f and then umount -l.
The mount table is authoritative; command exit status is not. One deadline bounds every spawned child, and mountx abandons a child that exceeds it. The operation remains idempotent and retryable. umount -f has a real effect here. v9fs implements .umount_begin through v9fs_umount_begin(), which cancels requests in flight. It has the same role as FUSE fuse_abort_conn.
Each mount has one connection, and the connection ends at unmount. 9P has no Tdestroy or equivalent of FUSE_DESTROY. Therefore, a mount waits for P9Connection.closed, which means the session and stream are closed. This applies to its own unmount() and another process's umount(8).
A terminated 9P server does not block the mount like a terminated FUSE daemon. The first later access returns ECONNRESET. Each following access immediately returns EIO. A plain umount removes the mount, so no force is necessary.
The remaining deadlock requires a client in the server process: see Troubleshooting.
#The layers below
Every file except server.ts and mount.ts runs on any operating system without privileges. Therefore, a JavaScript client built from the same codecs can test the protocol:
| 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: everyP9_T*/P9_R*message type, theGETATTR/SETATTRmasks, lock and qid-type constants.protocol.ts: every message encoded and decoded,P9FrameAssembler,P9DirentPacker,messageName().fids.ts:FidTableand the readdir cursor scheme: the state a stateful protocol needs.
#Not available
- Legacy 9P2000 opcodes.
Topen/Tcreate/Tstat/TwstatreturnRlerror ENOTSUP. This server supports only.L, and no v9fs client sent these opcodes during a complete local workload. Tauth. The session decodes this field so it can return a specific refusal. It does not authenticate the field. The socket is the complete security boundary. Fortrans=tcp, the network path to the socket is the boundary.- xattr, beyond the probe.
Txattrwalk/TxattrcreatereturnENOTSUP. The server handles this probe with little work because a real client sends oneTxattrwalkfor each created file (v9fs probessecurity.*on everyTlcreate).
#Next
- FUSE: the transport that beats this one whenever it can mount.
- NFSv3: the other root-needing transport, and the stateless trade this one avoids.
mountx/autoreference: the chooser that calls this.