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:
parent
2ee2f47275
commit
c6ac958ee8
@ -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."""
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user