fix(widgets): skip deferred single shots of closed widgets

This commit is contained in:
wyzula_j authored and Jan Wyzula committed 2026-10-07 18:36:26 +02:00
1 parent fd8707d7f5
commit 87b33bc355
10 files changed
+112 -10

No files matched your search

@@ -311,8 +311,11 @@ class DeviceFormDialog(QtWidgets.QDialog):
# this is relevant for the _wait_dialog exec which opens a modal dialog during validation
# and therefore must not have the signal emitted immediately in the same event loop iteration.
# Otherwise, the callback may be scheduled before the dialog is shown resulting in a deadlock.
# Bound to the validation widget: Qt drops the call if it is deleted before it runs.
QtCore.QTimer.singleShot(
0, lambda: ophyd_validation.change_device_configs([config], True, False)
0,
ophyd_validation,
lambda: ophyd_validation.change_device_configs([config], True, False),
)
return ophyd_validation
@@ -547,7 +547,7 @@ class DeviceTable(BECWidget, QtWidgets.QWidget):
self.search_controls.addLayout(self.search_layout)
self.search_controls.addSpacing(20) # Add some space between the search box and toggle
self.search_controls.addLayout(self.fuzzy_layout)
QtCore.QTimer.singleShot(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0))
self._call_later(0, lambda: self.fuzzy_is_disabled.stateChanged.emit(0))
# -------------------------------------------------------------------------
# Row Management, internal methods.
+4 -3
View File
@@ -11,7 +11,7 @@ from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import_from
from bec_qthemes import material_icon
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, Qt, QThread, QTimer, Signal
from qtpy.QtCore import QObject, Qt, QThread, Signal
from qtpy.QtGui import QTransform
from qtpy.QtWidgets import QDialog, QPushButton, QVBoxLayout
from toolz import partition
@@ -668,8 +668,9 @@ class Heatmap(ImageBase):
self.sync_signal_update.emit()
status = msg.get("done")
if status:
QTimer.singleShot(100, self.update_plot)
QTimer.singleShot(300, self.update_plot)
# Final refreshes for late scan data; skipped if the widget is closed meanwhile.
self._call_later(100, self.update_plot)
self._call_later(300, self.update_plot)
@SafeSlot()
def update_plot(self, _=None) -> None:
@@ -12,7 +12,7 @@ from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_obj
from bec_lib.scan_data_container import ScanDataContainer
from bec_lib.utils.import_utils import lazy_import
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Qt, QTimer, Signal
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
@@ -1180,9 +1180,8 @@ class Waveform(PlotBase):
if config.source == "history":
self._history_curves.append(curve)
QTimer.singleShot(
150, self.auto_range
) # autorange with a delay to ensure the plot is updated
# Autorange with a delay to ensure the plot is updated; skipped if the widget is closed.
self._call_later(150, self.auto_range)
self._refresh_alignment_state()
return curve
@@ -874,7 +874,7 @@ class LogPanel(BECWidget, QWidget):
# defer: when the current row moves because of a buffer trim, this signal fires
# while the trimmed rows are still present - only after the update settles can we
# tell a user-driven move from the selection model relocating a removed row
QTimer.singleShot(0, self._sync_detail_to_selection)
self._call_later(0, self._sync_detail_to_selection)
@SafeSlot()
def _sync_detail_to_selection(self):
@@ -685,6 +685,21 @@ class TestDeviceTable:
assert len(selected_configs) == 1
assert list(selected_configs[0].keys())[0] in ["motor_x", "detector_main"]
def test_initial_fuzzy_sync_skipped_when_closed_first(self, qtbot, mocked_client):
"""
The table defers its initial fuzzy-search sync to the event loop: it runs for an open
table and is skipped for a table closed before the event loop gets to it.
"""
with mock.patch.object(DeviceTable, "_state_change_fuzzy_search") as fuzzy_sync:
open_table = DeviceTable(client=mocked_client)
closed_table = DeviceTable(client=mocked_client)
qtbot.addWidget(open_table)
qtbot.addWidget(closed_table)
closed_table.close() # closeEvent -> cleanup() before the deferred sync runs
qtbot.wait(50)
fuzzy_sync.assert_called_once_with(0)
class TestOphydValidation:
"""
@@ -308,6 +308,21 @@ class TestDeviceManagerViewDialogs:
mock_create_dialog.assert_not_called()
mock_create_validation.assert_not_called()
def test_device_form_validation_start_dropped_with_validation_widget(
self, device_form_dialog: DeviceFormDialog, qtbot
):
"""
The dialog starts the ophyd validation from the event loop; the deferred start is
bound to the validation widget, so it is dropped if that widget is deleted first.
"""
with mock.patch.object(OphydValidation, "change_device_configs") as change_configs:
validation = device_form_dialog._create_and_run_ophyd_validation({"name": "dev"})
validation.deleteLater()
QtWidgets.QApplication.sendPostedEvents(None, QtCore.QEvent.Type.DeferredDelete)
qtbot.wait(50)
change_configs.assert_not_called()
def test_device_status_item(self, device_config: dict, qtbot):
"""Test the DeviceStatusItem widget."""
item = DeviceStatusItem(device_config=device_config, config_status=0, connection_status=0)
+29
View File
@@ -1193,3 +1193,32 @@ def test_heatmap_cleanup_detaches_update_proxy(qtbot, mocked_client):
qtbot.wait(300)
update_plot.assert_not_called()
def test_heatmap_scan_done_refreshes_skipped_after_close(qtbot, mocked_client):
"""
A finished scan schedules two delayed final refreshes: they run while the Heatmap is open
and are skipped once it has been closed (cleaned up) before they fire.
"""
with mock.patch.object(Heatmap, "update_plot") as update_plot:
open_hm = Heatmap(client=mocked_client)
qtbot.addWidget(open_hm)
qtbot.waitExposed(open_hm)
qtbot.wait(100) # let anything queued during construction drain
update_plot.reset_mock()
open_hm.on_scan_progress({"done": True}, {})
# one proxied update plus the 100 ms and 300 ms refreshes
qtbot.waitUntil(lambda: update_plot.call_count == 3, timeout=2000)
closed_hm = Heatmap(client=mocked_client)
qtbot.addWidget(closed_hm)
qtbot.waitExposed(closed_hm)
qtbot.wait(100)
update_plot.reset_mock()
closed_hm.on_scan_progress({"done": True}, {}) # schedules the refreshes
closed_hm.close() # closeEvent -> cleanup() before they fire
qtbot.wait(450)
update_plot.assert_not_called()
+19
View File
@@ -559,6 +559,25 @@ def test_log_panel_font_size_property(qtbot, log_panel: LogPanel):
assert log_panel._table.columnWidth(0) == default_level_width
def test_log_panel_deferred_detail_sync_skipped_after_close(qtbot, log_panel: LogPanel):
"""
A current-row change defers the detail-pane sync to the event loop: it runs while the
panel is open and is skipped once the panel has been closed before it is due.
"""
log_panel._table.setCurrentIndex(log_panel._proxy.index(1, 0))
log_panel._show_details()
assert not log_panel._detail.isHidden()
with patch.object(log_panel, "_sync_detail_to_selection") as sync_detail:
log_panel._on_current_row_changed()
qtbot.waitUntil(lambda: sync_detail.call_count == 1, timeout=500)
log_panel._on_current_row_changed()
log_panel.close() # closeEvent -> cleanup() before the deferred sync runs
qtbot.wait(50)
assert sync_detail.call_count == 1
def test_log_panel_custom_range_dialog_prefills_active_bounds(qtbot, log_panel: LogPanel):
toolbar = log_panel._toolbar
toolbar._apply_time_preset("Last 5 min", 300)
+21
View File
@@ -706,6 +706,27 @@ def test_waveform_scan_done_refreshes_skipped_after_cleanup(qtbot, waveform_with
assert slots["sync"].call_count == 0
def test_waveform_add_curve_autorange_skipped_after_close(qtbot, mocked_client):
"""
Adding a curve schedules a delayed auto-range: it runs while the Waveform is open and is
skipped once the Waveform has been closed (cleaned up) before the delay elapsed.
"""
open_wf = create_widget(qtbot, Waveform, client=mocked_client)
closed_wf = create_widget(qtbot, Waveform, client=mocked_client)
with (
mock.patch.object(open_wf, "auto_range") as open_auto_range,
mock.patch.object(closed_wf, "auto_range") as closed_auto_range,
):
open_wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="open_curve")
closed_wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="closed_curve")
calls_before_close = closed_auto_range.call_count
closed_wf.close() # closeEvent -> cleanup() before the auto-range is due
qtbot.waitUntil(lambda: open_auto_range.call_count > 0, timeout=1000)
qtbot.wait(250)
assert closed_auto_range.call_count == calls_before_close
def test_request_dap_always_resubmits_device_parent(qtbot, mocked_client_with_dap, monkeypatch):
"""
DAP curves attached to device curves keep the resubmit-on-every-update behavior,