web browser (Servo)
git clone https://git.lucas.co/cce-browser.git
perf: prewarm a spare webview so a link is a tab in ~65ms, not ~250
Creating a webview and spawning its WebProcess is ~200ms of every
open_tab. The WPE host now keeps one hidden webview parked on
about:blank and adopts it on open, paying only the navigation; the
replacement spare builds after the load starts, off the observed path.
Measured click-to-live-tab: ~250ms -> ~65ms on an internal page,
~400ms -> ~120ms warm on example.com. Costs one idle WebProcess.
Hand-rolled because WPE's platform API lacks the GTK port's
webkit_web_context_prewarm_spare_web_process.
Co-Authored-By: Claude Fable 5 <[email protected]>
CLAUDE.md | 9 +++++++++
src/wpe/host.rs | 46 ++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 53 insertions(+), 2 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index ffec663..639acdf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -77,6 +77,15 @@ tidiness this guards the profile dir: two engines must not share the plaintext
cookie jar. There is deliberately no `--new-window` yet; raising the existing
window on forward is also still open.
+The other half of click-to-tab latency is inside WebKit: creating a webview
+and spawning its WebProcess is ~200ms, so the WPE host keeps a hidden **spare
+webview** prewarmed on about:blank and `open_tab` adopts it (see the `spare`
+field in `src/wpe/host.rs`). Measured end to end: a link is a live tab in
+~65ms (internal page) / ~120ms (example.com, warm) against ~250/~400ms
+without. WPE's platform API has no
+`webkit_web_context_prewarm_spare_web_process` (the GTK port's answer), which
+is why the spare is hand-rolled.
+
## The frame pipeline
Servo renders into a **`SoftwareRenderingContext`** (CPU, no GPU handoff), one context
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index e618d71..253a396 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -146,6 +146,14 @@ pub struct WebKitHost {
last_frame: Option<(Vec<u8>, u32, u32)>,
/// Installed on every webview when force-dark is on.
ucm: *mut WebKitUserContentManager,
+ /// A pre-built hidden webview parked on about:blank, WebProcess already
+ /// spawned. `open_tab` adopts it and pays only the navigation — measured
+ /// at ~65ms to a live internal page against ~250ms building from scratch
+ /// (~200ms of which is webview creation + process spawn). The price is
+ /// one idle WebProcess held per window. Theme changes reach it anyway:
+ /// the colour scheme is display-level and force-dark lives in the shared
+ /// user-content-manager it was built with.
+ spare: Option<(*mut WebKitWebView, *mut WPEView, Rc<TabState>)>,
}
unsafe fn cstr(s: &str) -> CString {
@@ -270,6 +278,7 @@ impl WebKitHost {
prompts,
last_frame: None,
ucm: webkit_user_content_manager_new(),
+ spare: None,
};
host.open_tab(url);
host
@@ -334,11 +343,40 @@ impl WebKitHost {
}
}
- pub fn open_tab(&mut self, url: Url) {
+ /// Build the hidden spare webview so its WebProcess is up before the
+ /// next `open_tab` needs it.
+ fn prewarm_spare(&mut self) {
+ if self.spare.is_some() {
+ return;
+ }
let state = Rc::new(TabState::default());
+ let url = Url::parse("about:blank").expect("about:blank");
+ let (wv, view) = self.build_webview(&url, &state);
+ unsafe {
+ wpe_view_unmap(view);
+ wpe_view_set_visible(view, 0);
+ }
+ self.spare = Some((wv, view, state));
+ }
+
+ pub fn open_tab(&mut self, url: Url) {
+ let (webview, view, state) = match self.spare.take() {
+ // Adopt the prewarmed webview; only the navigation is paid.
+ Some((wv, view, state)) => {
+ unsafe {
+ let curl = cstr(url.as_str());
+ webkit_web_view_load_uri(wv, curl.as_ptr());
+ }
+ (wv, view, state)
+ }
+ None => {
+ let state = Rc::new(TabState::default());
+ let (wv, view) = self.build_webview(&url, &state);
+ (wv, view, state)
+ }
+ };
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,
@@ -349,6 +387,10 @@ impl WebKitHost {
image: None,
});
self.activate(self.tabs.len() - 1);
+ // Replace the spare right away, but after the load started, so the
+ // page fetch runs while this builds — measured, it does not show up
+ // in the click-to-tab time.
+ self.prewarm_spare();
}
/// Close a tab. Returns false when that was the last one (the app should