Skip to content
Waxed Display Server
← Back to Docs

Event Queues

Event Queues

Overview

Waxed uses lock-free, single-producer single-consumer (SPSC) ring buffers to pass input events from the core to plugins. This design ensures zero contention and deterministic latency for real-time input processing.

The event queues are critical for responsive user interaction - mouse movements and keyboard presses must be delivered to plugins with minimal delay, without blocking the render loop or the input processing thread.

Why Lock-Free Matters

Real-time input systems have strict latency requirements:

  1. Input processing thread receives events from libinput and pushes them to queues
  2. Render loop (or plugin thread) pops events and processes them
  3. Display refresh must reflect input within ~16ms (60Hz) to feel responsive

A mutex-based queue would introduce:

  • Lock contention between threads
  • Priority inversion if the render loop holds the lock
  • Cache line bouncing from lock/unlock operations
  • Unpredictable latency from scheduler decisions

Lock-free SPSC queues eliminate these issues by using atomic operations with carefully chosen memory ordering.

SPSC Pattern

The Single Producer Single Consumer pattern is key to the lock-free design:

Plugin Thread (Consumer)Queue (atomic head/tail)Input Thread (Producer)Plugin Thread (Consumer)Queue (atomic head/tail)Input Thread (Producer)Single Producer - Only InputManager.process_events() pushesSingle Consumer - Only plugin pops eventsRelaxed load tailAcquire load head (check full)Write buffer[tail]Release store tailRelaxed load headAcquire load tail (check empty)Read buffer[head]Release store headAllows relaxed loads for producer-only stateand acquire/release semantics for shared boundaryevent_queue_.push(event)event_queue_.pop(event)

Ring Buffer Structure

EVENT_QUEUE_SIZE = 64

Slot 0

Slot 1

Slot 2

Slot 3

... Slot 63 ...

head (next to read)

tail (next to write)

Empty condition:

head == tail

Full condition:

(tail + 1) % SIZE == head

Note: The queue always keeps one slot empty to distinguish between full and empty states without additional metadata.

EventQueue Structure

static constexpr size_t EVENT_QUEUE_SIZE = 64;

struct EventQueue {
    std::array<MouseEvent, EVENT_QUEUE_SIZE> buffer;
    std::atomic<size_t> head{0};
    std::atomic<size_t> tail{0};

    auto push(const MouseEvent& event) -> bool;
    auto pop(MouseEvent& event) -> bool;
    auto get_ptr() -> void*;
};

Buffer: Fixed-size array holding MouseEvent structures. Head: Index of the next event to pop (consumer position). Tail: Index of the next slot to write (producer position).

KeyboardEventQueue Structure

Identical structure to EventQueue, but holds KeyboardEvent structures:

struct KeyboardEventQueue {
    std::array<KeyboardEvent, EVENT_QUEUE_SIZE> buffer;
    std::atomic<size_t> head{0};
    std::atomic<size_t> tail{0};

    auto push(const KeyboardEvent& event) -> bool;
    auto pop(KeyboardEvent& event) -> bool;
    auto get_ptr() -> void*;
};

Queue Size

The queue size is fixed at 64 events for both mouse and keyboard queues:

static constexpr size_t EVENT_QUEUE_SIZE = 64;

This size was chosen to:

  • Accommodate bursts: High-DPI mice can report 1000+ events/second
  • Prevent overflow: 64 events @ 1000 Hz = 64ms buffer before overflow
  • Fit in cache: 64 * 32 bytes = 2KB (fits in L1 cache on most CPUs)
  • Power of 2: Allows modulo optimization (compiler optimization)

push() Operation

The producer (InputManager) calls push() to add events:

auto push(const MouseEvent& event) -> bool {
    size_t current_tail = tail.load(std::memory_order_relaxed);
    size_t next_tail = (current_tail + 1) % EVENT_QUEUE_SIZE;

    // Check if queue is full
    if (next_tail == head.load(std::memory_order_acquire)) {
        return false; // Queue full
    }

    buffer[current_tail] = event;
    tail.store(next_tail, std::memory_order_release);
    return true;
}

Memory Ordering:

  1. tail.load(relaxed): Producer-only read, no synchronization needed
  2. head.load(acquire): Synchronize with consumer’s release store
  3. tail.store(release): Make event visible to consumer

Return Value: true if event was queued, false if queue full.

pop() Operation

The consumer (plugin) calls pop() via CoreAPIFuncs.mouse_event_pop():

auto pop(MouseEvent& event) -> bool {
    size_t current_head = head.load(std::memory_order_relaxed);

    // Check if queue is empty
    if (current_head == tail.load(std::memory_order_acquire)) {
        return false; // Queue empty
    }

    event = buffer[current_head];
    head.store((current_head + 1) % EVENT_QUEUE_SIZE, std::memory_order_release);
    return true;
}

Memory Ordering:

  1. head.load(relaxed): Consumer-only read, no synchronization needed
  2. tail.load(acquire): Synchronize with producer’s release store
  3. head.store(release): Free slot for producer to reuse

Return Value: true if event was popped, false if queue empty.

Memory Ordering Semantics

The queues use C++11 atomics with specific memory orders:

OperationMemory OrderPurpose
tail.load() (producer)relaxedProducer owns tail, no sync needed
head.load() (push check)acquireRead consumer’s position with happens-before
tail.store() (push end)releasePublish event to consumer
head.load() (consumer)relaxedConsumer owns head, no sync needed
tail.load() (pop check)acquireRead producer’s position with happens-before
head.store() (pop end)releasePublish free slot to producer

Happens-Before Guarantee:

  • Producer’s tail.store(release) happens-before consumer’s tail.load(acquire)
  • Consumer’s head.store(release) happens-before producer’s head.load(acquire)
  • Event write happens-before event read

