mountx logomountx

Mounting

mount() from mountx/auto is the one you want unless you have a reason not to.

It works out what this host can mount with, uses that transport, and hands back that transport's own mount object with a transport tag defined on it — tagged, not wrapped. So await using, unmount() and every transport-specific member work exactly as they do when you import the transport directly.

import { mount } from "mountx/auto";
import { createMemoryDriver } from "mountx/drivers/memory";

await using mounted = await mount(createMemoryDriver(), "/mnt/point");

mounted.mountpoint; // "/mnt/point"
mounted.transport; // "fuse" | "nfs"
mounted.active; // false from the moment teardown starts

mount() resolves once the mountpoint is usable — not merely once the process has started.

#Asking first

import { probeTransports } from "mountx/auto";

const probe = await probeTransports();
probe.chosen; // "fuse" | "nfs" | undefined
probe.preference; // the order it chose from, for this platform
probe.fuse.usable; // and .reason when it is not
probe.reason; // when nothing can mount: what each transport is missing

The probe is cheap enough to call before you decide whether to offer a mount at all, and specific enough to print: when nothing works it names what each transport is missing rather than reporting the last failure. It also loads nothing heavy — it reaches only the NFS probe and the fusermount3 check, neither of which pulls in a protocol codec.

#Two deliberate limits

  • No probe when you name a transport. { transport: "nfs" } skips it entirely, so the error you get is that transport's own — which is more specific than anything the chooser could say.
  • No fallback after a failure. The probe decides once, from host facts. If the chosen transport then fails to mount, that error is what you get. Silently mounting the other one would hand you a filesystem with different semantics than the error you never saw.

#The mount object

Shared by both transports:

member
mountpointabsolute path of the mountpoint
sourcewhat the mount table shows as the device
activefalse from the moment teardown starts, whoever started it
unmount()idempotent, concurrency-safe, retryable after a failure
transport"fuse" or "nfs" — the discriminant, added by mountx/auto
[Symbol.asyncDispose]so await using works

Narrowing on transport reaches everything that transport has:

if (mounted.transport === "fuse") {
  mounted.session; // the FuseSession — its stats, inodes and handles
  mounted.fd; // the /dev/fuse descriptor
  mounted.closed; // resolves when the loop has ended; never rejects
  mounted.notifyInvalInode(2n); // drop the kernel's cache for one inode
  mounted.notifyInvalEntry(1n, "hello.txt"); // drop one name → inode mapping
} else {
  mounted.server; // the NfsServer behind it
  mounted.port; // the port both programs are on
}

#Unmounting

Three ways, and they all end in the same place:

// 1. explicit
await mounted.unmount();

// 2. scope-bound
{
  await using mounted = await mount(driver, "/mnt/point");
} // unmounted here

// 3. signals — SIGINT/SIGTERM, on by default

unmount() is idempotent and concurrency-safe: a second call is a no-op beyond awaiting the first. It always settles within unmountTimeout (default 10 s), and it throws if umount(8) refused — a busy mountpoint, essentially — or if the deadline passed and the connection had to be forced down. Both messages say how to recover, and a failed unmount can be retried.

The signal handlers are installed with the first mount and removed with the last. If nothing else in the process listens for the signal, the default action is re-raised once every mount is down, so the exit status stays honest. Turn them off with signals: false.

Caution

Never call process.exit() with a mount up. Node's exit path joins the libuv threadpool, and a live mount always has reads parked there — the process hangs. await mounted.unmount() and set process.exitCode instead. This is also why the signal handlers re-raise rather than exiting directly.

#Everything at once

import { liveMounts, unmountAll } from "mountx/auto";

(await liveMounts()).length; // every live mount in this process, tagged
const failures = await unmountAll(); // never rejects; returns what went wrong

Only transports you actually used are asked, so neither loads anything that was not already loaded.

#What teardown actually does

Worth knowing, because the failure modes are visible:

  • The mount table is the truth, not an exit status. umount(8) returning 0 is not the same as the mount being gone, so mountx reads the table — and treats "could not read it" as still mounted, never as gone. Forcing down a mount that turns out to be absent is harmless; declaring success on a guess shuts the server down under a live mount.
  • The deadline escalates. On expiry it goes to umount -f, and on Linux then -l. 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.
  • Unprivileged teardown is weaker, and says so. Both routes to force a FUSE connection down belong to root, so an unprivileged process can only ask fusermount3 -u -z and let the connection die with the superblock.
  • On macOS the escalation can be refused. Network volumes sit behind a sandbox approval that is never prompted for a command-line process. mountx names that case rather than blaming your driver, and tells you the mount survived. See NFSv3 § macOS consent.

#No mount stacking

Mounting over a live mountpoint is refused, in both directions: this process's own mounts, and any FUSE mount already in /proc/self/mounts. There is no option to override it.

#Options

Options that mean the same thing on both transports sit at the top level; transport-specific ones go in fuse: {…} or nfs: {…} and are applied after the shared ones, so they win.

await mount(driver, "/mnt/point", {
  // shared
  readOnly: true,
  signals: true,
  unmountTimeout: 10_000,
  useDriverIno: true,
  onError: (error) => {},
  onTransportError: (error) => {},

  fuse: { attrTimeout: 10, readers: 2 },
  nfs: { exportPath: "/", nobrowse: true },
});

A block for the transport you did not get is simply ignored, which is what makes one call portable. See Tuning for what is worth setting, and the reference for the full list.

Note

Importing mountx/fuse or mountx/nfs directly takes the same transport options at the top level rather than under fuse: {…}/nfs: {…}mountx/auto is what nests them, because it is the thing that has to hold both.

#Next

  • Tuning — cache timeouts, concurrency, and the rest of the knobs.
  • Troubleshooting — when it wedges, hangs, or leaves a stale path.
  • Transports — the two in detail, and how to pin one.
mountx logo

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