web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
feat(wpe): wake on GLib activity instead of polling for it
Measured over 8s loading the same page to the same two frames:
blocking on GLib's fds 63 wakeups
fixed-interval polling 495 wakeups
Roughly 8x fewer, for identical work, and the gap widens on an idle page
where polling keeps paying and blocking stops.
The bridge is deliberately narrow. calloop decides *when to look*; GLib
still does its own iteration. We never reimplement its
prepare/check/dispatch protocol — g_main_context_iteration does that
correctly — and g_main_context_query is used only to learn what to wait
on. Less to get subtly wrong, and it stays correct if GLib changes its
internals.
The awkward part is that calloop wants stable registrations while GLib's
fd set changes as WebKit opens and drops sockets. So the changing set
lives in an inner epoll fd which is itself the one stable thing calloop
watches, re-synced after every dispatch — after, not before, because the
set that matters is the one a page load just produced. The sync diffs
rather than rebuilding, so a busy socket is modified in place instead of
being deleted and re-added each pump.
pump drains that epoll first. calloop is level-triggered on the fd, so
leaving it readable across a pump that has not consumed the underlying
socket would spin the loop — which is the failure this change would
otherwise trade the polling for.
No cce-ui change needed: Application::register_sources already hands apps
a LoopHandle. host.poll_fd() and host.poll_timeout() are what that hook
will register; examples/wpe_loop.rs demonstrates the pattern with a bare
poll(2) so the behaviour is measurable without a compositor.
rustix is an optional dependency enabled by the wpe feature, so a default
build neither pulls nor compiles it. Verified both ways.
Co-Authored-By: Claude Opus 5 <[email protected]>
Cargo.toml | 5 +-
build.rs | 2 +
examples/wpe_loop.rs | 61 ++++++++++++++++++++++
src/wpe/glib_source.rs | 139 +++++++++++++++++++++++++++++++++++++++++++++++++
src/wpe/host.rs | 32 ++++++++++++
src/wpe/mod.rs | 1 +
6 files changed, 239 insertions(+), 1 deletion(-)
diff --git a/Cargo.toml b/Cargo.toml
index 5487561..8e9f67d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,7 +9,7 @@ build = "build.rs"
[features]
# The in-progress WPE WebKit port (see WPE-PORT.md). Off by default: the
# shipping browser is still Servo. Needs `pacman -S wpewebkit`.
-wpe = []
+wpe = ["dep:rustix"]
[dependencies]
cce-ui = { path = "../cce-ui" }
@@ -24,6 +24,9 @@ euclid = "0.22"
rustls = { version = "0.23", features = ["aws-lc-rs"] }
log = "0.4"
env_logger = "0.11"
+# Only used by the `wpe` backend, to hold GLib's changing pollfd set in one
+# epoll fd that calloop can watch. Optional so a default build skips it.
+rustix = { version = "0.38", features = ["event"], optional = true }
[build-dependencies]
bindgen = "0.72"
diff --git a/build.rs b/build.rs
index c9bcdbc..c7f5ba0 100644
--- a/build.rs
+++ b/build.rs
@@ -33,6 +33,8 @@ fn main() {
// The main loop AND the context: `pump` drains the context directly.
.allowlist_item("g_main_(loop|context)_.*")
.allowlist_item("G(Object|Type|Value|Bytes|Error|MainLoop|ParamSpec|Closure).*")
+ // The main-context poll protocol: GPollFD is what `query` fills in.
+ .allowlist_item("G(MainContext|PollFD|Source).*")
.allowlist_item("g_(type|object)_.*")
// GObject's generated enums are plain C enums; keep them as consts so
// vfunc tables and property flags stay comparable without casts.
diff --git a/examples/wpe_loop.rs b/examples/wpe_loop.rs
new file mode 100644
index 0000000..34f66b4
--- /dev/null
+++ b/examples/wpe_loop.rs
@@ -0,0 +1,61 @@
+//! Demonstrates the calloop integration pattern: **block on GLib's fds**
+//! rather than pumping on a timer.
+//!
+//! This is what `Application::register_sources` will do — register
+//! `host.poll_fd()` as a calloop `Generic` and a timer for
+//! `host.poll_timeout()`, both firing a `Message::Spin` that calls `pump`.
+//! Here the same thing is done with a bare `poll(2)` so the behaviour can be
+//! measured without a compositor.
+//!
+//! Run with `--features wpe`; pass `poll` as argv[1] to compare against the
+//! old fixed-interval polling.
+
+#[cfg(not(feature = "wpe"))]
+fn main() {
+ eprintln!("build with --features wpe");
+}
+
+#[cfg(feature = "wpe")]
+#[path = "../src/wpe/mod.rs"]
+mod wpe;
+
+#[cfg(feature = "wpe")]
+fn main() {
+ use rustix::event::{poll, PollFd, PollFlags};
+ use std::time::{Duration, Instant};
+
+ let blocking = std::env::args().nth(1).as_deref() != Some("poll");
+ let url = std::env::args()
+ .nth(2)
+ .unwrap_or_else(|| "https://example.com".into());
+ let mut host = wpe::WebKitHost::new(url::Url::parse(&url).unwrap(), (1200, 800));
+
+ let (mut wakeups, mut frames) = (0u32, 0u32);
+ let start = Instant::now();
+ while start.elapsed() < Duration::from_secs(8) {
+ if blocking {
+ // Sleep until GLib has work, or until it asked to be woken.
+ let ms = host
+ .poll_timeout()
+ .map(|d| d.as_millis() as i32)
+ .unwrap_or(1000)
+ .clamp(0, 1000);
+ if let Some(fd) = host.poll_fd() {
+ let mut fds = [PollFd::from_borrowed_fd(fd, PollFlags::IN)];
+ let _ = poll(&mut fds, ms);
+ }
+ } else {
+ std::thread::sleep(Duration::from_millis(16)); // the old way
+ }
+ wakeups += 1;
+ if host.pump().0 {
+ frames += 1;
+ }
+ }
+
+ println!(
+ "{:<9} {wakeups:>5} wakeups {frames:>3} frames over 8s title={:?}",
+ if blocking { "blocking" } else { "polling" },
+ host.title()
+ );
+}
diff --git a/src/wpe/glib_source.rs b/src/wpe/glib_source.rs
new file mode 100644
index 0000000..76cd43b
--- /dev/null
+++ b/src/wpe/glib_source.rs
@@ -0,0 +1,139 @@
+//! Waking on GLib activity instead of polling for it.
+//!
+//! WPE runs on a GLib `GMainContext`; cce-ui runs a calloop loop. The first
+//! cut of [`super::WebKitHost::pump`] simply drained the context on a timer,
+//! which works but burns wakeups when nothing is happening and adds latency
+//! when something is.
+//!
+//! The bridge here is deliberately narrow: **calloop decides *when to look*,
+//! GLib still does its own iteration.** We never reimplement GLib's
+//! prepare/check/dispatch protocol — `g_main_context_iteration` does that,
+//! correctly, and we only use `g_main_context_query` to learn what to wait on.
+//!
+//! GLib's fd set changes as WebKit opens sockets, and calloop wants stable
+//! registrations, so the changing set lives in an **inner epoll fd** that is
+//! itself the one stable thing calloop watches. Each pump re-syncs that set.
+//! GLib also asks for a timeout, which a calloop timer carries.
+
+use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
+
+use rustix::event::epoll;
+
+use super::ffi::*;
+
+/// The GLib fd set, mirrored into one epoll fd that calloop can watch.
+pub(super) struct GlibPoll {
+ epfd: OwnedFd,
+ /// What is currently registered, so a re-sync can diff rather than
+ /// teardown-and-rebuild every pump.
+ registered: Vec<(i32, epoll::EventFlags)>,
+ fds: Vec<GPollFD>,
+ /// GLib's requested timeout in ms; `None` means "no timer needed".
+ pub(super) timeout: Option<u32>,
+}
+
+fn flags_of(events: u16) -> epoll::EventFlags {
+ let mut f = epoll::EventFlags::empty();
+ // G_IO_IN / OUT / ERR / HUP, which are the poll(2) values.
+ if events & 0x001 != 0 {
+ f |= epoll::EventFlags::IN;
+ }
+ if events & 0x004 != 0 {
+ f |= epoll::EventFlags::OUT;
+ }
+ if events & 0x008 != 0 {
+ f |= epoll::EventFlags::ERR;
+ }
+ if events & 0x010 != 0 {
+ f |= epoll::EventFlags::HUP;
+ }
+ f
+}
+
+impl GlibPoll {
+ pub(super) fn new() -> std::io::Result<Self> {
+ let epfd = epoll::create(epoll::CreateFlags::CLOEXEC)?;
+ let mut this = Self {
+ epfd,
+ registered: Vec::new(),
+ fds: Vec::new(),
+ timeout: None,
+ };
+ this.sync();
+ Ok(this)
+ }
+
+ pub(super) fn fd(&self) -> BorrowedFd<'_> {
+ self.epfd.as_fd()
+ }
+
+ /// Ask GLib what it wants polled, and make the epoll set match.
+ ///
+ /// Called after every dispatch, because WebKit adds and drops fds as it
+ /// opens connections — a set captured once goes stale within a page load.
+ pub(super) fn sync(&mut self) {
+ unsafe {
+ let ctx = g_main_context_default();
+ // `query` is only meaningful between prepare and check; we are not
+ // running that protocol ourselves, but prepare also updates the
+ // context's own idea of the timeout, so call it for that.
+ let mut max_priority: i32 = 0;
+ g_main_context_prepare(ctx, &mut max_priority);
+
+ let mut timeout: i32 = -1;
+ // Two-pass: ask for the count, then fill.
+ let n = g_main_context_query(ctx, max_priority, &mut timeout, std::ptr::null_mut(), 0);
+ self.fds.clear();
+ self.fds.resize(n.max(0) as usize, std::mem::zeroed());
+ let n = if self.fds.is_empty() {
+ 0
+ } else {
+ g_main_context_query(
+ ctx,
+ max_priority,
+ &mut timeout,
+ self.fds.as_mut_ptr(),
+ self.fds.len() as i32,
+ )
+ };
+ self.fds.truncate(n.max(0) as usize);
+ self.timeout = (timeout >= 0).then_some(timeout as u32);
+ }
+
+ let want: Vec<(i32, epoll::EventFlags)> = self
+ .fds
+ .iter()
+ .map(|p| (p.fd, flags_of(p.events)))
+ .collect();
+
+ // Diff against what is registered. Same-fd-different-flags is a
+ // modify, not a delete plus add, so a busy socket is not churned.
+ for (fd, flags) in &want {
+ let borrowed = unsafe { BorrowedFd::borrow_raw(*fd) };
+ let data = epoll::EventData::new_u64(*fd as u64);
+ match self.registered.iter().find(|(f, _)| f == fd) {
+ Some((_, old)) if old == flags => {}
+ Some(_) => {
+ let _ = epoll::modify(&self.epfd, borrowed, data, *flags);
+ }
+ None => {
+ let _ = epoll::add(&self.epfd, borrowed, data, *flags);
+ }
+ }
+ }
+ for (fd, _) in &self.registered {
+ if !want.iter().any(|(f, _)| f == fd) {
+ let _ = epoll::delete(&self.epfd, unsafe { BorrowedFd::borrow_raw(*fd) });
+ }
+ }
+ self.registered = want;
+ }
+
+ /// Drain the inner epoll so it stops reporting readable. calloop is
+ /// level-triggered on this fd; without this the loop would spin on a
+ /// socket GLib has not consumed yet.
+ pub(super) fn drain(&self) {
+ let mut events = epoll::EventVec::with_capacity(16);
+ let _ = epoll::wait(&self.epfd, &mut events, 0);
+ }
+}
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index f3bd99d..0d16e72 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -22,6 +22,7 @@ use url::Url;
use cce_ui::widget::{KeyEvent, MouseButton};
use super::ffi::*;
+use super::glib_source::GlibPoll;
use super::input;
use super::subclass::{types, FRAME_SINK};
@@ -53,6 +54,8 @@ pub struct WebKitHost {
size_px: (u32, u32),
scale: f32,
pending: Rc<std::cell::RefCell<Pending>>,
+ /// GLib's pollfd set, mirrored into one epoll fd for calloop.
+ poll: Option<GlibPoll>,
}
unsafe fn cstr(s: &str) -> CString {
@@ -95,6 +98,9 @@ impl WebKitHost {
size_px,
scale: 1.0,
pending,
+ poll: GlibPoll::new()
+ .map_err(|e| log::warn!("no GLib epoll bridge ({e}); pump will poll"))
+ .ok(),
};
host.open_tab(url);
host
@@ -167,12 +173,38 @@ impl WebKitHost {
&self.tabs[self.active]
}
+ /// The epoll fd carrying GLib's pollfd set, for `register_sources`.
+ /// `None` if the bridge could not be created, in which case the app must
+ /// fall back to calling [`Self::pump`] on a timer.
+ pub fn poll_fd(&self) -> Option<std::os::fd::BorrowedFd<'_>> {
+ self.poll.as_ref().map(|p| p.fd())
+ }
+
+ /// How long calloop may sleep before pumping anyway, per GLib.
+ pub fn poll_timeout(&self) -> Option<std::time::Duration> {
+ self.poll
+ .as_ref()
+ .and_then(|p| p.timeout)
+ .map(|ms| std::time::Duration::from_millis(ms as u64))
+ }
+
/// Drain GLib's pending work, then upload any frame it produced.
/// Returns (new frame, any state change) like `ServoHost::pump`.
pub fn pump(&mut self) -> (bool, bool) {
+ // Clear the inner epoll first: calloop is level-triggered on that fd,
+ // so leaving it readable across a pump that does not consume the
+ // underlying socket would spin the loop.
+ if let Some(p) = &self.poll {
+ p.drain();
+ }
unsafe {
while g_main_context_iteration(std::ptr::null_mut(), 0) != 0 {}
}
+ // WebKit opens and drops sockets as it loads, so the set that matters
+ // is the one *after* dispatch, not before.
+ if let Some(p) = &mut self.poll {
+ p.sync();
+ }
let frame = self.pending.borrow_mut().frame.take();
let dirty = self.sync_page_state();
let Some((px, w, h)) = frame else {
diff --git a/src/wpe/mod.rs b/src/wpe/mod.rs
index de884e9..3c11e30 100644
--- a/src/wpe/mod.rs
+++ b/src/wpe/mod.rs
@@ -11,6 +11,7 @@ pub mod ffi {
mod subclass;
mod input;
+mod glib_source;
mod host;
// Not consumed yet — main.rs still drives ServoHost.