Launcher: vollständiger Startablauf nach §9.2

- 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
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 08:38:05 +02:00
parent cbe1360aaa
commit f7f52f909d
+178
View File
@@ -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())