Skip to content

Commit e91c9e6

Browse files
committed
Wait for the session to end when dropping BackgroundSession
Since 0.16, dropping a BackgroundSession unmounted the filesystem but left the session thread detached, so drop returned before the session had ended and Filesystem::destroy could run much later - or never, when the process exited first (#411). That also meant the destroy-exactly- once guarantee did not effectively extend to BackgroundSession (#239). Add a Drop impl that unmounts and then joins the session thread, restoring the pre-0.16 blocking behavior: destroy has run by the time drop returns. Two deliberate exceptions avoid blocking forever: a session created via Session::from_fd has no mount to remove, so nothing in drop can end it and its thread stays detached; and if the unmount itself fails, the session is still running, so the thread is detached as before instead of joined. The guard field of BackgroundSession is now private; use join() or umount_and_join(), which keep their existing signatures. Fixes #411 Fixes #239 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TfPeUUeQKYjo26Y9mQG9sA
1 parent e0e53d7 commit e91c9e6

7 files changed

Lines changed: 227 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
# FUSE for Rust - Changelog
22

33
## Unreleased
4+
* Dropping a `BackgroundSession` now unmounts the filesystem and waits for the session to
5+
end, guaranteeing that `Filesystem::destroy` has run when drop returns (#239, #411). This
6+
restores the pre-0.16 blocking drop behavior. Drop does not wait when the session cannot
7+
end: sessions created via `Session::from_fd` are left detached (use `join()` after ending
8+
them), and so are sessions whose unmount failed or whose connection is still alive a few
9+
seconds after the unmount (e.g. a lazily unmounted filesystem still in use).
10+
The `guard` field of `BackgroundSession` is now private - use `join()`/`umount_and_join()`
11+
* The pure-rust and `libfuse2` mount backends now report `fusermount` unmount failures
12+
instead of silently ignoring them
413
* Treat `ECONNABORTED` from the FUSE device as a clean session end (#212): with
514
`FUSE_ABORT_ERROR` negotiated, aborting the connection made `Session::run()` and
615
`BackgroundSession::umount_and_join()` return an error instead of ending normally

src/channel.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ impl Channel {
4747
}
4848
}
4949

50+
/// Returns the FUSE device of this channel.
51+
pub(crate) fn device(&self) -> Arc<DevFuse> {
52+
self.0.clone()
53+
}
54+
5055
/// Returns a sender object for this channel. The sender object can be
5156
/// used to send to the channel. Multiple sender objects can be used
5257
/// and they can safely be sent to other threads.

src/mnt/fuse2.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ impl MountImpl {
5151
// directly, which is what osxfuse does anyway, since we already converted
5252
// to the real path when we first mounted.
5353
if let Err(err) = crate::mnt::libc_umount(&self.mountpoint) {
54-
// Linux always returns EPERM for non-root users. We have to let the
55-
// library go through the setuid-root "fusermount -u" to unmount.
54+
// Linux always returns EPERM for non-root users. We have to go
55+
// through the setuid-root "fusermount -u" to unmount.
5656
if err == nix::errno::Errno::EPERM {
5757
#[cfg(not(any(
5858
target_os = "macos",
@@ -61,9 +61,12 @@ impl MountImpl {
6161
target_os = "openbsd",
6262
target_os = "netbsd"
6363
)))]
64-
unsafe {
65-
fuse_unmount_compat22(self.mountpoint.as_ptr());
66-
return Ok(());
64+
{
65+
// Not libfuse's fuse_unmount_compat22: that would swallow
66+
// fusermount failures (leaving a still-running session that
67+
// callers then believe has been unmounted) and stat the
68+
// mountpoint through the filesystem via realpath
69+
return crate::mnt::fusermount_unmount("fusermount", &self.mountpoint);
6770
}
6871
}
6972
return Err(err.into());

src/mnt/fuse2_sys.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,4 @@ unsafe extern "C" {
2323
// Therefore, the minimum version requirement for *_compat25 functions is libfuse-2.6.0.
2424

2525
pub(crate) fn fuse_mount_compat25(mountpoint: *const c_char, args: *const fuse_args) -> c_int;
26-
#[cfg(not(any(
27-
target_os = "macos",
28-
target_os = "freebsd",
29-
target_os = "dragonfly",
30-
target_os = "openbsd",
31-
target_os = "netbsd"
32-
)))]
33-
pub(crate) fn fuse_unmount_compat22(mountpoint: *const c_char);
3426
}

