web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
perf(wpe): an idle page sleeps until GLib's own deadline
The GLib bridge woke the UI thread on a calloop timer re-armed from
`poll_timeout` — the timeout recorded by the pump BEFORE the one the timer
was about to trigger — and clamped to 100 ms when GLib asked for none and
250 ms at most, because a stale long sleep could miss a timer GLib had
since moved earlier. So an idle static page pumped four times a second.
Now each pump records GLib's next deadline (`GlibPoll::deadline`, at
least 4 ms out), and the runner's idle sleep ends there
(`idle_poll_interval`), with `tick` sending the Spin once it has passed:
the runner re-reads it after every pump, so it is never stale. A 250 ms
heartbeat remains while the deadlock watch runs (it only advances when
pumped) or if the GLib fd could not be watched. Nothing scheduled sleeps
to the runner's 1 s cap.
Static local page in a scale-2 shadow, 20 s idle: UI-thread wakes 82 -> 23,
UI CPU 10 -> 0 ms. A page with setInterval(300), a setTimeout chain and a
rAF loop counts the same in 9 s on both builds (29 / 19 / ~547), and an
idle-time `open` through the instance socket navigates in the same 384 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WPE-PORT.md | 6 ++++
examples/wpe_loop.rs | 10 +++---
src/main.rs | 92 ++++++++++++++++++++++++++++++++++++--------------
src/wpe/glib_source.rs | 10 ++++++
src/wpe/host.rs | 18 ++++++----
5 files changed, 99 insertions(+), 37 deletions(-)
diff --git a/WPE-PORT.md b/WPE-PORT.md
index caa61fa..b47a0fc 100644
--- a/WPE-PORT.md
+++ b/WPE-PORT.md
@@ -258,6 +258,12 @@ undocumented-protocol ones were expensive.
4. ~~**GLib main loop vs `calloop`.**~~ Done. `register_sources` registers the epoll fd
carrying GLib's pollfd set, plus a timer from `poll_timeout`. Measured at **63
wakeups per 8s against 495** for the fixed-interval version it replaced.
+ Since 2026-10-06 there is no timer: each pump records GLib's own next deadline
+ (`glib_deadline`) and the runner's idle sleep ends there
+ (`idle_poll_interval`/`tick`), with a 250 ms heartbeat only while the deadlock
+ watch runs. The timer had read the timeout of the pump *before* the one it fired,
+ so it clamped to 100–250 ms: an idle static page woke the UI thread 82 times in
+ 20 s, now 23 (the runner's 1 s cap); JS timers, rAF and navigation unchanged.
5. **Cloudflare remains unproven**, and is no longer on the critical path — see below.
**The real cost was none of these.** It was the object graph: that `WebKitWebView`
diff --git a/examples/wpe_loop.rs b/examples/wpe_loop.rs
index aee3859..a9b6816 100644
--- a/examples/wpe_loop.rs
+++ b/examples/wpe_loop.rs
@@ -1,9 +1,9 @@
//! 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`.
+//! This is what the app does — `Application::register_sources` registers
+//! `host.poll_fd()` as a calloop `Generic` firing a `Message::Spin` that
+//! calls `pump`, and the runner's idle sleep ends at `host.glib_deadline()`.
//! Here the same thing is done with a bare `poll(2)` so the behaviour can be
//! measured without a compositor.
//!
@@ -51,8 +51,8 @@ fn main() {
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)
+ .glib_deadline()
+ .map(|t| t.saturating_duration_since(Instant::now()).as_millis() as i32)
.unwrap_or(1000)
.clamp(0, 1000);
if let Some(fd) = host.poll_fd() {
diff --git a/src/main.rs b/src/main.rs
index d66f6d3..487033a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -941,6 +941,16 @@ struct BrowserApp {
/// wakes the loop itself through its `EventLoopWaker`.
#[cfg(feature = "wpe")]
sender: calloop::channel::Sender<Message>,
+ /// Whether calloop watches the GLib fd (`register_sources`); without it,
+ /// pumps come on the heartbeat alone.
+ #[cfg(feature = "wpe")]
+ glib_fd_watched: bool,
+ /// When `update` last pumped GLib, which a heartbeat is measured from.
+ #[cfg(feature = "wpe")]
+ last_pump: std::time::Instant,
+ /// A `Spin` sent from `tick` and not yet pumped: one is enough.
+ #[cfg(feature = "wpe")]
+ spin_queued: bool,
/// App-side bundled-fonts `FontSystem` (the same set the toolkit renders
/// with) for URL-bar caret/click metrics via `shaped_cluster_offsets` —
/// `measure_text_width`'s inked-extent numbers drift off the drawn glyphs.
@@ -3982,6 +3992,25 @@ impl BrowserApp {
}
}
+impl BrowserApp {
+ /// When GLib next needs a pump nothing else will cause: its own timeout
+ /// as of the last pump, or a heartbeat while one is needed (no fd
+ /// watched, or the deadlock watch running). `None`: sleep until an
+ /// event — an idle static page used to be pumped four times a second
+ /// regardless (a fixed timer, 100 ms when GLib asked for no timeout and
+ /// never more than 250 ms, re-armed from the timeout of the pump BEFORE
+ /// the one it triggered).
+ #[cfg(feature = "wpe")]
+ fn next_glib_pump(&self) -> Option<std::time::Instant> {
+ const HEARTBEAT: std::time::Duration = std::time::Duration::from_millis(250);
+ let heartbeat = (!self.glib_fd_watched || self.host.wants_heartbeat()).then(|| self.last_pump + HEARTBEAT);
+ match (self.host.glib_deadline(), heartbeat) {
+ (Some(a), Some(b)) => Some(a.min(b)),
+ (a, b) => a.or(b),
+ }
+ }
+}
+
impl Application for BrowserApp {
type Message = Message;
@@ -4123,6 +4152,12 @@ impl Application for BrowserApp {
vi_swallowed: Vec::new(),
vi_search: None,
vi_searching: false,
+ #[cfg(feature = "wpe")]
+ glib_fd_watched: false,
+ #[cfg(feature = "wpe")]
+ last_pump: std::time::Instant::now(),
+ #[cfg(feature = "wpe")]
+ spin_queued: false,
}
}
@@ -4130,9 +4165,10 @@ impl Application for BrowserApp {
///
/// Servo pushed `Message::Spin` into calloop from its own threads; WPE
/// runs a GLib main context, so we register the epoll fd carrying its
- /// pollfd set plus a timer for the timeout GLib asks for. Both just fire
- /// `Spin`, which lands in `update` and calls `pump` — the same path the
- /// Servo waker used, so nothing downstream changes.
+ /// pollfd set; it fires `Spin`, which lands in `update` and calls `pump`
+ /// — the same path the Servo waker used. GLib's own timeouts, which no
+ /// fd reports, are kept by the runner's idle sleep instead
+ /// (`idle_poll_interval` / `tick`, from [`Self::next_glib_pump`]).
#[cfg(feature = "wpe")]
fn register_sources(&mut self, handle: &calloop::LoopHandle<'_, EngineState<Self>>) {
use calloop::{generic::Generic, Interest, Mode, PostAction};
@@ -4142,36 +4178,20 @@ impl Application for BrowserApp {
// Level-triggered: `pump` drains the epoll, so an un-consumed
// socket re-arms rather than being missed.
let source = Generic::new(fd, Interest::READ, Mode::Level);
- if let Err(e) = handle.insert_source(source, move |_, _, _| {
+ match handle.insert_source(source, move |_, _, _| {
let _ = tx.send(Message::Spin);
Ok(PostAction::Continue)
}) {
- log::warn!("could not watch the GLib fd ({e}); falling back to the timer alone");
+ Ok(_) => self.glib_fd_watched = true,
+ Err(e) => log::warn!("could not watch the GLib fd ({e}); pumping on the heartbeat alone"),
}
}
-
- // GLib also asks to be woken on its own schedule (timeouts, animation
- // frames), which no fd reports. Re-armed from `poll_timeout` each
- // fire, so an idle page settles to long sleeps instead of a fixed tick.
- let tx = self.sender.clone();
- let timer = calloop::timer::Timer::from_duration(std::time::Duration::from_millis(16));
- if let Err(e) = handle.insert_source(timer, move |_, _, state| {
- let _ = tx.send(Message::Spin);
- let next = state
- .inner
- .as_ref()
- .and_then(|app| app.host.poll_timeout())
- .unwrap_or(std::time::Duration::from_millis(100))
- .clamp(
- std::time::Duration::from_millis(4),
- std::time::Duration::from_millis(250),
- );
- calloop::timer::TimeoutAction::ToDuration(next)
- }) {
- log::warn!("could not arm the GLib timer ({e})");
- }
+ // The first pump, which sets GLib's first deadline.
+ self.spin_queued = true;
+ let _ = self.sender.send(Message::Spin);
}
+
fn settings(&self) -> WindowSettings {
WindowSettings {
title: self.title.clone().unwrap_or_else(|| "Browser".to_string()),
@@ -4186,6 +4206,11 @@ impl Application for BrowserApp {
fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
match msg {
Message::Spin => {
+ #[cfg(feature = "wpe")]
+ {
+ self.spin_queued = false;
+ self.last_pump = std::time::Instant::now();
+ }
let (new_frame, dirty) = self.host.pump();
// A page field opening or closing has to reach the frame
// that claims it, even when the page repaints nothing.
@@ -4389,6 +4414,13 @@ impl Application for BrowserApp {
}
fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+ // GLib's deadline (or the heartbeat) has come: pump. Through the
+ // sender, so the pump runs in `update` like every other.
+ #[cfg(feature = "wpe")]
+ if !self.spin_queued && self.next_glib_pump().is_some_and(|t| t <= std::time::Instant::now()) {
+ self.spin_queued = true;
+ let _ = self.sender.send(Message::Spin);
+ }
// The page's wheel glide, one frame's worth. It feeds the engine, so
// the frame it produces is what actually redraws; asking for a
// rebuild here is what keeps the loop turning until it lands.
@@ -4403,6 +4435,14 @@ impl Application for BrowserApp {
}
}
+ /// Sleep no longer than GLib's next deadline (`next_glib_pump`); the
+ /// `tick` that wakes then sends the pump.
+ #[cfg(feature = "wpe")]
+ fn idle_poll_interval(&self) -> Option<std::time::Duration> {
+ self.next_glib_pump()
+ .map(|t| t.saturating_duration_since(std::time::Instant::now()).max(std::time::Duration::from_millis(1)))
+ }
+
fn handle_focus_change(&mut self, focused: bool, needs_rebuild: &mut bool) {
// The page's own focus: without it WebKit paints no text caret.
self.host.focus(focused);
diff --git a/src/wpe/glib_source.rs b/src/wpe/glib_source.rs
index 76cd43b..7dfee43 100644
--- a/src/wpe/glib_source.rs
+++ b/src/wpe/glib_source.rs
@@ -30,6 +30,10 @@ pub(super) struct GlibPoll {
fds: Vec<GPollFD>,
/// GLib's requested timeout in ms; `None` means "no timer needed".
pub(super) timeout: Option<u32>,
+ /// When GLib next wants dispatching with no fd to say so — its own
+ /// timeout, measured from the moment it was asked (`sync`). `None`:
+ /// nothing scheduled, so only an fd (or the app) wakes it.
+ pub(super) deadline: Option<std::time::Instant>,
}
fn flags_of(events: u16) -> epoll::EventFlags {
@@ -58,6 +62,7 @@ impl GlibPoll {
registered: Vec::new(),
fds: Vec::new(),
timeout: None,
+ deadline: None,
};
this.sync();
Ok(this)
@@ -98,6 +103,11 @@ impl GlibPoll {
};
self.fds.truncate(n.max(0) as usize);
self.timeout = (timeout >= 0).then_some(timeout as u32);
+ // At least 4 ms out: a source that is ready again at once (a
+ // repeating idle) must not turn the loop into a spin.
+ self.deadline = self.timeout.map(|ms| {
+ std::time::Instant::now() + std::time::Duration::from_millis(u64::from(ms).max(4))
+ });
}
let want: Vec<(i32, epoll::EventFlags)> = self
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index a065470..b998ba0 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -1156,12 +1156,18 @@ impl WebKitHost {
rustix::io::dup(fd).ok()
}
- /// 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))
+ /// When GLib next needs a pump that no fd will announce, as of the last
+ /// pump (`GlibPoll::deadline`); `None` when nothing is scheduled.
+ pub fn glib_deadline(&self) -> Option<std::time::Instant> {
+ self.poll.as_ref().and_then(|p| p.deadline)
+ }
+
+ /// Whether pumps must keep coming even with GLib quiet: no GLib poll to
+ /// watch at all, or the deadlock watch timing a hung tab (it only
+ /// advances when pumped, and a hung tab is exactly the one producing no
+ /// GLib activity).
+ pub fn wants_heartbeat(&self) -> bool {
+ self.poll.is_none() || self.deadlock_watch.is_some()
}
/// Drain GLib's pending work, then upload any frame it produced.