From f3453cb9b7f52feab8ccffccb23dedffe2bb3f47 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 10 Sep 2026 15:34:05 +0200 Subject: [PATCH] fix(gui): recreate Wayland surfaces when raising windows --- README.md | 21 ++-- bec_widgets/utils/rpc_server.py | 47 ++------ tests/unit_tests/test_rpc_server.py | 163 +++++++++++++++++++--------- 3 files changed, 137 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index b8f4471f..8b028c80 100644 --- a/README.md +++ b/README.md @@ -106,12 +106,17 @@ when a window is behind another application. `gui.raise_window()` uses the same `gui.flomni.raise_window()` targets one window. Already-visible, active windows are left unchanged. On XCB (X11/XWayland), BEC retains its GNOME/RHEL window-raising workaround and restores the original -window flags afterwards. On native Wayland, the experimental workaround hides a visible, inactive -window and schedules showing it on a subsequent Qt event-loop turn. Repeated requests share one -pending show; an explicit RPC hide/show of that window, or a close, cancels it. Hidden windows are -shown immediately. -The workaround preserves contents and flags, but may flicker or let the compositor reposition the -window. It does not guarantee foreground activation. macOS uses Qt's normal activation request. +window flags afterwards. On native Wayland, the experimental workaround releases an inactive +window's existing native resources before showing it. This avoids reusing the Wayland surface +involved in alternating visibility with Qt 6.11.1 on the affected GNOME 40 console. It also applies to +previously hidden windows. A window that has never been shown is shown normally. + +The QWidget instances and their BEC connections stay alive; constructors and BEC cleanup are not +called. Native QWindow handles and backing stores are recreated, so this has a rendering cost and +cached native handles become invalid. Embedded renderers may have additional resource costs. +Flags and the non-minimized window state are preserved, but the compositor may reposition the +window and a visible window may flicker. Foreground activation remains compositor-dependent. +macOS uses Qt's normal activation request. Wayland's supported focus transfer uses an XDG activation token from the focused application. The terminal's input event/token is not carried by BEC's Redis RPC, so GNOME may reject activation. @@ -157,8 +162,8 @@ the output and the result of `gnome-shell --version` when reporting a GNOME issu If `cycle` alternates between appearing and disappearing, try `recreate` at least four times, one command at a time. It runs hide/destroy/show on the same QWidget: unlike `cycle`, it releases the native window resources before showing the widget again. This isolates native surface reuse -without changing BEC's window behavior. It is a diagnostic experiment, not a validated workaround -for BEC windows with docked widgets or embedded rendering contexts. +without starting BEC. A successful plain-widget probe still needs verification with BEC's docked +widgets and embedded renderers on the affected compositor. To capture the Wayland protocol alongside the probe output, write to a unique temporary file if the checkout is not writable: diff --git a/bec_widgets/utils/rpc_server.py b/bec_widgets/utils/rpc_server.py index 1288719e..31860af5 100644 --- a/bec_widgets/utils/rpc_server.py +++ b/bec_widgets/utils/rpc_server.py @@ -11,7 +11,7 @@ from bec_lib.client import BECClient from bec_lib.endpoints import MessageEndpoints from bec_lib.logger import bec_logger from bec_lib.utils.import_utils import lazy_import -from qtpy.QtCore import QEvent, QObject, Qt, QTimer +from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import QApplication, QWidget from redis.exceptions import RedisError @@ -40,24 +40,6 @@ class RegistryNotReadyError(Exception): """Raised when trying to access an object from the RPC registry that is not yet registered.""" -class _WindowRaiser(QObject): - """Finish a Wayland hide/show across event-loop turns; the window owns the timer.""" - - def __init__(self, window: QWidget) -> None: - super().__init__(window) - self.timer = QTimer(self) - self.timer.setSingleShot(True) - self.timer.timeout.connect(window.show) - self.timer.timeout.connect(window.raise_) - self.timer.timeout.connect(window.activateWindow) - window.installEventFilter(self) - - def eventFilter(self, watched: QObject, event: QEvent) -> bool: - if event.type() == QEvent.Close: - self.timer.stop() - return False - - @contextmanager def rpc_exception_hook(err_func): """This context replaces the popup message box for error display with a specific hook""" @@ -256,11 +238,6 @@ class RPCServer: self._raise_window(obj.window()) res = None else: - if method in ("hide", "show") and isinstance(obj, QWidget) and obj.isWindow(): - # Explicit visibility requests supersede a deferred Wayland raise. - raiser = obj.findChild(_WindowRaiser, options=Qt.FindDirectChildrenOnly) - if raiser is not None: - raiser.timer.stop() target_obj, method_obj = self._resolve_rpc_target(obj, method) # check if the method accepts args and kwargs if not callable(method_obj): @@ -279,10 +256,6 @@ class RPCServer: if window.isVisible() and window.isActiveWindow() and not window.isMinimized(): return - raiser = window.findChild(_WindowRaiser, options=Qt.FindDirectChildrenOnly) - if raiser is not None and raiser.timer.isActive(): - return - state = window.windowState() & ~Qt.WindowMinimized platform = QApplication.platformName() if platform == "xcb": @@ -300,16 +273,16 @@ class RPCServer: window.show() return - if window.isMinimized(): - window.setWindowState(state) - if platform.startswith("wayland") and window.isVisible(): - # Give Qt an event-loop turn between hiding and showing. A synchronous hide/show - # can leave the native Wayland surface hidden until the next RPC call. - if raiser is None: - raiser = _WindowRaiser(window) + if platform.startswith("wayland") and window.windowHandle() is not None: + # Qt 6.11/GNOME 40 can visually alternate when hide/show reuses a Wayland surface. + # Release native resources, keeping the QWidget instances and their BEC connections. + # Include previously hidden windows: plain show can reuse the affected surface too. + screen = window.screen() window.hide() - raiser.timer.start(0) - return + window.destroy() + window.setScreen(screen) + if window.windowState() != state: + window.setWindowState(state) if not window.isVisible(): window.show() window.raise_() diff --git a/tests/unit_tests/test_rpc_server.py b/tests/unit_tests/test_rpc_server.py index aa84c980..5c68e6eb 100644 --- a/tests/unit_tests/test_rpc_server.py +++ b/tests/unit_tests/test_rpc_server.py @@ -4,8 +4,9 @@ from unittest.mock import MagicMock, patch import pytest from bec_lib.service_config import ServiceConfig -from qtpy.QtCore import QCoreApplication, QEvent, QObject, Qt, QThread -from qtpy.QtWidgets import QWidget +from qtpy.QtCore import QEvent, QObject, Qt, QThread +from qtpy.QtTest import QSignalSpy +from qtpy.QtWidgets import QVBoxLayout, QWidget from bec_widgets.applications import companion_app as companion_app_module from bec_widgets.applications.companion_app import GUIServer @@ -130,10 +131,15 @@ def test_repeated_show_and_raise_preserve_visibility_and_flags( events = WindowEvents(dummy_widget) for _ in range(3): + handle = dummy_widget.windowHandle() rpc_server.run_rpc(dummy_widget, method, [], {}) qapp.processEvents() assert dummy_widget.isVisible() assert dummy_widget.windowFlags() == flags + if method == "raise" and platform.startswith("wayland"): + assert dummy_widget.windowHandle() is not handle + else: + assert dummy_widget.windowHandle() is handle expected_remaps = 3 if method == "raise" and platform.startswith("wayland") else 0 assert events.hide_events == events.show_events == expected_remaps @@ -186,9 +192,10 @@ def test_raise_active_window_is_noop(rpc_server, dummy_widget, monkeypatch, plat patch.object(dummy_widget, "activateWindow") as activate, patch.object(dummy_widget, "show") as show, patch.object(dummy_widget, "hide") as hide, + patch.object(dummy_widget, "destroy") as destroy, ): rpc_server.run_rpc(dummy_widget, "raise", [], {}) - for operation in (flags, state, raise_window, activate, show, hide): + for operation in (flags, state, raise_window, activate, show, hide, destroy): operation.assert_not_called() @@ -210,13 +217,22 @@ def test_raise_visible_inactive_window_requests_activation( @pytest.mark.parametrize("platform", ["wayland", "wayland-egl"]) -@pytest.mark.parametrize("initially_visible", [False, True]) -def test_wayland_raise_defers_remapping_but_shows_hidden_windows_immediately( - rpc_server, dummy_widget, monkeypatch, qtbot, platform, initially_visible +@pytest.mark.parametrize("initial_state", ["never_shown", "hidden", "visible"]) +def test_wayland_raise_preserves_widget_and_replaces_existing_native_window( + rpc_server, dummy_widget, monkeypatch, qapp, platform, initial_state ): monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: platform) monkeypatch.setattr(dummy_widget, "isActiveWindow", lambda: False) - dummy_widget.setVisible(initially_visible) + if initial_state != "never_shown": + dummy_widget.show() + qapp.processEvents() + if initial_state == "hidden": + dummy_widget.hide() + handle = dummy_widget.windowHandle() + native_destroyed = QSignalSpy(handle.destroyed) if handle is not None else None + widget_destroyed = QSignalSpy(dummy_widget.destroyed) + last_window_closed = QSignalSpy(qapp.lastWindowClosed) + screen = dummy_widget.screen() events = WindowEvents(dummy_widget) visible_during_activation = [] @@ -226,31 +242,17 @@ def test_wayland_raise_defers_remapping_but_shows_hidden_windows_immediately( side_effect=lambda: visible_during_activation.append(dummy_widget.isVisible()), ): rpc_server.run_rpc(dummy_widget, "raise", [], {}) - if initially_visible: - assert not dummy_widget.isVisible() - assert visible_during_activation == [] - else: - assert dummy_widget.isVisible() - qtbot.waitUntil(lambda: bool(visible_during_activation)) + assert dummy_widget.isVisible() + qapp.processEvents() - assert events.hide_events == int(initially_visible) + assert events.hide_events == int(initial_state == "visible") assert events.show_events == 1 assert visible_during_activation == [True] - assert dummy_widget.isVisible() - - -def test_repeated_wayland_raises_share_pending_remap(rpc_server, dummy_widget, monkeypatch, qtbot): - monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland") - monkeypatch.setattr(dummy_widget, "isActiveWindow", lambda: False) - dummy_widget.show() - events = WindowEvents(dummy_widget) - - for _ in range(3): - rpc_server.run_rpc(dummy_widget, "raise", [], {}) - assert not dummy_widget.isVisible() - - qtbot.waitUntil(dummy_widget.isVisible) - assert events.hide_events == events.show_events == 1 + assert dummy_widget.windowHandle() is not handle + assert dummy_widget.screen() is screen + if native_destroyed is not None: + assert native_destroyed.count() == 1 + assert widget_destroyed.count() == last_window_closed.count() == 0 def test_wayland_raise_from_dispatcher_worker_runs_on_gui_thread( @@ -287,7 +289,7 @@ def test_wayland_raise_from_dispatcher_worker_runs_on_gui_thread( @pytest.mark.parametrize("method", ["hide", "close", "show"]) -def test_visibility_request_cancels_pending_wayland_raise( +def test_visibility_request_after_wayland_raise_is_not_undone( rpc_server, dummy_widget, monkeypatch, qapp, method ): monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland") @@ -303,26 +305,11 @@ def test_visibility_request_cancels_pending_wayland_raise( qapp.processEvents() assert dummy_widget.isVisible() == (method == "show") - activate.assert_not_called() - - -def test_destroying_window_cancels_pending_wayland_raise(rpc_server, qapp, monkeypatch): - monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland") - window = QWidget() # Deleted explicitly below, before the timer can fire. - monkeypatch.setattr(window, "isActiveWindow", lambda: False) - window.show() - - with patch.object(window, "activateWindow") as activate: - rpc_server.run_rpc(window, "raise", [], {}) - window.deleteLater() - QCoreApplication.sendPostedEvents(None, QEvent.DeferredDelete) - qapp.processEvents() - - activate.assert_not_called() + activate.assert_called_once_with() @pytest.mark.parametrize("method", ["hide", "show"]) -def test_child_visibility_does_not_cancel_pending_window_raise( +def test_child_visibility_after_wayland_raise_does_not_hide_window( rpc_server, dummy_widget, qtbot, qapp, monkeypatch, method ): window = QWidget() @@ -341,14 +328,22 @@ def test_child_visibility_does_not_cancel_pending_window_raise( assert dummy_widget.isVisible() == (method == "show") -def test_wayland_remap_preserves_dock_contents(rpc_server, qtbot, qapp, monkeypatch, tmp_path): +def test_wayland_recreation_preserves_dock_data_and_bec_connections( + rpc_server, qtbot, qapp, monkeypatch, tmp_path +): monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(tmp_path)) dock_area = rpc_server._launch_dock_area(name="flomni", startup_profile="skip") window = dock_area.window() qtbot.addWidget(window) content = dock_area.new("TextBox", object_name="status") + content.set_plain_text("Preserved status") + waveform = dock_area.new("Waveform", object_name="waveform") + curve = waveform.plot(x=[1, 2, 3], y=[4, 5, 6]) qapp.processEvents() widgets = dock_area.widget_map() + slots = dict(dock_area.bec_dispatcher._registered_slots) + objects = [window, dock_area, content, waveform, curve] + destroyed = [QSignalSpy(obj.destroyed) for obj in objects] size = window.size() monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland") monkeypatch.setattr(window, "isActiveWindow", lambda: False) @@ -359,8 +354,78 @@ def test_wayland_remap_preserves_dock_contents(rpc_server, qtbot, qapp, monkeypa assert window.isVisible() assert window.size() == size assert content.isVisible() + assert content.plain_text == "Preserved status" + x, y = curve.get_data() + assert list(x) == [1, 2, 3] + assert list(y) == [4, 5, 6] assert dock_area.widget_map() == widgets - assert rpc_server.rpc_register.get_rpc_by_id(dock_area.gui_id) is dock_area + assert dock_area.bec_dispatcher._registered_slots == slots + for obj in objects[1:]: + assert rpc_server.rpc_register.get_rpc_by_id(obj.gui_id) is obj + assert all(spy.count() == 0 for spy in destroyed) + + # The same widgets can still receive RPC updates after recreating their native resources. + rpc_server.run_rpc(content, "set_plain_text", ["Updated status"], {}) + assert content.plain_text == "Updated status" + curve.set_data([7, 8], [9, 10]) + x, y = curve.get_data() + assert list(x) == [7, 8] + assert list(y) == [9, 10] + + +def test_wayland_recreation_preserves_native_child_widget(rpc_server, qtbot, monkeypatch, qapp): + window = QWidget() + qtbot.addWidget(window) + child = QWidget(window) + child.setAttribute(Qt.WA_NativeWindow) + QVBoxLayout(window).addWidget(child) + window.show() + qapp.processEvents() + native_visible = child.windowHandle().isVisible() + monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland") + monkeypatch.setattr(window, "isActiveWindow", lambda: False) + destroyed = QSignalSpy(child.destroyed) + + for _ in range(3): + rpc_server.run_rpc(window, "raise", [], {}) + qapp.processEvents() + assert child.parentWidget() is window + assert child.isVisible() + # Some QPA plugins (including offscreen) do not map native child windows. + assert child.windowHandle().isVisible() == native_visible + assert child.windowHandle().parent() is window.windowHandle() + assert destroyed.count() == 0 + + +def test_wayland_recreation_preserves_web_page_state( + rpc_server, qtbot, monkeypatch, qapp, tmp_path +): + monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(tmp_path)) + dock_area = rpc_server._launch_dock_area(name="web_probe", startup_profile="skip") + window = dock_area.window() + qtbot.addWidget(window) + content = dock_area.new("WebsiteWidget", object_name="web_page") + view = content.website + page = view.page() + with qtbot.waitSignal(page.loadFinished, timeout=10000): + view.setHtml( + "Preserved page" + ) + reloads = QSignalSpy(page.loadStarted) + destroyed = QSignalSpy(page.destroyed) + monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland") + monkeypatch.setattr(window, "isActiveWindow", lambda: False) + + for expected in (41, 42, 43): + rpc_server.run_rpc(window, "raise", [], {}) + qapp.processEvents() + assert content.website is view + assert view.page() is page + result = [] + page.runJavaScript("window.probeCounter += 1", result.append) + qtbot.waitUntil(lambda: bool(result)) + assert result == [expected] + assert reloads.count() == destroyed.count() == 0 @pytest.mark.parametrize("stay_on_top", [False, True])