Files
bec_widgets/tests/unit_tests/test_bec_dispatcher.py
T

679 lines
25 KiB
Python

# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
import gc
import os
import subprocess
import sys
import threading
import time
import weakref
from pathlib import Path
from unittest import mock
import pytest
import shiboken6
from bec_lib import service_config
from bec_lib.messages import GUIInstructionMessage, ScanMessage
from bec_lib.serialization import MsgpackSerialization
from qtpy.QtCore import QEvent, QObject, QThread
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.bec_dispatcher import BECDispatcher, QtRedisConnector, QtThreadSafeCallback
def test_init_handles_client_and_config_arg():
# Client passed
self_mock = mock.MagicMock(_initialized=False)
with mock.patch.object(BECDispatcher, "start_cli_server"):
BECDispatcher.__init__(self_mock, client=mock.MagicMock(name="test_client"))
assert "test_client" in repr(self_mock.client)
# No client, service config object
self_mock.reset_mock()
self_mock._initialized = False
with (
mock.patch.object(BECDispatcher, "start_cli_server"),
mock.patch("bec_widgets.utils.bec_dispatcher.BECClient") as client_cls,
):
config = service_config.ServiceConfig()
BECDispatcher.__init__(self_mock, client=None, config=config)
client_cls.assert_called_with(
config=config, connector_cls=QtRedisConnector, name="BECWidgets"
)
# No client, service config string
self_mock.reset_mock()
self_mock._initialized = False
with (
mock.patch.object(BECDispatcher, "start_cli_server"),
mock.patch("bec_widgets.utils.bec_dispatcher.BECClient"),
mock.patch("bec_widgets.utils.bec_dispatcher.ServiceConfig") as svc_cfg,
mock.patch("bec_widgets.utils.bec_dispatcher.isinstance", return_value=False),
):
config = service_config.ServiceConfig()
BECDispatcher.__init__(self_mock, client=None, config="test_str")
svc_cfg.assert_called_with("test_str")
@pytest.fixture
def bec_dispatcher_w_connector(bec_dispatcher, topics_msg_list, send_msg_event):
def pubsub_msg_generator():
send_msg_event.wait()
for topic, msg in topics_msg_list:
yield {"channel": topic.encode(), "pattern": None, "data": msg}
while True:
time.sleep(0.2)
yield StopIteration
redis_class_mock = mock.MagicMock()
pubsub = redis_class_mock().pubsub()
messages = pubsub_msg_generator()
pubsub.get_message.side_effect = lambda timeout: next(messages)
connector = QtRedisConnector("localhost:1", redis_class_mock)
bec_dispatcher.client.connector = connector
yield bec_dispatcher
connector.shutdown()
dummy_msg = MsgpackSerialization.dumps(ScanMessage(point_id=0, scan_id="0", data={}))
@pytest.fixture
def send_msg_event():
return threading.Event()
@pytest.mark.parametrize(
"topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg), ("topic3", dummy_msg))]
)
def test_dispatcher_disconnect_all(bec_dispatcher_w_connector, qtbot, send_msg_event):
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
cb2 = mock.Mock(spec=[])
bec_dispatcher.connect_slot(cb1, "topic1")
bec_dispatcher.connect_slot(cb1, "topic2")
bec_dispatcher.connect_slot(cb2, "topic2")
bec_dispatcher.connect_slot(cb2, "topic3")
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 3
send_msg_event.set()
qtbot.wait(10)
assert cb1.call_count == 2
assert cb2.call_count == 2
bec_dispatcher.disconnect_all()
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 0
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_disconnect_one(bec_dispatcher_w_connector, qtbot, send_msg_event):
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
cb2 = mock.Mock(spec=[])
bec_dispatcher.connect_slot(cb1, "topic1")
bec_dispatcher.connect_slot(cb2, "topic2")
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 2
bec_dispatcher.disconnect_slot(cb1, "topic1")
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 1
send_msg_event.set()
qtbot.wait(10)
assert cb1.call_count == 0
cb2.assert_called_once()
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg),)])
def test_dispatcher_disconnect_wrong_topic_is_safe_noop(
bec_dispatcher_w_connector, qtbot, send_msg_event
):
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
bec_dispatcher.connect_slot(cb1, "topic1")
# disconnecting a topic the slot is NOT subscribed to must not release topic1
bec_dispatcher.disconnect_slot(cb1, "topic-wrong")
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 1
send_msg_event.set()
qtbot.wait(10)
cb1.assert_called_once()
bec_dispatcher.disconnect_slot(cb1, "topic1")
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 0
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_disconnect_selects_wrapper_by_topic(
bec_dispatcher_w_connector, qtbot, send_msg_event
):
# The same callback registered twice with different cb_info produces two wrappers;
# disconnecting a topic must release it from the wrapper that actually holds it,
# not silently no-op on the first wrapper that matches the callback.
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
bec_dispatcher.connect_slot(cb1, "topic1", cb_info={"scan": "a"})
bec_dispatcher.connect_slot(cb1, "topic2", cb_info={"scan": "b"})
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 2
bec_dispatcher.disconnect_slot(cb1, "topic2")
remaining = bec_dispatcher.client.connector._managed_connection._topics_cb
assert len(remaining) == 1
assert "topic1" in remaining
# release the remaining subscription explicitly instead of leaning on fixture teardown
bec_dispatcher.disconnect_slot(cb1, "topic1")
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 0
# unblock the fixture's message generator so connector.shutdown() can join
send_msg_event.set()
qtbot.wait(10)
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg),)])
def test_dispatcher_2_cb_same_topic(bec_dispatcher_w_connector, qtbot, send_msg_event):
# test for BEC issue #276
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
cb2 = mock.Mock(spec=[])
num_slots = len(bec_dispatcher._registered_slots)
bec_dispatcher.connect_slot(cb1, "topic1")
bec_dispatcher.connect_slot(cb2, "topic1")
# The redis connector should only subscribe once to the topic
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 1
# The the given topic, two callbacks should be registered
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb["topic1"]) == 2
# The dispatcher should have two slots
assert len(bec_dispatcher._registered_slots) == num_slots + 2
bec_dispatcher.disconnect_slot(cb1, "topic1")
assert len(bec_dispatcher._registered_slots) == num_slots + 1
send_msg_event.set()
qtbot.wait(10)
assert cb1.call_count == 0
cb2.assert_called_once()
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg),)])
def test_dispatcher_2_cb_same_topic_same_slot(bec_dispatcher_w_connector, qtbot, send_msg_event):
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
bec_dispatcher.connect_slot(cb1, "topic1")
bec_dispatcher.connect_slot(cb1, "topic1")
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 1
assert (
len(list(filter(lambda slot: slot.cb == cb1, bec_dispatcher._registered_slots.values())))
== 1
)
send_msg_event.set()
qtbot.wait(10)
assert cb1.call_count == 1
bec_dispatcher.disconnect_slot(cb1, "topic1")
assert (
len(list(filter(lambda slot: slot.cb == cb1, bec_dispatcher._registered_slots.values())))
== 0
)
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_2_topic_same_cb(bec_dispatcher_w_connector, qtbot, send_msg_event):
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
bec_dispatcher.connect_slot(cb1, "topic1")
bec_dispatcher.connect_slot(cb1, "topic2")
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 2
bec_dispatcher.disconnect_slot(cb1, "topic1")
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 1
send_msg_event.set()
qtbot.wait(10)
cb1.assert_called_once()
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_2_topic_same_cb_with_boundmethod(
bec_dispatcher_w_connector, qtbot, send_msg_event
):
bec_dispatcher = bec_dispatcher_w_connector
class MockObject:
def mock_slot(self, msg, metadata):
pass
cb1 = MockObject()
bec_dispatcher.connect_slot(cb1.mock_slot, "topic1", {"metadata": "test"})
bec_dispatcher.connect_slot(cb1.mock_slot, "topic1", {"metadata": "test"})
def _get_slots():
return list(
filter(
lambda slot: slot == QtThreadSafeCallback(cb1.mock_slot, {"metadata": "test"}),
bec_dispatcher._registered_slots.values(),
)
)
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 1
assert len(_get_slots()) == 1
bec_dispatcher.disconnect_slot(cb1.mock_slot, "topic1")
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 0
assert len(_get_slots()) == 0
send_msg_event.set()
qtbot.wait(10)
def test_qt_redis_connector_logs_rpc_before_qt_callback(monkeypatch):
info_mock = mock.MagicMock()
warning_mock = mock.MagicMock()
monkeypatch.setattr("bec_widgets.utils.bec_dispatcher.logger.info", info_mock)
monkeypatch.setattr("bec_widgets.utils.bec_dispatcher.logger.warning", warning_mock)
def callback(_msg, _metadata):
pass
cb = QtThreadSafeCallback(callback)
connector = QtRedisConnector("localhost:1", mock.MagicMock())
rpc_msg = GUIInstructionMessage(
action="set_value",
parameter={"args": [1], "kwargs": {"source": "test"}, "gui_id": "ring"},
metadata={
"request_id": "dispatcher-request",
"receiver": "gui",
"object_name": "progressbar",
"timeout": 0.1,
"sent_at": 1.0,
"deadline": 1.1,
},
)
try:
connector._managed_connection._execute_callback(cb, {"data": rpc_msg}, {})
info_mock.assert_called_once()
info_message = info_mock.call_args.args[0]
assert "GUI RPC dispatcher received request before Qt callback emit" in info_message
assert "request_id=dispatcher-request" in info_message
assert "method=set_value" in info_message
assert "receiver=gui" in info_message
assert "target_gui_id=ring" in info_message
assert "object_name=progressbar" in info_message
assert "timeout=0.1" in info_message
assert "stale_on_dispatch=True" in info_message
warning_mock.assert_called_once()
warning_message = warning_mock.call_args.args[0]
assert "received request after client timeout deadline" in warning_message
assert "request_id=dispatcher-request" in warning_message
finally:
connector.shutdown()
def test_stop_cli_server_is_idempotent(bec_dispatcher):
"""Both GUIServer.shutdown and BECConnector.terminate stop the CLI server at
application exit; the second call must be a silent no-op, not an ERROR."""
from unittest import mock
from bec_widgets.utils import bec_dispatcher as bd_module
with mock.patch.object(bd_module, "logger") as mock_logger:
bec_dispatcher.stop_cli_server()
bec_dispatcher.stop_cli_server()
mock_logger.error.assert_not_called()
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_disconnect_topic_list_single_wrapper(
bec_dispatcher_w_connector, qtbot, send_msg_event
):
# One wrapper holding several topics: a list disconnect releases them all at once.
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
try:
bec_dispatcher.connect_slot(cb1, "topic1")
bec_dispatcher.connect_slot(cb1, "topic2")
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 2
bec_dispatcher.disconnect_slot(cb1, ["topic1", "topic2"])
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 0
finally:
# unblock the fixture's message generator so connector.shutdown() can join
send_msg_event.set()
qtbot.wait(10)
cb1.assert_not_called()
@pytest.mark.parametrize("topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg))])
def test_dispatcher_disconnect_topic_list_spans_wrappers(
bec_dispatcher_w_connector, qtbot, send_msg_event
):
# The same callback registered twice with different cb_info produces two wrappers,
# each holding one of the requested topics: a list disconnect must release BOTH,
# not only the first wrapper that overlaps.
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
try:
bec_dispatcher.connect_slot(cb1, "topic1", cb_info={"scan": "a"})
bec_dispatcher.connect_slot(cb1, "topic2", cb_info={"scan": "b"})
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 2
bec_dispatcher.disconnect_slot(cb1, ["topic1", "topic2"])
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 0
assert not any(
s.cb == cb1 for s in bec_dispatcher._registered_slots.values()
), "all wrappers of the slot must be dropped"
finally:
send_msg_event.set()
qtbot.wait(10)
cb1.assert_not_called()
@pytest.mark.parametrize(
"topics_msg_list", [(("topic1", dummy_msg), ("topic2", dummy_msg), ("topic3", dummy_msg))]
)
def test_dispatcher_disconnect_without_topics_releases_slot_everywhere(
bec_dispatcher_w_connector, qtbot, send_msg_event
):
# Omitting topics disconnects the slot from everything it is subscribed to,
# across all its wrappers — while other slots stay untouched.
bec_dispatcher = bec_dispatcher_w_connector
cb1 = mock.Mock(spec=[])
cb2 = mock.Mock(spec=[])
try:
bec_dispatcher.connect_slot(cb1, "topic1", cb_info={"scan": "a"})
bec_dispatcher.connect_slot(cb1, "topic2", cb_info={"scan": "a"})
bec_dispatcher.connect_slot(cb1, "topic3", cb_info={"scan": "b"})
bec_dispatcher.connect_slot(cb2, "topic1")
assert len(bec_dispatcher.client.connector._managed_connection._topics_cb) == 3
bec_dispatcher.disconnect_slot(cb1)
remaining = bec_dispatcher.client.connector._managed_connection._topics_cb
assert list(remaining) == ["topic1"], "only cb2's topic1 subscription remains"
assert not any(s.cb == cb1 for s in bec_dispatcher._registered_slots.values())
assert any(s.cb == cb2 for s in bec_dispatcher._registered_slots.values())
finally:
send_msg_event.set()
qtbot.waitUntil(lambda: cb2.call_count == 1, timeout=2000)
cb1.assert_not_called()
bec_dispatcher.disconnect_slot(cb2)
# the fixture's generator delivers messages for already-released topics, which
# auto-creates empty defaultdict keys — assert no callbacks remain instead
assert not any(bec_dispatcher.client.connector._managed_connection._topics_cb.values())
assert not any(s.cb == cb2 for s in bec_dispatcher._registered_slots.values())
def _queue_callback(wrapper):
"""Queue a real cross-thread emission without allowing GUI delivery yet."""
thread = threading.Thread(target=wrapper, args=({"value": 1}, {"source": "worker"}))
thread.start()
thread.join(timeout=5)
assert not thread.is_alive()
def _deliver_after_receiver_collection(cyclic):
"""Run in a child process: the original queued bound method could segfault."""
app = QApplication([])
calls = []
class Receiver:
def __init__(self):
self.state = "alive"
if cyclic:
self.cycle = self
def receive(self, content, metadata):
calls.append("entered")
calls.append(self.state)
receiver = Receiver()
receiver_ref = weakref.ref(receiver)
wrapper = QtThreadSafeCallback(receiver.receive)
_queue_callback(wrapper)
del receiver
gc.collect()
assert receiver_ref() is None
assert wrapper.cb is None
# A second queued callback proves that Qt has drained the queued deliveries.
delivered = []
sentinel = QtThreadSafeCallback(lambda *_: delivered.append(True))
_queue_callback(sentinel)
app.processEvents()
assert delivered == [True]
assert calls == []
print("Collected receiver was not called", flush=True)
@pytest.mark.parametrize("cyclic", [False, True], ids=["refcount", "cyclic-gc"])
def test_qthreadsafe_callback_skips_collected_receiver_in_subprocess(cyclic):
"""Keep native crash regressions from terminating the pytest process."""
result = subprocess.run(
[
sys.executable,
"-X",
"faulthandler",
"-c",
"from tests.unit_tests.test_bec_dispatcher import "
f"_deliver_after_receiver_collection; _deliver_after_receiver_collection({cyclic!r})",
],
cwd=Path(__file__).resolve().parents[2],
env={**os.environ, "QT_QPA_PLATFORM": "offscreen", "OPHYD_CONTROL_LAYER": "dummy"},
capture_output=True,
text=True,
timeout=30,
check=False,
)
assert result.returncode == 0, (
f"Queued callback subprocess exited with {result.returncode}\n"
f"{result.stdout[-6000:]}\n{result.stderr[-6000:]}"
)
assert "Collected receiver was not called" in result.stdout
def test_qthreadsafe_callback_delivers_live_receiver_on_gui_thread(qapp):
calls = []
class Receiver:
def receive(self, content, metadata):
calls.append((content, metadata, QThread.currentThread()))
receiver = Receiver()
wrapper = QtThreadSafeCallback(receiver.receive)
_queue_callback(wrapper)
assert calls == []
qapp.processEvents()
assert calls == [({"value": 1}, {"source": "worker"}, qapp.thread())]
def test_qthreadsafe_callback_delivers_subscription_context_to_qobject(qapp):
"""QObject slots receive subscription context without relying on a Qt sender."""
from bec_widgets.utils.error_popups import SafeSlot
calls = []
class Receiver(QObject):
@SafeSlot(dict, dict)
def receive(self, content, metadata):
calls.append((content, metadata, QThread.currentThread()))
receiver = Receiver()
wrapper = QtThreadSafeCallback(receiver.receive, cb_info={"scan_id": "scan-1"})
_queue_callback(wrapper)
assert calls == []
qapp.processEvents()
assert calls == [
({"value": 1}, {"source": "worker", "cb_info": {"scan_id": "scan-1"}}, qapp.thread())
]
def test_qthreadsafe_callback_subscription_context_is_not_shared():
"""A callback cannot mutate shared message metadata or another subscription's context."""
received = []
def callback(_content, metadata):
received.append(metadata["cb_info"]["scan_id"])
metadata["cb_info"]["scan_id"] = "changed"
metadata["source"] = "changed"
first = QtThreadSafeCallback(callback, cb_info={"scan_id": "scan-1"})
second = QtThreadSafeCallback(callback, cb_info={"scan_id": "scan-2"})
metadata = {"source": "worker", "cb_info": {"scan_id": "message-context"}}
first({}, metadata)
second({}, metadata)
assert received == ["scan-1", "scan-2"]
assert metadata == {"source": "worker", "cb_info": {"scan_id": "message-context"}}
assert first.cb_info == {"scan_id": "scan-1"}
assert second.cb_info == {"scan_id": "scan-2"}
@pytest.mark.parametrize(
"disconnect", ["disconnect_owner", "disconnect_slot", "disconnect_topics", "disconnect_all"]
)
def test_dispatcher_disconnect_cancels_queued_delivery(bec_dispatcher, qapp, disconnect):
owner = QObject()
callback = mock.Mock(spec=[])
bec_dispatcher.connect_slot(callback, "lifetime-test", owner=owner)
wrapper = next(slot for slot in bec_dispatcher._registered_slots if slot.cb is callback)
_queue_callback(wrapper)
callback.assert_not_called()
if disconnect == "disconnect_owner":
bec_dispatcher.disconnect_owner(owner)
elif disconnect == "disconnect_slot":
bec_dispatcher.disconnect_slot(callback, "lifetime-test")
elif disconnect == "disconnect_topics":
bec_dispatcher.disconnect_topics("lifetime-test")
else:
bec_dispatcher.disconnect_all()
# The connector may still hold an in-flight reference to the released wrapper.
_queue_callback(wrapper)
qapp.processEvents()
callback.assert_not_called()
assert wrapper not in bec_dispatcher._registered_slots
@pytest.mark.parametrize("disconnect", ["disconnect_slot", "disconnect_topics"])
def test_dispatcher_partial_disconnect_preserves_delivery(bec_dispatcher, qapp, disconnect):
owner = QObject()
callback = mock.Mock(spec=[])
bec_dispatcher.connect_slot(callback, ["removed-topic", "retained-topic"], owner=owner)
wrapper = next(slot for slot in bec_dispatcher._registered_slots if slot.cb is callback)
_queue_callback(wrapper)
if disconnect == "disconnect_slot":
bec_dispatcher.disconnect_slot(callback, "removed-topic")
else:
bec_dispatcher.disconnect_topics("removed-topic")
qapp.processEvents()
callback.assert_called_once_with({"value": 1}, {"source": "worker"})
assert wrapper.topics == {"retained-topic"}
_queue_callback(wrapper)
qapp.processEvents()
assert callback.call_count == 2
def test_dispatcher_reconnect_does_not_reactivate_old_delivery(bec_dispatcher, qapp):
owner = QObject()
callback = mock.Mock(spec=[])
bec_dispatcher.connect_slot(callback, "lifetime-test", owner=owner)
old_wrapper = next(slot for slot in bec_dispatcher._registered_slots if slot.cb is callback)
_queue_callback(old_wrapper)
bec_dispatcher.disconnect_owner(owner)
bec_dispatcher.connect_slot(callback, "lifetime-test", owner=owner)
new_wrapper = next(slot for slot in bec_dispatcher._registered_slots if slot.cb is callback)
assert new_wrapper is not old_wrapper
_queue_callback(old_wrapper)
_queue_callback(new_wrapper)
qapp.processEvents()
callback.assert_called_once_with({"value": 1}, {"source": "worker"})
def test_dispatcher_disconnect_releases_lambda_capture(bec_dispatcher):
owner = QObject()
owner_ref = weakref.ref(owner)
bec_dispatcher.connect_slot(
lambda _content, _metadata, target=owner: target.objectName(), "lifetime-test", owner=owner
)
wrapper = next(
slot
for slot in bec_dispatcher._registered_slots
if slot.cb_owner is not None and slot.cb_owner() is owner
)
callback_ref = weakref.ref(wrapper.cb)
del owner
gc.collect()
assert owner_ref() is not None
bec_dispatcher.disconnect_owner(owner_ref())
gc.collect()
# An in-flight wrapper must not keep the lambda or its captured owner alive.
assert callback_ref() is None
assert owner_ref() is None
@pytest.mark.parametrize("teardown", ["native-delete", "cleanup", "garbage-collect"])
def test_qthreadsafe_callback_checks_lambda_owner_at_delivery(qapp, teardown):
calls = []
owner = QObject()
owner_ref = weakref.ref(owner)
wrapper = QtThreadSafeCallback(lambda *_: calls.append(True), owner=owner)
_queue_callback(wrapper)
if teardown == "native-delete":
owner.deleteLater()
qapp.sendPostedEvents(owner, QEvent.Type.DeferredDelete)
assert not shiboken6.isValid(owner)
elif teardown == "cleanup":
owner._destroyed = True
else:
del owner
gc.collect()
assert owner_ref() is None
qapp.processEvents()
assert calls == []
@pytest.mark.parametrize("topics", [["span-a", "span-b"], None], ids=["topic-list", "no-topics"])
def test_dispatcher_disconnect_slot_spanning_wrappers_cancels_queued_deliveries(
bec_dispatcher, qapp, topics
):
# disconnect_slot may release several wrappers of the same callback at once (a topic list
# spanning registrations, or no topics at all); every fully emptied wrapper must be
# invalidated so deliveries that are already queued for it are dropped.
callback = mock.Mock(spec=[])
bec_dispatcher.connect_slot(callback, "span-a", cb_info={"scan": "a"})
bec_dispatcher.connect_slot(callback, "span-b", cb_info={"scan": "b"})
wrappers = [slot for slot in bec_dispatcher._registered_slots if slot.cb is callback]
assert len(wrappers) == 2
for wrapper in wrappers:
_queue_callback(wrapper)
bec_dispatcher.disconnect_slot(callback, topics)
for wrapper in wrappers:
_queue_callback(wrapper) # in-flight emissions after the release
qapp.processEvents()
callback.assert_not_called()
assert not any(slot.cb is callback for slot in bec_dispatcher._registered_slots)