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 startsmount() 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 missingYou 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 | |
|---|---|
mountpoint | absolute path of the mount point |
source | what the mount table shows as the device |
active | false 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 defaultunmount() 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 wrongThese 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 spawnedumount. mountx abandons a command that exceeds it because a kernel-blockedumount(8)does not die onSIGKILL. - 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.