Skip to content

Commit a2ec561

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 a2ec561

6 files changed

Lines changed: 170 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. Sessions created via `Session::from_fd` have
7+
no mount to remove and are still left detached on drop; use `join()` after ending them.
8+
The `guard` field of `BackgroundSession` is now private - use `join()`/`umount_and_join()`
9+
* The pure-rust and `libfuse2` mount backends now report `fusermount` unmount failures
10+
instead of silently ignoring them, so a failed unmount cannot make `BackgroundSession`
11+
teardown wait forever. The `libfuse2` backend no longer uses `fuse_unmount_compat22`,
12+
which also used to stat the mountpoint through the filesystem
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/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: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,43 @@ fn libc_umount(mnt: &CStr) -> nix::Result<()> {
212212
}
213213
}
214214

215+
/// Lazily unmount via the given fusermount binary. Helper failures are reported,
216+
/// so that callers do not mistake a still-mounted filesystem for an unmounted
217+
/// one - BackgroundSession would otherwise wait forever for the session to end.
218+
#[cfg(any(
219+
all(test, target_os = "linux"),
220+
fuser_mount_impl = "pure-rust",
221+
fuser_mount_impl = "libfuse2"
222+
))]
223+
pub(crate) fn fusermount_unmount(fusermount_bin: &str, mountpoint: &CStr) -> io::Result<()> {
224+
use std::os::unix::ffi::OsStrExt;
225+
226+
use log::debug;
227+
228+
let mut builder = std::process::Command::new(fusermount_bin);
229+
builder
230+
.stdout(std::process::Stdio::piped())
231+
.stderr(std::process::Stdio::piped());
232+
builder
233+
.arg("-u")
234+
.arg("-q")
235+
.arg("-z")
236+
.arg("--")
237+
.arg(std::ffi::OsStr::from_bytes(mountpoint.to_bytes()));
238+
239+
let output = builder.output()?;
240+
debug!("fusermount: {}", String::from_utf8_lossy(&output.stdout));
241+
debug!("fusermount: {}", String::from_utf8_lossy(&output.stderr));
242+
if !output.status.success() {
243+
return Err(io::Error::other(format!(
244+
"fusermount failed to unmount ({}): {}",
245+
output.status,
246+
String::from_utf8_lossy(&output.stderr).trim()
247+
)));
248+
}
249+
Ok(())
250+
}
251+
215252
/// Warning: This will return true if the filesystem has been detached (lazy unmounted), but not
216253
/// yet destroyed by the kernel.
217254
#[cfg(not(target_os = "macos"))]
@@ -276,6 +313,23 @@ mod test {
276313
);
277314
}
278315

