Refactor radar system
This commit is contained in:
@@ -13,13 +13,16 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from python_app.models.run_config_model import RunConfigModel
|
||||
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin
|
||||
from python_app.orchestration.config_writer import ConfigWriter
|
||||
from python_app.orchestration.process_supervisor import ProcessExitReport, ProcessSupervisor
|
||||
from python_app.orchestration.restart_policy import RestartPolicy
|
||||
@@ -53,6 +56,64 @@ class RestartPolicyTest(unittest.TestCase):
|
||||
self.assertTrue(RestartPolicy().should_restart_now(now_s=5_000.0, last_restart_s=0.0, consecutive_failures=0))
|
||||
|
||||
|
||||
class PipelineRecoveryPollingTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.window = mock.Mock(spec=AppWindowPipelineMixin)
|
||||
self.window._pipeline_should_run = True
|
||||
self.window._supervisor = mock.Mock()
|
||||
self.window._supervisor.collect_exit_reports.return_value = []
|
||||
|
||||
def test_missing_child_keeps_retrying_without_fresh_exit_reports(self) -> None:
|
||||
self.window._supervisor.pids.return_value = {"data_processor": 1, "data_preprocessor": 2}
|
||||
for _ in range(3):
|
||||
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
|
||||
self.assertEqual(self.window._recover_pipeline_after_crash.call_count, 3)
|
||||
|
||||
def test_failed_start_with_no_children_keeps_retrying(self) -> None:
|
||||
self.window._supervisor.pids.return_value = {}
|
||||
for _ in range(3):
|
||||
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
|
||||
self.assertEqual(self.window._recover_pipeline_after_crash.call_count, 3)
|
||||
|
||||
def test_failed_restarts_retry_after_backoff_without_new_exit_reports(self) -> None:
|
||||
self.window._supervisor.pids.return_value = {}
|
||||
self.window._supervisor.start.side_effect = RuntimeError("device unavailable")
|
||||
self.window._active_run_config = RunConfigModel()
|
||||
self.window._active_run_config_path = Path("run_config.json")
|
||||
self.window._pipeline_restart_count = 0
|
||||
self.window._last_pipeline_restart_s = 0.0
|
||||
self.window._RESTART_POLICY = RestartPolicy()
|
||||
self.window._log_error = mock.Mock()
|
||||
self.window._log_exception = mock.Mock()
|
||||
self.window._recover_pipeline_after_crash = (
|
||||
lambda: AppWindowPipelineMixin._recover_pipeline_after_crash(self.window)
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"python_app.gui.controllers.app_window_pipeline_mixin.time.monotonic",
|
||||
side_effect=[100.0, 101.0, 106.0],
|
||||
):
|
||||
for _ in range(3):
|
||||
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
|
||||
|
||||
self.assertEqual(self.window._supervisor.start.call_count, 2)
|
||||
self.assertEqual(self.window._pipeline_restart_count, 2)
|
||||
self.assertTrue(self.window._pipeline_should_run)
|
||||
|
||||
def test_healthy_pipeline_does_not_restart(self) -> None:
|
||||
self.window._supervisor.pids.return_value = {
|
||||
"data_processor": 1, "data_preprocessor": 2, "sweep_orchestrator": 3,
|
||||
}
|
||||
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
|
||||
self.window._recover_pipeline_after_crash.assert_not_called()
|
||||
|
||||
def test_explicit_stop_does_not_restart_missing_children(self) -> None:
|
||||
self.window._pipeline_should_run = False
|
||||
self.window._supervisor.pids.return_value = {}
|
||||
AppWindowPipelineMixin._handle_process_exit_reports(self.window)
|
||||
self.window._recover_pipeline_after_crash.assert_not_called()
|
||||
|
||||
|
||||
class ConfigWriterTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
@@ -88,23 +149,32 @@ class RadarModelCommandTest(unittest.TestCase):
|
||||
self.root = Path(self._dir.name)
|
||||
self.supervisor = ProcessSupervisor(self.root)
|
||||
|
||||
def _config_with_model(self, model: str) -> Path:
|
||||
def _config_with_model(self, model: str, driver_mode: str = "native") -> Path:
|
||||
path = self.root / "cfg.json"
|
||||
path.write_text(json.dumps({"radar": {"model": model}}))
|
||||
path.write_text(json.dumps({"radar": {"model": model, "driver_mode": driver_mode}}))
|
||||
return path
|
||||
|
||||
def test_read_radar_model(self) -> None:
|
||||
self.assertEqual(ProcessSupervisor._read_radar_model(self._config_with_model("kamil_adc")), "kamil_adc")
|
||||
|
||||
def test_read_radar_model_defaults_on_malformed(self) -> None:
|
||||
def test_malformed_root_falls_back_to_native_producer(self) -> None:
|
||||
path = self.root / "bad.json"
|
||||
path.write_text("[]") # not an object
|
||||
self.assertEqual(ProcessSupervisor._read_radar_model(path), "librevna")
|
||||
path.write_text("[]")
|
||||
self.assertTrue(self.supervisor._acquisition_command(path)[0].endswith("sweep_orchestrator"))
|
||||
|
||||
def test_matrix_producer_for_multi_and_sn9000(self) -> None:
|
||||
for model in ("librevna_multi", "sn9000"):
|
||||
cmd = self.supervisor._acquisition_command(self._config_with_model(model))
|
||||
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
|
||||
def test_null_replay_directory_does_not_select_replay(self) -> None:
|
||||
path = self._config_with_model("librevna")
|
||||
path.write_text(json.dumps({"radar": {"replay": {"dataset_dir": None}}}))
|
||||
self.assertEqual(self.supervisor._acquisition_command(path)[:3],
|
||||
[sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
|
||||
|
||||
def test_replay_overrides_every_model(self) -> None:
|
||||
for model in ("librevna", "librevna_multi", "kamil_adc"):
|
||||
path = self._config_with_model(model)
|
||||
path.write_text(json.dumps({"radar": {"model": model, "replay": {"dataset_dir": "capture"}}}))
|
||||
self.assertEqual(self.supervisor._acquisition_command(path)[:3],
|
||||
[sys.executable, "-m", "python_app.scripts.replay_raw_producer"])
|
||||
|
||||
def test_matrix_producer_for_multi(self) -> None:
|
||||
cmd = self.supervisor._acquisition_command(self._config_with_model("librevna_multi"))
|
||||
self.assertEqual(cmd[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
|
||||
|
||||
def test_kamil_producer_for_kamil_adc(self) -> None:
|
||||
cmd = self.supervisor._acquisition_command(self._config_with_model("kamil_adc"))
|
||||
@@ -114,6 +184,30 @@ class RadarModelCommandTest(unittest.TestCase):
|
||||
cmd = self.supervisor._acquisition_command(self._config_with_model("librevna"))
|
||||
self.assertTrue(cmd[0].endswith("build/bin/sweep_orchestrator"))
|
||||
|
||||
def test_single_and_multi_mock_share_the_simulator(self) -> None:
|
||||
for model in ("librevna", "librevna_multi"):
|
||||
command = self.supervisor._acquisition_command(self._config_with_model(model, "mock"))
|
||||
self.assertEqual(command[:3], [sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
|
||||
|
||||
def test_omitted_driver_mode_uses_the_default_simulator(self) -> None:
|
||||
path = self._config_with_model("librevna")
|
||||
path.write_text(json.dumps({"radar": {"model": "librevna"}}))
|
||||
self.assertEqual(self.supervisor._acquisition_command(path)[2], "python_app.scripts.matrix_raw_producer")
|
||||
|
||||
def test_missing_or_null_radar_fields_keep_schema_defaults(self) -> None:
|
||||
path = self.root / "defaults.json"
|
||||
for payload in ({}, {"radar": None}, {"radar": {}}, {"radar": {"model": None}},
|
||||
{"radar": {"driver_mode": None}}, {"radar": {"driver_mode": "mock"}}):
|
||||
with self.subTest(payload=payload):
|
||||
path.write_text(json.dumps(payload))
|
||||
self.assertEqual(self.supervisor._acquisition_command(path)[:3],
|
||||
[sys.executable, "-m", "python_app.scripts.matrix_raw_producer"])
|
||||
|
||||
def test_omitted_model_with_native_mode_uses_native_producer(self) -> None:
|
||||
path = self.root / "native.json"
|
||||
path.write_text(json.dumps({"radar": {"driver_mode": "native"}}))
|
||||
self.assertTrue(self.supervisor._acquisition_command(path)[0].endswith("sweep_orchestrator"))
|
||||
|
||||
|
||||
class ProcessExitReportTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
@@ -192,6 +286,46 @@ class SupervisorLifecycleTest(unittest.TestCase):
|
||||
self.assertFalse(self.supervisor._is_stale_pipeline_pid(2_000_000_000)) # no such pid
|
||||
self.assertFalse(self.supervisor._is_stale_pipeline_pid(os.getpid())) # the test runner
|
||||
|
||||
def test_stale_pid_guard_matches_executable_or_module_arguments(self) -> None:
|
||||
processor = str(self.supervisor._project_root / "build/bin/data_processor")
|
||||
cases = (
|
||||
(f"{processor}\x00--config\x00config.json\x00", True),
|
||||
("python\x00-m\x00python_app.scripts.matrix_raw_producer\x00", True),
|
||||
(f"bash\x00-c\x00echo {processor}\x00", False),
|
||||
("python\x00-c\x00print('python_app.scripts.matrix_raw_producer')\x00", False),
|
||||
("/other/project/build/bin/data_processor\x00", False),
|
||||
)
|
||||
for cmdline, expected in cases:
|
||||
with (
|
||||
self.subTest(cmdline=cmdline),
|
||||
mock.patch.object(Path, "read_bytes", return_value=cmdline.encode()),
|
||||
):
|
||||
self.assertEqual(self.supervisor._is_stale_pipeline_pid(123), expected)
|
||||
|
||||
def test_signal_group_does_not_kill_a_non_leaders_group(self) -> None:
|
||||
with (
|
||||
mock.patch.object(os, "getpgid", return_value=456),
|
||||
mock.patch.object(os, "killpg") as group_kill,
|
||||
mock.patch.object(os, "kill") as kill,
|
||||
):
|
||||
self.supervisor._signal_group(123, signal.SIGTERM)
|
||||
group_kill.assert_not_called()
|
||||
kill.assert_called_once_with(123, signal.SIGTERM)
|
||||
|
||||
def test_collector_reaper_does_not_match_command_text(self) -> None:
|
||||
entries = [Path("/proc/123"), Path("/proc/456")]
|
||||
cmdlines = [
|
||||
b"bash\x00-c\x00echo kamil_adc_collector\x00",
|
||||
b"/project/build/bin/kamil_adc_collector\x00--config\x00config.json\x00",
|
||||
]
|
||||
with (
|
||||
mock.patch.object(Path, "iterdir", return_value=entries),
|
||||
mock.patch.object(Path, "read_bytes", side_effect=cmdlines),
|
||||
mock.patch.object(ProcessSupervisor, "_signal_group") as signal_group,
|
||||
):
|
||||
self.supervisor._kill_kamil_collectors()
|
||||
signal_group.assert_called_once_with(456, signal.SIGKILL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user