AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
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).
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
"""hms_launcher – Supervisor/Launcher (PLAN.md §6.1A, §9).
|
||||
|
||||
Phase-0/1-Umfang: portable Pfadauflösung, Portwahl, GStreamer-Environment,
|
||||
Prozessüberwachung mit Restart-Policy und Crashloop-Erkennung,
|
||||
kontrolliertes Beenden mit Recovery-Markierung.
|
||||
"""
|
||||
|
||||
from hms_launcher.paths import AppPaths, resolve_app_root
|
||||
from hms_launcher.supervisor import (
|
||||
ProcessSpec,
|
||||
Supervisor,
|
||||
find_free_port,
|
||||
)
|
||||
|
||||
__all__ = ["AppPaths", "resolve_app_root", "ProcessSpec", "Supervisor", "find_free_port"]
|
||||
@@ -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())
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Portable Pfadregeln (PLAN.md §9, §9.1).
|
||||
|
||||
- Alle Pfade relativ zum Anwendungsroot; keine Laufwerksbuchstaben.
|
||||
- Keine Abhängigkeit vom Working Directory.
|
||||
- Temporäre Dateien in userdata/cache, nicht im OS-Profil.
|
||||
- Schreibbarkeit wird beim Start geprüft (Read-only-Modus folgt Phase 1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppPaths:
|
||||
"""Alle portablen Pfade je Anwendungsroot (§9-Struktur)."""
|
||||
|
||||
root: Path
|
||||
|
||||
@property
|
||||
def app(self) -> Path:
|
||||
return self.root / "app"
|
||||
|
||||
@property
|
||||
def runtime(self) -> Path:
|
||||
return self.root / "runtime"
|
||||
|
||||
@property
|
||||
def gstreamer_bin(self) -> Path:
|
||||
return self.runtime / "gstreamer" / "bin"
|
||||
|
||||
@property
|
||||
def gstreamer_plugins(self) -> Path:
|
||||
return self.runtime / "gstreamer" / "lib" / "gstreamer-1.0"
|
||||
|
||||
@property
|
||||
def web(self) -> Path:
|
||||
return self.root / "web"
|
||||
|
||||
@property
|
||||
def projects(self) -> Path:
|
||||
return self.root / "projects"
|
||||
|
||||
@property
|
||||
def media(self) -> Path:
|
||||
return self.root / "media"
|
||||
|
||||
@property
|
||||
def userdata(self) -> Path:
|
||||
return self.root / "userdata"
|
||||
|
||||
@property
|
||||
def database(self) -> Path:
|
||||
return self.userdata / "database"
|
||||
|
||||
@property
|
||||
def cache(self) -> Path:
|
||||
return self.userdata / "cache"
|
||||
|
||||
@property
|
||||
def identity(self) -> Path:
|
||||
return self.userdata / "identity" / "node_id"
|
||||
|
||||
@property
|
||||
def config(self) -> Path:
|
||||
return self.root / "config"
|
||||
|
||||
@property
|
||||
def logs(self) -> Path:
|
||||
return self.root / "logs"
|
||||
|
||||
def ensure_writable(self) -> bool:
|
||||
"""Prüft Schreibbarkeit des Roots (§9.1)."""
|
||||
probe = self.root / ".write_probe"
|
||||
try:
|
||||
probe.write_text("ok", encoding="ascii")
|
||||
probe.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def portable_environment(self) -> dict[str, str]:
|
||||
"""Umgebungsvariablen für gebündelte GStreamer-Runtime (§9.2, ADR-0002).
|
||||
|
||||
System-Plugins werden unterdrückt (leerer GST_PLUGIN_SYSTEM_PATH_1_0),
|
||||
damit ausschließlich die gebündelte, manifestierte Untermenge lädt.
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
gs_bin = self.gstreamer_bin
|
||||
if gs_bin.is_dir():
|
||||
path_var = "PATH"
|
||||
existing = env.get(path_var, "")
|
||||
env[path_var] = f"{gs_bin}{os.pathsep}{existing}" if existing else str(gs_bin)
|
||||
env["GST_PLUGIN_PATH_1_0"] = str(self.gstreamer_plugins)
|
||||
env["GST_PLUGIN_SYSTEM_PATH_1_0"] = ""
|
||||
return env
|
||||
|
||||
|
||||
def resolve_app_root(start_from: Path | None = None) -> Path:
|
||||
"""Bestimmt den Anwendungsroot anhand der PORTABLE_MODE-Markierung (§9).
|
||||
|
||||
Sucht vom gegebenen Pfad (Default: dieses Paket) aufwärts nach der
|
||||
Datei PORTABLE_MODE; im Entwickungsbaum ist das Repo-Root gemeint.
|
||||
"""
|
||||
current = Path(start_from or __file__).resolve()
|
||||
for candidate in [current, *current.parents]:
|
||||
if (candidate / "PORTABLE_MODE").is_file() or (candidate / "pyproject.toml").is_file():
|
||||
return candidate
|
||||
raise RuntimeError("app root not found (PORTABLE_MODE or pyproject.toml missing)")
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Supervisor/Launcher (PLAN.md §6.1A, §26.2, §26.4).
|
||||
|
||||
Aufgaben (§6.1A):
|
||||
- Start und Überwachung der Teilprozesse (Control Core, Renderer)
|
||||
- Wahl freier lokaler Ports (127.0.0.1)
|
||||
- Setzen portabler Runtime-Pfade (AppPaths.portable_environment)
|
||||
- Heartbeat-Überwachung auf Prozessebene (Lebenszyklus)
|
||||
- kontrolliertes Beenden mit Timeout (§26.4)
|
||||
- Wiederanlauf nach Control-Core-Absturz, Crashloop-Erkennung (§26.2)
|
||||
- Recovery-Markierung bei Zwangsbeendigung (§26.4)
|
||||
|
||||
Der Supervisor verwaltet echte Prozesse; keine Mock-Implementierung.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from hms_launcher.paths import AppPaths
|
||||
|
||||
DEFAULT_STOP_TIMEOUT_S = 10.0
|
||||
DEFAULT_MAX_RESTARTS = 5
|
||||
DEFAULT_RESTART_WINDOW_S = 60.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessSpec:
|
||||
"""Beschreibung eines zu überwachenden Teilprozesses."""
|
||||
|
||||
name: str
|
||||
cmd: list[str]
|
||||
restartable: bool = True
|
||||
max_restarts: int = DEFAULT_MAX_RESTARTS
|
||||
restart_window_s: float = DEFAULT_RESTART_WINDOW_S
|
||||
stop_timeout_s: float = DEFAULT_STOP_TIMEOUT_S
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessState:
|
||||
"""Laufzeitinformation zu einem überwachten Prozess (§26.2)."""
|
||||
|
||||
spec: ProcessSpec
|
||||
proc: subprocess.Popen | None = None
|
||||
restarts: list[float] = field(default_factory=list) # Zeitstempel je Neustart
|
||||
last_start_ns: int = 0
|
||||
crashlooped: bool = False
|
||||
stopped_by_supervisor: bool = False
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self.proc is not None and self.proc.poll() is None
|
||||
|
||||
@property
|
||||
def returncode(self) -> int | None:
|
||||
return self.proc.poll() if self.proc is not None else None
|
||||
|
||||
|
||||
def find_free_port(host: str = "127.0.0.1") -> int:
|
||||
"""Wählt einen freien lokalen Port (§6.1A). Socket wird sofort wieder
|
||||
freigegeben; der Kindprozess bindet ihn anschließend selbst."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind((host, 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
class Supervisor:
|
||||
"""Überwacht Teilprozesse mit Restart-Policy und Crashloop-Schwelle."""
|
||||
|
||||
def __init__(self, paths: AppPaths | None = None) -> None:
|
||||
self._paths = paths
|
||||
self._states: dict[str, ProcessState] = {}
|
||||
self._recovery_marker: Path | None = None
|
||||
if paths is not None:
|
||||
self._recovery_marker = paths.userdata / "recovery" / "unclean_shutdown"
|
||||
|
||||
# ---------- Start/Stop ----------
|
||||
|
||||
def start(self, spec: ProcessSpec) -> None:
|
||||
"""Startet einen Teilprozess mit portabler Umgebung."""
|
||||
if spec.name in self._states and self._states[spec.name].running:
|
||||
raise RuntimeError(f"process {spec.name!r} already running")
|
||||
env = dict(os.environ)
|
||||
if self._paths is not None:
|
||||
env.update(self._paths.portable_environment())
|
||||
proc = subprocess.Popen(
|
||||
spec.cmd,
|
||||
env=env,
|
||||
cwd=str(self._paths.root) if self._paths is not None else None,
|
||||
)
|
||||
state = self._states.get(spec.name)
|
||||
if state is None:
|
||||
state = ProcessState(spec=spec)
|
||||
self._states[spec.name] = state
|
||||
else:
|
||||
state.spec = spec
|
||||
state.proc = proc
|
||||
state.last_start_ns = time.monotonic_ns()
|
||||
state.stopped_by_supervisor = False
|
||||
|
||||
def stop(self, name: str, timeout: float | None = None) -> int | None:
|
||||
"""Kontrolliertes Beenden: terminate → warten → kill (§26.4).
|
||||
|
||||
Gibt den Rückgabecode zurück; None falls der Prozess nicht lief.
|
||||
"""
|
||||
state = self._states.get(name)
|
||||
if state is None or state.proc is None:
|
||||
return None
|
||||
if not state.running:
|
||||
return state.returncode
|
||||
state.stopped_by_supervisor = True
|
||||
timeout = timeout if timeout is not None else state.spec.stop_timeout_s
|
||||
state.proc.terminate() # SIGTERM: laufende Writes abschließen (§26.4)
|
||||
try:
|
||||
return state.proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Zwangsbeendigung: Recovery-Markierung setzen (§26.4)
|
||||
state.proc.kill()
|
||||
self._write_recovery_marker(name)
|
||||
return state.proc.wait(timeout=5)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Beendet alle Prozesse kontrolliert (Renderer zuletzt, um Output
|
||||
so lange wie möglich zu halten; §26.2)."""
|
||||
for name in reversed(list(self._states)):
|
||||
self.stop(name)
|
||||
|
||||
# ---------- Überwachung (§26.2) ----------
|
||||
|
||||
def check(self) -> dict[str, str]:
|
||||
"""Prüft alle Prozesse; startet Abgestürzte gemäß Policy neu.
|
||||
|
||||
Rückgabe: name → Zustand (running/restarted/crashloop/stopped).
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
now = time.monotonic()
|
||||
for name, state in list(self._states.items()):
|
||||
if state.running:
|
||||
result[name] = "running"
|
||||
continue
|
||||
if state.stopped_by_supervisor:
|
||||
result[name] = "stopped"
|
||||
continue
|
||||
if not state.spec.restartable or state.crashlooped:
|
||||
result[name] = "crashloop" if state.crashlooped else "stopped"
|
||||
continue
|
||||
# Neustarts innerhalb des Zeitfensters zählen (Crashloop, §26.2)
|
||||
state.restarts = [
|
||||
t for t in state.restarts if now - t < state.spec.restart_window_s
|
||||
]
|
||||
if len(state.restarts) >= state.spec.max_restarts:
|
||||
state.crashlooped = True # endlose Neustarts verhindern
|
||||
result[name] = "crashloop"
|
||||
continue
|
||||
state.restarts.append(now)
|
||||
self.start(state.spec)
|
||||
result[name] = "restarted"
|
||||
return result
|
||||
|
||||
def state(self, name: str) -> ProcessState:
|
||||
return self._states[name]
|
||||
|
||||
def names(self) -> list[str]:
|
||||
return list(self._states)
|
||||
|
||||
# ---------- Recovery (§26.4) ----------
|
||||
|
||||
def _write_recovery_marker(self, name: str) -> None:
|
||||
if self._recovery_marker is None:
|
||||
return
|
||||
self._recovery_marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self._recovery_marker, "a", encoding="utf-8") as fh:
|
||||
fh.write(
|
||||
f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} forced-kill {name}\n"
|
||||
)
|
||||
|
||||
def consume_recovery_marker(self) -> list[str]:
|
||||
"""Liest und löscht die Recovery-Markierung (Crash-Recovery-Dialog,
|
||||
§24.3/§26.4). Gibt die Zeilen zurück."""
|
||||
if self._recovery_marker is None or not self._recovery_marker.is_file():
|
||||
return []
|
||||
lines = self._recovery_marker.read_text(encoding="utf-8").splitlines()
|
||||
self._recovery_marker.unlink()
|
||||
return lines
|
||||
|
||||
|
||||
# Windows-kompatibles SIGTERM: terminate() nutzt auf Windows TerminateProcess,
|
||||
# das kein SIGTERM ist. Für sauberes Shutdown nutzen Kindprozesse dort einen
|
||||
# Steuerkanal (IPC-command) – der Supervisor sendet SIGTERM nur auf POSIX.
|
||||
|
||||
|
||||
def request_graceful_stop(proc: subprocess.Popen, timeout: float) -> int | None:
|
||||
"""POSIX: SIGTERM; Windows: proc.terminate(). Wartet dann kontrolliert."""
|
||||
if os.name == "posix":
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
else:
|
||||
proc.terminate()
|
||||
try:
|
||||
return proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return None
|
||||
Reference in New Issue
Block a user