FUSE
The preferred transport: Linux only, no root needed, and nothing is lost.
mountx/fuse speaks the kernel's own filesystem-in-userspace protocol — version 7.41, transcribed from include/uapi/linux/fuse.h at tag v6.12. Every struct is encoded and decoded, which is what makes the whole protocol testable without a kernel.
import { mount } from "mountx/fuse";
import { createMemoryDriver } from "mountx/drivers/memory";
await using mounted = await mount(createMemoryDriver(), "/mnt/point");Through mountx/auto this is what a Linux host gets automatically, and its options move under fuse: {…}.
#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
Mounting is a syscall Node cannot make. As root, mountx opens /dev/fuse itself and spawns mount(8) with the descriptor at its own fd number. Unprivileged, it asks fusermount3 — the setuid helper that ships with FUSE — to do the mounting and hand the connection back over a unix socket.
Past the descriptor, the two paths are the same code.
# if the helper is missing
apt install fuse3 # or: dnf install fuse3Receiving a file descriptor over SCM_RIGHTS needs recvmsg(2), which Node cannot do — so this one step goes through a tiny native addon. It is optional, lazy and never on the root path: a host with no prebuilt loses unprivileged mounting and nothing else.
Two limits an unprivileged mount inherits:
allowOtherneedsuser_allow_otherin/etc/fuse.conf. Without itfusermount3refuses the mount and says so.- Forcing a stuck mount down is weaker. Both routes to
fuse_abort_conn—MNT_FORCEand/sys/fs/fuse/connections/<n>/abort— belong to root. 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>;Linux only. Resolves once the mountpoint is usable. Options sit at the top level here — it is mountx/auto that 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 settles whether teardown was unmount() or somebody else's umount(8) — a fatal transport error surfaces through onTransportError instead, so it can be held without a catch.
unmount() is idempotent, concurrency-safe, and a no-op beyond awaiting closed if the mount is already gone. It always settles within unmountTimeout, and throws if umount(8) refused or the deadline passed — both messages say how to recover, and a failed unmount can be retried.
#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 mappingThese are what make long cache timeouts safe: keep the timeouts high and invalidate precisely when your storage moves.
#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 teardown 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 UV_FILE and every read parks one of the four default pool threads — threads shared with all other fs, dns and zlib work in the process, including your driver's own I/O. Two readers leaves 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)Messages in, replies out — no device anywhere. 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 |
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 is a real saving for a build that stats hundreds of missing headers, and a real hazard for any driver whose storage has other writers: a file created out of band stays invisible for the whole timeout. Opt in per mount.
#Teardown, and what it can't do
A -t fuse mount never receives FUSE_DESTROY, so the transport detects unmount itself — EOF or ENODEV on /dev/fuse — and destroys the session. That is idempotent and safe with requests in flight.
unmount() has a deadline (unmountTimeout, default 10 s) and escalates on expiry, because umount(8) quiesces the filesystem before detaching: a request that never gets a reply blocks it in D state, and with it unmount(), await using, and the signal handlers. Every spawned umount is bounded by that deadline and abandoned if it outlives it — a umount(8) parked in the kernel does not die on SIGKILL.
If the process dies without unmounting, the mountpoint is left stale, not frozen: 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 is a documented public layer, because low-level access is a feature — notifies, custom opcodes and record/replay tooling all need it. 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;The FUSE_INIT handshake as one pure function, which is why it is testable 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 */
},
});The tap is the hook record/replay is built on: "in" is one whole message as the kernel wrote it, "out" one whole reply or notification as the loop is about to write it — exactly the bytes a session has to be able to answer, which turns a transcript of a real kernel into a fixture that needs no kernel to replay.
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. Anything thrown is reported through onTransportError and costs nothing else — a broken recorder must not break a mountpoint.
#Not available
- Special files. No FIFOs, sockets or device nodes — the driver interface has no way to express them, because
node:fs/promisescannot create them either. This is the whole of the remaining pjdfstest failures. - macOS. macFUSE is a third-party kernel extension speaking its own protocol dialect, not the Linux
fuse.hthis is written against. macOS gets NFSv3.
#Next
- Tuning — the cache settings, in context.
- NFSv3 — the other transport.
mountx/autoreference — the chooser that calls this.