You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/10/26 09:06:35 UTC

(opendal-yinyang) branch xuanwo/linux-fuse-acceptance updated: test: cover busy SIGTERM grace and open-fd teardown

This is an automated email from the ASF dual-hosted git repository.

Xuanwo pushed a commit to branch xuanwo/linux-fuse-acceptance
in repository https://gitbox.apache.org/repos/asf/opendal-yinyang.git


The following commit(s) were added to refs/heads/xuanwo/linux-fuse-acceptance by this push:
     new 90e873c  test: cover busy SIGTERM grace and open-fd teardown
90e873c is described below

commit 90e873c913d98aa3de211a5b37e0970910d5674d
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Mon Oct 26 17:06:28 2026 +0800

    test: cover busy SIGTERM grace and open-fd teardown
    
    Parameterize pending-fsync interruption by SIGKILL or SIGTERM, require forced non-zero exit within 10s with a held descriptor or frozen request, verify fd I/O errors and lease release, and check same-mount fresh reopens after external changes.
    
    Co-authored-by: Chojan Shang <ps...@apache.org>
---
 docs/linux-fuse-acceptance.md  |   5 +-
 tests/linux_fuse/acceptance.rs | 132 +++++++++++++++++++++++++++++++++++++++--
 tests/linux_fuse/harness.rs    |  13 ++++
 3 files changed, 143 insertions(+), 7 deletions(-)

diff --git a/docs/linux-fuse-acceptance.md b/docs/linux-fuse-acceptance.md
index f4150b4..2f7ce25 100644
--- a/docs/linux-fuse-acceptance.md
+++ b/docs/linux-fuse-acceptance.md
@@ -96,7 +96,10 @@ cargo x test-linux-fuse --read-only
   after the descriptor is released.
 - Unmount lifecycle: after a successful external `fusermount3 -u`, `yy` exits
   on its own, removes the mount entry, and releases the staging lease. A
-  separate SIGTERM scenario verifies the same process and lease lifecycle.
+  separate clean SIGTERM scenario verifies the same process and lease
+  lifecycle. Busy SIGTERM with an open descriptor or a pending fsync must exit
+  through the forced non-zero grace path within 10s, leave the held descriptor
+  with I/O errors, release the lease, and allow the same staging to remount.
 
 The assertions follow the core specs. They do not add POSIX guarantees that the
 transactional filesystem does not provide; for example symlinks are rejected,
diff --git a/tests/linux_fuse/acceptance.rs b/tests/linux_fuse/acceptance.rs
index 6f0a8df..7e03dc5 100644
--- a/tests/linux_fuse/acceptance.rs
+++ b/tests/linux_fuse/acceptance.rs
@@ -18,7 +18,7 @@
 //! Real /dev/fuse acceptance scenarios for the YinYang mount adapter.
 
 use crate::harness::{
-    Fixture, Frontend, Publication, assert_errno, run_external, sync_all_with_deadline,
+    Fixture, Frontend, Mounted, Publication, assert_errno, run_external, sync_all_with_deadline,
 };
 use nix::unistd::truncate;
 use std::fs::{self, File, OpenOptions};
@@ -36,8 +36,16 @@ const EPERM: i32 = 1;
 const EOPNOTSUPP: i32 = 95;
 const EROFS: i32 = 30;
 const EINVAL: i32 = 22;
+const ENOTCONN: i32 = 107;
+const EIO: i32 = 5;
 const ATTR_TTL_WAIT: Duration = Duration::from_millis(1500);
 
