gui: XWayland fallback when the Wayland window is never mapped

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.
This commit is contained in:
Cristian Cezar Moisés 2026-07-11 11:42:47 -03:00
commit 95d9a8279b

View file

@ -23,7 +23,7 @@ try:
QTextEdit, QProgressBar, QTabWidget, QFrame, QCheckBox, QTextEdit, QProgressBar, QTabWidget, QFrame, QCheckBox,
QSpinBox, QMessageBox, QStatusBar, QScrollArea 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 from PySide6.QtGui import QPalette, QColor, QIcon, QPixmap
QT_BINDING = "PySide6" QT_BINDING = "PySide6"
except ImportError: except ImportError:
@ -34,7 +34,7 @@ except ImportError:
QTextEdit, QProgressBar, QTabWidget, QFrame, QCheckBox, QTextEdit, QProgressBar, QTabWidget, QFrame, QCheckBox,
QSpinBox, QMessageBox, QStatusBar, QScrollArea 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 from PyQt6.QtGui import QPalette, QColor, QIcon, QPixmap
QT_BINDING = "PyQt6" QT_BINDING = "PyQt6"
except ImportError: except ImportError:
@ -931,6 +931,66 @@ def main():
win.raise_() win.raise_()
win.activateWindow() 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 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: # 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 # PyInstaller --windowed sets sys.stderr to None (any write would raise and