#!/usr/bin/env python3 """Minimal two-process supervisor with bounded restart and graceful shutdown.""" from __future__ import annotations import logging import logging.handlers import os import signal import subprocess import sys import threading import time from pathlib import Path STOP = threading.Event() RUN_DIR = Path("/run/lumaops") LOG_DIR = Path(os.environ.get("LOGS_DIR", "/logs")) def configure_logging() -> None: logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s supervisor %(message)s", stream=sys.stdout, ) def signal_handler(signum: int, _frame: object) -> None: logging.info("received signal %s; starting graceful shutdown", signum) STOP.set() class ManagedProcess: def __init__(self, name: str, command: list[str], log_name: str) -> None: self.name = name self.command = command self.log_name = log_name self.process: subprocess.Popen[str] | None = None self.restarts = 0 self.next_start = 0.0 def start(self) -> None: if STOP.is_set(): return logging.info("starting %s", self.name) self.process = subprocess.Popen( # noqa: S603 - fixed administrator-controlled argv self.command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, env=os.environ.copy(), ) (RUN_DIR / f"{self.name}.pid").write_text(str(self.process.pid), encoding="ascii") threading.Thread(target=self._pump_output, daemon=True, name=f"{self.name}-logs").start() def _pump_output(self) -> None: assert self.process is not None and self.process.stdout is not None handler = logging.handlers.RotatingFileHandler( LOG_DIR / self.log_name, maxBytes=int(os.environ.get("LOG_MAX_BYTES", str(10 * 1024 * 1024))), backupCount=int(os.environ.get("LOG_BACKUP_COUNT", "5")), encoding="utf-8", ) handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) logger = logging.getLogger(f"process.{self.name}") logger.handlers = [handler] logger.propagate = False logger.setLevel(logging.INFO) for line in self.process.stdout: logger.info("%s", line.rstrip()) handler.close() def poll_and_restart(self) -> None: if self.process is None: if time.monotonic() >= self.next_start: self.start() return code = self.process.poll() if code is None: if time.monotonic() - self.next_start > 60: self.restarts = 0 return logging.error("%s exited with code %s", self.name, code) (RUN_DIR / f"{self.name}.pid").unlink(missing_ok=True) self.process = None delay = min(30.0, 0.5 * (2 ** min(self.restarts, 6))) self.restarts += 1 self.next_start = time.monotonic() + delay def terminate(self, timeout: float = 12.0) -> None: process = self.process if process is None or process.poll() is not None: return logging.info("stopping %s", self.name) process.send_signal(signal.SIGTERM) try: process.wait(timeout=timeout) except subprocess.TimeoutExpired: logging.warning("%s did not stop in %.1fs; sending SIGKILL", self.name, timeout) process.kill() process.wait(timeout=3) (RUN_DIR / f"{self.name}.pid").unlink(missing_ok=True) def main() -> int: configure_logging() for sig in (signal.SIGINT, signal.SIGTERM): signal.signal(sig, signal_handler) RUN_DIR.mkdir(parents=True, exist_ok=True) LOG_DIR.mkdir(parents=True, exist_ok=True) openrgb_config = Path(os.environ.get("OPENRGB_CONFIG_DIR", "/config/openrgb")) openrgb_config.mkdir(parents=True, exist_ok=True) port = os.environ.get("OPENRGB_PORT", "6742") openrgb = ManagedProcess( "openrgb", [ "/usr/local/bin/openrgb", "--server", "--server-host", "127.0.0.1", "--server-port", port, "--config", str(openrgb_config), "--noautoconnect", ], "openrgb.log", ) backend = ManagedProcess( "backend", ["/opt/venv/bin/python", "-m", "lumaops_backend.main"], "backend.log", ) processes = [openrgb, backend] for process in processes: process.start() process.next_start = time.monotonic() try: while not STOP.wait(0.5): for process in processes: process.poll_and_restart() finally: backend.terminate() openrgb.terminate() return 0 if __name__ == "__main__": raise SystemExit(main())