Refactor radar system
This commit is contained in:
@@ -3,18 +3,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Iterable
|
||||
from typing import Sequence
|
||||
|
||||
from python_app.file_io import atomic_write_text
|
||||
from python_app.orchestration.producer_selection import acquisition_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cap each child log so a long-lived daemon cannot fill the SD card. On reaching
|
||||
@@ -244,31 +245,23 @@ class ProcessSupervisor:
|
||||
# on every write, so a truncate genuinely restarts the file at offset 0.
|
||||
# `_roll_log_to_prev` above already renamed any previous log away, so not
|
||||
# truncating on open costs nothing.
|
||||
stdout_file = open(stdout_path, "ab")
|
||||
stderr_file = open(stderr_path, "ab")
|
||||
try:
|
||||
handle = subprocess.Popen(
|
||||
command,
|
||||
cwd=self._project_root,
|
||||
stdout=stdout_file,
|
||||
stderr=stderr_file,
|
||||
# Own session/process group so signalling the group on stop also
|
||||
# reaches device-I/O grandchildren the producer may have spawned.
|
||||
start_new_session=True,
|
||||
)
|
||||
with stdout_path.open("ab") as stdout_file, stderr_path.open("ab") as stderr_file:
|
||||
handle = subprocess.Popen(
|
||||
command,
|
||||
cwd=self._project_root,
|
||||
stdout=stdout_file,
|
||||
stderr=stderr_file,
|
||||
# Device-I/O grandchildren share this group and stop with it.
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError as exc:
|
||||
stdout_file.close()
|
||||
stderr_file.close()
|
||||
command_text = shlex.join(command)
|
||||
logger.error("Failed to spawn `%s`: %s: %s", name, type(exc).__name__, exc)
|
||||
raise RuntimeError(
|
||||
f"Failed to spawn {name} with command `{command_text}` from `{self._project_root}`: "
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
# The child holds its own dup'd fds; the parent's copies are not needed.
|
||||
stdout_file.close()
|
||||
stderr_file.close()
|
||||
|
||||
self._processes[name] = ManagedProcess(
|
||||
name=name,
|
||||
@@ -282,68 +275,8 @@ class ProcessSupervisor:
|
||||
self._write_pidfile()
|
||||
|
||||
def _acquisition_command(self, config_path: Path) -> list[str]:
|
||||
"""Return acquisition producer command selected by radar.model."""
|
||||
# A configured replay dataset overrides the model's normal producer:
|
||||
# recorded snapshots are replayed into the raw ring instead of
|
||||
# acquiring from (mock) hardware.
|
||||
replay_dir = self._read_replay_dataset_dir(config_path)
|
||||
if replay_dir:
|
||||
logger.debug("Selecting replay producer for dataset_dir=%s", replay_dir)
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"python_app.scripts.replay_raw_producer",
|
||||
"--config",
|
||||
str(config_path),
|
||||
]
|
||||
radar_model = self._read_radar_model(config_path)
|
||||
logger.debug("Selecting acquisition producer for radar.model=%s", radar_model)
|
||||
if radar_model in {"librevna_multi", "sn9000"}:
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"python_app.scripts.matrix_raw_producer",
|
||||
"--config",
|
||||
str(config_path),
|
||||
]
|
||||
if radar_model == "kamil_adc":
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"python_app.scripts.kamil_adc_raw_producer",
|
||||
"--config",
|
||||
str(config_path),
|
||||
]
|
||||
return [
|
||||
str(self._project_root / "build/bin/sweep_orchestrator"),
|
||||
"--config",
|
||||
str(config_path),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _read_radar_model(config_path: Path) -> str:
|
||||
"""Read `radar.model` cheaply without constructing the full config model."""
|
||||
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
return "librevna"
|
||||
radar_payload = payload.get("radar")
|
||||
if not isinstance(radar_payload, dict):
|
||||
return "librevna"
|
||||
return str(radar_payload.get("model", "librevna"))
|
||||
|
||||
@staticmethod
|
||||
def _read_replay_dataset_dir(config_path: Path) -> str:
|
||||
"""Read `radar.replay.dataset_dir` cheaply, '' when replay is off."""
|
||||
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
radar_payload = payload.get("radar")
|
||||
if not isinstance(radar_payload, dict):
|
||||
return ""
|
||||
replay_payload = radar_payload.get("replay")
|
||||
if not isinstance(replay_payload, dict):
|
||||
return ""
|
||||
return str(replay_payload.get("dataset_dir", "")).strip()
|
||||
"""Select the same acquisition producer as the shell launcher."""
|
||||
return acquisition_command(self._project_root, config_path)
|
||||
|
||||
def _stop_processes(self, names: Iterable[str]) -> None:
|
||||
"""Gracefully terminate processes, then force-kill on per-process timeout."""
|
||||
@@ -393,7 +326,12 @@ class ProcessSupervisor:
|
||||
if pid is None:
|
||||
return
|
||||
try:
|
||||
os.killpg(os.getpgid(pid), sig)
|
||||
# Managed children lead their own sessions. Never signal an unrelated
|
||||
# shell's group if a stale PID or an external collector does not.
|
||||
if os.getpgid(pid) == pid:
|
||||
os.killpg(pid, sig)
|
||||
else:
|
||||
os.kill(pid, sig)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
# Group already gone, or could not resolve it; fall back to the child.
|
||||
try:
|
||||
@@ -401,7 +339,8 @@ class ProcessSupervisor:
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
|
||||
def _kill_kamil_collectors(self) -> None:
|
||||
@staticmethod
|
||||
def _kill_kamil_collectors() -> None:
|
||||
"""SIGKILL any Kamil ADC collector found by name — a guaranteed device backstop.
|
||||
|
||||
The collector survives a force-killed producer (own session) and can hang
|
||||
@@ -421,8 +360,9 @@ class ProcessSupervisor:
|
||||
cmdline = (Path("/proc") / str(pid) / "cmdline").read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
if _KAMIL_COLLECTOR_MARKER in cmdline:
|
||||
self._signal_group(pid, signal.SIGKILL)
|
||||
executable = cmdline.split(b"\x00", 1)[0].rsplit(b"/", 1)[-1]
|
||||
if executable == _KAMIL_COLLECTOR_MARKER:
|
||||
ProcessSupervisor._signal_group(pid, signal.SIGKILL)
|
||||
killed += 1
|
||||
if killed:
|
||||
logger.warning("Force-killed %d orphaned Kamil ADC collector process(es)", killed)
|
||||
@@ -569,7 +509,7 @@ class ProcessSupervisor:
|
||||
for process in self._processes.values()
|
||||
if process.handle.poll() is None and process.handle.pid is not None
|
||||
]
|
||||
self._pidfile_path.write_text("\n".join(live_pids), encoding="utf-8")
|
||||
atomic_write_text(self._pidfile_path, "\n".join(live_pids))
|
||||
except OSError:
|
||||
# Best-effort bookkeeping: failure here must not break start/stop.
|
||||
pass
|
||||
@@ -599,7 +539,7 @@ class ProcessSupervisor:
|
||||
logger.warning("Reaping stale pipeline child from prior run (pid=%d)", pid)
|
||||
self._signal_group(pid, signal.SIGKILL)
|
||||
try:
|
||||
self._pidfile_path.write_text("", encoding="utf-8")
|
||||
atomic_write_text(self._pidfile_path, "")
|
||||
except OSError:
|
||||
pass
|
||||
# The ADC collector is a grandchild (spawned by the producer), not in the
|
||||
@@ -612,17 +552,20 @@ class ProcessSupervisor:
|
||||
Reads `/proc/<pid>/cmdline` so a recycled PID owned by an unrelated
|
||||
process is never killed.
|
||||
"""
|
||||
markers = (
|
||||
"build/bin/data_processor",
|
||||
"build/bin/data_preprocessor",
|
||||
"build/bin/sweep_orchestrator",
|
||||
native_names = {"data_processor", "data_preprocessor", "sweep_orchestrator"}
|
||||
producer_modules = {
|
||||
"python_app.scripts.matrix_raw_producer",
|
||||
"python_app.scripts.kamil_adc_raw_producer",
|
||||
"python_app.scripts.replay_raw_producer",
|
||||
)
|
||||
}
|
||||
try:
|
||||
raw = (Path("/proc") / str(pid) / "cmdline").read_bytes()
|
||||
except OSError:
|
||||
return False
|
||||
cmdline = raw.replace(b"\x00", b" ").decode("utf-8", errors="replace")
|
||||
return any(marker in cmdline for marker in markers)
|
||||
argv = raw.decode("utf-8", errors="replace").rstrip("\x00").split("\x00")
|
||||
executable = Path(argv[0])
|
||||
if executable.name in native_names:
|
||||
expected = self._project_root / "build/bin" / executable.name
|
||||
return executable == expected or executable == Path("build/bin") / executable.name
|
||||
is_python = executable.name in {"python", "python3"} or executable.name.startswith("python3.")
|
||||
return is_python and len(argv) >= 3 and argv[1] == "-m" and argv[2] in producer_modules
|
||||
|
||||
Reference in New Issue
Block a user