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

commit84de5de5b840c6b2f1ac2aeebdf8e2ec8fa4423d
parent8df52868f6
authorLucas Galante <[email protected]>
date2026-08-28 10:09
feat(wpe): tabs, with page state driven by signals rather than polling

The old sync_page_state read properties off the active webview, which is
fine for one tab and silently wrong for several: a tab loading in the
background stayed titleless and permanently "loading" until you switched
to it, and the tab strip shows a title per tab.

Each tab now owns an Rc<TabState> that WebKit's notify::title,
notify::uri and notify::is-loading handlers write into, and pump folds
every dirty tab's state into the fields the chrome reads. examples/wpe_tabs
opens a second tab and immediately backgrounds it, never activating it
again:

  tab1: title=Some("Example Domain") url=https://example.org/ loading=false

Conclusive because title is seeded None and loading seeded true at
construction, so both could only have changed by a signal firing on a
webview that was never active.

The Rc is not incidental. A connected closure outlives any borrow we could
hand it, and tabs live in a Vec that reallocates, so the state cannot be
addressed through the Tab. Each connection owns a ref released by a
destroy-notify, and Tab::drop unrefs the webview *before* the state can go
— destroying the object is what runs those notifies, so the other order is
a use-after-free rather than a leak.

close_tab mirrors ServoHost's, including how the next active index is
chosen and the usize::MAX sentinel that stops activate short-circuiting.
Dropping the Tab is what unrefs the webview and frees its registry image;
before this, nothing ever unreffed a webview at all.

Verified through open, background, switch, close, and close-the-last with
no crash, and the earlier host and input examples still pass.

Co-Authored-By: Claude Opus 5 <[email protected]>

 examples/wpe_tabs.rs |  71 ++++++++++++++++++++++++++
 src/wpe/host.rs      | 140 ++++++++++++++++++++++++++++++++++++++++++++-------
 2 files changed, 193 insertions(+), 18 deletions(-)

diff --git a/examples/wpe_tabs.rs b/examples/wpe_tabs.rs
new file mode 100644
index 0000000..bb27746
--- /dev/null
+++ b/examples/wpe_tabs.rs
@@ -0,0 +1,71 @@
+//! Tabs: several views on one display, and — the point of the change —
+//! **background tabs updating their own state**.
+//!
+//! The old polling read only the active webview, so a tab loading in the
+//! background stayed titleless until you switched to it. Here tab 1 is opened
+//! and immediately backgrounded; if signals work it still reports its title.
+//!
+//! `cargo run --release -p cce-browser --features wpe --example wpe_tabs`
+
+#[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() {
+    let mut host = wpe::WebKitHost::new(
+        url::Url::parse("https://example.com").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));
+        }
+    };
+    let dump = |h: &wpe::WebKitHost, label: &str| {
+        println!("{label}  (active={} of {})", h.active_index(), h.tab_count());
+        for i in 0..h.tab_count() {
+            let t = h.tab(i).unwrap();
+            println!(
+                "   tab{i}: title={:?} url={:?} loading={}",
+                t.title,
+                t.url.as_ref().map(|u| u.as_str()),
+                t.loading
+            );
+        }
+    };
+
+    settle(&mut host, 30);
+    dump(&host, "-- one tab --");
+
+    println!("\n-- open tab 1, then immediately background it --");
+    host.open_tab(url::Url::parse("https://example.org").unwrap());
+    host.activate(0); // switch away before it can finish loading
+    settle(&mut host, 40);
+    dump(&host, "   after settling (tab1 was never active again)");
+
+    let bg_ok = host.tab(1).map(|t| t.title.is_some() && !t.loading).unwrap_or(false);
+    println!("\n   background tab reported state: {}", if bg_ok { "OK" } else { "MISSING" });
+
+    println!("\n-- switch to tab 1 --");
+    host.activate(1);
+    settle(&mut host, 10);
+    println!("   active title={:?} image={:?}", host.title(), host.image());
+
+    println!("\n-- close tab 1 --");
+    assert!(host.close_tab(1), "should not be the last tab");
+    settle(&mut host, 6);
+    dump(&host, "   after close");
+
+    println!("\n-- close the last tab --");
+    let more = host.close_tab(0);
+    println!("   close_tab returned {more} (false = was the last)");
+    assert!(!more);
+    println!("\nOK — no crash through open/background/switch/close");
+}
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 0d16e72..a629121 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -14,6 +14,7 @@
 //! so the app wakes only when GLib has work — see WPE-PORT.md; doing it by
 //! polling first keeps this milestone about the engine, not the event loop.
 
