Skip to content

Commit e0e53d7

Browse files
claudecberner
authored andcommitted
Treat ECONNABORTED as a clean session end
With FUSE_ABORT_ERROR negotiated, the kernel fails reads on the FUSE device with ECONNABORTED instead of ENODEV once the connection has been aborted. The session event loop only recognized ENODEV, so an abort made Session::run() and BackgroundSession::umount_and_join() return an error for what is a normal administrative teardown (#212). Handle both errnos as the end of the session, and map both to the same NotConnected error when they interrupt the handshake. Fixes #212 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TfPeUUeQKYjo26Y9mQG9sA
1 parent 38f4e1e commit e0e53d7

2 files changed

Lines changed: 109 additions & 2 deletions

File tree

CHANGELOG.md

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

33
## Unreleased
4+
* Treat `ECONNABORTED` from the FUSE device as a clean session end (#212): with
5+
`FUSE_ABORT_ERROR` negotiated, aborting the connection made `Session::run()` and
6+
`BackgroundSession::umount_and_join()` return an error instead of ending normally
7+
the way an unmount (`ENODEV`) does
48
* `KernelConfig::set_max_write()` now rejects values below 4096 with the nearest valid value,
59
per its documented contract (#327). The kernel clamps `max_write` to at least 4096, so a
610
smaller value was accepted but ineffective: write requests of up to 4096 bytes still arrived

src/session.rs

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ impl<FS: Filesystem> Session<FS> {
345345
// Read the init request from the kernel
346346
let size = match self.ch.receive_retrying(buf) {
347347
Ok(size) => size,
348-
Err(nix::errno::Errno::ENODEV) => {
348+
Err(nix::errno::Errno::ENODEV | nix::errno::Errno::ECONNABORTED) => {
349349
return Err(io::Error::new(
350350
io::ErrorKind::NotConnected,
351351
"FUSE device disconnected during handshake",
@@ -553,7 +553,11 @@ impl<FS: Filesystem> SessionEventLoop<FS> {
553553
));
554554
}
555555
},
556-
Err(nix::errno::Errno::ENODEV) => return Ok(()),
556+
// The kernel returns ENODEV when the filesystem was unmounted, or
557+
// ECONNABORTED when the connection was aborted with FUSE_ABORT_ERROR
558+
// negotiated. Either way the connection is gone: a normal end of the
559+
// session, not an operation failure (issue #212)
560+
Err(nix::errno::Errno::ENODEV | nix::errno::Errno::ECONNABORTED) => return Ok(()),
557561
Err(err) => return Err(err.into()),
558562
}
559563
}
@@ -597,3 +601,102 @@ impl BackgroundSession {
597601
})?
598602
}
599603
}
604+
605+
// The abort test uses fusectl, which only exists on Linux; on other targets the
606+
// whole module would be dead code
607+
#[cfg(all(test, target_os = "linux"))]
608+
mod test {
609+
use std::io::Write;
610+
use std::mem::ManuallyDrop;
611+
612+
use super::*;
613+
use crate::Config;
614+
use crate::InitFlags;
615+
use crate::KernelConfig;
616+
use crate::Request;
617+
618+
/// A filesystem that requests FUSE_ABORT_ERROR during init, so that after an
619+
/// abort the FUSE device fails reads with ECONNABORTED instead of ENODEV.
620+
struct AbortErrorFs;
621+
622+
impl Filesystem for AbortErrorFs {
623+
fn init(&mut self, _req: &Request, config: &mut KernelConfig) -> io::Result<()> {
624+
// Ignore the error: on kernels without FUSE_ABORT_ERROR an abort
625+
// yields ENODEV, which ends the session cleanly anyway
626+
let _ = config.add_capabilities(InitFlags::FUSE_ABORT_ERROR);
627+
Ok(())
628+
}
629+
}
630+
631+
/// Aborting the connection (via fusectl) with FUSE_ABORT_ERROR negotiated makes
632+
/// the FUSE device return ECONNABORTED. The session loop must treat that as a
633+
/// normal end of the session, so that umount_and_join() reports success rather
634+
/// than an error for an administratively aborted filesystem (issue #212).
635+
#[test]
636+
fn session_ends_cleanly_after_abort() {
637+
// Leak the directory on failure: it may still be a (dead) mountpoint
638+
let tmp = ManuallyDrop::new(tempfile::tempdir().unwrap());
639+
// The mount table lists the canonical path; resolve while it is a plain dir
640+
let mountpoint = tmp.path().canonicalize().unwrap();
641+
642+
let session = Session::new(AbortErrorFs, &mountpoint, &Config::default()).unwrap();
643+
let bg = session.spawn().unwrap();
644+
645+
// Wait until the handshake completed: the kernel forwards filesystem
646+
// requests only after the init reply was processed, so a completed
647+
// operation (the errno does not matter) proves the loop is past it
648+
let _ = std::fs::metadata(&mountpoint);
649+
650+
let Some(abort_path) = fusectl_abort_path(&mountpoint) else {
651+
eprintln!("skipping session_ends_cleanly_after_abort: fusectl not available");
652+
bg.umount_and_join().unwrap();
653+
ManuallyDrop::into_inner(tmp);
654+
return;
655+
};
656+
std::fs::OpenOptions::new()
657+
.write(true)
658+
.open(abort_path)
659+
.unwrap()
660+
.write_all(b"1")
661+
.unwrap();
662+
663+
bg.umount_and_join()
664+
.expect("session must end cleanly after the connection was aborted");
665+
666+
// Teardown intentionally leaves the dead mount in the mount table (the
667+
// same as libfuse); detach it so the tempdir can be removed
668+
let _ = nix::mount::umount2(&mountpoint, nix::mount::MntFlags::MNT_DETACH);
669+
for fusermount in ["fusermount3", "fusermount"] {
670+
let _ = std::process::Command::new(fusermount)
671+
.args(["-u", "-q", "-z", "--"])
672+
.arg(&mountpoint)
673+
.status();
674+
}
675+
ManuallyDrop::into_inner(tmp);
676+
}
677+
678+
/// The fusectl abort file for the FUSE mount at `mountpoint`: the connection
679+
/// directory is named after the mount's anonymous device number.
680+
fn fusectl_abort_path(mountpoint: &Path) -> Option<std::path::PathBuf> {
681+
let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
682+
let mut device = None;
683+
for line in mountinfo.lines() {
684+
let mut fields = line.split(' ');
685+
let (Some(dev), Some(path)) = (fields.nth(2), fields.nth(1)) else {
686+
continue;
687+
};
688+
// mountinfo octal-escapes special characters; the tempdir path has none.
689+
// Later entries are mounted on top of earlier ones, so the last match wins
690+
if Path::new(path) == mountpoint {
691+
let (major, minor) = dev.split_once(':')?;
692+
let (major, minor): (u64, u64) = (major.parse().ok()?, minor.parse().ok()?);
693+
// fusectl names the directory with the raw kernel-internal
694+
// device number, (major << 20) | minor: fuse_ctl_add_conn()
695+
// prints fc->dev (= sb->s_dev) without re-encoding it
696+
device = Some((major << 20) | minor);
697+
}
698+
}
699+
let path = std::path::PathBuf::from(format!("/sys/fs/fuse/connections/{}/abort", device?));
700+
path.exists().then_some(path)
701+
}
702+
}

0 commit comments

Comments
 (0)