Files
ELY-Browser/crates/ely_app/src/shell/web_surface_runtime.rs
T
ZacharyZhang-NY 90c029eddb Move Servo IPC off UI thread
Root cause of the post-tab lag: the GPUI 16 ms timer was calling
`WebSurfaceRuntime::ensure_tab` and `tick` on the UI thread, and each
call did a synchronous `serde_json` write plus `read_line` against the
Servo sidecar over stdin/stdout. With even one visible tab, every
frame stalled on cross-process IPC.

Introduce `web_surface_worker.rs` — a per-profile worker thread that
owns the `ServoLiveClient`, drains a coalescing request queue
(latest Ensure/Poll per tab wins, no unbounded growth), and ships
results back through a `std::sync::mpsc` channel. `WebSurfaceRuntime`
now submits work non-blockingly and drains responses in `tick`; the
UI thread never blocks on the sidecar.

Adjacent in-flight cleanup riding along: hardware IOSurface
rendering-context completion (sidecar `live_protocol`,
`hardware_rendering_context`, GPUI BGRA surface shader), CSS viewport
size + device pixel ratio plumbing into `ServoLiveFrame`, and the
Send opt-ins for `CVPixelBuffer`-bearing types so frames can cross
the thread boundary.
2026-05-15 16:41:40 -04:00

449 lines
16 KiB
Rust