+use std::cell::{Cell, RefCell};
 use std::ffi::{c_char, CString};
 use std::rc::Rc;
 
@@ -26,18 +27,86 @@ use super::glib_source::GlibPoll;
 use super::input;
 use super::subclass::{types, FRAME_SINK};
 
+/// Page state a tab's WebKit signals write into.
+///
+/// Held behind an `Rc` because each connected signal owns a reference: the
+/// closure outlives any borrow we could hand it, and the webview may emit
+/// after the `Tab` has moved within `tabs` (a `Vec` reallocates).
+#[derive(Default)]
+struct TabState {
+    title: RefCell<Option<String>>,
+    url: RefCell<Option<Url>>,
+    loading: Cell<bool>,
+    /// Set by any signal, cleared by `pump`. This is what lets a *background*
+    /// tab report a title change — the old polling only ever looked at the
+    /// active webview.
+    dirty: Cell<bool>,
+}
+
 /// One tab: its webview plus the app-visible page state and the last frame
 /// uploaded to the image registry (id, w px, h px). Same shape as
 /// `webview::Tab` so the chrome reads it identically.
 pub struct Tab {
     webview: *mut WebKitWebView,
     view: *mut WPEView,
+    state: Rc<TabState>,
     pub title: Option<String>,
     pub url: Option<Url>,
     pub loading: bool,
     image: Option<(u32, u32, u32)>,
 }
 
+impl Drop for Tab {
+    fn drop(&mut self) {
+        // Unref the webview *first*: destroying it runs the closures'
+        // destroy-notify, which releases their `Rc<TabState>` refs. Dropping
+        // the state before the object that can still emit into it would be a
+        // use-after-free.
+        unsafe { g_object_unref(self.webview as *mut _) };
+        if let Some((id, ..)) = self.image {
+            cce_ui::vk::free_image(id);
+        }
+    }
+}
+
+/// `notify::` handler shared by title / uri / is-loading: read the property
+/// straight back off the emitting webview and stash it.
+unsafe extern "C" fn on_notify(
+    obj: *mut GObject,
+    _pspec: *mut GParamSpec,
+    data: gpointer,
+) {
+    let st = &*(data as *const TabState);
+    let wv = obj as *mut WebKitWebView;
+    *st.title.borrow_mut() = from_cstr(webkit_web_view_get_title(wv));
+    if let Some(u) = from_cstr(webkit_web_view_get_uri(wv)).and_then(|u| Url::parse(&u).ok()) {
+        *st.url.borrow_mut() = Some(u);
+    }
+    st.loading.set(webkit_web_view_is_loading(wv) != 0);
+    st.dirty.set(true);
+}
+
+/// Releases the `Rc` ref a connection owned, when the closure is destroyed.
+unsafe extern "C" fn drop_state_ref(data: gpointer, _closure: *mut GClosure) {
+    drop(Rc::from_raw(data as *const TabState));
+}
+
+unsafe fn connect_notify(wv: *mut WebKitWebView, signal: &str, state: &Rc<TabState>) {
+    let name = cstr(signal);
+    // Each connection owns its own ref, handed back by `drop_state_ref`.
+    let raw = Rc::into_raw(state.clone()) as gpointer;
+    g_signal_connect_data(
+        wv as *mut _,
+        name.as_ptr(),
+        Some(std::mem::transmute::<_, unsafe extern "C" fn()>(
+            on_notify as unsafe extern "C" fn(*mut GObject, *mut GParamSpec, gpointer),
+        )),
+        raw,
+        Some(drop_state_ref),
+        0,
+    );
+}
+
 /// Frames handed over by `render_buffer`, drained by `pump`. A slot, not a
 /// queue: only the newest frame is worth uploading, and WPE will not produce
 /// another until we release the current one anyway.
@@ -107,7 +176,7 @@ impl WebKitHost {
         }
     }
 
