init commit

This commit is contained in:
Ayzen
2026-03-05 14:42:33 +03:00
commit fd4618b20d
964 changed files with 325114 additions and 0 deletions
@@ -0,0 +1,202 @@
"""Process supervisor for lifecycle management of C++ pipeline binaries."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import subprocess
import time
from typing import Iterable
from typing import Sequence
@dataclass(slots=True)
class ManagedProcess:
"""Metadata and subprocess handle for one managed child process."""
name: str
command: list[str]
handle: subprocess.Popen[str]
class ProcessSupervisor:
"""Start, monitor, and stop pipeline subprocesses."""
def __init__(self, project_root: Path, readiness_timeout_s: float = 3.0) -> None:
"""Create supervisor bound to repository root directory."""
self._project_root = project_root
self._readiness_timeout_s = readiness_timeout_s
self._processes: dict[str, ManagedProcess] = {}
def is_running(self) -> bool:
"""Return whether acquisition-side processes are alive."""
return self._is_alive("data_preprocessor") or self._is_alive("sweep_orchestrator")
def is_processor_running(self) -> bool:
"""Return whether data processor process is alive."""
return self._is_alive("data_processor")
def start(self, config_path: Path) -> None:
"""Start required pipeline binaries and wait until they are ready."""
if self.is_running():
raise RuntimeError("Acquisition processes are already running")
command_specs = {
"data_processor": [
str(self._project_root / "build/bin/data_processor"),
"--config",
str(config_path),
],
"data_preprocessor": [
str(self._project_root / "build/bin/data_preprocessor"),
"--config",
str(config_path),
],
"sweep_orchestrator": [
str(self._project_root / "build/bin/sweep_orchestrator"),
"--config",
str(config_path),
],
}
processor_was_running = self.is_processor_running()
required_processes: list[str] = ["data_preprocessor", "sweep_orchestrator"]
if not processor_was_running:
required_processes.insert(0, "data_processor")
try:
if not processor_was_running:
self._spawn("data_processor", command_specs["data_processor"])
self._spawn("data_preprocessor", command_specs["data_preprocessor"])
self._spawn("sweep_orchestrator", command_specs["sweep_orchestrator"])
self._wait_until_ready(required_processes)
except Exception:
if processor_was_running:
self.stop()
else:
self.stop_all()
raise
def stop(self) -> None:
"""Stop acquisition-side processes, keep processor process intact."""
self._stop_processes(["sweep_orchestrator", "data_preprocessor"])
def stop_orchestrator(self) -> None:
"""Stop orchestrator process only."""
self._stop_processes(["sweep_orchestrator"])
def stop_preprocessor(self) -> None:
"""Stop preprocessor process only."""
self._stop_processes(["data_preprocessor"])
def stop_all(self) -> None:
"""Stop all managed processes."""
self._stop_processes(["sweep_orchestrator", "data_preprocessor", "data_processor"])
def _spawn(self, name: str, command: list[str]) -> None:
"""Spawn one process unless same process is already alive."""
existing = self._processes.get(name)
if existing is not None and existing.handle.poll() is None:
return
handle = subprocess.Popen(
command,
cwd=self._project_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
self._processes[name] = ManagedProcess(name=name, command=command, handle=handle)
def _stop_processes(self, names: Iterable[str]) -> None:
"""Gracefully terminate processes, then force-kill on timeout."""
ordered_names = list(names)
for name in ordered_names:
process = self._processes.get(name)
if process is None:
continue
if process.handle.poll() is None:
process.handle.terminate()
deadline = time.monotonic() + 2.0
for name in ordered_names:
process = self._processes.get(name)
if process is None:
continue
if process.handle.poll() is not None:
continue
timeout = max(0.0, deadline - time.monotonic())
try:
process.handle.wait(timeout=timeout)
except subprocess.TimeoutExpired:
process.handle.kill()
process.handle.wait(timeout=1.0)
self._drop_exited()
def _drop_exited(self) -> None:
"""Remove exited process entries from internal map."""
exited_names = [name for name, process in self._processes.items() if process.handle.poll() is not None]
for name in exited_names:
self._processes.pop(name, None)
def _is_alive(self, name: str) -> bool:
"""Return `True` when named process handle exists and is running."""
process = self._processes.get(name)
if process is None:
return False
return process.handle.poll() is None
def collect_crash_reports(self) -> list[str]:
"""Collect stderr/stdout reports from processes that have exited."""
reports: list[str] = []
exited_names: list[str] = []
for name, process in self._processes.items():
return_code = process.handle.poll()
if return_code is None:
continue
stderr = ""
stdout = ""
if process.handle.stdout is not None:
stdout = process.handle.stdout.read().strip()
if process.handle.stderr is not None:
stderr = process.handle.stderr.read().strip()
details = stderr
if stdout and stderr:
details = f"{stderr}\nstdout:\n{stdout}"
elif stdout:
details = f"stdout:\n{stdout}"
reports.append(f"{process.name} exited with code {return_code}: {details}")
exited_names.append(name)
for name in exited_names:
self._processes.pop(name, None)
return reports
def _wait_until_ready(self, required_processes: Sequence[str]) -> None:
"""Wait until all required processes are alive or timeout/crash occurs."""
deadline = time.monotonic() + self._readiness_timeout_s
while time.monotonic() < deadline:
crashed = self.collect_crash_reports()
if crashed:
raise RuntimeError("; ".join(crashed))
if all(self._is_alive(process_name) for process_name in required_processes):
return
time.sleep(0.05)
names = ", ".join(required_processes)
raise RuntimeError(f"Timed out waiting for processes to start: {names}")
def pids(self) -> dict[str, int]:
"""Return PID mapping for currently alive managed processes."""
return {
process.name: process.handle.pid
for process in self._processes.values()
if process.handle.poll() is None and process.handle.pid is not None
}