"""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