From 4f9c5bdc1d5c35d9243919fa0fa8878512cc6f67 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 10 Oct 2026 21:56:18 +0200 Subject: [PATCH] =?UTF-8?q?fix(mounts):=20bei=20Zeit=C3=BCberschreitung=20?= =?UTF-8?q?ganze=20Prozessgruppe=20von=20mount=20beenden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/netmover/mounting/linux.py | 26 ++++++++++++++++++++++++- tests/test_linux_backend.py | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/netmover/mounting/linux.py b/src/netmover/mounting/linux.py index 8f0c546..832ce6b 100644 --- a/src/netmover/mounting/linux.py +++ b/src/netmover/mounting/linux.py @@ -8,7 +8,9 @@ Optionszeile – die stünde sonst in der Prozessliste. from __future__ import annotations +import contextlib import os +import signal import subprocess import uuid from collections.abc import Callable, Sequence @@ -24,6 +26,28 @@ log = get_logger(__name__) Runner = Callable[..., "subprocess.CompletedProcess[str]"] + +def run_process_group(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + """Wie ``subprocess.run``, aber in eigener Prozessgruppe: Bei Zeitüberschreitung + wird die ganze Gruppe beendet. ``mount`` startet Hilfsprogramme wie + ``mount.nfs``; würde nur ``mount`` beendet, liefe ``mount.nfs`` weiter und + hängte womöglich Minuten später doch noch ein (auf der Test-VM beobachtet).""" + timeout = kwargs.pop("timeout", None) + kwargs.pop("check", None) + capture = kwargs.pop("capture_output", False) + if capture: + kwargs["stdout"] = kwargs["stderr"] = subprocess.PIPE + with subprocess.Popen(argv, start_new_session=True, **kwargs) as proc: + try: + out, err = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + with contextlib.suppress(ProcessLookupError): + os.killpg(proc.pid, signal.SIGKILL) + proc.communicate() + raise + return subprocess.CompletedProcess(argv, proc.returncode, out, err) + + _ENV = {"LC_ALL": "C", "PATH": "/usr/sbin:/usr/bin:/sbin:/bin"} @@ -60,7 +84,7 @@ class LinuxMountBackend(MountBackend): self, run_dir: Path, mountinfo_path: Path = MOUNTINFO, - runner: Runner = subprocess.run, + runner: Runner = run_process_group, ) -> None: self.run_dir = run_dir self.mountinfo_path = mountinfo_path diff --git a/tests/test_linux_backend.py b/tests/test_linux_backend.py index a31e2b9..ffb6821 100644 --- a/tests/test_linux_backend.py +++ b/tests/test_linux_backend.py @@ -115,3 +115,38 @@ def test_is_mounted_reads_mountinfo(tmp_path: Path) -> None: ) def test_explain(fstype: str, msg: str, expect: str) -> None: assert expect in explain_mount_error(fstype, msg) + + +def _alive(pid: int) -> bool: + try: + with open(f"/proc/{pid}/stat") as fh: + return fh.read().split(")")[-1].split()[0] != "Z" + except FileNotFoundError: + return False + + +def test_timeout_kills_whole_process_group(tmp_path: Path) -> None: + """mount startet Helfer (mount.nfs) – die dürfen nach dem Zeitlimit nicht weiterlaufen.""" + from netmover.mounting.linux import run_process_group + + pidfile = tmp_path / "helper.pid" + script = f"sleep 30 & echo $! > {pidfile}; wait" + with pytest.raises(subprocess.TimeoutExpired): + run_process_group(["sh", "-c", script], capture_output=True, text=True, timeout=0.5) + helper = int(pidfile.read_text()) + for _ in range(20): + if not _alive(helper): + break + import time + + time.sleep(0.05) + assert not _alive(helper) + + +def test_process_group_runner_returns_output() -> None: + from netmover.mounting.linux import run_process_group + + r = run_process_group( + ["sh", "-c", "echo out; echo err >&2; exit 3"], capture_output=True, text=True, timeout=5 + ) + assert (r.returncode, r.stdout, r.stderr) == (3, "out\n", "err\n")