feat: close main app on server client restart requests

This commit is contained in:
wakonig_k authored and Jan Wyzula committed 2026-10-21 16:34:22 +02:00
1 parent e290738343
commit 10e203f311
2 files changed
+111 -1

No files matched your search

+16
View File
@@ -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:
+95 -1
View File
@@ -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())