From 87b33bc35574ae2bcc5230d178757ca0a14277de Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 7 Oct 2026 14:56:43 +0200 Subject: [PATCH] fix(widgets): skip deferred single shots of closed widgets --- .../device_form_dialog.py | 5 +++- .../components/device_table/device_table.py | 2 +- bec_widgets/widgets/plots/heatmap/heatmap.py | 7 +++-- .../widgets/plots/waveform/waveform.py | 7 ++--- .../widgets/utility/logpanel/logpanel.py | 2 +- .../test_device_manager_components.py | 15 ++++++++++ tests/unit_tests/test_device_manager_view.py | 15 ++++++++++ tests/unit_tests/test_heatmap_widget.py | 29 +++++++++++++++++++ tests/unit_tests/test_logpanel.py | 19 ++++++++++++ tests/unit_tests/test_waveform.py | 21 ++++++++++++++ 10 files changed, 112 insertions(+), 10 deletions(-) diff --git a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py index 11f5f701..58db2ba2 100644 --- a/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py +++ b/bec_widgets/applications/views/device_manager_view/device_manager_dialogs/device_form_dialog.py @@ -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 diff --git a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py index 4b4a408c..9d3b9a27 100644 --- a/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py +++ b/bec_widgets/widgets/control/device_manager/components/device_table/device_table.py @@ -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. diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index 139c7b40..7520f612 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -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: diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index b6aef85b..1838f782 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -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 diff --git a/bec_widgets/widgets/utility/logpanel/logpanel.py b/bec_widgets/widgets/utility/logpanel/logpanel.py index c9b95420..74c0a733 100644 --- a/bec_widgets/widgets/utility/logpanel/logpanel.py +++ b/bec_widgets/widgets/utility/logpanel/logpanel.py @@ -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): diff --git a/tests/unit_tests/test_device_manager_components.py b/tests/unit_tests/test_device_manager_components.py index e513c0e5..b8de3332 100644 --- a/tests/unit_tests/test_device_manager_components.py +++ b/tests/unit_tests/test_device_manager_components.py @@ -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: """ diff --git a/tests/unit_tests/test_device_manager_view.py b/tests/unit_tests/test_device_manager_view.py index dc0f59c3..be934951 100644 --- a/tests/unit_tests/test_device_manager_view.py +++ b/tests/unit_tests/test_device_manager_view.py @@ -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) diff --git a/tests/unit_tests/test_heatmap_widget.py b/tests/unit_tests/test_heatmap_widget.py index a434fe8f..f1ca4f75 100644 --- a/tests/unit_tests/test_heatmap_widget.py +++ b/tests/unit_tests/test_heatmap_widget.py @@ -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() diff --git a/tests/unit_tests/test_logpanel.py b/tests/unit_tests/test_logpanel.py index 32710cd7..352a2296 100644 --- a/tests/unit_tests/test_logpanel.py +++ b/tests/unit_tests/test_logpanel.py @@ -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) diff --git a/tests/unit_tests/test_waveform.py b/tests/unit_tests/test_waveform.py index 2dca9dcb..de77bdbc 100644 --- a/tests/unit_tests/test_waveform.py +++ b/tests/unit_tests/test_waveform.py @@ -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,