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.
This commit is contained in:
2026-05-15 16:41:40 -04:00
parent f4c650c4d8
commit 90c029eddb
29 changed files with 2113 additions and 496 deletions
+8 -6
View File
@@ -32,6 +32,7 @@ mod web_surface_permissions;
mod web_surface_runtime;
mod web_surface_state;
mod web_surface_view;
mod web_surface_worker;
#[cfg(test)]
mod gpui_harness_tests;
@@ -116,9 +117,8 @@ impl ElyShell {
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let command_input = cx.new(|cx| {
InputState::new(window, cx).placeholder("Search ELY or type a command…")
});
let command_input =
cx.new(|cx| InputState::new(window, cx).placeholder("Search ELY or type a command…"));
let plugin_search_input =
cx.new(|cx| InputState::new(window, cx).placeholder("Search plugins…"));
let translucency_slider = cx.new(|_cx| {
@@ -129,14 +129,16 @@ impl ElyShell {
.default_value(f32::from(DEFAULT_TRANSLUCENCY_PCT))
});
let translucency_subscription =
cx.subscribe(&translucency_slider, |shell: &mut Self, _state, event: &SliderEvent, cx| {
let translucency_subscription = cx.subscribe(
&translucency_slider,
|shell: &mut Self, _state, event: &SliderEvent, cx| {
let SliderEvent::Change(SliderValue::Single(value)) = event else {
return;
};
let pct = value.clamp(0.0, 100.0).round() as u8;
shell.set_translucency_pct(pct, cx);
});
},
);
let command_subscription = cx.subscribe_in(
&command_input,
+98 -29
View File
@@ -11,8 +11,9 @@ use super::{
web_surface_permissions::WebSurfaceSitePermission,
web_surface_runtime::{WebSurfaceRuntime, WebSurfaceRuntimeFrame, WebSurfaceUrlChange},
web_surface_state::{
PerTabSurface, WebSurfaceClickState, WebSurfaceInputOutcome, WebSurfaceKeyboardFocusState,
WebSurfacePendingInput, WebSurfaceScrollState, WebSurfaceState, WebSurfaceTextInputState,
PerTabSurface, WebSurfaceClickState, WebSurfaceEnsureKey, WebSurfaceInputOutcome,
WebSurfaceKeyboardFocusState, WebSurfacePendingInput, WebSurfaceScrollState,
WebSurfaceState, WebSurfaceTextInputState,
},
};
@@ -30,6 +31,11 @@ impl WebSurfaceStore {
Self { runtime: WebSurfaceRuntime::new(), surfaces: BTreeMap::new(), keyboard_focus: None }
}
#[cfg(test)]
pub(super) fn new_with_runtime(runtime: WebSurfaceRuntime) -> Self {
Self { runtime, surfaces: BTreeMap::new(), keyboard_focus: None }
}
pub(super) fn state(&self, tab_id: &TabId) -> Option<&WebSurfaceState> {
self.surfaces.get(tab_id).and_then(|surface| surface.state.as_ref())
}
@@ -39,37 +45,41 @@ impl WebSurfaceStore {
tab: &BrowserTab,
profile_data_mode: ProfileDataMode,
permissions: &[WebSurfaceSitePermission],
) -> Option<WebSurfaceUrlChange> {
) -> WebSurfaceEnsureOutcome {
if !is_external_web_url(tab.url().as_str()) {
return None;
return WebSurfaceEnsureOutcome::default();
}
let requested_url = tab.url().as_str().to_string();
let size = self.surfaces.get(tab.id()).and_then(|surface| surface.viewport_size)?;
let Some(size) = self.surfaces.get(tab.id()).and_then(|surface| surface.viewport_size)
else {
return WebSurfaceEnsureOutcome::default();
};
let ensure_key =
WebSurfaceEnsureKey::new(requested_url.clone(), size, tab.zoom_percent(), permissions);
if self.surfaces.get(tab.id()).is_some_and(|surface| !surface.should_ensure(&ensure_key)) {
return WebSurfaceEnsureOutcome::default();
}
let input = self.take_pending_input(tab.id(), requested_url.as_str());
let previous_frame =
self.previous_ready_frame(tab.id(), requested_url.as_str(), tab.zoom_percent());
match self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input) {
Ok(result) if result.frame.is_some() => {
let url_change = result.url_change;
let Some(frame) = result.frame else {
return url_change;
};
self.surface_mut(tab.id()).state = Some(WebSurfaceState::Ready(frame));
url_change
Ok(result) => {
self.surface_mut(tab.id()).mark_ensured(ensure_key);
if result.started_loading {
self.surface_mut(tab.id()).state = Some(WebSurfaceState::Loading {
requested_url: result.requested_url,
previous_frame,
});
return WebSurfaceEnsureOutcome { changed: true, url_change: None };
}
WebSurfaceEnsureOutcome::default()
}
Ok(result) if result.started_loading => {
self.surface_mut(tab.id()).state = Some(WebSurfaceState::Loading {
requested_url: result.requested_url,
previous_frame,
});
result.url_change
}
Ok(result) => result.url_change,
Err(message) => {
self.surface_mut(tab.id()).mark_ensured(ensure_key);
self.surface_mut(tab.id()).state = Some(WebSurfaceState::Failed { message });
None
WebSurfaceEnsureOutcome { changed: true, url_change: None }
}
}
}
@@ -81,6 +91,23 @@ impl WebSurfaceStore {
for frame in frames {
match frame {
WebSurfaceRuntimeFrame::Ready { tab_id, frame, url_change } => {
match self.initial_display_gate_message(&tab_id, &frame, false) {
Ok(()) => {}
Err(message) => {
self.surface_mut(&tab_id).state =
Some(WebSurfaceState::Failed { message });
result.changed = true;
continue;
}
}
if self.should_hold_initial_frame(&tab_id, &frame, false) {
self.surface_mut(&tab_id).state = Some(WebSurfaceState::Loading {
requested_url: frame.requested_url.clone(),
previous_frame: None,
});
result.changed = true;
continue;
}
self.surface_mut(&tab_id).state = Some(WebSurfaceState::Ready(*frame));
result.changed = true;
if let Some(url_change) = url_change {
@@ -148,6 +175,7 @@ impl WebSurfaceStore {
None => delta,
});
surface.pending_scroll_point = Some(point);
surface.mark_pending_input_started();
// Drop any buffered click — its viewport coordinates were
// captured against the pre-scroll page, so applying it after
// the scroll would land on the wrong DOM element. Keep
@@ -172,21 +200,13 @@ impl WebSurfaceStore {
let Some(current_size) = surface.viewport_size else {
surface.viewport_size = Some(size);
surface.pending_viewport_size = None;
return WebSurfaceInputOutcome::Applied;
};
if current_size == size {
surface.pending_viewport_size = None;
return WebSurfaceInputOutcome::NoChange;
}
if surface.pending_viewport_size != Some(size) {
surface.pending_viewport_size = Some(size);
return WebSurfaceInputOutcome::Buffered;
}
surface.pending_viewport_size = None;
surface.viewport_size = Some(size);
WebSurfaceInputOutcome::Applied
}
@@ -246,6 +266,7 @@ impl WebSurfaceStore {
let surface = self.surface_mut(tab_id);
surface.typed_text = None;
surface.click_point = Some(state);
surface.mark_pending_input_started();
WebSurfaceInputOutcome::Applied
}
@@ -288,6 +309,7 @@ impl WebSurfaceStore {
}
entry.text.push_str(text);
surface.mark_pending_input_started();
WebSurfaceInputOutcome::Applied
}
@@ -313,8 +335,10 @@ impl WebSurfaceStore {
.filter(|state| state.requested_url == requested_url)
.map(|state| state.text);
let hover_point = surface.hover_point.take();
let enqueued_at = surface.pending_input_started_at.take();
WebSurfacePendingInput {
enqueued_at,
scroll_offset,
scroll_delta,
scroll_point,
@@ -346,6 +370,40 @@ impl WebSurfaceStore {
}
}
fn should_hold_initial_frame(
&self,
tab_id: &TabId,
frame: &WebSurfaceFrame,
has_previous_frame: bool,
) -> bool {
!has_previous_frame
&& self
.previous_ready_frame(tab_id, frame.requested_url.as_str(), frame.zoom_percent())
.is_none()
&& matches!(frame.has_visible_content_for_initial_display(), Ok(false))
}
fn initial_display_gate_message(
&self,
tab_id: &TabId,
frame: &WebSurfaceFrame,
has_previous_frame: bool,
) -> Result<(), String> {
if has_previous_frame
|| self
.previous_ready_frame(tab_id, frame.requested_url.as_str(), frame.zoom_percent())
.is_some()
{
return Ok(());
}
frame.has_visible_content_for_initial_display().map(|_| ()).map_err(|error| {
format!(
"Servo hardware surface initial content check failed for {}: {error}",
frame.requested_url
)
})
}
fn surface_mut(&mut self, tab_id: &TabId) -> &mut PerTabSurface {
self.surfaces.entry(tab_id.clone()).or_insert_with(PerTabSurface::new)
}
@@ -362,12 +420,23 @@ impl WebSurfaceStore {
pub(super) fn surface_for_test(&self, tab_id: &TabId) -> Option<&PerTabSurface> {
self.surfaces.get(tab_id)
}
#[cfg(test)]
pub(super) fn flush_runtime_for_test(&self) {
self.runtime.flush_for_test();
}
}
pub(super) fn is_external_web_url(url: &str) -> bool {
url.starts_with("https://") || url.starts_with("http://")
}
#[derive(Default)]
pub(super) struct WebSurfaceEnsureOutcome {
pub(super) changed: bool,
pub(super) url_change: Option<WebSurfaceUrlChange>,
}
#[derive(Default)]
pub(super) struct WebSurfaceTickResult {
pub(super) changed: bool,
@@ -22,16 +22,8 @@ impl ElyShell {
cx: &mut Context<Self>,
) -> AnyElement {
let state_entity = cx.entity().clone();
let Some(profile_data_mode) = profile_data_mode_for(tab, snapshot) else {
if profile_data_mode_for(tab, snapshot).is_none() {
return render_failed_web_surface(tab, "Profile context is unavailable.", state_entity);
};
let permissions = web_surface_site_permissions_for_tab(tab, snapshot);
if let Some(url_change) =
self.web_surfaces.ensure_surface(tab, profile_data_mode, &permissions)
&& self.apply_web_surface_url_change(url_change)
{
cx.notify();
}
match self.web_surfaces.state(tab.id()) {
@@ -51,15 +43,20 @@ impl ElyShell {
}
pub(super) fn tick_external_web_surfaces(&mut self) -> bool {
let (visible_tab_ids, open_tab_ids) = match &self.state {
super::ShellState::Ready(core) => {
(core.visible_content_tab_ids().unwrap_or_else(|_| Vec::new()), core.open_tab_ids())
}
super::ShellState::StartupError(_) => (Vec::new(), Vec::new()),
let (visible_tab_ids, open_tab_ids, snapshot) = match &self.state {
super::ShellState::Ready(core) => (
core.visible_content_tab_ids().unwrap_or_else(|_| Vec::new()),
core.open_tab_ids(),
core.snapshot().ok(),
),
super::ShellState::StartupError(_) => (Vec::new(), Vec::new(), None),
};
self.web_surfaces.retain_tabs(&open_tab_ids);
let mut url_changed = snapshot
.as_ref()
.map(|snapshot| self.ensure_visible_web_surfaces(snapshot, &visible_tab_ids))
.unwrap_or(false);
let result = self.web_surfaces.tick(&visible_tab_ids);
let mut url_changed = false;
for url_change in result.url_changes {
url_changed |= self.apply_web_surface_url_change(url_change);
}
@@ -71,13 +68,9 @@ impl ElyShell {
tab_id: TabId,
bounds: Bounds<Pixels>,
scale_factor: f32,
cx: &mut Context<Self>,
_cx: &mut Context<Self>,
) {
if self.web_surfaces.record_viewport_size(&tab_id, bounds, scale_factor)
== WebSurfaceInputOutcome::Applied
{
cx.notify();
}
let _ = self.web_surfaces.record_viewport_size(&tab_id, bounds, scale_factor);
}
pub(super) fn scroll_external_web_viewport(
@@ -87,18 +80,15 @@ impl ElyShell {
delta: Point<Pixels>,
position: Point<Pixels>,
scale_factor: f32,
cx: &mut Context<Self>,
_cx: &mut Context<Self>,
) {
if self.web_surfaces.record_scroll_delta(
let _ = self.web_surfaces.record_scroll_delta(
&tab_id,
requested_url.as_str(),
delta,
position,
scale_factor,
) == WebSurfaceInputOutcome::Applied
{
cx.notify();
}
);
}
pub(super) fn hover_external_web_viewport(
@@ -106,13 +96,9 @@ impl ElyShell {
tab_id: TabId,
position: Point<Pixels>,
scale_factor: f32,
cx: &mut Context<Self>,
_cx: &mut Context<Self>,
) {
if self.web_surfaces.record_hover_point(&tab_id, position, scale_factor)
== WebSurfaceInputOutcome::Applied
{
cx.notify();
}
let _ = self.web_surfaces.record_hover_point(&tab_id, position, scale_factor);
}
pub(super) fn click_external_web_viewport(
@@ -121,19 +107,16 @@ impl ElyShell {
requested_url: String,
position: Point<Pixels>,
window: &mut gpui::Window,
cx: &mut Context<Self>,
_cx: &mut Context<Self>,
) {
self.focus_handle.focus(window);
let scale_factor = window.scale_factor();
if self.web_surfaces.record_click_point(
let _ = self.web_surfaces.record_click_point(
&tab_id,
requested_url.as_str(),
position,
scale_factor,
) == WebSurfaceInputOutcome::Applied
{
cx.notify();
}
);
}
/// Hand focus to the shell's root focus handle so subsequent
@@ -149,12 +132,11 @@ impl ElyShell {
tab_id: TabId,
requested_url: String,
text: &str,
cx: &mut Context<Self>,
_cx: &mut Context<Self>,
) -> bool {
if self.web_surfaces.record_typed_text(&tab_id, requested_url.as_str(), text)
== WebSurfaceInputOutcome::Applied
{
cx.notify();
return true;
}
@@ -163,6 +145,28 @@ impl ElyShell {
}
impl ElyShell {
fn ensure_visible_web_surfaces(
&mut self,
snapshot: &BrowserSnapshot,
visible_tab_ids: &[TabId],
) -> bool {
let mut url_changed = false;
let mut changed = false;
let visible_tabs = visible_external_web_tabs(&snapshot.tabs, visible_tab_ids);
for tab in visible_tabs {
let Some(profile_data_mode) = profile_data_mode_for(tab, snapshot) else {
continue;
};
let permissions = web_surface_site_permissions_for_tab(tab, snapshot);
let outcome = self.web_surfaces.ensure_surface(tab, profile_data_mode, &permissions);
changed |= outcome.changed;
if let Some(url_change) = outcome.url_change {
url_changed |= self.apply_web_surface_url_change(url_change);
}
}
changed || url_changed
}
fn apply_web_surface_url_change(&mut self, change: WebSurfaceUrlChange) -> bool {
let Ok(url) = UrlText::parse(change.loaded_url) else {
return false;
@@ -182,6 +186,17 @@ impl ElyShell {
}
}
fn visible_external_web_tabs<'a>(
tabs: &'a [BrowserTab],
visible_tab_ids: &[TabId],
) -> Vec<&'a BrowserTab> {
visible_tab_ids
.iter()
.filter_map(|tab_id| tabs.iter().find(|tab| tab.id() == tab_id))
.filter(|tab| super::web_surface::is_external_web_url(tab.url().as_str()))
.collect()
}
fn profile_data_mode_for(tab: &BrowserTab, snapshot: &BrowserSnapshot) -> Option<ProfileDataMode> {
snapshot.profiles.iter().find(|profile| profile.id() == tab.profile_id()).map(|profile| {
match profile.kind() {
@@ -190,3 +205,36 @@ fn profile_data_mode_for(tab: &BrowserTab, snapshot: &BrowserSnapshot) -> Option
}
})
}
#[cfg(test)]
mod tests {
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use super::visible_external_web_tabs;
#[test]
fn visible_external_web_tabs_follow_visible_order() -> Result<(), String> {
let first_id = TabId::new();
let second_id = TabId::new();
let internal_id = TabId::new();
let tabs = vec![
web_tab(first_id.clone(), "https://example.com/first")?,
web_tab(internal_id.clone(), "ely://settings")?,
web_tab(second_id.clone(), "http://example.com/second")?,
];
let visible =
visible_external_web_tabs(&tabs, &[internal_id, second_id.clone(), first_id.clone()])
.into_iter()
.map(|tab| tab.id().clone())
.collect::<Vec<_>>();
assert_eq!(visible, vec![second_id, first_id]);
Ok(())
}
fn web_tab(tab_id: TabId, url: &str) -> Result<BrowserTab, String> {
let url = UrlText::parse(url).map_err(|error| error.to_string())?;
Ok(BrowserTab::new(tab_id, SpaceId::new(), ProfileId::new(), "Web", url))
}
}
+161 -5
View File
@@ -4,7 +4,10 @@ use std::sync::Arc;
use ahash::AHasher;
#[cfg(target_os = "macos")]
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
use core_video::{
pixel_buffer::{CVPixelBuffer, kCVPixelBufferLock_ReadOnly, kCVPixelFormatType_32BGRA},
r#return::kCVReturnSuccess,
};
use gpui::RenderImage;
use image::{ImageBuffer, Rgba};
use thiserror::Error;
@@ -47,6 +50,9 @@ pub(super) struct WebSurfaceFrame {
render_state: String,
width: u32,
height: u32,
device_pixel_ratio: f32,
css_viewport_width: u32,
css_viewport_height: u32,
scroll_offset: WebSurfaceScrollOffset,
zoom_percent: u16,
click_point: Option<WebSurfaceClickPoint>,
@@ -83,6 +89,9 @@ impl WebSurfaceFrame {
render_state: frame.render_state().to_string(),
width: frame.width(),
height: frame.height(),
device_pixel_ratio: frame.device_pixel_ratio(),
css_viewport_width: frame.css_viewport_width(),
css_viewport_height: frame.css_viewport_height(),
scroll_offset,
zoom_percent,
click_point: None,
@@ -114,6 +123,9 @@ impl WebSurfaceFrame {
validate_hardware_pixel_buffer(pixel_buffer, parts.width, parts.height)?;
}
#[cfg(all(test, feature = "live-site-smoke"))]
let pixel_sample = pixel_sample_for_parts(&parts)?;
let image = if parts.rgba_bytes.is_empty() {
None
} else {
@@ -139,16 +151,19 @@ impl WebSurfaceFrame {
render_state: parts.render_state,
width: parts.width,
height: parts.height,
device_pixel_ratio: parts.device_pixel_ratio,
css_viewport_width: parts.css_viewport_width,
css_viewport_height: parts.css_viewport_height,
scroll_offset: parts.scroll_offset,
zoom_percent: parts.zoom_percent,
click_point: parts.click_point,
typed_text: parts.typed_text,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: parts.non_white_pixel_count,
non_white_pixel_count: pixel_sample.non_white_pixel_count,
#[cfg(all(test, feature = "live-site-smoke"))]
content_pixel_count: parts.content_pixel_count,
content_pixel_count: pixel_sample.content_pixel_count,
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: parts.sample_hash,
sample_hash: pixel_sample.sample_hash,
image,
#[cfg(target_os = "macos")]
pixel_buffer: parts.pixel_buffer,
@@ -183,7 +198,16 @@ impl WebSurfaceFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn size(&self) -> WebSurfaceSize {
WebSurfaceSize { width: self.width, height: self.height, device_pixel_ratio_percent: 100 }
WebSurfaceSize {
width: self.width,
height: self.height,
device_pixel_ratio_percent: (self.device_pixel_ratio * 100.0).round() as u16,
}
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn css_viewport_size(&self) -> (u32, u32) {
(self.css_viewport_width, self.css_viewport_height)
}
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -204,6 +228,16 @@ impl WebSurfaceFrame {
self.loaded_url.as_deref()
}
pub(super) fn has_visible_content_for_initial_display(&self) -> Result<bool, WebSurfaceError> {
#[cfg(target_os = "macos")]
if let Some(pixel_buffer) = self.pixel_buffer.as_ref() {
return sample_hardware_pixel_buffer(pixel_buffer)
.map(|sample| sample.has_visible_content());
}
Ok(true)
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn non_white_pixel_count(&self) -> u64 {
self.non_white_pixel_count
@@ -239,6 +273,9 @@ struct WebSurfaceFrameParts {
render_state: String,
width: u32,
height: u32,
device_pixel_ratio: f32,
css_viewport_width: u32,
css_viewport_height: u32,
scroll_offset: WebSurfaceScrollOffset,
zoom_percent: u16,
click_point: Option<WebSurfaceClickPoint>,
@@ -273,6 +310,18 @@ pub(super) enum WebSurfaceError {
#[cfg(target_os = "macos")]
#[error("servo hardware surface pixel format 0x{actual:x} is unsupported; expected 32BGRA")]
UnsupportedHardwareSurfaceFormat { actual: u32 },
#[cfg(target_os = "macos")]
#[error("servo hardware surface lock failed with status {status}")]
HardwareSurfaceLockFailed { status: i32 },
#[cfg(target_os = "macos")]
#[error("servo hardware surface unlock failed with status {status}")]
HardwareSurfaceUnlockFailed { status: i32 },
#[cfg(target_os = "macos")]
#[error("servo hardware surface base address is unavailable")]
HardwareSurfaceBaseAddressUnavailable,
#[cfg(target_os = "macos")]
#[error("servo hardware surface row stride {bytes_per_row} is too small for width {width}")]
HardwareSurfaceRowStrideTooSmall { width: usize, bytes_per_row: usize },
}
#[cfg(target_os = "macos")]
@@ -315,6 +364,113 @@ fn rgba_hash(bytes: &[u8]) -> u64 {
hasher.finish()
}
#[cfg(any(all(test, feature = "live-site-smoke"), target_os = "macos"))]
struct WebSurfacePixelSample {
non_white_pixel_count: u64,
content_pixel_count: u64,
#[cfg_attr(not(all(test, feature = "live-site-smoke")), allow(dead_code))]
sample_hash: u64,
}
#[cfg(any(all(test, feature = "live-site-smoke"), target_os = "macos"))]
impl WebSurfacePixelSample {
fn has_visible_content(&self) -> bool {
self.non_white_pixel_count > 0 && self.content_pixel_count > 0
}
}
#[cfg(all(test, feature = "live-site-smoke"))]
fn pixel_sample_for_parts(
parts: &WebSurfaceFrameParts,
) -> Result<WebSurfacePixelSample, WebSurfaceError> {
#[cfg(target_os = "macos")]
if let Some(pixel_buffer) = parts.pixel_buffer.as_ref() {
return sample_hardware_pixel_buffer(pixel_buffer);
}
Ok(WebSurfacePixelSample {
non_white_pixel_count: parts.non_white_pixel_count,
content_pixel_count: parts.content_pixel_count,
sample_hash: parts.sample_hash,
})
}
#[cfg(target_os = "macos")]
fn sample_hardware_pixel_buffer(
pixel_buffer: &CVPixelBuffer,
) -> Result<WebSurfacePixelSample, WebSurfaceError> {
let lock_status = pixel_buffer.lock_base_address(kCVPixelBufferLock_ReadOnly);
if lock_status != kCVReturnSuccess {
return Err(WebSurfaceError::HardwareSurfaceLockFailed { status: lock_status });
}
let sample = sample_locked_hardware_pixel_buffer(pixel_buffer);
let unlock_status = pixel_buffer.unlock_base_address(kCVPixelBufferLock_ReadOnly);
if unlock_status != kCVReturnSuccess {
return Err(WebSurfaceError::HardwareSurfaceUnlockFailed { status: unlock_status });
}
sample
}
#[cfg(target_os = "macos")]
fn sample_locked_hardware_pixel_buffer(
pixel_buffer: &CVPixelBuffer,
) -> Result<WebSurfacePixelSample, WebSurfaceError> {
let width = pixel_buffer.get_width();
let height = pixel_buffer.get_height();
let bytes_per_row = pixel_buffer.get_bytes_per_row();
let row_width = width.saturating_mul(4);
if bytes_per_row < row_width {
return Err(WebSurfaceError::HardwareSurfaceRowStrideTooSmall { width, bytes_per_row });
}
#[expect(unsafe_code)]
let base_address = unsafe { pixel_buffer.get_base_address() };
if base_address.is_null() {
return Err(WebSurfaceError::HardwareSurfaceBaseAddressUnavailable);
}
let byte_len = bytes_per_row.saturating_mul(height);
#[expect(unsafe_code)]
let bytes = unsafe { std::slice::from_raw_parts(base_address.cast::<u8>(), byte_len) };
Ok(sample_bgra_rows(bytes, width, height, bytes_per_row))
}
#[cfg(target_os = "macos")]
fn sample_bgra_rows(
bytes: &[u8],
width: usize,
height: usize,
bytes_per_row: usize,
) -> WebSurfacePixelSample {
let mut non_white_pixel_count = 0;
let mut content_pixel_count = 0;
let mut sample_hash = 0xcbf29ce484222325_u64;
for y in 0..height {
let row_start = y * bytes_per_row;
let row = &bytes[row_start..row_start + width * 4];
for (x, pixel) in row.chunks_exact(4).enumerate() {
let [blue, green, red, alpha] = [pixel[0], pixel[1], pixel[2], pixel[3]];
if alpha > 0 && (red < 245 || green < 245 || blue < 245) {
non_white_pixel_count += 1;
}
if alpha > 0 && (red < 220 || green < 220 || blue < 220) {
content_pixel_count += 1;
}
if (y * width + x).is_multiple_of(97) {
for byte in [red, green, blue, alpha] {
sample_hash ^= u64::from(byte);
sample_hash = sample_hash.wrapping_mul(0x100000001b3);
}
}
}
}
WebSurfacePixelSample { non_white_pixel_count, content_pixel_count, sample_hash }
}
fn resolve_render_image(
width: u32,
height: u32,
@@ -39,6 +39,7 @@ const LIVE_SITE_SCROLL_DOWN_Y: i32 = 360;
const LIVE_SITE_SCROLL_UP_Y: i32 = -240;
const LIVE_SITE_SCROLL_POINT_X: f32 = 320.0;
const LIVE_SITE_SCROLL_POINT_Y: f32 = 320.0;
const RETINA_SCALE_FACTOR: f32 = 2.0;
#[test]
fn web_surface_cases_cover_prd_reference_urls() -> Result<(), Box<dyn Error>> {
@@ -73,6 +74,13 @@ fn web_surface_resizes_prd_site_without_failed_state() -> Result<(), Box<dyn Err
})
}
#[test]
fn web_surface_reports_css_viewport_at_retina_scale() -> Result<(), Box<dyn Error>> {
run_isolated_live_site_test("web_surface_reports_css_viewport_at_retina_scale", || {
assert_web_surface_reports_retina_css_viewport()
})
}
fn run_isolated_live_site_test(
test_name: &str,
test: impl FnOnce() -> Result<(), Box<dyn Error>>,
@@ -199,12 +207,6 @@ fn assert_web_surface_resizes_prd_site() -> Result<(), Box<dyn Error>> {
LIVE_SURFACE_HEIGHT,
)?;
assert_eq!(
store.record_viewport_size(tab.id(), resized_live_surface_bounds(), 1.0),
WebSurfaceInputOutcome::Buffered,
"{}",
case.url,
);
assert_eq!(
store.record_viewport_size(tab.id(), resized_live_surface_bounds(), 1.0),
WebSurfaceInputOutcome::Applied,
@@ -223,6 +225,38 @@ fn assert_web_surface_resizes_prd_site() -> Result<(), Box<dyn Error>> {
Ok(())
}
fn assert_web_surface_reports_retina_css_viewport() -> Result<(), Box<dyn Error>> {
let mut store = WebSurfaceStore::new();
let profile_id = ProfileId::new();
let case = PRD_TOP_SITE_CASES
.iter()
.find(|case| case.url == "https://servo.org/")
.ok_or("missing servo.org live-site case")?;
let tab = web_tab(profile_id, case.url)?;
assert_eq!(
store.record_viewport_size(tab.id(), live_surface_bounds(), RETINA_SCALE_FACTOR),
WebSurfaceInputOutcome::Applied,
"{}",
case.url,
);
store.ensure_surface(&tab, ProfileDataMode::Transient, &[]);
wait_for_ready_frame_at_css_size(
&mut store,
tab.id(),
case,
ExpectedCssViewport {
physical_width: LIVE_SURFACE_WIDTH * RETINA_SCALE_FACTOR as u32,
physical_height: LIVE_SURFACE_HEIGHT * RETINA_SCALE_FACTOR as u32,
css_width: LIVE_SURFACE_WIDTH,
css_height: LIVE_SURFACE_HEIGHT,
dpr_percent: (RETINA_SCALE_FACTOR * 100.0).round() as u16,
},
)?;
store.close_surface(tab.id());
Ok(())
}
fn render_web_surface_frame(
store: &mut WebSurfaceStore,
profile_id: &ProfileId,
@@ -383,6 +417,62 @@ fn wait_for_ready_frame_at_size(
}
}
fn wait_for_ready_frame_at_css_size(
store: &mut WebSurfaceStore,
tab_id: &TabId,
case: &LiveSiteCase,
expected: ExpectedCssViewport,
) -> Result<WebSurfaceFrame, String> {
let started_at = Instant::now();
let mut last_error = None;
loop {
if started_at.elapsed() >= LIVE_SITE_WAIT_TIMEOUT {
return Err(last_error.unwrap_or_else(|| {
format!(
"timed out rendering {} at {}x{} css {}x{}",
case.url,
expected.physical_width,
expected.physical_height,
expected.css_width,
expected.css_height,
)
}));
}
store.tick(std::slice::from_ref(tab_id));
match store.state(tab_id) {
Some(WebSurfaceState::Ready(frame))
if frame.size().width == expected.physical_width
&& frame.size().height == expected.physical_height =>
{
if let Err(error) = validate_prd_frame_at_css_size(frame, case, expected) {
last_error = Some(error);
thread::sleep(LIVE_SITE_WAIT_INTERVAL);
continue;
}
return Ok(frame.clone());
}
Some(WebSurfaceState::Ready(_)) => {}
Some(WebSurfaceState::Failed { message, .. }) => {
return Err(format!("{} failed: {message}", case.url));
}
Some(WebSurfaceState::Loading { .. }) | None => {}
}
thread::sleep(LIVE_SITE_WAIT_INTERVAL);
}
}
#[derive(Clone, Copy)]
struct ExpectedCssViewport {
physical_width: u32,
physical_height: u32,
css_width: u32,
css_height: u32,
dpr_percent: u16,
}
fn validate_prd_frame(
frame: &WebSurfaceFrame,
case: &LiveSiteCase,
@@ -401,6 +491,10 @@ fn validate_prd_frame(
frame.scroll_offset().y() == expected_scroll_y,
format!("{} scroll: {:?}", case.url, frame.scroll_offset()),
)?;
require(
frame.css_viewport_size() == (LIVE_SURFACE_WIDTH, LIVE_SURFACE_HEIGHT),
format!("{} CSS viewport: {:?}", case.url, frame.css_viewport_size()),
)?;
require_render_state_is_open(frame.render_state(), case.url)?;
require(
frame.url_label().contains(normalized_url(case.url)),
@@ -411,23 +505,28 @@ fn validate_prd_frame(
format!("title: {}", frame.title_label()),
)?;
let expected_detail = if expected_scroll_y == 0 {
format!("{} 934x657", frame.render_state())
format!("{} {}x{}", frame.render_state(), LIVE_SURFACE_WIDTH, LIVE_SURFACE_HEIGHT)
} else {
format!("{} 934x657 y={expected_scroll_y}", frame.render_state())
format!(
"{} {}x{} y={expected_scroll_y}",
frame.render_state(),
LIVE_SURFACE_WIDTH,
LIVE_SURFACE_HEIGHT,
)
};
require(
frame.detail_label() == expected_detail,
format!("{} detail: {}", case.url, frame.detail_label()),
)?;
if frame.has_hardware_surface() {
return Ok(());
}
require(frame.non_white_pixel_count() > 0, case.url.to_string())?;
require(
frame.non_white_pixel_count() > 0,
format!("{} non-white pixels: {}", case.url, frame.non_white_pixel_count()),
)?;
require(
frame.content_pixel_count() >= MINIMUM_CONTENT_PIXELS,
format!("{} content pixels: {}", case.url, frame.content_pixel_count()),
)?;
require(frame.sample_hash() > 0, case.url.to_string())
require(frame.sample_hash() > 0, format!("{} sample hash: {}", case.url, frame.sample_hash()))
}
fn validate_prd_frame_at_size(
@@ -446,6 +545,10 @@ fn validate_prd_frame_at_size(
format!("{} size: {:?}", case.url, frame.size()),
)?;
require_render_state_is_open(frame.render_state(), case.url)?;
require(
frame.css_viewport_size() == (expected_width, expected_height),
format!("{} CSS viewport: {:?}", case.url, frame.css_viewport_size()),
)?;
require(
frame.url_label().contains(normalized_url(case.url)),
format!("url: {}", frame.url_label()),
@@ -454,15 +557,45 @@ fn validate_prd_frame_at_size(
frame.title_label().contains(case.title_fragment),
format!("title: {}", frame.title_label()),
)?;
if frame.has_hardware_surface() {
return Ok(());
}
require(frame.non_white_pixel_count() > 0, case.url.to_string())?;
require(
frame.non_white_pixel_count() > 0,
format!("{} non-white pixels: {}", case.url, frame.non_white_pixel_count()),
)?;
require(
frame.content_pixel_count() >= MINIMUM_CONTENT_PIXELS,
format!("{} content pixels: {}", case.url, frame.content_pixel_count()),
)?;
require(frame.sample_hash() > 0, case.url.to_string())
require(frame.sample_hash() > 0, format!("{} sample hash: {}", case.url, frame.sample_hash()))
}
fn validate_prd_frame_at_css_size(
frame: &WebSurfaceFrame,
case: &LiveSiteCase,
expected: ExpectedCssViewport,
) -> Result<(), String> {
require(
frame.size()
== WebSurfaceSize {
width: expected.physical_width,
height: expected.physical_height,
device_pixel_ratio_percent: expected.dpr_percent,
},
format!("{} size: {:?}", case.url, frame.size()),
)?;
require_render_state_is_open(frame.render_state(), case.url)?;
require(
frame.css_viewport_size() == (expected.css_width, expected.css_height),
format!("{} CSS viewport: {:?}", case.url, frame.css_viewport_size()),
)?;
require(
frame.url_label().contains(normalized_url(case.url)),
format!("url: {}", frame.url_label()),
)?;
require(
frame.title_label().contains(case.title_fragment),
format!("title: {}", frame.title_label()),
)?;
Ok(())
}
fn log_prd_frame(label: &str, frame: &WebSurfaceFrame, case: &LiveSiteCase) {
+182 -172
View File
@@ -1,4 +1,4 @@
use std::{collections::BTreeMap, fs, path::PathBuf};
use std::{collections::BTreeMap, fs, path::PathBuf, time::Instant};
use ely_domain::{BrowserTab, ProfileId, TabId};
@@ -13,10 +13,11 @@ use super::{
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
web_surface_permissions::WebSurfaceSitePermission,
web_surface_state::WebSurfacePendingInput,
web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, WorkerResponse},
};
pub(super) struct WebSurfaceRuntime {
clients: BTreeMap<WebSurfaceRuntimeScope, ScopedRuntimeClient>,
workers: BTreeMap<WebSurfaceRuntimeScope, ScopedWorker>,
sessions: BTreeMap<TabId, WebSurfaceSession>,
client_factory: LiveRuntimeClientFactory,
}
@@ -24,15 +25,15 @@ pub(super) struct WebSurfaceRuntime {
impl WebSurfaceRuntime {
pub(super) fn new() -> Self {
Self {
clients: BTreeMap::new(),
workers: BTreeMap::new(),
sessions: BTreeMap::new(),
client_factory: new_servo_live_client,
}
}
#[cfg(test)]
fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self {
Self { clients: BTreeMap::new(), sessions: BTreeMap::new(), client_factory }
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(
@@ -44,15 +45,17 @@ impl WebSurfaceRuntime {
input: WebSurfacePendingInput,
) -> Result<WebSurfaceEnsureResult, String> {
let scope = WebSurfaceRuntimeScope::new(tab.profile_id().clone(), profile_data_mode);
self.ensure_runtime(scope.clone())?;
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);
@@ -62,89 +65,116 @@ impl WebSurfaceRuntime {
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 frame = self
.client_for_scope(&scope)?
.ensure(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(),
})?
.map(|frame| {
WebSurfaceFrame::from_live_frame(
requested_url.clone(),
next_scroll_offset,
zoom_percent,
frame,
)
.map_err(|error| error.to_string())
})
.transpose()?;
let url_change = frame.as_ref().and_then(|frame| {
self.sessions
.get_mut(tab.id())
.and_then(|session| session.url_change_for(tab.id(), requested_url.as_str(), frame))
});
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 session = session_for_scope(&mut self.sessions, tab.id(), scope);
session.requested_url = requested_url.clone();
session.size = size;
session.zoom_percent = zoom_percent;
session.scroll_offset = next_scroll_offset;
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, frame, url_change })
Ok(WebSurfaceEnsureResult { requested_url, started_loading })
}
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec<WebSurfaceRuntimeFrame> {
let mut frames = Vec::new();
for job in visible_poll_jobs(&self.sessions, visible_tab_ids) {
let result = match self.clients.get_mut(&job.scope) {
Some(client) => client.client.poll(job.tab_id.as_str().to_string()),
None => Err(missing_runtime_message(&job.scope)),
// 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;
};
match result {
Ok(Some(frame)) => match WebSurfaceFrame::from_live_frame(
job.requested_url.clone(),
job.scroll_offset,
job.zoom_percent,
frame,
) {
Ok(frame) => {
let url_change = self.sessions.get_mut(&job.tab_id).and_then(|session| {
session.url_change_for(&job.tab_id, job.requested_url.as_str(), &frame)
});
frames.push(WebSurfaceRuntimeFrame::Ready {
tab_id: job.tab_id,
frame: Box::new(frame),
url_change,
})
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(),
}),
}
}
Err(error) => frames.push(WebSurfaceRuntimeFrame::Failed {
tab_id: job.tab_id,
message: error.to_string(),
}),
},
Ok(None) => {}
Err(error) => frames
.push(WebSurfaceRuntimeFrame::Failed { tab_id: job.tab_id, message: error }),
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
}
@@ -153,81 +183,60 @@ impl WebSurfaceRuntime {
let Some(session) = self.sessions.remove(tab_id) else {
return;
};
if let Some(client) = self.clients.get_mut(&session.scope) {
let _ = client.client.close(tab_id.as_str().to_string());
if let Some(scoped) = self.workers.get(&session.scope) {
scoped.worker.submit_close(tab_id.as_str().to_string());
}
}
fn ensure_runtime(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
if self.clients.contains_key(&scope) {
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)?;
self.clients.insert(scope, ScopedRuntimeClient { client, transient_profile_data_dir });
let worker = LiveRuntimeWorker::new(client)?;
self.workers.insert(scope, ScopedWorker { worker, transient_profile_data_dir });
Ok(())
}
fn client_for_scope(
&mut self,
scope: &WebSurfaceRuntimeScope,
) -> Result<&mut dyn LiveRuntimeClient, String> {
match self.clients.get_mut(scope) {
Some(client) => Ok(client.client.as_mut()),
None => Err(missing_runtime_message(scope)),
}
fn lookup_session_tab_id(&self, tab_id: &str) -> Option<TabId> {
self.sessions.keys().find(|key| key.as_str() == tab_id).cloned()
}
#[cfg(test)]
fn client_count_for_test(&self) -> usize {
self.clients.len()
pub(super) fn client_count_for_test(&self) -> usize {
self.workers.len()
}
#[cfg(test)]
fn session_scope_for_test(&self, tab_id: &TabId) -> Option<&WebSurfaceRuntimeScope> {
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
.clients
.workers
.values()
.filter_map(|client| client.transient_profile_data_dir.clone())
.filter_map(|scoped| scoped.transient_profile_data_dir.clone())
.collect::<Vec<_>>();
self.clients.clear();
self.workers.clear();
for path in transient_profile_data_dirs {
let _ = fs::remove_dir_all(path);
}
}
}
type LiveRuntimeClientFactory = fn(PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String>;
trait LiveRuntimeClient {
fn ensure(&mut self, request: ServoLiveEnsureRequest) -> Result<Option<WebLiveFrame>, String>;
fn poll(&mut self, tab_id: String) -> Result<Option<WebLiveFrame>, String>;
fn close(&mut self, tab_id: String) -> Result<(), String>;
}
type WebLiveFrame = crate::services::servo_live::ServoLiveFrame;
impl LiveRuntimeClient for ServoLiveClient {
fn ensure(&mut self, request: ServoLiveEnsureRequest) -> Result<Option<WebLiveFrame>, String> {
ServoLiveClient::ensure(self, request).map_err(|error| error.to_string())
}
fn poll(&mut self, tab_id: String) -> Result<Option<WebLiveFrame>, String> {
ServoLiveClient::poll(self, tab_id).map_err(|error| error.to_string())
}
fn close(&mut self, tab_id: String) -> Result<(), String> {
ServoLiveClient::close(self, tab_id).map_err(|error| error.to_string())
}
}
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)
@@ -235,31 +244,31 @@ fn new_servo_live_client(config_dir: PathBuf) -> Result<Box<dyn LiveRuntimeClien
.map_err(|error| error.to_string())
}
struct ScopedRuntimeClient {
client: Box<dyn LiveRuntimeClient>,
struct ScopedWorker {
worker: LiveRuntimeWorker,
transient_profile_data_dir: Option<PathBuf>,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct WebSurfaceRuntimeScope {
pub(super) struct WebSurfaceRuntimeScope {
profile_id: ProfileId,
profile_data_mode: ProfileDataMode,
}
impl WebSurfaceRuntimeScope {
fn new(profile_id: ProfileId, profile_data_mode: ProfileDataMode) -> Self {
pub(super) fn new(profile_id: ProfileId, profile_data_mode: ProfileDataMode) -> Self {
Self { profile_id, profile_data_mode }
}
}
#[derive(Clone)]
struct WebSurfaceSession {
scope: WebSurfaceRuntimeScope,
requested_url: String,
size: WebSurfaceSize,
zoom_percent: u16,
scroll_offset: WebSurfaceScrollOffset,
pending_user_navigation: bool,
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 {
@@ -310,20 +319,9 @@ impl WebSurfaceSession {
}
}
#[derive(Clone)]
struct WebSurfacePollJob {
tab_id: TabId,
scope: WebSurfaceRuntimeScope,
requested_url: String,
scroll_offset: WebSurfaceScrollOffset,
zoom_percent: u16,
}
pub(super) struct WebSurfaceEnsureResult {
pub(super) requested_url: String,
pub(super) started_loading: bool,
pub(super) frame: Option<WebSurfaceFrame>,
pub(super) url_change: Option<WebSurfaceUrlChange>,
}
pub(super) enum WebSurfaceRuntimeFrame {
@@ -364,7 +362,7 @@ fn config_dir_for_scope(
}
}
fn session_for_scope<'a>(
pub(super) fn session_for_scope<'a>(
sessions: &'a mut BTreeMap<TabId, WebSurfaceSession>,
tab_id: &TabId,
scope: WebSurfaceRuntimeScope,
@@ -377,33 +375,6 @@ fn session_for_scope<'a>(
session
}
fn visible_poll_jobs(
sessions: &BTreeMap<TabId, WebSurfaceSession>,
visible_tab_ids: &[TabId],
) -> Vec<WebSurfacePollJob> {
sessions
.iter()
.filter(|(tab_id, _)| {
visible_tab_ids.iter().any(|visible_tab_id| visible_tab_id == *tab_id)
})
.map(|(tab_id, session)| WebSurfacePollJob {
tab_id: tab_id.clone(),
scope: session.scope.clone(),
requested_url: session.requested_url.clone(),
scroll_offset: session.scroll_offset,
zoom_percent: session.zoom_percent,
})
.collect()
}
fn missing_runtime_message(scope: &WebSurfaceRuntimeScope) -> String {
format!(
"Servo live runtime is unavailable for profile {} ({:?})",
scope.profile_id.as_str(),
scope.profile_data_mode
)
}
fn scroll_wire_fields(
delta: Option<super::web_surface_geometry::WebSurfaceScrollDelta>,
point: Option<super::web_surface_geometry::WebSurfaceClickPoint>,
@@ -423,6 +394,45 @@ fn input_requests_history_navigation(input: &WebSurfacePendingInput) -> bool {
|| 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(
@@ -8,14 +8,22 @@ use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use crate::{
services::ProfileDataMode,
shell::{
WebSurfaceStore,
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
web_surface_state::WebSurfacePendingInput,
web_surface_state::{WebSurfaceInputOutcome, WebSurfacePendingInput},
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
},
};
use super::*;
use crate::services::servo_live::{ServoLiveEnsureRequest, ServoLiveFrame};
static FAKE_CLOSE_COUNT: AtomicUsize = AtomicUsize::new(0);
static FAKE_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
static IDLE_SKIP_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
static RECOVERY_FACTORY_COUNT: AtomicUsize = AtomicUsize::new(0);
static FAILING_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
#[test]
fn runtime_keeps_independent_clients_for_profile_scopes() -> Result<(), String> {
@@ -47,6 +55,8 @@ fn runtime_keeps_independent_clients_for_profile_scopes() -> Result<(), String>
pending_input(),
)?;
runtime.flush_for_test();
assert_eq!(runtime.client_count_for_test(), 2);
assert_eq!(
runtime.session_scope_for_test(first_tab.id()),
@@ -66,16 +76,114 @@ fn close_tab_removes_session_and_closes_client() -> Result<(), String> {
let tab = web_tab(TabId::new(), ProfileId::new(), "https://example.com/close")?;
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test();
runtime.close_tab(tab.id());
runtime.flush_for_test();
assert_eq!(runtime.session_scope_for_test(tab.id()), None);
assert_eq!(FAKE_CLOSE_COUNT.load(Ordering::SeqCst), before + 1);
runtime.close_tab(tab.id());
runtime.flush_for_test();
assert_eq!(FAKE_CLOSE_COUNT.load(Ordering::SeqCst), before + 1);
Ok(())
}
#[test]
fn unchanged_surface_without_input_skips_runtime_ensure() -> Result<(), String> {
IDLE_SKIP_ENSURE_COUNT.store(0, Ordering::SeqCst);
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
idle_skip_client_factory,
));
let tab = web_tab(TabId::new(), ProfileId::new(), "https://example.com/idle")?;
assert_eq!(
store.record_viewport_size(tab.id(), viewport_bounds(), 1.0),
WebSurfaceInputOutcome::Applied,
);
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]).changed);
store.flush_runtime_for_test();
assert_eq!(IDLE_SKIP_ENSURE_COUNT.load(Ordering::SeqCst), 1);
assert!(!store.ensure_surface(&tab, ProfileDataMode::Transient, &[]).changed);
store.flush_runtime_for_test();
assert_eq!(IDLE_SKIP_ENSURE_COUNT.load(Ordering::SeqCst), 1);
Ok(())
}
#[test]
fn sidecar_exit_removes_dead_runtime_client() -> Result<(), String> {
RECOVERY_FACTORY_COUNT.store(0, Ordering::SeqCst);
let mut runtime = WebSurfaceRuntime::new_with_client_factory(recovery_client_factory);
let profile = ProfileId::new();
let crashed_tab = web_tab(TabId::new(), profile.clone(), "https://example.com/crash")?;
runtime.ensure_tab(
&crashed_tab,
surface_size(),
ProfileDataMode::Transient,
&[],
pending_input(),
)?;
runtime.flush_for_test();
let frames = runtime.tick(&[crashed_tab.id().clone()]);
assert!(
frames.iter().any(
|frame| matches!(frame, WebSurfaceRuntimeFrame::Failed { tab_id, .. } if tab_id == crashed_tab.id())
),
"the crashed tab must surface as a Failed frame",
);
assert_eq!(runtime.client_count_for_test(), 0);
let next_tab = web_tab(TabId::new(), profile, "https://example.com/next")?;
runtime.ensure_tab(
&next_tab,
surface_size(),
ProfileDataMode::Transient,
&[],
pending_input(),
)?;
runtime.flush_for_test();
assert_eq!(RECOVERY_FACTORY_COUNT.load(Ordering::SeqCst), 2);
assert_eq!(runtime.client_count_for_test(), 1);
Ok(())
}
#[test]
fn failed_surface_ensure_waits_for_a_new_key_before_retrying() -> Result<(), String> {
FAILING_ENSURE_COUNT.store(0, Ordering::SeqCst);
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
failing_client_factory,
));
let tab = web_tab(TabId::new(), ProfileId::new(), "https://example.com/crash")?;
assert_eq!(
store.record_viewport_size(tab.id(), viewport_bounds(), 1.0),
WebSurfaceInputOutcome::Applied,
);
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]).changed);
store.flush_runtime_for_test();
let tick = store.tick(&[tab.id().clone()]);
assert!(tick.changed, "the failing client must surface a state change via tick");
assert_eq!(FAILING_ENSURE_COUNT.load(Ordering::SeqCst), 1);
assert!(!store.ensure_surface(&tab, ProfileDataMode::Transient, &[]).changed);
store.flush_runtime_for_test();
let _ = store.tick(&[tab.id().clone()]);
assert_eq!(FAILING_ENSURE_COUNT.load(Ordering::SeqCst), 1);
assert_eq!(
store.record_viewport_size(tab.id(), resized_viewport_bounds(), 1.0),
WebSurfaceInputOutcome::Applied,
);
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]).changed);
store.flush_runtime_for_test();
let _ = store.tick(&[tab.id().clone()]);
assert_eq!(FAILING_ENSURE_COUNT.load(Ordering::SeqCst), 2);
Ok(())
}
#[test]
fn session_scope_change_resets_tab_state() {
let tab_id = TabId::new();
@@ -101,28 +209,110 @@ fn session_scope_change_resets_tab_state() {
}
struct FakeLiveRuntimeClient;
struct IdleSkipLiveRuntimeClient;
struct SidecarExitLiveRuntimeClient;
struct FailingLiveRuntimeClient;
impl LiveRuntimeClient for FakeLiveRuntimeClient {
fn ensure(&mut self, _request: ServoLiveEnsureRequest) -> Result<Option<WebLiveFrame>, String> {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
FAKE_ENSURE_COUNT.fetch_add(1, Ordering::SeqCst);
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<WebLiveFrame>, String> {
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), String> {
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
FAKE_CLOSE_COUNT.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
impl LiveRuntimeClient for IdleSkipLiveRuntimeClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
IDLE_SKIP_ENSURE_COUNT.fetch_add(1, Ordering::SeqCst);
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
impl LiveRuntimeClient for SidecarExitLiveRuntimeClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Err(LiveRuntimeClientError::SidecarExited)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Err(LiveRuntimeClientError::SidecarExited)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Err(LiveRuntimeClientError::SidecarExited)
}
}
impl LiveRuntimeClient for FailingLiveRuntimeClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
FAILING_ENSURE_COUNT.fetch_add(1, Ordering::SeqCst);
Err(LiveRuntimeClientError::SidecarExited)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
fn fake_client_factory(
_config_dir: std::path::PathBuf,
) -> Result<Box<dyn LiveRuntimeClient>, String> {
Ok(Box::new(FakeLiveRuntimeClient))
}
fn idle_skip_client_factory(
_config_dir: std::path::PathBuf,
) -> Result<Box<dyn LiveRuntimeClient>, String> {
Ok(Box::new(IdleSkipLiveRuntimeClient))
}
fn recovery_client_factory(
_config_dir: std::path::PathBuf,
) -> Result<Box<dyn LiveRuntimeClient>, String> {
let factory_call = RECOVERY_FACTORY_COUNT.fetch_add(1, Ordering::SeqCst);
if factory_call == 0 {
return Ok(Box::new(SidecarExitLiveRuntimeClient));
}
Ok(Box::new(FakeLiveRuntimeClient))
}
fn failing_client_factory(
_config_dir: std::path::PathBuf,
) -> Result<Box<dyn LiveRuntimeClient>, String> {
Ok(Box::new(FailingLiveRuntimeClient))
}
fn web_tab(tab_id: TabId, profile_id: ProfileId, url: &str) -> Result<BrowserTab, String> {
let url = UrlText::parse(url).map_err(|error| error.to_string())?;
Ok(BrowserTab::new(tab_id, SpaceId::new(), profile_id, "Web", url))
@@ -132,8 +322,23 @@ fn surface_size() -> WebSurfaceSize {
WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 }
}
fn viewport_bounds() -> gpui::Bounds<gpui::Pixels> {
gpui::Bounds::new(
gpui::point(gpui::px(0.0), gpui::px(0.0)),
gpui::size(gpui::px(640.0), gpui::px(480.0)),
)
}
fn resized_viewport_bounds() -> gpui::Bounds<gpui::Pixels> {
gpui::Bounds::new(
gpui::point(gpui::px(0.0), gpui::px(0.0)),
gpui::size(gpui::px(720.0), gpui::px(480.0)),
)
}
fn pending_input() -> WebSurfacePendingInput {
WebSurfacePendingInput {
enqueued_at: None,
scroll_offset: WebSurfaceScrollOffset::default(),
scroll_delta: None,
scroll_point: None,
+96 -5
View File
@@ -1,3 +1,5 @@
use std::time::Instant;
use ely_domain::TabId;
use gpui::{Bounds, Pixels};
@@ -6,6 +8,7 @@ use super::{
web_surface_geometry::{
WebSurfaceClickPoint, WebSurfaceScrollDelta, WebSurfaceScrollOffset, WebSurfaceSize,
},
web_surface_permissions::WebSurfaceSitePermission,
};
pub(super) struct WebSurfaceScrollState {
@@ -43,6 +46,7 @@ pub(super) struct WebSurfaceTextInputState {
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct WebSurfacePendingInput {
pub(super) enqueued_at: Option<Instant>,
pub(super) scroll_offset: WebSurfaceScrollOffset,
pub(super) scroll_delta: Option<WebSurfaceScrollDelta>,
pub(super) scroll_point: Option<WebSurfaceClickPoint>,
@@ -68,9 +72,6 @@ pub(super) enum WebSurfaceInputOutcome {
Applied,
/// Same value as currently recorded — nothing to flush downstream.
NoChange,
/// First sighting of a new value; held back until a second
/// matching measurement confirms it (viewport-resize debounce).
Buffered,
/// Geometry constructor rejected the input (zero/NaN/negative
/// bounds). The viewport never measured cleanly.
DroppedInvalidBounds,
@@ -113,11 +114,12 @@ pub(super) enum WebSurfaceState {
pub(super) struct PerTabSurface {
pub(super) viewport_bounds: Option<Bounds<Pixels>>,
pub(super) viewport_size: Option<WebSurfaceSize>,
pub(super) pending_viewport_size: Option<WebSurfaceSize>,
pub(super) last_ensure_key: Option<WebSurfaceEnsureKey>,
pub(super) hover_point: Option<WebSurfaceClickPoint>,
pub(super) click_point: Option<WebSurfaceClickState>,
pub(super) pending_scroll_delta: Option<WebSurfaceScrollDelta>,
pub(super) pending_scroll_point: Option<WebSurfaceClickPoint>,
pub(super) pending_input_started_at: Option<Instant>,
pub(super) scroll_offset: Option<WebSurfaceScrollState>,
pub(super) typed_text: Option<WebSurfaceTextInputState>,
pub(super) state: Option<WebSurfaceState>,
@@ -128,17 +130,38 @@ impl PerTabSurface {
Self {
viewport_bounds: None,
viewport_size: None,
pending_viewport_size: None,
last_ensure_key: None,
hover_point: None,
click_point: None,
pending_scroll_delta: None,
pending_scroll_point: None,
pending_input_started_at: None,
scroll_offset: None,
typed_text: None,
state: None,
}
}
pub(super) fn mark_pending_input_started(&mut self) {
self.pending_input_started_at.get_or_insert_with(Instant::now);
}
pub(super) fn should_ensure(&self, key: &WebSurfaceEnsureKey) -> bool {
self.last_ensure_key.as_ref() != Some(key) || self.has_pending_input()
}
pub(super) fn mark_ensured(&mut self, key: WebSurfaceEnsureKey) {
self.last_ensure_key = Some(key);
}
fn has_pending_input(&self) -> bool {
self.hover_point.is_some()
|| self.click_point.is_some()
|| self.pending_scroll_delta.is_some()
|| self.pending_scroll_point.is_some()
|| self.typed_text.is_some()
}
pub(super) fn scroll_offset_for(&self, requested_url: &str) -> WebSurfaceScrollOffset {
self.scroll_offset
.as_ref()
@@ -147,3 +170,71 @@ impl PerTabSurface {
.unwrap_or_default()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct WebSurfaceEnsureKey {
requested_url: String,
size: WebSurfaceSize,
zoom_percent: u16,
permissions: Vec<WebSurfaceSitePermission>,
}
impl WebSurfaceEnsureKey {
pub(super) fn new(
requested_url: String,
size: WebSurfaceSize,
zoom_percent: u16,
permissions: &[WebSurfaceSitePermission],
) -> Self {
Self { requested_url, size, zoom_percent, permissions: permissions.to_vec() }
}
}
#[cfg(test)]
mod tests {
use gpui::{point, px};
use super::*;
#[test]
fn unchanged_surface_without_input_skips_ensure() {
let key = ensure_key("https://example.com/", 800, 600);
let mut surface = PerTabSurface::new();
assert!(surface.should_ensure(&key));
surface.mark_ensured(key.clone());
assert!(!surface.should_ensure(&key));
}
#[test]
fn pending_input_forces_ensure_even_when_key_matches() {
let key = ensure_key("https://example.com/", 800, 600);
let mut surface = PerTabSurface::new();
surface.mark_ensured(key.clone());
surface.pending_scroll_delta =
WebSurfaceScrollDelta::from_point(point(px(0.0), px(120.0)), 1.0);
assert!(surface.should_ensure(&key));
}
#[test]
fn viewport_change_forces_ensure() {
let old_key = ensure_key("https://example.com/", 800, 600);
let new_key = ensure_key("https://example.com/", 1024, 768);
let mut surface = PerTabSurface::new();
surface.mark_ensured(old_key);
assert!(surface.should_ensure(&new_key));
}
fn ensure_key(url: &str, width: u32, height: u32) -> WebSurfaceEnsureKey {
WebSurfaceEnsureKey::new(
url.to_string(),
WebSurfaceSize { width, height, device_pixel_ratio_percent: 100 },
100,
&[],
)
}
}
+50 -11
View File
@@ -62,16 +62,11 @@ fn scroll_delta_enters_pending_input_after_wheel() -> Result<(), Box<dyn Error>>
}
#[test]
fn viewport_size_changes_after_stable_second_measurement() -> Result<(), Box<dyn Error>> {
fn viewport_size_changes_on_first_clean_measurement() -> Result<(), Box<dyn Error>> {
let mut store = WebSurfaceStore::new();
let tab = web_tab("https://example.com/resize")?;
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0));
assert_eq!(
store.record_viewport_size(tab.id(), resized_once_bounds(), 1.0),
WebSurfaceInputOutcome::Buffered,
"first sighting of a new size must wait for a confirming second measurement",
);
assert_applied(store.record_viewport_size(tab.id(), resized_once_bounds(), 1.0));
Ok(())
}
@@ -327,11 +322,7 @@ fn click_survives_viewport_bounds_change_before_drain() -> Result<(), Box<dyn Er
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0));
assert_applied(store.record_click_point(tab.id(), url, point(px(160.0), px(120.0)), 1.0));
// Resize: first measurement is buffered (requires confirmation).
assert_eq!(
store.record_viewport_size(tab.id(), resized_once_bounds(), 1.0),
WebSurfaceInputOutcome::Buffered,
);
assert_applied(store.record_viewport_size(tab.id(), resized_once_bounds(), 1.0));
let input = store.take_pending_input(tab.id(), url);
assert_eq!(
@@ -490,6 +481,54 @@ fn hardware_live_frame_rejects_unsupported_surface_format() -> Result<(), String
Ok(())
}
#[cfg(all(target_os = "macos", feature = "live-site-smoke"))]
#[test]
fn hardware_live_frame_samples_bgra_surface_pixels() -> Result<(), String> {
use core_video::{
pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA},
r#return::kCVReturnSuccess,
};
use crate::services::servo_live::ServoLiveFrame;
use crate::shell::web_surface_frame::WebSurfaceFrame;
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 2, 1, None)
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
let lock_status = pixel_buffer.lock_base_address(0);
if lock_status != kCVReturnSuccess {
return Err(format!("CVPixelBufferLockBaseAddress returned status {lock_status}"));
}
let bytes_per_row = pixel_buffer.get_bytes_per_row();
#[expect(unsafe_code)]
unsafe {
let base_address = pixel_buffer.get_base_address().cast::<u8>();
let bytes = std::slice::from_raw_parts_mut(base_address, bytes_per_row);
bytes[0..8].copy_from_slice(&[
0, 0, 255, 255, // red in BGRA memory order
255, 255, 255, 255,
]);
}
let unlock_status = pixel_buffer.unlock_base_address(0);
if unlock_status != kCVReturnSuccess {
return Err(format!("CVPixelBufferUnlockBaseAddress returned status {unlock_status}"));
}
let live = ServoLiveFrame::for_test_with_pixel_buffer(2, 1, pixel_buffer);
let frame = WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
live,
)
.map_err(|error| error.to_string())?;
assert_eq!(frame.non_white_pixel_count(), 1);
assert_eq!(frame.content_pixel_count(), 1);
assert_ne!(frame.sample_hash(), 0);
Ok(())
}
fn web_bounds() -> Bounds<gpui::Pixels> {
Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)))
}
+16 -4
View File
@@ -1,14 +1,14 @@
use ely_domain::{BrowserTab, TabId};
use gpui::{
AnyElement, App, Entity, ImageSource, InteractiveElement, IntoElement, MouseButton, ObjectFit,
ParentElement, Styled, StyledImage, Window, canvas, div, img, px, rgb, surface,
AnyElement, App, Corners, Entity, ImageSource, InteractiveElement, IntoElement, MouseButton,
ObjectFit, ParentElement, Styled, StyledImage, Window, canvas, div, img, px, rgb, surface,
};
use super::{
ElyShell, web_surface_frame::WebSurfaceFrame,
web_surface_geometry::servo_scroll_delta_from_wheel_delta,
};
use ely_design_system::colors;
use ely_design_system::{colors, spacing};
pub(super) fn render_ready_web_surface(
frame: &WebSurfaceFrame,
@@ -20,7 +20,10 @@ pub(super) fn render_ready_web_surface(
return render_web_surface(
tab,
state_entity,
surface(pixel_buffer.clone()).size_full().object_fit(ObjectFit::Fill),
surface(pixel_buffer.clone())
.size_full()
.corner_radii(web_surface_corner_radii())
.object_fit(ObjectFit::Fill),
);
}
@@ -53,6 +56,15 @@ pub(super) fn render_failed_web_surface(
render_web_surface(tab, state_entity, error_page(message))
}
fn web_surface_corner_radii() -> Corners<gpui::Pixels> {
Corners {
top_left: px(0.0),
top_right: px(0.0),
bottom_right: px(spacing::RADIUS_CARD),
bottom_left: px(spacing::RADIUS_CARD),
}
}
fn error_page(message: &str) -> impl IntoElement {
div()
.size_full()
@@ -0,0 +1,336 @@
use std::{
collections::BTreeMap,
io,
sync::{Arc, Condvar, Mutex, mpsc},
thread::JoinHandle,
};
use crate::services::servo_live::{
ServoLiveClient, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame,
};
/// IPC surface for the per-profile Servo sidecar.
///
/// Production wraps [`ServoLiveClient`] directly; tests substitute a
/// fake. The contract: every call is blocking and may run for tens of
/// milliseconds. Implementations live on the worker thread, never the
/// UI thread.
pub(super) trait LiveRuntimeClient: Send {
fn ensure(
&mut self,
request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError>;
fn poll(&mut self, tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError>;
fn close(&mut self, tab_id: String) -> Result<(), LiveRuntimeClientError>;
}
impl LiveRuntimeClient for ServoLiveClient {
fn ensure(
&mut self,
request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
ServoLiveClient::ensure(self, request).map_err(LiveRuntimeClientError::from)
}
fn poll(&mut self, tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
ServoLiveClient::poll(self, tab_id).map_err(LiveRuntimeClientError::from)
}
fn close(&mut self, tab_id: String) -> Result<(), LiveRuntimeClientError> {
ServoLiveClient::close(self, tab_id).map_err(LiveRuntimeClientError::from)
}
}
#[derive(Debug)]
pub(super) enum LiveRuntimeClientError {
SidecarExited,
Message(String),
}
impl LiveRuntimeClientError {
pub(super) fn is_sidecar_exited(&self) -> bool {
matches!(self, Self::SidecarExited)
}
}
impl std::fmt::Display for LiveRuntimeClientError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SidecarExited => formatter.write_str("servo live sidecar exited"),
Self::Message(message) => formatter.write_str(message),
}
}
}
impl From<String> for LiveRuntimeClientError {
fn from(message: String) -> Self {
Self::Message(message)
}
}
impl From<ServoLiveError> for LiveRuntimeClientError {
fn from(error: ServoLiveError) -> Self {
if error.is_sidecar_process_unusable() {
return Self::SidecarExited;
}
Self::Message(error.to_string())
}
}
impl From<io::Error> for LiveRuntimeClientError {
fn from(error: io::Error) -> Self {
Self::Message(error.to_string())
}
}
/// Output of a worker request.
pub(super) enum WorkerResponse {
Frame { tab_id: String, frame: ServoLiveFrame },
Failed { tab_id: String, message: String },
SidecarExited,
}
enum WorkerRequest {
Ensure(ServoLiveEnsureRequest),
Poll { tab_id: String },
}
struct WorkerQueue {
/// Latest request per tab. A new submission for a tab replaces any
/// earlier in-flight-but-not-yet-started request, so a flurry of
/// scrolls never piles up — the worker always processes the most
/// recent frame's worth of inputs.
pending: BTreeMap<String, WorkerRequest>,
/// Close orders. Sent after pending is cleared for that tab so the
/// worker never closes a tab that still has live frames in flight.
closes: Vec<String>,
/// True while the worker is processing a request. `wait_until_idle`
/// uses this alongside the queue emptiness to know when all
/// previously-submitted work has actually run.
in_flight: bool,
shutdown: bool,
}
/// Owns a [`LiveRuntimeClient`] on a dedicated OS thread and exposes
/// a non-blocking API: submit ensure/poll/close, then drain responses.
///
/// The UI thread never blocks on Servo IPC. Submissions push into a
/// coalescing queue (latest request per tab wins). The worker thread
/// drains the queue, runs the blocking IPC, and emits responses on a
/// `std::sync::mpsc` channel that the UI thread reads with `try_recv`.
pub(super) struct LiveRuntimeWorker {
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
response_rx: mpsc::Receiver<WorkerResponse>,
thread: Option<JoinHandle<()>>,
}
impl LiveRuntimeWorker {
pub(super) fn new(client: Box<dyn LiveRuntimeClient>) -> Result<Self, String> {
let queue = Arc::new((
Mutex::new(WorkerQueue {
pending: BTreeMap::new(),
closes: Vec::new(),
in_flight: false,
shutdown: false,
}),
Condvar::new(),
));
let (response_tx, response_rx) = mpsc::channel();
let queue_for_thread = queue.clone();
let thread = std::thread::Builder::new()
.name("ely-servo-live".to_string())
.spawn(move || {
run_worker(client, queue_for_thread, response_tx);
})
.map_err(|error| format!("failed to spawn servo live worker thread: {error}"))?;
Ok(Self { queue, response_rx, thread: Some(thread) })
}
pub(super) fn submit_ensure(&self, request: ServoLiveEnsureRequest) {
let tab_id = request.tab_id.clone();
let (lock, cvar) = &*self.queue;
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if q.shutdown {
return;
}
q.pending.insert(tab_id, WorkerRequest::Ensure(request));
cvar.notify_one();
}
pub(super) fn submit_poll(&self, tab_id: String) {
let (lock, cvar) = &*self.queue;
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if q.shutdown {
return;
}
// A pending Ensure already produces the latest frame after its
// run; don't downgrade it to a Poll. Only insert if nothing is
// queued.
q.pending.entry(tab_id.clone()).or_insert(WorkerRequest::Poll { tab_id });
cvar.notify_one();
}
pub(super) fn submit_close(&self, tab_id: String) {
let (lock, cvar) = &*self.queue;
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if q.shutdown {
return;
}
q.pending.remove(&tab_id);
q.closes.push(tab_id);
cvar.notify_one();
}
pub(super) fn drain_responses(&self) -> Vec<WorkerResponse> {
let mut out = Vec::new();
while let Ok(response) = self.response_rx.try_recv() {
out.push(response);
}
out
}
/// Test-only barrier. Blocks the caller until the worker has
/// drained everything currently submitted. Production code never
/// waits — the whole point of the worker is that the UI thread
/// progresses without IPC latency.
#[cfg(test)]
pub(super) fn wait_until_idle(&self) {
let (lock, cvar) = &*self.queue;
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
while !q.pending.is_empty() || !q.closes.is_empty() || q.in_flight {
q = match cvar.wait(q) {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
}
}
}
impl Drop for LiveRuntimeWorker {
fn drop(&mut self) {
{
let (lock, cvar) = &*self.queue;
if let Ok(mut q) = lock.lock() {
q.shutdown = true;
cvar.notify_all();
}
}
if let Some(handle) = self.thread.take() {
let _ = handle.join();
}
}
}
fn run_worker(
mut client: Box<dyn LiveRuntimeClient>,
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
response_tx: mpsc::Sender<WorkerResponse>,
) {
let (lock, cvar) = &*queue;
loop {
let work = {
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
q.in_flight = false;
cvar.notify_all();
while q.pending.is_empty() && q.closes.is_empty() && !q.shutdown {
q = match cvar.wait(q) {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
}
if q.shutdown {
return;
}
let next = if let Some(close_id) = q.closes.pop() {
Work::Close(close_id)
} else {
let key = match q.pending.keys().next().cloned() {
Some(key) => key,
None => continue,
};
let Some(request) = q.pending.remove(&key) else {
continue;
};
Work::Request(request)
};
q.in_flight = true;
next
};
let exit_after_dispatch = match work {
Work::Close(tab_id) => {
let _ = client.close(tab_id);
false
}
Work::Request(WorkerRequest::Ensure(request)) => {
let tab_id = request.tab_id.clone();
dispatch_result(&response_tx, tab_id, client.ensure(request))
}
Work::Request(WorkerRequest::Poll { tab_id }) => {
let request_tab_id = tab_id.clone();
dispatch_result(&response_tx, request_tab_id, client.poll(tab_id))
}
};
if exit_after_dispatch {
// Release the in-flight flag and wake any flush waiter
// before exiting so wait_until_idle doesn't block forever
// on a thread that has already returned.
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
q.in_flight = false;
cvar.notify_all();
return;
}
}
}
enum Work {
Close(String),
Request(WorkerRequest),
}
/// Forward a single client result to the response channel. Returns
/// `true` when the worker should exit (sidecar process died).
fn dispatch_result(
response_tx: &mpsc::Sender<WorkerResponse>,
tab_id: String,
result: Result<Option<ServoLiveFrame>, LiveRuntimeClientError>,
) -> bool {
match result {
Ok(Some(frame)) => {
let _ = response_tx.send(WorkerResponse::Frame { tab_id, frame });
false
}
Ok(None) => false,
Err(error) => {
let exited = error.is_sidecar_exited();
let message = error.to_string();
let _ = response_tx.send(WorkerResponse::Failed { tab_id, message });
if exited {
let _ = response_tx.send(WorkerResponse::SidecarExited);
return true;
}
false
}
}
}