+#[derive(Clone, Copy)]
+enum Interrupt {
+    Kill,
+    Terminate,
+}
+
 #[test]
 #[ignore = "requires /dev/fuse and isolated S3; run with cargo x test-linux-fuse"]
 fn fuse_read_only_both_publications() {
@@ -718,6 +726,19 @@ fn external_changes(fixture: &Fixture) {
         "pread past the old EOF must not leak bytes from the externally grown file"
     );
     drop(old);
+    let mut fresh = File::open(mounted.mountpoint().join("external.txt"))
+        .expect("reopen externally grown file on the live mount");
+    let mut fresh_bytes = Vec::new();
+    fresh
+        .read_to_end(&mut fresh_bytes)
+        .expect("read externally grown file on the live mount");
+    assert_eq!(fresh_bytes, b"BBBBBBBB");
+    assert_eq!(
+        fresh.metadata().expect("fstat fresh grow").len(),
+        8,
+        "fresh open must materialize the external grow length"
+    );
+    drop(fresh);
 
     let dirty_path = mounted.mountpoint().join("dirty.txt");
     let mut dirty = OpenOptions::new()
@@ -783,6 +804,19 @@ fn external_changes(fixture: &Fixture) {
     );
     assert_eq!(&tail, b"89");
     drop(old_shrink);
+    let mut fresh_shrink = File::open(reopen.mountpoint().join("shrink.txt"))
+        .expect("reopen externally shrunk file on the live mount");
+    let mut fresh_shrink_bytes = Vec::new();
+    fresh_shrink
+        .read_to_end(&mut fresh_shrink_bytes)
+        .expect("read externally shrunk file on the live mount");
+    assert_eq!(fresh_shrink_bytes, b"0123");
+    assert_eq!(
+        fresh_shrink.metadata().expect("fstat fresh shrink").len(),
+        4,
+        "fresh open must materialize the external shrink length"
+    );
+    drop(fresh_shrink);
     reopen.unmount();
 
     let mountpoint = fixture
@@ -973,8 +1007,30 @@ fn fuse_daemon_crash_interrupts_pending_fsync_service() {
     let config = fixture.volume_config("stage-interrupted", Frontend::Fuse, false);
     let mountpoint = fixture.temp.path().join("mnt-interrupted");
     fs::create_dir(&mountpoint).expect("create mountpoint");
-    let mut mounted = fixture.mount(&config, &mountpoint);
+    let mounted = fixture.mount(&config, &mountpoint);
+    interrupt_pending_fsync(&fixture, &library, &config, mounted, Interrupt::Kill);
+}
+
+#[test]
+#[ignore = "requires /dev/fuse and isolated S3; run with cargo x test-linux-fuse"]
+fn fuse_sigterm_pending_fsync_service_exits_after_grace() {
+    let fixture = Fixture::new(Publication::Service);
+    let library = fixture.volume_config("lib-sigterm-fsync", Frontend::Library, false);
+    fixture.library_seed(&library, "interrupted.txt", b"seed");
+    let config = fixture.volume_config("stage-sigterm-fsync", Frontend::Fuse, false);
+    let mountpoint = fixture.temp.path().join("mnt-sigterm-fsync");
+    fs::create_dir(&mountpoint).expect("create mountpoint");
+    let mounted = fixture.mount(&config, &mountpoint);
+    interrupt_pending_fsync(&fixture, &library, &config, mounted, Interrupt::Terminate);
+}
 
+fn interrupt_pending_fsync(
+    fixture: &Fixture,
+    library: &Path,
+    config: &Path,
+    mut mounted: Mounted,
+    interrupt: Interrupt,
+) {
     let mut dirty = OpenOptions::new()
         .read(true)
         .write(true)
@@ -999,16 +1055,25 @@ fn fuse_daemon_crash_interrupts_pending_fsync_service() {
         .expect("read staged bytes while frozen");
     assert_eq!(frozen_bytes, b"interrupted-local");
     drop(frozen_reader);
-    mounted.kill();
+    match interrupt {
+        Interrupt::Kill => mounted.kill(),
+        Interrupt::Terminate => {
+            let status = mounted.terminate_busy();
+            assert!(
+                !status.success(),
+                "SIGTERM with a pending fsync must force a non-zero exit after grace"
+            );
+        }
+    }
     let outcome = receiver
         .recv_timeout(Duration::from_secs(20))
         .unwrap_or_else(|error| {
-            panic!("fsync did not return after the mount daemon was killed: {error}")
+            panic!("fsync did not return after the mount daemon stopped: {error}")
         });
     caller.join().expect("fsync caller panicked");
     assert!(
         outcome.is_err(),
-        "an fsync interrupted by killing the mount daemon must fail, not report remote durability"
+        "an fsync interrupted by stopping the mount daemon must fail, not report remote durability"
     );
     drop(dirty);
 
@@ -1018,7 +1083,10 @@ fn fuse_daemon_crash_interrupts_pending_fsync_service() {
         b"seed",
         "the interrupted publication must not have reached the authority"
     );
-    let mountpoint = fixture.temp.path().join("mnt-interrupted-reopen");
+    let mountpoint = fixture.temp.path().join(format!(
+        "mnt-interrupted-reopen-{}",
+        fixture.publication().as_str()
+    ));
     fs::create_dir(&mountpoint).expect("create reopen mountpoint");
     let mut reopened = fixture.mount(&config, &mountpoint);
     assert_eq!(
@@ -1100,6 +1168,58 @@ fn fuse_delayed_completion_survives_client_interruption_service() {
     mounted.unmount();
 }
 
+#[test]
+#[ignore = "requires /dev/fuse and isolated S3; run with cargo x test-linux-fuse"]
+fn fuse_sigterm_with_open_fd_exits_and_releases_lease_both_publications() {
+    for publication in [Publication::Object, Publication::Service] {
+        let fixture = Fixture::new(publication);
+        let suffix = fixture.publication().as_str();
+        let library = fixture.volume_config(
+            &format!("lib-sigterm-open-{suffix}"),
+            Frontend::Library,
+            false,
+        );
+        fixture.library_seed(&library, "sigterm-open.txt", b"seed");
+        let config = fixture.volume_config(
+            &format!("stage-sigterm-open-{suffix}"),
+            Frontend::Fuse,
+            false,
+        );
+        let mountpoint = fixture
+            .temp
+            .path()
+            .join(format!("mnt-sigterm-open-{suffix}"));
+        fs::create_dir(&mountpoint).expect("create mountpoint");
+        let mut mounted = fixture.mount(&config, &mountpoint);
+        let path = mounted.mountpoint().join("sigterm-open.txt");
+        let mut held = File::open(&path).expect("hold a descriptor across SIGTERM");
+        let status = mounted.terminate_busy();
+        assert!(
+            !status.success(),
+            "SIGTERM with an open descriptor must exit through the forced non-zero path"
+        );
+        let mut after = Vec::new();
+        let error = held
+            .read_to_end(&mut after)
+            .expect_err("a held descriptor must report an I/O error after daemon exit");
+        assert_errno(error, &[ENOTCONN, EIO], "read after busy SIGTERM");
+        drop(held);
+
+        let mountpoint = fixture
+            .temp
+            .path()
+            .join(format!("mnt-sigterm-open-reopen-{suffix}"));
+        fs::create_dir(&mountpoint).expect("create reopen mountpoint");
+        let mut reopened = fixture.mount(&config, &mountpoint);
+        assert_eq!(
+            fs::read(reopened.mountpoint().join("sigterm-open.txt"))
+                .expect("read after SIGTERM remount"),
+            b"seed"
+        );
+        reopened.unmount();
+    }
+}
+
 #[test]
 #[ignore = "requires /dev/fuse and isolated S3; run with cargo x test-linux-fuse"]
 fn fuse_busy_unmount_object() {
diff --git a/tests/linux_fuse/harness.rs b/tests/linux_fuse/harness.rs
index 8c5d6d2..0a5a052 100644
--- a/tests/linux_fuse/harness.rs
+++ b/tests/linux_fuse/harness.rs
@@ -718,6 +718,19 @@ impl Mounted {
         self.assert_staging_lease_released();
     }
 
+    /// SIGTERM with open descriptors or a pending fsync must exit under the
+    /// forced non-zero grace path and release the staging lease. No success
+    /// assertion is made here; the caller requires the busy exit contract.
+    pub fn terminate_busy(&mut self) -> ExitStatus {
+        let pid = self.child.id().to_string();
+        let output = run_external("kill", &["-TERM", &pid], Duration::from_secs(30));
+        output.success_or("send SIGTERM to yy mount");
+        let status = self.wait_exit(Duration::from_secs(10));
+        self.unmount_force();
+        self.assert_staging_lease_released();
+        status
+    }
+
     pub fn unmount(&mut self) {
         let mountpoint = self.mountpoint.to_str().expect("UTF-8 mountpoint");
         let output = run_external("fusermount3", &["-u", mountpoint], Duration::from_secs(30));