some fixes

This commit is contained in:
Ayzen
2026-06-05 14:40:10 +03:00
parent 22942d9dc9
commit bbea744459
35 changed files with 1797 additions and 297 deletions
@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
import logging
import math
import threading
import time
from typing import TYPE_CHECKING
@@ -26,6 +27,12 @@ logger = logging.getLogger(__name__)
# of all entries (1.75 s today) plus the cost of close()/open() themselves.
_REOPEN_BACKOFF_SECONDS: tuple[float, ...] = (0.25, 0.5, 1.0)
# Hard ceiling on the wall-clock time a single acquire_collection() may spend in
# the recovery path (backoff sleeps + close()/open() cost across every retry).
# Bounding this keeps a shutdown that arrives mid-recovery comfortably under the
# systemd TimeoutStopSec so the unit is never SIGKILLed for hanging on exit.
_MAX_RECOVERY_WALL_SECONDS: float = 10.0
_INPUT_S_PARAMETERS_BY_OUTPUT: dict[int, tuple[str, ...]] = {
0: ("s31", "s41", "s51", "s61"),
1: ("s32", "s42", "s52", "s62"),
@@ -101,13 +108,22 @@ class MultiDeviceLibreVnaService:
except Exception as exc: # noqa: BLE001 — recovery path, never propagate
logger.warning("Multi-device close() ignored transport error: %s", exc)
def recover(self) -> None:
def recover(
self,
*,
stop_event: threading.Event | None = None,
deadline_monotonic: float | None = None,
) -> None:
"""Reopen native device transports after a failed acquisition.
Tries several short backoffs so a transient USB stall does not kill the
producer on the very first retry. Raises the last error only after
every attempt failed — the outer acquisition loop is expected to count
these as recovery_attempts.
When `stop_event` is supplied the backoff waits on it instead of
sleeping, so a shutdown request aborts the loop immediately; an optional
`deadline_monotonic` caps the total wall-time spent here.
"""
if self._using_mock_backend:
return
@@ -115,7 +131,21 @@ class MultiDeviceLibreVnaService:
last_error: Exception | None = None
for attempt_index, delay_s in enumerate(_REOPEN_BACKOFF_SECONDS, start=1):
time.sleep(delay_s)
# Bail out the instant a stop is requested or the recovery budget is
# spent, rather than committing to another (re)open attempt.
if stop_event is not None and stop_event.is_set():
logger.info("Multi-device recover() aborted: stop requested")
return
if deadline_monotonic is not None and time.monotonic() >= deadline_monotonic:
logger.warning("Multi-device recover() aborted: recovery time budget exhausted")
break
# Interruptible backoff: wait() returns early the moment stop is set.
if stop_event is not None:
if stop_event.wait(delay_s):
logger.info("Multi-device recover() aborted: stop requested")
return
else:
time.sleep(delay_s)
try:
self.open()
if self._controller is not None:
@@ -151,15 +181,27 @@ class MultiDeviceLibreVnaService:
power_dbm=float(sweep.power_dbm),
)
def acquire_collection(self, collection_id: int = 1) -> SweepCollection:
"""Acquire one complete virtual 2x4 matrix in canonical combo order."""
def acquire_collection(
self,
collection_id: int = 1,
*,
stop_event: threading.Event | None = None,
) -> SweepCollection:
"""Acquire one complete virtual 2x4 matrix in canonical combo order.
When `stop_event` is supplied it is threaded into the recovery path so a
shutdown request aborts the backoff/retry loops promptly (default `None`
preserves the original blocking behavior).
"""
if self._sweep_configuration is None:
raise RuntimeError("Multi-device service is not configured")
capture_start_ns = time.monotonic_ns()
if self._using_mock_backend:
collection = self._acquire_mock_collection(collection_id, capture_start_ns)
else:
collection = self._acquire_native_collection_with_recovery(collection_id, capture_start_ns)
collection = self._acquire_native_collection_with_recovery(
collection_id, capture_start_ns, stop_event=stop_event
)
collection.capture_end_ns = time.monotonic_ns()
return collection
@@ -167,8 +209,13 @@ class MultiDeviceLibreVnaService:
self,
collection_id: int,
capture_start_ns: int,
*,
stop_event: threading.Event | None = None,
) -> SweepCollection:
last_error: Exception | None = None
# Cap total recovery wall-time across all retries so a shutdown that
# lands mid-recovery stays well under the systemd TimeoutStopSec.
recovery_deadline = time.monotonic() + _MAX_RECOVERY_WALL_SECONDS
for attempt_index in range(self.recovery_attempts + 1):
try:
return self._acquire_native_collection(collection_id, capture_start_ns)
@@ -176,6 +223,13 @@ class MultiDeviceLibreVnaService:
last_error = exc
if attempt_index >= self.recovery_attempts:
break
# Stop the moment shutdown is requested or the recovery budget is
# spent — do not start another reconnect we cannot finish in time.
if stop_event is not None and stop_event.is_set():
break
if time.monotonic() >= recovery_deadline:
logger.warning("multi-device recovery time budget exhausted; giving up")
break
logger.warning(
"multi-device acquisition failed, reconnecting devices (%d/%d): %s",
attempt_index + 1,
@@ -188,7 +242,7 @@ class MultiDeviceLibreVnaService:
# attempt and try again on the next loop iteration, so a
# transient USB hiccup cannot kill the whole producer.
try:
self.recover()
self.recover(stop_event=stop_event, deadline_monotonic=recovery_deadline)
except Exception as recover_exc: # noqa: BLE001
last_error = recover_exc
logger.warning(
@@ -198,6 +252,10 @@ class MultiDeviceLibreVnaService:
recover_exc,
exc_info=True,
)
# If recovery was interrupted by a stop request, do not loop back
# for another acquisition attempt; let shutdown proceed.
if stop_event is not None and stop_event.is_set():
break
assert last_error is not None
raise last_error