Skip to content

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:

  1. Display<Self> -- The Wayland display server object, generic over your state type. Owns the socket, client list, and protocol global registry.

  2. CompositorHandler -- Trait you implement to react to wl_compositor and wl_surface events. The commit method fires whenever a client submits a new buffer.

  3. XdgShellHandler -- Handles desktop window lifecycle. new_toplevel is called when a client creates a window. You configure its initial size and send the configure event.

  4. 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.

  5. EventLoop::try_new() -- Creates the calloop event loop backed by epoll. Generic over MiniComp so every callback gets &mut MiniComp.

  6. add_socket_auto() -- Creates a Unix socket in $XDG_RUNTIME_DIR with an auto-generated name (e.g., wayland-1). Clients connect here.

  7. 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 (or ext-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 swayipc crate 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 epoll on Linux. Designed for compositors: single-threaded, &mut State passed to every callback.
wayland-server
Rust implementation of the Wayland server-side protocol. Part of the wayland-rs project 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-rs crate.
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.