FUSE
FUSE is the preferred transport. It supports Linux, does not require root when fusermount3 is available, and preserves all driver features.
mountx/fuse implements version 7.41 of the kernel Filesystem in Userspace (FUSE) protocol. Its definitions are transcribed from include/uapi/linux/fuse.h at tag v6.12. mountx encodes and decodes every structure. Therefore, tests can exercise the complete protocol without a kernel.
import { mount } from "mountx/fuse";
import { createMemoryDriver } from "mountx/drivers/memory";
await using mounted = await mount(createMemoryDriver(), "/mnt/point");mountx/auto selects FUSE automatically on a supported Linux host. Put its options in fuse: {…} when you use auto.
#Why it is preferred
- Real per-open state.
open/releaseexist, so a driver declaringhandleskeeps a file readable after it is unlinked. - Errno passes through untouched. The wire is the Linux errno namespace, so
fsError("ENOTEMPTY")arrives asENOTEMPTY. - You control the kernel's caching. Attribute, entry and negative timeouts, page-cache retention, and explicit invalidation. This is where the performance is.
statfs,chmod,chown, symlinks, hardlinks: everything the driver interface can express reaches the kernel.
#Mounting without root
Node cannot make the mount system call. As root, mountx opens /dev/fuse itself and spawns mount(8) with the descriptor at its own fd number. For an unprivileged mount, mountx asks fusermount3 to mount and return the connection over a Unix socket. This set-user-ID helper is included with FUSE.
After mountx receives the file descriptor, both paths use the same code.
# if the helper is missing
apt install fuse3 # or: dnf install fuse3Receiving a file descriptor through SCM_RIGHTS requires recvmsg(2). Node cannot call it. A small native addon performs this one step. It is optional, lazy and never on the root path: a host with no prebuilt loses unprivileged mounting and nothing else.
An unprivileged mount has two limits:
allowOtherneedsuser_allow_otherin/etc/fuse.conf. Without itfusermount3refuses the mount and says so.- Forcing a stuck mount down is weaker. Only root can use the two routes to
fuse_abort_conn:MNT_FORCEand/sys/fs/fuse/connections/<n>/abort. An unprivileged process can only runfusermount3 -u -zand let the connection die with the superblock.
import { rootlessProbe, fusermountPath } from "mountx/fuse";
rootlessProbe(); // { usable, reason } — the helper and the addon
fusermountPath(); // string | undefined — where the helper was foundThe _FUSE_COMMFD handshake is transcribed from libfuse 3.18.2 (lib/mount.c, util/fusermount.c), including which -o options the helper accepts and which four it supplies itself.
#mount(driver, mountpoint, options?)
function mount(driver: FsDriver, mountpoint: string, options?: MountOptions): Promise<Mount>;This function supports Linux only. It resolves when the mount point is usable. Options are at the top level here. mountx/auto nests them under fuse: {…}.
#The mount object
interface Mount extends AsyncDisposable {
readonly mountpoint: string;
readonly source: string; // what /proc/mounts shows as the device (fsname)
readonly session: FuseSession; // stats, inodes, handle table
readonly fd: number; // the /dev/fuse descriptor, open until teardown finishes
readonly active: boolean; // false from the moment teardown starts
readonly closed: Promise<void>; // resolves when the loop ended; never rejects
unmount(): Promise<void>;
notifyInvalInode(ino: bigint, off?: bigint, len?: bigint): void;
notifyInvalEntry(parent: bigint, name: string, flags?: number): void;
}closed resolves after teardown by unmount() or by another process that calls umount(8). Fatal transport errors use onTransportError instead. Therefore, you can use closed without a catch.
unmount() is idempotent, concurrency-safe, and a no-op beyond awaiting closed if the mount is already gone.
The promise always settles. A normal unmount settles within unmountTimeout. A forced unmount settles within twice that time because unmountTimeout applies separately to each phase.
It throws if umount(8) refuses or the deadline passes. Both errors include recovery instructions. You can retry a failed unmount.
#Cache invalidation
mounted.notifyInvalInode(42n); // forget this inode's cached data and attributes
mounted.notifyInvalInode(42n, 0n, 4096n); // just this byte range
mounted.notifyInvalEntry(1n, "hello.txt"); // forget one name → inode mappingIf you use long cache timeouts, invalidate the cache when storage changes. These methods invalidate only the affected inode or entry.
#MountOptions
Extends FuseSessionOptions, so the caching options are settable here too.
| option | default | |
|---|---|---|
readers | 2 | reads outstanding on /dev/fuse — a threadpool knob, see below |
fsname | "mountx" | what /proc/mounts shows as the device |
subtype | none | makes the type read fuse.<subtype> |
defaultPermissions | true | let the kernel enforce mode bits from getattr |
allowOther | false | let users other than the mounting one in |
readOnly | false | -o ro; the driver is not told, it just never sees writes |
maxRead | kernel's | cap on a single READ, in bytes |
mountOptions | none | extra -o options, appended verbatim |
signals | true | unmount on SIGINT/SIGTERM |
initTimeout | 10_000 | ms to wait for the kernel's FUSE_INIT |
unmountTimeout | 10_000 | ms each teardown phase may spend; 0/Infinity waits forever |
device | /dev/fuse | root mode only; a test double is the only reason to change it |
tap | none | tee every byte crossing /dev/fuse — see below |
onTransportError | none | transport-level failures that are not part of teardown |
Caution
readers is a threadpool knob. /dev/fuse is a character device, so libuv classifies it as UV_FILE. Each read uses one of the four default pool threads. The process shares these threads with all fs, dns, and zlib work, including driver I/O.
Two readers leave two for the driver; four would deadlock it.
Raise it only together with UV_THREADPOOL_SIZE, which must be set before the process starts.
#FuseSession
new FuseSession(driver: FsDriver, options?: FuseSessionOptions)FuseSession converts messages to replies without using a device. handleMessage() never rejects: every request needing a reply gets exactly one, and a thrown value becomes a negative errno (unknown → EIO).
session.driver; // the Loopback wrapping your driver
session.stats; // { requests, replies, errors, noReply, dropped, assertions }
session.inodes; // the InodeTable
await session.destroy(); // idempotent, safe with requests in flight#FuseSessionOptions
| option | default | |
|---|---|---|
attrTimeout | 10 | seconds the kernel may cache attributes |
entryTimeout | 10 | seconds it may cache name → inode |
negativeTimeout | 0 | seconds it may cache a failed lookup — off by default |
keepCache | true | reply FOPEN_KEEP_CACHE, keeping page cache across opens |
flushMechanism | "enosys" | how FLUSH is declined for a driver that declares durableWrites |
useDriverIno | true | identify files by the driver's (dev, ino), so hardlinks share a nodeid |
init | — | InitPreferences, passed to negotiateInit |
debug | on outside production | run the reply-exactly-once assertions |
onError | none | called for every request that ends in an error reply |
onAssertion | collect | called when a dev-mode assertion fails |
Note
negativeTimeout reduces work for builds that check hundreds of missing headers. It can be unsafe when another writer changes the storage. A file created outside mountx stays invisible until the timeout expires. Opt in per mount.
#FLUSH, and the driver that has nothing to say
The kernel sends one FLUSH for each close(2) of an open file, and the session returns success. flush is not fsync. A driver has no work when write() is complete at resolution. However, the reply preserves close(2) error reporting for a driver that defers work.
A driver that declares capabilities.durableWrites is saying there is nothing deferred, and the session then declines FLUSH altogether. This removes one request for each created file. In the measured install workload, FLUSH was the third most frequent opcode.
flushMechanism selects the mechanism, but does not enable it. Without the capability, this option has no effect:
"enosys"(default) answers the firstFLUSH-ENOSYS. The kernel records this result for the connection and does not ask again. It costs one request per mount, needs no specific protocol version, and preserves the kernel'sclose(2)work."noflush"setsFOPEN_NOFLUSHin theOPEN/CREATEreply instead. This flag applies to each open file, so the kernel sends noFLUSH. It requires protocol 7.35 or newer. The kernel ignores it whenwriteback_cachewas negotiated. It also skips kernel writeback at close, which affects pages changed through a sharedmmap.
#Teardown, and what it can't do
A -t fuse mount does not receive FUSE_DESTROY. The transport detects unmount through end-of-file (EOF) or ENODEV on /dev/fuse. It then destroys the session. That is idempotent and safe with requests in flight.
unmount() has an unmountTimeout deadline of 10 seconds by default. It escalates when the deadline expires. umount(8) must stop filesystem activity before detaching. An unanswered request can block it in D state. This also blocks unmount(), await using, and signal handlers.
The deadline bounds every spawned umount and each unprivileged fusermount3 -u. mountx abandons a child that exceeds the deadline. A kernel-blocked umount(8) does not die on SIGKILL, so waiting could continue forever. Leaving it active would also keep the mount busy during escalation.
The escalation phase receives another budget of the same size. All escalation steps, including the post-abort settle window, share that budget. Therefore, forced teardown settles within twice unmountTimeout.
If the process ends without unmounting, the mount point becomes stale, not blocked: ls says ENOTCONN. Recover with fusermount3 -u /mnt/point, or sudo umount -l /mnt/point if it was mounted as root.
#The layers below
mountx/fuse exposes its low-level layer as a documented public API. Notifications, custom opcodes, and record and replay tools need this layer. Everything except the mount itself is pure data transformation with no I/O and no syscalls, so it runs on any OS:
| module | |
|---|---|
constants.ts | opcodes and the FUSE_*/FOPEN_*/FATTR_*/DT_* families |
protocol.ts | every struct both directions, framing, dirent packing |
init.ts | negotiateInit() — pure, and the FUSE_INIT handshake in one function |
session.ts | FuseSession(driver, options) — messages in, replies out, no device |
inodes.ts | nodeid ↔ path ↔ (dev, ino), refcounting, subtree remap on rename |
notify.ts | the two invalidation encoders |
record.ts | tee /dev/fuse traffic to a transcript, and replay one |
All of it is re-exported from mountx/fuse by name:
constants.ts: transcribed from the kernel'sinclude/uapi/linux/fuse.hat tag v6.12, protocol 7.41.protocol.ts: every struct encoded and decoded, theOPCODESdispatch table, message framing, errno-on-the-wire helpers, andDirentPacker.notify.ts:encodeNotifyInvalInode/encodeNotifyInvalEntryand their decoders, plusFUSE_NAME_MAX.
Note
Wire constants are transcribed, never guessed or borrowed from the host's node:fs. The FUSE numbers come from the kernel header, the fusermount3 handshake from libfuse's own source, and both are named where they are used.
#negotiateInit(kernelInit, preferences?)
function negotiateInit(kernelInit: FuseInitIn, preferences?: InitPreferences): InitNegotiation;This pure function implements the FUSE_INIT handshake. You can test it without a kernel. DEFAULT_WANTED_FLAGS and DEFAULT_MAX_WRITE (1 MiB) are the shipped preferences; joinInitFlags/splitInitFlags move between the wire's two 32-bit halves and a single bigint.
#InodeTable
nodeid ↔ path ↔ (dev, ino), with lookup refcounting, subtree remap on rename, and orphan tracking. Entirely synchronous.
#Record and replay
import {
decodeTranscript,
encodeTranscript,
replayTranscript,
TranscriptRecorder,
} from "mountx/fuse";
await mount(driver, "/mnt/point", {
tap: (direction, bytes) => {
/* "in" = one whole message as the kernel wrote it */
},
});Record and replay use the tap. An "in" value is one complete message written by the kernel. An "out" value is one complete reply or notification before the loop writes it. These are the exact bytes that a session must process. A real kernel transcript can therefore become a replay fixture that needs no kernel.
TranscriptRecorder is the tap consumer that copies as it goes; encodeTranscript/decodeTranscript are the container format; replayTranscript() feeds a transcript back through a fresh session.
Caution
Nothing is copied before the call, so the Uint8Array is a view of a buffer reused the moment the tap returns. A recorder has to copy what it keeps. A thrown value is reported through onTransportError and has no other effect. A broken recorder must not break a mount point.
#Not available
- Special files without a driver that implements them.
FUSE_MKNODneedsmountx.mknodfor a FIFO, socket, or device node.node:fs/promisescannot provide this extension, but the memory driver does. Without it, all types except a regular file returnENOSYS. With this extension, all pjdfstest tests pass. One mount-level caveat remains.fusermount3usesnodev, so an unprivileged mount can contain a device node that it cannot open. - macOS. macFUSE is a third-party kernel extension speaking its own protocol dialect, not the Linux
fuse.hthis is written against. macOS gets NFS (NFSv3 by default).
#Next
- Tuning: the cache settings, in context.
- 9P2000.L: stateful and root-only, for when FUSE cannot mount.
- NFS: v3 stateless, v4.1 stateful, and the only transport that runs on macOS.
mountx/autoreference: the chooser that calls this.