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

commit07005acec55ec49e766aeedcdafe074da1462f60
parentb59162d3d4
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 22:41
fix(wpe): pace the engine to the chrome's draws

render_buffer said wpe_view_buffer_rendered the moment a frame arrived, so
WebKit believed every frame was on screen and kept compositing at its own
rate whether or not the window was drawn. With the display off (the chrome
drawing ~4 times a second on the runner's starvation fallback), Muji in the
active tab had the engine compositing 90-105 frames a second, 4 of them
read, the browser and its web processes at 37-40% of a core.

`rendered` is now said when pump reads a frame, and a frame is read only
once the chrome has drawn the one before it. A finished frame waiting
behind an undrawn one is unacknowledged, so the engine composites nothing
further — one frame on screen, one waiting, as WebKit's own Wayland
backend gets from the frame callback. display_list wakes a pump for a
waiting frame, since a held-up page makes no noise that would. Buffers
superseded or released unread are answered with both halves.

Measured in a scale-2 shadow, Muji active: display off 13% (was 37-40%),
engine 4 frames/s (was ~100); visible 45-48% at 50 frames/s shown (was
58-63% at 46). Saying `rendered` at the draw instead halved visible
animation to 30 fps, which is why it is said at the read.

The examples call frame_drawn after pump, as wpe_crash already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                | 71 ++++++++++++++++++++++++++++--------------------
 examples/wpe_autofill.rs |  2 +-
 examples/wpe_ctx.rs      |  2 +-
 examples/wpe_dark.rs     |  2 +-
 examples/wpe_focus.rs    |  2 +-
 examples/wpe_host.rs     |  1 +
 examples/wpe_input.rs    |  2 +-
 examples/wpe_loop.rs     |  1 +
 examples/wpe_middle.rs   |  2 +-
 examples/wpe_options.rs  |  2 +-
 examples/wpe_paste.rs    |  2 +-
 examples/wpe_select.rs   |  2 +-
 examples/wpe_tabs.rs     |  2 +-
 src/main.rs              |  8 ++++--
 src/wpe/host.rs          | 51 ++++++++++++++++++++++++++++------
 src/wpe/subclass.rs      | 30 +++++++++++---------
 16 files changed, 119 insertions(+), 63 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 2bc1c01..d6f712b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -188,35 +188,48 @@ animations stop, as its `requestAnimationFrame` already does. Its timers still
 run — WebKit has no hidden-page timer throttling here — which is most of what a
 heavy page costs in the background.
 
-### The readback is paced to draws
-
-`render_buffer` says the two halves of the buffer protocol at different times,
-and that is the pacing. `wpe_view_buffer_rendered` — *displayed* — is said at
-once, so the engine's own frame pacing never waits on us.
-`wpe_view_buffer_released` — *the memory is yours again* — waits until the
-pixels have been copied out, which happens in `pump`, not in the callback.
-(Saying neither is what stalls the engine after exactly one frame; that is what
-the old comment here warned about.)
-
-Holding the buffer buys two things. A frame superseded before anyone read it is
-handed back **unread**, so several frames dispatched inside one pump's drain
-cost one copy rather than N. And `pending_draw` gates the readback on the
-chrome having actually drawn (`frame_drawn`, called from `display_list`): while
-nothing has drawn the last frame, the next one is left held rather than copied
-over a picture nobody saw.
-
-That second half is where the win is, and it is not the one first expected.
-Measured in a shadow against a page animating at 63 fps: **visible and drawing,
-62 of 63 frames are read — the pacing changes nothing**, because `pump` is what
-dispatches the engine's frames and it dispatches them promptly, so the engine
-never gets ahead. **Minimized, 4 of 64 are read** — 60 handed back unread, a
-page that used to cost its full window size sixty times a second while nobody
-was looking. Restoring recovers the full rate within a second, with live
-content.
-
-`CCE_BROWSER_FRAME_DEBUG=1` logs the two counts once a second; the gap between
-them is invisible from the outside, since a browser that skips nine frames in
-ten looks exactly like one that copies all ten.
+### The engine is paced to draws
+
+Neither half of the buffer protocol is said in `render_buffer`.
+`wpe_view_buffer_released` — *the memory is yours again* — is said once the
+pixels are copied out, in `pump`. `wpe_view_buffer_rendered` — *displayed* —
+is the engine's pacing: until it is said, WebKit composites nothing new for
+that view, and its main thread runs no rendering update (rAF, style, layout,
+paint all wait on the composite). WebKit's own Wayland backend says it from
+the compositor's frame callback. Here it is said **when the frame is read**,
+and a frame is read only once the chrome has drawn the one before it
+(`pending_draw`, cleared by `frame_drawn` from `display_list`). So the page
+runs at the rate the window is drawn: one frame on screen, one finished and
+waiting, nothing composited beyond that. (Saying neither stalls the engine
+after exactly one frame.)
+
+Points that are choices:
+
+- **Not at the draw.** Saying `rendered` only once the frame was drawn left
+  the engine no overlap with the chrome, and Muji's banner, animating at
+  60 fps in a visible window, dropped to 30.
+- **A frame held behind an undrawn one has to be fetched.** Its view is waiting
+  on `rendered`, so it makes no noise that would turn the loop; `frame_drawn`
+  returns whether one is waiting, and `display_list` sends `Spin` for it.
+- **Every buffer gets both halves.** One superseded unread (another view's
+  frame arrived), released with `release_held`, or unreadable is answered
+  `rendered` too on the spot — a view never answered never composites again.
+- **Headless callers must call `frame_drawn`** after `pump` (the examples do),
+  or the page stops after its second frame.
+
+Until 2026-10-05 `rendered` was said at once in `render_buffer`, and only the
+*readback* was paced. Measured in a scale-2 shadow with Muji in the active tab:
+display off, the engine still composited 90-105 frames a second of which 4
+were read, the browser and its web processes using 37-40% of a core; paced,
+it composites the 4 it shows and uses 13%. Visible, 50 frames/s shown at
+45-48% against 46 at 58-63% before. With a plain tall page and the display
+off, the overlay scrollbar's fade now costs 4 frames/s instead of 60 for its
+~5 seconds. (The fade is WebKit's: timer-driven, 2s delay then 3s, and it
+ends on its own drawn or not. A report of it looping forever while undrawn
+did not reproduce.)
+
+`CCE_BROWSER_FRAME_DEBUG=1` logs frames produced against frames read once a
+second; paced, the two match, and the rate is the draw rate.
 
 ## Tabs
 
diff --git a/examples/wpe_autofill.rs b/examples/wpe_autofill.rs
index fffce11..d5af75b 100644
--- a/examples/wpe_autofill.rs
+++ b/examples/wpe_autofill.rs
@@ -117,7 +117,7 @@ fn main() {
     host.focus(true);
     let settle = |h: &mut wpe::WebKitHost, n: u32| {
         for _ in 0..n {
-            h.pump();
+            h.pump(); h.frame_drawn();
             std::thread::sleep(std::time::Duration::from_millis(50));
         }
     };
diff --git a/examples/wpe_ctx.rs b/examples/wpe_ctx.rs
index e097217..49541b3 100644
--- a/examples/wpe_ctx.rs
+++ b/examples/wpe_ctx.rs
@@ -29,7 +29,7 @@ fn main() {
     let url = "http://127.0.0.1:8795/ctx.html";
     let mut host = wpe::WebKitHost::new(url::Url::parse(url).unwrap(), (1200, 800));
     let settle = |h: &mut wpe::WebKitHost, n: u32| {
-        for _ in 0..n { h.pump(); std::thread::sleep(std::time::Duration::from_millis(50)); }
+        for _ in 0..n { h.pump(); h.frame_drawn(); std::thread::sleep(std::time::Duration::from_millis(50)); }
     };
     settle(&mut host, 30);
     println!("loaded: {:?}", host.title());
diff --git a/examples/wpe_dark.rs b/examples/wpe_dark.rs
index 38dede8..b5f5c22 100644
--- a/examples/wpe_dark.rs
+++ b/examples/wpe_dark.rs
@@ -29,7 +29,7 @@ fn main() {
     let url = "data:text/html,<body style='background:%23ffffff'><h1 style='color:%23000'>hello</h1></body>";
     let mut host = wpe::WebKitHost::new(url::Url::parse(url).unwrap(), (400, 300));
     let settle = |h: &mut wpe::WebKitHost, n: u32| {
-        for _ in 0..n { h.pump(); std::thread::sleep(std::time::Duration::from_millis(50)); }
+        for _ in 0..n { h.pump(); h.frame_drawn(); std::thread::sleep(std::time::Duration::from_millis(50)); }
     };
 
     settle(&mut host, 30);
diff --git a/examples/wpe_focus.rs b/examples/wpe_focus.rs
index 76df616..f9c0282 100644
--- a/examples/wpe_focus.rs
+++ b/examples/wpe_focus.rs
@@ -42,7 +42,7 @@ fn main() {
     let mut host = wpe::WebKitHost::new(url::Url::parse(page).unwrap(), (400, 300));
     let settle = |h: &mut wpe::WebKitHost, n: u32| {
         for _ in 0..n {
-            h.pump();
+            h.pump(); h.frame_drawn();
             std::thread::sleep(std::time::Duration::from_millis(50));
         }
     };
diff --git a/examples/wpe_host.rs b/examples/wpe_host.rs
index 1fdefe3..7a4cd62 100644
--- a/examples/wpe_host.rs
+++ b/examples/wpe_host.rs
@@ -49,6 +49,7 @@ fn main() {
             host.back();
         }
         let (new_frame, dirty) = host.pump();
+        host.frame_drawn();
         if new_frame {
             frames += 1;
             println!(
diff --git a/examples/wpe_input.rs b/examples/wpe_input.rs
index b668f85..44e53b7 100644
--- a/examples/wpe_input.rs
+++ b/examples/wpe_input.rs
@@ -41,7 +41,7 @@ fn main() {
 
     let settle = |h: &mut wpe::WebKitHost, n: u32| {
         for _ in 0..n {
-            h.pump();
+            h.pump(); h.frame_drawn();
             std::thread::sleep(std::time::Duration::from_millis(50));
         }
     };
diff --git a/examples/wpe_loop.rs b/examples/wpe_loop.rs
index 9f8dafb..aee3859 100644
--- a/examples/wpe_loop.rs
+++ b/examples/wpe_loop.rs
@@ -66,6 +66,7 @@ fn main() {
         if host.pump().0 {
             frames += 1;
         }
+        host.frame_drawn();
     }
 
     println!(
diff --git a/examples/wpe_middle.rs b/examples/wpe_middle.rs
index 053acdf..a63d52a 100644
--- a/examples/wpe_middle.rs
+++ b/examples/wpe_middle.rs
@@ -77,7 +77,7 @@ fn main() {
     let mut host = wpe::WebKitHost::new(url::Url::parse(&format!("{base}/")).unwrap(), (1200, 800));
     let settle = |h: &mut wpe::WebKitHost, n: u32| {
         for _ in 0..n {
-            h.pump();
+            h.pump(); h.frame_drawn();
             std::thread::sleep(std::time::Duration::from_millis(50));
         }
     };
diff --git a/examples/wpe_options.rs b/examples/wpe_options.rs
index 9aebf39..2a09ddd 100644
--- a/examples/wpe_options.rs
+++ b/examples/wpe_options.rs
@@ -48,7 +48,7 @@ fn main() {
     host.resize(2400, 1600, 2.0);
     let settle = |h: &mut wpe::WebKitHost, n: u32| {
         for _ in 0..n {
-            h.pump();
+            h.pump(); h.frame_drawn();
             std::thread::sleep(std::time::Duration::from_millis(50));
         }
     };
diff --git a/examples/wpe_paste.rs b/examples/wpe_paste.rs
index 7e44be0..dd815bc 100644
--- a/examples/wpe_paste.rs
+++ b/examples/wpe_paste.rs
@@ -36,7 +36,7 @@ fn main() {
     let url = std::env::args().nth(1).unwrap_or_else(|| "http://127.0.0.1:8790/paste.html".into());
     let mut host = wpe::WebKitHost::new(url::Url::parse(&url).unwrap(), (1200, 800));
     let settle = |h: &mut wpe::WebKitHost, n: u32| {
-        for _ in 0..n { h.pump(); std::thread::sleep(std::time::Duration::from_millis(50)); }
+        for _ in 0..n { h.pump(); h.frame_drawn(); std::thread::sleep(std::time::Duration::from_millis(50)); }
     };
 
     settle(&mut host, 40);
diff --git a/examples/wpe_select.rs b/examples/wpe_select.rs
index 9ec0198..694cc7b 100644
--- a/examples/wpe_select.rs
+++ b/examples/wpe_select.rs
@@ -44,7 +44,7 @@ fn main() {
     let mut host = wpe::WebKitHost::new(url::Url::parse(page).unwrap(), (1200, 800));
     let settle = |h: &mut wpe::WebKitHost, n: u32| {
         for _ in 0..n {
-            h.pump();
+            h.pump(); h.frame_drawn();
             std::thread::sleep(std::time::Duration::from_millis(50));
         }
     };
diff --git a/examples/wpe_tabs.rs b/examples/wpe_tabs.rs
index 7454580..e2d9763 100644
--- a/examples/wpe_tabs.rs
+++ b/examples/wpe_tabs.rs
@@ -39,7 +39,7 @@ fn main() {
     );
     let settle = |h: &mut wpe::WebKitHost, n: u32| {
         for _ in 0..n {
-            h.pump();
+            h.pump(); h.frame_drawn();
             std::thread::sleep(std::time::Duration::from_millis(50));
         }
     };
diff --git a/src/main.rs b/src/main.rs
index eec6f8f..2564a66 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5261,9 +5261,13 @@ impl Application for BrowserApp {
     fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
         // Whatever the engine last handed over is about to be on screen. That
         // is what lets the next one be read: until a frame is drawn, reading
-        // another would be copying over a picture nobody saw.
+        // another would be copying over a picture nobody saw. One already
+        // waiting has to be fetched: its page is held up until it is read,
+        // and a held-up page makes no noise that would turn the loop.
         #[cfg(feature = "wpe")]
-        self.host.frame_drawn();
+        if self.host.frame_drawn() {
+            let _ = self.sender.send(Message::Spin);
+        }
         self.win = (size.width, size.height);
         self.sync_ime();
         let mut pc = PaintCtx::new();
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index e8d1e49..b41447d 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -364,10 +364,9 @@ pub struct WebKitHost {
     /// A frame has been uploaded that nothing has drawn yet.
     ///
     /// The readback is paced by this: while it is set, a finished buffer is
-    /// left *held* instead of being copied, and the next engine frame hands it
-    /// back unread. An animating page in a window nobody is drawing — occluded,
-    /// on another desktop — therefore costs nothing, where before it copied
-    /// its full window size sixty times a second into a picture no one saw.
+    /// left *held* instead of being copied over a picture nobody saw — and
+    /// since a held buffer is not yet acknowledged, the engine waits on it
+    /// too (see `frame_drawn`).
     pending_draw: Cell<bool>,
     /// The injected account watcher, kept so the setting can take it away
     /// again. `None` when account autocomplete is off, which is also when no
@@ -515,7 +514,10 @@ impl WebKitHost {
                 // several frames can be dispatched inside a single pump's
                 // drain, and only the last of them will ever be shown, so the
                 // rest are not worth 35 MB of copying each.
+                // It will never be shown, so it is as done as it will get:
+                // say both halves, or that view composites nothing again.
                 if let Some((old_view, old_buffer)) = slot.held.replace((view, buffer)) {
+                    wpe_view_buffer_rendered(old_view, old_buffer);
                     wpe_view_buffer_released(old_view, old_buffer);
                 }
                 if frame_debug() {
@@ -1081,7 +1083,10 @@ impl WebKitHost {
     /// Give back an unread buffer, if one is being held.
     fn release_held(&self) {
         if let Some((view, buffer)) = self.pending.borrow_mut().held.take() {
-            unsafe { wpe_view_buffer_released(view, buffer) };
+            unsafe {
+                wpe_view_buffer_rendered(view, buffer);
+                wpe_view_buffer_released(view, buffer);
+            }
         }
     }
 
@@ -1194,8 +1199,10 @@ impl WebKitHost {
         }
         self.watch_deadlock();
         // Nothing has drawn the last frame yet, so reading another would be
-        // copying over a picture that was never shown. Leave the buffer held:
-        // the engine's next frame supersedes it and hands it back unread.
+        // copying over a picture that was never shown. Leave the buffer held
+        // until the draw. (Its view is waiting on `rendered` meanwhile, so it
+        // is not followed by another; one from a different view supersedes
+        // it and hands it back unread.)
         if self.pending_draw.get() {
             return (false, self.sync_page_state());
         }
@@ -1211,11 +1218,22 @@ impl WebKitHost {
         // one on screen. A view no tab owns is the prewarmed spare, or a tab
         // closed since: nothing shows it.
         let Some(index) = self.tabs.iter().position(|t| t.view == view) else {
-            unsafe { wpe_view_buffer_released(view, buffer) };
+            unsafe {
+                wpe_view_buffer_rendered(view, buffer);
+                wpe_view_buffer_released(view, buffer);
+            }
             return (false, dirty);
         };
         let read = unsafe {
             let read = self.read_frame(index, buffer, damage);
+            // Read now and drawn at the next frame: as good as on screen, so
+            // the engine may start on the next one while this one waits for
+            // the draw. That next one is then held, unacknowledged, until
+            // the draw has happened — which is what keeps the engine to the
+            // chrome's rate. (Said at the draw instead, the two never
+            // overlapped, and a Muji banner animating at 60 fps in a visible
+            // window dropped to 30.)
+            wpe_view_buffer_rendered(view, buffer);
             // The pixels are ours now; the memory can go back.
             wpe_view_buffer_released(view, buffer);
             read
@@ -1348,8 +1366,23 @@ impl WebKitHost {
 
     /// The chrome drew: whatever was uploaded is on screen, so the next
     /// engine frame is worth reading. Called from `display_list`.
-    pub fn frame_drawn(&self) {
+    ///
+    /// Returns whether a frame is already waiting to be read. Its view is
+    /// held up until it is (`pump` says `rendered` as it reads), and having
+    /// been held up it makes no noise that would turn the loop — so the
+    /// caller must wake a pump, or the page sits on that frame until GLib's
+    /// next timeout.
+    ///
+    /// This is what paces the engine to the chrome: one frame being drawn,
+    /// one more finished and waiting, and nothing composited beyond that. So
+    /// the page runs at the output's refresh while the window is up, at the
+    /// runner's starvation fallback (~4 a second) with the display off, and
+    /// not at all where nothing draws. Anything that reads frames without a
+    /// chrome (the examples) must call this too, or the page stops after its
+    /// second frame.
+    pub fn frame_drawn(&self) -> bool {
         self.pending_draw.set(false);
+        self.pending.borrow().held.is_some()
     }
 
     /// Fold each tab's signal-written state into the fields the chrome reads.
diff --git a/src/wpe/subclass.rs b/src/wpe/subclass.rs
index b90d5e0..4753490 100644
--- a/src/wpe/subclass.rs
+++ b/src/wpe/subclass.rs
@@ -90,9 +90,10 @@ pub(super) unsafe fn types() -> &'static Types {
 /// frame before, in buffer pixels as `(x, y, width, height)` — and is empty
 /// when the engine did not say.
 ///
-/// Returns whether the sink is **keeping** the buffer. If it is, releasing it
-/// is the sink's job — it reads the pixels out at the next pump and hands the
-/// memory back then.
+/// Returns whether the sink is **keeping** the buffer. If it is, both halves
+/// of the answer are the sink's job: `released` once the pixels are copied
+/// out, `rendered` once the chrome is ready for the next frame (see
+/// `view_render_buffer`).
 #[allow(clippy::type_complexity)]
 pub(super) static mut FRAME_SINK: Option<
     Box<dyn FnMut(*mut WPEView, *mut WPEBuffer, &[(i32, i32, i32, i32)]) -> bool>,
@@ -105,18 +106,20 @@ unsafe extern "C" fn view_render_buffer(
     n_damage: u32,
     _error: *mut *mut GError,
 ) -> gboolean {
-    // The two halves mean different things and are no longer said together.
-    // `rendered` means *displayed*: said at once, so the engine's own frame
-    // pacing never waits on our readback. `released` means *the memory is
-    // yours again*, and that has to wait until the pixels have been copied
-    // out of it — so the sink says it, at the pump that reads the buffer.
+    // Neither half is said here. `released` means *the memory is yours
+    // again*: said once the pixels are copied out. `rendered` means *it is
+    // on screen*, and it is the engine's pacing: until it is said WebKit
+    // composites nothing new for this view, and its main thread runs no
+    // rendering update either (rAF, style, layout and paint all wait on the
+    // composite). WebKit's own Wayland backend says it from the compositor's
+    // frame callback. Here it is said when the frame is read, and a frame is
+    // read only once the chrome has drawn the one before it (`pump`,
+    // `frame_drawn`), so the page runs at the rate the window is drawn.
     // (Saying neither is what stalls the engine after exactly one frame.)
     //
-    // Holding the buffer until then is also the backpressure: the engine
-    // cannot run arbitrarily far ahead of a browser that is not keeping up,
-    // and a frame superseded before anyone read it is handed back unread
-    // rather than copied.
-    wpe_view_buffer_rendered(view, buffer);
+    // Said at once, as it was until 2026-10-05, a window nobody was drawing
+    // (display off, minimized) still had its page composited sixty times a
+    // second, every frame handed back unread.
     let damage: Vec<(i32, i32, i32, i32)> = if damage.is_null() {
         Vec::new()
     } else {
@@ -128,6 +131,7 @@ unsafe extern "C" fn view_render_buffer(
     #[allow(static_mut_refs)]
     let held = FRAME_SINK.as_mut().is_some_and(|sink| sink(view, buffer, &damage));
     if !held {
+        wpe_view_buffer_rendered(view, buffer);
         wpe_view_buffer_released(view, buffer);
     }
     1