diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index e238599d..56c78730 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -1,5 +1,7 @@ from bec_widgets.applications.startup_profiler import startup_profiler # isort: skip +from bec_lib.endpoints import MessageEndpoints +from bec_lib.logger import bec_logger from bec_qthemes import material_icon from qtpy.QtCore import QTimer from qtpy.QtGui import QAction # type: ignore @@ -14,6 +16,7 @@ from bec_widgets.applications.views.device_manager_view.device_manager_view impo from bec_widgets.applications.views.dock_area_view.dock_area_view import DockAreaView from bec_widgets.applications.views.view import ViewBase, WaveformViewInline, WaveformViewPopup from bec_widgets.utils.colors import apply_theme +from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.utils.guided_tour import GuidedTour from bec_widgets.utils.launcher_ready import notify_launcher_ready from bec_widgets.utils.name_utils import sanitize_namespace @@ -70,6 +73,19 @@ class BECMainApp(BECMainWindow): self._setup_guided_tour() startup_profiler.mark("guided tour") + self.bec_dispatcher.connect_slot(self._on_client_restart, MessageEndpoints.client_restart()) + + @SafeSlot(dict, dict) + def _on_client_restart(self, msg: dict, _meta: dict) -> None: + """Close the application when the server requests clients to restart.""" + reason = msg.get("reason") + detail = f": {reason}" if reason else "" + bec_logger.logger.info(f"Client restart requested by server{detail}. Exiting...") + # Include hidden windows and continue if a launcher rejects its close event. + for widget in self.app.topLevelWidgets(): + widget.close() + self.app.quit() + def showEvent(self, event): super().showEvent(event) if self._launcher_ready_notified: diff --git a/tests/unit_tests/test_main_app.py b/tests/unit_tests/test_main_app.py index ebdeb33c..f771de12 100644 --- a/tests/unit_tests/test_main_app.py +++ b/tests/unit_tests/test_main_app.py @@ -1,10 +1,15 @@ +import threading + import pytest -from qtpy.QtCore import QRect +from bec_lib import messages +from bec_lib.endpoints import MessageEndpoints +from qtpy.QtCore import QRect, Qt, QThread from qtpy.QtWidgets import QWidget from bec_widgets.applications.main_app import BECMainApp from bec_widgets.applications.views.view import ViewBase from bec_widgets.tests.client_mocks import mocked_client +from bec_widgets.tests.utils import create_widget from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.widgets.containers.dock_area.profile_utils import ( is_experimental_features_enabled, @@ -14,6 +19,95 @@ from bec_widgets.widgets.containers.dock_area.profile_utils import ( ANIM_TEST_DURATION = 60 # ms +@pytest.mark.parametrize("reason", ["", "server maintenance"]) +@pytest.mark.parametrize("visible", [False, True], ids=["hidden", "visible"]) +def test_server_restart_closes_main_app_and_other_windows( + qtbot, mocked_client, monkeypatch, tmp_path, reason, visible +): + class OtherWindow(QWidget): + closed = False + + def closeEvent(self, event): + self.closed = True + super().closeEvent(event) + + monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(tmp_path)) + app = create_widget(qtbot, BECMainApp, client=mocked_client) + # Keep the window alive so qtbot can close it again during teardown. + app.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False) + other_window = create_widget(qtbot, OtherWindow) + if visible: + app.show() + other_window.show() + dispatcher = app.bec_dispatcher + quit_threads = [] + monkeypatch.setattr(app.app, "quit", lambda: quit_threads.append(QThread.currentThread())) + + restart_slot = next( + slot for slot in dispatcher._registered_slots if slot.cb == app._on_client_restart + ) + assert MessageEndpoints.client_restart().endpoint in restart_slot.topics + message = messages.ClientRestartMessage(reason=reason) + # Deliver like the Redis worker, through the registered Qt dispatcher callback. + worker = threading.Thread(target=restart_slot, args=(message.content, message.metadata)) + worker.start() + worker.join(timeout=1) + assert not worker.is_alive() + + qtbot.waitUntil(lambda: len(quit_threads) == 1) + assert quit_threads == [app.app.thread()] + assert app._destroyed + assert other_window.closed + assert not other_window.isVisible() + assert not any(slot.cb == app._on_client_restart for slot in dispatcher._registered_slots) + + +def test_closed_main_app_ignores_pending_restart(qtbot, mocked_client, monkeypatch, tmp_path): + monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(tmp_path)) + app = create_widget(qtbot, BECMainApp, client=mocked_client) + app.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False) + quit_calls = [] + monkeypatch.setattr(app.app, "quit", lambda: quit_calls.append(True)) + restart_slot = next( + slot for slot in app.bec_dispatcher._registered_slots if slot.cb == app._on_client_restart + ) + + app.close() + restart_slot({"reason": "server maintenance"}, {}) + + assert quit_calls == [] + assert not restart_slot._active + + +def test_server_restart_continues_after_rejected_close(qtbot, mocked_client, monkeypatch, tmp_path): + class RejectingWindow(QWidget): + reject_close = True + + def closeEvent(self, event): + if self.reject_close: + event.ignore() + else: + super().closeEvent(event) + + monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(tmp_path)) + app = create_widget(qtbot, BECMainApp, client=mocked_client) + app.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False) + app.show() + rejecting_window = create_widget(qtbot, RejectingWindow) + rejecting_window.show() + quit_calls = [] + monkeypatch.setattr(app.app, "quit", lambda: quit_calls.append(True)) + # Qt visits windows in an unspecified order; put the rejecting window first. + with monkeypatch.context() as patch: + patch.setattr(app.app, "topLevelWidgets", lambda: [rejecting_window, app]) + try: + app._on_client_restart({}, {}) + assert app._destroyed + assert quit_calls == [True] + finally: + rejecting_window.reject_close = False + + @pytest.fixture def viewbase(qtbot): v = ViewBase(content=QWidget())