use std::{collections::BTreeMap, fs, path::PathBuf, time::Instant};
use ely_domain::{BrowserTab, ProfileId, TabId};
use crate::services::{
ProfileDataMode,
servo_live::{ServoLiveClient, ServoLiveEnsureRequest, ServoLiveSitePermission},
servo_profile_data::{default_profile_data_root, profile_data_dir, transient_profile_data_dir},
};
use super::{
web_surface_frame::WebSurfaceFrame,
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
web_surface_permissions::WebSurfaceSitePermission,
web_surface_state::WebSurfacePendingInput,
web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, WorkerResponse},
};
pub(super) struct WebSurfaceRuntime {
workers: BTreeMap<WebSurfaceRuntimeScope, ScopedWorker>,
sessions: BTreeMap<TabId, WebSurfaceSession>,
client_factory: LiveRuntimeClientFactory,
}
impl WebSurfaceRuntime {
pub(super) fn new() -> Self {
Self {
workers: BTreeMap::new(),
sessions: BTreeMap::new(),
client_factory: new_servo_live_client,
}
}
#[cfg(test)]
pub(super) fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self {
Self { workers: BTreeMap::new(), sessions: BTreeMap::new(), client_factory }
}
pub(super) fn ensure_tab(
&mut self,
tab: &BrowserTab,
size: WebSurfaceSize,
profile_data_mode: ProfileDataMode,
permissions: &[WebSurfaceSitePermission],
input: WebSurfacePendingInput,
) -> Result<WebSurfaceEnsureResult, String> {
let scope = WebSurfaceRuntimeScope::new(tab.profile_id().clone(), profile_data_mode);
self.ensure_worker(scope.clone())?;
let requested_url = tab.url().as_str().to_string();
let zoom_percent = tab.zoom_percent();
let enqueued_at = input.enqueued_at;
let input_kind = pending_input_kind(&input);
let (scroll_delta_x, scroll_delta_y, scroll_point_x, scroll_point_y) =
scroll_wire_fields(input.scroll_delta, input.scroll_point)?;
let user_navigation_input = input_requests_history_navigation(&input);
let next_scroll_offset = input.scroll_offset;
let started_loading = {
let session = session_for_scope(&mut self.sessions, tab.id(), scope.clone());
let started_loading = session.started_loading(&requested_url, size, zoom_percent);
if started_loading {
session.pending_user_navigation = false;
}
if user_navigation_input {
session.pending_user_navigation = true;
}
session.requested_url = requested_url.clone();
session.size = size;
session.zoom_percent = zoom_percent;
session.scroll_offset = next_scroll_offset;
started_loading
};
let request = ServoLiveEnsureRequest {
tab_id: tab.id().as_str().to_string(),
profile_id: tab.profile_id().as_str().to_string(),
url: requested_url.clone(),
width: size.width,
height: size.height,
page_zoom_percent: zoom_percent,
device_pixel_ratio: size.device_pixel_ratio_f32(),
scroll_delta_x,
scroll_delta_y,
scroll_point_x,
scroll_point_y,
click_x: input.click_point.map(|point| point.x()),
click_y: input.click_point.map(|point| point.y()),
hover_x: input.hover_point.map(|point| point.x()),
hover_y: input.hover_point.map(|point| point.y()),
typed_text: input.typed_text,
site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(),
};
let Some(scoped) = self.workers.get(&scope) else {
return Err("Servo worker was created but is no longer registered".to_string());
};
scoped.worker.submit_ensure(request);
log_ensure_submitted(tab, size, input_kind, enqueued_at, started_loading);
Ok(WebSurfaceEnsureResult { requested_url, started_loading })
}
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec<WebSurfaceRuntimeFrame> {
// Submit a Poll for every visible tab whose session is live so
// animations / JS-driven content keep advancing without user
// input. The worker coalesces — a Poll never overrides a
// pending Ensure — so this stays cheap even at 120 Hz.
for tab_id in visible_tab_ids {
let Some(session) = self.sessions.get(tab_id) else {
continue;
};
let Some(scoped) = self.workers.get(&session.scope) else {
continue;
};
scoped.worker.submit_poll(tab_id.as_str().to_string());
}
let mut frames = Vec::new();
let mut dead_scopes = Vec::new();
let scopes: Vec<WebSurfaceRuntimeScope> = self.workers.keys().cloned().collect();
for scope in scopes {
let responses = self
.workers
.get(&scope)
.map(|scoped| scoped.worker.drain_responses())
.unwrap_or_default();
for response in responses {
match response {
WorkerResponse::Frame { tab_id, frame } => {
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
continue;
};
let session = match self.sessions.get_mut(&tab_id_obj) {
Some(session) => session,
None => continue,
};
let requested_url = session.requested_url.clone();
let scroll_offset = session.scroll_offset;
let zoom_percent = session.zoom_percent;
match WebSurfaceFrame::from_live_frame(
requested_url.clone(),
scroll_offset,
zoom_percent,
frame,
) {
Ok(frame) => {
let url_change = session.url_change_for(
&tab_id_obj,
requested_url.as_str(),
&frame,
);
frames.push(WebSurfaceRuntimeFrame::Ready {
tab_id: tab_id_obj,
frame: Box::new(frame),
url_change,
});
}
Err(error) => frames.push(WebSurfaceRuntimeFrame::Failed {
tab_id: tab_id_obj,
message: error.to_string(),
}),
}
}
WorkerResponse::Failed { tab_id, message } => {
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
continue;
};
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
}
WorkerResponse::SidecarExited => dead_scopes.push(scope.clone()),
}
}
}
for scope in dead_scopes {
self.workers.remove(&scope);
}
frames
}
pub(super) fn close_tab(&mut self, tab_id: &TabId) {
let Some(session) = self.sessions.remove(tab_id) else {
return;
};
if let Some(scoped) = self.workers.get(&session.scope) {
scoped.worker.submit_close(tab_id.as_str().to_string());
}
}
fn ensure_worker(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
if self.workers.contains_key(&scope) {
return Ok(());
}
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
let client = (self.client_factory)(config_dir)?;
let worker = LiveRuntimeWorker::new(client)?;
self.workers.insert(scope, ScopedWorker { worker, transient_profile_data_dir });
Ok(())
}
fn lookup_session_tab_id(&self, tab_id: &str) -> Option<TabId> {
self.sessions.keys().find(|key| key.as_str() == tab_id).cloned()
}
#[cfg(test)]
pub(super) fn client_count_for_test(&self) -> usize {
self.workers.len()
}
#[cfg(test)]
pub(super) fn session_scope_for_test(&self, tab_id: &TabId) -> Option<&WebSurfaceRuntimeScope> {
self.sessions.get(tab_id).map(|session| &session.scope)
}
#[cfg(test)]
pub(super) fn flush_for_test(&self) {
for scoped in self.workers.values() {
scoped.worker.wait_until_idle();
}
}
}
impl Drop for WebSurfaceRuntime {
fn drop(&mut self) {
let transient_profile_data_dirs = self
.workers
.values()
.filter_map(|scoped| scoped.transient_profile_data_dir.clone())
.collect::<Vec<_>>();
self.workers.clear();
for path in transient_profile_data_dirs {
let _ = fs::remove_dir_all(path);
}
}
}
pub(super) type LiveRuntimeClientFactory =
fn(PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String>;
fn new_servo_live_client(config_dir: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
ServoLiveClient::new(config_dir)
.map(|client| Box::new(client) as Box<dyn LiveRuntimeClient>)
.map_err(|error| error.to_string())
}
struct ScopedWorker {
worker: LiveRuntimeWorker,
transient_profile_data_dir: Option<PathBuf>,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(super) struct WebSurfaceRuntimeScope {
profile_id: ProfileId,
profile_data_mode: ProfileDataMode,
}
impl WebSurfaceRuntimeScope {
pub(super) fn new(profile_id: ProfileId, profile_data_mode: ProfileDataMode) -> Self {
Self { profile_id, profile_data_mode }
}
}
#[derive(Clone)]
pub(super) struct WebSurfaceSession {
pub(super) scope: WebSurfaceRuntimeScope,
pub(super) requested_url: String,
pub(super) size: WebSurfaceSize,
pub(super) zoom_percent: u16,
pub(super) scroll_offset: WebSurfaceScrollOffset,
pub(super) pending_user_navigation: bool,
}
impl WebSurfaceSession {
fn new(scope: WebSurfaceRuntimeScope) -> Self {
Self {
scope,
requested_url: String::new(),
size: WebSurfaceSize::default(),
zoom_percent: 0,
scroll_offset: WebSurfaceScrollOffset::default(),
pending_user_navigation: false,
}
}
fn started_loading(
&self,
requested_url: &str,
size: WebSurfaceSize,
zoom_percent: u16,
) -> bool {
self.requested_url != requested_url
|| self.size != size
|| self.zoom_percent != zoom_percent
}
fn url_change_for(
&mut self,
tab_id: &TabId,
requested_url: &str,
frame: &WebSurfaceFrame,
) -> Option<WebSurfaceUrlChange> {
let loaded_url = frame.loaded_url()?;
if loaded_url == requested_url {
return None;
}
let kind = if self.pending_user_navigation {
WebSurfaceUrlChangeKind::UserInitiated
} else {
WebSurfaceUrlChangeKind::Observed
};
self.pending_user_navigation = false;
Some(WebSurfaceUrlChange {
tab_id: tab_id.clone(),
loaded_url: loaded_url.to_string(),
kind,
})
}
}
pub(super) struct WebSurfaceEnsureResult {
pub(super) requested_url: String,
pub(super) started_loading: bool,
}
pub(super) enum WebSurfaceRuntimeFrame {
Ready { tab_id: TabId, frame: Box<WebSurfaceFrame>, url_change: Option<WebSurfaceUrlChange> },
Failed { tab_id: TabId, message: String },
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct WebSurfaceUrlChange {
pub(super) tab_id: TabId,
pub(super) loaded_url: String,
pub(super) kind: WebSurfaceUrlChangeKind,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum WebSurfaceUrlChangeKind {
UserInitiated,
Observed,
}
fn config_dir_for_scope(
scope: &WebSurfaceRuntimeScope,
) -> Result<(PathBuf, Option<PathBuf>), String> {
match scope.profile_data_mode {
ProfileDataMode::Persistent => {
let root = default_profile_data_root()
.ok_or_else(|| "Profile data root is unavailable".to_string())?;
let config_dir = profile_data_dir(&root, &scope.profile_id);
fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?;
Ok((config_dir, None))
}
ProfileDataMode::Transient => {
let config_dir =
transient_profile_data_dir(&scope.profile_id).map_err(|error| error.to_string())?;
fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?;
Ok((config_dir.clone(), Some(config_dir)))
}
}
}
pub(super) fn session_for_scope<'a>(
sessions: &'a mut BTreeMap<TabId, WebSurfaceSession>,
tab_id: &TabId,
scope: WebSurfaceRuntimeScope,
) -> &'a mut WebSurfaceSession {
let session =
sessions.entry(tab_id.clone()).or_insert_with(|| WebSurfaceSession::new(scope.clone()));
if session.scope != scope {
*session = WebSurfaceSession::new(scope);
}
session
}
fn scroll_wire_fields(
delta: Option<super::web_surface_geometry::WebSurfaceScrollDelta>,
point: Option<super::web_surface_geometry::WebSurfaceClickPoint>,
) -> Result<(i32, i32, Option<u32>, Option<u32>), String> {
match delta {
Some(delta) => {
let point = point
.ok_or_else(|| "Servo scroll input is missing a viewport point".to_string())?;
Ok((delta.x(), delta.y(), Some(point.x()), Some(point.y())))
}
None => Ok((0, 0, None, None)),
}
}
fn input_requests_history_navigation(input: &WebSurfacePendingInput) -> bool {
input.click_point.is_some()
|| input.typed_text.as_deref().is_some_and(|text| text.contains('\n'))
}
fn pending_input_kind(input: &WebSurfacePendingInput) -> &'static str {
if input.scroll_delta.is_some() {
"scroll"
} else if input.click_point.is_some() {
"click"
} else if input.typed_text.is_some() {
"text"
} else if input.hover_point.is_some() {
"hover"
} else {
"idle"
}
}
fn log_ensure_submitted(
tab: &BrowserTab,
size: WebSurfaceSize,
input_kind: &'static str,
enqueued_at: Option<Instant>,
started_loading: bool,
) {
if input_kind == "idle" && !started_loading {
return;
}
let queued_us = enqueued_at.map(|started_at| started_at.elapsed().as_micros());
tracing::info!(
target: "ely::web_surface::latency",
tab_id = %tab.id().as_str(),
url = %tab.url().as_str(),
input_kind,
queued_us,
started_loading,
width = size.width,
height = size.height,
device_pixel_ratio = size.device_pixel_ratio_f32(),
"web_surface_ensure_submitted",
);
}
impl From<&WebSurfaceSitePermission> for ServoLiveSitePermission {
fn from(permission: &WebSurfaceSitePermission) -> Self {
Self::new(
permission.origin().as_str(),
permission.feature().as_str(),
permission.decision(),
)
}
}
#[cfg(test)]
#[path = "web_surface_runtime_tests.rs"]
mod tests;