perf(web-surface): throttle idle Servo polling
This commit is contained in:
@@ -15,7 +15,8 @@ use gpui::Context;
|
||||
|
||||
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
|
||||
/// the Sync settings page can pick the right widget cluster (only the
|
||||
@@ -131,7 +132,7 @@ impl ElyShell {
|
||||
return;
|
||||
};
|
||||
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) => {
|
||||
let _ = engine.install_bearer("");
|
||||
}
|
||||
@@ -210,8 +211,7 @@ fn spawn_verify_otp(
|
||||
}
|
||||
};
|
||||
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,
|
||||
Err(error) => {
|
||||
let _ = tx.send(SyncStateUpdate::AuthError {
|
||||
|
||||
@@ -16,19 +16,23 @@ mod plugins;
|
||||
mod reading_list;
|
||||
mod render;
|
||||
mod settings_actions;
|
||||
mod shell_actions;
|
||||
mod shortcut_files;
|
||||
mod sidebar;
|
||||
mod site_permissions;
|
||||
mod space_files;
|
||||
mod spaces;
|
||||
mod splits;
|
||||
mod sync_state;
|
||||
mod tab_groups;
|
||||
mod tab_lifecycle;
|
||||
mod web_surface;
|
||||
mod web_surface_cadence;
|
||||
mod web_surface_controller;
|
||||
mod web_surface_frame;
|
||||
mod web_surface_geometry;
|
||||
mod web_surface_keyboard;
|
||||
mod web_surface_metadata;
|
||||
mod web_surface_permissions;
|
||||
mod web_surface_runtime;
|
||||
mod web_surface_state;
|
||||
@@ -51,14 +55,9 @@ use bookmarks::PendingBookmarkEdit;
|
||||
use downloads::PendingDownloadFileAction;
|
||||
use history::{PendingHistoryDomainClear, PendingHistoryTimeClear};
|
||||
use plugins::{PendingPluginInstall, PendingPluginUninstall};
|
||||
use sync_state::SyncStateUpdate;
|
||||
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 {
|
||||
Ready(Box<BrowserCore>),
|
||||
StartupError(String),
|
||||
@@ -113,36 +112,6 @@ pub struct ElyShell {
|
||||
_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 {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self::new_with_config(InitialBrowserConfig::ely_defaults(), window, cx)
|
||||
@@ -486,129 +455,6 @@ impl ElyShell {
|
||||
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>) {
|
||||
|
||||
@@ -9,6 +9,7 @@ use gpui_component::slider::SliderValue;
|
||||
|
||||
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};
|
||||
|
||||
impl ElyShell {
|
||||
@@ -316,25 +317,25 @@ fn run_sync_upload(
|
||||
profile_dir: std::path::PathBuf,
|
||||
device_name: String,
|
||||
bytes: Vec<u8>,
|
||||
inbox: std::sync::mpsc::Sender<super::SyncStateUpdate>,
|
||||
inbox: std::sync::mpsc::Sender<SyncStateUpdate>,
|
||||
) {
|
||||
let mut engine = match SyncEngine::for_profile_dir(
|
||||
&profile_dir,
|
||||
device_name,
|
||||
super::sync_platform_label(),
|
||||
sync_platform_label(),
|
||||
) {
|
||||
Ok(engine) => engine,
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
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;
|
||||
}
|
||||
};
|
||||
match engine.upload_bytes(bytes) {
|
||||
Ok(ely_browser_core::SyncOutcome::SignedOut) => {
|
||||
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 {
|
||||
snapshot_id,
|
||||
@@ -354,15 +355,15 @@ fn run_sync_upload(
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let _ = inbox.send(super::SyncStateUpdate::SyncReady { last_synced_at_secs });
|
||||
let _ = inbox.send(SyncStateUpdate::SyncReady { last_synced_at_secs });
|
||||
}
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
tracing::warn!(target: "ely::sync", error = %message, "snapshot upload failed");
|
||||
let update = if message.contains("device_not_approved") {
|
||||
super::SyncStateUpdate::AwaitingDeviceApproval
|
||||
SyncStateUpdate::AwaitingDeviceApproval
|
||||
} else {
|
||||
super::SyncStateUpdate::SyncError { message }
|
||||
SyncStateUpdate::SyncError { message }
|
||||
};
|
||||
let _ = inbox.send(update);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::{collections::BTreeMap, time::Instant};
|
||||
|
||||
use ely_domain::{BrowserTab, TabId};
|
||||
use gpui::{Bounds, Pixels, Point};
|
||||
|
||||
use crate::services::ProfileDataMode;
|
||||
|
||||
use super::web_surface_metadata::WebSurfacePageMetadata;
|
||||
use super::{
|
||||
web_surface_frame::WebSurfaceFrame,
|
||||
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 {
|
||||
runtime: WebSurfaceRuntime,
|
||||
/// 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) {
|
||||
Ok(()) => {}
|
||||
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 {
|
||||
self.surface_mut(&tab_id).state =
|
||||
Some(WebSurfaceState::Failed { message });
|
||||
@@ -163,12 +132,6 @@ impl WebSurfaceStore {
|
||||
Some(WebSurfaceState::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!(
|
||||
target: "ely::web_surface",
|
||||
tab_id = %tab_id,
|
||||
@@ -238,12 +201,6 @@ impl WebSurfaceStore {
|
||||
});
|
||||
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
|
||||
// `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;
|
||||
WebSurfaceInputOutcome::Applied
|
||||
}
|
||||
@@ -278,6 +235,16 @@ impl WebSurfaceStore {
|
||||
tab_id: &TabId,
|
||||
position: Point<Pixels>,
|
||||
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 {
|
||||
let Some(surface) = self.surfaces.get_mut(tab_id) else {
|
||||
return WebSurfaceInputOutcome::DroppedNoViewportBounds;
|
||||
@@ -290,7 +257,14 @@ impl WebSurfaceStore {
|
||||
else {
|
||||
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.mark_hover_enqueued(now);
|
||||
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::{
|
||||
ElyShell,
|
||||
web_surface::WebSurfacePageMetadata,
|
||||
web_surface_metadata::WebSurfacePageMetadata,
|
||||
web_surface_permissions::web_surface_site_permissions_for_tab,
|
||||
web_surface_runtime::{WebSurfaceUrlChange, WebSurfaceUrlChangeKind},
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use crate::services::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
web_surface_cadence::{WebSurfaceInputKind, WebSurfacePollCadence},
|
||||
web_surface_frame::WebSurfaceFrame,
|
||||
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
|
||||
web_surface_permissions::WebSurfaceSitePermission,
|
||||
@@ -69,6 +70,7 @@ impl WebSurfaceRuntime {
|
||||
session.size = size;
|
||||
session.zoom_percent = zoom_percent;
|
||||
session.scroll_offset = next_scroll_offset;
|
||||
session.cadence.note_ensure(input_kind, started_loading, Instant::now());
|
||||
started_loading
|
||||
};
|
||||
|
||||
@@ -96,29 +98,16 @@ impl WebSurfaceRuntime {
|
||||
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);
|
||||
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> {
|
||||
// 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 dead_scopes = Vec::new();
|
||||
let scopes: Vec<WebSurfaceRuntimeScope> = self.workers.keys().cloned().collect();
|
||||
let now = Instant::now();
|
||||
for scope in scopes {
|
||||
let responses = self
|
||||
.workers
|
||||
@@ -138,6 +127,7 @@ impl WebSurfaceRuntime {
|
||||
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(), now);
|
||||
match WebSurfaceFrame::from_live_frame(
|
||||
requested_url.clone(),
|
||||
scroll_offset,
|
||||
@@ -176,6 +166,22 @@ impl WebSurfaceRuntime {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -269,6 +275,7 @@ pub(super) struct WebSurfaceSession {
|
||||
pub(super) zoom_percent: u16,
|
||||
pub(super) scroll_offset: WebSurfaceScrollOffset,
|
||||
pub(super) pending_user_navigation: bool,
|
||||
pub(super) cadence: WebSurfacePollCadence,
|
||||
}
|
||||
|
||||
impl WebSurfaceSession {
|
||||
@@ -280,6 +287,7 @@ impl WebSurfaceSession {
|
||||
zoom_percent: 0,
|
||||
scroll_offset: WebSurfaceScrollOffset::default(),
|
||||
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'))
|
||||
}
|
||||
|
||||
fn pending_input_kind(input: &WebSurfacePendingInput) -> &'static str {
|
||||
fn pending_input_kind(input: &WebSurfacePendingInput) -> WebSurfaceInputKind {
|
||||
if input.scroll_delta.is_some() {
|
||||
"scroll"
|
||||
WebSurfaceInputKind::Scroll
|
||||
} else if input.click_point.is_some() {
|
||||
"click"
|
||||
WebSurfaceInputKind::Click
|
||||
} else if input.typed_text.is_some() {
|
||||
"text"
|
||||
WebSurfaceInputKind::Text
|
||||
} else if input.hover_point.is_some() {
|
||||
"hover"
|
||||
WebSurfaceInputKind::Hover
|
||||
} else {
|
||||
"idle"
|
||||
WebSurfaceInputKind::Idle
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ely_domain::TabId;
|
||||
use gpui::{Bounds, Pixels};
|
||||
@@ -116,6 +116,7 @@ pub(super) struct PerTabSurface {
|
||||
pub(super) viewport_size: Option<WebSurfaceSize>,
|
||||
pub(super) last_ensure_key: Option<WebSurfaceEnsureKey>,
|
||||
pub(super) hover_point: Option<WebSurfaceClickPoint>,
|
||||
last_hover_enqueued_at: Option<Instant>,
|
||||
pub(super) click_point: Option<WebSurfaceClickState>,
|
||||
pub(super) pending_scroll_delta: Option<WebSurfaceScrollDelta>,
|
||||
pub(super) pending_scroll_point: Option<WebSurfaceClickPoint>,
|
||||
@@ -132,6 +133,7 @@ impl PerTabSurface {
|
||||
viewport_size: None,
|
||||
last_ensure_key: None,
|
||||
hover_point: None,
|
||||
last_hover_enqueued_at: None,
|
||||
click_point: None,
|
||||
pending_scroll_delta: None,
|
||||
pending_scroll_point: None,
|
||||
@@ -146,6 +148,15 @@ impl PerTabSurface {
|
||||
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 {
|
||||
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)]
|
||||
pub(super) struct WebSurfaceEnsureKey {
|
||||
requested_url: String,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::error::Error;
|
||||
use std::{error::Error, time::Duration};
|
||||
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
use gpui::{Bounds, point, px, size};
|
||||
@@ -218,6 +218,36 @@ fn zero_wheel_delta_reports_zero_delta() -> Result<(), Box<dyn Error>> {
|
||||
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
|
||||
/// 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
|
||||
|
||||
@@ -162,20 +162,27 @@ impl LiveRuntimeWorker {
|
||||
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 mut q = match lock.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
if q.shutdown {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
// 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 });
|
||||
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();
|
||||
inserted
|
||||
}
|
||||
|
||||
pub(super) fn submit_close(&self, tab_id: String) {
|
||||
|
||||
Reference in New Issue
Block a user