From 115b0cf07ae1c28f143171e610785da1421fc37f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 3 Aug 2026 11:26:41 +0200 Subject: [PATCH] fix(connector): break C++-anchored reference cycle retaining task owners --- bec_widgets/utils/bec_connector.py | 12 +++++++++++- tests/unit_tests/test_bec_connector.py | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/bec_widgets/utils/bec_connector.py b/bec_widgets/utils/bec_connector.py index e4f71bc1..511a5edc 100644 --- a/bec_widgets/utils/bec_connector.py +++ b/bec_widgets/utils/bec_connector.py @@ -403,8 +403,18 @@ class BECConnector: # Keep a reference to the worker so it is not garbage collected. self._workers.append(worker) - # When the worker is done (success or failure), remove it from our list. + # When the worker is done (success or failure), remove it from our + # list. The closure must disconnect itself from both signals before + # returning: the Qt connection holds the closure strongly from C++, + # and the closure captures self and worker — without the disconnect + # this forms a reference cycle anchored in C++ that Python's GC + # cannot break, keeping the owning widget alive forever. def _discard_worker(*_): + for signal in (worker.signals.completed, worker.signals.failed): + try: + signal.disconnect(_discard_worker) + except (RuntimeError, TypeError): + pass try: self._workers.remove(worker) except ValueError: diff --git a/tests/unit_tests/test_bec_connector.py b/tests/unit_tests/test_bec_connector.py index 8dd77952..5b4fe749 100644 --- a/tests/unit_tests/test_bec_connector.py +++ b/tests/unit_tests/test_bec_connector.py @@ -277,3 +277,26 @@ def test_bec_connector_parent_id_returns_none_on_error(bec_connector): bec_connector, "_get_rpc_parent_ancestor", side_effect=ValueError("broken hierarchy") ): assert bec_connector.parent_id is None + + +def test_bec_connector_worker_completion_does_not_retain_owner(qtbot, mocked_client): + """The worker-discard closure is held strongly + by the Qt signal connection and captures the owner; without disconnecting + itself it forms a C++-anchored reference cycle that keeps the owner (and + widget) alive forever.""" + import gc + import weakref + + connector = _CleanupBroadcastWidget(client=mocked_client) + connector.submit_task(lambda: None) + qtbot.waitUntil(lambda: not connector._workers, timeout=5000) + + ref = weakref.ref(connector) + connector.close() + connector.deleteLater() + qtbot.wait(20) + del connector + for _ in range(3): + gc.collect() + + assert ref() is None, "connector kept alive by worker completion closure"