diff --git a/docker/api/main.py b/docker/api/main.py index 5312ed2..52040dc 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -447,6 +447,7 @@ def _mounts_nachsehen() -> None: if vorher is not False: db.add_log("warning", "mounts", f"{name}: antwortet nicht — wird neu verbunden") + begonnen = time.monotonic() try: mount_verwaltung.reparieren( name, eintrag["typ"], eintrag["quelle"], @@ -454,9 +455,16 @@ def _mounts_nachsehen() -> None: eintrag.get("passwort") or "", ) _MOUNT_STAND[name] = True - db.add_log("success", "mounts", f"{name}: neu verbunden") + # Die DAUER mit ins Log: Sie war die entscheidende Spur, als eine + # Wiederanbindung drei Minuten brauchte (ein `stat` auf dem toten + # Mount). Steht sie da, muss man sie nicht erst rekonstruieren. + db.add_log("success", "mounts", + f"{name}: neu verbunden ({time.monotonic() - begonnen:.1f}s)") except Exception as e: - db.add_log("warning", "mounts", f"{name}: Neuverbinden fehlgeschlagen — {e}") + db.add_log( + "warning", "mounts", + f"{name}: Neuverbinden fehlgeschlagen nach " + f"{time.monotonic() - begonnen:.1f}s — {e}") async def _rohdaten_schleife(): diff --git a/docker/api/mounts.py b/docker/api/mounts.py index 81ab596..c8462bc 100644 --- a/docker/api/mounts.py +++ b/docker/api/mounts.py @@ -67,6 +67,37 @@ def ist_erreichbar(name: str) -> bool: return False +def pfad_lage(ziel: str) -> str: + """Existiert dieses Verzeichnis? „da" | „weg" | „unklar" — mit Zeitgrenze. + + ⚠️ Der Grund, warum es das gibt (Befund 26.07.2026, an Zeitstempeln gemessen): + Eine Wiederanbindung brauchte **3 Minuten 15 Sekunden**, und die Zeit ging + nicht in die Mount-Versuche, sondern in die allererste Zeile von `mounten()`: + + os.makedirs(ziel, exist_ok=True) + + `exist_ok` prüft mit `os.path.isdir()`, und ein `stat` auf einen TOTEN + CIFS-Mount blockiert im Kernel bis zum SMB-Timeout. Ausgerechnet der Aufruf, + der nur „lege den Ordner an, falls er fehlt" bedeutet, hing also drei Minuten + — bevor irgendeine der sorgfältig begrenzten Prüfungen überhaupt dran war. + Dritter Fund derselben Sorte an einem Tag: os-Aufruf auf einen Netzpfad ohne + Zeitgrenze. + + Deshalb wird die Lage jetzt mit einem Kind-Prozess erfragt (`timeout 4 ls`), + der sich abbrechen lässt. „unklar" heißt: existiert, antwortet aber nicht — + dann ist ein makedirs weder nötig noch möglich. + """ + try: + ergebnis = subprocess.run( + ["timeout", "4", "ls", "-d", ziel], capture_output=True, timeout=6 + ) + except (OSError, subprocess.TimeoutExpired): + return "unklar" + if ergebnis.returncode == 0: + return "da" + return "unklar" if ergebnis.returncode == 124 else "weg" + + def wirklich_erreichbar(name: str, warten=None) -> bool: """Antwortet die Freigabe auch noch DREI SEKUNDEN später? (zweimal geprüft) @@ -273,12 +304,14 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "", Mount-Fehlern. """ ziel = _mountpoint(name) - # exist_ok fängt „Ordner da" NICHT, wenn ein toter Mount os.path.isdir - # täuschen lässt (FileExistsError, Befund 24.07.) — deshalb tolerant. - try: - os.makedirs(ziel, exist_ok=True) - except FileExistsError: - pass + # Den Ordner NUR anlegen, wenn er wirklich fehlt — und das mit Zeitgrenze + # klären (siehe pfad_lage): `os.makedirs(..., exist_ok=True)` hing auf einem + # toten CIFS-Mount drei Minuten im Kernel, weil `exist_ok` ein `stat` macht. + if pfad_lage(ziel) == "weg": + try: + os.makedirs(ziel, exist_ok=True) + except OSError: + pass # Rennen mit einem anderen Aufruf oder Rechte — mount sagt es # ⚠️ ERREICHBARKEIT VOR ismount — nicht umgekehrt (Befund 26.07.2026). # # Vorher stand hier `if os.path.ismount(ziel): return schreibtest(ziel)`. @@ -387,22 +420,25 @@ def _lazy_umount(ziel: str) -> None: def aushaengen(name: str) -> None: + """Freigabe abhängen und den Mountpunkt aufräumen. + + Ohne `os.path.ismount`/`os.rmdir` als erste Schritte: Beide `stat`en den + Pfad, und auf einem toten CIFS-Mount blockiert das im Kernel (siehe + pfad_lage). Ein `umount` auf einen Pfad ohne Mount kostet dagegen nichts. + """ ziel = _mountpoint(name) - try: - noch_mount = os.path.ismount(ziel) - except OSError: - noch_mount = True # stale - if noch_mount: - ergebnis = subprocess.run( - ["umount", ziel], capture_output=True, text=True, timeout=30 - ) - if ergebnis.returncode != 0: - # Toter/beschäftigter Mount → lazy detach (klappt immer) - _lazy_umount(ziel) - try: - os.rmdir(ziel) - except OSError: - pass # nicht leer oder weg — egal + ergebnis = subprocess.run( + ["umount", ziel], capture_output=True, text=True, timeout=30 + ) + if ergebnis.returncode != 0: + # Toter/beschäftigter Mount → lazy detach (klappt immer) + _lazy_umount(ziel) + # Erst wenn der Pfad wieder antwortet, darf rmdir ihn anfassen. + if pfad_lage(ziel) == "da": + try: + os.rmdir(ziel) + except OSError: + pass # nicht leer oder weg — egal def reparieren(name: str, typ: str, quelle: str, optionen: str = "", @@ -411,13 +447,14 @@ def reparieren(name: str, typ: str, quelle: str, optionen: str = "", Nötig, wenn ein NAS-Mount stale geworden ist (Rebuild, NAS-Schlaf) — das normale mounten() würde am „ist schon Mountpoint" hängenbleiben. + + `os.path.ismount` stand hier vorher als Vorbedingung — auf einem toten + CIFS-Mount blockiert dieses `stat` im Kernel (siehe pfad_lage). Gefragt wird + es gar nicht mehr: `umount -l` auf einen Pfad, an dem nichts hängt, kostet + nichts und meldet nur einen Rückgabewert, den hier niemand braucht. """ ziel = _mountpoint(name) - try: - if os.path.ismount(ziel): - _lazy_umount(ziel) - except OSError: - _lazy_umount(ziel) + _lazy_umount(ziel) return mounten(name, typ, quelle, optionen, username, passwort) diff --git a/docker/api/test_mounts_helpers.py b/docker/api/test_mounts_helpers.py index b017cbd..dc8517e 100644 --- a/docker/api/test_mounts_helpers.py +++ b/docker/api/test_mounts_helpers.py @@ -184,6 +184,9 @@ def test_mounten_geht_bei_totem_mount_den_reparatur_weg(monkeypatch): ablauf = [] monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None) + # pfad_lage gestubbt: Sie ruft selbst `timeout ls` auf, und hier geht es um + # die Reihenfolge von Loesen und Mounten. + monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "da") # Vorher tot (der Vor-Check), nach dem Mount dauerhaft erreichbar. Die # Doppelprobe wird hier gestubbt, damit der Test nicht 3 s echt wartet. monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False) @@ -217,6 +220,7 @@ def test_mounten_prueft_das_ergebnis_und_versucht_es_zweimal(monkeypatch): ablauf = [] # nie erreichbar: vorher, nach Versuch 1, nach Versuch 2 monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None) + monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "da") monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False) monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: False) monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: None) @@ -322,3 +326,96 @@ def test_stale_loesen_wartet_nicht_wenn_nichts_zu_loesen_war(monkeypatch): lambda cmd, **k: types.SimpleNamespace(returncode=1, stdout=b"", stderr=b"")) assert mounts._stale_mounts_loesen("/app/media/x") == 0 assert gewartet == [] + + +def test_pfad_lage_unterscheidet_da_weg_unklar(monkeypatch): + """DER Fund vom 26.07.2026: Eine Wiederanbindung brauchte 3 min 15 s, und die + Zeit ging in die ERSTE Zeile von mounten() - `os.makedirs(ziel, + exist_ok=True)`. `exist_ok` prueft mit os.path.isdir, und ein `stat` auf einen + toten CIFS-Mount blockiert im Kernel bis zum SMB-Timeout. Deshalb wird die + Lage jetzt mit einem abbrechbaren Kind-Prozess erfragt.""" + import types + + import mounts + + def antwort(rc): + return lambda cmd, **k: types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"") + + monkeypatch.setattr(mounts.subprocess, "run", antwort(0)) + assert mounts.pfad_lage("/app/media/rippy") == "da" + monkeypatch.setattr(mounts.subprocess, "run", antwort(2)) + assert mounts.pfad_lage("/app/media/neu") == "weg" + # 124 = `timeout` hat abgeschossen: existiert, antwortet aber nicht + monkeypatch.setattr(mounts.subprocess, "run", antwort(124)) + assert mounts.pfad_lage("/app/media/totes-nas") == "unklar" + + +def test_pfad_lage_nutzt_eine_zeitgrenze(monkeypatch): + import types + + import mounts + + gesehen = {} + monkeypatch.setattr( + mounts.subprocess, "run", + lambda cmd, **k: (gesehen.update(cmd=cmd, kw=k), + types.SimpleNamespace(returncode=0))[1]) + mounts.pfad_lage("/x") + assert gesehen["cmd"][0] == "timeout" + assert gesehen["kw"]["timeout"] is not None + + +def test_mounten_legt_den_ordner_nur_an_wenn_er_fehlt(monkeypatch): + """Bei "unklar" (toter Mount) darf makedirs NICHT laufen - genau dort hing es + drei Minuten. Der Ordner ist dann ohnehin da.""" + import types + + import mounts + + angelegt = [] + monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "unklar") + monkeypatch.setattr(mounts.os, "makedirs", + lambda *a, **k: angelegt.append(a[0] if a else "")) + monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False) + monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: True) + monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: 0) + monkeypatch.setattr(mounts, "schreibtest", lambda p: True) + monkeypatch.setattr( + mounts.subprocess, "run", + lambda cmd, **k: types.SimpleNamespace(returncode=0, stdout="", stderr="")) + + assert mounts.mounten("rippy", "cifs", "//nas/rippy") is True + assert angelegt == [] + + +def test_mounten_legt_den_ordner_bei_erstinstallation_an(monkeypatch): + import types + + import mounts + + angelegt = [] + monkeypatch.setattr(mounts, "pfad_lage", lambda ziel: "weg") + monkeypatch.setattr(mounts.os, "makedirs", + lambda *a, **k: angelegt.append(a[0] if a else "")) + monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False) + monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: True) + monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: 0) + monkeypatch.setattr(mounts, "schreibtest", lambda p: True) + monkeypatch.setattr( + mounts.subprocess, "run", + lambda cmd, **k: types.SimpleNamespace(returncode=0, stdout="", stderr="")) + + mounts.mounten("rippy", "cifs", "//nas/rippy") + assert angelegt == ["/app/media/rippy"] + + +def test_reparieren_fragt_ismount_nicht_mehr(monkeypatch): + """os.path.ismount ist ein `stat` und blockiert auf einem toten CIFS-Mount. + Gebraucht wird es nicht: `umount -l` auf einen leeren Pfad kostet nichts.""" + import mounts + + monkeypatch.setattr(mounts.os.path, "ismount", + lambda p: (_ for _ in ()).throw(AssertionError("nicht fragen!"))) + monkeypatch.setattr(mounts, "_lazy_umount", lambda ziel: None) + monkeypatch.setattr(mounts, "mounten", lambda *a, **k: True) + assert mounts.reparieren("rippy", "cifs", "//nas/rippy") is True