From 1d267375f9bda3167a3f3e349ee7aa27b123e32d Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 2 Jul 2026 23:45:47 +0200 Subject: [PATCH] fix(rpc): isolate broadcast callbacks and retry delivery after failures --- bec_widgets/utils/rpc_register.py | 12 ++++++++++- tests/unit_tests/test_rpc_register.py | 31 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/bec_widgets/utils/rpc_register.py b/bec_widgets/utils/rpc_register.py index e126683c..6493769b 100644 --- a/bec_widgets/utils/rpc_register.py +++ b/bec_widgets/utils/rpc_register.py @@ -182,6 +182,7 @@ class RPCRegister: self._broadcast_pending = False connections = self.list_all_connections() dead_refs = [] + delivery_failed = False for callback_ref in list(self.callbacks): callback = callback_ref() if callback is None: @@ -193,12 +194,21 @@ class RPCRegister: # e.g. during shutdown): calling it would raise on any Qt access. dead_refs.append(callback_ref) continue - callback(connections) + try: + callback(connections) + except Exception: + delivery_failed = True + logger.exception(f"RPC registry broadcast callback {callback!r} failed") for ref in dead_refs: try: self.callbacks.remove(ref) except ValueError: pass + if delivery_failed: + # The state change must not be lost: leave the registry dirty so the + # next broadcast retries delivery (duplicates are safe, the payload + # is the full state). + self._broadcast_pending = True def object_is_registered(self, obj: BECConnector) -> bool: """ diff --git a/tests/unit_tests/test_rpc_register.py b/tests/unit_tests/test_rpc_register.py index 40d3f6ee..6d9dcb9e 100644 --- a/tests/unit_tests/test_rpc_register.py +++ b/tests/unit_tests/test_rpc_register.py @@ -189,3 +189,34 @@ def test_broadcast_without_callbacks_skips_registry_walk(rpc_register, monkeypat rpc_register.broadcast() walk.assert_called_once() assert len(received) == 1 + + +def test_broadcast_prunes_callbacks_of_destroyed_qobjects(rpc_register, qapp): + """A bound-method callback whose QObject was destroyed (Python wrapper still + referenced, C++ side gone — the shutdown scenario) must be pruned, not called.""" + import shiboken6 + from qtpy.QtCore import QObject + + class Listener(QObject): + def __init__(self): + super().__init__() + self.received = [] + + def on_update(self, connections): + self.received.append(connections) + + listener = Listener() + rpc_register.add_callback(listener.on_update) + rpc_register.mark_broadcast_pending() + rpc_register.broadcast() + assert len(listener.received) == 1 + callbacks_before = len(rpc_register.callbacks) + + shiboken6.delete(listener) + assert not shiboken6.isValid(listener) + + rpc_register.mark_broadcast_pending() + rpc_register.broadcast() # must neither call the dead callback nor raise + + assert len(listener.received) == 1 + assert len(rpc_register.callbacks) == callbacks_before - 1