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:
2026-06-22 10:22:18 +02:00
co-authored by Claude Sonnet 4.6
parent 2bdd3203cf
commit 7c95a3bde3
+31 -19
View File
@@ -236,22 +236,25 @@ class SudForecastPlot(FigureCanvasQTAgg):
class Window(QtWidgets.QMainWindow, Ui_MainWindow):
# on_sud_changed() runs on the websocket recv thread, but a modal dialog
# must be shown from the GUI thread - a signal/slot crosses that thread
# boundary safely (Qt auto-queues the slot call onto the receiver's
# thread when sender and receiver differ).
user_message_signal = QtCore.pyqtSignal(str)
# on_*_changed()/on_ws_connect_changed() are invoked straight from the
# websocket client's background thread (WsClient.bg_thread), but Qt
# widgets may only be touched from the GUI thread. A signal/slot crosses
# that boundary safely (Qt auto-queues the slot call onto the receiver's
# 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):
QtWidgets.QMainWindow.__init__(self)
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()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
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.connected = 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_system = self.msg_dispatch.msgio_get('System')
self.msg_pot.set_recv_handler(self.on_pot_changed)
self.msg_sensor.set_recv_handler(self.on_sensor_changed)
self.msg_heater.set_recv_handler(self.on_heater_changed)
self.msg_stirrer.set_recv_handler(self.on_stirrer_changed)
self.msg_tempctrl.set_recv_handler(self.on_tempctrl_changed)
self.msg_sud.set_recv_handler(self.on_sud_changed)
self.msg_system.set_recv_handler(self.on_system_changed)
# set_recv_handler's callback runs on WsClient's background thread -
# route each through recv_signal instead of wiring the real handler
# directly, so its body always runs on the GUI thread.
self.msg_pot.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_pot_changed, msg))
self.msg_sensor.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_sensor_changed, msg))
self.msg_heater.set_recv_handler(lambda msg: self.recv_signal.emit(self.on_heater_changed, msg))
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)
# 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.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):
self.slider_pwr_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):
self.msg_sud.send({'Stop': True})
def on_user_message_signal(self, message):
# Runs on the GUI thread (queued there by the cross-thread signal
# emit in on_sud_changed()). The Sud is already blocked in
# WAIT_USER server-side; closing this dialog is what unblocks it.
def show_user_message(self, message):
# on_sud_changed() already runs on the GUI thread (via recv_signal),
# so this can show the dialog directly. The Sud is already blocked
# in WAIT_USER server-side; closing this dialog is what unblocks it.
QtWidgets.QMessageBox.information(self, "Sud", message)
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)
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:
self.sud_user_message = msg['UserMessage']
elif "Step" in key: