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, sessions: BTreeMap, retry_state: BTreeMap, retired_workers: Vec>>, retired_permission_consumptions: Vec, transient_cleanup_error: Option, 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 { 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 { self.reap_retired_workers(false); let mut frames = self.take_retired_permission_frames(); let now = Instant::now(); let scopes = self.workers.keys().cloned().collect::>(); 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 { 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, now: Instant, frames: &mut Vec, ) -> 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 { 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::>(); 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;