Fix 300s executor shutdown with threading.Event

Replace time.sleep() with threading.Event.wait() in all poll
loops so worker threads exit immediately on shutdown instead of
blocking for up to 500ms per iteration. Fixes the on_unmount
crash (NoMatches from querying removed DOM nodes) by signaling
the event directly rather than iterating child widgets.

Three shutdown paths covered: q key (on_unmount), Ctrl+C
(try/finally in main), and Textual internal shutdown.
This commit is contained in:
Ryan Malloy 2026-02-14 10:21:42 -07:00
parent 48746937a7
commit ba8859cc31
4 changed files with 23 additions and 19 deletions

View File

@ -6,6 +6,7 @@ device status bar, and five swappable screen panels.
import argparse
import logging
import threading
from textual.app import App, ComposeResult
from textual.binding import Binding
@ -52,6 +53,7 @@ class BirdcageApp(App):
firmware_name: str = "g2"
skip_init: bool = False
device: object = None
shutdown_event: threading.Event = threading.Event()
@property
def SUB_TITLE(self) -> str: # noqa: N802
@ -130,13 +132,8 @@ class BirdcageApp(App):
screen.on_show()
def on_unmount(self) -> None:
"""Stop all polling threads and disconnect the device on shutdown."""
for mode_key in MODES:
screen = self.query_one(f"#{mode_key}")
if hasattr(screen, "_polling"):
screen._polling = False
if hasattr(screen, "_monitoring"):
screen._monitoring = False
"""Signal all worker threads to exit and disconnect the device."""
self.shutdown_event.set()
if self.device and hasattr(self.device, "disconnect"):
self.device.disconnect()
@ -173,4 +170,11 @@ def main() -> None:
app.serial_port = args.port
app.firmware_name = args.firmware
app.skip_init = args.skip_init
app.run()
try:
app.run()
except KeyboardInterrupt:
pass
finally:
app.shutdown_event.set()
if app.device and hasattr(app.device, "disconnect"):
app.device.disconnect()

View File

@ -6,7 +6,6 @@ and AZ/EL sparklines. Bottom row provides manual move controls.
"""
import logging
import time
from textual import work
from textual.app import ComposeResult
@ -98,9 +97,10 @@ class PositionScreen(Container):
@work(thread=True, exclusive=True, group="position-poll")
def _do_position_poll(self) -> None:
"""Poll device at ~2 Hz for position and step data."""
while self._polling:
shutdown = self.app.shutdown_event
while self._polling and not shutdown.is_set():
if self._device is None:
time.sleep(0.5)
shutdown.wait(0.5)
continue
try:
@ -135,7 +135,7 @@ class PositionScreen(Container):
except Exception:
log.debug("Torque poll failed", exc_info=True)
time.sleep(0.5)
shutdown.wait(0.5)
# ------------------------------------------------------------------
# Thread-safe widget update callbacks

View File

@ -7,7 +7,6 @@ raw (az, el, rssi) data for offline analysis.
import csv
import logging
import time
from pathlib import Path
from textual import work
@ -98,6 +97,7 @@ class ScanScreen(Container):
def _do_scan(self) -> None:
"""Execute the AZ/EL grid scan in a background thread."""
worker = get_current_worker()
shutdown = self.app.shutdown_event
device = self._device
if device is None:
return
@ -146,7 +146,7 @@ class ScanScreen(Container):
for _el_idx, el_val in enumerate(el_values):
for _az_idx, az_val in enumerate(az_values):
if not self._scanning or worker.is_cancelled:
if not self._scanning or worker.is_cancelled or shutdown.is_set():
self.app.call_from_thread(self._set_status, "Scan stopped")
return
@ -160,7 +160,7 @@ class ScanScreen(Container):
continue
# Settle time -- let the motor stop and vibrations damp.
time.sleep(0.3)
shutdown.wait(0.3)
# Read signal.
try:

View File

@ -7,7 +7,6 @@ sparklines for DVB and ADC RSSI, peak tracking, and LNA toggle.
import logging
import re
import time
from textual import work
from textual.app import ComposeResult
@ -82,9 +81,10 @@ class SignalScreen(Container):
@work(thread=True, exclusive=True, group="signal-poll")
def _do_signal_poll(self) -> None:
"""Poll RSSI at the configured rate while monitoring is active."""
while self._monitoring:
shutdown = self.app.shutdown_event
while self._monitoring and not shutdown.is_set():
if self._device is None:
time.sleep(0.5)
shutdown.wait(0.5)
continue
# Read config from inputs (safe defaults on parse failure).
@ -141,7 +141,7 @@ class SignalScreen(Container):
except Exception:
log.debug("Lock status poll failed", exc_info=True)
time.sleep(1.0 / rate)
shutdown.wait(1.0 / rate)
# ------------------------------------------------------------------
# Thread-safe widget update callbacks