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:
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
&[],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user