Skip to main content
LibCore provides the EventLoop system for handling application tasks concurrently on a single thread. This event-driven architecture is fundamental to how Ladybird processes user input, network events, timers, and other asynchronous operations.
This document describes LibCore’s event loop system, not web event loops (which are a separate LibWeb concept).

What is an event loop?

The event loop is a design pattern found in many systems. It can be briefly summarized as:
An in-process loop, running continuously, which processes incoming events from signals, notifiers, file watchers, timers, etc. by running associated callbacks.
The event loop relies on callbacks eventually returning control to it, enabling other events to be processed. In this way, it operates like a cooperative multitasking scheduler in userspace.
LibCore’s event loop system is primarily used with graphical applications. LibGUI and IPC are deeply integrated into it. Command-line applications may not need to interact with the event loop.

How it works

The execution cycle

When an event loop runs, it sits in exec() repeatedly pump()ing the loop:
  1. exec() waits for an event to happen
  2. Runs all callbacks associated with that event
  3. Goes back to sleep and waits for more events

Low-level mechanics

Here’s what happens under the hood:
1

Enter event loop

On exec(), the event loop enters the event loop stack. Then, the loop is pumped repeatedly.
2

Wait for events

Each pump() first puts the event loop to sleep with wait_for_event(), then handles all events.
3

System-level waiting

wait_for_event() uses select(2) to wait until one of the prepared file descriptors becomes readable or writable, or until a timeout expires.
4

Handle events

After select(2) returns, signals are dispatched to handlers. Other new events (expired timers, file notifications) are added to the event queue.
5

Exit handling

If the event loop’s exit is requested, it returns pending events to the queue for the next-lower event loop to handle.

The select(2) system call

The event loop uses select(2) to efficiently wait for events:
// Pseudocode representation
select(file_descriptors, &readable, &writable, &timeout);
File descriptors monitored:
  • File notifiers: Registered by the application
  • Wake pipe: Used for signal handling and cross-thread wakeups
Timeout behavior:
  • Infinite: When there are no time-based wake conditions
  • Minimum timer: When timers are registered, timeout is the shortest
  • Zero: When events are already available (immediate return)
While an event loop has nothing to do, the kernel keeps the thread asleep. This is why most GUI applications appear in “Selecting” state in SystemMonitor.

The wake pipe mechanism

The wake pipe serves two critical purposes:
  1. Signal handling: When a POSIX signal is received that the event loop has a handler for, handle_signal() writes the handler number to the pipe
  2. Cross-thread waking: The wake() function writes 0 (not a valid signal number) to wake the event loop from other threads

Thread-local state

All event loop state is thread-local:
  • Notifiers
  • Timers
  • Event loop stack
  • Event queues
The convenience getter EventLoop::current() returns the topmost event loop on the calling thread.
Each thread has its own event loop stack. Never access or manipulate event loops from other threads.

Event loop stack

The event loop stack enables nested event loops, primarily used for modal GUI windows.

How the stack works

  • Each window can add another event loop onto the stack
  • When an event loop exits, remaining events are added to the lower event loop’s queue
  • This allows GUI::Window and similar systems to create event loops without interfering with existing ones
// Simplified example
EventLoop main_loop;
main_loop.exec();  // Main event loop

// Later, a modal dialog creates a nested loop
EventLoop dialog_loop;
dialog_loop.exec();  // Nested loop on top of stack

// When dialog closes, control returns to main_loop

Event types

The event loop handles several kinds of events:

POSIX signals

Register signal handlers with EventLoop::register_signal():
EventLoop::register_signal(SIGINT, [](int) {
    // Signal handler runs as a normal event
    // No POSIX signal handler weirdness!
});
The event loop registers the specified POSIX signal with the kernel, and the handler runs as a normal event without the complexity of traditional signal handlers.

Posted events

EventLoop::post_event() fires an event targeting a specific Core::EventReceiver:
EventLoop::post_event(target, make<CustomEvent>());

Deferred callbacks

EventLoop::deferred_invoke() calls an arbitrary callback on the next iteration:
EventLoop::deferred_invoke([this] {
    // Runs on next event loop iteration
    update_ui();
});

Timer events

Create timers that fire after a timeout, possibly repeatedly:
  • EventLoop::register_timer: Low-level timer registration
  • Object::start_timer(): Object-based timer API
  • Core::Timer: User-friendly utility class
auto timer = Core::Timer::create();
timer->set_interval(1000);  // 1 second
timer->on_timeout = [] {
    // Called every second
};
timer->start();

File notifications

Core::Notifier handles file descriptor readiness:
auto notifier = Core::Notifier::create(
    socket_fd,
    Core::Notifier::Type::Read
);
notifier->on_activation = [socket_fd] {
    // Socket is readable
    auto data = read_from_socket(socket_fd);
};
All events are registered and fire on the event loop of the thread that created them.

Best practices

Do’s

Create in main()

Create your main event loop in main() and pass it to classes that need it

Know your thread

Only access the event loop when you know which thread you’re on

Signal across threads

Use event signaling to communicate with event loops on other threads

Handle events anywhere

It’s fine to handle events from any thread

Don’ts

Violating these rules can cause crashes, undefined behavior, or initialization order fiascos.
  • DO NOT store an event loop in a global variable
    • The event loop relies on global variable initialization
    • UBSAN will catch initialization order fiascos
  • DO NOT access the current event loop if you don’t know your thread
    • The program will crash if there’s no event loop on your thread
  • DO NOT pump() or exec() the event loop of another thread
    • Sleeping and waking relies on thread-local variables
    • Only signal events to other threads’ event loops

Example usage

Here’s a typical pattern for GUI applications:
int main(int argc, char** argv)
{
    auto app = GUI::Application::create(argc, argv);
    
    // Create main window
    auto window = GUI::Window::construct();
    
    // Set up UI...
    
    window->show();
    
    // GUI::Application::exec() runs an event loop
    return app->exec();
}

Return value pattern

The pattern return app->exec() is common in GUI applications because:
  • exec() returns a value comparable to process return codes
  • The event loop processes events until the application exits
  • The return value propagates through to the process exit code

Integration with LibGUI

LibGUI is deeply integrated with the event loop:
  • GUI::Application: Creates and manages the main event loop
  • GUI::Window: Can create nested event loops for modal dialogs
  • Widget events: Mouse, keyboard, and paint events delivered via event loop
  • Timers: Used for animations, periodic updates, and timeouts

Integration with IPC

Ladybird’s IPC system relies heavily on the event loop:
  • IPC connections register file notifiers for socket readiness
  • Messages are dispatched as events through the event loop
  • Asynchronous IPC responses delivered via deferred callbacks
The event loop’s ability to handle multiple file descriptors simultaneously enables efficient IPC between Ladybird’s multiple processes.

Architecture overview

Overview of Ladybird’s architecture

Process architecture

Multi-process design and IPC

Build docs developers (and LLMs) love