fix(api): os.path.isdir hing im Kernel und toetete die Schleife - harte Zeitgrenze
Ampel / ampel (push) Successful in 28s

Ursache gefunden, nicht geraten. Der neue Diagnose-Endpunkt sagte
`rohdaten.alter_sekunden: null` - die Funktion war NIE EINMAL fertig geworden,
und kein Fehler war gemeldet. Der Blick auf die Threads des API-Prozesses zeigte
zwei im Zustand **D** (uninterruptible sleep, im Kernel blockiert):

  tid=1600034 name=uvicorn state=D
  tid=1600037 name=uvicorn state=D

Der Mechanismus: Die Schleife startete ihren ersten Durchlauf, waehrend Rippy
beim Container-Start die CIFS-Freigabe neu einhaengte. Ihr `os.path.isdir` blieb
im Kernel stecken, `asyncio.to_thread` kam nie zurueck, die Schleife erreichte
ihr `sleep` nie - und war damit fuer immer tot. Sichtbar war nur, dass
can_retry dauerhaft false blieb.

Ein Timeout um den Aufruf haette nichts geholfen: Ein im Kernel haengender
Thread laesst sich aus Python nicht abbrechen, jeder Versuch haette einen
weiteren Thread verbrannt, bis der Pool leer ist.

Ein Kind-PROZESS laesst sich abbrechen. Geprueft wird jetzt mit
`timeout 4 ls -d <pfad>` - dasselbe Werkzeug, das mounts.ist_erreichbar seit dem
24.07.2026 fuer genau dieses Problem benutzt (dort fuer den toten NAS-Mount).
Laeuft es in die Zeitgrenze, gilt das Verzeichnis als "nicht da": Ein Ort, den
man nicht in Sekunden ansehen kann, ist fuer einen Rip ohnehin unbrauchbar.

os.listdir bleibt fuer /app/media selbst - das ist ein lokales Verzeichnis, die
Freigaben sind Unterordner davon. Und os.path.isdir bleibt fuer den Rueckfall
auf /app/temp/raw: ein Docker-Volume, dort kann nichts haengen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-26 13:28:02 +02:00
parent 0d8426d353
commit 2554c2633b
3 changed files with 140 additions and 10 deletions
+70
View File
@@ -109,3 +109,73 @@ def test_groesse_ueberspringt_unlesbares():
bytes_gesamt, anzahl = rohdaten.groesse(
["/x"], lambda p: ["a.mkv"], lambda p: True, getsize_kaputt)
assert (bytes_gesamt, anzahl) == (0, 0)
# --- Die harte Zeitgrenze: ein haengender Mount darf nichts toeten -----------
#
# Am 26.07.2026 hing os.path.isdir an der CIFS-Freigabe im
# KERNEL (Prozess-Zustand D). asyncio.to_thread kam nie zurueck, die
# Hintergrund-Schleife erreichte ihr sleep nie und war dauerhaft tot.
class _Lauf:
"""Merkt sich das Kommando und liefert einen gesetzten Rueckgabewert."""
def __init__(self, rc=0, wirf=None):
self.rc, self.wirf, self.cmd = rc, wirf, None
def __call__(self, cmd, **kwargs):
self.cmd = cmd
if self.wirf:
raise self.wirf
class E:
returncode = self.rc
return E()
def test_verzeichnis_da_nutzt_timeout_und_ls():
"""Ein Kind-PROZESS laesst sich abbrechen, ein im Kernel haengender Thread
nicht. Deshalb genau dieses Kommando - wie mounts.ist_erreichbar."""
lauf = _Lauf(rc=0)
assert rohdaten.verzeichnis_da("/app/media/rippy/x", lauf) is True
assert lauf.cmd[0] == "timeout"
assert lauf.cmd[1] == str(rohdaten.PRUEF_TIMEOUT_SEKUNDEN)
assert lauf.cmd[2:] == ["ls", "-d", "/app/media/rippy/x"]
def test_verzeichnis_da_nicht_vorhanden():
assert rohdaten.verzeichnis_da("/gibt/es/nicht", _Lauf(rc=2)) is False
def test_verzeichnis_in_der_zeitgrenze_gilt_als_nicht_da():
"""`timeout` beendet ls mit 124. Ein Ort, den man nicht in Sekunden ansehen
kann, ist fuer einen Rip ohnehin unbrauchbar."""
assert rohdaten.verzeichnis_da("/app/media/totes-nas/x", _Lauf(rc=124)) is False
def test_verzeichnis_da_ueberlebt_kaputte_umgebung():
import subprocess as sp
assert rohdaten.verzeichnis_da("/x", _Lauf(wirf=OSError("kein timeout"))) is False
assert rohdaten.verzeichnis_da(
"/x", _Lauf(wirf=sp.TimeoutExpired("ls", 6))) is False
assert rohdaten.verzeichnis_da("", _Lauf(rc=0)) is False
def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
"""Zusammenspiel: os.listdir fuer /app/media (lokal, sicher),
verzeichnis_da fuer die Kandidaten (koennen im Netz liegen)."""
def laufen(cmd, **kwargs):
pfad = cmd[-1]
class E:
returncode = 0 if pfad == f"/app/media/rippy/{JOB}" else 1
return E()
gefunden = rohdaten.suche(
JOB, "",
listdir=lambda p: ["bluray", "movies", "rippy"],
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
)
assert gefunden == [f"/app/media/rippy/{JOB}"]