Mounting and lifecycle

Mount a backend, choose platform options, keep the session alive and unmount cleanly.

MountSession turns a backend into a live volume. It owns the mount and the service that answers the kernel, so the mounted filesystem lasts exactly as long as the session value.

Mount

Every platform exposes the same four constructors:

ConstructorBackend
mount(fs, path)Filesystem
mount_with_options(fs, path, &opts)Filesystem
mount_network(fs, path)NetworkFilesystem
mount_network_with_options(fs, path, &opts)NetworkFilesystem

All of them take the backend by value and return io::Result<MountSession>. The call is synchronous and returns once the mount exists: after mount(8) succeeds on macOS, after the FUSE session starts on Linux, and after PrjStartVirtualizing succeeds on Windows.

use fsk::MountSession;

let session = MountSession::mount(MyFilesystem::new(), "/tmp/my-volume")?;
println!("mounted at {}", session.mount_path().display());

The session owns the backend; there is no handle back to it. Keep any state you need to reach from elsewhere in your own Arc, not through the session.

The mount directory

The directory is created if it is missing on all three platforms. It is not removed when the mount stops.

macOS additionally canonicalises the path before mounting, so mount_path() may differ from what you passed: /tmp/my-volume becomes /private/tmp/my-volume. The canonical path is what diskutil receives when the session unmounts.

macOS and Linux do not check whether the directory is empty: the session creates the path and mounts over it, and a busy or invalid path is reported by mount(8) or FUSE. Windows does: ProjFS requires an empty directory or one already initialized as a virtualization root, and rejects anything else before the mount starts.

Dropping the session unmounts:

PlatformWhat Drop does
macOSRuns diskutil unmount <path> and discards its error
LinuxCalls umount_and_join, which waits for the dispatcher threads
WindowsCalls PrjStopVirtualizing

unmount(self) is the explicit form. On macOS it returns io::Result<()>; on Linux and Windows it returns () because the drop path has no error to report.

Options

Only the fields that change behaviour are listed. Defaults come from MountOptions::default().

macOS:

FieldDefaultEffect
read_onlyfalseAdds -r to the mount(8) command
mount_optionsemptyJoined with , and passed as -o <value>

Linux:

FieldDefaultEffect
read_onlyfalseMounts with MountOption::RO instead of RW
filesystem_name"fsk"Reported by statfs and visible in /proc/mounts
worker_threadsavailable parallelism, or 4FUSE dispatcher count; values below 1 become 1
kernel_permissionstrueAdds DefaultPermissions, so the kernel checks mode, uid, gid
metadata_ttlone secondHow long the kernel may cache attributes

Windows:

FieldDefaultEffect
worker_threadsavailable parallelism, or 4ProjFS pool size (2× for pool threads)
use_negative_path_cachetrueRemembers names reported as absent

MountOptions::read_only and filesystem_name are declared on Windows but are not applied when the projection starts. A Windows mount is not made read-only by that flag; enforce read-only through metadata (mode without write bits) and open_file instead. There is also no volume name or statfs display on Windows.

Threads and shutdown

Mounting is a blocking call, but serving is not: the session starts the threads that answer the kernel and returns. macOS runs an accept loop plus one thread per IPC connection, Linux runs its FUSE dispatchers, and Windows uses the ProjFS pool.

Treat the mount call itself as blocking work. In an async application, call it from spawn_blocking or a dedicated thread rather than on a runtime worker. The same applies to fsk::install(), which downloads and verifies FSK.app synchronously.

The session must outlive the mount. A common shape is a dedicated thread that owns the value and waits for a stop flag:

use fsk::MountSession;
use std::{
    sync::{
        Arc,
        atomic::{AtomicBool, Ordering},
    },
    thread,
    time::Duration,
};

fn main() -> std::io::Result<()> {
    let mount_path = std::env::args().nth(1).expect("usage: my-fs <mount-path>");

    fsk::ensure_authorization()?;
    let session = MountSession::mount(MyFilesystem::new(), &mount_path)?;
    println!("mounted at {} — press Ctrl-C to unmount", session.mount_path().display());

    let running = Arc::new(AtomicBool::new(true));
    let signal = Arc::clone(&running);
    ctrlc::set_handler(move || signal.store(false, Ordering::Release))
        .map_err(std::io::Error::other)?;

    while running.load(Ordering::Acquire) {
        thread::park_timeout(Duration::from_millis(250));
    }

    // Dropping the session unmounts and releases any local-file leases.
    drop(session);
    Ok(())
}

On macOS, call session.unmount()? instead of dropping when you want to see a diskutil failure. On Linux, stopping waits for in-flight dispatcher calls, so a backend method that never returns keeps the session alive. Do not block indefinitely inside a trait method.

Mount failures

Most failures happen before your backend is called.

ErrorCause
NotFound, "install FSK.app with fsk::install()"macOS has no matching app; ensure_authorization never installs
PermissionDenied, "enable FSK in the window that opened"The extension is installed but not enabled
FSKit mount failed: …mount(8) rejected the mount; the message carries its stderr
"FUSE must be available"/dev/fuse is missing or is not a character device
ProjFS initialization errorClient-ProjFS is missing, or the root is neither empty nor virtualized

For macOS, read the mount(8) stderr before blaming the backend: a disabled extension and a mount path already in use produce different messages there. On Linux, the mounting user also needs permission to use /dev/fuse, which is separate from the crate.

Next