src/mnt/fuse_pure.rs

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ impl MountImpl {
8484
if err == nix::errno::Errno::EPERM {
8585
// Linux always returns EPERM for non-root users. We have to let the
8686
// library go through the setuid-root "fusermount -u" to unmount.
87-
fuse_unmount_pure(&self.mountpoint);
87+
fuse_unmount_pure(&self.mountpoint)?;
8888
return Ok(());
8989
} else {
9090
return Err(err.into());
@@ -120,33 +120,20 @@ fn fuse_mount_pure(
120120
fuse_mount_fusermount(mountpoint, options, acl)
121121
}
122122

123-
fn fuse_unmount_pure(mountpoint: &CStr) {
123+
fn fuse_unmount_pure(mountpoint: &CStr) -> io::Result<()> {
124124
#[cfg(target_os = "linux")]
125125
{
126126
if nix::mount::umount2(mountpoint, nix::mount::MntFlags::MNT_DETACH).is_ok() {
127-
return;
127+
return Ok(());
128128
}
129129
}
130130
#[cfg(target_os = "macos")]
131131
{
132132
if nix::mount::unmount(mountpoint, nix::mount::MntFlags::MNT_FORCE).is_ok() {
133-
return;
133+
return Ok(());
134134
}
135135
}
136-
137-
let mut builder = Command::new(detect_fusermount_bin());
138-
builder.stdout(Stdio::piped()).stderr(Stdio::piped());
139-
builder
140-
.arg("-u")
141-
.arg("-q")
142-
.arg("-z")
143-
.arg("--")
144-
.arg(OsStr::new(&mountpoint.to_string_lossy().into_owned()));
145-
146-
if let Ok(output) = builder.output() {
147-
debug!("fusermount: {}", String::from_utf8_lossy(&output.stdout));
148-
debug!("fusermount: {}", String::from_utf8_lossy(&output.stderr));
149-
}
136+
crate::mnt::fusermount_unmount(&detect_fusermount_bin(), mountpoint)
150137
}
151138

152139
fn detect_fusermount_bin() -> String {

src/mnt/mod.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,75 @@ fn libc_umount(mnt: &CStr) -> nix::Result<()> {
212212
}
213213
}
214214

215+
/// Waits (bounded) for the FUSE kernel connection to end after an unmount was
216+
/// requested. Returns false if the connection is still alive when the timeout
217+
/// expires, e.g. because a lazily unmounted filesystem is still in use, or an
218+
/// unmount helper failed in a way that cannot be observed directly.
219+
#[cfg(not(target_os = "macos"))]
220+
pub(crate) fn connection_ended(fuse_device: &DevFuse, timeout: std::time::Duration) -> bool {
221+
let deadline = std::time::Instant::now() + timeout;
222+
while is_mounted(fuse_device) {
223+
if std::time::Instant::now() >= deadline {
224+
return false;
225+
}
226+
std::thread::sleep(std::time::Duration::from_millis(10));
227+
}
228+
true
229+
}
230+
231+
/// The FUSE device poll semantics are untested on macOS, so assume the unmount
232+
/// took effect there.
233+
#[cfg(target_os = "macos")]
234+
pub(crate) fn connection_ended(_fuse_device: &DevFuse, _timeout: std::time::Duration) -> bool {
235+
true
236+
}
237+
238+
/// Lazily unmount via the given fusermount binary. Helper failures are reported,
239+
/// so that callers do not mistake a still-mounted filesystem for an unmounted
240+
/// one - BackgroundSession would otherwise wait forever for the session to end.
241+
#[cfg(any(
242+
all(test, target_os = "linux"),
243+
fuser_mount_impl = "pure-rust",
244+
all(
245+
fuser_mount_impl = "libfuse2",
246+
not(any(
247+
target_os = "macos",
248+
target_os = "freebsd",
249+
target_os = "dragonfly",
250+
target_os = "openbsd",
251+
target_os = "netbsd"
252+
))
253+
)
254+
))]
255+
pub(crate) fn fusermount_unmount(fusermount_bin: &str, mountpoint: &CStr) -> io::Result<()> {
256+
use std::os::unix::ffi::OsStrExt;
257+
258+
use log::debug;
259+
260+
let mut builder = std::process::Command::new(fusermount_bin);
261+
builder
262+
.stdout(std::process::Stdio::piped())
263+
.stderr(std::process::Stdio::piped());
264+
builder
265+
.arg("-u")
266+
.arg("-q")
267+
.arg("-z")
268+
.arg("--")
269+
.arg(std::ffi::OsStr::from_bytes(mountpoint.to_bytes()));
270+
271+
let output = builder.output()?;
272+
debug!("fusermount: {}", String::from_utf8_lossy(&output.stdout));
273+
debug!("fusermount: {}", String::from_utf8_lossy(&output.stderr));
274+
if !output.status.success() {
275+
return Err(io::Error::other(format!(
276+
"fusermount failed to unmount ({}): {}",
277+
output.status,
278+
String::from_utf8_lossy(&output.stderr).trim()
279+
)));
280+
}
281+
Ok(())
282+
}
283+
215284
/// Warning: This will return true if the filesystem has been detached (lazy unmounted), but not
216285
/// yet destroyed by the kernel.
217286
#[cfg(not(target_os = "macos"))]
@@ -276,6 +345,23 @@ mod test {
276345
);
277346
}
278347

