362e089be0
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell. Jetzt ist sauber getrennt: ROOT (was der Nutzer sieht und braucht): - run.py = das Programm - hms_app/ = der Anwendungscode - HMS MediaEngine.app = macOS Doppelklick-Starter - HMS-Start.vbs = Windows Doppelklick-Starter - HMS-Install.vbs = Windows Erst-Installation - HMS-Mac-Install.command = macOS Homebrew-Installation - HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix) - installer_gui.py = grafischer Installer - launcher.pyw + launcher_core.py = interne Start-Logik - LIESMICH.txt = 10-Zeilen-Kurzanleitung - .gitignore _entwicklung/ (alles andere, NICHT benoetigt): - packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/ build/ fixture_profiles/ - PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md - pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann _entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne. Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
190 lines
6.6 KiB
Python
190 lines
6.6 KiB
Python
"""Tests Supervisor/Launcher mit echten Kindprozessen (PLAN.md §6.1A, §26).
|
|
|
|
Keine Mocks: die Tests starten reale Python-Kindprozesse und prüfen
|
|
Start, kontrolliertes Beenden, Neustart nach Absturz, Crashloop-Erkennung
|
|
und Recovery-Markierung.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from hms_launcher import AppPaths, ProcessSpec, Supervisor, find_free_port
|
|
|
|
|
|
def _sleeper(seconds: float = 30.0) -> list[str]:
|
|
"""Kindprozess-Kommando, das `seconds` lang still läuft."""
|
|
return [sys.executable, "-c", f"import time; time.sleep({seconds})"]
|
|
|
|
|
|
def _crasher() -> list[str]:
|
|
"""Kindprozess-Kommando, das sofort mit Code 1 endet."""
|
|
return [sys.executable, "-c", "import sys; sys.exit(1)"]
|
|
|
|
|
|
def _wait_exit(state, timeout: float = 5.0) -> None:
|
|
deadline = time.monotonic() + timeout
|
|
while state.proc is not None and state.proc.poll() is None and time.monotonic() < deadline:
|
|
time.sleep(0.02)
|
|
|
|
|
|
def _wait_file(path: Path, timeout: float = 5.0) -> None:
|
|
deadline = time.monotonic() + timeout
|
|
while not path.is_file() and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
assert path.is_file(), f"kindprozess schrieb {path} nicht"
|
|
|
|
|
|
def test_find_free_port_returns_usable_port() -> None:
|
|
port = find_free_port()
|
|
assert 1024 <= port <= 65535
|
|
import socket
|
|
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.bind(("127.0.0.1", port))
|
|
|
|
|
|
def test_start_and_running_state(tmp_path: Path) -> None:
|
|
sup = Supervisor(AppPaths(root=tmp_path))
|
|
sup.start(ProcessSpec(name="renderer", cmd=_sleeper()))
|
|
try:
|
|
assert sup.state("renderer").running
|
|
assert sup.check()["renderer"] == "running"
|
|
finally:
|
|
sup.shutdown()
|
|
assert sup.state("renderer").returncode is not None
|
|
|
|
|
|
def test_duplicate_start_rejected(tmp_path: Path) -> None:
|
|
sup = Supervisor(AppPaths(root=tmp_path))
|
|
sup.start(ProcessSpec(name="core", cmd=_sleeper()))
|
|
try:
|
|
with pytest.raises(RuntimeError, match="already running"):
|
|
sup.start(ProcessSpec(name="core", cmd=_sleeper()))
|
|
finally:
|
|
sup.shutdown()
|
|
|
|
|
|
def test_controlled_stop_uses_terminate(tmp_path: Path) -> None:
|
|
sup = Supervisor(AppPaths(root=tmp_path))
|
|
sup.start(ProcessSpec(name="core", cmd=_sleeper(), stop_timeout_s=5.0))
|
|
rc = sup.stop("core")
|
|
try:
|
|
assert rc is not None
|
|
assert not sup.state("core").running
|
|
# kontrolliert beendet → kein Neustart durch check()
|
|
assert sup.check()["core"] == "stopped"
|
|
# keine Recovery-Markierung bei sauberem Stop
|
|
assert sup.consume_recovery_marker() == []
|
|
finally:
|
|
sup.shutdown()
|
|
|
|
|
|
def test_crash_triggers_restart(tmp_path: Path) -> None:
|
|
sup = Supervisor(AppPaths(root=tmp_path))
|
|
spec = ProcessSpec(
|
|
name="core", cmd=_sleeper(0.2), max_restarts=3, restart_window_s=30.0
|
|
)
|
|
sup.start(spec)
|
|
_wait_exit(sup.state("core"))
|
|
result = sup.check() # Absturz erkannt → Neustart
|
|
assert result["core"] == "restarted"
|
|
assert sup.state("core").running
|
|
assert len(sup.state("core").restarts) == 1
|
|
sup.shutdown()
|
|
|
|
|
|
def test_crashloop_detection_stops_restarting(tmp_path: Path) -> None:
|
|
sup = Supervisor(AppPaths(root=tmp_path))
|
|
spec = ProcessSpec(name="core", cmd=_crasher(), max_restarts=2, restart_window_s=60.0)
|
|
sup.start(spec)
|
|
_wait_exit(sup.state("core"))
|
|
assert sup.check()["core"] == "restarted" # Neustart 1
|
|
_wait_exit(sup.state("core"))
|
|
assert sup.check()["core"] == "restarted" # Neustart 2
|
|
_wait_exit(sup.state("core"))
|
|
assert sup.check()["core"] == "crashloop" # Schwelle erreicht (§26.2)
|
|
assert sup.state("core").crashlooped
|
|
# weiteres check() startet nicht mehr
|
|
assert sup.check()["core"] == "crashloop"
|
|
sup.shutdown()
|
|
|
|
|
|
def test_non_restartable_process_stays_down(tmp_path: Path) -> None:
|
|
sup = Supervisor(AppPaths(root=tmp_path))
|
|
sup.start(ProcessSpec(name="oneshot", cmd=_crasher(), restartable=False))
|
|
_wait_exit(sup.state("oneshot"))
|
|
assert sup.check()["oneshot"] == "stopped"
|
|
assert not sup.state("oneshot").running
|
|
|
|
|
|
def test_recovery_marker_on_forced_kill(tmp_path: Path) -> None:
|
|
"""Kindprozess ignoriert SIGTERM → kill → Recovery-Markierung (§26.4).
|
|
|
|
Der Kindprozess bestätigt die Handler-Registrierung über eine Ready-Datei;
|
|
erst danach sendet der Test SIGTERM (keine Race Condition).
|
|
"""
|
|
if sys.platform == "win32":
|
|
return # Signal-Ignorieren unter Windows nicht testbar
|
|
ready = tmp_path / "ready.txt"
|
|
ignore_term = (
|
|
"import signal, time, sys\n"
|
|
"signal.signal(signal.SIGTERM, lambda *a: None)\n"
|
|
f"open(r'{ready}', 'w').write('ok')\n"
|
|
"time.sleep(30)\n"
|
|
)
|
|
sup = Supervisor(AppPaths(root=tmp_path))
|
|
sup.start(
|
|
ProcessSpec(
|
|
name="stubborn",
|
|
cmd=[sys.executable, "-c", ignore_term],
|
|
stop_timeout_s=0.3,
|
|
)
|
|
)
|
|
_wait_file(ready) # Handler aktiv, bevor SIGTERM gesendet wird
|
|
rc = sup.stop("stubborn")
|
|
assert rc is not None
|
|
lines = sup.consume_recovery_marker()
|
|
assert any("forced-kill stubborn" in line for line in lines)
|
|
# Markierung nach dem Lesen gelöscht
|
|
assert sup.consume_recovery_marker() == []
|
|
|
|
|
|
def test_shutdown_stops_all_processes(tmp_path: Path) -> None:
|
|
sup = Supervisor(AppPaths(root=tmp_path))
|
|
sup.start(ProcessSpec(name="renderer", cmd=_sleeper()))
|
|
sup.start(ProcessSpec(name="core", cmd=_sleeper()))
|
|
sup.shutdown()
|
|
assert not sup.state("renderer").running
|
|
assert not sup.state("core").running
|
|
|
|
|
|
def test_environment_gets_gstreamer_vars(tmp_path: Path) -> None:
|
|
"""Kindprozess erbt die portable GStreamer-Umgebung (§9.2, ADR-0002).
|
|
|
|
Der Kindprozess schreibt beide Variablen in eine Datei (kein stdout-
|
|
Piping nötig)."""
|
|
paths = AppPaths(root=tmp_path)
|
|
result_file = tmp_path / "env_result.txt"
|
|
probe = (
|
|
"import os\n"
|
|
f"open(r'{result_file}', 'w').write(\n"
|
|
" os.environ.get('GST_PLUGIN_SYSTEM_PATH_1_0', 'MISSING')\n"
|
|
" + '|'\n"
|
|
" + os.environ.get('GST_PLUGIN_PATH_1_0', 'MISSING')\n"
|
|
")\n"
|
|
)
|
|
sup = Supervisor(paths)
|
|
sup.start(
|
|
ProcessSpec(name="envprobe", cmd=[sys.executable, "-c", probe], restartable=False)
|
|
)
|
|
_wait_exit(sup.state("envprobe"))
|
|
_wait_file(result_file)
|
|
content = result_file.read_text(encoding="utf-8")
|
|
system_path, plugin_path = content.split("|", 1)
|
|
assert system_path == "" # System-Plugins unterdrückt (§9.2)
|
|
assert plugin_path.endswith("gstreamer-1.0") # gebündelte Plugin-Untermenge
|