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:
co-authored by
Claude Opus 5
parent
21c626eb4e
commit
fed24237c9
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user