git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commit31b10265896bcc1194e09628470528e2facfdb06
parent19171ae62a
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 14:59
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.