This ensures events are never read before they’re fully written, and free slots are properly visible to the producer.

MouseEvent Structure

struct MouseEvent {
    enum Type : uint8_t {
        ButtonPress,      // Mouse button pressed
        ButtonRelease,    // Mouse button released
        WheelScroll,      // Mouse wheel scrolled
        DoubleClick       // Double-click detected
    };

    Type type;
    uint8_t button;       // Button code (BTN_LEFT, BTN_RIGHT, etc.)
    int16_t delta;        // Scroll delta (for WheelScroll)
    int32_t x, y;         // Position at event time
    uint64_t timestamp;   // Event timestamp (monotonic nanoseconds)
};

Type Field: Distinguishes between button events and scroll events. Button: Linux input code (e.g., BTN_LEFT = 0x110 truncated to 0x10). Delta: Scroll wheel units (positive = up/away, negative = down/toward). Position: Global cursor coordinates at event time. Timestamp: CLOCK_MONOTONIC nanoseconds for event ordering.

KeyboardEvent Structure

struct KeyboardEvent {
    enum Type : uint8_t {
        KeyPress,
        KeyRelease
    };

    Type type;
    uint32_t key_code;     // Linux KEY_* code
    uint32_t modifiers;    // Modifier bitmask
    uint64_t timestamp;    // Event timestamp (monotonic nanoseconds)
};

Type Field: Key press or release. Key Code: Linux kernel KEY_* defines (e.g., KEY_ESC = 1, KEY_Q = 16). Modifiers: Bitmask of active modifier keys (see below). Timestamp: CLOCK_MONOTONIC nanoseconds.

Modifier Flags

Modifiers are bitmask flags in KeyboardEvent.modifiers:

constexpr uint32_t KEYBOARD_MOD_CTRL  = 1 << 0;  // Bit 0
constexpr uint32_t KEYBOARD_MOD_ALT   = 1 << 1;  // Bit 1
constexpr uint32_t KEYBOARD_MOD_SHIFT = 1 << 2;  // Bit 2

Usage Example:

void handle_key_event(const KeyboardEvent& event) {
    if (event.modifiers & KEYBOARD_MOD_CTRL) {
        // Ctrl is held
    }
    if (event.modifiers & KEYBOARD_MOD_ALT) {
        // Alt is held
    }
    if (event.modifiers & KEYBOARD_MOD_SHIFT) {
        // Shift is held
    }
}

Note: The core tracks modifier state internally and sets the modifier flags on each keyboard event.

How Plugins Consume Events

Plugins receive event queue pointers via InputState:

InputState state;
core->funcs->get_input_state(core, &state);

// state.mouse_event_queue is now a void* pointing to EventQueue
// state.keyboard_event_queue is now a void* pointing to KeyboardEventQueue

Plugins then pop events in their render/update loop:

void update_input(CoreAPI* core) {
    InputState state;
    core->funcs->get_input_state(core, &state);

    MouseEvent mouse_event;
    while (core->funcs->mouse_event_pop(state.mouse_event_queue, &mouse_event)) {
        switch (mouse_event.type) {
            case MouseEvent::ButtonPress:
                handle_click(mouse_event.x, mouse_event.y, mouse_event.button);
                break;
            case MouseEvent::WheelScroll:
                handle_scroll(mouse_event.delta);
                break;
            // ...
        }
    }

    KeyboardEvent key_event;
    while (core->funcs->keyboard_event_pop(state.keyboard_event_queue, &key_event)) {
        if (key_event.type == KeyboardEvent::KeyPress) {
            handle_key_press(key_event.key_code, key_event.modifiers);
        }
    }
}

Non-Blocking: pop() returns false immediately when the queue is empty, so plugins can poll without blocking.

Producer-Consumer Flow Diagram

Render/Plugin ThreadEvent QueueInput ThreadRender/Plugin ThreadEvent QueueInput Threadprocess_events() calledMemory operations:- Relaxed load tail- Acquire load head (check full)- Write buffer[tail]- Release store tail (publish)Event now in queueMemory operations:- Relaxed load head- Acquire load tail (check empty)- Read buffer[head]- Release store head (consume)Continue pushing more eventslibinput event received1event_queue_.push(event)2mouse_event_pop()3Plugin processes event4

Overflow Handling

If the queue is full (producer can’t keep up with consumer):

  1. push() returns false
  2. InputManager drops the event (does not retry)
  3. Event is lost - plugin will not receive it

This is intentional design:

  • Real-time systems must prioritize latency over correctness
  • Waiting for space would block input processing
  • 64-event buffer is large enough for normal burst scenarios

Plugins can detect overflow patterns by checking event timestamps for gaps.

Thread Safety Guarantees

Safe Operations:

  • InputManager thread: push() only
  • Plugin thread: pop() only
  • Multiple plugin threads: NOT safe (only one consumer)

Unsafe:

  • Calling push() from multiple threads
  • Calling pop() from multiple threads
  • Calling push() and pop() from the same thread

Why: The relaxed loads assume single access patterns. Multi-producer or multi-consumer would require CAS loops or different algorithms.

Integration Points

InputManager:

  • Owns EventQueue and KeyboardEventQueue instances
  • Pushes events in process_events() when libinput dispatches
  • Exposes queues via get_event_queue() and get_keyboard_event_queue()

CoreAPI:

  • get_input_state() returns queue pointers to plugins
  • mouse_event_pop() and keyboard_event_pop() wrap the queue’s pop() method
  • Queues passed as void* to hide implementation from plugins

CoreAPIFuncs Implementation:

  • Casts void* back to EventQueue* or KeyboardEventQueue*
  • Calls the appropriate pop() method
  • Returns true if event was popped, false if empty