Marshal websocket recv callbacks onto the Qt GUI thread
WsClient's background thread (ws/client/ws_client.py) called every on_*_changed handler - and on_ws_connect_changed - directly, but they all mutate Qt widgets (lcdNumber.display(), setText(), setChecked(), the plot canvas, ...), which Qt only allows from the GUI thread. Caught live as "QObject: Cannot create children for a parent that is in a different thread" on startup, and is a latent crash/connection-drop risk under load (a cross-thread Qt exception would be swallowed by ws_client.py's bare except, silently tearing down the connection). Adds a generic recv_signal (pyqtSignal(object, object)) carrying the real handler and its message; _dispatch_recv (its slot) runs on the GUI thread regardless of which thread emitted, so every handler's body is now safe. user_message_signal - previously the only handler already using this pattern, just for one case - becomes redundant now that on_sud_changed itself always runs on the GUI thread, so its modal dialog can be shown directly (renamed to show_user_message()). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhiQe64F74uHV8jzuoSa5K
This commit is contained in:
+31
-19
@@ -236,22 +236,25 @@ class SudForecastPlot(FigureCanvasQTAgg):
|
|||||||
|
|
||||||
|
|
||||||
class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
||||||
# on_sud_changed() runs on the websocket recv thread, but a modal dialog
|
# on_*_changed()/on_ws_connect_changed() are invoked straight from the
|
||||||
# must be shown from the GUI thread - a signal/slot crosses that thread
|
# websocket client's background thread (WsClient.bg_thread), but Qt
|
||||||
# boundary safely (Qt auto-queues the slot call onto the receiver's
|
# widgets may only be touched from the GUI thread. A signal/slot crosses
|
||||||
# thread when sender and receiver differ).
|
# that boundary safely (Qt auto-queues the slot call onto the receiver's
|
||||||
user_message_signal = QtCore.pyqtSignal(str)
|
# thread when sender and receiver differ) - recv_signal carries the
|
||||||
|
# handler itself plus its one argument, so every channel can share it
|
||||||
|
# instead of needing one signal per handler.
|
||||||
|
recv_signal = QtCore.pyqtSignal(object, object)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
QtWidgets.QMainWindow.__init__(self)
|
QtWidgets.QMainWindow.__init__(self)
|
||||||
self.setWindowIcon(QtGui.QIcon('res/beer.png'))
|
self.setWindowIcon(QtGui.QIcon('res/beer.png'))
|
||||||
self.user_message_signal.connect(self.on_user_message_signal)
|
self.recv_signal.connect(self._dispatch_recv)
|
||||||
self.user_config = UserConfig()
|
self.user_config = UserConfig()
|
||||||
|
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
self.msg_dispatch = MessageDispatcherSync(auto_subscribe=True)
|
self.msg_dispatch = MessageDispatcherSync(auto_subscribe=True)
|
||||||
self.msg_dispatch.on_connect_changed = self.on_ws_connect_changed
|
self.msg_dispatch.on_connect_changed = lambda connected: self.recv_signal.emit(self.on_ws_connect_changed, connected)
|
||||||
self.ws_client = WsClient(listener=self.msg_dispatch, loop=loop)
|
self.ws_client = WsClient(listener=self.msg_dispatch, loop=loop)
|
||||||
self.connected = False
|
self.connected = False
|
||||||
self.sud_loaded = False
|
self.sud_loaded = False
|
||||||
@@ -307,13 +310,16 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
self.msg_sud = self.msg_dispatch.msgio_get('Sud')
|
self.msg_sud = self.msg_dispatch.msgio_get('Sud')
|
||||||
self.msg_system = self.msg_dispatch.msgio_get('System')
|
self.msg_system = self.msg_dispatch.msgio_get('System')
|
||||||
|
|
||||||
self.msg_pot.set_recv_handler(self.on_pot_changed)
|
# set_recv_handler's callback runs on WsClient's background thread -
|
||||||
self.msg_sensor.set_recv_handler(self.on_sensor_changed)
|
# route each through recv_signal instead of wiring the real handler
|
||||||
self.msg_heater.set_recv_handler(self.on_heater_changed)
|
# directly, so its body always runs on the GUI thread.
|
||||||
self.msg_stirrer.set_recv_handler(self.on_stirrer_changed)
|
self.msg_pot.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_pot_changed, msg))
|
||||||
self.msg_tempctrl.set_recv_handler(self.on_tempctrl_changed)
|
self.msg_sensor.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_sensor_changed, msg))
|
||||||
self.msg_sud.set_recv_handler(self.on_sud_changed)
|
self.msg_heater.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_heater_changed, msg))
|
||||||
self.msg_system.set_recv_handler(self.on_system_changed)
|
self.msg_stirrer.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_stirrer_changed, msg))
|
||||||
|
self.msg_tempctrl.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_tempctrl_changed, msg))
|
||||||
|
self.msg_sud.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_sud_changed, msg))
|
||||||
|
self.msg_system.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_system_changed, msg))
|
||||||
|
|
||||||
self.setupUi(self)
|
self.setupUi(self)
|
||||||
# Matches tc_enabled's default (False) until the server says otherwise.
|
# Matches tc_enabled's default (False) until the server says otherwise.
|
||||||
@@ -394,6 +400,12 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
forecast_layout.setContentsMargins(0, 0, 0, 0)
|
forecast_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
forecast_layout.addWidget(self.forecast_plot)
|
forecast_layout.addWidget(self.forecast_plot)
|
||||||
|
|
||||||
|
def _dispatch_recv(self, handler, msg):
|
||||||
|
"""recv_signal's slot - runs on the GUI thread regardless of which
|
||||||
|
thread emitted the signal, so handler(msg) can touch Qt widgets
|
||||||
|
safely."""
|
||||||
|
handler(msg)
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
self.slider_pwr_initial_update = True
|
self.slider_pwr_initial_update = True
|
||||||
self.slider_temp_soll_initial_update = True
|
self.slider_temp_soll_initial_update = True
|
||||||
@@ -644,10 +656,10 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
def on_action_sud_stop(self):
|
def on_action_sud_stop(self):
|
||||||
self.msg_sud.send({'Stop': True})
|
self.msg_sud.send({'Stop': True})
|
||||||
|
|
||||||
def on_user_message_signal(self, message):
|
def show_user_message(self, message):
|
||||||
# Runs on the GUI thread (queued there by the cross-thread signal
|
# on_sud_changed() already runs on the GUI thread (via recv_signal),
|
||||||
# emit in on_sud_changed()). The Sud is already blocked in
|
# so this can show the dialog directly. The Sud is already blocked
|
||||||
# WAIT_USER server-side; closing this dialog is what unblocks it.
|
# in WAIT_USER server-side; closing this dialog is what unblocks it.
|
||||||
QtWidgets.QMessageBox.information(self, "Sud", message)
|
QtWidgets.QMessageBox.information(self, "Sud", message)
|
||||||
self.msg_sud.send({'Confirm': True})
|
self.msg_sud.send({'Confirm': True})
|
||||||
|
|
||||||
@@ -848,7 +860,7 @@ class Window(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
self.forecast_plot.show_schedule(self.sud_schedule, start_theta, self.sud_name)
|
self.forecast_plot.show_schedule(self.sud_schedule, start_theta, self.sud_name)
|
||||||
|
|
||||||
if self.sud_state == SUD_WAIT_USER_STATE and prev_state != SUD_WAIT_USER_STATE and self.sud_user_message:
|
if self.sud_state == SUD_WAIT_USER_STATE and prev_state != SUD_WAIT_USER_STATE and self.sud_user_message:
|
||||||
self.user_message_signal.emit(self.sud_user_message)
|
self.show_user_message(self.sud_user_message)
|
||||||
elif "UserMessage" in key:
|
elif "UserMessage" in key:
|
||||||
self.sud_user_message = msg['UserMessage']
|
self.sud_user_message = msg['UserMessage']
|
||||||
elif "Step" in key:
|
elif "Step" in key:
|
||||||
|
|||||||
Reference in New Issue
Block a user