Skip to content

Commit 154d2df

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 154d2df

3 files changed

Lines changed: 141 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
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 mount backend now reports `fusermount` unmount failures instead of silently
10+
ignoring them, so a failed unmount cannot make `BackgroundSession` teardown wait forever
411
* Treat `ECONNABORTED` from the FUSE device as a clean session end (#212): with
512
`FUSE_ABORT_ERROR` negotiated, aborting the connection made `Session::run()` and
613
`BackgroundSession::umount_and_join()` return an error instead of ending normally

src/mnt/fuse_pure.rs

Lines changed: 40 additions & 8 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,21 +120,27 @@ 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+
fusermount_unmount(&detect_fusermount_bin(), mountpoint)
137+
}
136138

137-
let mut builder = Command::new(detect_fusermount_bin());
139+
/// Lazily unmount via the given fusermount binary. Helper failures are reported,
140+
/// so that callers do not mistake a still-mounted filesystem for an unmounted
141+
/// one - BackgroundSession would otherwise wait forever for the session to end.
142+
fn fusermount_unmount(fusermount_bin: &str, mountpoint: &CStr) -> io::Result<()> {
143+
let mut builder = Command::new(fusermount_bin);
138144
builder.stdout(Stdio::piped()).stderr(Stdio::piped());
139145
builder
140146
.arg("-u")
@@ -143,10 +149,17 @@ fn fuse_unmount_pure(mountpoint: &CStr) {
143149
.arg("--")
144150
.arg(OsStr::new(&mountpoint.to_string_lossy().into_owned()));
145151

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));
152+
let output = builder.output()?;
153+
debug!("fusermount: {}", String::from_utf8_lossy(&output.stdout));
154+
debug!("fusermount: {}", String::from_utf8_lossy(&output.stderr));
155+
if !output.status.success() {
156+
return Err(io::Error::other(format!(
157+
"fusermount failed to unmount ({}): {}",
158+
output.status,
159+
String::from_utf8_lossy(&output.stderr).trim()
160+
)));
149161
}
162+
Ok(())
150163
}
151164

152165
fn detect_fusermount_bin() -> String {
@@ -499,3 +512,22 @@ fn fuse_mount_sys(
499512
) -> Result<Option<DevFuse>, Error> {
500513
Ok(None)
501514
}
515+
516+
#[cfg(all(test, target_os = "linux"))]
517+
mod test {
518+
use std::ffi::CString;
519+
520+
/// A fusermount helper that cannot run or exits nonzero must report an error:
521+
/// the filesystem is then still mounted, and treating that as success would
522+
/// make teardown wait forever for a session that never ends.
523+
#[test]
524+
fn fusermount_unmount_reports_failure() {
525+
let mountpoint = CString::new("/nonexistent").unwrap();
526+
// Helper exits nonzero (/bin/false ignores its arguments)
527+
assert!(super::fusermount_unmount("/bin/false", &mountpoint).is_err());
528+
// Helper cannot be executed at all
529+
assert!(super::fusermount_unmount("/nonexistent/fusermount", &mountpoint).is_err());
530+
// Helper reports success
531+
assert!(super::fusermount_unmount("/bin/true", &mountpoint).is_ok());
532+
}
533+
}

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)