-    fn build_webview(&self, url: &Url) -> (*mut WebKitWebView, *mut WPEView) {
+    fn build_webview(&self, url: &Url, state: &Rc<TabState>) -> (*mut WebKitWebView, *mut WPEView) {
         unsafe {
             let prop = cstr("display");
             let wv = g_object_new(
@@ -118,6 +187,11 @@ impl WebKitHost {
             ) as *mut WebKitWebView;
             let view = webkit_web_view_get_wpe_view(wv);
             wpe_view_set_toplevel(view, self.toplevel);
+            // Signals, not polling: a background tab has to be able to report
+            // its title without anyone asking the active webview.
+            for sig in ["notify::title", "notify::uri", "notify::is-loading"] {
+                connect_notify(wv, sig, state);
+            }
             wpe_view_resized(view, self.size_px.0 as i32, self.size_px.1 as i32);
             wpe_view_set_visible(view, 1);
             wpe_view_map(view);
@@ -128,10 +202,14 @@ impl WebKitHost {
     }
 
     pub fn open_tab(&mut self, url: Url) {
-        let (webview, view) = self.build_webview(&url);
+        let state = Rc::new(TabState::default());
+        state.loading.set(true);
+        *state.url.borrow_mut() = Some(url.clone());
+        let (webview, view) = self.build_webview(&url, &state);
         self.tabs.push(Tab {
             webview,
             view,
+            state,
             title: None,
             url: Some(url),
             loading: true,
@@ -140,6 +218,32 @@ impl WebKitHost {
         self.activate(self.tabs.len() - 1);
     }
 
+    /// Close a tab. Returns false when that was the last one (the app should
+    /// exit; the tab is gone either way). Mirrors `ServoHost::close_tab`,
+    /// including how the next active index is chosen.
+    pub fn close_tab(&mut self, index: usize) -> bool {
+        if index >= self.tabs.len() {
+            return true;
+        }
+        let was_active = index == self.active;
+        let old_active = self.active;
+        // Dropping the Tab unrefs the webview and frees its registry image.
+        drop(self.tabs.remove(index));
+        if self.tabs.is_empty() {
+            return false;
+        }
+        let next = if was_active {
+            index.min(self.tabs.len() - 1)
+        } else if old_active > index {
+            old_active - 1
+        } else {
+            old_active
+        };
+        self.active = usize::MAX; // force activate() to do the work
+        self.activate(next);
+        true
+    }
+
     /// Make tab `index` visible and focused. Mirrors `ServoHost::activate`,
     /// including the `usize::MAX` sentinel so the first call is not a no-op.
     pub fn activate(&mut self, index: usize) {
@@ -218,25 +322,25 @@ impl WebKitHost {
         (true, true)
     }
 
-    /// Pull title/url/loading off the active webview. WebKit exposes these as
-    /// properties; polling them here keeps the delegate-free shape of this
-    /// first cut. Signals (`notify::title`, `load-changed`) are the better
-    /// answer once tabs land, so background tabs update too.
+    /// Fold each tab's signal-written state into the fields the chrome reads.
+    ///
+    /// Every tab, not just the active one — that is the whole point of moving
+    /// off polling. The tab strip shows a title per tab, so a background tab
+    /// finishing a load has to be visible without switching to it.
     fn sync_page_state(&mut self) -> bool {
-        unsafe {
-            let tab = &mut self.tabs[self.active];
-            let title = from_cstr(webkit_web_view_get_title(tab.webview));
-            let uri = from_cstr(webkit_web_view_get_uri(tab.webview));
-            let loading = webkit_web_view_is_loading(tab.webview) != 0;
-            let url = uri.and_then(|u| Url::parse(&u).ok());
-            let changed = title != tab.title || url != tab.url || loading != tab.loading;
-            tab.title = title;
-            if url.is_some() {
-                tab.url = url;
+        let mut changed = false;
+        for tab in &mut self.tabs {
+            if !tab.state.dirty.replace(false) {
+                continue;
+            }
+            tab.title = tab.state.title.borrow().clone();
+            if let Some(u) = tab.state.url.borrow().clone() {
+                tab.url = Some(u);
             }
-            tab.loading = loading;
-            changed
+            tab.loading = tab.state.loading.get();
+            changed = true;
         }
+        changed
     }
 
     pub fn image(&self) -> Option<(u32, u32, u32)> {