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

```ts
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`](/guide/drivers/custom#file-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](/guide/tuning) 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.

```sh
# 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](/transports#no-native-code-with-one-exception): 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_conn` — `MNT_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.

```ts
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?)`

```ts
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

```ts
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

```ts
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](/guide/tuning#caching-this-is-where-the-speed-is) safe: keep the timeouts high and invalidate precisely when your storage moves.

## `MountOptions`

Extends [`FuseSessionOptions`](#fusesessionoptions), so the caching options are settable here too.

| option               | default     |                                                                       |
| -------------------- | ----------- | --------------------------------------------------------------------- |
| `readers`            | `2`         | reads outstanding on `/dev/fuse` — a **threadpool** knob, see below   |
| `fsname`             | `"mountx"`  | what `/proc/mounts` shows as the device                               |
| `subtype`            | none        | makes the type read `fuse.<subtype>`                                  |
| `defaultPermissions` | `true`      | let the kernel enforce mode bits from `getattr`                       |
| `allowOther`         | `false`     | let users other than the mounting one in                              |
| `readOnly`           | `false`     | `-o ro`; the driver is not told, it just never sees writes            |
| `maxRead`            | kernel's    | cap on a single `READ`, in bytes                                      |
| `mountOptions`       | none        | extra `-o` options, appended verbatim                                 |
| `signals`            | `true`      | unmount on `SIGINT`/`SIGTERM`                                         |
| `initTimeout`        | `10_000`    | ms to wait for the kernel's `FUSE_INIT`                               |
| `unmountTimeout`     | `10_000`    | ms teardown may spend; `0`/`Infinity` waits forever                   |
| `device`             | `/dev/fuse` | root mode only; a test double is the only reason to change it         |
| `tap`                | none        | tee every byte crossing `/dev/fuse` — see [below](#record-and-replay) |
| `onTransportError`   | none        | transport-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`

```ts
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`).

```ts
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`

| option            | default               |                                                                          |
| ----------------- | --------------------- | ------------------------------------------------------------------------ |
| `attrTimeout`     | `10`                  | seconds the kernel may cache attributes                                  |
| `entryTimeout`    | `10`                  | seconds it may cache name → inode                                        |
| `negativeTimeout` | `0`                   | seconds it may cache a _failed_ lookup — off by default                  |
| `keepCache`       | `true`                | reply `FOPEN_KEEP_CACHE`, keeping page cache across opens                |
| `useDriverIno`    | `true`                | identify files by the driver's `(dev, ino)`, so hardlinks share a nodeid |
| `init`            | —                     | `InitPreferences`, passed to `negotiateInit`                             |
| `debug`           | on outside production | run the reply-exactly-once assertions                                    |
| `onError`         | none                  | called for every request that ends in an error reply                     |
| `onAssertion`     | collect               | called 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.ts` | opcodes and the `FUSE_*`/`FOPEN_*`/`FATTR_*`/`DT_*` families            |
| `protocol.ts`  | every struct both directions, framing, dirent packing                   |
| `init.ts`      | `negotiateInit()` — pure, and the `FUSE_INIT` handshake in one function |
| `session.ts`   | `FuseSession(driver, options)` — messages in, replies out, no device    |
| `inodes.ts`    | nodeid ↔ path ↔ `(dev, ino)`, refcounting, subtree remap on rename      |
| `notify.ts`    | the two invalidation encoders                                           |
| `record.ts`    | tee `/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?)`

```ts
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

```ts
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](https://github.com/pjd/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](/transports/nfs).

## Next

- [Tuning](/guide/tuning) — the cache settings, in context.
- [NFSv3](/transports/nfs) — the other transport.
- [`mountx/auto` reference](/transports/auto) — the chooser that calls this.
