Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
# FUSE for Rust - Changelog

## Unreleased
* Dropping a `BackgroundSession` now unmounts the filesystem and waits for the session to
end, guaranteeing that `Filesystem::destroy` has run when drop returns (#239, #411). This
restores the pre-0.16 blocking drop behavior. Drop does not wait when the session cannot
end: sessions created via `Session::from_fd` are left detached (use `join()` after ending
them), and so are sessions whose unmount failed or whose connection is still alive a few
seconds after the unmount (e.g. a lazily unmounted filesystem still in use).
The `guard` field of `BackgroundSession` is now private - use `join()`/`umount_and_join()`
* The pure-rust and `libfuse2` mount backends now report `fusermount` unmount failures
instead of silently ignoring them
* Treat `ECONNABORTED` from the FUSE device as a clean session end (#212): with
`FUSE_ABORT_ERROR` negotiated, aborting the connection made `Session::run()` and
`BackgroundSession::umount_and_join()` return an error instead of ending normally
Expand Down
5 changes: 5 additions & 0 deletions src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ impl Channel {
}
}

/// Returns the FUSE device of this channel.
pub(crate) fn device(&self) -> Arc<DevFuse> {
self.0.clone()
}

/// Returns a sender object for this channel. The sender object can be
/// used to send to the channel. Multiple sender objects can be used
/// and they can safely be sent to other threads.
Expand Down
13 changes: 8 additions & 5 deletions src/mnt/fuse2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ impl MountImpl {
// directly, which is what osxfuse does anyway, since we already converted
// to the real path when we first mounted.
if let Err(err) = crate::mnt::libc_umount(&self.mountpoint) {
// Linux always returns EPERM for non-root users. We have to let the
// library go through the setuid-root "fusermount -u" to unmount.
// Linux always returns EPERM for non-root users. We have to go
// through the setuid-root "fusermount -u" to unmount.
if err == nix::errno::Errno::EPERM {
#[cfg(not(any(
target_os = "macos",
Expand All @@ -61,9 +61,12 @@ impl MountImpl {
target_os = "openbsd",
target_os = "netbsd"
)))]
unsafe {
fuse_unmount_compat22(self.mountpoint.as_ptr());
return Ok(());
{
// Not libfuse's fuse_unmount_compat22: that would swallow
// fusermount failures (leaving a still-running session that
// callers then believe has been unmounted) and stat the
// mountpoint through the filesystem via realpath
return crate::mnt::fusermount_unmount("fusermount", &self.mountpoint);
}
}
return Err(err.into());
Expand Down
8 changes: 0 additions & 8 deletions src/mnt/fuse2_sys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,4 @@ unsafe extern "C" {
// Therefore, the minimum version requirement for *_compat25 functions is libfuse-2.6.0.

pub(crate) fn fuse_mount_compat25(mountpoint: *const c_char, args: *const fuse_args) -> c_int;
#[cfg(not(any(
target_os = "macos",
target_os = "freebsd",
target_os = "dragonfly",
target_os = "openbsd",
target_os = "netbsd"
)))]
pub(crate) fn fuse_unmount_compat22(mountpoint: *const c_char);
}
23 changes: 5 additions & 18 deletions src/mnt/fuse_pure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ impl MountImpl {
if err == nix::errno::Errno::EPERM {
// Linux always returns EPERM for non-root users. We have to let the
// library go through the setuid-root "fusermount -u" to unmount.
fuse_unmount_pure(&self.mountpoint);
fuse_unmount_pure(&self.mountpoint)?;
return Ok(());
} else {
return Err(err.into());
Expand Down Expand Up @@ -120,33 +120,20 @@ fn fuse_mount_pure(
fuse_mount_fusermount(mountpoint, options, acl)
}

fn fuse_unmount_pure(mountpoint: &CStr) {
fn fuse_unmount_pure(mountpoint: &CStr) -> io::Result<()> {
#[cfg(target_os = "linux")]
{
if nix::mount::umount2(mountpoint, nix::mount::MntFlags::MNT_DETACH).is_ok() {
return;
return Ok(());
}
}
#[cfg(target_os = "macos")]
{
if nix::mount::unmount(mountpoint, nix::mount::MntFlags::MNT_FORCE).is_ok() {
return;
return Ok(());
}
}

let mut builder = Command::new(detect_fusermount_bin());
builder.stdout(Stdio::piped()).stderr(Stdio::piped());
builder
.arg("-u")
.arg("-q")
.arg("-z")
.arg("--")
.arg(OsStr::new(&mountpoint.to_string_lossy().into_owned()));

if let Ok(output) = builder.output() {
debug!("fusermount: {}", String::from_utf8_lossy(&output.stdout));
debug!("fusermount: {}", String::from_utf8_lossy(&output.stderr));
}
crate::mnt::fusermount_unmount(&detect_fusermount_bin(), mountpoint)
}

fn detect_fusermount_bin() -> String {
Expand Down
86 changes: 86 additions & 0 deletions src/mnt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,75 @@ fn libc_umount(mnt: &CStr) -> nix::Result<()> {
}
}

/// Waits (bounded) for the FUSE kernel connection to end after an unmount was
/// requested. Returns false if the connection is still alive when the timeout
/// expires, e.g. because a lazily unmounted filesystem is still in use, or an
/// unmount helper failed in a way that cannot be observed directly.
#[cfg(not(target_os = "macos"))]
pub(crate) fn connection_ended(fuse_device: &DevFuse, timeout: std::time::Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
while is_mounted(fuse_device) {
if std::time::Instant::now() >= deadline {
return false;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
true
}

/// The FUSE device poll semantics are untested on macOS, so assume the unmount
/// took effect there.
#[cfg(target_os = "macos")]
pub(crate) fn connection_ended(_fuse_device: &DevFuse, _timeout: std::time::Duration) -> bool {
true
}

/// Lazily unmount via the given fusermount binary. Helper failures are reported,
/// so that callers do not mistake a still-mounted filesystem for an unmounted
/// one - BackgroundSession would otherwise wait forever for the session to end.
#[cfg(any(
all(test, target_os = "linux"),
fuser_mount_impl = "pure-rust",
all(
fuser_mount_impl = "libfuse2",
not(any(
target_os = "macos",
target_os = "freebsd",
target_os = "dragonfly",
target_os = "openbsd",
target_os = "netbsd"
))
)
))]
pub(crate) fn fusermount_unmount(fusermount_bin: &str, mountpoint: &CStr) -> io::Result<()> {
use std::os::unix::ffi::OsStrExt;

use log::debug;

let mut builder = std::process::Command::new(fusermount_bin);
builder
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
builder
.arg("-u")
.arg("-q")
.arg("-z")
.arg("--")
.arg(std::ffi::OsStr::from_bytes(mountpoint.to_bytes()));

let output = builder.output()?;
debug!("fusermount: {}", String::from_utf8_lossy(&output.stdout));
debug!("fusermount: {}", String::from_utf8_lossy(&output.stderr));
if !output.status.success() {
return Err(io::Error::other(format!(
"fusermount failed to unmount ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(())
}

/// Warning: This will return true if the filesystem has been detached (lazy unmounted), but not
/// yet destroyed by the kernel.
#[cfg(not(target_os = "macos"))]
Expand Down Expand Up @@ -276,6 +345,23 @@ mod test {
);
}

/// A fusermount helper that cannot run or exits nonzero must report an error:
/// the filesystem is then still mounted, and treating that as success would
/// make teardown wait forever for a session that never ends.
#[test]
#[cfg(target_os = "linux")]
fn fusermount_unmount_reports_failure() {
use std::ffi::CString;

let mountpoint = CString::new("/nonexistent").unwrap();
// Helper exits nonzero (/bin/false ignores its arguments)
assert!(fusermount_unmount("/bin/false", &mountpoint).is_err());
// Helper cannot be executed at all
assert!(fusermount_unmount("/nonexistent/fusermount", &mountpoint).is_err());
// Helper reports success
assert!(fusermount_unmount("/bin/true", &mountpoint).is_ok());
}

#[cfg(not(target_os = "macos"))]
fn cmd_mount() -> String {
std::str::from_utf8(
Expand Down
124 changes: 114 additions & 10 deletions src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,17 +227,21 @@ impl<FS: Filesystem> Session<FS> {
}

/// Run the session loop in a background thread. If the returned handle is dropped,
/// the filesystem is unmounted and the given session ends.
/// the filesystem is unmounted and the session is waited for, so that
/// `Filesystem::destroy` has run when drop returns - except in the cases documented
/// on [`BackgroundSession`], where the session thread is detached instead.
pub fn spawn(self) -> io::Result<BackgroundSession> {
let sender = self.ch.sender();
let fuse_device = self.ch.device();
// Take the fuse_session, so that we can unmount it
let mount = std::mem::take(&mut *self.mount.mount.lock());
let guard = thread::Builder::new()
.name("fuser-bg".to_string())
.spawn(move || self.run())?;
Ok(BackgroundSession {
guard,
guard: Some(guard),
sender,
fuse_device,
mount,
})
}
Expand Down Expand Up @@ -565,33 +569,69 @@ impl<FS: Filesystem> SessionEventLoop<FS> {
}

/// The background session data structure
///
/// Dropping this unmounts the filesystem and blocks until the session has ended,
/// which guarantees that `Filesystem::destroy` has run when drop returns. Because
/// of that, it must not be dropped from within a filesystem callback, which runs
/// on the session thread being waited for. For a session created via
/// `Session::from_fd` there is no mount to remove, so dropping cannot end the
/// session and leaves its thread detached; end the session externally and use
/// `join` to wait for it instead. The thread is likewise left detached, with a
/// warning, rather than waiting for a session that may never end when the
/// unmount fails, or when the kernel connection is still alive a few seconds
/// after a successful unmount request (e.g. a lazily unmounted filesystem that
/// is still in use, or an unmount helper that failed without reporting it).
#[derive(Debug)]
pub struct BackgroundSession {
/// Thread guard of the background session
pub guard: JoinHandle<io::Result<()>>,
/// Thread guard of the background session. None once joined.
guard: Option<JoinHandle<io::Result<()>>>,
/// Object for creating Notifiers for client use
sender: ChannelSender,
/// The FUSE device fd of the session's kernel connection
fuse_device: Arc<DevFuse>,
/// Ensures the filesystem is unmounted when the session ends
mount: Option<Mount>,
}

/// How long teardown waits for the kernel connection to end after a successful
/// unmount request, before giving up on joining the session thread. The
/// connection can end asynchronously (auto_unmount, kernel teardown), but a few
/// seconds only pass without it ending when the session cannot be waited for,
/// e.g. a lazily unmounted filesystem still in use
const UNMOUNT_WAIT: std::time::Duration = std::time::Duration::from_secs(5);

impl BackgroundSession {
/// Unmount the filesystem and join the background thread.
/// Unmount the filesystem and join the background thread, returning the
/// session result. `Filesystem::destroy` has run when this returns.
pub fn umount_and_join(mut self) -> io::Result<()> {
if let Some(mount) = self.mount.take() {
mount.umount()?;
if let Err(err) = mount.umount() {
// The filesystem is still mounted and the session still running,
// so joining would block indefinitely; leave the thread detached,
// as before
self.guard.take();
return Err(err);
}
}
self.join()
self.join_impl()
}

/// Returns an object that can be used to send notifications to the kernel
pub fn notifier(&self) -> Notifier {
Notifier::new(self.sender.clone())
}

/// Join the filesystem thread.
pub fn join(self) -> io::Result<()> {
self.guard
/// Join the filesystem thread without unmounting first: blocks until
/// something else ends the session, e.g. an external unmount.
pub fn join(mut self) -> io::Result<()> {
self.join_impl()
}

fn join_impl(&mut self) -> io::Result<()> {
let Some(guard) = self.guard.take() else {
return Ok(());
};
guard
.join()
.map_err(|_panic: Box<dyn std::any::Any + Send>| {
io::Error::new(
Expand All @@ -602,6 +642,36 @@ impl BackgroundSession {
}
}

impl Drop for BackgroundSession {
fn drop(&mut self) {
// Unmount and wait for the session to end, so that Filesystem::destroy
// has run by the time drop returns (issues #239 and #411). Without the
// join, the process could exit before the detached session thread got
// around to calling destroy
let Some(mount) = self.mount.take() else {
// No mount to remove (Session::from_fd): nothing here can end the
// session, so joining could block forever; leave the thread detached
return;
};
if let Err(err) = mount.umount() {
// Still mounted, so the session is still running and joining would
// block indefinitely
warn!("Failed to unmount filesystem during drop: {err}");
return;
}
// The connection can end asynchronously (e.g. auto_unmount), and some
// unmount helpers cannot report failures. Wait boundedly, and if the
// session lives on, detach rather than block indefinitely
if !crate::mnt::connection_ended(&self.fuse_device, UNMOUNT_WAIT) {
warn!("FUSE connection still alive after unmount; detaching session thread");
return;
}
if let Err(err) = self.join_impl() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid joining after an unverified helper unmount

When an unprivileged pure-Rust mount must fall back to fusermount, MountImpl::umount_impl reports success even if the helper cannot run or exits nonzero: fuse_unmount_pure ignores both the command error and output.status. In that failure case the FUSE connection remains live, so this unconditional join_impl() waits forever during an ordinary BackgroundSession drop. Propagate the helper failure or verify that the connection ended before joining.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - fixed in the latest push by propagating the failure at its source. fuse_unmount_pure now returns io::Result: the fusermount invocation checks both the spawn result and the exit status (fusermount_unmount, with a unit test covering nonzero-exit, unspawnable-binary, and success), and umount_impl propagates it. A failed helper therefore surfaces as Err from Mount::umount(), and the existing error path in Drop/umount_and_join detaches instead of joining - no indefinite wait.

Two boundary notes:

  • A successful fusermount -z on a busy filesystem is not a failure: the session ends when the last user releases it, and drop intentionally blocks until then (the pre-0.16 semantics these issues ask to restore).
  • The libfuse2/libfuse3 EPERM fallbacks go through libfuse's fuse_unmount_compat22/fuse_session_unmount, which are void and cannot report helper failures. That exposure is unchanged and predates this PR - umount_and_join() has joined after those unverified paths since 0.17. Making those backends verifiable would require fuser to bypass libfuse's own unmount, which is out of scope here.

Generated by Claude Code

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip joining after unverified libfuse2 unmounts

When built with libfuse2 and run unprivileged, this join can block forever if the compatibility unmount fails. The pure-Rust fix addresses the prior case, but fresh evidence remains in src/mnt/fuse2.rs: lines 47-50 explicitly state that fuse_unmount_compat22 can leave the mount in place without reporting status, while lines 64-66 nevertheless return Ok(()); that success reaches this unconditional join while the session is still serving. Verify that the connection ended, or leave the thread detached for this backend, before joining.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest push, and by upgrading rather than detaching: the libfuse2 EPERM path no longer uses fuse_unmount_compat22 at all. It now invokes fusermount directly through the same status-checked helper the pure-Rust backend uses (fusermount_unmount, shared in mnt), so a helper failure surfaces as Err and teardown detaches instead of joining. This is strictly better than skipping the join for that backend - the destroy-on-drop guarantee is kept whenever the unmount actually succeeded - and it drops compat22's other known hazard, the realpath that stats the mountpoint through the filesystem (the comment in this file already avoided fuse_unmount_compat22 for the primary path for exactly that reason). The now-unused extern declaration is removed.

libfuse3 remains the one backend whose EPERM fallback cannot report helper failures: its unmount is coupled to the fuse_session (which owns the fd and gets destroyed in the same call sequence), so bypassing fuse_session_unmount is not a safe local change. Its fuse_kern_unmount does at least skip dead connections itself, and the exposure is unchanged from what umount_and_join() has had since 0.17.


Generated by Claude Code

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify libfuse3 unmount before joining

When the libfuse3 backend runs unprivileged and its fusermount helper cannot unmount, this join can still block forever. Fresh evidence beyond the fixed libfuse2 path remains in src/mnt/fuse3.rs: MountImpl::umount_impl calls the void fuse_session_unmount, destroys the libfuse session, and returns Ok(()) without knowing whether the mount was removed; the duplicated FUSE descriptor used by the background loop remains live if removal failed. Propagate or independently verify the libfuse3 unmount result before joining.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest push, together with the auto-unmount case, by a single mechanism instead of more per-backend surgery: after a successful unmount request, Drop now waits (bounded, 5s) for the kernel connection to actually end - polling the FUSE device fd the session already holds - and joins only then; if the connection is still alive, it warns and detaches. A silently-failed fusermount3 leaves the connection alive, so this covers the libfuse3 fallback (and any other unmount path that cannot report failure) without bypassing fuse_session_unmount, whose coupling to the session made per-backend verification unsafe. The wait also tolerates asynchronous teardown (auto_unmount, kernel-side delays), and a slow Filesystem::destroy is unaffected: the bound applies only to the connection ending, while the join itself still waits for destroy to complete. umount_and_join() keeps its explicit, documented blocking semantics.


Generated by Claude Code

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify auto-unmount completion before joining

With the pure-Rust AutoUnmount path, this join can still wait forever if the long-lived fusermount helper dies after mounting or fails during its eventual cleanup. Fresh evidence beyond the regular-helper fix is src/mnt/fuse_pure.rs:78-82, where teardown merely closes auto_unmount_socket and returns Ok(()) without retaining the child handle, checking its status, or verifying that the connection ended. Do not join until that asynchronous unmount is confirmed, or detach the thread when it cannot be confirmed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest push by the same bounded verification as the libfuse3 thread: after a successful unmount request, Drop waits up to 5s for the kernel connection to end (polling the session's own FUSE device fd) and joins only then; otherwise it warns and detaches. For the AutoUnmount path this handles both directions: the normal case, where fusermount unmounts asynchronously moments after the socket closes (the wait absorbs the latency and the join then proceeds), and the pathological one, where the fusermount process died without unmounting and the connection never ends (the wait expires and the thread is detached instead of blocking forever).


Generated by Claude Code

warn!("Session ended with an error during drop: {err}");
}
}
}

// The abort test uses fusectl, which only exists on Linux; on other targets the
// whole module would be dead code
#[cfg(all(test, target_os = "linux"))]
Expand Down Expand Up @@ -675,6 +745,40 @@ mod test {
ManuallyDrop::into_inner(tmp);
}

/// Dropping a BackgroundSession must unmount the filesystem and wait for the
/// session to end, so that Filesystem::destroy has run when drop returns.
#[test]
fn drop_waits_for_destroy() {
struct DestroyFs {
destroyed: Arc<std::sync::atomic::AtomicBool>,
}
impl Filesystem for DestroyFs {
fn destroy(&mut self) {
// Give drop a chance to return early: without the join, drop
// consistently wins this race and the assertion below fails
std::thread::sleep(std::time::Duration::from_millis(200));
self.destroyed
.store(true, std::sync::atomic::Ordering::SeqCst);
}
}

let tmp = ManuallyDrop::new(tempfile::tempdir().unwrap());
let destroyed = Arc::new(std::sync::atomic::AtomicBool::new(false));
let fs = DestroyFs {
destroyed: destroyed.clone(),
};
let bg = Session::new(fs, tmp.path(), &Config::default())
.unwrap()
.spawn()
.unwrap();
drop(bg);
assert!(
destroyed.load(std::sync::atomic::Ordering::SeqCst),
"destroy() must have been called by the time drop returns"
);
ManuallyDrop::into_inner(tmp);
}

/// The fusectl abort file for the FUSE mount at `mountpoint`: the connection
/// directory is named after the mount's anonymous device number.
fn fusectl_abort_path(mountpoint: &Path) -> Option<std::path::PathBuf> {
Expand Down
Loading