fix(gui): recreate Wayland surfaces when raising windows

This commit is contained in:
wyzula_j committed 2026-09-10 15:34:05 +02:00
1 parent b6ed262f72
commit f3453cb9b7
3 files changed
+137 -94

No files matched your search

+13 -8
View File
@@ -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:
+10 -37
View File
@@ -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_()
+114 -49
View File
@@ -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(
"<html><body>Preserved page<script>window.probeCounter = 40;</script></body></html>"
)
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])