489 lines
19 KiB
Rust
489 lines
19 KiB
Rust
use std::{
|
|
collections::BTreeMap,
|
|
thread::JoinHandle,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use ely_domain::{BrowserTab, ColorScheme, TabId};
|
|
|
|
use crate::services::{
|
|
ProfileDataMode,
|
|
servo_live::{ServoLiveEnsureRequest, ServoLivePermissionGrant, ServoLiveSitePermission},
|
|
servo_profile_data::cleanup_stale_transient_profile_data_dirs,
|
|
};
|
|
|
|
use super::{
|
|
web_surface_frame::WebSurfaceFrame,
|
|
web_surface_geometry::WebSurfaceSize,
|
|
web_surface_permissions::WebSurfaceSitePermission,
|
|
web_surface_runtime_session::{
|
|
WebSurfaceRuntimeScope, WebSurfaceSession, config_dir_for_scope, session_for_scope,
|
|
},
|
|
web_surface_runtime_wire::{
|
|
allow_once_grants, input_requests_history_navigation, log_ensure_submitted,
|
|
pending_input_kind, scroll_wire_fields,
|
|
},
|
|
web_surface_state::WebSurfacePendingInput,
|
|
web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, RequestGeneration, WorkerResponse},
|
|
};
|
|
use cleanup::{
|
|
LiveRuntimeClientFactory, ScopedWorker, new_servo_live_client, shutdown_scoped_worker,
|
|
};
|
|
|
|
pub(super) use super::web_surface_runtime_session::{
|
|
WebSurfaceEnsureResult, WebSurfaceRuntimeFrame, WebSurfaceUrlChange, WebSurfaceUrlChangeKind,
|
|
};
|
|
|
|
pub(super) struct WebSurfaceRuntime {
|
|
workers: BTreeMap<WebSurfaceRuntimeScope, ScopedWorker>,
|
|
sessions: BTreeMap<TabId, WebSurfaceSession>,
|
|
retry_state: BTreeMap<WebSurfaceRuntimeScope, ScopeRetryState>,
|
|
retired_workers: Vec<JoinHandle<Result<(), String>>>,
|
|
retired_permission_consumptions: Vec<ServoLivePermissionGrant>,
|
|
transient_cleanup_error: Option<String>,
|
|
client_factory: LiveRuntimeClientFactory,
|
|
last_generation: u64,
|
|
color_scheme: ColorScheme,
|
|
}
|
|
|
|
const SIDECAR_RESTART_BASE_DELAY: Duration = Duration::from_millis(250);
|
|
const SIDECAR_RESTART_MAX_DELAY: Duration = Duration::from_secs(5);
|
|
impl WebSurfaceRuntime {
|
|
pub(super) fn new() -> Self {
|
|
let transient_cleanup_error =
|
|
cleanup_stale_transient_profile_data_dirs().err().map(|error| error.to_string());
|
|
Self {
|
|
workers: BTreeMap::new(),
|
|
sessions: BTreeMap::new(),
|
|
retry_state: BTreeMap::new(),
|
|
retired_workers: Vec::new(),
|
|
retired_permission_consumptions: Vec::new(),
|
|
transient_cleanup_error,
|
|
client_factory: new_servo_live_client,
|
|
last_generation: 0,
|
|
color_scheme: ColorScheme::Light,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(super) fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self {
|
|
Self {
|
|
workers: BTreeMap::new(),
|
|
sessions: BTreeMap::new(),
|
|
retry_state: BTreeMap::new(),
|
|
retired_workers: Vec::new(),
|
|
retired_permission_consumptions: Vec::new(),
|
|
transient_cleanup_error: None,
|
|
client_factory,
|
|
last_generation: 0,
|
|
color_scheme: ColorScheme::Light,
|
|
}
|
|
}
|
|
|
|
pub(super) fn ensure_tab(
|
|
&mut self,
|
|
tab: &BrowserTab,
|
|
size: WebSurfaceSize,
|
|
profile_data_mode: ProfileDataMode,
|
|
permissions: &[WebSurfaceSitePermission],
|
|
input: WebSurfacePendingInput,
|
|
) -> Result<WebSurfaceEnsureResult, String> {
|
|
let scope = WebSurfaceRuntimeScope::new(tab.profile_id().clone(), profile_data_mode);
|
|
self.prepare_tab_scope(tab.id(), tab.profile_id(), profile_data_mode)?;
|
|
|
|
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 generation = self.next_request_generation();
|
|
|
|
let submitted_at = Instant::now();
|
|
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, self.color_scheme);
|
|
if started_loading {
|
|
session.pending_user_navigation = false;
|
|
session.frame_generation_floor = Some(generation);
|
|
}
|
|
if user_navigation_input {
|
|
session.pending_user_navigation = true;
|
|
}
|
|
session.requested_url = requested_url.clone();
|
|
session.size = size;
|
|
session.zoom_percent = zoom_percent;
|
|
session.color_scheme = Some(self.color_scheme);
|
|
session.scroll_offset = next_scroll_offset;
|
|
session.generation = Some(generation);
|
|
session.cadence.note_ensure(input_kind, started_loading, submitted_at);
|
|
started_loading
|
|
};
|
|
|
|
let request = ServoLiveEnsureRequest {
|
|
tab_id: tab.id().as_str().to_string(),
|
|
profile_id: tab.profile_id().as_str().to_string(),
|
|
url: requested_url.clone(),
|
|
width: size.width,
|
|
height: size.height,
|
|
page_zoom_percent: zoom_percent,
|
|
device_pixel_ratio: size.device_pixel_ratio_f32(),
|
|
color_scheme: self.color_scheme,
|
|
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_permission_generation: generation.value(),
|
|
site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(),
|
|
allow_once_grants: allow_once_grants(tab.profile_id(), permissions),
|
|
};
|
|
|
|
let Some(scoped) = self.workers.get_mut(&scope) else {
|
|
return Err("Servo sidecar worker was created but is no longer registered".to_string());
|
|
};
|
|
scoped.track_allow_once_grants(&request.allow_once_grants);
|
|
scoped.worker.submit_ensure(generation, request);
|
|
if let Some(session) = self.sessions.get_mut(tab.id()) {
|
|
session.cadence.note_poll_submitted(submitted_at);
|
|
}
|
|
log_ensure_submitted(tab, size, input_kind.label(), enqueued_at, started_loading);
|
|
|
|
Ok(WebSurfaceEnsureResult { requested_url, started_loading })
|
|
}
|
|
|
|
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec<WebSurfaceRuntimeFrame> {
|
|
self.reap_retired_workers(false);
|
|
let mut frames = self.take_retired_permission_frames();
|
|
let now = Instant::now();
|
|
let scopes = self.workers.keys().cloned().collect::<Vec<_>>();
|
|
let mut unavailable_scopes = Vec::new();
|
|
|
|
for scope in scopes {
|
|
let responses = self
|
|
.workers
|
|
.get(&scope)
|
|
.map(|scoped| scoped.worker.drain_responses())
|
|
.unwrap_or_default();
|
|
if self.collect_responses(&scope, responses, now, &mut frames) {
|
|
unavailable_scopes.push(scope);
|
|
}
|
|
}
|
|
for scope in unavailable_scopes {
|
|
self.invalidate_scope(&scope, now);
|
|
}
|
|
frames.extend(self.take_retired_permission_frames());
|
|
|
|
let poll_now = Instant::now();
|
|
for tab_id in visible_tab_ids {
|
|
let Some((scope, generation)) = self.sessions.get(tab_id).and_then(|session| {
|
|
session
|
|
.cadence
|
|
.should_poll(poll_now)
|
|
.then(|| (session.scope.clone(), session.generation))
|
|
}) else {
|
|
continue;
|
|
};
|
|
let Some(generation) = generation else {
|
|
continue;
|
|
};
|
|
if !self.workers.contains_key(&scope) {
|
|
continue;
|
|
}
|
|
let _ = self.workers.get(&scope).is_some_and(|scoped| {
|
|
scoped.worker.submit_poll(generation, tab_id.as_str().to_string())
|
|
});
|
|
if let Some(session) = self.sessions.get_mut(tab_id) {
|
|
session.cadence.note_poll_submitted(poll_now);
|
|
}
|
|
}
|
|
|
|
frames
|
|
}
|
|
|
|
pub(super) fn next_poll_delay(
|
|
&self,
|
|
visible_tab_ids: &[TabId],
|
|
now: Instant,
|
|
) -> Option<Duration> {
|
|
visible_tab_ids
|
|
.iter()
|
|
.filter_map(|tab_id| self.sessions.get(tab_id))
|
|
.filter(|session| self.workers.contains_key(&session.scope))
|
|
.map(|session| session.cadence.next_poll_delay(now))
|
|
.min()
|
|
}
|
|
|
|
pub(super) fn has_session(
|
|
&self,
|
|
tab_id: &TabId,
|
|
profile_id: &ely_domain::ProfileId,
|
|
profile_data_mode: ProfileDataMode,
|
|
) -> bool {
|
|
self.sessions.get(tab_id).is_some_and(|session| {
|
|
session.scope == WebSurfaceRuntimeScope::new(profile_id.clone(), profile_data_mode)
|
|
&& self.workers.contains_key(&session.scope)
|
|
})
|
|
}
|
|
|
|
pub(super) fn set_color_scheme(&mut self, color_scheme: ColorScheme) {
|
|
self.color_scheme = color_scheme;
|
|
}
|
|
|
|
pub(super) fn prepare_tab_scope(
|
|
&mut self,
|
|
tab_id: &TabId,
|
|
profile_id: &ely_domain::ProfileId,
|
|
profile_data_mode: ProfileDataMode,
|
|
) -> Result<(), String> {
|
|
let scope = WebSurfaceRuntimeScope::new(profile_id.clone(), profile_data_mode);
|
|
self.detach_tab_from_previous_scope(tab_id, &scope);
|
|
self.ensure_worker(scope.clone())?;
|
|
session_for_scope(&mut self.sessions, tab_id, scope);
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_worker(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
|
|
self.reap_retired_workers(false);
|
|
if self.workers.contains_key(&scope) {
|
|
return Ok(());
|
|
}
|
|
if scope.is_transient() && self.transient_cleanup_error.is_some() {
|
|
match cleanup_stale_transient_profile_data_dirs() {
|
|
Ok(()) => self.transient_cleanup_error = None,
|
|
Err(error) => {
|
|
let message = error.to_string();
|
|
self.transient_cleanup_error = Some(message.clone());
|
|
return Err(message);
|
|
}
|
|
}
|
|
}
|
|
let now = Instant::now();
|
|
if let Some(retry) = self.retry_state.get(&scope)
|
|
&& now < retry.retry_after
|
|
{
|
|
return Err("Servo sidecar restart is cooling down".to_string());
|
|
}
|
|
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
|
|
let client_factory = self.client_factory;
|
|
let worker = match LiveRuntimeWorker::new(move || client_factory(config_dir)) {
|
|
Ok(worker) => worker,
|
|
Err(error) => {
|
|
self.note_scope_failure(&scope, now);
|
|
return Err(error);
|
|
}
|
|
};
|
|
self.workers.insert(scope, ScopedWorker::new(worker, transient_profile_data_dir));
|
|
Ok(())
|
|
}
|
|
|
|
fn collect_responses(
|
|
&mut self,
|
|
scope: &WebSurfaceRuntimeScope,
|
|
responses: Vec<WorkerResponse>,
|
|
now: Instant,
|
|
frames: &mut Vec<WebSurfaceRuntimeFrame>,
|
|
) -> bool {
|
|
let mut runtime_unavailable = false;
|
|
for response in responses {
|
|
match response {
|
|
WorkerResponse::Frame { generation, tab_id, frame } => {
|
|
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
|
|
continue;
|
|
};
|
|
if !self.sessions.get(&tab_id_obj).is_some_and(|session| {
|
|
&session.scope == scope && session.accepts_frame_generation(generation)
|
|
}) {
|
|
continue;
|
|
}
|
|
self.note_scope_success(scope);
|
|
let Some(session) = self.sessions.get_mut(&tab_id_obj) else {
|
|
continue;
|
|
};
|
|
let requested_url = session.requested_url.clone();
|
|
let scroll_offset = session.scroll_offset;
|
|
let zoom_percent = session.zoom_percent;
|
|
session.cadence.note_frame(frame.render_state(), frame.pixels_changed(), now);
|
|
match WebSurfaceFrame::from_live_frame(
|
|
requested_url.clone(),
|
|
scroll_offset,
|
|
zoom_percent,
|
|
frame,
|
|
) {
|
|
Ok(frame) => {
|
|
let url_change =
|
|
session.url_change_for(&tab_id_obj, requested_url.as_str(), &frame);
|
|
frames.push(WebSurfaceRuntimeFrame::Ready {
|
|
tab_id: tab_id_obj,
|
|
frame: Box::new(frame),
|
|
url_change,
|
|
});
|
|
}
|
|
Err(error) => frames.push(WebSurfaceRuntimeFrame::Failed {
|
|
tab_id: tab_id_obj,
|
|
message: error.to_string(),
|
|
}),
|
|
}
|
|
}
|
|
WorkerResponse::Failed { generation, tab_id, message } => {
|
|
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
|
|
continue;
|
|
};
|
|
if self.sessions.get(&tab_id_obj).is_some_and(|session| {
|
|
&session.scope == scope && session.generation == Some(generation)
|
|
}) {
|
|
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
|
|
}
|
|
}
|
|
WorkerResponse::RuntimeUnavailable => runtime_unavailable = true,
|
|
WorkerResponse::PermissionSnapshotAccepted(grant) => {
|
|
frames.push(WebSurfaceRuntimeFrame::PermissionSnapshotAccepted(grant));
|
|
}
|
|
WorkerResponse::PermissionConsumed(consumed) => {
|
|
if let Some(scoped) = self.workers.get_mut(scope) {
|
|
scoped.mark_permission_consumed(&consumed);
|
|
}
|
|
frames.push(WebSurfaceRuntimeFrame::PermissionConsumed(consumed));
|
|
}
|
|
}
|
|
}
|
|
runtime_unavailable
|
|
}
|
|
|
|
fn lookup_session_tab_id(&self, tab_id: &str) -> Option<TabId> {
|
|
self.sessions.keys().find(|key| key.as_str() == tab_id).cloned()
|
|
}
|
|
|
|
fn next_request_generation(&mut self) -> RequestGeneration {
|
|
assert!(self.last_generation < u64::MAX, "web surface request generation exhausted");
|
|
self.last_generation += 1;
|
|
RequestGeneration::new(self.last_generation)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(super) fn client_count_for_test(&self) -> usize {
|
|
self.workers.len()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(super) fn session_scope_for_test(&self, tab_id: &TabId) -> Option<&WebSurfaceRuntimeScope> {
|
|
self.sessions.get(tab_id).map(|session| &session.scope)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(super) fn flush_for_test(&mut self) {
|
|
for scoped in self.workers.values() {
|
|
scoped.worker.wait_until_idle();
|
|
}
|
|
self.reap_retired_workers(true);
|
|
}
|
|
|
|
fn remove_worker(&mut self, scope: &WebSurfaceRuntimeScope) {
|
|
let Some(mut scoped) = self.workers.remove(scope) else {
|
|
return;
|
|
};
|
|
self.retire_scoped_worker_permissions(&mut scoped);
|
|
if scoped.transient_profile_data_dir.is_some() {
|
|
match std::thread::Builder::new()
|
|
.name("ely-servo-profile-cleanup".to_string())
|
|
.spawn(move || shutdown_scoped_worker(scoped))
|
|
{
|
|
Ok(handle) => self.retired_workers.push(handle),
|
|
Err(error) => self.transient_cleanup_error = Some(error.to_string()),
|
|
}
|
|
} else {
|
|
let _ = shutdown_scoped_worker(scoped);
|
|
}
|
|
}
|
|
|
|
fn reap_retired_workers(&mut self, wait_for_all: bool) {
|
|
let mut pending = Vec::new();
|
|
for handle in self.retired_workers.drain(..) {
|
|
if !wait_for_all && !handle.is_finished() {
|
|
pending.push(handle);
|
|
continue;
|
|
}
|
|
match handle.join() {
|
|
Ok(Ok(())) => {}
|
|
Ok(Err(error)) => self.transient_cleanup_error = Some(error),
|
|
Err(_) => {
|
|
self.transient_cleanup_error =
|
|
Some("Servo profile cleanup thread panicked".to_string());
|
|
}
|
|
}
|
|
}
|
|
self.retired_workers = pending;
|
|
}
|
|
|
|
fn invalidate_scope(&mut self, scope: &WebSurfaceRuntimeScope, now: Instant) {
|
|
self.remove_worker(scope);
|
|
self.sessions.retain(|_, session| &session.scope != scope);
|
|
self.note_scope_failure(scope, now);
|
|
}
|
|
|
|
fn note_scope_failure(&mut self, scope: &WebSurfaceRuntimeScope, now: Instant) {
|
|
let retry = ScopeRetryState::after_failure(self.retry_state.get(scope), now);
|
|
self.retry_state.insert(scope.clone(), retry);
|
|
}
|
|
|
|
fn note_scope_success(&mut self, scope: &WebSurfaceRuntimeScope) {
|
|
self.retry_state.remove(scope);
|
|
}
|
|
}
|
|
|
|
impl Drop for WebSurfaceRuntime {
|
|
fn drop(&mut self) {
|
|
let scopes = self.workers.keys().cloned().collect::<Vec<_>>();
|
|
for scope in scopes {
|
|
self.remove_worker(&scope);
|
|
}
|
|
self.reap_retired_workers(true);
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct ScopeRetryState {
|
|
failure_count: u32,
|
|
retry_after: Instant,
|
|
}
|
|
|
|
impl ScopeRetryState {
|
|
fn after_failure(previous: Option<&Self>, now: Instant) -> Self {
|
|
let failure_count = previous.map_or(1, |state| state.failure_count.saturating_add(1));
|
|
let shift = failure_count.saturating_sub(1).min(31);
|
|
let multiplier = 1_u32 << shift;
|
|
let delay =
|
|
SIDECAR_RESTART_BASE_DELAY.saturating_mul(multiplier).min(SIDECAR_RESTART_MAX_DELAY);
|
|
Self { failure_count, retry_after: now + delay }
|
|
}
|
|
}
|
|
|
|
#[path = "web_surface_runtime_cleanup.rs"]
|
|
mod cleanup;
|
|
|
|
#[cfg(test)]
|
|
#[path = "web_surface_runtime_backpressure_tests.rs"]
|
|
mod backpressure_tests;
|
|
#[cfg(test)]
|
|
#[path = "web_surface_runtime_cleanup_tests.rs"]
|
|
mod cleanup_tests;
|
|
#[cfg(test)]
|
|
#[path = "web_surface_runtime_generation_tests.rs"]
|
|
mod generation_tests;
|
|
#[cfg(test)]
|
|
#[path = "web_surface_runtime_retry_tests.rs"]
|
|
mod retry_tests;
|
|
#[cfg(test)]
|
|
#[path = "web_surface_runtime_tests.rs"]
|
|
mod tests;
|
|
#[cfg(test)]
|
|
#[path = "web_surface_theme_tests.rs"]
|
|
mod theme_tests;
|