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/release exist, so a driver declaring handles keeps a file readable after it is unlinked.
  • Errno passes through untouched. The wire is the Linux errno namespace, so fsError("ENOTEMPTY") arrives as ENOTEMPTY.
  • 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 fuse3

Receiving 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:

  • allowOther needs user_allow_other in /etc/fuse.conf. Without it fusermount3 refuses the mount and says so.
  • Forcing a stuck mount down is weaker. Only root can use the two routes to fuse_abort_conn: MNT_FORCE and /sys/fs/fuse/connections/<n>/abort. An unprivileged process can only run fusermount3 -u -z and 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 found

The _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 mapping

If 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.

optiondefault
readers2reads outstanding on /dev/fuse — a threadpool knob, see below
fsname"mountx"what /proc/mounts shows as the device
subtypenonemakes the type read fuse.<subtype>
defaultPermissionstruelet the kernel enforce mode bits from getattr
allowOtherfalselet users other than the mounting one in
readOnlyfalse-o ro; the driver is not told, it just never sees writes
maxReadkernel'scap on a single READ, in bytes
mountOptionsnoneextra -o options, appended verbatim
signalstrueunmount on SIGINT/SIGTERM
initTimeout10_000ms to wait for the kernel's FUSE_INIT
unmountTimeout10_000ms each teardown phase may spend; 0/Infinity waits forever
device/dev/fuseroot mode only; a test double is the only reason to change it
tapnonetee every byte crossing /dev/fuse — see below
onTransportErrornonetransport-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

optiondefault
attrTimeout10seconds the kernel may cache attributes
entryTimeout10seconds it may cache name → inode
negativeTimeout0seconds it may cache a failed lookup — off by default
keepCachetruereply FOPEN_KEEP_CACHE, keeping page cache across opens
flushMechanism"enosys"how FLUSH is declined for a driver that declares durableWrites
useDriverInotrueidentify files by the driver's (dev, ino), so hardlinks share a nodeid
initInitPreferences, passed to negotiateInit
debugon outside productionrun the reply-exactly-once assertions
onErrornonecalled for every request that ends in an error reply
onAssertioncollectcalled 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 first FLUSH -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's close(2) work.
  • "noflush" sets FOPEN_NOFLUSH in the OPEN/CREATE reply instead. This flag applies to each open file, so the kernel sends no FLUSH. It requires protocol 7.35 or newer. The kernel ignores it when writeback_cache was negotiated. It also skips kernel writeback at close, which affects pages changed through a shared mmap.

#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.tsopcodes and the FUSE_*/FOPEN_*/FATTR_*/DT_* families
protocol.tsevery struct both directions, framing, dirent packing
init.tsnegotiateInit() — pure, and the FUSE_INIT handshake in one function
session.tsFuseSession(driver, options) — messages in, replies out, no device
inodes.tsnodeid ↔ path ↔ (dev, ino), refcounting, subtree remap on rename
notify.tsthe two invalidation encoders
record.tstee /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's include/uapi/linux/fuse.h at tag v6.12, protocol 7.41.
  • protocol.ts: every struct encoded and decoded, the OPCODES dispatch table, message framing, errno-on-the-wire helpers, and DirentPacker.
  • notify.ts: encodeNotifyInvalInode/encodeNotifyInvalEntry and their decoders, plus FUSE_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_MKNOD needs mountx.mknod for a FIFO, socket, or device node. node:fs/promises cannot provide this extension, but the memory driver does. Without it, all types except a regular file return ENOSYS. With this extension, all pjdfstest tests pass. One mount-level caveat remains. fusermount3 uses nodev, 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.h this 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/auto reference: the chooser that calls this.

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