From 5541121f4ec8bf5821ff7c86495e4babfd84cad4 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 12 Jun 2026 01:19:04 +0200 Subject: [PATCH] testsuite: sweep stale borg FUSE mounts left by aborted runs When a test run is aborted (Ctrl-C, a timeout, or a crashed xdist worker), the fuse_mount context manager's `finally: umount` does not run, leaving a live FUSE mount under the pytest temp dir. A later run then fails to rm_rf the old temp tree (Resource busy / Device not configured / Read-only file system), which surfaces as noisy pytest warnings. Add pytest_sessionstart/sessionfinish hooks (run once on the xdist controller) that walk the pytest temp tree for leftover "mountpoint" directories and unmount them via borg's own cross-platform umount helper, making runs self-healing across previously aborted sessions. Discovery uses only the stdlib (os.walk + os.path.ismount, with an os.listdir fallback for dead/stale mounts) and the mount is named by the well-known on-disk layout, so there is no OS-specific mount-table parsing or force-unmount handling. Co-Authored-By: Claude Opus 4.8 --- src/borg/conftest.py | 58 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/borg/conftest.py b/src/borg/conftest.py index 557cb66f7..166dc407d 100644 --- a/src/borg/conftest.py +++ b/src/borg/conftest.py @@ -1,5 +1,7 @@ +import getpass import os import shutil +import tempfile import pytest @@ -16,6 +18,62 @@ from borg.testsuite import has_lchflags, has_llfuse, has_pyfuse3, has_mfusepy # from borg.testsuite import are_symlinks_supported, are_hardlinks_supported, is_utime_fully_supported # noqa: E402 from borg.testsuite.archiver import BORG_EXES from borg.testsuite.platform.platform_test import fakeroot_detected # noqa: E402 +from borg.helpers import umount # noqa: E402 + + +def _pytest_tmp_root(): + # Mirror the parent of pytest's basetemp: /pytest-of-. + # Old (already-removed-from-retention) run dirs live here too, which is where + # FUSE mounts orphaned by aborted runs hide. + try: + user = getpass.getuser() + except Exception: + user = "unknown" + return os.path.join(tempfile.gettempdir(), f"pytest-of-{user}") + + +def _looks_mounted(path): + # A live FUSE mount reports as a mount point; a dead/stale one makes the + # directory inaccessible (os.path.ismount swallows that and returns False). + if os.path.ismount(path): + return True + try: + os.listdir(path) + except OSError: + return True + return False + + +def _sweep_stale_fuse_mounts(): + # The fuse test helper always mounts at a directory named "mountpoint" under the + # pytest temp tree and unmounts it in a finally block. If a run is aborted, that + # cleanup is skipped and the mount lingers, so the next run's temp-dir GC (rm_rf) + # trips over it ("Resource busy" etc.). Walk the temp tree, find such leftover + # mountpoints and unmount them with borg's own (cross-platform) umount helper. + root = _pytest_tmp_root() + for dirpath, dirnames, filenames in os.walk(root, onerror=lambda err: None): + if "mountpoint" not in dirnames: + continue + dirnames.remove("mountpoint") # never descend into a (possibly stale) mount + mountpoint = os.path.join(dirpath, "mountpoint") + if _looks_mounted(mountpoint): + try: + umount(mountpoint) + except Exception: # nosec B110 + pass # best-effort: never let cleanup break the test session + + +def pytest_sessionstart(session): + # Run once in the main process (not in each xdist worker), before any temp-dir + # GC, to clear mounts left behind by a previous, aborted session. + if not hasattr(session.config, "workerinput"): + _sweep_stale_fuse_mounts() + + +def pytest_sessionfinish(session, exitstatus): + # Safety net for anything stranded by this session (e.g. a hard interrupt). + if not hasattr(session.config, "workerinput"): + _sweep_stale_fuse_mounts() @pytest.fixture(autouse=True)