Make firmware sweep cancellable to prevent shutdown deadlock

send_with_timeout now uses a 2s per-byte timeout with a deadline
loop instead of one long blocking read, checking a cancel event
between reads. SerialBridge.disconnect() sets the cancel event
before acquiring the lock, so a blocked firmware sweep aborts
within ~2s and releases the lock for clean port shutdown.
This commit is contained in:
Ryan Malloy 2026-02-14 16:56:14 -07:00
parent 2ee2f47275
commit c6ac958ee8
2 changed files with 55 additions and 9 deletions

View File

@ -10,6 +10,7 @@ from __future__ import annotations
import logging import logging
import re import re
import threading
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Callable from collections.abc import Callable
@ -420,20 +421,55 @@ class CarryoutG2Protocol(FirmwareProtocol):
raise ValueError(f"Could not parse RSSI from: {response!r}") raise ValueError(f"Could not parse RSSI from: {response!r}")
def send_with_timeout(self, cmd: str, timeout: float = 90) -> str: def send_with_timeout(
"""Send a command with a custom serial timeout. self,
cmd: str,
timeout: float = 90,
cancel: threading.Event | None = None,
) -> str:
"""Send a command with a deadline-based timeout and optional cancellation.
Used for long-running firmware commands (azscanwxp, azscan) that stream Uses a short per-byte read timeout (2s) so the cancel event can be
output over tens of seconds before the final prompt. checked between reads. The overall deadline limits total wall-clock
time. Used for long-running firmware commands (azscanwxp, azscan)
that stream output before the final prompt.
Args:
cmd: Command string to send.
timeout: Maximum wall-clock seconds to wait for the prompt.
cancel: Optional event; if set, the read aborts immediately.
Raises:
InterruptedError: If *cancel* is set during the read.
TimeoutError: If the prompt is not received within *timeout*.
""" """
if not self._serial: if not self._serial:
raise RuntimeError("Not connected") raise RuntimeError("Not connected")
original = self._serial.timeout
self._serial.timeout = timeout original_timeout = self._serial.timeout
self._serial.timeout = 2.0 # short per-byte timeout for cancel checks
try: try:
return self._send(cmd) self._serial.write(f"{cmd}\r".encode("ascii"))
resp_data = bytearray()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if cancel and cancel.is_set():
raise InterruptedError(f"Cancelled during: {cmd!r}")
byte = self._serial.read(1)
if len(byte) == 0:
continue # short timeout expired, loop and check again
resp_data.append(byte[0])
if byte[0] == self.PROMPT_CHAR:
break
else:
raise TimeoutError(f"No prompt after {timeout}s for: {cmd!r}")
time.sleep(0.001)
return resp_data.decode("utf-8", errors="ignore")
finally: finally:
self._serial.timeout = original self._serial.timeout = original_timeout
def send_raw(self, cmd: str) -> str: def send_raw(self, cmd: str) -> str:
"""Send arbitrary command, return raw prompt-terminated response.""" """Send arbitrary command, return raw prompt-terminated response."""

View File

@ -63,6 +63,7 @@ class SerialBridge:
def __init__(self, protocol: CarryoutG2Protocol) -> None: def __init__(self, protocol: CarryoutG2Protocol) -> None:
self._proto = protocol self._proto = protocol
self._lock = threading.Lock() self._lock = threading.Lock()
self._cancel = threading.Event()
self._menu = Menu.UNKNOWN self._menu = Menu.UNKNOWN
self._connected = False self._connected = False
@ -158,13 +159,20 @@ class SerialBridge:
self._menu = self._detect_menu() self._menu = self._detect_menu()
def disconnect(self) -> None: def disconnect(self) -> None:
"""Close the serial connection.""" """Close the serial connection.
Signals cancellation first to unblock any long-running serial
reads (e.g. firmware sweep), then acquires the lock to close
the port cleanly.
"""
self._cancel.set()
with self._lock: with self._lock:
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self._go_to_root() self._go_to_root()
self._proto.disconnect() self._proto.disconnect()
self._connected = False self._connected = False
self._menu = Menu.UNKNOWN self._menu = Menu.UNKNOWN
self._cancel.clear() # reset for potential reconnect
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
@ -424,9 +432,11 @@ class SerialBridge:
# Move to start position and wait for prompt. # Move to start position and wait for prompt.
self._send(f"a 0 {start_az}") self._send(f"a 0 {start_az}")
# Execute firmware sweep with extended timeout. # Execute firmware sweep with extended timeout.
# Pass cancel event so disconnect() can interrupt the read.
response = self._proto.send_with_timeout( response = self._proto.send_with_timeout(
f"azscanwxp 0 {span} {step_cdeg} {num_xponders}", f"azscanwxp 0 {span} {step_cdeg} {num_xponders}",
timeout=timeout, timeout=timeout,
cancel=self._cancel,
) )
# Parse streaming output lines. # Parse streaming output lines.