Skip to content

Support presenting to a native Wayland surface on Qt#233

Open
hmaarrfk wants to merge 2 commits into
pygfx:mainfrom
hmaarrfk:qt-wayland-native
Open

Support presenting to a native Wayland surface on Qt#233
hmaarrfk wants to merge 2 commits into
pygfx:mainfrom
hmaarrfk:qt-wayland-native

Conversation

@hmaarrfk

@hmaarrfk hmaarrfk commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

xref: #36

got to test this on an actual linux machine soon running wayland. Curious to see this in action, but the commits look good.

  • CI Tested on qt+x11
  • CI Tested on qt+xwayland
  • CI Tested on qt+wayland
Claude's notes

What this does

On Wayland, rendercanvas puts Qt on XWayland, and the code to present to a native Wayland surface is disabled with an if False. That code could not have worked: it pairs the wl_surface from winId() with a display from get_alt_wayland_display(), i.e. a fresh wl_display_connect(). A wl_surface is a proxy owned by the connection that created it, so libwayland aborts with Proxy and queue point to different wl_displays as soon as a buffer is attached. The existing comment ("this segfaults, so it looks like the real display object is needed?") diagnosed this correctly.

Worth noting for anyone testing this: creating the surface succeeds with the wrong display, and so does wgpuSurfaceGetCapabilities. It only aborts once frames are actually presented, so a probe has to render.

Qt can say what display it is connected to, and that display is the only one its surface is valid on. So this:

  • gets the real wl_display from Qt (QNativeInterface::QWaylandApplication on Qt6, the platform native interface on Qt5);
  • decides the platform from the qpa plugin Qt actually uses rather than from sys.platform, since under a Wayland session Qt may still be on XWayland, where winId() is an X11 window;
  • only puts Qt on XWayland when the Qt lib cannot provide its display, so those libs keep using the x11 code-path. Qt picks its platform when the application is instantiated, so this is decided at import-time.

A second bug this uncovered

WgpuContextToScreen._rc_close unconfigured the wgpu context but never released the surface, leaving it to the garbage collector. On Wayland the surface belongs to the toolkit's display-connection, so during interpreter shutdown wgpuSurfaceRelease ran after Qt had torn that connection down — a use-after-free (gdb showed the freed map was Qt's wl_display + 0x90). x11 never hit this because an X11 window id is just a number that outlives any connection. This was unreachable before, so it is newly-exposed rather than a regression.

Which Qt versions get native Wayland

QNativeInterface.QWaylandApplication turns out to be recent. Checked each PySide6 minor:

PySide6 QNativeInterface QWaylandApplication result
6.11, 6.10 yes yes native Wayland
6.9, 6.8, 6.7 yes no XWayland
6.6, 6.5 no no XWayland

So most installs today keep the current behaviour, and this is a no-op for them. It also means the check has to be for QWaylandApplication specifically: QNativeInterface alone exists since 6.7 and would be a false positive on 6.7–6.9.

Behaviour changes worth a look

  • An explicit QT_QPA_PLATFORM is no longer overridden. Previously QT_QPA_PLATFORM=xcb was set unconditionally under Wayland, so a user asking for wayland was silently overruled. Forcing still works in both directions.
  • The _get_surface_idsNone → bitmap path is now only reachable when a QApplication already exists at import (already_had_app_on_import) with a Qt lib that cannot provide its display. It is too late to force xcb there, so degrading to bitmap beats crashing.
  • wgpucontext.py is shared with glfw. Releasing the surface at close rather than at GC is correct for every backend, but it is not Qt-only code. Happy to split it out.

Testing

Tested against a headless weston with lavapipe (software Vulkan), on PySide6 only, since that is all that was installed locally. Presenting to screen works for both a top-level canvas and an embedded QRenderWidget, and the teardown crash is gone. tests/test_backend_qt.py passes on native Wayland and on xcb. The rest of the suite is unchanged (the 3 test_meta failures locally are a pre-existing git-version mismatch, identical on a stashed baseline).

The second commit adds a test-qt-linux job covering the three cases: Wayland with a capable Qt, Wayland with PySide6 6.9 (must fall back to XWayland), and x11-only. Each case asserts which qpa platform Qt ends up on before running the tests, because a case that silently fell back would otherwise still pass and test nothing. I checked the assertion actually fails the step when the platform is wrong.

The jobs were validated by running the same steps in ubuntu/python docker images, which turned up two things worth knowing: the pip PySide6 wheel does ship libqwayland.so, and Qt's xcb plugin needs libxcb-cursor0 (Qt >= 6.5), which the existing dependency list does not install.

Not addressed

glfw and wx are untouched and stay on XWayland, so they are not equivalent to Qt after this. Neither exposes the display of its connection (glfw could via glfw.get_wayland_display(), wx has no way at all). glfw on Wayland was not testable here: glfw.init() hangs under headless weston because libdecor cannot initialise GTK, before rendercanvas is involved.

<details><summary>Claude's draft</summary>

