mountx logomountx

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

hostroot?notes
Linuxyesmount(2) needs CAP_SYS_ADMIN; no setuid helper the way FUSE has fusermount3
macOSno v9fs client on any BSD kernel — 9P never appears there
Windowsno 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 and fsyncing 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.

#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/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 for the three commands end to end.

#MountP9Options

Extends P9ServerOptions (and through it P9SessionOptions), so everything on this page is settable in one object.

optiondefault
serverserve an existing P9Server; its listen() is still called — see the caveats on the type
readOnlyfalsemount -o ro and the session's own readOnly
mountMsize131096 (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)
mountOptionsnoneextra -o options, appended verbatim, last (so they win)
signalstrueunmount on SIGINT/SIGTERM
unmountTimeout10_000ms each teardown phase may spend; 0/Infinity waits forever
onTransportErrornone(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. 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 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 /mnt
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

optiondefault
port00 means an ephemeral port, which port then reports
host"127.0.0.1"address to bind
pathlisten on a unix socket instead of TCP; its directory must be 0700, owned by this process
allowRemotefalseaccept TCP connections from non-loopback addresses
socketMode0o600mode applied to a unix socket once bound
allowSharedDirectoryfalseskip the 0700 directory check
maxFrame1 MiB (P9_DEFAULT_MAX_FRAME)largest frame accepted before Tversion negotiates a real msize
maxInFlight16 (DEFAULT_MAX_IN_FLIGHT)requests dispatched at once per connection before the rest wait
onTransportErrornone(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

optiondefault
useDriverInotrueidentify files by the driver's (dev, ino) — changes which files share a qid.path, not what goes on the wire; see below
msizeDEFAULT_MSIZE (1 MiB)ceiling on the negotiated msize; the agreed value is min(client's proposal, this)
readOnlyfalserefuse every mutating request with EROFS
claimOwnershiptruechown a newly created entry to the attaching user
debugon outside productionrun the reply-exactly-once assertions
onAssertioncollectcalled when a dev-mode assertion fails
onErrornonecalled 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.tslittle-endian primitives: bounds-checked reader/writer, u64 as bigint, unpadded length-prefixed strings
constants.tsmessage types and masks, transcribed from the kernel's include/net/9p/9p.h (v6.12)
protocol.tsevery 9P2000.L message both directions, frame assembly, dirent packing
fids.tsthe fid table: paths, open state, readdir cursors, qid identity
session.tsP9Session(driver, options) — bytes in, bytes out, no socket
probe.tsp9ClientProbe()node:fs and nothing else, so mountx/auto reaches it without the codec
server.tsthe socket. mount.tsmount(8)

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

  • wire.tsP9Reader/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.tsFidTable 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 — the transport that beats this one whenever it can mount.
  • NFSv3 — the other root-needing transport, and the stateless trade this one avoids.
  • mountx/auto reference — the chooser that calls this.
mountx logo

mountx  Write a filesystem in JavaScript, mount it as a real folder.