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,
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