some fixes
This commit is contained in:
@@ -12,13 +12,15 @@ import html
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtGui import QTextCursor
|
||||
from PyQt6.QtWidgets import QMainWindow, QMessageBox
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow, QMessageBox
|
||||
|
||||
from python_app.gui.controllers.app_window_config_mixin import AppWindowConfigMixin
|
||||
from python_app.gui.controllers.app_window_control_button_mixin import AppWindowControlButtonMixin
|
||||
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin
|
||||
from python_app.gui.controllers.app_window_plot_mixin import AppWindowPlotMixin
|
||||
from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin
|
||||
@@ -47,6 +49,7 @@ class AppWindow(
|
||||
AppWindowPlotMixin,
|
||||
AppWindowPipelineMixin,
|
||||
AppWindowSnapshotMixin,
|
||||
AppWindowControlButtonMixin,
|
||||
QMainWindow,
|
||||
):
|
||||
"""Top-level window coordinating GUI state and acquisition runtime."""
|
||||
@@ -64,6 +67,7 @@ class AppWindow(
|
||||
self._init_history_state()
|
||||
self._init_runtime_limits()
|
||||
self._init_polling_timer()
|
||||
self._init_control_button_state()
|
||||
self._bootstrap_ui_runtime()
|
||||
|
||||
def _init_paths(self, project_root: Path) -> None:
|
||||
@@ -234,6 +238,9 @@ class AppWindow(
|
||||
self._pipeline_metrics.set_log_sink(self._log)
|
||||
self._timer.start()
|
||||
self._maybe_auto_start_pipeline()
|
||||
self._start_control_button_watcher()
|
||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
||||
self._install_headless_watchdog()
|
||||
|
||||
def _resolve_startup_profile_path(self) -> Path:
|
||||
"""Resolve active profile path from session-state or root fallback path."""
|
||||
@@ -278,7 +285,7 @@ class AppWindow(
|
||||
if self._is_truthy_env("RADAR_SYSTEM_AUTO_APPLY_RADAR"):
|
||||
QTimer.singleShot(500, self._auto_apply_radar_then_start)
|
||||
else:
|
||||
QTimer.singleShot(500, self._start_run)
|
||||
QTimer.singleShot(500, self._auto_start_pipeline_step)
|
||||
|
||||
def _auto_apply_radar_then_start(self) -> None:
|
||||
"""Apply current radar settings then start the pipeline (headless boot)."""
|
||||
@@ -287,8 +294,59 @@ class AppWindow(
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_exception("Auto apply-radar failed", exc, level="WARN")
|
||||
# Hand control back to the event loop so widget updates from
|
||||
# _apply_radar_settings can flush before _start_run takes over.
|
||||
QTimer.singleShot(100, self._start_run)
|
||||
# _apply_radar_settings can flush, then wait one second before the
|
||||
# start takes over so the device settles after apply-radar.
|
||||
QTimer.singleShot(1000, self._auto_start_pipeline_step)
|
||||
|
||||
def _auto_start_pipeline_step(self) -> None:
|
||||
"""Run the launcher-requested pipeline start.
|
||||
|
||||
In headless mode a start that does not bring the pipeline up is fatal: we
|
||||
exit non-zero so `systemd Restart=on-failure` restarts the unit instead of
|
||||
leaving an idle daemon producing nothing. (The producer itself waits for
|
||||
the device forever, so a live-but-deviceless producer counts as running.)
|
||||
"""
|
||||
self._start_run()
|
||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS") and not self._supervisor.is_running():
|
||||
self._headless_fatal("Headless auto-start did not bring the pipeline up")
|
||||
|
||||
def _install_headless_watchdog(self) -> None:
|
||||
"""Self-heal a headless daemon: if a managed pipeline process crashes (exits
|
||||
without us stopping it), exit non-zero so the service restarts clean.
|
||||
|
||||
Intentional stops drop processes from the supervisor first, so a normal
|
||||
stop/start or tmp-reference transition never trips this.
|
||||
"""
|
||||
self._headless_watchdog = QTimer(self)
|
||||
self._headless_watchdog.setInterval(2000)
|
||||
self._headless_watchdog.timeout.connect(self._headless_watchdog_tick)
|
||||
self._headless_watchdog.start()
|
||||
|
||||
def _headless_watchdog_tick(self) -> None:
|
||||
"""Escalate any unexpected managed-process exit to a fatal headless restart."""
|
||||
crashed = [
|
||||
report
|
||||
for report in self._supervisor.collect_exit_reports()
|
||||
if not report.expected_clean_exit
|
||||
]
|
||||
if crashed:
|
||||
names = ", ".join(report.name for report in crashed)
|
||||
details = "\n\n".join(report.format() for report in crashed)
|
||||
self._headless_fatal(f"Pipeline process exited unexpectedly: {names}", details=details)
|
||||
|
||||
def _headless_fatal(self, reason: str, *, details: str | None = None) -> None:
|
||||
"""Log loudly to stderr and exit non-zero so systemd restarts the service.
|
||||
|
||||
Headless deployments have no operator and the in-app log only reaches an
|
||||
offscreen widget, so a dead pipeline would otherwise go unnoticed.
|
||||
"""
|
||||
self._log_error(reason, details=details)
|
||||
print(f"[radar] FATAL (headless): {reason}", file=sys.stderr, flush=True)
|
||||
if details:
|
||||
print(details, file=sys.stderr, flush=True)
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.exit(1)
|
||||
|
||||
@staticmethod
|
||||
def _is_truthy_env(name: str) -> bool:
|
||||
@@ -507,6 +565,8 @@ class AppWindow(
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
"""Ensure workers and dialogs are closed before window destruction."""
|
||||
try:
|
||||
# 0) Stop the GPIO button watcher so a late press cannot start work.
|
||||
self._stop_control_button_watcher()
|
||||
self._resume_pipeline_after_capture = False
|
||||
# 1) Abort active capture first (releases exclusive hardware resources).
|
||||
self._abort_capture_sequence(resume_pipeline=False)
|
||||
|
||||
Reference in New Issue
Block a user