perf(web-surface): throttle idle Servo polling

This commit is contained in:
2026-05-15 23:25:09 -04:00
parent 736ff08e90
commit ca8f118785
13 changed files with 500 additions and 242 deletions
+4 -4
View File
@@ -15,7 +15,8 @@ use gpui::Context;
use crate::services::servo_profile_data::{default_profile_data_root, profile_data_dir}; use crate::services::servo_profile_data::{default_profile_data_root, profile_data_dir};
use super::{ElyShell, ShellState, SyncStateUpdate}; use super::sync_state::{SyncStateUpdate, sync_platform_label};
use super::{ElyShell, ShellState};
/// Where the user is in the email OTP form. Tracked on `ElyShell` so /// Where the user is in the email OTP form. Tracked on `ElyShell` so
/// the Sync settings page can pick the right widget cluster (only the /// the Sync settings page can pick the right widget cluster (only the
@@ -131,7 +132,7 @@ impl ElyShell {
return; return;
}; };
let profile_dir = profile_data_dir(&profile_root, &active_profile_id); let profile_dir = profile_data_dir(&profile_root, &active_profile_id);
match SyncEngine::for_profile_dir(&profile_dir, "ELY", super::sync_platform_label()) { match SyncEngine::for_profile_dir(&profile_dir, "ELY", sync_platform_label()) {
Ok(mut engine) => { Ok(mut engine) => {
let _ = engine.install_bearer(""); let _ = engine.install_bearer("");
} }
@@ -210,8 +211,7 @@ fn spawn_verify_otp(
} }
}; };
let mut engine = let mut engine =
match SyncEngine::for_profile_dir(&profile_dir, "ELY", super::sync_platform_label()) match SyncEngine::for_profile_dir(&profile_dir, "ELY", sync_platform_label()) {
{
Ok(engine) => engine, Ok(engine) => engine,
Err(error) => { Err(error) => {
let _ = tx.send(SyncStateUpdate::AuthError { let _ = tx.send(SyncStateUpdate::AuthError {
+5 -159
View File
@@ -16,19 +16,23 @@ mod plugins;
mod reading_list; mod reading_list;
mod render; mod render;
mod settings_actions; mod settings_actions;
mod shell_actions;
mod shortcut_files; mod shortcut_files;
mod sidebar; mod sidebar;
mod site_permissions; mod site_permissions;
mod space_files; mod space_files;
mod spaces; mod spaces;
mod splits; mod splits;
mod sync_state;
mod tab_groups; mod tab_groups;
mod tab_lifecycle; mod tab_lifecycle;
mod web_surface; mod web_surface;
mod web_surface_cadence;
mod web_surface_controller; mod web_surface_controller;
mod web_surface_frame; mod web_surface_frame;
mod web_surface_geometry; mod web_surface_geometry;
mod web_surface_keyboard; mod web_surface_keyboard;
mod web_surface_metadata;
mod web_surface_permissions; mod web_surface_permissions;
mod web_surface_runtime; mod web_surface_runtime;
mod web_surface_state; mod web_surface_state;
@@ -51,14 +55,9 @@ use bookmarks::PendingBookmarkEdit;
use downloads::PendingDownloadFileAction; use downloads::PendingDownloadFileAction;
use history::{PendingHistoryDomainClear, PendingHistoryTimeClear}; use history::{PendingHistoryDomainClear, PendingHistoryTimeClear};
use plugins::{PendingPluginInstall, PendingPluginUninstall}; use plugins::{PendingPluginInstall, PendingPluginUninstall};
use sync_state::SyncStateUpdate;
use web_surface::WebSurfaceStore; use web_surface::WebSurfaceStore;
use crate::{
CloseCurrentTab, DownloadCurrentPage, FocusAddressBar, FocusCommandMode, OpenDownloads,
OpenHistory, OpenNewTab, OpenSettings, OpenTaskManager, ResetZoom, RestoreClosedTab,
SelectNextTab, SelectPreviousTab, ToggleFavoriteTab, TogglePinnedTab, ZoomIn, ZoomOut,
};
enum ShellState { enum ShellState {
Ready(Box<BrowserCore>), Ready(Box<BrowserCore>),
StartupError(String), StartupError(String),
@@ -113,36 +112,6 @@ pub struct ElyShell {
_translucency_subscription: Subscription, _translucency_subscription: Subscription,
} }
/// Messages the off-thread sync workers push back to the shell so
/// `SyncConnectionState` on `BrowserCore` and the in-flight auth
/// form reflect live state without the UI thread ever touching the
/// network. `SignedIn` is the initial-probe state set synchronously
/// on shell startup and does not flow through this channel.
#[derive(Clone, Debug)]
pub(crate) enum SyncStateUpdate {
SignedOut,
AwaitingDeviceApproval,
SyncReady { last_synced_at_secs: u64 },
SyncError { message: String },
AuthOtpSent { email: String },
AuthSucceeded { email: String },
AuthError { email: String, message: String },
}
/// Stable label for the current OS used by the device registration
/// payload. Defined once here so every off-thread call site agrees.
pub(crate) const fn sync_platform_label() -> &'static str {
if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"other"
}
}
impl ElyShell { impl ElyShell {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::new_with_config(InitialBrowserConfig::ely_defaults(), window, cx) Self::new_with_config(InitialBrowserConfig::ely_defaults(), window, cx)
@@ -486,129 +455,6 @@ impl ElyShell {
cx.notify(); cx.notify();
} }
} }
fn on_close_current_tab(
&mut self,
_: &CloseCurrentTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.close_active_tab(window, cx);
}
fn on_focus_address_bar(
&mut self,
_: &FocusAddressBar,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.focus_address_bar(window, cx);
}
fn on_focus_command_mode(
&mut self,
_: &FocusCommandMode,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.focus_command_mode(window, cx);
}
fn on_open_new_tab(&mut self, _: &OpenNewTab, window: &mut Window, cx: &mut Context<Self>) {
self.open_new_tab(window, cx);
}
fn on_open_downloads(
&mut self,
_: &OpenDownloads,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_downloads(window, cx);
}
fn on_download_current_page(
&mut self,
_: &DownloadCurrentPage,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.download_active_tab(window, cx);
}
fn on_open_history(&mut self, _: &OpenHistory, window: &mut Window, cx: &mut Context<Self>) {
self.open_history(window, cx);
}
fn on_open_settings(&mut self, _: &OpenSettings, window: &mut Window, cx: &mut Context<Self>) {
self.open_settings(window, cx);
}
fn on_open_task_manager(
&mut self,
_: &OpenTaskManager,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_task_manager(window, cx);
}
fn on_restore_closed_tab(
&mut self,
_: &RestoreClosedTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.restore_closed_tab(window, cx);
}
fn on_reset_zoom(&mut self, _: &ResetZoom, _: &mut Window, cx: &mut Context<Self>) {
self.reset_active_tab_zoom(cx);
}
fn on_select_next_tab(
&mut self,
_: &SelectNextTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.select_next_tab(window, cx);
}
fn on_select_previous_tab(
&mut self,
_: &SelectPreviousTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.select_previous_tab(window, cx);
}
fn on_toggle_favorite_tab(
&mut self,
_: &ToggleFavoriteTab,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.toggle_active_tab_favorite(cx);
}
fn on_toggle_pinned_tab(
&mut self,
_: &TogglePinnedTab,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.toggle_active_tab_pinned(cx);
}
fn on_zoom_in(&mut self, _: &ZoomIn, _: &mut Window, cx: &mut Context<Self>) {
self.zoom_active_tab_in(cx);
}
fn on_zoom_out(&mut self, _: &ZoomOut, _: &mut Window, cx: &mut Context<Self>) {
self.zoom_active_tab_out(cx);
}
} }
fn start_external_web_surface_timer(cx: &mut Context<ElyShell>) { fn start_external_web_surface_timer(cx: &mut Context<ElyShell>) {
+8 -7
View File
@@ -9,6 +9,7 @@ use gpui_component::slider::SliderValue;
use crate::services::servo_profile_data::{default_profile_data_root, profile_data_dir}; use crate::services::servo_profile_data::{default_profile_data_root, profile_data_dir};
use super::sync_state::{SyncStateUpdate, sync_platform_label};
use super::{ElyShell, ShellState}; use super::{ElyShell, ShellState};
impl ElyShell { impl ElyShell {
@@ -316,25 +317,25 @@ fn run_sync_upload(
profile_dir: std::path::PathBuf, profile_dir: std::path::PathBuf,
device_name: String, device_name: String,
bytes: Vec<u8>, bytes: Vec<u8>,
inbox: std::sync::mpsc::Sender<super::SyncStateUpdate>, inbox: std::sync::mpsc::Sender<SyncStateUpdate>,
) { ) {
let mut engine = match SyncEngine::for_profile_dir( let mut engine = match SyncEngine::for_profile_dir(
&profile_dir, &profile_dir,
device_name, device_name,
super::sync_platform_label(), sync_platform_label(),
) { ) {
Ok(engine) => engine, Ok(engine) => engine,
Err(error) => { Err(error) => {
let message = error.to_string(); let message = error.to_string();
tracing::warn!(target: "ely::sync", error = %message, "could not initialise sync engine"); tracing::warn!(target: "ely::sync", error = %message, "could not initialise sync engine");
let _ = inbox.send(super::SyncStateUpdate::SyncError { message }); let _ = inbox.send(SyncStateUpdate::SyncError { message });
return; return;
} }
}; };
match engine.upload_bytes(bytes) { match engine.upload_bytes(bytes) {
Ok(ely_browser_core::SyncOutcome::SignedOut) => { Ok(ely_browser_core::SyncOutcome::SignedOut) => {
tracing::info!(target: "ely::sync", "no bearer token on disk; sync skipped"); tracing::info!(target: "ely::sync", "no bearer token on disk; sync skipped");
let _ = inbox.send(super::SyncStateUpdate::SignedOut); let _ = inbox.send(SyncStateUpdate::SignedOut);
} }
Ok(ely_browser_core::SyncOutcome::Uploaded { Ok(ely_browser_core::SyncOutcome::Uploaded {
snapshot_id, snapshot_id,
@@ -354,15 +355,15 @@ fn run_sync_upload(
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
let _ = inbox.send(super::SyncStateUpdate::SyncReady { last_synced_at_secs }); let _ = inbox.send(SyncStateUpdate::SyncReady { last_synced_at_secs });
} }
Err(error) => { Err(error) => {
let message = error.to_string(); let message = error.to_string();
tracing::warn!(target: "ely::sync", error = %message, "snapshot upload failed"); tracing::warn!(target: "ely::sync", error = %message, "snapshot upload failed");
let update = if message.contains("device_not_approved") { let update = if message.contains("device_not_approved") {
super::SyncStateUpdate::AwaitingDeviceApproval SyncStateUpdate::AwaitingDeviceApproval
} else { } else {
super::SyncStateUpdate::SyncError { message } SyncStateUpdate::SyncError { message }
}; };
let _ = inbox.send(update); let _ = inbox.send(update);
} }
+149
View File
@@ -0,0 +1,149 @@
use gpui::{Context, Window};
use crate::{
CloseCurrentTab, DownloadCurrentPage, FocusAddressBar, FocusCommandMode, OpenDownloads,
OpenHistory, OpenNewTab, OpenSettings, OpenTaskManager, ResetZoom, RestoreClosedTab,
SelectNextTab, SelectPreviousTab, ToggleFavoriteTab, TogglePinnedTab, ZoomIn, ZoomOut,
};
use super::ElyShell;
impl ElyShell {
pub(super) fn on_close_current_tab(
&mut self,
_: &CloseCurrentTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.close_active_tab(window, cx);
}
pub(super) fn on_focus_address_bar(
&mut self,
_: &FocusAddressBar,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.focus_address_bar(window, cx);
}
pub(super) fn on_focus_command_mode(
&mut self,
_: &FocusCommandMode,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.focus_command_mode(window, cx);
}
pub(super) fn on_open_new_tab(
&mut self,
_: &OpenNewTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_new_tab(window, cx);
}
pub(super) fn on_open_downloads(
&mut self,
_: &OpenDownloads,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_downloads(window, cx);
}
pub(super) fn on_download_current_page(
&mut self,
_: &DownloadCurrentPage,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.download_active_tab(window, cx);
}
pub(super) fn on_open_history(
&mut self,
_: &OpenHistory,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_history(window, cx);
}
pub(super) fn on_open_settings(
&mut self,
_: &OpenSettings,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_settings(window, cx);
}
pub(super) fn on_open_task_manager(
&mut self,
_: &OpenTaskManager,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.open_task_manager(window, cx);
}
pub(super) fn on_restore_closed_tab(
&mut self,
_: &RestoreClosedTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.restore_closed_tab(window, cx);
}
pub(super) fn on_reset_zoom(&mut self, _: &ResetZoom, _: &mut Window, cx: &mut Context<Self>) {
self.reset_active_tab_zoom(cx);
}
pub(super) fn on_select_next_tab(
&mut self,
_: &SelectNextTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.select_next_tab(window, cx);
}
pub(super) fn on_select_previous_tab(
&mut self,
_: &SelectPreviousTab,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.select_previous_tab(window, cx);
}
pub(super) fn on_toggle_favorite_tab(
&mut self,
_: &ToggleFavoriteTab,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.toggle_active_tab_favorite(cx);
}
pub(super) fn on_toggle_pinned_tab(
&mut self,
_: &TogglePinnedTab,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.toggle_active_tab_pinned(cx);
}
pub(super) fn on_zoom_in(&mut self, _: &ZoomIn, _: &mut Window, cx: &mut Context<Self>) {
self.zoom_active_tab_in(cx);
}
pub(super) fn on_zoom_out(&mut self, _: &ZoomOut, _: &mut Window, cx: &mut Context<Self>) {
self.zoom_active_tab_out(cx);
}
}
+29
View File
@@ -0,0 +1,29 @@
/// Messages the off-thread sync workers push back to the shell so
/// `SyncConnectionState` on `BrowserCore` and the in-flight auth
/// form reflect live state without the UI thread ever touching the
/// network. `SignedIn` is the initial-probe state set synchronously
/// on shell startup and does not flow through this channel.
#[derive(Clone, Debug)]
pub(crate) enum SyncStateUpdate {
SignedOut,
AwaitingDeviceApproval,
SyncReady { last_synced_at_secs: u64 },
SyncError { message: String },
AuthOtpSent { email: String },
AuthSucceeded { email: String },
AuthError { email: String, message: String },
}
/// Stable label for the current OS used by the device registration
/// payload. Defined once here so every off-thread call site agrees.
pub(crate) const fn sync_platform_label() -> &'static str {
if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"other"
}
}
+19 -45
View File
@@ -1,10 +1,11 @@
use std::collections::BTreeMap; use std::{collections::BTreeMap, time::Instant};
use ely_domain::{BrowserTab, TabId}; use ely_domain::{BrowserTab, TabId};
use gpui::{Bounds, Pixels, Point}; use gpui::{Bounds, Pixels, Point};
use crate::services::ProfileDataMode; use crate::services::ProfileDataMode;
use super::web_surface_metadata::WebSurfacePageMetadata;
use super::{ use super::{
web_surface_frame::WebSurfaceFrame, web_surface_frame::WebSurfaceFrame,
web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollDelta, WebSurfaceSize}, web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollDelta, WebSurfaceSize},
@@ -17,33 +18,6 @@ use super::{
}, },
}; };
/// One page's worth of metadata observed in a Ready frame. The
/// controller applies these to the `BrowserTab` (title / favicon_key)
/// after the frame has been swapped into the surface state. Title and
/// favicon are independent — a navigation typically settles the URL
/// first, then Servo emits a title change a frame or two later, and
/// the favicon URL is derived from the loaded URL.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct WebSurfacePageMetadata {
pub(super) tab_id: TabId,
pub(super) title: Option<String>,
pub(super) favicon_url: Option<String>,
}
impl WebSurfacePageMetadata {
fn from_frame(tab_id: &TabId, frame: &WebSurfaceFrame) -> Option<Self> {
let title = frame.title().map(str::to_string);
let favicon_url = frame
.loaded_url()
.and_then(|loaded| ely_domain::UrlText::parse(loaded).ok())
.and_then(|url| url.favicon_url());
if title.is_none() && favicon_url.is_none() {
return None;
}
Some(Self { tab_id: tab_id.clone(), title, favicon_url })
}
}
pub(super) struct WebSurfaceStore { pub(super) struct WebSurfaceStore {
runtime: WebSurfaceRuntime, runtime: WebSurfaceRuntime,
/// Single owner of every per-tab invariant. See [`PerTabSurface`]. /// Single owner of every per-tab invariant. See [`PerTabSurface`].
@@ -125,11 +99,6 @@ impl WebSurfaceStore {
match self.initial_display_gate_message(&tab_id, &frame, had_ready) { match self.initial_display_gate_message(&tab_id, &frame, had_ready) {
Ok(()) => {} Ok(()) => {}
Err(message) => { Err(message) => {
// Only transition to Failed when there is
// nothing on screen yet — once a real frame
// has rendered, transient gate failures
// (e.g. a stray empty-paint pass) must not
// wipe it out.
if !had_ready { if !had_ready {
self.surface_mut(&tab_id).state = self.surface_mut(&tab_id).state =
Some(WebSurfaceState::Failed { message }); Some(WebSurfaceState::Failed { message });
@@ -163,12 +132,6 @@ impl WebSurfaceStore {
Some(WebSurfaceState::Ready(_)) Some(WebSurfaceState::Ready(_))
); );
if had_ready { if had_ready {
// Keep the last good frame on screen — the
// worker emits Failed for any transient ensure
// / poll error (parse glitch, momentary IPC
// hiccup) and downgrading every one of them
// strobes the page. The error still surfaces
// through `tracing` for diagnostics.
tracing::warn!( tracing::warn!(
target: "ely::web_surface", target: "ely::web_surface",
tab_id = %tab_id, tab_id = %tab_id,
@@ -238,12 +201,6 @@ impl WebSurfaceStore {
}); });
surface.pending_scroll_point = Some(point); surface.pending_scroll_point = Some(point);
surface.mark_pending_input_started(); 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
// `keyboard_focus` and `typed_text` though: Servo maintains
// its own DOM focus across scrolls, so a focused input keeps
// accepting the user's keystrokes after they wheel-scroll.
surface.click_point = None; surface.click_point = None;
WebSurfaceInputOutcome::Applied WebSurfaceInputOutcome::Applied
} }
@@ -278,6 +235,16 @@ impl WebSurfaceStore {
tab_id: &TabId, tab_id: &TabId,
position: Point<Pixels>, position: Point<Pixels>,
scale_factor: f32, scale_factor: f32,
) -> WebSurfaceInputOutcome {
self.record_hover_point_at(tab_id, position, scale_factor, Instant::now())
}
fn record_hover_point_at(
&mut self,
tab_id: &TabId,
position: Point<Pixels>,
scale_factor: f32,
now: Instant,
) -> WebSurfaceInputOutcome { ) -> WebSurfaceInputOutcome {
let Some(surface) = self.surfaces.get_mut(tab_id) else { let Some(surface) = self.surfaces.get_mut(tab_id) else {
return WebSurfaceInputOutcome::DroppedNoViewportBounds; return WebSurfaceInputOutcome::DroppedNoViewportBounds;
@@ -290,7 +257,14 @@ impl WebSurfaceStore {
else { else {
return WebSurfaceInputOutcome::DroppedOutOfBounds; return WebSurfaceInputOutcome::DroppedOutOfBounds;
}; };
if surface.hover_point == Some(point) {
return WebSurfaceInputOutcome::NoChange;
}
if surface.hover_is_throttled(now) {
return WebSurfaceInputOutcome::NoChange;
}
surface.hover_point = Some(point); surface.hover_point = Some(point);
surface.mark_hover_enqueued(now);
WebSurfaceInputOutcome::Applied WebSurfaceInputOutcome::Applied
} }
@@ -0,0 +1,172 @@
use std::time::{Duration, Instant};
const ACTIVE_POLL_INTERVAL: Duration = Duration::from_millis(8);
const IDLE_POLL_INTERVAL: Duration = Duration::from_millis(80);
const LOAD_BOOST_WINDOW: Duration = Duration::from_secs(5);
const INPUT_BOOST_WINDOW: Duration = Duration::from_millis(600);
const HOVER_BOOST_WINDOW: Duration = Duration::from_millis(120);
const FRAME_SETTLE_WINDOW: Duration = Duration::from_millis(250);
#[derive(Clone, Debug, Default)]
pub(super) struct WebSurfacePollCadence {
next_poll_at: Option<Instant>,
active_until: Option<Instant>,
last_render_phase: Option<WebSurfaceRenderPhase>,
}
impl WebSurfacePollCadence {
pub(super) fn note_ensure(
&mut self,
input_kind: WebSurfaceInputKind,
started_loading: bool,
now: Instant,
) {
if started_loading {
self.last_render_phase = None;
self.boost_until(now + LOAD_BOOST_WINDOW);
}
match input_kind {
WebSurfaceInputKind::Idle => {}
WebSurfaceInputKind::Hover => self.boost_until(now + HOVER_BOOST_WINDOW),
WebSurfaceInputKind::Scroll
| WebSurfaceInputKind::Click
| WebSurfaceInputKind::Text => {
self.boost_until(now + INPUT_BOOST_WINDOW);
}
}
}
pub(super) fn note_frame(&mut self, render_state: &str, now: Instant) {
let phase = WebSurfaceRenderPhase::from_render_state(render_state);
match phase {
WebSurfaceRenderPhase::Created | WebSurfaceRenderPhase::Loading => {
self.boost_until(now + LOAD_BOOST_WINDOW);
}
WebSurfaceRenderPhase::Complete if self.last_render_phase != Some(phase) => {
self.boost_until(now + FRAME_SETTLE_WINDOW);
}
WebSurfaceRenderPhase::Complete => {}
WebSurfaceRenderPhase::Other => {
self.boost_until(now + INPUT_BOOST_WINDOW);
}
}
self.last_render_phase = Some(phase);
}
pub(super) fn should_poll(&self, now: Instant) -> bool {
self.next_poll_at.is_none_or(|next| now >= next)
}
pub(super) fn note_poll_submitted(&mut self, now: Instant) {
self.next_poll_at = Some(now + self.current_interval(now));
}
fn current_interval(&self, now: Instant) -> Duration {
if self.active_until.is_some_and(|deadline| now < deadline) {
ACTIVE_POLL_INTERVAL
} else {
IDLE_POLL_INTERVAL
}
}
fn boost_until(&mut self, deadline: Instant) {
if self.active_until.is_none_or(|current| deadline > current) {
self.active_until = Some(deadline);
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum WebSurfaceRenderPhase {
Created,
Loading,
Complete,
Other,
}
impl WebSurfaceRenderPhase {
fn from_render_state(render_state: &str) -> Self {
match render_state {
"created" => Self::Created,
"loading" => Self::Loading,
"complete" => Self::Complete,
_ => Self::Other,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum WebSurfaceInputKind {
Idle,
Scroll,
Click,
Hover,
Text,
}
impl WebSurfaceInputKind {
pub(super) fn label(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Scroll => "scroll",
Self::Click => "click",
Self::Hover => "hover",
Self::Text => "text",
}
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use super::{WebSurfaceInputKind, WebSurfacePollCadence};
#[test]
fn idle_poll_uses_low_frequency_after_submission() {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_poll_submitted(start);
assert!(!cadence.should_poll(start + Duration::from_millis(79)));
assert!(cadence.should_poll(start + Duration::from_millis(80)));
}
#[test]
fn scroll_input_uses_active_frame_cadence() {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_ensure(WebSurfaceInputKind::Scroll, false, start);
cadence.note_poll_submitted(start);
assert!(!cadence.should_poll(start + Duration::from_millis(7)));
assert!(cadence.should_poll(start + Duration::from_millis(8)));
}
#[test]
fn complete_frames_settle_then_return_to_idle_cadence() {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("complete", start);
cadence.note_poll_submitted(start + Duration::from_millis(300));
assert!(!cadence.should_poll(start + Duration::from_millis(379)));
assert!(cadence.should_poll(start + Duration::from_millis(380)));
}
#[test]
fn repeated_complete_frames_do_not_extend_settle_window() {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("complete", start);
cadence.note_frame("complete", start + Duration::from_millis(200));
cadence.note_poll_submitted(start + Duration::from_millis(260));
assert!(!cadence.should_poll(start + Duration::from_millis(339)));
assert!(cadence.should_poll(start + Duration::from_millis(340)));
}
}
@@ -6,7 +6,7 @@ use crate::services::ProfileDataMode;
use super::{ use super::{
ElyShell, ElyShell,
web_surface::WebSurfacePageMetadata, web_surface_metadata::WebSurfacePageMetadata,
web_surface_permissions::web_surface_site_permissions_for_tab, web_surface_permissions::web_surface_site_permissions_for_tab,
web_surface_runtime::{WebSurfaceUrlChange, WebSurfaceUrlChangeKind}, web_surface_runtime::{WebSurfaceUrlChange, WebSurfaceUrlChangeKind},
web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState}, web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState},
@@ -0,0 +1,29 @@
use ely_domain::TabId;
use super::web_surface_frame::WebSurfaceFrame;
/// One page's worth of metadata observed in a Ready frame. The
/// controller applies these to the `BrowserTab` after the frame has
/// been swapped into the surface state. Title and favicon are
/// independent: navigation often settles the URL first, then Servo
/// emits a title change a frame or two later.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct WebSurfacePageMetadata {
pub(super) tab_id: TabId,
pub(super) title: Option<String>,
pub(super) favicon_url: Option<String>,
}
impl WebSurfacePageMetadata {
pub(super) fn from_frame(tab_id: &TabId, frame: &WebSurfaceFrame) -> Option<Self> {
let title = frame.title().map(str::to_string);
let favicon_url = frame
.loaded_url()
.and_then(|loaded| ely_domain::UrlText::parse(loaded).ok())
.and_then(|url| url.favicon_url());
if title.is_none() && favicon_url.is_none() {
return None;
}
Some(Self { tab_id: tab_id.clone(), title, favicon_url })
}
}
+29 -21
View File
@@ -9,6 +9,7 @@ use crate::services::{
}; };
use super::{ use super::{
web_surface_cadence::{WebSurfaceInputKind, WebSurfacePollCadence},
web_surface_frame::WebSurfaceFrame, web_surface_frame::WebSurfaceFrame,
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize}, web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
web_surface_permissions::WebSurfaceSitePermission, web_surface_permissions::WebSurfaceSitePermission,
@@ -69,6 +70,7 @@ impl WebSurfaceRuntime {
session.size = size; session.size = size;
session.zoom_percent = zoom_percent; session.zoom_percent = zoom_percent;
session.scroll_offset = next_scroll_offset; session.scroll_offset = next_scroll_offset;
session.cadence.note_ensure(input_kind, started_loading, Instant::now());
started_loading started_loading
}; };
@@ -96,29 +98,16 @@ impl WebSurfaceRuntime {
return Err("Servo worker was created but is no longer registered".to_string()); return Err("Servo worker was created but is no longer registered".to_string());
}; };
scoped.worker.submit_ensure(request); scoped.worker.submit_ensure(request);
log_ensure_submitted(tab, size, input_kind, enqueued_at, started_loading); log_ensure_submitted(tab, size, input_kind.label(), enqueued_at, started_loading);
Ok(WebSurfaceEnsureResult { requested_url, started_loading }) Ok(WebSurfaceEnsureResult { requested_url, started_loading })
} }
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec<WebSurfaceRuntimeFrame> { pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec<WebSurfaceRuntimeFrame> {
// Submit a Poll for every visible tab whose session is live so
// animations / JS-driven content keep advancing without user
// input. The worker coalesces — a Poll never overrides a
// pending Ensure — so this stays cheap even at 120 Hz.
for tab_id in visible_tab_ids {
let Some(session) = self.sessions.get(tab_id) else {
continue;
};
let Some(scoped) = self.workers.get(&session.scope) else {
continue;
};
scoped.worker.submit_poll(tab_id.as_str().to_string());
}
let mut frames = Vec::new(); let mut frames = Vec::new();
let mut dead_scopes = Vec::new(); let mut dead_scopes = Vec::new();
let scopes: Vec<WebSurfaceRuntimeScope> = self.workers.keys().cloned().collect(); let scopes: Vec<WebSurfaceRuntimeScope> = self.workers.keys().cloned().collect();
let now = Instant::now();
for scope in scopes { for scope in scopes {
let responses = self let responses = self
.workers .workers
@@ -138,6 +127,7 @@ impl WebSurfaceRuntime {
let requested_url = session.requested_url.clone(); let requested_url = session.requested_url.clone();
let scroll_offset = session.scroll_offset; let scroll_offset = session.scroll_offset;
let zoom_percent = session.zoom_percent; let zoom_percent = session.zoom_percent;
session.cadence.note_frame(frame.render_state(), now);
match WebSurfaceFrame::from_live_frame( match WebSurfaceFrame::from_live_frame(
requested_url.clone(), requested_url.clone(),
scroll_offset, scroll_offset,
@@ -176,6 +166,22 @@ impl WebSurfaceRuntime {
self.workers.remove(&scope); self.workers.remove(&scope);
} }
let poll_now = Instant::now();
for tab_id in visible_tab_ids {
let Some(session) = self.sessions.get_mut(tab_id) else {
continue;
};
if !session.cadence.should_poll(poll_now) {
continue;
}
let Some(scoped) = self.workers.get(&session.scope) else {
continue;
};
if scoped.worker.submit_poll(tab_id.as_str().to_string()) {
session.cadence.note_poll_submitted(poll_now);
}
}
frames frames
} }
@@ -269,6 +275,7 @@ pub(super) struct WebSurfaceSession {
pub(super) zoom_percent: u16, pub(super) zoom_percent: u16,
pub(super) scroll_offset: WebSurfaceScrollOffset, pub(super) scroll_offset: WebSurfaceScrollOffset,
pub(super) pending_user_navigation: bool, pub(super) pending_user_navigation: bool,
pub(super) cadence: WebSurfacePollCadence,
} }
impl WebSurfaceSession { impl WebSurfaceSession {
@@ -280,6 +287,7 @@ impl WebSurfaceSession {
zoom_percent: 0, zoom_percent: 0,
scroll_offset: WebSurfaceScrollOffset::default(), scroll_offset: WebSurfaceScrollOffset::default(),
pending_user_navigation: false, pending_user_navigation: false,
cadence: WebSurfacePollCadence::default(),
} }
} }
@@ -394,17 +402,17 @@ fn input_requests_history_navigation(input: &WebSurfacePendingInput) -> bool {
|| input.typed_text.as_deref().is_some_and(|text| text.contains('\n')) || input.typed_text.as_deref().is_some_and(|text| text.contains('\n'))
} }
fn pending_input_kind(input: &WebSurfacePendingInput) -> &'static str { fn pending_input_kind(input: &WebSurfacePendingInput) -> WebSurfaceInputKind {
if input.scroll_delta.is_some() { if input.scroll_delta.is_some() {
"scroll" WebSurfaceInputKind::Scroll
} else if input.click_point.is_some() { } else if input.click_point.is_some() {
"click" WebSurfaceInputKind::Click
} else if input.typed_text.is_some() { } else if input.typed_text.is_some() {
"text" WebSurfaceInputKind::Text
} else if input.hover_point.is_some() { } else if input.hover_point.is_some() {
"hover" WebSurfaceInputKind::Hover
} else { } else {
"idle" WebSurfaceInputKind::Idle
} }
} }
+14 -1
View File
@@ -1,4 +1,4 @@
use std::time::Instant; use std::time::{Duration, Instant};
use ely_domain::TabId; use ely_domain::TabId;
use gpui::{Bounds, Pixels}; use gpui::{Bounds, Pixels};
@@ -116,6 +116,7 @@ pub(super) struct PerTabSurface {
pub(super) viewport_size: Option<WebSurfaceSize>, pub(super) viewport_size: Option<WebSurfaceSize>,
pub(super) last_ensure_key: Option<WebSurfaceEnsureKey>, pub(super) last_ensure_key: Option<WebSurfaceEnsureKey>,
pub(super) hover_point: Option<WebSurfaceClickPoint>, pub(super) hover_point: Option<WebSurfaceClickPoint>,
last_hover_enqueued_at: Option<Instant>,
pub(super) click_point: Option<WebSurfaceClickState>, pub(super) click_point: Option<WebSurfaceClickState>,
pub(super) pending_scroll_delta: Option<WebSurfaceScrollDelta>, pub(super) pending_scroll_delta: Option<WebSurfaceScrollDelta>,
pub(super) pending_scroll_point: Option<WebSurfaceClickPoint>, pub(super) pending_scroll_point: Option<WebSurfaceClickPoint>,
@@ -132,6 +133,7 @@ impl PerTabSurface {
viewport_size: None, viewport_size: None,
last_ensure_key: None, last_ensure_key: None,
hover_point: None, hover_point: None,
last_hover_enqueued_at: None,
click_point: None, click_point: None,
pending_scroll_delta: None, pending_scroll_delta: None,
pending_scroll_point: None, pending_scroll_point: None,
@@ -146,6 +148,15 @@ impl PerTabSurface {
self.pending_input_started_at.get_or_insert_with(Instant::now); self.pending_input_started_at.get_or_insert_with(Instant::now);
} }
pub(super) fn hover_is_throttled(&self, now: Instant) -> bool {
self.last_hover_enqueued_at
.is_some_and(|last| now.duration_since(last) < HOVER_INPUT_MIN_INTERVAL)
}
pub(super) fn mark_hover_enqueued(&mut self, now: Instant) {
self.last_hover_enqueued_at = Some(now);
}
pub(super) fn should_ensure(&self, key: &WebSurfaceEnsureKey) -> bool { pub(super) fn should_ensure(&self, key: &WebSurfaceEnsureKey) -> bool {
self.last_ensure_key.as_ref() != Some(key) || self.has_pending_input() self.last_ensure_key.as_ref() != Some(key) || self.has_pending_input()
} }
@@ -171,6 +182,8 @@ impl PerTabSurface {
} }
} }
const HOVER_INPUT_MIN_INTERVAL: Duration = Duration::from_millis(32);
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct WebSurfaceEnsureKey { pub(super) struct WebSurfaceEnsureKey {
requested_url: String, requested_url: String,
+31 -1
View File
@@ -1,4 +1,4 @@
use std::error::Error; use std::{error::Error, time::Duration};
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText}; use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use gpui::{Bounds, point, px, size}; use gpui::{Bounds, point, px, size};
@@ -218,6 +218,36 @@ fn zero_wheel_delta_reports_zero_delta() -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
#[test]
fn hover_input_is_rate_limited() -> Result<(), Box<dyn Error>> {
let mut store = WebSurfaceStore::new();
let tab = web_tab("https://example.com/hover")?;
let start = std::time::Instant::now();
assert_applied(store.record_viewport_size(tab.id(), web_bounds(), 1.0));
assert_applied(store.record_hover_point_at(tab.id(), point(px(10.0), px(10.0)), 1.0, start));
assert_eq!(
store.record_hover_point_at(
tab.id(),
point(px(12.0), px(12.0)),
1.0,
start + Duration::from_millis(8),
),
WebSurfaceInputOutcome::NoChange,
);
assert_applied(store.record_hover_point_at(
tab.id(),
point(px(44.0), px(45.0)),
1.0,
start + Duration::from_millis(33),
));
let input = store.take_pending_input(tab.id(), tab.url().as_str());
assert_eq!(input.hover_point.map(|point| (point.x(), point.y())), Some((44, 45)));
Ok(())
}
/// Pinning the per-tab isolation invariant. A click recorded against /// Pinning the per-tab isolation invariant. A click recorded against
/// tab A must not be drained by, dropped by, or overwritten by any /// tab A must not be drained by, dropped by, or overwritten by any
/// state mutation routed to tab B. The store keys every click on its /// state mutation routed to tab B. The store keys every click on its
+10 -3
View File
@@ -162,20 +162,27 @@ impl LiveRuntimeWorker {
cvar.notify_one(); cvar.notify_one();
} }
pub(super) fn submit_poll(&self, tab_id: String) { pub(super) fn submit_poll(&self, tab_id: String) -> bool {
let (lock, cvar) = &*self.queue; let (lock, cvar) = &*self.queue;
let mut q = match lock.lock() { let mut q = match lock.lock() {
Ok(guard) => guard, Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(), Err(poisoned) => poisoned.into_inner(),
}; };
if q.shutdown { if q.shutdown {
return; return false;
} }
// A pending Ensure already produces the latest frame after its // A pending Ensure already produces the latest frame after its
// run; don't downgrade it to a Poll. Only insert if nothing is // run; don't downgrade it to a Poll. Only insert if nothing is
// queued. // queued.
q.pending.entry(tab_id.clone()).or_insert(WorkerRequest::Poll { tab_id }); let inserted = match q.pending.entry(tab_id.clone()) {
std::collections::btree_map::Entry::Vacant(entry) => {
entry.insert(WorkerRequest::Poll { tab_id });
true
}
std::collections::btree_map::Entry::Occupied(_) => false,
};
cvar.notify_one(); cvar.notify_one();
inserted
} }
pub(super) fn submit_close(&self, tab_id: String) { pub(super) fn submit_close(&self, tab_id: String) {