On Wayland, rendercanvas put Qt on XWayland, and the code to present to a
native Wayland surface was disabled with an `if False`. That code could not
work: it paired the `wl_surface` from `winId()` with a display obtained from
`get_alt_wayland_display()`, i.e. a fresh `wl_display_connect()`. A surface is
a proxy owned by the connection that created it, so libwayland aborts with
"Proxy and queue point to different wl_displays" as soon as a buffer is
attached. Note that creating the surface succeeds, so this only shows when
frames are actually presented.

Qt can tell us what display it is connected to, and that display is the only
one its surface is valid on. So:

* Get the real `wl_display` from Qt (`QNativeInterface::QWaylandApplication`
  on Qt6, the platform native interface on Qt5).
* Decide the platform from the qpa plugin that Qt actually uses, rather than
  from `sys.platform`: under a Wayland session Qt may still be on XWayland,
  in which case `winId()` is an X11 window.
* Only put Qt on XWayland when the Qt lib cannot provide its display, so that
  those libs keep using the x11 code-path. Qt selects its platform when the
  application is instantiated, so this is decided at import-time. An explicit
  QT_QPA_PLATFORM is no longer overridden.

Also release the wgpu surface when the canvas is closed, instead of leaving it
to the garbage collector. On Wayland the surface is owned by the display-
connection of the toolkit, so releasing it after the toolkit has torn that
connection down is a use-after-free (during interpreter shutdown).

Tested on Wayland with a headless weston and lavapipe: presenting to screen
works, both for a top-level canvas and an embedded QRenderWidget. Only PySide6
was available to test with. The other backends are unchanged: they have no way
to obtain their display, so they stay on XWayland.

Resume this Claude session:
```
cd /home/mark/git/pygfx/rendercanvas
claude --resume 0a03c1b5-9aa0-4d24-b370-c3799f1a1abe
```
</details>
@hmaarrfk
hmaarrfk force-pushed the qt-wayland-native branch from ba7d4d1 to 61aad67 Compare July 17, 2026 02:16
@hmaarrfk

Copy link
Copy Markdown
Contributor Author

On the main branch, you get the X11 decorations
Screenshot From 2026-07-17 12-26-36

On this branch you get the wayland side client side decorations
Screenshot From 2026-07-17 12-27-28

@hmaarrfk
hmaarrfk marked this pull request as ready for review July 17, 2026 16:30
@hmaarrfk

Copy link
Copy Markdown
Contributor Author

This is ready for review

Comment on lines +178 to +186
# Release the surface now, instead of leaving it to the garbage
# collector. The surface only makes sense as long as the window
# exists, and on Wayland it is owned by the display-connection of
# the toolkit, so releasing it after the toolkit has torn that
# connection down is a use-after-free. Dropping our reference (above)
# already does this, but only if no other references are left.
release = getattr(wgpu_context, "_release", None) # private method
if release is not None:
release()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@almarklein this is the shadiest part abut this code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/pygfx/wgpu-py/blob/main/wgpu/backends/wgpu_native/_api.py#L1049

but AI was convinced that sometimes this method did not exist.

I would love to see native support for qt-wayland especially since x11 is being moved away from so quickly.

@hmaarrfk
hmaarrfk force-pushed the qt-wayland-native branch from 61aad67 to a07ff83 Compare July 17, 2026 18:26
<details><summary>Claude's draft</summary>

The qt backend was not tested on Linux at all: the entries in `test-backends`
all run on Windows, so the (already present) step to install xvfb never runs.
Now that Qt presents to a native Wayland surface, the Wayland and x11
code-paths need testing.

Adds a `test-qt-linux` job with three cases:

* wayland: a Wayland session (headless weston) with a Qt that can provide its
  wl_display, so Qt runs on Wayland natively.
* wayland-old-qt: a Wayland session (headless weston with XWayland enabled) and
  PySide6 6.9, which cannot provide its wl_display, so rendercanvas puts it on
  XWayland. This exercises the real fallback target rather than a bare X server.
* x11: a system that only has x11.

Each case asserts what qpa platform Qt ends up on before running the tests,
because otherwise a case that silently falls back would still pass and test
nothing. Verified that the assertion does fail the step when the platform is
not the expected one.

Note that `QNativeInterface::QWaylandApplication` is only exposed by PySide6
since 6.10 (checked 6.5 up to 6.11), so the XWayland path is what most
installs use today, which is why it is worth a case of its own.

The jobs were checked by running the same steps in ubuntu/python docker
images. This showed that Qt's xcb plugin needs libxcb-cursor0, libxcb-icccm4,
libxcb-keysyms1 and libxkbcommon-x11-0, none of which the existing dependency
list installs. Its wayland plugin needs none of these, which is why only the
x11 cases were affected. Note that Qt's own hint ("xcb-cursor0 or
libxcb-cursor0 is needed") is printed whenever the xcb plugin fails to load,
also when the missing library is a different one.

Resume this Claude session:
```
cd /home/mark/git/pygfx/rendercanvas
claude --resume 0a03c1b5-9aa0-4d24-b370-c3799f1a1abe
```
</details>
@hmaarrfk
hmaarrfk force-pushed the qt-wayland-native branch from a07ff83 to b81ee29 Compare July 17, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant