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.
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?)
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.
#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,fstating andfsyncing through the same handle all keep working. close()andfsync()reach the driver for real, onTclunkandTfsync— what ahandles: truedriver 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 getsNFS3ERR_STALE/NFS4ERR_STALEfrom 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.
createP9Serveraccepts more than one connection, but locking is grant-all and a rename is only serialized per connection — see Serving more than one client.
#mount9p(driver, mountpoint, options?)
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.
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:
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/pointp9_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 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" | 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'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?)
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.
Tlockalways answers success andTgetlockalways 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
PathLockkeeps 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 Tlcreates (or a Tlcreate racing a Tlopen) on the same fid race exactly the way two Tlopens 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):
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
}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 Treads 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
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.
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 and NFS: 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.
#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— 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/TwstatanswerRlerror ENOTSUP— this server speaks.Lonly, 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, fortrans=tcp, the network path to it) is the entire security boundary.- xattr, beyond the probe.
Txattrwalk/TxattrcreateanswerENOTSUP— kept cheap deliberately, sinceTxattrwalkis one per file a real client creates (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.