fix(logpanel): keep the log queue out of the RPC registry and tear it down with the last panel

This commit is contained in:
2026-08-04 12:11:00 +02:00
committed by Jan Wyzula
parent 0c1651f62e
commit 2c7437142c
2 changed files with 58 additions and 5 deletions
@@ -170,9 +170,13 @@ class BecLogsQueue(BECConnector, QObject):
def __init__(self, parent: QObject | None, maxlen: int = 2500, **kwargs) -> None:
if BecLogsQueue._instance:
raise RuntimeError("Create no more than one BecLogsQueue - use BecLogsQueue.instance()")
super().__init__(parent=parent, **kwargs)
# rpc_exposed=False: a background data pump with no RPC surface must not enter
# the RPC registry, where it would linger as a windowless connection
super().__init__(parent=parent, rpc_exposed=False, **kwargs)
self._max_length = maxlen
self._paused = False
self._attached_panels = 0
self._cleaned = False
self._data = deque(
(
item["data"]
@@ -209,13 +213,30 @@ class BecLogsQueue(BECConnector, QObject):
self._paused = not self._paused
self.paused.emit(self._paused)
def attach(self):
"""Register a consumer panel; the queue stays alive while any panel is open."""
self._attached_panels += 1
def detach(self):
"""Unregister a consumer panel. The last one tears the queue down - log history
survives in Redis and is re-read when the next panel opens."""
self._attached_panels -= 1
if self._attached_panels <= 0:
self.cleanup()
def cleanup(self, *_):
"""Stop listening to the Redis log stream"""
"""Stop listening to the Redis log stream and release the singleton. Idempotent:
reachable from the last panel's detach, from tests, and from app quit."""
if self._cleaned:
return
self._cleaned = True
QCoreApplication.instance().aboutToQuit.disconnect(self.cleanup)
self.bec_dispatcher.disconnect_slot(
self._process_incoming_log_msg, [MessageEndpoints.log()]
)
self._update_timer.stop()
BecLogsQueue._instance = None
self.deleteLater()
@SafeSlot(verify_sender=True)
def _process_incoming_log_msg(self, msg: dict, _metadata: dict):
@@ -244,6 +265,7 @@ class BecLogsTableModel(QAbstractTableModel):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.log_queue = BecLogsQueue.instance()
self.log_queue.attach()
self._headers = _CONST.headers
self._max_length = self.log_queue.max_length
self._rows: list[_LogRec] = self.log_queue.snapshot_records()
@@ -975,11 +997,13 @@ class LogPanel(BECWidget, QWidget):
return QSize(600, 300)
def cleanup(self):
"""Detach from the shared log queue so a closed panel stops receiving updates."""
"""Detach from the shared log queue so a closed panel stops receiving updates.
The last panel's detach tears the queue down entirely."""
self._model.log_queue.new_records.disconnect(self._model._on_new_records)
if hasattr(self, "_toolbar"):
self._model.log_queue.paused.disconnect(self._toolbar.set_paused)
self._model.log_queue.buffered.disconnect(self._toolbar.set_buffered)
self._model.log_queue.detach()
super().cleanup()
+31 -2
View File
@@ -273,12 +273,41 @@ def test_log_panel_survives_malformed_messages(qtbot, log_panel: LogPanel):
second_panel.close()
def test_log_panel_close_detaches_from_queue(qtbot, log_panel: LogPanel):
def test_log_panel_close_tears_down_queue(qtbot, log_panel: LogPanel):
from bec_widgets.widgets.utility.logpanel.logpanel import BecLogsQueue
queue = log_panel._model.log_queue
assert not queue.rpc_register.object_is_registered(queue) # never an RPC connection
log_panel.close()
# the last panel's close fully tears the queue down - nothing lingers after it
assert BecLogsQueue._instance is None
assert not queue._update_timer.isActive()
_feed(log_panel, [make_log_msg(0)])
assert log_panel._model.rowCount() == 3 # closed panel no longer receives updates
assert len(queue) == 4 # the shared history still ingests
def test_log_panel_queue_survives_until_last_panel_closes(qtbot, mocked_client, monkeypatch):
from bec_widgets.widgets.utility.logpanel.logpanel import BecLogsQueue
monkeypatch.setattr(mocked_client.connector, "xread", lambda *_, **__: TEST_LOG_MESSAGES)
first = LogPanel()
qtbot.addWidget(first)
second = LogPanel()
qtbot.addWidget(second)
queue = first._model.log_queue
assert second._model.log_queue is queue
second.close()
assert BecLogsQueue._instance is queue # one panel still open
assert queue._update_timer.isActive()
first.close()
assert BecLogsQueue._instance is None
assert not queue._update_timer.isActive()
# a new panel re-creates the queue and backfills its history from Redis
reopened = LogPanel()
qtbot.addWidget(reopened)
assert reopened._model.log_queue is not queue
assert reopened._model.rowCount() == 3
reopened.close()
def test_direct_queue_construction_registers_singleton(qtbot, mocked_client, monkeypatch):