316+
/// A fusermount helper that cannot run or exits nonzero must report an error:
317+
/// the filesystem is then still mounted, and treating that as success would
318+
/// make teardown wait forever for a session that never ends.
319+
#[test]
320+
#[cfg(target_os = "linux")]
321+
fn fusermount_unmount_reports_failure() {
322+
use std::ffi::CString;
323+
324+
let mountpoint = CString::new("/nonexistent").unwrap();
325+
// Helper exits nonzero (/bin/false ignores its arguments)
326+
assert!(fusermount_unmount("/bin/false", &mountpoint).is_err());
327+
// Helper cannot be executed at all
328+
assert!(fusermount_unmount("/nonexistent/fusermount", &mountpoint).is_err());
329+
// Helper reports success
330+
assert!(fusermount_unmount("/bin/true", &mountpoint).is_ok());
331+
}
332+
279333
#[cfg(not(target_os = "macos"))]
280334
fn cmd_mount() -> String {
281335
std::str::from_utf8(

src/session.rs

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,8 @@ 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();
233234
// Take the fuse_session, so that we can unmount it
@@ -236,7 +237,7 @@ impl<FS: Filesystem> Session<FS> {
236237
.name("fuser-bg".to_string())
237238
.spawn(move || self.run())?;
238239
Ok(BackgroundSession {
239-
guard,
240+
guard: Some(guard),
240241
sender,
241242
mount,
242243
})
@@ -565,33 +566,57 @@ impl<FS: Filesystem> SessionEventLoop<FS> {
565566
}
566567

567568
/// The background session data structure
569+
///
570+
/// Dropping this unmounts the filesystem and blocks until the session has ended,
571+
/// which guarantees that `Filesystem::destroy` has run when drop returns. Because
572+
/// of that, it must not be dropped from within a filesystem callback, which runs
573+
/// on the session thread being waited for. For a session created via
574+
/// `Session::from_fd` there is no mount to remove, so dropping cannot end the
575+
/// session and leaves its thread detached; end the session externally and use
576+
/// `join` to wait for it instead. If unmounting fails, the thread is likewise
577+
/// left detached rather than waiting for a session that cannot end.
568578
#[derive(Debug)]
569579
pub struct BackgroundSession {
570-
/// Thread guard of the background session
571-
pub guard: JoinHandle<io::Result<()>>,
580+
/// Thread guard of the background session. None once joined.
581+
guard: Option<JoinHandle<io::Result<()>>>,
572582
/// Object for creating Notifiers for client use
573583
sender: ChannelSender,
574584
/// Ensures the filesystem is unmounted when the session ends
575585
mount: Option<Mount>,
576586
}
577587

578588
impl BackgroundSession {
579-
/// Unmount the filesystem and join the background thread.
589+
/// Unmount the filesystem and join the background thread, returning the
590+
/// session result. `Filesystem::destroy` has run when this returns.
580591
pub fn umount_and_join(mut self) -> io::Result<()> {
581592
if let Some(mount) = self.mount.take() {
582-
mount.umount()?;
593+
if let Err(err) = mount.umount() {
594+
// The filesystem is still mounted and the session still running,
595+
// so joining would block indefinitely; leave the thread detached,
596+
// as before
597+
self.guard.take();
598+
return Err(err);
599+
}
583600
}
584-
self.join()
601+
self.join_impl()
585602
}
586603

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

592-
/// Join the filesystem thread.
593-
pub fn join(self) -> io::Result<()> {
594-
self.guard
609+
/// Join the filesystem thread without unmounting first: blocks until
610+
/// something else ends the session, e.g. an external unmount.
611+
pub fn join(mut self) -> io::Result<()> {
612+
self.join_impl()
613+
}
614+
615+
fn join_impl(&mut self) -> io::Result<()> {
616+
let Some(guard) = self.guard.take() else {
617+
return Ok(());
618+
};
619+
guard
595620
.join()
596621
.map_err(|_panic: Box<dyn std::any::Any + Send>| {
597622
io::Error::new(
@@ -602,6 +627,29 @@ impl BackgroundSession {
602627
}
603628
}
604629

630+
impl Drop for BackgroundSession {
631+
fn drop(&mut self) {
632+
// Unmount and wait for the session to end, so that Filesystem::destroy
633+
// has run by the time drop returns (issues #239 and #411). Without the
634+
// join, the process could exit before the detached session thread got
635+
// around to calling destroy
636+
let Some(mount) = self.mount.take() else {
637+
// No mount to remove (Session::from_fd): nothing here can end the
638+
// session, so joining could block forever; leave the thread detached
639+
return;
640+
};
641+
if let Err(err) = mount.umount() {
642+
// Still mounted, so the session is still running and joining would
643+
// block indefinitely
644+
warn!("Failed to unmount filesystem during drop: {err}");
645+
return;
646+
}
647+
if let Err(err) = self.join_impl() {
648+
warn!("Session ended with an error during drop: {err}");
649+
}
650+
}
651+
}
652+
605653
// The abort test uses fusectl, which only exists on Linux; on other targets the
606654
// whole module would be dead code
607655
#[cfg(all(test, target_os = "linux"))]
@@ -675,6 +723,42 @@ mod test {
675723
ManuallyDrop::into_inner(tmp);
676724
}
677725

726+
/// Dropping a BackgroundSession must unmount the filesystem and wait for the
727+
/// session to end, so that Filesystem::destroy has run when drop returns
728+
/// (issues #239 and #411). Previously the session thread was detached, and
729+
/// destroy raced (or lost to) process exit.
730+
#[test]
731+
fn drop_waits_for_destroy() {
732+
struct DestroyFs {
733+
destroyed: Arc<std::sync::atomic::AtomicBool>,
734+
}
735+
impl Filesystem for DestroyFs {
736+
fn destroy(&mut self) {
737+
// Give drop a chance to return early: without the join, drop
738+
// consistently wins this race and the assertion below fails
739+
std::thread::sleep(std::time::Duration::from_millis(200));
740+
self.destroyed
741+
.store(true, std::sync::atomic::Ordering::SeqCst);
742+
}
743+
}
744+
745+
let tmp = ManuallyDrop::new(tempfile::tempdir().unwrap());
746+
let destroyed = Arc::new(std::sync::atomic::AtomicBool::new(false));
747+
let fs = DestroyFs {
748+
destroyed: destroyed.clone(),
749+
};
750+
let bg = Session::new(fs, tmp.path(), &Config::default())
751+
.unwrap()
752+
.spawn()
753+
.unwrap();
754+
drop(bg);
755+
assert!(
756+
destroyed.load(std::sync::atomic::Ordering::SeqCst),
757+
"destroy() must have been called by the time drop returns"
758+
);
759+
ManuallyDrop::into_inner(tmp);
760+
}
761+
678762
/// The fusectl abort file for the FUSE mount at `mountpoint`: the connection
679763
/// directory is named after the mount's anonymous device number.
680764
fn fusectl_abort_path(mountpoint: &Path) -> Option<std::path::PathBuf> {

0 commit comments

Comments
 (0)