Project 1: Bare-Metal Wayland Client
Build a raw Wayland client in C that opens a resizable window using only libwayland-client and wl_shm buffers.
Quick Reference
| Attribute | Value |
|---|---|
| Difficulty | Level 3: Advanced |
| Time Estimate | 1-2 weeks |
| Main Programming Language | C (Alternatives: C++, Rust, Zig) |
| Alternative Programming Languages | C++, Rust, Zig |
| Coolness Level | Level 4: Hardcore Tech Flex |
| Business Potential | Level 1: The “Resume Gold” |
| Prerequisites | C pointers/structs, Linux file descriptors, mmap, event loops |
| Key Topics | Wayland registry, wl_shm buffers, xdg-shell lifecycle, frame callbacks |
1. Learning Objectives
By completing this project, you will:
- Implement the complete Wayland client lifecycle: connect, discover globals, bind, create surfaces, commit buffers.
- Build and manage wl_shm buffers with correct stride, format, and release semantics.
- Implement the xdg-shell configure/ack/commit loop correctly for resizable windows.
- Design an event loop that integrates Wayland dispatch with frame callbacks.
- Debug protocol ordering issues using WAYLAND_DEBUG and your own state logs.
2. All Theory Needed (Per-Concept Breakdown)
This section covers every concept required to implement the client correctly and confidently.
2.1 Wayland Object Model and Registry Discovery
Fundamentals
Wayland is not a single API call that creates a window; it is a protocol where the client and compositor exchange typed messages over a UNIX domain socket. Everything is an object identified by an integer ID and associated with an interface such as wl_surface or wl_seat. On connect, the client knows nothing about the compositor’s capabilities. It must request a registry object and listen for global announcements. Each global advertises an interface and a version. The client chooses the highest version it supports and binds, which creates a client-side proxy object. This binding step is mandatory and explicit. The object model is strict about lifetimes: you destroy objects explicitly and must not reuse IDs incorrectly. Understanding the object model is foundational because every later step (creating surfaces, buffers, or shell objects) is expressed as messages on those objects.
Deep Dive into the concept
Wayland’s object model is the reason it is both fast and unforgiving. When you call wl_display_connect, libwayland opens a UNIX socket and assigns the connection an internal state machine. The first real object you obtain is wl_registry, which is a special object the compositor creates for you. The registry is the dynamic capability list of the compositor. Unlike X11, where you can assume core global objects exist with fixed IDs, Wayland requires a negotiation phase. The compositor sends wl_registry.global events, each containing a name (an integer handle), an interface string, and a version. The name is not stable between runs, so you never persist it; you only use it to bind.
Binding is where the object model becomes concrete. The client allocates a new object ID in its own ID space and calls wl_registry_bind with the global name and an interface definition. If you pass version 4 to a global that supports version 6, the compositor will still accept you, but you must now behave as if only version 4 requests exist. This is critical for compatibility. Libwayland enforces some of this: the generated proxy code only exposes requests available in the version you bound. But the library cannot protect you from protocol state errors, such as calling a request before the compositor has configured your surface. Those errors trigger wl_display.error and immediate disconnect.
Object IDs have ownership rules. Client-created objects are assigned IDs by the client and are referenced by the compositor. Server-created objects are allocated and announced by the compositor. In the XML protocol, this is visible as new_id arguments. If you do not understand who allocates the ID, you will accidentally create invalid objects and the compositor will disconnect you. The object model is also asynchronous: a request is not an answer. If you send wl_compositor.create_surface, you do not receive any synchronous confirmation. Instead, you must wait for events and manage your own state machine. A good mental model is to treat every object as a small protocol state machine with legal transitions. This is why a clean code structure matters: you will keep a struct for global state, separate structs per surface, and explicit flags for configured, mapped, and running.
Another subtlety is the registry event ordering. The registry emits globals, then you typically issue a wl_display_roundtrip to wait for them before binding. But globals can appear later (for example, hotplugged outputs). Robust clients therefore keep the registry listener active, update state as new globals appear, and handle multiple outputs without restarting. In this project, you can keep it simple by binding in the first roundtrip, but you still need to store global names and versions correctly. If you mis-handle a global, you will fail to bind xdg_wm_base and will never be able to create a toplevel window.
Finally, object lifetime and cleanup: Every object you create should be destroyed in reverse order. For a client, you will destroy xdg_toplevel, then xdg_surface, then wl_surface, then wl_shm_pool, then the wl_display connection. If you destroy a wl_surface while an xdg_surface still exists, it is a protocol error. If you destroy the display without destroying children, the server will clean them up, but your client will leak resources and may not flush destroy requests. Good cleanup ordering also makes debugging easier because you can log each destroy and verify that the protocol state machine is returning to idle.
How this fit on projects
This concept powers the entire project. You will use it in Section 3.1 and Section 3.2 (specification and requirements), Section 5.2 (project structure), and Section 5.4 (concepts to master before coding). Every object you create and every event you process depends on correct registry discovery and object lifetimes.
Definitions & key terms
- object ID -> a 32-bit integer that identifies a protocol object on the connection
- interface -> the type of an object, defined in XML (wl_surface, wl_shm, xdg_wm_base)
- global -> a compositor-advertised interface instance available to clients
- bind -> the request that creates a client-side proxy for a global
- proxy -> client-side handle used to send requests and receive events
- resource -> server-side handle for a client-owned object
- request -> client to server message on an object
- event -> server to client message on an object
- roundtrip -> synchronous wait for all pending events to be dispatched
Mental Model Diagram (ASCII)
Client Compositor
------ ----------
wl_display (socket) <-------------------------> wl_client
|
| wl_display.get_registry()
v
wl_registry (proxy) <----------------------> wl_registry (resource)
|
| registry.global(name=7, interface="wl_compositor", version=6)
| registry.global(name=8, interface="xdg_wm_base", version=5)
v
wl_registry.bind(name=8, id=20, interface=xdg_wm_base)
|
v
xdg_wm_base (proxy id=20) <---------------> xdg_wm_base (resource id=20)
How It Works (Step-by-Step)
- Connect to the compositor with wl_display_connect.
- Request a wl_registry and add a listener to receive globals.
- Dispatch events (or roundtrip) until you receive global announcements.
- For each global you care about, bind to it at a compatible version.
- Store the bound proxy objects in your app state.
- Use those proxies to create surfaces, buffers, and shell objects.
- Destroy objects in reverse order when exiting.
Invariants:
- You must bind to xdg_wm_base before creating an xdg_surface.
- You must not call methods that are not in your negotiated version.
- You must destroy xdg_toplevel before xdg_surface, and xdg_surface before wl_surface.
Failure modes:
- Binding to a missing global -> NULL proxy, no window.
- Using wrong version -> compositor disconnects with protocol error.
- Destroy order wrong -> protocol error and disconnect.
Minimal Concrete Example
struct wl_display *display = wl_display_connect(NULL);
struct wl_registry *registry = wl_display_get_registry(display);
wl_registry_add_listener(registry, ®istry_listener, &state);
wl_display_roundtrip(display); // wait for globals
state.compositor = wl_registry_bind(registry, state.compositor_name,
&wl_compositor_interface, state.compositor_version);
state.wm_base = wl_registry_bind(registry, state.wm_base_name,
&xdg_wm_base_interface, state.wm_base_version);
Common Misconceptions
- “The registry names are stable across runs.” -> They are ephemeral handles.
- “Wayland is synchronous like Xlib.” -> It is asynchronous; you must keep state.
- “If I connect, I can create a surface immediately.” -> You must bind first.
Check-Your-Understanding Questions
- Why do you need to call wl_display_roundtrip after requesting the registry?
- What happens if you bind to a global using a higher version than your client supports?
- Who allocates object IDs for client-created objects?
Check-Your-Understanding Answers
- Because registry events are delivered asynchronously; roundtrip ensures you receive them before binding.
- You will send requests that the compositor does not expect, causing a protocol error and disconnect.
- The client allocates IDs for objects it creates and passes them in requests with new_id.
Real-World Applications
- Any Wayland client toolkit (GTK, Qt, SDL) uses registry discovery and binding.
- Compositors use the same model for server-side objects.
- Protocol extension development relies on correct object creation semantics.
Where You’ll Apply It
- In this project: see Section 3.2 Functional Requirements, Section 4.1 High-Level Design, Section 5.2 Project Structure, Section 5.4 Concepts You Must Understand First.
- Also used in: P02 Simple Wayland Compositor, P03 Custom Protocol, P04 Layer Shell Panel.
References
- “The Wayland Book” by Drew DeVault, Chapters 1-4
- Wayland core protocol spec (wl_registry, wl_compositor)
- libwayland-client headers (wayland-client-core.h)
Key Insights
The registry is your dynamic contract with the compositor; everything else is built on the objects you bind from it.
Summary
Wayland’s object model is explicit, asynchronous, and versioned. You must discover globals, bind carefully, and manage object lifetimes to avoid protocol errors.
Homework/Exercises to Practice the Concept
- Sketch a message timeline for registry discovery and binding for wl_compositor, wl_shm, and xdg_wm_base.
- Write a small program that connects to Wayland, prints all globals, and exits.
- Modify the program to handle a new global that appears after a delay (simulate hotplug with outputs).
Solutions to the Homework/Exercises
- The timeline should show: connect -> get_registry -> global events -> bind -> use proxies -> destroy.
- Use wl_registry_add_listener and print interface/version in registry.global callback.
- Keep the registry listener active and store new globals in a list; handle new outputs by creating output data structures.
2.2 wl_shm Buffers, Memory Mapping, and Pixel Formats
Fundamentals
Wayland clients render their own pixels and hand buffers to the compositor. The simplest buffer path is wl_shm, which uses a shared memory file (memfd or shm_open) mapped into both processes. You allocate a buffer large enough for width * height * bytes_per_pixel, create a wl_shm_pool, then create a wl_buffer from that pool. The buffer format must match a compositor-supported format, most commonly WL_SHM_FORMAT_XRGB8888. The buffer has a stride (bytes per row) that is usually width * 4 but can be larger for alignment. The compositor does not copy your buffer; it reads it directly, so you must respect buffer lifetimes and only reuse a buffer after receiving wl_buffer.release.
Deep Dive into the concept
The shared memory path looks deceptively simple but hides a lot of subtle correctness requirements. First, you need a file descriptor that both processes can map. On modern Linux, memfd_create is preferred because it creates an anonymous file without a filesystem entry. You then ftruncate the fd to the desired size and mmap it with PROT_READ | PROT_WRITE. That memory becomes your pixel store. You create a wl_shm_pool using wl_shm_create_pool, then create one or more wl_buffer objects from the pool. The pool can be resized, but shrinking is not allowed. Many clients allocate a new pool per resize to keep the logic straightforward.
The pixel format is critical. WL_SHM_FORMAT_XRGB8888 means each pixel is 32 bits, with 8 bits each for X (unused alpha), red, green, and blue in native endianness. On little-endian systems, the least significant byte is blue, so a pixel value 0xFF0000 appears as red. If you get endianness wrong, you will see swapped colors. The compositor only advertises a subset of formats; you must query wl_shm.format events to ensure your chosen format is supported. For most compositors, XRGB8888 and ARGB8888 are supported. You should code defensively: store the supported formats and pick one you know how to render.
Stride is another subtlety. Stride is the number of bytes between rows. Many sample programs set stride = width * 4, but some compositors or rendering backends require alignment (for example, 16 or 64 bytes). The Wayland protocol allows any stride as long as the buffer is large enough, but it is your responsibility to compute the correct buffer size as stride * height. If you resize, you must recalculate stride and size. If you ignore stride, your image will appear skewed or corrupted. This is one of the most common sources of confusing graphics artifacts in bare-metal clients.
Buffer lifecycle is strict. After you attach a buffer to a surface and commit, the compositor may still be using it even after you return to your event loop. You cannot write to the buffer again until you receive wl_buffer.release for that specific buffer. If you do, you risk tearing or undefined rendering. The common pattern is to create two buffers (double buffering). You alternate: attach buffer A, wait for release, then attach buffer B, and so on. For a simple project, you can also create a new buffer each time you need to redraw and destroy old ones after release, but that is slower.
The shared memory path is CPU-based. If you render by writing pixel values in a loop, you will be CPU-bound. This is fine for a solid color window or basic drawing, but it will not scale to complex UIs. The point of this project is not performance; it is the protocol. However, you should still structure your code so that you can replace the rendering path with a real renderer later. For example, treat the buffer as a draw target and separate the “draw” function from the Wayland glue. That makes it easy to add text rendering or image blits later. You can also experiment with simple patterns (gradients, checkerboards) to verify that your pixel math is correct.
Finally, remember that wl_shm buffers are one of two main buffer paths. The other is DMA-BUF, used for zero-copy GPU buffers. In later projects, your compositor will handle both. For now, it is enough to know that wl_shm is universally supported and perfect for learning, but it is not the performance path for high-end applications. That contrast is part of your learning: you will see why wlroots uses GPU rendering internally even though the protocol supports wl_shm.
How this fit on projects
This concept is core to Section 3.2 and Section 3.5 (data formats), Section 4.4 data structures, and Section 5.4 (concepts needed before coding). You will also apply it in Section 5.10 when implementing the buffer lifecycle and resize handling.
Definitions & key terms
- wl_shm -> Wayland shared memory global used to create buffers
- wl_shm_pool -> server-side handle for a shared memory region
- wl_buffer -> a buffer object representing pixel data to be attached to a surface
- stride -> bytes per row of pixels
- format -> pixel format such as WL_SHM_FORMAT_XRGB8888
- release -> event indicating compositor is done with a buffer
- double buffering -> alternating between two buffers to avoid overwriting in-use data
Mental Model Diagram (ASCII)
Client process Compositor process
-------------- ------------------
memfd file fd=5 <---- shared memory ----> same fd mapped
| mmap() -> pixels | reads pixels
| create wl_shm_pool | pool resource
| create wl_buffer (id=30) | buffer resource
| wl_surface.attach(buffer=30) | uses buffer for scanout
| wl_surface.commit() | schedules frame
| (wait) | wl_buffer.release -> client
How It Works (Step-by-Step)
- Create an anonymous shared memory fd (memfd_create or shm_open).
- ftruncate the fd to required size = stride * height.
- mmap the fd and obtain a pointer to pixel memory.
- Create a wl_shm_pool from the fd and size.
- Create a wl_buffer from the pool with width, height, stride, and format.
- Fill pixel memory with your content.
- Attach the buffer to the surface and commit.
- Wait for wl_buffer.release before reusing or destroying the buffer.
Invariants:
- Buffer size must be at least stride * height.
- You must not write to a buffer until it is released.
- Format must be supported by the compositor.
Failure modes:
- Wrong stride -> distorted image or crash.
- Reusing buffer too early -> flicker or undefined output.
- Unsupported format -> protocol error or blank window.
Minimal Concrete Example
int fd = create_memfd("wayland-shm", size);
ftruncate(fd, size);
uint32_t *pixels = mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, size);
struct wl_buffer *buffer = wl_shm_pool_create_buffer(pool, 0,
width, height, stride, WL_SHM_FORMAT_XRGB8888);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
pixels[y * (stride/4) + x] = 0x00FF0000; // solid red
}
}
Common Misconceptions
- “I can write into the buffer after commit.” -> Not until wl_buffer.release.
- “Stride is always width * 4.” -> It must match what you allocate and declare.
- “ARGB and XRGB are the same.” -> Alpha channel semantics differ.
Check-Your-Understanding Questions
- Why does wl_shm require a file descriptor instead of a malloc pointer?
- What happens if you call wl_surface.attach with a buffer smaller than the declared size?
- Why is double buffering common even for simple clients?
Check-Your-Understanding Answers
- Because the compositor is a separate process and needs a shared mapping via an fd.
- The compositor will read out of bounds, likely causing a protocol error or crash.
- It avoids modifying a buffer that is still in use by the compositor.
Real-World Applications
- Simple Wayland utilities (wallpapers, panels) often use wl_shm.
- Remote desktop and screen capture tools use shared memory for CPU paths.
- Debug tools use wl_shm to visualize buffer lifetimes.
Where You’ll Apply It
- In this project: see Section 3.5 Data Formats, Section 4.4 Data Structures, Section 5.10 Phase 2 (Core Functionality), Section 6 Testing Strategy.
- Also used in: P02 Simple Wayland Compositor for buffer import, P04 Layer Shell Panel.
References
- Wayland core protocol (wl_shm)
- “The Wayland Book” (buffer management sections)
- Linux man pages: memfd_create(2), shm_open(3), mmap(2)
Key Insights
wl_shm is the simplest buffer path, but it forces you to be explicit about memory, format, and lifecycle.
Summary
A wl_shm buffer is shared memory with strict lifetime rules. Correct stride and format handling is essential for reliable rendering.
Homework/Exercises to Practice the Concept
- Write a program that creates two buffers and alternates between red and blue every second.
- Implement a checkerboard pattern and verify it remains aligned on resize.
- Print the supported wl_shm formats and select the first compatible one.
Solutions to the Homework/Exercises
- Use a timer or frame callback; only redraw after buffer release.
- Recompute stride and size after resize; draw using stride/4 as row width.
- In the wl_shm.format callback, store formats in a list and pick XRGB8888 if present.
2.3 XDG-Shell Surface Lifecycle and Configure/Ack/Commit
Fundamentals
Wayland does not define window behavior in its core protocol. For regular desktop windows, clients use the xdg-shell protocol. You create a wl_surface, then wrap it in an xdg_surface and an xdg_toplevel. The compositor sends a configure event to tell you the window size and state. You must acknowledge it with the provided serial before you attach a buffer and commit. This is a strict state machine. If you attach buffers before the first configure is acknowledged, the compositor may disconnect you. The configure/ack/commit loop ensures the compositor controls presentation and avoids race conditions during resizing.
Deep Dive into the concept
The xdg-shell protocol is essentially the policy layer that turns a generic surface into a real window. Without it, a wl_surface is just a buffer sink with no semantics for resize, title, or interactive close. When you call xdg_wm_base_get_xdg_surface, you create an xdg_surface. You then call xdg_surface_get_toplevel to indicate that the surface should be managed as a top-level window. The compositor may then send xdg_toplevel.configure events to describe desired width/height, maximize state, or fullscreen state. The client must respond by acknowledging the configure serial via xdg_surface_ack_configure, then drawing a buffer that matches the suggested size.
The first configure event is a gatekeeper. It tells you that the compositor has accepted the toplevel role and knows how to manage it. Until you ack it, the surface is not considered “mapped”. Many beginner clients attach a buffer immediately and wonder why nothing appears. The correct sequence is: create surface, create xdg_surface, add listeners, get initial configure, ack configure, attach buffer, commit. That handshake ensures the compositor and client are synchronized about size and state.
Resizing is also coordinated through configure events. When the user drags the window, the compositor sends a new configure with a width/height. You must respond by allocating a buffer of that size and committing it. If you cannot resize (maybe because you are fixed-size), you can ignore the size and commit the old size, but the compositor is allowed to enforce a size anyway. A good client follows the configure size to avoid conflicts. The serial number in configure events is not optional; it creates a strict ordering between configures and commits. You must ack the exact serial you are responding to. If you ack an old serial after a new configure has arrived, the compositor may reject it or treat your surface as misbehaving.
The xdg_toplevel also supports close requests. The compositor sends a close event when the user clicks the close button. You should respond by cleaning up and exiting gracefully. If you ignore it, the window may linger or the compositor might force-close you. Setting the window title and app ID are also part of xdg_toplevel: they affect how the compositor labels your window and might influence grouping. In a bare-metal client, you set those fields explicitly and see them appear in your window manager.
Another important piece is surface commit semantics. A wl_surface.commit applies all pending state changes: buffer attachments, damage regions, and role-related metadata. You often call commit immediately after attaching a new buffer. The configure/ack/commit cycle can be seen as: compositor suggests -> client acknowledges -> client commits the new buffer. If you miss a step, the compositor does not know what you want. This model is different from X11 where the server retains content and simply asks you to redraw on Expose. Here, your client must always provide the buffer for the exact size negotiated.
Finally, xdg-shell is versioned and evolves. Some features like xdg-decoration or xdg-activation are separate protocols. For this project, you only need the core xdg-shell to create a toplevel. However, understanding that these are separate and versioned helps you extend your client later. It also explains why registry discovery is essential: without xdg_wm_base, you must fall back to other protocols or exit. Robust clients detect this and provide a clear error.
How this fit on projects
This concept powers the main interaction with the compositor. You will apply it in Section 3.1 (what you build), Section 3.2 (functional requirements), Section 5.10 (implementation phases), and Section 7 (common pitfalls around configure handling).
Definitions & key terms
- xdg_wm_base -> global object for xdg-shell protocol
- xdg_surface -> a surface with shell semantics
- xdg_toplevel -> a toplevel window role
- configure -> event describing size/state requested by compositor
- ack_configure -> client acknowledgment of a configure event
- mapped -> a surface that is ready to be displayed
- role -> a protocol-defined behavior of a surface (toplevel, popup, etc.)
Mental Model Diagram (ASCII)
Client Compositor
------ ----------
create wl_surface
create xdg_surface
create xdg_toplevel
|
| (wait)
|<---- xdg_surface.configure(serial=10, width=800, height=600)
|
ack_configure(serial=10)
attach buffer 800x600
commit surface
|
|<---- xdg_toplevel.configure(serial=11, width=1024, height=768)
|
ack_configure(serial=11)
attach new buffer 1024x768
commit surface
How It Works (Step-by-Step)
- Bind xdg_wm_base from the registry.
- Create wl_surface.
- Create xdg_surface and xdg_toplevel.
- Add listeners for configure and close events.
- Wait for the first configure event.
- Ack configure with the serial.
- Create a buffer of the configured size.
- Attach and commit the buffer.
- On subsequent configure events, repeat steps 6-8.
Invariants:
- You must ack every configure before committing a buffer for that configure.
- The first commit must follow the first configure.
- You must destroy xdg_toplevel before xdg_surface.
Failure modes:
- Committing before configure -> client is disconnected.
- Ignoring resize configures -> window stuck at wrong size.
- Not handling close event -> window remains or is force-closed.
Minimal Concrete Example
static void xdg_surface_configure(void *data, struct xdg_surface *surf,
uint32_t serial) {
struct app_state *app = data;
xdg_surface_ack_configure(surf, serial);
if (!app->buffer || app->needs_resize) {
recreate_buffer(app, app->pending_width, app->pending_height);
}
wl_surface_attach(app->surface, app->buffer, 0, 0);
wl_surface_commit(app->surface);
}
Common Misconceptions
- “I can draw before configure.” -> You must wait for configure.
- “Configure size is optional.” -> You should respect it to avoid protocol issues.
- “Commit is automatic after attach.” -> Commit is explicit.
Check-Your-Understanding Questions
- Why is the configure/ack/commit dance required?
- What should you do if width or height is 0 in a configure event?
- How does xdg_toplevel.close differ from SIGINT?
Check-Your-Understanding Answers
- It synchronizes client and compositor on size/state to avoid race conditions.
- Use a default size or keep the previous size, then commit a buffer.
- close is a protocol request from the compositor; you should handle it gracefully.
Real-World Applications
- All Wayland desktop apps use xdg-shell for window management.
- Toolkits hide this logic, but the sequence is the same.
Where You’ll Apply It
- In this project: see Section 3.2 Functional Requirements, Section 3.6 Edge Cases, Section 5.10 Phase 2.
- Also used in: P02 Simple Wayland Compositor (server-side handling of xdg-shell).
References
- xdg-shell protocol specification
- “The Wayland Book” Chapter on surfaces and shell
Key Insights
The configure/ack/commit sequence is the core contract that keeps Wayland windows predictable and safe.
Summary
xdg-shell turns a generic surface into a real window. Correct configure handling is required for your window to appear and resize.
Homework/Exercises to Practice the Concept
- Add logging that prints every configure serial, width, and height.
- Implement a fixed-size mode and see how the compositor behaves.
- Add a close handler that gracefully destroys all objects.
Solutions to the Homework/Exercises
- In the configure callback, printf the serial and dimensions before ack.
- Ignore the configure size but still ack; observe the compositor likely forces size.
- Handle xdg_toplevel.close by setting a quit flag and cleaning up.
2.4 Event Loop, Frame Callbacks, and Presentation Timing
Fundamentals
Wayland is event-driven. You do not call a draw function and expect the compositor to update; you wait for events and respond. The main loop dispatches incoming events from the Wayland socket and triggers redraws when appropriate. The frame callback (wl_surface.frame) tells you when it is a good time to render the next frame. If you render continuously without waiting for frame callbacks, you can waste CPU and cause unnecessary compositor work. A well-behaved client only draws when it has something to show or when the compositor requests a frame. This pacing model keeps CPU usage low and avoids unnecessary frames.
Deep Dive into the concept
At the heart of every Wayland client is a dispatch loop. libwayland provides wl_display_dispatch, wl_display_dispatch_pending, and wl_display_flush. A common pattern is: dispatch events, handle callbacks, then if you have pending requests, flush them to the socket. If you are integrating Wayland with another event loop (like epoll), you can use wl_display_get_fd to obtain the socket fd and manage it alongside other sources. For this project, a simple blocking loop using wl_display_dispatch is sufficient.
Frame callbacks are subtle. When you call wl_surface_frame, the compositor returns a wl_callback object. You add a listener to that callback, and when the compositor is ready for you to draw again, it sends a done event with a timestamp. This is the Wayland equivalent of a swapchain present signal. It does not force you to draw, but it is the preferred pacing signal. A typical loop is: after committing a buffer, request a frame callback. When the callback fires, redraw (if needed), attach a new buffer, and commit again. This prevents you from outrunning the compositor and reduces latency and CPU usage.
The frame callback timestamp is in milliseconds and is typically the time of the presentation. You can use it to measure frame times, but for this project, treat it as an opaque value. More important is the idea that the compositor controls pacing. It may also coalesce frame callbacks if nothing changes, which is why you must trigger redraws explicitly (for example, on input or resize). If you want to animate, you can schedule the next frame in response to the callback. If you are static, you can stop requesting frame callbacks after the first draw. This is an important performance lesson: not all clients should render continuously.
Another important part of the event loop is error handling and disconnection. If wl_display_dispatch returns -1, the connection is dead. You should exit gracefully. If you rely on an external loop, you need to check for POLLIN and handle EPIPE or ECONNRESET. This project is a good place to learn to structure a state machine: a running flag, a need_redraw flag, and a pending_resize size. You will set flags in event callbacks and act on them in the main loop or in the frame callback.
The event loop also interacts with signal handling. If the user presses Ctrl+C, you should handle SIGINT, set a quit flag, and exit the dispatch loop. Doing cleanup inside the signal handler is unsafe; you should just set a flag and let the main loop do orderly teardown. This is not Wayland-specific, but it is essential for a clean shutdown. Another subtlety: if you call wl_display_roundtrip in a thread that is also doing dispatch, you can deadlock. Keep your client single-threaded for this project.
Understanding event loops here will help you with the compositor project later. Compositors must integrate libinput, DRM events, timers, and Wayland server events. Even though this project only uses one event source, it teaches you the correct mental model for asynchronous systems: events drive state transitions, and you never assume that requests complete synchronously.
How this fit on projects
You will implement the event loop in Section 5.10 Phase 2. Frame callbacks are part of Section 3.2 (functional requirements) and Section 3.6 (edge cases). The timing model also impacts testing in Section 6.
Definitions & key terms
- dispatch -> process incoming events from the Wayland socket
- flush -> send pending requests to the compositor
- frame callback -> an event that signals when to render the next frame
- event loop -> main loop that waits for input and dispatches events
- presentation timing -> the compositor-controlled schedule of frames
Mental Model Diagram (ASCII)
Main Loop
---------
while (running) {
wl_display_dispatch(display); // blocks until events
if (need_redraw) {
draw();
wl_surface_commit(surface);
request_frame_callback();
}
}
Compositor
----------
- sends configure, input, frame done events
- throttles client via frame callbacks
How It Works (Step-by-Step)
- Register event listeners on registry, xdg_surface, and buffers.
- Enter a loop calling wl_display_dispatch.
- When a configure or frame done event arrives, set a flag to redraw.
- In your redraw logic, attach a buffer and commit.
- Request another frame callback if you expect to redraw again.
- Exit the loop on quit flag or disconnection.
Invariants:
- Do not redraw continuously without a reason.
- Always dispatch events to keep the protocol alive.
Failure modes:
- Busy loop without frame callbacks -> high CPU usage.
- Ignoring dispatch -> compositor disconnects due to unresponsiveness.
- Blocking in callbacks -> missed events and lag.
Minimal Concrete Example
static void frame_done(void *data, struct wl_callback *cb, uint32_t time) {
struct app_state *app = data;
wl_callback_destroy(cb);
app->needs_redraw = true;
}
while (app.running && wl_display_dispatch(app.display) != -1) {
if (app.needs_redraw) {
draw_and_commit(app);
app.needs_redraw = false;
}
}
Common Misconceptions
- “I should redraw in a tight loop.” -> Wayland prefers frame callbacks.
- “wl_display_dispatch returns immediately.” -> It blocks until events.
- “Frame callbacks are required for every commit.” -> They are a pacing tool, not a requirement.
Check-Your-Understanding Questions
- Why are frame callbacks better than rendering in a timer loop?
- What happens if you never call wl_display_dispatch?
- How would you integrate Wayland events with another event source?
Check-Your-Understanding Answers
- They align rendering with compositor timing, reducing wasted work and latency.
- The compositor sees you as unresponsive and may disconnect; you will never receive events.
- Use wl_display_get_fd and integrate it with poll/epoll.
Real-World Applications
- Games and animation apps use frame callbacks for vsync pacing.
- Toolkits integrate Wayland dispatch into their main loops.
Where You’ll Apply It
- In this project: see Section 5.10 Phase 2, Section 6 Testing Strategy, Section 7 Common Pitfalls.
- Also used in: P02 Simple Wayland Compositor for server-side event loops.
References
- libwayland-client documentation
- “The Wayland Book” sections on event loops and frame callbacks
Key Insights
The compositor controls timing; your client renders only when it makes sense to do so.
Summary
A clean event loop and correct use of frame callbacks keep your client responsive, efficient, and protocol-correct.
Homework/Exercises to Practice the Concept
- Implement a simple animation that changes the color each frame callback.
- Add a timer (timerfd) that requests a redraw every second.
- Log the frame callback timestamps and compute frame intervals.
Solutions to the Homework/Exercises
- On each frame_done, increment a color value, draw, and commit.
- Integrate timerfd with poll and set needs_redraw when it fires.
- Store last timestamp and print (time - last) for each callback.
3. Project Specification
3.1 What You Will Build
You will build a minimal Wayland client in C that:
- Connects to the Wayland compositor and discovers required globals
- Creates an xdg-shell toplevel surface
- Allocates wl_shm buffers and renders a solid-color window
- Handles resize events and redraws at the new size
- Responds to close requests and exits cleanly
Excluded:
- GPU rendering, EGL, or OpenGL
- Advanced widgets or text rendering
- Decorations (you rely on the compositor)
3.2 Functional Requirements
- Connect and discover globals: The client must connect to the Wayland display, enumerate globals, and bind wl_compositor, wl_shm, and xdg_wm_base.
- Create a toplevel window: The client must create wl_surface, xdg_surface, and xdg_toplevel, and set a window title.
- Render with wl_shm: The client must allocate a shared memory buffer and render a solid color.
- Handle configure events: On resize, the client must create a new buffer and redraw.
- Event loop: The client must dispatch Wayland events and exit on close or SIGINT.
3.3 Non-Functional Requirements
- Performance: CPU usage under 2 percent when idle (no animation).
- Reliability: No protocol errors; the compositor should not disconnect the client.
- Usability: Window should resize smoothly and close gracefully.
3.4 Example Usage / Output
$ ./wayland_client --width 800 --height 600 --color 0x3366cc
[Wayland] Connected to wayland-0
[Registry] wl_compositor v6
[Registry] wl_shm v1
[Registry] xdg_wm_base v5
[Surface] Created toplevel "Bare Metal Client"
[Render] 800x600 XRGB8888 stride=3200
[Loop] Waiting for events
3.5 Data Formats / Schemas / Protocols
- Pixel format: WL_SHM_FORMAT_XRGB8888
- Stride: width * 4 bytes (32bpp)
- Buffer size: stride * height
- Protocol: Wayland core + xdg-shell
3.6 Edge Cases
- configure width/height == 0 -> use last known size or a default
- compositor does not advertise xdg_wm_base -> exit with error
- wl_shm does not advertise XRGB8888 -> fallback to ARGB8888 or exit
- buffer release never arrives -> create new buffer to avoid stalling
3.7 Real World Outcome
3.7.1 How to Run (Copy/Paste)
# Build
cc -O2 -Wall -o wayland_client main.c \
$(pkg-config --cflags --libs wayland-client wayland-protocols)
# Run
./wayland_client --width 800 --height 600 --color 0x3366cc
3.7.2 Golden Path Demo (Deterministic)
- Fixed size: 800x600
- Fixed color: 0x3366cc
- Fixed title: “Bare Metal Client”
- Deterministic rendering: no animation, no randomness
3.7.3 If CLI: Exact Terminal Transcript
$ ./wayland_client --width 800 --height 600 --color 0x3366cc
[Wayland] Connected to wayland-0
[Registry] wl_compositor v6
[Registry] wl_shm v1
[Registry] xdg_wm_base v5
[XDG] toplevel title="Bare Metal Client"
[Render] buffer=800x600 stride=3200 format=XRGB8888
[Frame] frame callback installed
[Loop] running (Ctrl+C to quit)
^C
[Signal] SIGINT received
[Cleanup] destroy xdg_toplevel
[Cleanup] destroy xdg_surface
[Cleanup] destroy wl_surface
[Cleanup] destroy wl_shm_pool
[Cleanup] munmap buffer
[Exit] done
Exit codes:
- 0 on clean exit
- 1 if required globals missing
- 2 if wl_shm format unsupported
3.7.4 If GUI / Desktop
Window details:
- One top-level window titled “Bare Metal Client”
- Solid fill color (0x3366cc)
- Resizable by window manager
- Close button closes the app
ASCII wireframe:
+------------------------------------------------+
| Bare Metal Client | [X]
| |
| (solid color fill) |
| |
| |
+------------------------------------------------+
3.7.5 Failure Demo (Deterministic)
$ ./wayland_client --color 0x00ff00 --force-no-xdg
[Error] xdg_wm_base not advertised by compositor
[Exit] code=1
4. Solution Architecture
4.1 High-Level Design
+------------------------------+
| Wayland Client App |
|------------------------------|
| - registry discovery |
| - surface + xdg-shell |
| - buffer allocator (wl_shm) |
| - event loop + callbacks |
+--------------+---------------+
|
v
+------------------------------+
| Wayland Compositor |
| (external, not in project) |
+------------------------------+
4.2 Key Components
| Component | Responsibility | Key Decisions |
|---|---|---|
| Registry handler | Track globals and bind interfaces | Bind highest supported version |
| Buffer manager | Create and reuse wl_shm buffers | Use double buffering to avoid reuse issues |
| XDG shell handler | Manage configure/ack/close | Strict ordering with serial tracking |
| Event loop | Dispatch events and trigger redraws | Use frame callbacks for pacing |
4.3 Data Structures (No Full Code)
struct app_state {
struct wl_display *display;
struct wl_registry *registry;
struct wl_compositor *compositor;
struct wl_shm *shm;
struct xdg_wm_base *wm_base;
struct wl_surface *surface;
struct xdg_surface *xdg_surface;
struct xdg_toplevel *toplevel;
struct wl_buffer *buffers[2];
void *shm_data[2];
int width, height;
int stride;
int current_buffer;
bool running;
bool needs_redraw;
};
4.4 Algorithm Overview
Key Algorithm: Configure-Driven Redraw
- Receive configure event with serial and new size.
- Ack the configure serial.
- Reallocate buffer if size changed.
- Draw into buffer.
- Attach and commit.
- Request a frame callback.
Complexity Analysis:
- Time: O(width * height) per redraw for pixel fill
- Space: O(width * height * 4) bytes per buffer
5. Implementation Guide
5.1 Development Environment Setup
sudo apt install build-essential pkg-config libwayland-dev wayland-protocols
5.2 Project Structure
wayland-client/
|-- src/
| |-- main.c
| |-- wl_helpers.c
| |-- wl_helpers.h
| `-- shm_buffer.c
|-- tests/
| `-- test_stride.c
|-- Makefile
`-- README.md
5.3 The Core Question You’re Answering
“What is the minimal conversation between a Linux app and the compositor that results in visible pixels on screen?”
Before you code, internalize that the compositor will never draw for you. Your job is to allocate pixels, send them as a buffer, and react to the compositor’s timing and size constraints.
5.4 Concepts You Must Understand First
Stop and research these before coding:
- Wayland registry discovery and binding
- wl_shm buffer lifecycle and wl_buffer.release
- xdg-shell configure/ack/commit lifecycle
- Event-driven programming with callbacks
5.5 Questions to Guide Your Design
- How will you store globals and handle missing ones?
- How will you manage buffer lifetimes safely?
- Will you redraw only on configure, or on frame callbacks too?
- How will you handle resize and zero size events?
5.6 Thinking Exercise
Draw a sequence diagram of messages for the first frame. Identify where you must wait for events before proceeding.
5.7 The Interview Questions They’ll Ask
- Why does Wayland require a configure/ack/commit loop?
- What is the role of wl_shm and how is it implemented?
- How does Wayland differ from X11 in who draws pixels?
5.8 Hints in Layers
Hint 1: Minimal registry handling
wl_registry_add_listener(registry, ®istry_listener, &app);
wl_display_roundtrip(display);
Hint 2: Two-buffer strategy Allocate two buffers and alternate only after receiving wl_buffer.release.
Hint 3: Configure order Never attach a buffer before the first configure has been acknowledged.
5.9 Books That Will Help
| Topic | Book | Chapter | |——-|——|———| | Wayland protocol | “The Wayland Book” by Drew DeVault | Ch. 1-4, 8-9 | | Shared memory | “The Linux Programming Interface” | Ch. 54 | | Event loops | “The Linux Programming Interface” | Ch. 63 | | C memory model | “Effective C” by Seacord | Ch. 6 |
5.10 Implementation Phases
Phase 1: Foundation (2-3 days)
Goals:
- Connect to the display
- Enumerate globals
Tasks:
- Implement registry listener and print global interfaces.
- Bind to wl_compositor, wl_shm, xdg_wm_base.
Checkpoint: Running program prints globals and exits cleanly.
Phase 2: Core Functionality (4-6 days)
Goals:
- Create surface and xdg-shell objects
- Create wl_shm buffer and draw
Tasks:
- Implement xdg_surface configure callback and ack.
- Allocate a buffer, draw a solid color, attach and commit.
- Add frame callback for pacing.
Checkpoint: Window appears and is resizable.
Phase 3: Polish and Edge Cases (2-3 days)
Goals:
- Resize handling
- Clean shutdown
Tasks:
- Recreate buffers on resize.
- Handle close event and SIGINT.
Checkpoint: No protocol errors; clean exit.
5.11 Key Implementation Decisions
| Decision | Options | Recommendation | Rationale | |———-|———|—————-|———–| | Buffer strategy | single buffer, double buffer | double buffer | avoids writing into in-use buffer | | Rendering cadence | continuous loop, frame callbacks | frame callbacks | efficient and protocol-friendly | | Resize handling | ignore size, recreate buffer | recreate buffer | correct visual output |
6. Testing Strategy
6.1 Test Categories
| Category | Purpose | Examples | |———-|———|———-| | Unit Tests | Validate helper functions | stride calculations, buffer size math | | Integration Tests | Ensure protocol sequence | registry -> configure -> commit | | Edge Case Tests | Handle corner cases | width/height 0, missing globals |
6.2 Critical Test Cases
- Missing xdg_wm_base: Program exits with code 1 and clear error.
- Resize sequence: Multiple configures in a row; only latest buffer is used.
- Buffer reuse: Ensure no writes occur before wl_buffer.release.
6.3 Test Data
Inputs:
- width=1 height=1 color=0x000000
- width=800 height=600 color=0x3366cc
Expected:
- buffer size = width*height*4
- stride = width*4
7. Common Pitfalls & Debugging
7.1 Frequent Mistakes
| Pitfall | Symptom | Solution | |———|———|———-| | Committing before configure | window never appears | wait for configure, ack first | | Wrong stride | distorted image | compute stride as width4 and size = strideheight | | Reusing buffer early | flicker or crash | wait for wl_buffer.release |
7.2 Debugging Strategies
- WAYLAND_DEBUG=1: Inspect request order and verify configure/ack/commit.
- Verbose logging: Print every event and serial to confirm state machine.
7.3 Performance Traps
- Redrawing continuously without frame callbacks can spike CPU usage.
8. Extensions & Challenges
8.1 Beginner Extensions
- Draw a checkerboard pattern instead of solid color.
- Add keyboard input to change colors.
8.2 Intermediate Extensions
- Implement double buffering with explicit wl_buffer.release handling.
- Render a simple gradient and verify correct endianness.
8.3 Advanced Extensions
- Replace wl_shm rendering with EGL + OpenGL.
- Add DMA-BUF support using zwp_linux_dmabuf_v1.
9. Real-World Connections
9.1 Industry Applications
- GTK/Qt Apps: They use the same xdg-shell lifecycle and buffer handling.
- Embedded UIs: Many embedded UIs use wl_shm for simplicity.
9.2 Related Open Source Projects
- weston-simple-shm: Minimal Wayland client example
- gtk4: Full toolkit using the same protocol but hidden behind APIs
9.3 Interview Relevance
- Discuss protocol-driven state machines and buffer lifetimes.
- Explain differences between Wayland and X11 rendering models.
10. Resources
10.1 Essential Reading
- “The Wayland Book” by Drew DeVault - Chapters 1-4, 8-9
- Wayland core protocol spec (wl_shm, wl_surface, wl_registry)
10.2 Video Resources
- “Wayland Protocol Explained” talks (Wayland Conference videos)
10.3 Tools & Documentation
- wayland-info: list globals and formats
- WAYLAND_DEBUG: inspect protocol traffic
10.4 Related Projects in This Series
- P02 Simple Wayland Compositor: Learn the server side.
- P05 X11 Comparison: Contrast design models.
11. Self-Assessment Checklist
11.1 Understanding
- I can explain the registry discovery and binding process.
- I can explain why configure/ack/commit is required.
- I can describe wl_shm buffer lifetimes and release events.
11.2 Implementation
- The window appears and resizes correctly.
- I handle close and SIGINT cleanly.
- No protocol errors occur during normal use.
11.3 Growth
- I can compare this client with an X11 client.
- I can extend the buffer drawing logic to render shapes.
12. Submission / Completion Criteria
Minimum Viable Completion:
- The app opens a Wayland window with a solid color.
- Resizing triggers buffer recreation and redraw.
- Clean exit on close or SIGINT.
Full Completion:
- All minimum criteria plus:
- Double buffering implemented with proper release handling.
- Clear logging of configure and buffer lifecycle events.
Excellence (Going Above & Beyond):
- Add optional command-line arguments for color and size.
- Include a brief write-up comparing Wayland and X11.