Wayland platform internals
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. The Wayland suites named here exist and are registered in cmake/UnitTests.cmake, but none was executed for this page, and no GitHub workflow selects CNA_PLATFORM=WAYLAND. Behaviour across a live compositor matrix (GNOME, KDE, wlroots compositors, real GPUs, real input methods, fractional-scale outputs) is not established here.
modules/platform/src/Wayland implements CNA's platform contract directly over libwayland-client, xkbcommon, EGL, VK_KHR_wayland_surface and wl_shm. It is not SDL's Wayland video driver and never falls back to X11 through Xwayland. A Wayland window is a compositor-negotiated wl_surface plus an xdg_toplevel role, not an object whose position and drawable size CNA can dictate; that one fact organises startup, input, resize, presentation and teardown. This page is for the maintainer who changes the backend or debugs it; what the backend offers a game, and how to build it, is in the Native Wayland section of the platforms guide.
Read this page together with the user-level pages it complements: the dependency and behaviour tables for native Wayland, the seven-platform capability matrix and Tutorial 136. Those pages say what the backend does; this one says where in the source it happens, in which order, and what breaks when the order changes. The contract every backend implements is on Platform architecture; the sibling backends are X11 and SDL3.
Connection, registry and stable capabilities
WaylandPlatform::WaylandPlatform does its work in a fixed order, and the order is load-bearing:
- With D-Bus headers present it looks for the freedesktop desktop portal on the session bus (
Freedesktop::DesktopPortal::Connect) and builds the system-information service. Neither belongs to the compositor, so both exist even when no compositor is reachable. CreateServicescreatesWaylandKeyboard,WaylandMouse,WaylandTouch,WaylandTabletandWaylandTextInputbefore the connection exists, because the compositor announces its seats, and each seat's keyboard, pointer and touch devices, during the connection's own startup exchange.- It constructs
WaylandConnectionwith a 5-second startup timeout (kStartupTimeout). The connection callswl_display_connect(nullptr), so it followsWAYLAND_DISPLAYor an inheritedWAYLAND_SOCKET, installs the registry listener and performs two bounded roundtrips. The first delivers and binds the globals; the second delivers what the bound objects immediately say about themselves (thewl_shmformats, seat capabilities, output geometry), which the capability set is computed from. A compositor withoutwl_compositororxdg_wm_baseis refused with aPlatformExceptionnaming the missing global. - With a connection, it attaches the per-seat services (text-input-v3, tablet, data devices), attaches
xdg-outputto the outputs, creates the display service,WaylandGlContext,WaylandVulkanSurface(only when Vulkan headers were found at configure time),WaylandDialogs(discarded again when it offers neither a message box nor a file dialog) and the idle inhibitor, then performs one more bounded roundtrip soxdg-outputgeometry is known before anyone asks. - If
/dev/inputis a directory (and the build foundlinux/input.h), it creates the evdev controller hub without opening any device node. - Finally
ComputeCapabilitiesfillscapabilities_once.GetCapabilitiesreturns that copy for the platform's whole life.
WaylandPlatform() ├─ portal (session bus) + system info ← not the compositor's ├─ keyboard / mouse / touch / tablet / text-input services ├─ WaylandConnection(wl_display + registry, 2 bounded roundtrips) │ ├─ singletons bound at negotiated versions (wl_compositor, wl_shm, xdg_wm_base, …) │ ├─ OnSeatAnnounced → WaylandSeat → keyboard/pointer/touch attach │ └─ OnOutputAnnounced → WaylandOutput (scale, geometry, name) ├─ seat services: text-input-v3, tablet, wl_data_device / primary selection ├─ displays, EGL (probed now), Vulkan surface, dialogs, idle inhibitor, roundtrip ├─ evdev controller hub (Linux, nothing opened yet) └─ capabilities_ = ComputeCapabilities() ← frozen CreateWindow → wl_surface (+ viewport, fractional scale, built-in frame) → register → Show: xdg_surface + xdg_toplevel → first configure + ack → logical size / pixel size → renderer surface
An unreachable compositor is recorded, not thrown: the constructor catches the connection's PlatformException and keeps its text in connectionError_, because StorageDevice and TitleContainer still need a platform for the filesystem and host services. AcquireSubsystem(PlatformSubsystem::Video) calls RequireConnection, which throws a PlatformException carrying that saved reason; the keyboard, mouse, text-input and display accessors return null without a connection. Releasing Video never disconnects, since the capability set would otherwise change under callers that cached it. Like X11, and unlike SDL3, which acquires its video subsystem lazily and balances references a host may share, the native backend opens its connection eagerly so the snapshot is stable.
Versions are negotiated per global by NegotiateVersion in WaylandConnection.hpp: the lowest of what the compositor advertises, what the backend implements and what the compiled libwayland headers describe, so no event can arrive for which the listener struct has no slot. The backend implements wl_compositor v6 (preferred_buffer_scale), wl_shm v2 (release), xdg_wm_base v6 (the suspended state), wl_data_device_manager v3, wl_seat v9 and wl_output v4. xdg_wm_base.ping is answered from inside dispatch, so a game that polls once per frame answers within a frame. When a compositor withdraws a singleton, OnGlobalRemove destroys the proxy and nulls its field in WaylandGlobals; seats and outputs go to the platform's OnGlobalRemoved, which detaches their services and makes every window forget a vanished output. The capability snapshot is not recomputed: a later call that needs the withdrawn global refuses by name.
What backs each capability
The capability matrix gives the user-visible answer. For a maintainer, the useful fact is which member or global ComputeCapabilities reads, because each accessor returns non-null exactly when its flag is true:
| Flag(s) | Condition in ComputeCapabilities |
|---|---|
gamepad, joystick, gamepadRumble, gamepadSensors, haptics | the evdev hub exists (/dev/input present in a Linux build); computed before the connection check |
powerInfo, inputDeviceEnumeration | Linux evdev build; the input-device service always exists |
multipleWindows, nativeWindowHandle, highDpi, borderlessFullscreen, textInput, exactKeyboardState, pixelAccurateMouse | any live connection |
multipleDisplays | the display service, which is created with every connection |
surfacePresentation | wl_shm bound and it announced XRGB8888 (WaylandConnection::HasXrgb8888) |
cursorShapes | cursor-shape-v1 or a loadable cursor theme, or custom images on wl_shm |
relativeMouse | both relative-pointer and pointer-constraints bound |
ime | zwp_text_input_manager_v3 bound |
openGlContext | WaylandGlContext::IsAvailable: both EGL libraries loaded and an EGL display initialised on this connection |
vulkanSurface | built with Vulkan headers (the service object exists) |
clipboard, clipboardData, dragAndDrop; primarySelection | a seat plus wl_data_device_manager; additionally the primary-selection manager |
nativeFileDialog; messageBox | the desktop portal was found; WaylandDialogs::HasMessageBox is hard-wired false |
globalPointer, tray, camera, sensors, managedEntrypoint | always false, each with a stated reason in the source (no desktop coordinates or warping; no tray protocol; not window-system facilities; an ordinary main()) |
The mandatory and optional build inputs that decide which of these globals the backend can bind at all are detected by cmake/PlatformWayland.cmake: wayland-client ≥ 1.18, xkbcommon ≥ 0.5, wayland-scanner and wayland-protocols with stable xdg-shell are mandatory; each optional protocol XML defines its own CNA_WAYLAND_HAVE_<PROTOCOL>. The bindings are generated at build time into generated/wayland-protocols of the build tree, with the interface tables emitted as wayland-scanner private-code (hidden visibility) so a host that links its own copy of, for example, xdg_wm_base_interface cannot collide with CNA's. EGL, libwayland-egl, libwayland-cursor, Vulkan and libdbus are headers-only at build time and opened at run time; modules/platform/CMakeLists.txt links only libwayland-client, xkbcommon and ${CMAKE_DL_LIBS}, all PRIVATE.
Creating a window means waiting for configure
WaylandPlatform::CreateWindow calls RequireConnection, rejects a non-positive size with a PlatformException, and throws PlatformNotSupportedException(OpenGlContext) when WindowRenderIntent::OpenGl is requested without a usable EGL. It then allocates a WindowId and constructs the WaylandWindow, whose constructor creates the wl_surface, records the surface-to-window mapping for input routing, declares the whole surface opaque, attaches a wp_viewport and a wp_fractional_scale_v1 when offered, creates the built-in title bar (WaylandFrame) and makes the first scale decision. Only after the window is entered in windows_ and registered with the GL, Vulkan and idle-inhibit services does CreateWindow call Show. Registering before showing is necessary: showing dispatches compositor events until the first configure, and a wl_keyboard.enter on the new surface during that wait must already resolve to the new window.
The wl_surface lives exactly as long as the WaylandWindow; the xdg_surface/xdg_toplevel role exists only while the window is shown, because xdg-shell has no "hide". The configure state machine is explicit:
NoRole ── Show(): CreateRole() null buffer attach + commit, ▲ xdg_surface, xdg_toplevel, title, │ app_id (/proc/self/comm), decoration, │ min/max, maximize/fullscreen requests, │ initial commit without a buffer │ ▼ │ AwaitingInitialConfigure ── DispatchUntil(configured, 5 s) │ │ └─ timeout or dead connection: │ │ DestroyRole(), PlatformException │ ▼ └── Hide(): DestroyRole() Configured ── RequestActivation (xdg-activation)
WaylandWindow::Show returns only once the compositor's first xdg_surface.configure has been acknowledged, so a renderer never attaches a buffer to an unconfigured surface. A missing configure becomes PlatformException("WaylandWindow::Show", …) with either the connection's recorded error or "the compositor did not configure the window within 5 s", and the half-created role is destroyed. Because the surface itself survives Hide/Show, a wl_egl_window or VkSurfaceKHR made from it stays valid across them. A window created hidden with a fullscreen mode records the request and applies it when the role is created. After a successful show, RequestActivation asks for focus through xdg-activation: the first window spends the launcher's XDG_ACTIVATION_TOKEN and removes it from the environment, as the specification asks; later windows use the latest input serial, and without any input the compositor's own focus policy decides.
xdg_toplevel.configure only records a pending size and state set; xdg_surface.configure carries the serial and triggers ApplyConfigure. That function lays out the built-in frame for the new state first (no title bar while fullscreen), then resolves the size with ResolveConfigureSize from WaylandScaling.hpp: a zero dimension keeps the client's choice (the floating size comes back when leaving maximized or fullscreen), a floating suggestion is clamped to the window's own limits, and a constrained window (maximized, fullscreen or tiled) takes what it is told. A fixed-size window keeps its size whatever a floating configure suggests. Some compositors repeat the current geometry in every floating configure, so a configure sent before the compositor saw a local SetSize would silently undo it; SetSize therefore records the geometry it replaced in supersededGeometries_ (at most eight), and ApplyConfigure ignores a suggestion that matches one of them. It then calls xdg_surface_ack_configure, marks the window Configured, applies viewport, buffer scale and window geometry, and posts the resulting events: Resized when the logical size changed, PixelSizeChanged when the drawable changed, Maximized/Restored, Minimized/Restored from the suspended state (only with xdg_wm_base v6), FocusGained/FocusLost when HasFocus changed, and always Exposed. CommitState commits without a buffer only when the window is configured and something has already drawn into it.
A Wayland "resize ignored" bug can therefore live in the compositor's suggestion, the stale-geometry filter, the built-in frame height, the scale decision or the renderer's surface update; SetSize alone cannot force a compositor to present a size. On a floating window SetSize applies the size immediately and posts Resized/PixelSizeChanged; on a constrained one it only remembers the size for when the window floats again. WaylandWindow::Sync performs a roundtrip bounded at 1 second and, if a maximize or fullscreen request is outstanding, waits at most another 250 ms for its configure.
Logical size, pixel size and scale
GetClientBounds always reports position 0,0: the compositor never tells a client its global window coordinates, and WindowDescription's position is not applied. GetPixelSize returns the calculated drawable extent and GetDisplayScale the current pixels-per-logical-unit value, integer or fractional. DecideScale chooses one of three methods:
| Situation | ScaleMethod | Buffer and surface |
|---|---|---|
WindowDescription::highDpi false | Unscaled | one buffer pixel per logical unit; the compositor scales if it must |
high DPI and wp_viewporter bound | Viewport (or Unscaled at scale 1) | scale = preferred_scale/120 from fractional-scale-v1, else the integer scale; buffer = lround(logical × scale); the viewport destination is always the logical size |
| high DPI without a viewporter | BufferScale | wl_surface.set_buffer_scale(n) with the integer scale (the compositor's preferred_buffer_scale, else the largest scale of the outputs the surface has entered) |
The viewport carries integer scales too where it exists, because an integer set_buffer_scale makes a buffer whose size is not a multiple of the scale a fatal wl_surface.invalid_size protocol error, and an EGL buffer one frame behind a scale change is exactly such a buffer. A scale change posts DisplayScaleChanged and, when the drawable changed, PixelSizeChanged. Do not copy X11's one-coordinate-space assumption or SDL3's pixel-density API into this backend.
Fullscreen, close and adoption
Exclusive fullscreen is requested exactly like borderless fullscreen, on the output the window is on, and GetFullscreenMode reports only what the compositor confirmed, never exclusive: an ordinary Wayland client cannot own a display mode. Minimize is one-way, since xdg-shell has no request that undoes set_minimized; Restore leaves maximized and fullscreen only. xdg_toplevel.close (or the built-in frame's close button) calls RequestClose, which posts CloseRequested and, for the last window only, a QuitEvent; the window is never destroyed by the platform. On the runtime side, Game::PollEvents in modules/runtime/src/Game.cpp calls Exit() on CloseRequested in any case (Exit is idempotent), so for a Game the close button always ends the game.
AdoptWindow returns a BorrowedWindow that forwards every call to a window this platform already owns and destroys nothing. AdoptWindowHandle accepts only this platform's own token, which is the window id; a foreign client's wl_surface cannot be adopted, because a Wayland client can learn nothing about another client's surface and a wrapper could only lie. When the compositor draws no decorations (no server-side mode from xdg-decoration), WaylandFrame draws a title bar of CNA's own on subsurfaces; its height is inside the window geometry, which is why min/max sizes and set_window_geometry add FrameHeight().
Protocol callbacks enqueue CNA events
This is a callback-to-value-event route, not Xlib's explicit XNextEvent translation loop. Seats attach WaylandKeyboard, WaylandMouse, WaylandTouch, WaylandTablet and WaylandTextInput to the announced protocol objects; their listeners resolve a wl_surface to a CNA WindowId through the platform's surface map and call PostEvent, which appends to pending_. One frame's poll then runs:
WaylandPlatform::PollEvents(destination)
destination.clear()
if connection alive:
WaylandConnection::Pump() prepare_read / poll(0) / read / dispatch_pending — never blocks
└─ listeners → PostEvent → pending_
WaylandKeyboard::GenerateRepeats(steady_clock::now())
WaylandDataDevices::Pump() outgoing selection writes, finished drops
if connection dead (first time): one "[CNA][Wayland] …" line on stderr, QuitEvent queued
move pending_ → destination
evdev hub: Pump + TakeEvents(destination) (only once the Gamepad subsystem is started)
desktop portal: Pump()
Game::PollEvents
→ PlatformInputBridge::ProcessEvent (every event, before Game's own handling)
→ window events: updateFromPlatform, UpdateViewportFromWindow, OnSurfaceInvalidated, Exit on close
→ keyboard->Update(), mouse->Update() (end of Game::PollEvents)
→ gamepad/joystick Update() once the Gamepad subsystem is initialized
Keyboard repeat is generated by the client, as Wayland requires: the rate and delay come from wl_keyboard.repeat_info (defaults 25 per second after 600 ms for a keyboard older than v4), at most four repeats are produced per poll so a stalled frame does not type a burst, and a rate of 0 disables repeat. On wl_keyboard.enter the keys already held become held in the snapshot without press events; on leave every held key is released without events, because the compositor sends no release for a key let go elsewhere. Text is produced from xkbcommon only while text input is active for the focused window, with dead keys and Multi_key sequences resolved by an xkbcommon compose table looked up for the locale named by LC_ALL, LC_CTYPE or LANG, without calling setlocale. Where zwp_text_input_manager_v3 exists, the input method composes instead: preedit arrives as TextEditingEvent, commits as TextInputEvent, and keys it consumes never reach the client as keys. Tablet pens report through the same host as touchscreens, so a game reading TouchPanel cannot tell them apart. The pointer only ever has surface-local coordinates; SetCapture, TryGetGlobalPosition and SetGlobalPosition throw PlatformNotSupportedException(GlobalPointer).
Event delivery is not the public polling path. Keyboard::GetState and Mouse::GetState read snapshots owned by WaylandKeyboard and WaylandMouse; the services rebuild them inside their own callbacks and again when Game::PollEvents calls Update() at its end, after the batch has passed through PlatformInputBridge. There is no separate input-update step in Game at this snapshot. The event-versus-snapshot split, and the bridge, are traced in Input internals and One frame source trace.
Several requests are accepted by a compositor only in answer to recent input, and RecordSerial keeps the latest serial and its seat (from key, pointer, touch and tablet events). The recorded serial is used by xdg-activation, by clipboard and primary-selection ownership, by the window menu and by the built-in frame's interactive move and resize. Cursor changes are different: wl_pointer.set_cursor and cursor-shape-v1 use each pointer's own enter serial. Focus, enter and leave are compositor decisions. If input vanishes after Hide/Show, the surface and its surface-to-window mapping are unchanged (only the role was recreated), so inspect keyboard focus, seat attachment and the configure path before changing the public input façade.
When the connection dies (a protocol error, or the compositor went away), PollEvents writes the recorded error once to stderr and queues exactly one QuitEvent. It does not reconnect: every Wayland proxy, and so every window, died with the connection, and nothing could restore them. Later calls that need the connection throw PlatformException with the recorded reason, which names the interface, object id and protocol error code when the compositor raised one. This is a different failure class from a slow or missed frame.
EGL, Vulkan and shared-memory presenters
WaylandGraphicsServices.cpp implements three independent seams. GraphicsDevice in modules/graphics/src/Xna/GraphicsDevice.cpp passes a renderer only the service its descriptor names: GetGlContext() for a family with needsGlContext (the five EasyGL identities and OPENGL4), GetVulkanSurface() for needsVulkanSurface (VULKAN).
| Service | Mechanism | Who owns what |
|---|---|---|
WaylandGlContext (IPlatformGlContext) | libEGL.so.1 and libwayland-egl.so.1 opened with dlopen; an EGL display from the platform's own wl_display (EGL_PLATFORM_WAYLAND_KHR/_EXT) | the platform owns contexts, EGLSurfaces, wl_egl_windows and the EGL display |
WaylandVulkanSurface (IPlatformVulkanSurface) | instance extensions VK_KHR_surface + VK_KHR_wayland_surface; vkCreateWaylandSurfaceKHR resolved through the caller's vkGetInstanceProcAddr | the caller owns the VkSurfaceKHR and must destroy it before the window |
WaylandSurfacePresenter (IPlatformSurfacePresenter) | a ring of at most three XRGB8888 wl_shm buffers (memfd, sealed against shrinking; unlinked shm_open fallback) | the caller owns the presenter; the window must outlive it |
EGL. Availability is decided in the WaylandGlContext constructor by actually calling eglInitialize on this connection, because openGlContext is a promise: a machine where libEGL loads but no display initialises reports the capability false instead of accepting a GL window and failing at the context. CreateContext requires a window created with WindowRenderIntent::OpenGl; the window's EGL config is chosen by its first context (depth 24, stencil 8 by default, like the X11 visual) and later contexts must use the same API (OpenGL versus OpenGL ES) so any of them can be current on the window's one surface. The wl_egl_window and EGLSurface are created lazily from GetPixelSize(), presented opaque where EGL_EXT_present_opaque exists, and resized by a pixel-size listener that WaylandWindow calls on every configure or scale change, before the next frame is drawn. EGL's own swap interval is set to 0; vsync is CNA's: SwapBuffers waits for the previous frame's wl_surface.frame callback on a private event queue for at most 100 ms, so an occluded or minimised window keeps running at about ten frames per second instead of hanging in eglSwapBuffers, and the application's own events are never dispatched from inside a swap. A failed config or context is a PlatformException naming the EGL error, a platform problem, not a stock-shader problem.
Vulkan. The platform links no Vulkan loader and needs no Vulkan header for the create-info struct, which it restates. CreateSurface throws when no loader is reachable, when the instance was created without VK_KHR_wayland_surface, or when the call fails, and it uses the same wl_display and wl_surface as every other seam. The platform does not track surfaces it created, so a renderer that destroys its VkSurfaceKHR after the window (or the platform) addresses a dead wl_surface.
Shared memory. CreateSurfacePresenter throws PlatformNotSupportedException(SurfacePresentation) when the compositor offers no wl_shm with XRGB8888, and PlatformException for a window this platform did not create (an adopted wrapper is resolved by id). Present validates the SurfaceFrame, fits it into the window's pixel size with the shared scaling code, clears letterbox bars to black, damages the whole buffer and, with vsync on, paces on the frame callback (100 ms at most); a buffer of a size the window no longer has is retired and destroyed only after the compositor releases it. On Wayland no stock renderer reaches this presenter inside a Game: GraphicsDevice gives a CPU family a window and presenter only where surfacePresentation is true and nativeWindowHandle is false (the terminal), so SOFTWARE stays off-screen here, as the renderers-on-native-platforms table says. The presenter is exercised by the backend's tests and by the validation harness's presenter scenario, and is available to code that calls IPlatform::CreateSurfacePresenter directly.
Before a renderer resizes, establish whether the compositor changed logical bounds, output scale or pixel extent; ApplyConfigure and UpdateScale post the matching events and GetPixelSize is the value a renderer should consume. Game::PollEvents answers Resized, PixelSizeChanged and DisplayScaleChanged by refreshing GameWindow, updating the viewport and calling the renderer's OnSurfaceInvalidated. The native handle (NativeWindowSystem::Wayland, with display = wl_display* and surface = wl_surface*) is a borrowed description: a renderer may create an EGL or Vulkan surface from it but never owns the platform's proxies. Renderer-side details are on EasyGL, OpenGL4 and Vulkan.
Protocol destruction order and no-compositor mode
WaylandPlatform::~WaylandPlatform runs in this order, and reversing any step leaves a proxy callback or release request addressing service state that no longer exists:
- Every window the application still holds is taken through
OnWindowDestroyedandWaylandWindow::Abandon: its proxies are destroyed while the display exists and the wrapper stays behind inert (itshost_is cleared, so its later destructor touches nothing). A window that outlives its platform is still a caller contract violation; abandonment limits the damage rather than legitimising that order. - The session-bus screen-saver inhibition is dropped.
connection_.reset().~WaylandConnectioncalls the platform'sOnDisconnectingfirst, which destroys everything made from the connection while the services still exist: idle inhibitor, dialogs, the GL service (which terminates its EGL display while thewl_displayis still connected, because EGL's Wayland platform destroys proxies of its own on it), the Vulkan service, data devices, text-input and tablet seat objects, the seats (handing keyboards, pointers and touch back to their services), the pointer's connection objects, outputs and the display service. Then the connection destroys its singletons in reverse bind order, destroys the registry, flushes the buffered destroy requests and disconnects.- Only then are the dialog, input-device, text-input, tablet, touch, mouse and keyboard services reset, and finally the evdev controllers, whose node closure stops any rumble still playing.
A window's own destructor mirrors this: ~WaylandWindow first calls OnWindowDestroyed, which makes every service forget the window (the GL service destroys the EGLSurface and wl_egl_window, which must go before their wl_surface), and then ReleaseNativeObjects destroys the frame, the decoration, the xdg_toplevel and xdg_surface (role objects before the surface, or the compositor raises defunct_role_object), the fractional-scale and viewport objects, unmaps and destroys the wl_surface and flushes. A renderer's own Vulkan surface or EGL use must therefore end before its window, and the window before the platform; Ownership and shutdown traces that order from the Game side.
Every wait in the backend is bounded, so "the compositor never answered" is diagnosed instead of hanging:
| Wait | Bound | On expiry |
|---|---|---|
| startup roundtrips (two, plus one after services are attached) | 5 s each | connection refused, reason recorded |
first configure in Show | 5 s | role destroyed, PlatformException |
Sync | 1 s roundtrip + 250 ms for an expected configure | returns |
| frame callback before a swap or present | 100 ms | frame proceeds; the pending callback is reused, not duplicated |
a free wl_shm buffer when all three are held | 100 ms | that frame is dropped |
Timing is POSIX, not the compositor's: GetPerformanceCounter is Posix::MonotonicNanoseconds at a fixed 1,000,000,000 Hz, GetTicksMilliseconds counts from a steady_clock epoch taken at construction, and Delay is Posix::SleepMilliseconds (helpers in src/Posix). Frame callbacks decide when a submitted buffer is shown, but Game's fixed or variable timestep stays in the runtime. As for host-process state, the backend installs no signal handler, never calls setlocale and sets no environment variable; the one environment change it makes is removing XDG_ACTIVATION_TOKEN once it has spent it. It never starts a compositor or another display server; only the test launcher does that (see below).
Maintainer note on EGL and process exit: modules/platform/src/CurrentPlatform.cpp deliberately never destroys the lazily created default platform, so a process that draws through a standalone GraphicsDevice exits without eglTerminate. tools/platform/lsan_x11_mesa.supp records, with the measurement that justified it, a Mesa radeonsi allocation that only eglTerminate frees, reproduced on the Wayland and surfaceless EGL platforms without CNA code. A LeakSanitizer report of that shape under Wayland EGL is that recorded driver behaviour, not necessarily a CNA leak.
How to debug and what proves behavior
The backend's tests are compiled only when CNA_PLATFORM=WAYLAND (cmake/UnitTests.cmake filters Wayland*.cpp out of every other selection) and are split into CTest entries by what each needs. All live entries run through tools/platform/wayland_test_server.sh, which starts a private compositor in a private runtime directory and exits 77, CTest's skip code, when that compositor is not installed or does not come up. WaylandTestEnvironment.cpp points WAYLAND_DISPLAY at nothing (unless the launcher names its private compositor), removes WAYLAND_SOCKET and points the session bus at a path that does not exist before any test runs, so no test can reach the desktop it is started from.
| CTest entry | Runs | Needs |
|---|---|---|
CnaWaylandMappingTests | pure mapping, scaling, configure-size, text, MIME and shm-file suites, WaylandIsSdlFree (source scan), WaylandTestEnvironment, plus the shared XKB, freedesktop and Linux evdev suites | nothing |
CnaWaylandProtocolTests | WaylandProtocol.*: 78 cases against the in-process WaylandTestCompositor, reached through WAYLAND_SOCKET, which posts the protocol errors a strict compositor would and can withdraw globals or change output scale mid-test | wayland-server at build time (linked into the test binary only) |
CnaWaylandPortalTests | WaylandPortal.*: desktop portal with the xdg-foreign parent window | libdbus and dbus-daemon; skips otherwise |
CnaWaylandWestonTests, CnaWaylandWestonGpuTests, CnaWaylandWestonScaledTests | WaylandLive.* (the first two also the PlatformConformance/PlatformWindowConformance instances for Wayland) on headless Weston: software renderer, Weston's GL renderer, output scale 2 | Weston |
CnaWaylandMutterTests, CnaWaylandMutterCzechTests | WaylandMutter.* (plus live and conformance in the first) on headless gnome-shell with input from its RemoteDesktop API on a private bus; once with a Czech keymap | gnome-shell, dbus-daemon |
CnaWaylandIbusTests | WaylandIme.*: a real input method (ibus, Korean engine) composing into a CNA window | gnome-shell, ibus, the hangul engine |
CnaWaylandLinkClosure | WaylandLinkClosure.cmake: no executable NEEDs SDL, X11, xcb or GLX; the platform-only harness's whole closure is libwayland-client, xkbcommon and the C/C++ runtime; run-time-loaded libraries are not NEEDED | readelf and the validation harness target |
CnaWaylandPlatformSelection | WaylandPlatformSelection.cmake: the selection is offered where buildable, refused naming the package where not, default unchanged | registered in every build, whatever CNA_PLATFORM is |
The mapping and protocol entries prove translation and protocol-state handling; they cannot prove configure timing, EGL, Vulkan or presentation on a real compositor. A live entry that exits 77 for a missing compositor, or a case that skips for a missing global, is not evidence. For a real desktop, cna_wayland_desktop_validation (built with tests when the platform is Wayland, wired in cmake/Harnesses.cmake) drives the backend against whatever WAYLAND_DISPLAY names and prints one PASS/FAIL/SKIP line per check; its scenarios are listed in the validation harness table. No GitHub workflow selects CNA_PLATFORM=WAYLAND: .github/workflows/platform-ci.yml installs the Wayland development packages in one job but unsets WAYLAND_DISPLAY and runs everything under Xvfb. None of these suites was executed for this page. For a renderer integration change, run an applicable EasyGL, OPENGL4 or VULKAN smoke test under Wayland as well; the what-to-test-after-changing-X page and the test target index list the neighbouring suites.
Typical diagnosis, in the order that usually finds the cause fastest:
- No window, or
AcquireSubsystem(Video)throws: readGetConnectionError(); then the registry globals (a compositor withoutxdg_wm_baseis refused) and the 5-second first-configure timeout. - GL window refused at creation:
openGlContextfalse means EGL did not load or did not initialise a display on this connection (or the build found no EGL headers).CreateWindow's refusal only names the capability;WaylandGlContext::CreateContextcarries the EGL loader's own reason, andcna_wayland_desktop_validation infoprints the capability set. - Wrong size: compare the configure's suggested size and states,
supersededGeometries_,FrameHeight(), theScaleDecisionand theResized/PixelSizeChangedpair actually posted. - Missing input: check the seat and its capabilities were announced, the surface-to-window map, keyboard focus (
keyboardFocusCount_, oractivatedwithout a keyboard) and whether text input is active for that window. - Black or frozen frame: establish which of EGL, Vulkan or
wl_shmwas selected, whether the window was configured when the first buffer was attached, and whether frames are being paced by a frame callback that never comes (the 100 ms bound keeps such a window at about 10 fps). - Game quits unexpectedly: look for the single
[CNA][Wayland]stderr line; a protocol error names the interface, object and code. - Crash at shutdown: verify the renderer's Vulkan surface and GL contexts were destroyed before the window, and the window before the platform, so nothing references a proxy after
OnDisconnecting.
A maintainer adding a capability should follow the recipe in Modify a platform backend: bind the global in WaylandConnection::Bind behind its CNA_WAYLAND_HAVE_* definition, derive the flag in ComputeCapabilities so that the accessor is non-null exactly when the flag is true, cover the state machine in WaylandProtocolTests against the test compositor, and add a live case. The wider test layout is on Test architecture.
Read in this order
PlatformSelection.cmakeandPlatformWayland.cmake: how the selection is offered or refused, the mandatory and optional inputs and protocol generation.WaylandPlatform.hpp: the capability boundary, the owned services and the "what else is left alone" contract.WaylandConnection.hppandWaylandConnection.cpp: registry, version negotiation, bounded dispatch and the error state.WaylandPlatform.cpp: constructor order,ComputeCapabilities,PollEvents,OnDisconnectingand the destructor.WaylandWindow.hpp,WaylandWindow.cppandWaylandScaling.hpp: role, configure and acknowledge, size resolution and scale decisions.WaylandKeyboard.cpp,WaylandMouse.cppandWaylandTextInput.hpp: repeats, focus, snapshots, serials and composition.WaylandGraphicsServices.hppandWaylandGraphicsServices.cpp: EGL, Vulkan andwl_shmownership and pacing.WaylandProtocolTests.cppandWaylandLiveTests.cpp: which behaviour has test evidence against the test compositor and against real compositors.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-200: The registered CTest CNAEXT_NoPosixSetenv fails at this snapshot: twelve Wayland setenv/unsetenv call lines trip the gate — scripts/check_no_posix_setenv.py forbids POSIX setenv/unsetenv under modules/, tools/ and tests/, but the native Wayland backend and its tests added twelve such lines on 2026-09-15 and 2026-09-16, so the gate CNA registe
- CNA-VGAP-012: No CI workflow selects CNA_PLATFORM=WAYLAND, so the native Wayland backend's suites run only on a developer's machine — The Wayland ctest suites are registered only under CNA_PLATFORM=WAYLAND and launched through tools/platform/wayland_test_server.sh, but no GitHub workflow configures that platform, so the native Wayland backend has no co
Related pages
The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.
- Architecture
- Platform architecture
- Internals
- Platform backends · X11 (sibling native backend) · SDL3 (default backend) · Input internals
- Maintainer workflow
- Modify a platform backend
- Tests and validation
- Test architecture · What to test after changing X
- Reference
- Test target index