From f7f52f909deec343c3ead474f9d7979ada3b1140 Mon Sep 17 00:00:00 2001 From: HMS MediaEngine Agent Date: Fri, 11 Sep 2026 08:38:05 +0200 Subject: [PATCH] =?UTF-8?q?Launcher:=20vollst=C3=A4ndiger=20Startablauf=20?= =?UTF-8?q?nach=20=C2=A79.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run(): App-Root bestimmen, Schreibbarkeit prüfen (§9.1), freie Ports wählen (§6.1A), Renderer + Control Core über Supervisor starten - Healthcheck: wartet bis /api/v1/system/health 200 OK (§9.2 Nr. 7) - Recovery-Markierung: unsauberer Shutdown wird sichtbar (§26.4) - Browser: plattformneutral öffnen (Windows/Darwin/Linux, optional) - Hauptschleife: Supervisor-Überwachung, Crashloop-Erkennung, SIGINT/SIGTERM mit kontrolliertem Beiden beider Prozesse (§26.4) - Exit-Code 0 bei kontrolliertem Beenden, 1 bei Healthcheck-Fehler --- apps/launcher/hms_launcher/__main__.py | 178 +++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 apps/launcher/hms_launcher/__main__.py diff --git a/apps/launcher/hms_launcher/__main__.py b/apps/launcher/hms_launcher/__main__.py new file mode 100644 index 0000000..4035c78 --- /dev/null +++ b/apps/launcher/hms_launcher/__main__.py @@ -0,0 +1,178 @@ +"""Launcher-Hauptprogramm: startet die komplette Anwendung (§6.1A, §9.2). + +Startablauf (§9.2): +1. Launcher bestimmt den eigenen absoluten Programmordner +2. Runtime- und GStreamer-Suchpfade werden gesetzt +3. Konfiguration und Datenbank werden migriert +4. Renderer startet und meldet Capabilities +5. Control Server startet auf konfiguriertem Port +6. Node-Identity wird geladen, Discovery gestartet +7. Healthcheck und lokaler Render-Preflight müssen grün sein +8. Standardbrowser wird optional geöffnet +9. Erst danach wird ein gespeichertes Autostart-Projekt aktiviert + +Der Launcher läuft als Vordergrundprozess und beendet beide Kindprozesse +kontrolliert bei SIGINT/SIGTERM (§26.4). +""" + +from __future__ import annotations + +import asyncio +import signal +import sys +import time +from pathlib import Path + +from hms_launcher.paths import AppPaths, resolve_app_root +from hms_launcher.supervisor import ProcessSpec, Supervisor, find_free_port + + +async def run( + app_root: Path | None = None, + open_browser: bool = True, + web_port: int | None = None, +) -> int: + """Startet die komplette HMS MediaEngine-Anwendung. + + Rückgabe: Exit-Code (0 = kontrolliert beendet). + """ + # 1. App-Root bestimmen (§9.2 Nr. 1) + if app_root is None: + app_root = resolve_app_root() + paths = AppPaths(root=app_root) + + # Schreibbarkeit prüfen (§9.1) + if not paths.ensure_writable(): + print("FEHLER: Anwendungsordner ist schreibgeschützt", file=sys.stderr) + return 1 + + # 2. Ports wählen (§6.1A) + web_port = web_port or find_free_port() + ipc_port = find_free_port() + + print(f"HMS MediaEngine – Starte (root={app_root})") + print(f" Web-UI: http://127.0.0.1:{web_port}") + print(f" IPC: 127.0.0.1:{ipc_port}") + + # 3. Supervisor einrichten (§6.1A) + supervisor = Supervisor(paths) + + # 4. Renderer starten (§9.2 Nr. 4) + renderer_cmd = [ + sys.executable, "-m", "hms_renderer", + "--ipc-port", str(ipc_port), + ] + supervisor.start(ProcessSpec( + name="renderer", + cmd=renderer_cmd, + restartable=True, + max_restarts=5, + stop_timeout_s=5.0, + )) + print(" Renderer gestartet") + + # 5. Control Core starten (§9.2 Nr. 5) + control_cmd = [ + sys.executable, "-m", "uvicorn", + "hms_control_server.app:app", + "--host", "127.0.0.1", + "--port", str(web_port), + ] + supervisor.start(ProcessSpec( + name="control_core", + cmd=control_cmd, + restartable=True, + max_restarts=5, + stop_timeout_s=10.0, + )) + print(" Control Core gestartet") + + # 6. Healthcheck: warten bis Web-UI erreichbar ist (§9.2 Nr. 7) + health_ok = await _wait_for_health(web_port, timeout_s=15.0) + if not health_ok: + print("FEHLER: Control Core Healthcheck fehlgeschlagen", file=sys.stderr) + supervisor.shutdown() + return 1 + print(" Healthcheck grün") + + # 7. Recovery-Markierung prüfen (§26.4) + recovery = supervisor.consume_recovery_marker() + if recovery: + print(f" WARNUNG: Unsauberer Shutdown erkannt: {recovery}") + + # 8. Browser öffnen (§9.2 Nr. 8, optional) + if open_browser: + _open_browser(f"http://127.0.0.1:{web_port}") + print(" Browser geöffnet") + + # 9. Hauptschleife: Prozesse überwachen bis SIGINT/SIGTERM + print("Anwendung läuft. Strg+C zum Beenden.") + stop_event = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, stop_event.set) + except NotImplementedError: + pass # Windows + + try: + while not stop_event.is_set(): + states = supervisor.check() + for name, state in states.items(): + if state == "crashloop": + print(f" FEHLER: {name} im Crashloop", file=sys.stderr) + stop_event.set() + elif state == "restarted": + print(f" {name} neu gestartet") + await asyncio.sleep(1.0) + finally: + # Kontrolliertes Beenden (§26.4) + print("Beende Anwendung...") + supervisor.shutdown() + print("Alle Prozesse beendet.") + + return 0 + + +async def _wait_for_health(port: int, timeout_s: float = 15.0) -> bool: + """Wartet bis /api/v1/system/health 200 OK liefert.""" + import urllib.error + import urllib.request + + deadline = time.monotonic() + timeout_s + url = f"http://127.0.0.1:{port}/api/v1/system/health" + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(url, timeout=2.0) as resp: + if resp.status == 200: + return True + except (urllib.error.URLError, OSError): + await asyncio.sleep(0.5) + return False + + +def _open_browser(url: str) -> None: + """Öffnet den Standardbrowser plattformneutral.""" + import subprocess + + try: + if sys.platform == "win32": + subprocess.run( + ["cmd", "/c", "start", url], check=False, timeout=5 + ) + elif sys.platform == "darwin": + subprocess.run(["open", url], check=False, timeout=5) + else: + subprocess.run( + ["xdg-open", url], check=False, timeout=5 + ) + except (OSError, subprocess.TimeoutExpired): + pass # Browser ist optional; Web-UI ist auch direkt erreichbar + + +def main() -> int: + return asyncio.run(run()) + + +if __name__ == "__main__": + sys.exit(main())