From fed24237c917ae22debfe42f528eaf40f57f2a38 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 29 Aug 2026 20:54:44 +0200 Subject: [PATCH] fix(windows): makemkvcon ueberlebte Rippy und hielt das Laufwerk fest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commander: „jetzt hast du den makemkvcon kram dir nicht angeguckt". Stimmt — es stand als Frage im Notizbuch statt als Riegel im Code. ## Gemessen, nicht vermutet Ein Elternprozess startete makemkvcon, dann wurde er hart beendet (`taskkill /F` OHNE `/T` — genau das, was beim Dienst-Stopp und beim Drueber-Installieren passiert): ohne Leine makemkvcon PID 15368 vor dem Kill: True danach: True mit Leine makemkvcon PID 43608 vor dem Kill: True danach: False Windows raeumt Kindprozesse nicht auf. Eine solche Waise HAELT DAS LAUFWERK — jeder spaetere Rip scheitert dann mit „Das Öffnen der Disk schlug fehl". Zwei davon standen waehrend der Messungen auf diesem Rechner. ## Zwei Riegel * **Die Leine** (`winlauf.kinder_an_die_leine`) — eine Arbeitsgruppe (Job Object) mit KILL_ON_JOB_CLOSE. Stirbt Rippy, sterben makemkvcon, HandBrake und flac mit. Auch beim Absturz, auch per Taskmanager. Einmal beim Dienststart gesetzt, deckt sie JEDEN Werkzeugaufruf ab. * **Der Aufraeumer** (`winlauf.waisen_beenden`) — beendet beim Start Werkzeug-Prozesse, deren Elternprozess es nicht mehr gibt. Fuer das, was eine aeltere Fassung oder ein Absturz hinterlassen hat. Nur ELTERNLOSE: Ein makemkvcon eines laufenden Rippy bleibt unangetastet, `makemkv.exe` (die Oberflaeche) steht gar nicht erst auf der Liste. Am echten Fall nachgestellt: Waise erzeugt, Rippy gestartet, Waise weg. ## Drei Prozesse duerfen NICHT mitsterben Dienst, Fensterprogramm und das Aufraeum-Skript der Deinstallation loesen sich ueber `eigenstaendig_starten` heraus (CREATE_BREAKAWAY_FROM_JOB). Mit Rueckfall ohne die Fahne: Steckt Rippy in einer fremden Arbeitsgruppe ohne Herausloese-Erlaubnis, verweigert Windows den Start rundweg — ein Fenster, das gar nicht mehr aufgeht, waere schlimmer als eines, das mitstirbt. ## Die Falle beim Bauen Der erste Anlauf meldete nur „ging nicht". Ursache: Ohne `argtypes` reicht ctypes einen Griff als 32-Bit-int weiter. `GetCurrentProcess()` liefert aber (HANDLE)-1 = 0xFFFFFFFFFFFFFFFF, ctypes wirft `ArgumentError: int too long to convert`, und das breite `except` verschluckte es. `kernel32()` meldet jetzt jede Signatur an; ein Test wacht darueber. Co-Authored-By: Claude Opus 5 --- src/rippy/platform/test_winlauf.py | 7 +- src/rippy/platform/test_winwaisen.py | 176 ++++++++++++++++++ src/rippy/platform/winlauf.py | 263 +++++++++++++++++++++++++++ src/rippy/windows_app.py | 60 +++++- 4 files changed, 498 insertions(+), 8 deletions(-) create mode 100644 src/rippy/platform/test_winwaisen.py diff --git a/src/rippy/platform/test_winlauf.py b/src/rippy/platform/test_winlauf.py index ff2b60f..19a7b18 100644 --- a/src/rippy/platform/test_winlauf.py +++ b/src/rippy/platform/test_winlauf.py @@ -169,7 +169,12 @@ def test_beide_startwege_reichen_den_zeiger_nicht_weiter(): quelle = os.path.join(os.path.dirname(__file__), "..", "windows_app.py") with open(os.path.abspath(quelle), encoding="utf-8") as f: text = f.read() - stellen = [m.start() for m in re.finditer(r"subprocess\.Popen\(\[programm", text)] + # Seit dem 29.08.2026 gehen beide Wege ueber `eigenstaendig_starten` — + # der Dienst und das Fenster muessen sich aus Rippys Arbeitsgruppe + # herausloesen (winlauf.kinder_an_die_leine). Am `env=` aendert das nichts, + # und genau darum geht es hier weiterhin. + stellen = [m.start() for m in re.finditer( + r"(subprocess\.Popen|eigenstaendig_starten)\(\s*\[programm", text)] assert len(stellen) == 2, "Startwege haben sich geaendert: %d gefunden" % len(stellen) for stelle in stellen: aufruf = text[stelle:stelle + 300] diff --git a/src/rippy/platform/test_winwaisen.py b/src/rippy/platform/test_winwaisen.py new file mode 100644 index 0000000..c40ecfb --- /dev/null +++ b/src/rippy/platform/test_winwaisen.py @@ -0,0 +1,176 @@ +"""Kein makemkvcon ueberlebt Rippy — und keine Waise haelt das Laufwerk fest. + +## Der Befund (29.08.2026, auf dem Rechner des Commanders gemessen) + +Ein Elternprozess startete `makemkvcon`, dann wurde er hart beendet +(`taskkill /F` ohne `/T` — genau das, was beim Dienst-Stopp und beim +Drueber-Installieren passiert): + + ohne Leine makemkvcon PID 15368 vor dem Kill: True danach: True + mit Leine makemkvcon PID 43608 vor dem Kill: True danach: False + +Ohne Leine bleibt die Waise stehen. **Und eine Waise haelt das Laufwerk fest** +— jeder spaetere Rip scheitert dann mit „Das Öffnen der Disk schlug fehl". + +Diese Tests pruefen die Logik, nicht das Betriebssystem: Sie laufen auch unter +Linux (Vertrag der Portschicht) und brauchen keine Windows-Aufrufe. +""" + +import os + +import pytest + +from rippy.platform import winlauf + + +# ── Wen raeumt Rippy weg, und wen nicht ──────────────────────────────── + + +def test_eine_waise_wird_erkannt(): + liste = [ + (100, 4, "explorer.exe"), + (200, 999, "makemkvcon64.exe"), # Elternprozess 999 gibt es nicht + ] + assert winlauf.waisen_finden(liste) == [(200, "makemkvcon64.exe")] + + +def test_ein_makemkvcon_mit_lebendem_eltern_bleibt_unangetastet(): + """Das gehoert zu einem laufenden Rip — oder zu einem zweiten Rippy.""" + liste = [ + (100, 4, "Rippy.exe"), + (200, 100, "makemkvcon64.exe"), # Kind eines lebenden Rippy + ] + assert winlauf.waisen_finden(liste) == [] + + +def test_die_makemkv_oberflaeche_wird_nie_abgeschossen(): + """`makemkv.exe` ist das Programm, das der Commander offen haben darf. + Nur `makemkvcon` ist das Kommandozeilen-Werkzeug, das Rippy startet.""" + liste = [(200, 999, "makemkv.exe")] + assert winlauf.waisen_finden(liste) == [] + + +def test_handbrake_und_flac_zaehlen_mit(): + liste = [(1, 999, "HandBrakeCLI.exe"), (2, 999, "flac.exe")] + assert {name for _, name in winlauf.waisen_finden(liste)} == { + "HandBrakeCLI.exe", "flac.exe"} + + +def test_gross_und_kleinschreibung_ist_egal(): + """Windows meldet mal `makemkvcon64.exe`, mal anders geschrieben.""" + assert winlauf.waisen_finden([(1, 999, "MakeMKVcon64.EXE")]) == [ + (1, "MakeMKVcon64.EXE")] + + +def test_beenden_meldet_was_es_beendet_hat(): + liste = [(1, 999, "makemkvcon64.exe"), (2, 999, "flac.exe")] + versucht = [] + + def toeten(pid): + versucht.append(pid) + return True + + assert winlauf.waisen_beenden(liste, toeten=toeten) == [ + "makemkvcon64.exe", "flac.exe"] + assert versucht == [1, 2] + + +def test_ein_prozess_der_sich_nicht_beenden_laesst_bricht_nichts_ab(): + """Ein fremder Prozess mit demselben Namen, an den Rippy nicht herankommt, + darf den Start nicht aufhalten — die anderen werden trotzdem weggeraeumt.""" + liste = [(1, 999, "makemkvcon64.exe"), (2, 999, "flac.exe")] + + def toeten(pid): + if pid == 1: + raise OSError(5, "Zugriff verweigert") + return True + + assert winlauf.waisen_beenden(liste, toeten=toeten) == ["flac.exe"] + + +# ── Die Leine ────────────────────────────────────────────────────────── + + +def test_ohne_windows_gibt_es_keine_leine(): + """Unter Linux uebernimmt das der Container bzw. systemd — nicht Rippy.""" + if os.name == "nt": + pytest.skip("hier laeuft Windows") + assert winlauf.kinder_an_die_leine() is False + assert winlauf.prozessliste() == [] + + +@pytest.mark.skipif(os.name != "nt", reason="Arbeitsgruppen gibt es nur unter Windows") +def test_die_leine_haelt_und_bleibt_gehalten(): + assert winlauf.kinder_an_die_leine() is True + griff = winlauf._ARBEITSGRUPPE + assert griff, "der Griff MUSS liegen bleiben — sonst sterben alle Kinder sofort" + assert winlauf.kinder_an_die_leine() is True, "ein zweiter Aufruf ist harmlos" + assert winlauf._ARBEITSGRUPPE == griff, "und legt keine zweite Gruppe an" + + +@pytest.mark.skipif(os.name != "nt", reason="braucht echte Prozesse") +def test_die_prozessliste_sieht_diesen_prozess(): + eigene = {pid for pid, _, _ in winlauf.prozessliste()} + assert os.getpid() in eigene + + +# ── Waechter gegen die Falle, die beim Bauen zugeschnappt ist ────────── + + +def test_jeder_kernel32_aufruf_hat_eine_angemeldete_signatur(): + """⚠️ Ohne `argtypes` reicht ctypes einen Griff als 32-Bit-int weiter. + + `GetCurrentProcess()` liefert den Pseudogriff (HANDLE)-1, also + 0xFFFFFFFFFFFFFFFF — ctypes wirft dann `ArgumentError: int too long to + convert`, das breite `except` verschluckt es, und `kinder_an_die_leine` + meldete nur „ging nicht". Genau so ist der erste Anlauf gescheitert. + """ + import inspect + + quelle = inspect.getsource(winlauf.kernel32) + for name in ("CreateJobObjectW", "SetInformationJobObject", + "AssignProcessToJobObject", "GetCurrentProcess", + "CreateToolhelp32Snapshot", "OpenProcess", "TerminateProcess"): + assert f"k.{name}.argtypes" in quelle, f"{name} ohne argtypes" + assert f"k.{name}.restype" in quelle, f"{name} ohne restype" + + +@pytest.mark.skipif(os.name != "nt", reason="Strukturen nur unter Windows") +def test_die_struktur_hat_die_groesse_die_windows_erwartet(): + """144 Byte auf 64-Bit — am 29.08.2026 gegen Windows gemessen. Stimmt die + Groesse nicht, lehnt SetInformationJobObject stumm ab.""" + import ctypes + + assert ctypes.sizeof(winlauf._ERWEITERTE_GRENZEN()) == 144 + + +def test_die_drei_dauerlaeufer_loesen_sich_heraus(): + """Dienst, Fenster und Aufraeum-Skript MUESSEN Rippy ueberleben. + + Wuerden sie in der Arbeitsgruppe haengen, wuerde die Deinstallation ihr + eigenes Aufraeum-Skript umbringen und der Dienst mit seinem Starter sterben. + """ + import inspect + + from rippy import windows_app + + for funktion in (windows_app.starte_hintergrund, # der Dienst + windows_app.starte_fensterprozess, # das Fenster + windows_app.selbst_loeschen): # das Aufraeum-Skript + quelle = inspect.getsource(funktion) + assert "eigenstaendig_starten" in quelle, ( + f"{funktion.__name__} startet noch direkt — der Prozess wuerde" + " mit Rippy sterben") + assert "subprocess.Popen" not in quelle, ( + f"{funktion.__name__} hat noch einen direkten Start daneben") + + +def test_der_rueckfall_ohne_die_fahne_ist_vorhanden(): + """Steckt Rippy in einer fremden Arbeitsgruppe ohne Herausloese-Erlaubnis, + verweigert Windows den Start rundweg. Ein Fenster, das gar nicht mehr + aufgeht, waere schlimmer als eines, das mitstirbt.""" + import inspect + + quelle = inspect.getsource(winlauf.eigenstaendig_starten) + assert "except OSError" in quelle + assert quelle.count("subprocess.Popen") == 2, "mit Fahne und ohne" diff --git a/src/rippy/platform/winlauf.py b/src/rippy/platform/winlauf.py index 971ce8e..2d56ede 100644 --- a/src/rippy/platform/winlauf.py +++ b/src/rippy/platform/winlauf.py @@ -164,6 +164,269 @@ def reste_aufraeumen(ordner: str = None, eigener: str = None, return weg +# ═══════════════════════════════════ Kinder ueberleben Rippy nicht ══ +# +# ## Der Befund (29.08.2026, auf diesem Rechner gemessen) +# +# Ein Python-Elternprozess startete `makemkvcon`, dann wurde der Elternprozess +# hart beendet (`taskkill /F`, ohne `/T` — genau das, was Windows beim +# Dienst-Stopp und beim Drueber-Installieren tut): +# +# vor dem Kill: Eltern True, Kind True +# danach: Eltern False, Kind True <-- die Waise +# +# Zwei `makemkvcon64.exe` blieben danach im Taskmanager stehen. **Eine solche +# Waise haelt das Laufwerk fest.** Jeder spaetere Rip scheitert dann mit +# „Das Oeffnen der Disk schlug fehl" — dem Satz, den der Commander gemeldet hat. +# +# Windows raeumt Kindprozesse nicht auf. Es gibt genau ein Mittel dagegen, und +# das ist die Arbeitsgruppe (Job Object): Prozesse darin sterben, wenn die +# Gruppe geschlossen wird — und geschlossen wird sie automatisch, wenn der +# letzte Griff darauf verschwindet, also wenn Rippy endet. Auch beim Absturz. +# +# ⚠️ `_ARBEITSGRUPPE` MUSS ein Modulwert bleiben. Wird der Griff eingesammelt, +# schliesst Windows die Gruppe — und bringt damit sofort alle Kinder um. + +TH32CS_SNAPPROCESS = 0x00000002 +JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0800 +JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 +JOB_OBJECT_ERWEITERTE_GRENZEN = 9 +CREATE_BREAKAWAY_FROM_JOB = 0x01000000 +PROCESS_TERMINATE = 0x0001 + +# Nur diese werden je als Waise beendet. `makemkv.exe` steht bewusst NICHT +# dabei: Das ist die Oberflaeche von MakeMKV, die der Commander offen haben +# darf, ohne dass Rippy sie abschiesst. +WERKZEUG_PROZESSE = ("makemkvcon.exe", "makemkvcon64.exe", + "handbrakecli.exe", "flac.exe") + +_ARBEITSGRUPPE = None +_K32 = None + + +if os.name == "nt": + import ctypes + from ctypes import wintypes + + class _BASIS_GRENZEN(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_longlong), + ("PerJobUserTimeLimit", ctypes.c_longlong), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + class _EA_ZAEHLER(ctypes.Structure): + _fields_ = [(name, ctypes.c_ulonglong) for name in ( + "ReadOperationCount", "WriteOperationCount", "OtherOperationCount", + "ReadTransferCount", "WriteTransferCount", "OtherTransferCount")] + + class _ERWEITERTE_GRENZEN(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", _BASIS_GRENZEN), + ("IoInfo", _EA_ZAEHLER), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + class _PROZESSEINTRAG(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), + ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), + ("th32DefaultHeapID", ctypes.c_size_t), + ("th32ModuleID", wintypes.DWORD), + ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), + ("pcPriClassBase", ctypes.c_long), + ("dwFlags", wintypes.DWORD), + ("szExeFile", wintypes.WCHAR * 260), + ] + + +def eigenstaendig_starten(befehl, **kw): + """Startet einen Prozess, der Rippy UEBERLEBEN soll. + + Drei Prozesse duerfen nicht mitsterben: der Dienst (vom Starter aus), das + Fensterprogramm und das Aufraeum-Skript der Deinstallation. Sie loesen + sich mit `CREATE_BREAKAWAY_FROM_JOB` aus der Arbeitsgruppe. + + ⚠️ Der Rueckfall ohne die Fahne ist Pflicht, nicht Vorsicht: Steckt Rippy + in einer FREMDEN Arbeitsgruppe, die kein Herausloesen erlaubt, verweigert + Windows den Start rundweg (Zugriff verweigert). Ein Fenster, das gar nicht + mehr aufgeht, waere schlimmer als eines, das mitstirbt. + """ + fahnen = kw.pop("creationflags", 0) + try: + return subprocess.Popen( + befehl, creationflags=fahnen | CREATE_BREAKAWAY_FROM_JOB, **kw) + except OSError: + return subprocess.Popen(befehl, creationflags=fahnen, **kw) + + +def kernel32(): + """kernel32 mit vollstaendig angemeldeten Signaturen. + + ## Warum die `argtypes` hier keine Formsache sind (Befund 29.08.2026) + + Ohne sie reicht ctypes einen Python-Integer als **32-Bit-int** weiter. + `GetCurrentProcess()` liefert aber den Pseudogriff `(HANDLE)-1`, also + 0xFFFFFFFFFFFFFFFF — und ctypes wirft beim Weiterreichen + + ArgumentError: int too long to convert + + Der erste Anlauf fing das mit einem breiten `except` ab und meldete + schlicht „ging nicht". Gemessen: alle drei Aufrufe gelingen, sobald die + Signaturen stehen. Ein Griff ist 64 Bit breit; das muss man Windows sagen. + """ + global _K32 + if _K32 is not None: + return _K32 + k = ctypes.WinDLL("kernel32", use_last_error=True) + k.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR] + k.CreateJobObjectW.restype = wintypes.HANDLE + k.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, + ctypes.c_void_p, wintypes.DWORD] + k.SetInformationJobObject.restype = wintypes.BOOL + k.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE] + k.AssignProcessToJobObject.restype = wintypes.BOOL + k.GetCurrentProcess.argtypes = [] + k.GetCurrentProcess.restype = wintypes.HANDLE + k.CloseHandle.argtypes = [wintypes.HANDLE] + k.CloseHandle.restype = wintypes.BOOL + k.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] + k.CreateToolhelp32Snapshot.restype = wintypes.HANDLE + k.Process32FirstW.argtypes = [wintypes.HANDLE, ctypes.c_void_p] + k.Process32FirstW.restype = wintypes.BOOL + k.Process32NextW.argtypes = [wintypes.HANDLE, ctypes.c_void_p] + k.Process32NextW.restype = wintypes.BOOL + k.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + k.OpenProcess.restype = wintypes.HANDLE + k.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] + k.TerminateProcess.restype = wintypes.BOOL + _K32 = k + return k + + +def kinder_an_die_leine() -> bool: + """Bindet alle kuenftigen Kindprozesse an das Leben dieses Prozesses. + + Stirbt Rippy — geordnet, abgestuerzt oder per Taskmanager —, sterben + makemkvcon, HandBrake und flac mit. Das Laufwerk ist danach frei. + + Einmal beim Start aufrufen. Wirft nie; ein Rippy ohne Arbeitsgruppe laeuft + wie bisher weiter, nur ohne diesen Schutz. + + `BREAKAWAY_OK` ist Absicht: Der Dienst startet das Fensterprogramm und das + Aufraeum-Skript der Deinstallation. Beide sollen Rippy ueberleben und + loesen sich mit `CREATE_BREAKAWAY_FROM_JOB` heraus. + """ + global _ARBEITSGRUPPE + if os.name != "nt": + return False + if _ARBEITSGRUPPE is not None: + return True + try: + k32 = kernel32() + gruppe = k32.CreateJobObjectW(None, None) + if not gruppe: + return False + grenzen = _ERWEITERTE_GRENZEN() + grenzen.BasicLimitInformation.LimitFlags = ( + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK) + if not k32.SetInformationJobObject( + gruppe, JOB_OBJECT_ERWEITERTE_GRENZEN, + ctypes.byref(grenzen), ctypes.sizeof(grenzen)): + k32.CloseHandle(gruppe) + return False + if not k32.AssignProcessToJobObject(gruppe, k32.GetCurrentProcess()): + # Steckt Rippy schon in einer fremden Gruppe, die kein Herausloesen + # erlaubt, geht das nicht. Kein Grund zu scheitern — nur kein Schutz. + k32.CloseHandle(gruppe) + return False + _ARBEITSGRUPPE = gruppe # Griff offenhalten, siehe oben + return True + except Exception: # noqa: BLE001 + return False + + +def prozessliste() -> list: + """[(pid, eltern_pid, name), ...] aller laufenden Prozesse. Leer bei Fehlern.""" + if os.name != "nt": + return [] + try: + k32 = kernel32() + schnappschuss = k32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if not schnappschuss or schnappschuss == wintypes.HANDLE(-1).value: + return [] + try: + eintrag = _PROZESSEINTRAG() + eintrag.dwSize = ctypes.sizeof(_PROZESSEINTRAG) + liste = [] + weiter = k32.Process32FirstW(schnappschuss, ctypes.byref(eintrag)) + while weiter: + liste.append((eintrag.th32ProcessID, + eintrag.th32ParentProcessID, + eintrag.szExeFile)) + weiter = k32.Process32NextW(schnappschuss, ctypes.byref(eintrag)) + return liste + finally: + k32.CloseHandle(schnappschuss) + except Exception: # noqa: BLE001 + return [] + + +def waisen_finden(liste=None, namen=WERKZEUG_PROZESSE) -> list: + """Werkzeug-Prozesse, deren Elternprozess es nicht mehr gibt. + + Nur elternlose: Ein makemkvcon, das gerade zu einem laufenden Rippy + gehoert, hat einen lebenden Elternprozess und bleibt unangetastet. Das + gilt auch fuer ein zweites Rippy und fuer einen Aufruf von Hand. + """ + liste = prozessliste() if liste is None else liste + lebende = {pid for pid, _, _ in liste} + return [(pid, name) for pid, eltern, name in liste + if (name or "").lower() in namen and eltern not in lebende] + + +def waisen_beenden(liste=None, toeten=None) -> list: + """Beendet elternlose Werkzeug-Prozesse. Gibt ihre Namen zurueck. + + ## Warum das beim Start passieren muss + + Eine Waise haelt das Laufwerk fest — der naechste Rip scheitert dann mit + „Das Oeffnen der Disk schlug fehl", und niemand kann sich das erklaeren. + `kinder_an_die_leine` verhindert neue Waisen; diese hier raeumt weg, was + eine aeltere Fassung oder ein Absturz hinterlassen hat. + """ + if toeten is None: + def toeten(pid): + k32 = kernel32() + griff = k32.OpenProcess(PROCESS_TERMINATE, False, pid) + if not griff: + return False + try: + return bool(k32.TerminateProcess(griff, 1)) + finally: + k32.CloseHandle(griff) + + beendet = [] + for pid, name in waisen_finden(liste): + try: + if toeten(pid): + beendet.append(name) + except Exception: # noqa: BLE001 + pass + return beendet + + def ohne_konsole() -> bool: """Läuft dieser Prozess ohne Standardausgabe? diff --git a/src/rippy/windows_app.py b/src/rippy/windows_app.py index 5e09564..4cacbc8 100644 --- a/src/rippy/windows_app.py +++ b/src/rippy/windows_app.py @@ -379,12 +379,15 @@ def ui_auspacken(ziel: str) -> str: def starte_hintergrund(programm: str) -> None: """Startet Rippy als Hintergrundprozess — ohne aufblitzendes Fenster.""" + from rippy.platform import winlauf from rippy.platform.winlauf import OHNE_FENSTER, umgebung_ohne_bundle # `env=` ist hier kein Beiwerk: Ohne die Bereinigung liefe dieses Kind im # Auspack-Ordner des Elternprozesses. Begruendung in winlauf.py. - subprocess.Popen([programm, "--dienst"], creationflags=OHNE_FENSTER, - close_fds=True, env=umgebung_ohne_bundle()) + # `eigenstaendig_starten`: Der Dienst soll den Starter ueberleben. + winlauf.eigenstaendig_starten( + [programm, "--dienst"], creationflags=OHNE_FENSTER, + close_fds=True, env=umgebung_ohne_bundle()) def starte_fensterprozess(programm: str = None) -> None: @@ -399,11 +402,15 @@ def starte_fensterprozess(programm: str = None) -> None: Fenster ist der Client davor. Beide heissen im Taskmanager „Rippy.exe", und das Fenster laesst sich schliessen, ohne den Dienst mitzureissen. """ + from rippy.platform import winlauf from rippy.platform.winlauf import OHNE_FENSTER, umgebung_ohne_bundle - subprocess.Popen([programm or _eigene_datei(), "--oeffnen"], - creationflags=OHNE_FENSTER, close_fds=True, - env=umgebung_ohne_bundle()) + # `eigenstaendig_starten`: Das Fenster haengt nicht am Leben des Dienstes + # — es laesst sich schliessen, ohne ihn mitzureissen, und umgekehrt. + winlauf.eigenstaendig_starten( + [programm or _eigene_datei(), "--oeffnen"], + creationflags=OHNE_FENSTER, close_fds=True, + env=umgebung_ohne_bundle()) def deinstallieren(still: bool = False) -> None: @@ -544,6 +551,7 @@ def selbst_loeschen(exe: str, ordner: str) -> str: """ import tempfile + from rippy.platform import winlauf from rippy.platform.winlauf import OHNE_FENSTER skript = os.path.join(tempfile.gettempdir(), "rippy-aufraeumen.bat") @@ -553,8 +561,10 @@ def selbst_loeschen(exe: str, ordner: str) -> str: with open(skript, "w", encoding="ascii", errors="replace", newline="\r\n") as f: f.write(aufraeum_skript(exe, ordner)) - subprocess.Popen(["cmd", "/c", skript], creationflags=OHNE_FENSTER, - close_fds=True) + # `eigenstaendig_starten`: Das Skript loescht Rippy — es MUSS Rippy + # ueberleben, sonst bleibt die halbe Installation stehen. + winlauf.eigenstaendig_starten(["cmd", "/c", skript], + creationflags=OHNE_FENSTER, close_fds=True) return skript @@ -666,6 +676,30 @@ def _reste_wegraeumen() -> None: pass # Aufraeumen ist Kuer. Es darf den Dienst nicht kosten. +def _waisen_wegraeumen() -> list: + """Werkzeug-Prozesse aufraeumen, die kein Elternprozess mehr haelt. + + Die Leine verhindert NEUE Waisen. Weggeraeumt werden muessen die, die eine + aeltere Fassung oder ein Absturz hinterlassen hat — genau der Fall des + Commanders: RippySetup drueber installiert, waehrend noch ein Rip lief. + Der Dienst wurde gestoppt, makemkvcon blieb stehen und hielt das Laufwerk. + """ + from rippy.platform import winlauf + + beendet = winlauf.waisen_beenden() + if beendet: + text = ("Herrenlose Werkzeug-Prozesse beendet: %s — sie hielten sonst " + "das Laufwerk fest." % ", ".join(sorted(set(beendet)))) + melden(" " + text) + try: + from rippy import store + + store.add_log("warning", "windows", text) + except Exception: # noqa: BLE001 + pass # Ohne Datenbank steht es nur im Protokoll + return beendet + + def dienst_starten(port: int = STANDARD_PORT) -> int: """Server im Hintergrund-Thread, Tray-Symbol im Vordergrund. @@ -673,6 +707,18 @@ def dienst_starten(port: int = STANDARD_PORT) -> int: laufen, sonst bekommt es keine Fensternachrichten und das Symbol erscheint nie. """ + # ⚠️ ZUERST die Leine, vor allem anderen. + # + # Ab hier sterben makemkvcon, HandBrake und flac mit Rippy — auch beim + # Absturz und beim Drueber-Installieren. Ohne das bleibt ein makemkvcon + # stehen und HAELT DAS LAUFWERK: Jeder spaetere Rip scheitert dann mit + # „Das Öffnen der Disk schlug fehl". Gemessen am 29.08.2026, Herleitung + # in winlauf.kinder_an_die_leine. + from rippy.platform import winlauf + + winlauf.kinder_an_die_leine() + _waisen_wegraeumen() + # Reste frueherer Laeufe wegraeumen — nebenher, damit der Start nicht # darauf wartet. Beim Commander lagen 20 solcher Ordner mit 1,1 GB # herum (Befund 28.08.2026); die Ursache ist mit `umgebung_ohne_bundle`