Mounting

Use mount() from mountx/auto unless you must select a transport directly.

It checks which transport the host can use and mounts with that transport. It returns the transport's own mount object and adds a transport tag. It does not wrap the object. Therefore, await using, unmount(), and all transport-specific members work as they do with a direct transport import.

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" | "9p" | "nfs"
mounted.active; // false from the moment teardown starts

mount() resolves only when the mount point is usable. It does not resolve only because a child process started.

#Asking first

import { probeTransports } from "mountx/auto";

const probe = await probeTransports();
probe.chosen; // "fuse" | "9p" | "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

You can run the small probe before you offer a mount. If no transport works, it reports the missing requirement for each transport. It does not report only the last failure. The probe runs only the NFS check, 9P check, and fusermount3 check. These checks do not load protocol codecs.

#Two deliberate limits

  • No probe runs when you name a transport. { transport: "nfs" } calls NFS directly. You receive its specific error.
  • No fallback occurs after a failure. The probe selects one transport from host facts. If that transport cannot mount, mount() returns its error. A fallback could silently give you different filesystem behavior.

#The mount object

Shared by all three transports:

member
mountpointabsolute path of the mount point
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", "9p" 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 if (mounted.transport === "9p") {
  mounted.server; // the P9Server behind it
  mounted.connection; // the kernel's connection — the only one this mount cares about
  mounted.trans; // "unix" | "tcp"
} else {
  mounted.server; // the NfsServer behind it
  mounted.port; // the port both programs are on
}

#Unmounting

Use one of these three methods. They all run the same unmount operation:

// 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 safe to call concurrently.

A second call only waits for the first call.

When normal unmount succeeds, this promise settles within unmountTimeout, which defaults to 10 seconds. A forced unmount settles within twice that time. The timeout applies separately to each of the two phases. Each command receives only the time that remains in its phase.

It throws if umount(8) refuses a busy mount point. It also throws if the deadline passes and mountx must force down the connection.

Both messages say how to recover, and a failed unmount can be retried.

mountx installs signal handlers for the first mount and removes them after the last mount. If no other listener handles the signal, mountx re-raises the default action after all mounts are down. This preserves the expected exit status. Turn them off with signals: false.

Caution

Never call process.exit() with a mount up. Node's exit path joins the libuv thread pool. A live mount always has pending reads there, so the process does not exit. 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 on the transports auto loaded, tagged
const failures = await unmountAll(); // never rejects; returns what went wrong

These functions check only transports that are already loaded. They do not load unused transports.

The functions are not limited to mounts returned by mount(). Each transport keeps one process-wide registry of live mounts. Both functions read the entire registry.

A mount you made by importing mountx/fuse directly is returned by liveMounts() and is unmounted by unmountAll(), once mountx/auto has itself mounted over FUSE.

They cannot see a transport that mountx/auto never loaded. For example, assume that auto loaded only FUSE and you imported mountx/9p directly. The 9P mount is invisible to these functions. Use unmountAll9p() for it.

See mountx/auto § liveMounts/unmountAll.

#What teardown actually does

These rules explain visible teardown failures:

  • The mount table is the truth, not an exit status. A zero exit status from umount(8) does not prove that the mount is gone. mountx reads the mount table to verify removal. If it cannot read the table, it treats the path as still mounted. 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. The deadline bounds each spawned umount. mountx abandons a command that exceeds it because a kernel-blocked umount(8) does not die on SIGKILL.
  • Unprivileged teardown is weaker, and says so. Only root can use either route that forces down a FUSE connection. An unprivileged process can only run fusermount3 -u -z. It must let the connection end 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 NFS § macOS consent.

#No mount stacking

mountx refuses to mount over a live mount point. It checks mounts from the current process and all FUSE mounts in /proc/self/mounts. You cannot override this check.

#Options

Put shared options at the top level. Put transport-specific options in fuse: {…}, "9p": {…}, or nfs: {…}. mountx applies transport-specific options after shared options, so they take priority.

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

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

mountx ignores the block for a transport that it did not select. This lets one call work on different hosts. See Tuning for what is worth setting, and the reference for the full list.

Note

exportPath is where the client lands, not a fence. An NFS client can reach everything under the driver's root, regardless of this setting. To serve a subtree, scope the driver itself. Why.

Note

When you import mountx/fuse or mountx/nfs directly, put transport options at the top level. Do not nest them under fuse: {…} or nfs: {…}. mountx/auto nests the options because it accepts settings for multiple transports.

#Next

  • Virtual machines: the same driver, mounted inside a VM guest instead.
  • Tuning: cache timeouts, concurrency, and the rest of the knobs.
  • Troubleshooting: blocked processes, failed unmounts, and stale paths.
  • Transports: the three mount transports in detail, and how to select one.

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