fix(windows): makemkvcon ueberlebte Rippy und hielt das Laufwerk fest

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-29 20:54:44 +02:00
co-authored by Claude Opus 5
parent 21c626eb4e
commit fed24237c9
4 changed files with 498 additions and 8 deletions
+6 -1
View File
@@ -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]
+176
View File
@@ -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"
+263
View File
@@ -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?
+53 -7
View File
@@ -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`