gui: fix Wayland SIGSEGV on launch; guard stderr for windowed bundles

e15329a's window-activation code crashed the GUI at startup on Wayland:
raise_()/activateWindow()/move() go through Qt-Wayland's xdg restack/
xdg-activation paths, which SEGSEGV in PySide6 6.9 as shipped on Guix
(faulthandler pinpointed win.raise_(); QT_QPA_PLATFORM=wayland reproduces,
--selftest — plain show() — never crashed). Gate the whole center/raise/
activate block behind platformName() == "xcb": on X11 it is the canonical
show-then-activate sequence and verified working under XWayland; on Wayland
the compositor maps and focuses the new toplevel itself (raise/activate were
no-ops there even before they crashed). Strict equality keeps wayland-egl
and any unknown platform on the safe plain-show path. Centering is computed
before show() again, as pre-e15329a, so X11 windows map already-centered.

Also make every sys.stderr access None-safe: PyInstaller --windowed (>=5.8,
i.e. the shipped Windows .exe) sets sys.stderr = None, so the unguarded
startup notice would have raised AttributeError right after show() and
killed the window; a dead pipe similarly raises on flush. Guard the notice
(+ try/except OSError), the import-error message, and the debug discovery
echo. Verified: launch stays alive on wayland, xcb, and offscreen-with-
fd2-closed; --selftest passes offscreen/wayland incl. VAPTVUPT_DEBUG=1
with stderr closed.
This commit is contained in:
Cristian Cezar Moisés 2026-07-11 10:56:17 -03:00
commit 92488d47b9

View file

@ -38,12 +38,13 @@ except ImportError:
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:
sys.stderr.write( if sys.stderr is not None: # None under PyInstaller --windowed
"ERROR: vaptvupt-gui requires PySide6 or PyQt6. Install one of:\n" sys.stderr.write(
" Debian/Ubuntu: sudo apt install python3-pyqt6\n" "ERROR: vaptvupt-gui requires PySide6 or PyQt6. Install one of:\n"
" Fedora/RHEL: sudo dnf install python3-pyqt6\n" " Debian/Ubuntu: sudo apt install python3-pyqt6\n"
" pip (any OS): pip install PySide6\n" " Fedora/RHEL: sudo dnf install python3-pyqt6\n"
) " pip (any OS): pip install PySide6\n"
)
sys.exit(1) sys.exit(1)
# ── Find vaptvupt binary ── # ── Find vaptvupt binary ──
@ -64,7 +65,8 @@ _DISCOVERY_LOG = []
def _discovery_log(msg): def _discovery_log(msg):
_DISCOVERY_LOG.append(msg) _DISCOVERY_LOG.append(msg)
# Echo to stderr if VAPTVUPT_DEBUG or ZUPT_DEBUG is set # Echo to stderr if VAPTVUPT_DEBUG or ZUPT_DEBUG is set
if os.environ.get("VAPTVUPT_DEBUG") or os.environ.get("ZUPT_DEBUG"): if ((os.environ.get("VAPTVUPT_DEBUG") or os.environ.get("ZUPT_DEBUG"))
and sys.stderr is not None): # None under PyInstaller --windowed
sys.stderr.write(f" [discovery] {msg}\n") sys.stderr.write(f" [discovery] {msg}\n")
def _is_runnable(path): def _is_runnable(path):
@ -908,24 +910,39 @@ def main():
f"event loop ran (rc={rc}); CLI={VAPTVUPT}") f"event loop ran (rc={rc}); CLI={VAPTVUPT}")
return rc return rc
# Center on the active screen and force focus. Without this the window can # Center + raise + focus ONLY on X11 (xcb), where a stacking WM may place
# open off-screen or behind the last-focused frame on a tiling compositor # the window off-screen or leave it unfocused. On Wayland the compositor
# (Sway/i3/Hyprland) — the usual cause of "the GUI won't start / is stuck": # owns placement and focus, and these calls (self-move / xdg restack /
# it launched, but you can't see it. # xdg-activation) SEGFAULT some Qt-Wayland builds — including PySide6 6.9 as
scr = app.primaryScreen() # shipped on Guix — so they must not run there. Plain show() is what
if scr is not None: # --selftest exercises and is stable; the compositor maps and focuses the
fg = win.frameGeometry() # new toplevel itself. On Windows/macOS Qt's automatic placement centers
fg.moveCenter(scr.availableGeometry().center()) # first windows and the OS foregrounds a freshly launched app, so skipping
win.move(fg.topLeft()) # is safe there too. Strict == "xcb" keeps wayland-egl etc. on the safe path.
is_x11 = app.platformName() == "xcb"
if is_x11:
scr = app.primaryScreen()
if scr is not None:
fg = win.frameGeometry()
fg.moveCenter(scr.availableGeometry().center())
win.move(fg.topLeft())
win.show() win.show()
win.raise_() if is_x11:
win.activateWindow() win.raise_()
win.activateWindow()
# 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. # a "stuck" terminal. Emit one line to stderr so it's unambiguous. Guarded:
sys.stderr.write(f"VaptVupt {ZUPT_VER_NUMBER} GUI started — window open " # PyInstaller --windowed sets sys.stderr to None (any write would raise and
f"(close it to exit).\n") # kill the window we just showed), and a dead pipe raises OSError on flush —
sys.stderr.flush() # a courtesy notice must never take the GUI down.
if sys.stderr is not None:
try:
sys.stderr.write(f"VaptVupt {ZUPT_VER_NUMBER} GUI started — "
f"window open (close it to exit).\n")
sys.stderr.flush()
except OSError:
pass
return app.exec() return app.exec()
if __name__ == "__main__": if __name__ == "__main__":