348+
/// A fusermount helper that cannot run or exits nonzero must report an error:
349+
/// the filesystem is then still mounted, and treating that as success would
350+
/// make teardown wait forever for a session that never ends.
351+
#[test]
352+
#[cfg(target_os = "linux")]
353+
fn fusermount_unmount_reports_failure() {
354+
use std::ffi::CString;
355+
356+
let mountpoint = CString::new("/nonexistent").unwrap();
357+
// Helper exits nonzero (/bin/false ignores its arguments)
358+
assert!(fusermount_unmount("/bin/false", &mountpoint).is_err());
359+
// Helper cannot be executed at all
360+
assert!(fusermount_unmount("/nonexistent/fusermount", &mountpoint).is_err());
361+
// Helper reports success
362+
assert!(fusermount_unmount("/bin/true", &mountpoint).is_ok());
363+
}
364+
279365
#[cfg(not(target_os = "macos"))]
280366
fn cmd_mount() -> String {
281367
std::str::from_utf8(

src/session.rs

Lines changed: 114 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -227,17 +227,21 @@ impl<FS: Filesystem> Session<FS> {
227227
}
228228

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

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

596+
/// How long teardown waits for the kernel connection to end after a successful
597+
/// unmount request, before giving up on joining the session thread. The
598+
/// connection can end asynchronously (auto_unmount, kernel teardown), but a few
599+
/// seconds only pass without it ending when the session cannot be waited for,
600+
/// e.g. a lazily unmounted filesystem still in use
601+
const UNMOUNT_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
602+
578603
impl BackgroundSession {
579-
/// Unmount the filesystem and join the background thread.
604+
/// Unmount the filesystem and join the background thread, returning the
605+
/// session result. `Filesystem::destroy` has run when this returns.
580606
pub fn umount_and_join(mut self) -> io::Result<()> {
581607
if let Some(mount) = self.mount.take() {
582-
mount.umount()?;
608+
if let Err(err) = mount.umount() {
609+
// The filesystem is still mounted and the session still running,
610+
// so joining would block indefinitely; leave the thread detached,
611+
// as before
612+
self.guard.take();
613+
return Err(err);
614+
}
583615
}
584-
self.join()
616+
self.join_impl()
585617
}
586618

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

592-
/// Join the filesystem thread.
593-
pub fn join(self) -> io::Result<()> {
594-
self.guard
624+
/// Join the filesystem thread without unmounting first: blocks until
625+
/// something else ends the session, e.g. an external unmount.
626+
pub fn join(mut self) -> io::Result<()> {
627+
self.join_impl()
628+
}
629+
630+
fn join_impl(&mut self) -> io::Result<()> {
631+
let Some(guard) = self.guard.take() else {
632+
return Ok(());
633+
};
634+
guard
595635
.join()
596636
.map_err(|_panic: Box<dyn std::any::Any + Send>| {
597637
io::Error::new(
@@ -602,6 +642,36 @@ impl BackgroundSession {
602642
}
603643
}
604644

645+
impl Drop for BackgroundSession {
646+
fn drop(&mut self) {
647+
// Unmount and wait for the session to end, so that Filesystem::destroy
648+
// has run by the time drop returns (issues #239 and #411). Without the
649+
// join, the process could exit before the detached session thread got
650+
// around to calling destroy
651+
let Some(mount) = self.mount.take() else {
652+
// No mount to remove (Session::from_fd): nothing here can end the
653+
// session, so joining could block forever; leave the thread detached
654+
return;
655+
};
656+
if let Err(err) = mount.umount() {
657+
// Still mounted, so the session is still running and joining would
658+
// block indefinitely
659+
warn!("Failed to unmount filesystem during drop: {err}");
660+
return;
661+
}
662+
// The connection can end asynchronously (e.g. auto_unmount), and some
663+
// unmount helpers cannot report failures. Wait boundedly, and if the
664+
// session lives on, detach rather than block indefinitely
665+
if !crate::mnt::connection_ended(&self.fuse_device, UNMOUNT_WAIT) {
666+
warn!("FUSE connection still alive after unmount; detaching session thread");
667+
return;
668+
}
669+
if let Err(err) = self.join_impl() {
670+
warn!("Session ended with an error during drop: {err}");
671+
}
672+
}
673+
}
674+
605675
// The abort test uses fusectl, which only exists on Linux; on other targets the
606676
// whole module would be dead code
607677
#[cfg(all(test, target_os = "linux"))]
@@ -675,6 +745,40 @@ mod test {
675745
ManuallyDrop::into_inner(tmp);
676746
}
677747

748+
/// Dropping a BackgroundSession must unmount the filesystem and wait for the
749+
/// session to end, so that Filesystem::destroy has run when drop returns.
750+
#[test]
751+
fn drop_waits_for_destroy() {
752+
struct DestroyFs {
753+
destroyed: Arc<std::sync::atomic::AtomicBool>,
754+
}
755+
impl Filesystem for DestroyFs {
756+
fn destroy(&mut self) {
757+
// Give drop a chance to return early: without the join, drop
758+
// consistently wins this race and the assertion below fails
759+
std::thread::sleep(std::time::Duration::from_millis(200));
760+
self.destroyed
761+
.store(true, std::sync::atomic::Ordering::SeqCst);
762+
}
763+
}
764+
765+
let tmp = ManuallyDrop::new(tempfile::tempdir().unwrap());
766+
let destroyed = Arc::new(std::sync::atomic::AtomicBool::new(false));
767+
let fs = DestroyFs {
768+
destroyed: destroyed.clone(),
769+
};
770+
let bg = Session::new(fs, tmp.path(), &Config::default())
771+
.unwrap()
772+
.spawn()
773+
.unwrap();
774+
drop(bg);
775+
assert!(
776+
destroyed.load(std::sync::atomic::Ordering::SeqCst),
777+
"destroy() must have been called by the time drop returns"
778+
);
779+
ManuallyDrop::into_inner(tmp);
780+
}
781+
678782
/// The fusectl abort file for the FUSE mount at `mountpoint`: the connection
679783
/// directory is named after the mount's anonymous device number.
680784
fn fusectl_abort_path(mountpoint: &Path) -> Option<std::path::PathBuf> {

0 commit comments

Comments
 (0)