How LibCore Handles File Descriptor Events and I/O Multiplexing in Ladybird

LibCore implements I/O multiplexing using the POSIX poll() system call, wrapping file descriptors in Core::Notifier objects and managing per-thread state through ThreadData structures that include a wake pipe for interrupting blocking operations.

The Ladybird browser engine relies on LibCore for cross-platform asynchronous I/O operations. In the Unix implementation found in Libraries/LibCore/EventLoopImplementationUnix.cpp, the library abstracts raw file descriptor readiness into high-level events using a centralized polling mechanism that scales across multiple threads without global lock contention.

Core Architecture: ThreadData and Notifier Abstractions

At the heart of LibCore's event system lies the ThreadData structure, defined in Libraries/LibCore/EventLoopImplementationUnix.cpp (lines 23-88). This per-thread singleton maintains the complete state for I/O multiplexing, including a Vector<pollfd> for the underlying poll() call, a HashMap<Notifier*, size_t> mapping notifiers to indices, a TimeoutSet binary heap for timer management, and a dedicated wake pipe.

The Core::Notifier class acts as a thin wrapper around a file descriptor, specifying which events—read, write, hang-up, or error—the application wishes to monitor. Each Notifier registers itself with the thread-local EventLoopManagerUnix upon creation, translating high-level NotificationType flags into the POLLIN and POLLOUT bits required by the POSIX poll() system call.

Registering File Descriptors with the Event Loop

When you instantiate a Notifier and enable it, the library executes a precise registration sequence. First, the Notifier::set_enabled method invokes Core::EventLoop::register_notifier, which delegates to EventLoopManagerUnix::register_notifier in EventLoopImplementationUnix.cpp.

The manager appends the file descriptor to the thread's poll_fds vector and updates the notifier_to_index HashMap for O(1) lookup. The helper function notification_type_to_poll_events (lines 37-45) performs the bitwise translation between LibCore's NotificationType enum and the standard pollfd event flags.

// From EventLoopImplementationUnix.cpp
thread_data.notifier_to_index.set(&notifier, thread_data.poll_fds.size());
thread_data.notifiers.append(&notifier);
thread_data.poll_fds.append({
    .fd = notifier.fd(),
    .events = notification_type_to_poll_events(notifier.type()),
    .revents = 0
});

The Wake Pipe Mechanism for Interrupting poll()

Each thread maintains a private two-ended pipe called the wake pipe. The read end occupies index 0 of the poll_fds array, ensuring it is always the first descriptor checked when poll() returns. The write end is used exclusively by the EventLoopImplementationUnix::wake() method (lines 21-30) to inject a zero-valued integer, forcing the blocking poll() call to return immediately.

void EventLoopImplementationUnix::wake()
{
    int wake_event = 0;
    Core::System::write(m_wake_pipe_write_fd, { &wake_event, sizeof(wake_event) });
}

This design allows other threads or signal handlers to wake the event loop for processing queued events or POSIX signals without waiting for the full timeout duration.

The Main Poll Loop and Event Dispatch

The EventLoopManagerUnix::wait_for_events method drives the event loop lifecycle. Before calling poll(), the manager queries thread_data.timeouts.next_timer_expiration() to compute the maximum blocking duration. If timers are pending, it calculates the remaining milliseconds; otherwise, it passes -1 to wait indefinitely.

The System::poll() call (lines 60-68) is wrapped in a retry loop to handle EINTR interruptions gracefully. Upon return, the loop processes results in strict priority order:

  1. Wake Pipe (Index 0): If poll_fds[0].revents & POLLIN is true, the code drains the pipe. A non-zero value indicates a POSIX signal requires handling, while zero indicates a standard wake request.
  2. Notifier Events (Indices ≥1): For each subsequent entry, the loop reconstructs a NotificationType from the revents field (POLLIN, POLLOUT, POLLHUP, POLLERR). If the detected events intersect with the notifier's registered interests, the system posts a NotifierActivation event to the ThreadEventQueue (lines 104-128).
  3. Timer Firing: Finally, the loop invokes thread_data.timeouts.fire_expired(time_after_poll) (lines 130-133) to dispatch Timer events to their associated EventReceiver objects.

Unregistering Notifiers and Resource Cleanup

When a Notifier is destroyed or explicitly disabled, EventLoopManagerUnix::unregister_notifier (lines 160-182) performs an efficient removal. The method locates the notifier's index in the poll_fds vector, removes the entry by swapping the last element into the vacant slot, and updates the notifier_to_index map to reflect the new indices. This swap-and-pop strategy maintains a compact array, ensuring the poll() call iterates only over active descriptors.

Practical Code Examples

Monitoring a Socket for Readability

// Assume `socket_fd` is a non-blocking TCP socket.
Core::Notifier socket_notifier(socket_fd, Core::Notifier::Type::Read);
socket_notifier.on_activation = [&] {
    // This runs inside the event loop when data is available.
    char buffer[1024];
    ssize_t n = read(socket_notifier.fd(), buffer, sizeof(buffer));
    if (n > 0) {
        // Process incoming data …
    }
};

The notifier registers itself automatically via the constructor; the event loop invokes the lambda whenever poll() reports POLLIN on socket_fd.

Waking the Loop from Another Thread

// In thread A:
Core::EventLoop::current().wake();

Calling wake() writes to the wake pipe, causing the poll() in thread A's loop to return immediately and process pending events.

Setting a One-Shot Timer

auto timer_id = Core::EventLoop::register_timer(
    Core::EventReceiver::current(),
    500,               // 500 ms
    false              // do not reload
);

When the timer expires, the associated EventReceiver receives a Timer event via the same ThreadEventQueue used for notifier activations.

Summary

  • LibCore uses POSIX poll() for I/O multiplexing, implemented in Libraries/LibCore/EventLoopImplementationUnix.cpp.
  • Each thread maintains isolated state via ThreadData, eliminating global lock contention through per-thread event loops.
  • Core::Notifier objects wrap file descriptors and translate POLLIN/POLLOUT events into activation callbacks posted to the ThreadEventQueue.
  • A dedicated wake pipe at index 0 of the poll array allows immediate interruption of blocking poll() calls via the wake() method.
  • The TimeoutSet binary heap efficiently schedules timer events alongside file descriptor monitoring, calculating optimal timeout values before each poll cycle.

Frequently Asked Questions

How does LibCore handle file descriptor events and I/O multiplexing?

LibCore implements I/O multiplexing using the POSIX poll() system call, managing per-thread file descriptor state through the ThreadData structure. The system wraps descriptors in Core::Notifier objects that map POLLIN and POLLOUT events to user-defined activation callbacks, processed in the wait_for_events method of EventLoopManagerUnix.

What is the purpose of the wake pipe in LibCore's event loop?

The wake pipe is a per-thread pipe where the read end occupies index 0 of the poll_fds array, allowing the EventLoopImplementationUnix::wake() method to interrupt blocking poll() calls by writing a zero-valued integer to the write end. This mechanism enables immediate processing of queued events or POSIX signals without waiting for the full timeout period.

How does LibCore manage timers alongside file descriptor polling?

LibCore uses a TimeoutSet class implementing a binary heap priority queue to track timer expirations, calculating the next timeout before each poll() call via next_timer_expiration(). When poll() returns, the system fires expired timers by posting Timer events to their associated EventReceiver objects before processing file descriptor events.

How are Notifier objects unregistered from the event loop?

When a Notifier is disabled or destroyed, EventLoopManagerUnix::unregister_notifier removes the file descriptor from the poll_fds vector and updates the notifier_to_index map, swapping the last element into the removed position to maintain a compact array for efficient polling.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →