Wire Stop button to cancel blocked firmware sweeps

Stop handler now calls cancel_operation() on the device bridge,
which sets a threading.Event that interrupts the 2s-timeout serial
read loop in send_with_timeout(). InterruptedError is caught
separately to prevent falling back to software sweep on cancel.

disconnect() uses acquire(timeout=5) with force-close fallback
instead of blocking lock acquisition — prevents deadlock when a
stuck worker holds the serial lock during shutdown.

Add 3 Textual async tests (pytest-asyncio) to verify Stop behavior:
firmware sweep stop, software sweep stop, and sweep restart.
This commit is contained in:
Ryan Malloy 2026-02-14 17:12:11 -07:00
parent 972c26b22f
commit e7e71c47d7
5 changed files with 204 additions and 2 deletions

View file

@ -158,6 +158,18 @@ class SerialBridge:
self._connected = True
self._menu = self._detect_menu()
def cancel_operation(self) -> None:
"""Signal any in-progress long-running operation to abort.
Safe to call from any thread. The cancel event is checked every
~2 seconds by ``send_with_timeout``.
"""
self._cancel.set()
def clear_cancel(self) -> None:
"""Reset the cancel event so future operations proceed normally."""
self._cancel.clear()
def disconnect(self) -> None:
"""Close the serial connection.
@ -166,13 +178,26 @@ class SerialBridge:
the port cleanly.
"""
self._cancel.set()
with self._lock:
if not self._lock.acquire(timeout=5):
# Lock held by dead/stuck worker — force-close the port
# so the blocked serial read raises an exception.
logger.warning("Lock acquisition timed out, force-closing port")
with contextlib.suppress(Exception):
self._proto.disconnect()
self._connected = False
self._menu = Menu.UNKNOWN
self._cancel.clear()
return
try:
with contextlib.suppress(Exception):
self._go_to_root()
self._proto.disconnect()
self._connected = False
self._menu = Menu.UNKNOWN
self._cancel.clear() # reset for potential reconnect
self._cancel.clear()
finally:
self._lock.release()
@property
def is_connected(self) -> bool:

View file

@ -261,6 +261,12 @@ class DemoDevice:
self._connected = True
self._menu = _DemoMenu.ROOT
def cancel_operation(self) -> None:
pass # Demo operations are instant, nothing to cancel.
def clear_cancel(self) -> None:
pass # No cancel state in demo mode.
def disconnect(self) -> None:
self._connected = False
self._menu = _DemoMenu.ROOT

View file

@ -442,11 +442,18 @@ class SignalScreen(Container):
try:
self._do_sweep_firmware(device)
return
except InterruptedError:
# User cancelled via Stop or app shutdown — don't retry.
log.info("Firmware sweep cancelled")
return
except Exception:
log.warning(
"Firmware sweep failed, falling back to software",
exc_info=True,
)
# Only fall through to software if still active.
if not self._sweeping:
return
with contextlib.suppress(Exception):
self.app.call_from_thread(
self._set_sweep_status,
@ -456,6 +463,9 @@ class SignalScreen(Container):
self._do_sweep_software(device)
finally:
self._sweeping = False
# Clear cancel so the next sweep doesn't immediately abort.
if self._device and hasattr(self._device, "clear_cancel"):
self._device.clear_cancel()
with contextlib.suppress(Exception):
self.app.call_from_thread(self._reset_sweep_buttons)
@ -641,6 +651,9 @@ class SignalScreen(Container):
def _handle_sweep_stop(self) -> None:
self._sweeping = False
# Cancel any blocked serial operation (firmware sweep).
if self._device and hasattr(self._device, "cancel_operation"):
self._device.cancel_operation()
self._set_sweep_status("Stopping...")
self._reset_sweep_buttons()
@ -676,6 +689,8 @@ class SignalScreen(Container):
self._do_scan_inner(device)
finally:
self._scanning = False
if self._device and hasattr(self._device, "clear_cancel"):
self._device.clear_cancel()
with contextlib.suppress(Exception):
self.app.call_from_thread(self._reset_scan_buttons)
@ -875,6 +890,8 @@ class SignalScreen(Container):
def _handle_scan_stop(self) -> None:
self._scanning = False
if self._device and hasattr(self._device, "cancel_operation"):
self._device.cancel_operation()
self._set_scan_status("Stopping...")
self._reset_scan_buttons()