Skip to content

Commit ee67787

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 ee67787

7 files changed

Lines changed: 228 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: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -227,17 +227,20 @@ 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 this blocks until the session has ended and
231+
/// `Filesystem::destroy` has run.
231232
pub fn spawn(self) -> io::Result<BackgroundSession> {
232233
let sender = self.ch.sender();
234+
let fuse_device = self.ch.device();
233235
// Take the fuse_session, so that we can unmount it
234236
let mount = std::mem::take(&mut *self.mount.mount.lock());
235237
let guard = thread::Builder::new()
236238
.name("fuser-bg".to_string())
237239
.spawn(move || self.run())?;
238240
Ok(BackgroundSession {
239-
guard,
241+
guard: Some(guard),
240242
sender,
243+
fuse_device,
241244
mount,
242245
})
243246
}
@@ -565,33 +568,69 @@ impl<FS: Filesystem> SessionEventLoop<FS> {
565568
}
566569

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

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

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

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

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

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

0 commit comments

Comments
 (0)