Building Wayland Compositors in Rust with Smithay¶
Smithay is a pure-Rust framework for building Wayland compositors. It replaces wlroots' C approach with Rust's ownership model, turning compositor object-lifecycle bugs into compile-time errors. This article covers Smithay internals, the calloop event loop, backend/renderer abstraction, the wlroots-rs failure story, production compositors (COSMIC, niri), a working code skeleton, and what this means for Warmwind's stack.
Why Smithay Exists¶
The Wayland ecosystem had a gap: wlroots is excellent but written in C, and
every attempt to wrap it in Rust failed. Smithay fills that gap by
reimplementing compositor building blocks in pure Rust -- no FFI, no unsafe
wrappers around C object lifecycles, no impedance mismatch.
graph LR
Cal["calloop"] --> WS["wayland-server"]
WS --> Proto["Protocol Handlers"]
Proto --> Comp["Compositor State"]
Comp --> Rend["Renderer"]
Comp --> Inp["Input (libinput)"]
Rend --> BE["Backend (udev/winit/x11)"]
Inp --> BE
BE --> HW["Hardware (DRM/KMS)"]
Smithay Architecture¶
Smithay is not a compositor -- it is a library of composable modules. You pick the pieces you need and wire them together. The main architectural layers:
Core Modules¶
| Module | Crate / Path | Purpose |
|---|---|---|
wayland-server |
smithay::wayland |
Pure-Rust Wayland protocol implementation (server side) |
calloop |
smithay::reexports::calloop |
Callback-based event loop for I/O multiplexing |
| Backend | smithay::backend |
Hardware abstraction: udev, DRM, libinput, winit, x11 |
| Renderer | smithay::backend::renderer |
GPU abstraction trait with concrete implementations |
| Desktop | smithay::desktop |
Window management helpers, layer-shell, popups |
| Input | smithay::input |
Seat, keyboard, pointer, touch state machines |
Backend Abstraction¶
Smithay supports multiple backends for different execution contexts:
| Backend | When to use | What it provides |
|---|---|---|
| udev + libinput + DRM | Production (TTY) | Real hardware: GPU rendering, physical input devices, multi-monitor via DRM/KMS |
| winit | Development | Runs compositor inside a window on an existing desktop -- fastest iteration cycle |
| x11 | Development on X11 | Similar to winit but uses X11 directly as the host |
| headless | Testing / CI | No display, no input -- useful for automated testing and WayVNC-style capture |
The backend choice is typically a runtime decision. Anvil (Smithay's reference compositor) and COSMIC both select the backend based on the environment:
// Pseudocode: backend selection at startup
if std::env::var("WAYLAND_DISPLAY").is_ok() || std::env::var("DISPLAY").is_ok() {
// Running inside an existing session -- use winit for development
init_winit_backend(&mut event_loop, &mut state);
} else {
// Running from a TTY -- use real hardware
init_udev_backend(&mut event_loop, &mut state);
}
Renderer Trait¶
Smithay defines a Renderer trait that abstracts GPU operations. Two concrete
implementations ship with the library:
GlesRenderer(OpenGL ES 2.0+)- The default for hardware-accelerated compositing. Uses EGL for context creation and works with DRM/GBM buffer allocation. This is what production compositors (COSMIC, niri) use on real hardware.
PixmanRenderer(CPU software)- Software rasterizer using the Pixman library. No GPU required. Useful for headless servers, CI testing, and environments where GPU access is unavailable or untrusted -- relevant for Warmwind's containerized sessions where GPU passthrough may not be configured.
// The Renderer trait -- simplified
pub trait Renderer {
type Error;
type TextureId;
type Frame: Frame; // one frame of rendering
fn render(
&mut self,
output_size: Size<i32, Physical>,
transform: Transform,
) -> Result<Self::Frame, Self::Error>;
}
The trait-based design means compositor code is generic over the renderer. You
can develop with PixmanRenderer on a headless CI box and deploy with
GlesRenderer on hardware -- same compositor logic, different backend.
calloop: The Event Loop That Makes It Work¶
calloop is a callback-based event loop created specifically for Wayland compositors. It is the nervous system of every Smithay compositor.
Why Not tokio?¶
Compositors have unique requirements that async runtimes like tokio do not serve well:
| Requirement | tokio | calloop |
|---|---|---|
Centralized mutable state (&mut State) |
Requires Arc<Mutex<_>> everywhere |
Passed directly to every callback |
| Deterministic callback ordering | Tasks can run in any order | Sources polled sequentially, predictable |
| File descriptor integration (DRM, input) | Possible but awkward | First-class EventSource trait |
| No hidden threads | Multi-threaded by default | Single-threaded, no surprises |
| Frame timing control | Not designed for it | Idle callbacks + timer sources |
A compositor spends most of its time waiting: for client requests, for
vblank, for input events. calloop's epoll-backed polling is perfect for
this -- low overhead, no thread pool, no Send + Sync constraints on your
state.
How calloop Works¶
use calloop::{EventLoop, LoopSignal};
// 1. Create the loop with your state type
let mut event_loop: EventLoop<MyState> =
EventLoop::try_new().expect("failed to create event loop");
let handle = event_loop.handle();
// 2. Insert event sources -- each gets a callback
handle.insert_source(
wayland_source, // Wayland client connections
|event, metadata, state: &mut MyState| { // callback gets &mut State
state.handle_wayland_event(event, metadata);
},
)?;
handle.insert_source(
libinput_source, // keyboard, mouse, touch
|event, _metadata, state: &mut MyState| {
state.handle_input(event);
},
)?;
handle.insert_source(
drm_source, // vblank, page-flip complete
|event, _metadata, state: &mut MyState| {
state.handle_vblank(event);
},
)?;
// 3. Run -- blocks, dispatches callbacks sequentially
let signal: LoopSignal = event_loop.get_signal();
event_loop.run(
Duration::from_millis(16), // timeout per iteration (~60 fps)
&mut my_state, // passed as &mut to every callback
|state| { // idle callback -- runs after all events
state.maybe_render();
},
)?;
The key insight: every callback receives &mut MyState. Because calloop is
single-threaded and sequential, there is no contention -- no mutexes, no
RefCell, no Rc<RefCell<_>> dances. This is the same design principle behind
wlroots' wl_event_loop, but expressed in safe Rust.
calloop vs wl_event_loop¶
| Aspect | wl_event_loop (C, used by wlroots) |
calloop (Rust, used by Smithay) |
|---|---|---|
| Language | C with void * user data |
Rust with generic State type |
| Type safety | Cast void * in every callback |
Compiler-checked &mut State |
| Source composition | Manual fd management | EventSource trait, composable |
| Async integration | None | Optional futures executor |
| Error handling | Return codes, easy to ignore | Result<T, E>, must handle |
The wlroots-rs Failure Story¶
Understanding why wlroots-rs failed is essential for appreciating Smithay's design. The Way Cooler project (a Rust compositor) spent over 1,000 commits trying to build safe Rust bindings around wlroots. They gave up.
The Core Problem: Object Lifecycle Mismatch¶
Wayland objects (outputs, surfaces, seats) have server-controlled lifecycles. An output can disappear at any moment -- the user unplugs the monitor. In C, wlroots handles this with listener callbacks and manual cleanup. In Rust, the borrow checker demands to know at compile time who owns what and when things are freed.
The wlroots-rs approach was to wrap every wlroots object in a reference-counted handle:
// wlroots-rs pattern (simplified) -- THE APPROACH THAT FAILED
pub struct OutputHandle {
inner: Rc<Cell<Option<*mut wlr_output>>>, // nullable raw pointer
}
impl OutputHandle {
pub fn run<F, R>(&self, f: F) -> Result<R, HandleErr>
where F: FnOnce(&mut Output) -> R
{
// Every single access must check if the C object is still alive
let ptr = self.inner.get().ok_or(HandleErr::Destroyed)?;
// ... unsafe dereference ...
}
}
The result: 11,000 lines of wrapper code that covered less than half of wlroots' API surface. Every method call required a liveness check. Custom Wayland protocols were impossible to implement safely. The developer concluded: the approach was "nowhere near usable and the current trajectory doesn't look like it will be usable very soon either."
How Smithay Avoids This¶
Smithay takes the opposite approach: implement everything in Rust from scratch. No C library to wrap. No FFI boundary where ownership gets lost.
| Problem | wlroots-rs (FFI) | Smithay (pure Rust) |
|---|---|---|
| Output disappears | Nullable handle, runtime check | Drop impl destroys protocol object |
| Surface buffer lifecycle | Raw pointer + manual ref-count | Arc<Buffer> with Rust semantics |
| New protocol support | Impossible through wrapper | Implement trait, add to wayland-server |
| Compile-time safety | Only for Rust wrapper layer | All the way down to the protocol wire |
| Custom state per object | void *user_data casts |
Generic UserData with TypeMap |
When a Wayland object is destroyed in Smithay, the Rust struct is dropped. The
drop implementation sends the wl_display.delete_id event and cleans up
resources. This is the ownership model working as intended -- no dangling
pointers, no double-free, no use-after-free.
Practical: Minimal Smithay Compositor Skeleton¶
This is a working Smithay compositor skeleton -- not pseudocode. It creates a Wayland display, sets up the calloop event loop, registers a compositor handler, and processes client connections. Based on Smithay's smallvil example.
// Cargo.toml dependencies:
// smithay = { version = "0.3", features = ["wayland_frontend", "backend_winit", "renderer_gles2"] }
// calloop = "0.14"
use smithay::{
backend::winit::{self, WinitEvent},
delegate_compositor, delegate_shm, delegate_xdg_shell,
output::{Mode, Output, PhysicalProperties, Subpixel},
reexports::{
calloop::EventLoop,
wayland_server::{Display, DisplayHandle},
},
utils::{Rectangle, Size, Transform},
wayland::{
compositor::{CompositorClientState, CompositorHandler, CompositorState},
shell::xdg::{XdgShellHandler, XdgShellState, ToplevelSurface},
shm::{ShmHandler, ShmState},
},
};
use std::time::Duration;
// ---- State: single struct owns everything ----
struct MiniComp {
display: Display<Self>, // (1)
compositor_state: CompositorState, // tracks wl_surface objects
xdg_shell_state: XdgShellState, // xdg_toplevel / xdg_popup
shm_state: ShmState, // shared-memory buffer pool
}
// ---- Trait implementations Smithay requires ----
impl CompositorHandler for MiniComp { // (2)
fn compositor_state(&mut self) -> &mut CompositorState {
&mut self.compositor_state
}
fn client_compositor_state<'a>(
&self,
client: &'a wayland_server::Client,
) -> &'a CompositorClientState {
// Per-client state stored in client's data map
&client.get_data::<CompositorClientState>().unwrap()
}
fn commit(&mut self, surface: &wayland_server::protocol::wl_surface::WlSurface) {
// Client committed a new buffer -- schedule repaint
println!("surface committed: {:?}", surface.id());
}
}
impl ShmHandler for MiniComp {
fn shm_state(&self) -> &ShmState { &self.shm_state }
}
impl XdgShellHandler for MiniComp { // (3)
fn xdg_shell_state(&mut self) -> &mut XdgShellState {
&mut self.xdg_shell_state
}
fn new_toplevel(&mut self, surface: ToplevelSurface) {
// A client opened a window -- configure it
surface.with_pending_state(|state| {
state.size = Some((800, 600).into());
});
surface.send_configure();
}
}
// ---- Smithay delegate macros wire traits to the display ----
delegate_compositor!(MiniComp); // (4)
delegate_shm!(MiniComp);
delegate_xdg_shell!(MiniComp);
fn main() {
// ---- Create display and event loop ----
let mut display = Display::<MiniComp>::new().unwrap();
let dh: DisplayHandle = display.handle();
let compositor_state = CompositorState::new::<MiniComp>(&dh);
let xdg_shell_state = XdgShellState::new::<MiniComp>(&dh);
let shm_state = ShmState::new::<MiniComp>(&dh, vec![]);
let mut state = MiniComp {
display,
compositor_state,
xdg_shell_state,
shm_state,
};
// ---- Start calloop event loop ----
let mut event_loop: EventLoop<MiniComp> =
EventLoop::try_new().expect("event loop"); // (5)
// Insert the Wayland display as an event source
let socket_name = state
.display
.add_socket_auto() // (6)
.expect("wayland socket");
println!("Listening on: {}", socket_name);
// ---- Main loop: dispatch events, render ----
loop { // (7)
event_loop
.dispatch(Duration::from_millis(16), &mut state)
.expect("event loop dispatch");
state.display.flush_clients().unwrap();
}
}
Annotations:
-
Display<Self>-- The Wayland display server object, generic over your state type. Owns the socket, client list, and protocol global registry. -
CompositorHandler-- Trait you implement to react towl_compositorandwl_surfaceevents. Thecommitmethod fires whenever a client submits a new buffer. -
XdgShellHandler-- Handles desktop window lifecycle.new_toplevelis called when a client creates a window. You configure its initial size and send the configure event. -
delegate_*!macros -- These are Smithay's dispatch wiring. They generate the boilerplate that routes protocol events to your trait implementations. Without them, the display does not know which handler to call. -
EventLoop::try_new()-- Creates the calloop event loop backed byepoll. Generic overMiniCompso every callback gets&mut MiniComp. -
add_socket_auto()-- Creates a Unix socket in$XDG_RUNTIME_DIRwith an auto-generated name (e.g.,wayland-1). Clients connect here. -
The main loop dispatches events (Wayland client messages, timers, input) and flushes outgoing messages back to clients. A real compositor would also render here on vblank.
This skeleton does not render
It handles protocol messages but has no renderer attached. A real compositor
would initialize a GlesRenderer or PixmanRenderer, attach it to an
output, and render surfaces in the main loop. See Smithay's
smallvil and
anvil for full
rendering pipelines.
COSMIC Desktop: Production Proof¶
COSMIC is System76's Rust-native desktop environment shipping on Pop!_OS. Its compositor, cosmic-comp, is the largest production Smithay consumer and the strongest evidence that Rust compositors work at scale.
What cosmic-comp Builds on Top of Smithay¶
| Layer | What cosmic-comp adds |
|---|---|
| Workspace management | Named and dynamic workspaces, cross-monitor drag-and-drop |
| Tiling engine | Automatic tiling with manual override, gaps, resize handles |
| XWayland | Full X11 compatibility layer for legacy apps |
| Animations | Spring-physics window transitions, workspace switching effects |
| Session management | D-Bus integration, login/lock screen, multi-seat support |
| Layer shell | Panel, dock, OSD, notification overlays via wlr-layer-shell |
| Protocols | cosmic-toplevel-info, cosmic-workspace, cosmic-overlap-notify -- custom protocols for desktop integration |
| Profiling hooks | Built-in frame timing, Smithay performance instrumentation |
COSMIC proves that Smithay is not just a toy. It handles:
- Multi-GPU configurations (laptop + external display with different GPUs)
- VRR (Variable Refresh Rate) for adaptive sync monitors
- HDR pipeline work (in progress as of early 2026)
- Hundreds of Wayland protocol extensions beyond the core
Performance work
Christian Meissl (Smithay maintainer) profiled Smithay directly inside cosmic-comp and pushed performance improvements upstream. The library is being battle-tested against real desktop workloads -- not just demo compositors.
Architecture Pattern¶
graph LR
A["cosmic-session"] --> B["cosmic-comp"]
B --> C["Smithay"]
C --> D["calloop"]
C --> E["wayland-server"]
C --> F["DRM/KMS"]
B --> G["XWayland"]
B --> H["cosmic-panel"]
B --> I["cosmic-launcher"]
B --> J["cosmic-osd"]
cosmic-comp sits between the desktop shell components (panel, launcher, OSD)
and Smithay's low-level plumbing. It owns the toplevel state, workspace logic,
and protocol routing. The shell components are separate Wayland clients that
communicate through layer-shell and custom protocols.
niri: A Different Kind of Compositor¶
niri is a scrollable-tiling Wayland compositor written in Rust on Smithay. Unlike i3/Sway's tree-based tiling, niri arranges windows in columns on an infinite horizontal strip.
Key Architectural Choices¶
Scrolling model: Windows never resize when you open a new one. The strip extends to the right. You scroll left/right to navigate. This eliminates the "new window squishes everything" problem of traditional tiling.
One strip per monitor: Each monitor has its own independent horizontal strip with its own set of dynamic vertical workspaces. No cross-monitor tree.
Spring physics animations: niri uses spring-based animation curves for window movement, workspace switching, and scrolling. Not just cosmetic -- it provides spatial orientation cues.
Architecture (source layout):
| File | Responsibility |
|---|---|
src/niri.rs |
Core state: Wayland display, Smithay backend, event dispatch |
src/layout/ |
Scrollable tiling logic, workspace management, column sizing |
src/input/ |
Keyboard, pointer, gestures, keybinding dispatch |
src/render/ |
Frame rendering, damage tracking, OpenGL |
src/animation.rs |
Spring physics engine for smooth transitions |
src/protocols/ |
Wayland protocol handler implementations |
niri is daily-drivable and has been adopted by users who want predictable window placement. It demonstrates that Smithay is flexible enough to support radically different window management paradigms -- not just i3 clones.
How Warmwind Could Use Smithay¶
Warmwind runs Sway (wlroots, C) as their compositor for kiosk sessions where Chromium serves AI agent interfaces via WayVNC. Here is why Smithay is relevant to their stack and what you should be prepared to discuss:
Scenario 1: Replace Sway with a Purpose-Built Rust Compositor¶
Warmwind's compositor needs are narrow: launch Chromium fullscreen, capture frames for VNC, inject input. They do not need i3 compatibility, workspaces, or user configuration. A bespoke Smithay compositor could:
- Reduce attack surface -- no IPC socket, no config parser, no unused
protocol implementations. Only
xdg-shell,wlr-screencopy(orext-image-copy-capture), and virtual input. - Enforce invariants at compile time -- Rust's type system can make illegal states unrepresentable (e.g., only one toplevel surface allowed per session).
- Simplify deployment -- single static binary, no wlroots shared library dependency, no Sway configuration management.
- Enable per-tenant customization -- since the compositor is Rust code, not a config file, tenant-specific behavior (watermarks, input filtering, session timeouts) can be built in as typed configuration.
Scenario 2: Keep Sway, Extend with Rust Tooling¶
A less disruptive approach:
- Sway IPC from Rust -- use the
swayipccrate to control Sway sessions programmatically. Launch, monitor, restart Chromium. React to output hotplug events. - Custom Wayland protocol clients -- write Rust-based Wayland clients that
use Smithay's client toolkit (
smithay-client-toolkit) to interact with Sway for frame capture, input injection, or session monitoring. - Gradual migration path -- start with Rust tooling around Sway, build confidence with Wayland protocol handling, then migrate to a Smithay compositor when the team is ready.
The Security Argument¶
In Warmwind's multi-tenant environment, where AI agents control browser sessions, compositor security is not optional:
| Concern | Sway (C) | Smithay compositor (Rust) |
|---|---|---|
| Memory safety | Manual -- CVEs possible | Guaranteed (safe Rust) |
| Protocol fuzzing resistance | Depends on wlroots hardening | Type system prevents many classes of bugs |
| Privilege separation | Standard Wayland isolation | Same, plus Rust unsafe audit surface is small |
| Custom protocol correctness | C struct + callback, easy to mismanage lifecycle | Trait-based, compile-time checked |
| Dependency supply chain | C library ecosystem | cargo audit, SBOM generation, reproducible builds |
Interview framing
Do not argue that Warmwind should abandon Sway. Instead, demonstrate that you understand both stacks. Show that you can maintain and patch their existing C/wlroots compositor and evaluate whether a Rust-native path makes sense for their security and maintenance requirements. The ability to bridge both worlds is the value proposition.
Ownership Model: Why Rust Fits Wayland¶
The Wayland protocol is fundamentally about object lifecycle management. Every resource (display, surface, buffer, seat, output) is created by one side and destroyed when no longer needed. This maps directly to Rust's ownership:
// Wayland lifecycle ↔ Rust ownership
{
let surface = compositor.create_surface(); // wl_compositor.create_surface
surface.attach(buffer); // wl_surface.attach
surface.commit(); // wl_surface.commit
} // surface dropped → wl_surface.destroy sent automatically
In C (wlroots), you must manually call wl_resource_destroy() and ensure no
dangling pointers remain in listener lists. Miss one and you get a
use-after-free. In Smithay, the Drop trait handles it. The borrow checker
ensures no reference outlives the object.
This is not a theoretical advantage. The Way Cooler post-mortem documented real, persistent bugs from trying to map C object lifecycles to Rust through FFI. Smithay's pure-Rust approach eliminates the entire class of problems.
Glossary¶
Glossary (linked to upstream docs)
- Smithay
- Pure-Rust library providing building blocks for Wayland compositors. Not a compositor itself -- a framework.
- calloop
- Callback-based event loop for Rust. Uses
epollon Linux. Designed for compositors: single-threaded,&mut Statepassed to every callback. - wayland-server
- Rust implementation of the Wayland server-side protocol. Part of the
wayland-rsproject maintained by the Smithay team. - GlesRenderer
- Smithay's OpenGL ES 2.0+ renderer. Hardware-accelerated compositing via EGL + GBM.
- PixmanRenderer
- Software renderer using the Pixman library. No GPU needed. Used for headless and testing scenarios.
- cosmic-comp
- System76's production Wayland compositor built on Smithay. Ships in COSMIC desktop / Pop!_OS.
- niri
- Scrollable-tiling Wayland compositor in Rust. Daily-drivable alternative to Sway with a column-based window model.
- smallvil
- Minimal reference compositor in the Smithay repo. Best starting point for learning the framework.
- anvil
- Full-featured reference compositor in the Smithay repo. Demonstrates multi-backend, XWayland, and layer-shell support.
- DRM/KMS (Direct Rendering Manager / Kernel Mode Setting)
- Linux kernel subsystem for GPU access and display pipeline control.
Smithay wraps this via the
drm-rscrate. - wlroots-rs
- Abandoned Rust bindings for wlroots. Failed due to fundamental incompatibility between C object lifecycles and Rust's ownership model.
- delegate macros
- Smithay macros (
delegate_compositor!,delegate_shm!, etc.) that generate protocol dispatch boilerplate, routing Wayland events to your trait implementations.