mountx logomountx

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/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

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 fuse3

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

  • 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. Both routes to fuse_abort_connMNT_FORCE and /sys/fs/fuse/connections/<n>/abort — belong to root. 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>;

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 mapping

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

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 teardown 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 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

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
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 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.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.tsencodeNotifyInvalInode/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;

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/promises cannot 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.h this is written against. macOS gets NFSv3.

#Next

mountx logo

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