From 95d9a8279b2eea1576451d801b4167e18c8cb398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristian=20Cezar=20Mois=C3=A9s?= Date: Sat, 11 Jul 2026 11:42:47 -0300 Subject: [PATCH] gui: XWayland fallback when the Wayland window is never mapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Sway 1.12 + Qt 6.9 (Guix, NVIDIA) Qt-Wayland deadlocks before mapping: WAYLAND_DEBUG shows the client completes xdg_toplevel setup but never sends the initial wl_surface.commit, so the compositor never sends configure and the surface never maps — the event loop runs, the app prints its startup notice, and no window ever appears. This reproduces with a bare PySide6 QLabel, so it is a toolkit/compositor bug, not ours; no Qt env knob (fractional-scale disable, scale pinning) unblocks it, while the same window maps instantly on XWayland. Fix: a map watchdog on Wayland platforms. An event filter LATCHES the first Expose on the toplevel QWindow (sampling isExposed() at a deadline would misfire: a healthy hidden window — other workspace, scratchpad, locker — reads unexposed ~100 ms after frame callbacks stop). If no expose ever arrived after 4 s, re-exec the same process with QT_QPA_PLATFORM=xcb. Safety rails: a sentinel env var (VAPTVUPT_XCB_FALLBACK_DONE) makes a second fallback impossible even if '-platform wayland' argv (which outranks the env override) brings the child up on Wayland again; execve failure is caught and degrades to the no-fallback message; frozen bundles reuse argv as-is (PyInstaller sets argv[0] to the exe); DISPLAY-unset systems just get an honest notice; VAPTVUPT_NO_XCB_FALLBACK=1 opts out. Verified live: wayland launch relaunches at 4 s and the window appears in the sway tree (title 'VaptVupt 5.0.0', visible, tiled) — first time the GUI is actually on screen on this machine; latch flips true where expose events exist (offscreen); sentinel path stays wayland with no exec; selftest OK. --- gui/src/zupt_gui.py | 64 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/gui/src/zupt_gui.py b/gui/src/zupt_gui.py index 6b76857..5504f69 100644 --- a/gui/src/zupt_gui.py +++ b/gui/src/zupt_gui.py @@ -23,7 +23,7 @@ try: QTextEdit, QProgressBar, QTabWidget, QFrame, QCheckBox, QSpinBox, QMessageBox, QStatusBar, QScrollArea ) - from PySide6.QtCore import Qt, Signal, QObject, QThread, QTimer + from PySide6.QtCore import Qt, Signal, QObject, QThread, QTimer, QEvent from PySide6.QtGui import QPalette, QColor, QIcon, QPixmap QT_BINDING = "PySide6" except ImportError: @@ -34,7 +34,7 @@ except ImportError: QTextEdit, QProgressBar, QTabWidget, QFrame, QCheckBox, QSpinBox, QMessageBox, QStatusBar, QScrollArea ) - from PyQt6.QtCore import Qt, pyqtSignal as Signal, QObject, QThread, QTimer + from PyQt6.QtCore import Qt, pyqtSignal as Signal, QObject, QThread, QTimer, QEvent from PyQt6.QtGui import QPalette, QColor, QIcon, QPixmap QT_BINDING = "PyQt6" except ImportError: @@ -931,6 +931,66 @@ def main(): win.raise_() win.activateWindow() + # Wayland map watchdog. On some compositor/toolkit combos (seen live on + # Sway 1.12 + Qt 6.9: a handshake deadlock where Qt never sends the initial + # wl_surface.commit, so the compositor never sends configure) the event + # loop runs but the window NEVER maps — the app looks "started" yet nothing + # appears. In that state no Expose event is ever delivered, so LATCH the + # first expose; do NOT sample isExposed() at the deadline (a healthy window + # that is merely hidden — other workspace, scratchpad, locker — reads + # unexposed ~100 ms after frame callbacks stop and would misfire). If no + # expose ever arrived, relaunch this same process on XWayland (xcb), which + # is unaffected. The sentinel env var prevents any relaunch loop (e.g. + # "-platform wayland" in argv outranks the env override and would come up + # wayland again). Nothing auto-starts jobs before the deadline, so the exec + # cannot interrupt real work. Opt out with VAPTVUPT_NO_XCB_FALLBACK=1. + if app.platformName().startswith("wayland"): + class _ExposeLatch(QObject): + exposed_once = False + def eventFilter(self, obj, ev): + if ev.type() == QEvent.Type.Expose and obj.isExposed(): + self.exposed_once = True + return False + latch = _ExposeLatch() + handle = win.windowHandle() + if handle is not None: + handle.installEventFilter(latch) + def _wayland_map_check(): + if latch.exposed_once or (handle is not None and handle.isExposed()): + return + can_fallback = (os.environ.get("DISPLAY") + and sys.executable + and os.environ.get("VAPTVUPT_NO_XCB_FALLBACK") != "1" + and os.environ.get("VAPTVUPT_XCB_FALLBACK_DONE") != "1") + if sys.stderr is not None: + try: + sys.stderr.write( + "Window was not exposed within 4 s (the compositor may " + "never have mapped it); " + + ("relaunching on XWayland (xcb)...\n" if can_fallback + else "leaving the Wayland window as-is (no X11 " + "fallback: DISPLAY unset, opted out, or " + "already tried).\n")) + sys.stderr.flush() + except OSError: + pass + if can_fallback: + env = dict(os.environ, QT_QPA_PLATFORM="xcb", + VAPTVUPT_XCB_FALLBACK_DONE="1") + argv = (list(sys.argv) if getattr(sys, "frozen", False) + else [sys.executable] + sys.argv) + try: + os.execve(sys.executable, argv, env) + except OSError as exc: + if sys.stderr is not None: + try: + sys.stderr.write(f"XWayland relaunch failed ({exc});" + " window will not appear.\n") + sys.stderr.flush() + except OSError: + pass + QTimer.singleShot(4000, _wayland_map_check) + # A GUI blocks the launching shell, so a working launch otherwise looks like # a "stuck" terminal. Emit one line to stderr so it's unambiguous. Guarded: # PyInstaller --windowed sets sys.stderr to None (any write would raise and