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:
- Input processing thread receives events from libinput and pushes them to queues
- Render loop (or plugin thread) pops events and processes them
- 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:
Ring Buffer Structure
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:
tail.load(relaxed): Producer-only read, no synchronization neededhead.load(acquire): Synchronize with consumer’s release storetail.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:
head.load(relaxed): Consumer-only read, no synchronization neededtail.load(acquire): Synchronize with producer’s release storehead.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:
| Operation | Memory Order | Purpose |
|---|---|---|
tail.load() (producer) | relaxed | Producer owns tail, no sync needed |
head.load() (push check) | acquire | Read consumer’s position with happens-before |
tail.store() (push end) | release | Publish event to consumer |
head.load() (consumer) | relaxed | Consumer owns head, no sync needed |
tail.load() (pop check) | acquire | Read producer’s position with happens-before |
head.store() (pop end) | release | Publish free slot to producer |
Happens-Before Guarantee:
- Producer’s
tail.store(release)happens-before consumer’stail.load(acquire) - Consumer’s
head.store(release)happens-before producer’shead.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
Overflow Handling
If the queue is full (producer can’t keep up with consumer):
push()returnsfalse- InputManager drops the event (does not retry)
- 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()andpop()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
EventQueueandKeyboardEventQueueinstances - Pushes events in
process_events()when libinput dispatches - Exposes queues via
get_event_queue()andget_keyboard_event_queue()
CoreAPI:
get_input_state()returns queue pointers to pluginsmouse_event_pop()andkeyboard_event_pop()wrap the queue’spop()method- Queues passed as
void*to hide implementation from plugins
CoreAPIFuncs Implementation:
- Casts
void*back toEventQueue*orKeyboardEventQueue* - Calls the appropriate
pop()method - Returns
trueif event was popped,falseif empty