How Lightpanda's Notification System Handles Page Lifecycle Events
Lightpanda isolates each CDP connection in its own Notification instance that dispatches page lifecycle events through doubly-linked listener lists, guaranteeing that events like page creation, navigation, and frame loading are delivered only to the client that owns the session.
The lightpanda-io/browser project implements a dedicated notification subsystem to manage page lifecycle events across Chrome DevTools Protocol (CDP) sessions. At the core of this system is src/Notification.zig, which provides the registry and dispatch logic that connects browser internals to CDP clients. Understanding how this mechanism tracks page creation, navigation, and removal is essential for developers extending Lightpanda's automation capabilities.
Architecture of the Notification System
Per-Session Isolation
Lightpanda creates a unique Notification instance per CDP session during Session.init, ensuring complete isolation between clients. Each Notification struct maintains an EventListeners union that holds doubly-linked lists for every hard-coded lifecycle event (see lines 69-78 in src/Notification.zig). This design prevents cross-contamination where one CDP client would receive events from another client's pages.
Listener Storage and Tracking
The system employs a dual-structure design for managing subscriptions:
- Doubly-linked lists stored in the
EventListenersstruct maintain the ordered set of callbacks for each event type (e.g.,.page_created,.page_navigate). - A hash-map named
listenersmaps receiver pointer addresses toArrayListcollections ofListenerobjects.
This architecture enables O(1) bulk unregistration via unregisterAll while preserving deterministic, ordered dispatch through the linked lists.
Page Lifecycle Event Flow
Lightpanda emits specific events at distinct phases of a page's existence. The following table maps each phase to its source location and payload type:
- Page creation —
Session.createPagecallsself.notification.dispatch(.page_created, page)immediately afterPage.initreturns (Session.zig lines 48-49). Payload:*Page. - Page removal —
Session.removePageemits.page_removewith an empty struct{}before the page object is torn down (Session.zig lines 53-55). - Navigation start —
Page.navigatedispatches.page_navigatewith a*Notification.PageNavigatepayload before any HTTP request is sent (Page.zig lines 76-82). - Navigation finish — The same navigation function emits
.page_navigatedafter document injection or once the HTTP response is fully received (Page.zig lines 92-99). Payload:*Notification.PageNavigated. - Frame creation —
Page.frameCreatedtriggers.page_frame_createdwith frame metadata (Page.zig lines 1072-1074). Payload:*Notification.PageFrameCreated. - Network idle —
Page.networkIdleandPage.networkAlmostIdleemit.page_network_idleand.page_network_almost_idlerespectively for load-state tracking.
Core Implementation Details
Registering Event Listeners
The register function (lines 98-124 in src/Notification.zig) accepts a comptime EventType, a receiver pointer, and a callback function matching the event's signature:
pub fn register(self: *Notification, comptime event: EventType,
receiver: anytype, func: EventFunc(event)) !void { … }
Internally, the function allocates a Listener from a memory pool, appends it to the appropriate per-event doubly-linked list, and records the receiver-to-listener mapping in the hash-map. This allows the system to track every callback belonging to a specific CDP client.
Dispatching Events
The dispatch routine (lines 56-76) iterates the linked list corresponding to the comptime event type, casting stored function pointers back to their concrete signatures before invocation:
pub fn dispatch(self: *Notification, comptime event: EventType,
data: ArgType(event)) void { … }
The system tolerates listener errors by logging them without propagating exceptions, ensuring a single faulty handler cannot block the notification chain for other registered listeners.
Unregistering Listeners
Cleanup supports both targeted and bulk removal:
unregisterremoves a specific listener from a single event list.unregisterAll(starting at line 125) looks up the receiver in the hash-map, removes all associated nodes from their respective event lists, destroys the listener objects via the memory pool, and purges the hash-map entry.
This prevents memory leaks when a CDP connection closes and its receiver object is destroyed.
Working with the Notification API
Register a Listener for Navigation Events
The following pattern demonstrates how a CDP handler or test suite might subscribe to page navigation:
// Somewhere in a CDP handler or test code
var notifier = try Notification.init(allocator);
defer notifier.deinit();
const client = struct {
var navigateCount: usize = 0;
fn onNavigate(ptr: *anyopaque, data: *const Notification.PageNavigate) !void {
const self: *@This() = @ptrCast(@alignCast(ptr));
self.navigateCount += data.timestamp;
}
};
var testClient = client{};
try notifier.register(.page_navigate, &testClient, client.onNavigate);
The registration adds client.onNavigate to the .page_navigate list and records the receiver (&testClient) in the hashmap for later cleanup.
Dispatch a Navigation Start (Internal Usage)
As implemented in src/browser/Page.zig, the browser kernel dispatches lifecycle events directly to the session's notification instance:
session.notification.dispatch(.page_navigate, &.{
.frame_id = self._frame_id,
.req_id = req_id,
.opts = opts,
.url = self.url,
.timestamp = timestamp(.monotonic),
});
All registered page_navigate listeners are invoked immediately in the order they were registered.
Unregister All Listeners for a Receiver
When a CDP connection closes, bulk cleanup prevents dangling pointers:
notifier.unregisterAll(&testClient);
The hashmap entry is removed and all associated listener nodes are destroyed, releasing memory back to the pool.
Summary
- Per-session isolation: Lightpanda creates one
Notificationinstance per CDP session to guarantee client isolation, stored inSession.zig. - Dual data structures: Doubly-linked lists manage ordered dispatch while a hash-map enables efficient bulk unregistration via
unregisterAll. - Complete lifecycle coverage: Events span page creation, removal, navigation start/finish, frame creation, and network idle states.
- Fault tolerance: The dispatch loop logs listener errors without halting execution, ensuring robust event delivery even with faulty handlers.
- Source locations: Dispatch calls originate from
src/browser/Session.zig(creation/removal) andsrc/browser/Page.zig(navigation and frames), with consumption typically occurring insrc/cdp/cdp.zig.
Frequently Asked Questions
How does Lightpanda prevent event leaks between CDP clients?
By instantiating a unique Notification object for each Session during initialization, the browser ensures that listener registrations are scoped to a single connection. When the connection terminates, unregisterAll cleans up every listener associated with that specific receiver pointer, preventing any stray callbacks from surviving beyond the client's lifecycle.
What happens if a page lifecycle listener throws an error?
The dispatch implementation in src/Notification.zig wraps each listener invocation in an error-handling block that logs failures without propagating them. This design prevents a single faulty callback from interrupting the notification chain for other registered handlers on the same event.
Which source files are responsible for emitting page lifecycle events?
src/browser/Session.zig handles .page_created and .page_remove, while src/browser/Page.zig emits the majority of events including navigation start/finish, frame creation, and network idle states. CDP handlers in src/cdp/cdp.zig (line 438) consume these events to send corresponding protocol messages to the client.
Can I register multiple listeners for the same event type on one notification instance?
Yes. The doubly-linked list structure within the EventListeners struct supports unlimited listeners per event type. Each call to register appends a new node to the event's list, and dispatch iterates through all nodes in registration order, guaranteeing every handler receives the event.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →