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:
parent
48746937a7
commit
ba8859cc31
@ -6,6 +6,7 @@ device status bar, and five swappable screen panels.
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
@ -52,6 +53,7 @@ class BirdcageApp(App):
|
|||||||
firmware_name: str = "g2"
|
firmware_name: str = "g2"
|
||||||
skip_init: bool = False
|
skip_init: bool = False
|
||||||
device: object = None
|
device: object = None
|
||||||
|
shutdown_event: threading.Event = threading.Event()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def SUB_TITLE(self) -> str: # noqa: N802
|
def SUB_TITLE(self) -> str: # noqa: N802
|
||||||
@ -130,13 +132,8 @@ class BirdcageApp(App):
|
|||||||
screen.on_show()
|
screen.on_show()
|
||||||
|
|
||||||
def on_unmount(self) -> None:
|
def on_unmount(self) -> None:
|
||||||
"""Stop all polling threads and disconnect the device on shutdown."""
|
"""Signal all worker threads to exit and disconnect the device."""
|
||||||
for mode_key in MODES:
|
self.shutdown_event.set()
|
||||||
screen = self.query_one(f"#{mode_key}")
|
|
||||||
if hasattr(screen, "_polling"):
|
|
||||||
screen._polling = False
|
|
||||||
if hasattr(screen, "_monitoring"):
|
|
||||||
screen._monitoring = False
|
|
||||||
if self.device and hasattr(self.device, "disconnect"):
|
if self.device and hasattr(self.device, "disconnect"):
|
||||||
self.device.disconnect()
|
self.device.disconnect()
|
||||||
|
|
||||||
@ -173,4 +170,11 @@ def main() -> None:
|
|||||||
app.serial_port = args.port
|
app.serial_port = args.port
|
||||||
app.firmware_name = args.firmware
|
app.firmware_name = args.firmware
|
||||||
app.skip_init = args.skip_init
|
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()
|
||||||
|
|||||||
@ -6,7 +6,6 @@ and AZ/EL sparklines. Bottom row provides manual move controls.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
|
|
||||||
from textual import work
|
from textual import work
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
@ -98,9 +97,10 @@ class PositionScreen(Container):
|
|||||||
@work(thread=True, exclusive=True, group="position-poll")
|
@work(thread=True, exclusive=True, group="position-poll")
|
||||||
def _do_position_poll(self) -> None:
|
def _do_position_poll(self) -> None:
|
||||||
"""Poll device at ~2 Hz for position and step data."""
|
"""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:
|
if self._device is None:
|
||||||
time.sleep(0.5)
|
shutdown.wait(0.5)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -135,7 +135,7 @@ class PositionScreen(Container):
|
|||||||
except Exception:
|
except Exception:
|
||||||
log.debug("Torque poll failed", exc_info=True)
|
log.debug("Torque poll failed", exc_info=True)
|
||||||
|
|
||||||
time.sleep(0.5)
|
shutdown.wait(0.5)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Thread-safe widget update callbacks
|
# Thread-safe widget update callbacks
|
||||||
|
|||||||
@ -7,7 +7,6 @@ raw (az, el, rssi) data for offline analysis.
|
|||||||
|
|
||||||
import csv
|
import csv
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from textual import work
|
from textual import work
|
||||||
@ -98,6 +97,7 @@ class ScanScreen(Container):
|
|||||||
def _do_scan(self) -> None:
|
def _do_scan(self) -> None:
|
||||||
"""Execute the AZ/EL grid scan in a background thread."""
|
"""Execute the AZ/EL grid scan in a background thread."""
|
||||||
worker = get_current_worker()
|
worker = get_current_worker()
|
||||||
|
shutdown = self.app.shutdown_event
|
||||||
device = self._device
|
device = self._device
|
||||||
if device is None:
|
if device is None:
|
||||||
return
|
return
|
||||||
@ -146,7 +146,7 @@ class ScanScreen(Container):
|
|||||||
|
|
||||||
for _el_idx, el_val in enumerate(el_values):
|
for _el_idx, el_val in enumerate(el_values):
|
||||||
for _az_idx, az_val in enumerate(az_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")
|
self.app.call_from_thread(self._set_status, "Scan stopped")
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -160,7 +160,7 @@ class ScanScreen(Container):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Settle time -- let the motor stop and vibrations damp.
|
# Settle time -- let the motor stop and vibrations damp.
|
||||||
time.sleep(0.3)
|
shutdown.wait(0.3)
|
||||||
|
|
||||||
# Read signal.
|
# Read signal.
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -7,7 +7,6 @@ sparklines for DVB and ADC RSSI, peak tracking, and LNA toggle.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
|
|
||||||
from textual import work
|
from textual import work
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
@ -82,9 +81,10 @@ class SignalScreen(Container):
|
|||||||
@work(thread=True, exclusive=True, group="signal-poll")
|
@work(thread=True, exclusive=True, group="signal-poll")
|
||||||
def _do_signal_poll(self) -> None:
|
def _do_signal_poll(self) -> None:
|
||||||
"""Poll RSSI at the configured rate while monitoring is active."""
|
"""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:
|
if self._device is None:
|
||||||
time.sleep(0.5)
|
shutdown.wait(0.5)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Read config from inputs (safe defaults on parse failure).
|
# Read config from inputs (safe defaults on parse failure).
|
||||||
@ -141,7 +141,7 @@ class SignalScreen(Container):
|
|||||||
except Exception:
|
except Exception:
|
||||||
log.debug("Lock status poll failed", exc_info=True)
|
log.debug("Lock status poll failed", exc_info=True)
|
||||||
|
|
||||||
time.sleep(1.0 / rate)
|
shutdown.wait(1.0 / rate)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Thread-safe widget update callbacks
|
# Thread-safe widget update callbacks
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user