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 re
import threading
import time
from abc import ABC, abstractmethod
from collections.abc import Callable
@ -420,20 +421,55 @@ class CarryoutG2Protocol(FirmwareProtocol):
raise ValueError(f"Could not parse RSSI from: {response!r}")
def send_with_timeout(self, cmd: str, timeout: float = 90) -> str:
"""Send a command with a custom serial timeout.
def send_with_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
output over tens of seconds before the final prompt.
Uses a short per-byte read timeout (2s) so the cancel event can be
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:
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:
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:
self._serial.timeout = original
self._serial.timeout = original_timeout
def send_raw(self, cmd: str) -> str:
"""Send arbitrary command, return raw prompt-terminated response."""