Move Servo IPC off UI thread
Root cause of the post-tab lag: the GPUI 16 ms timer was calling `WebSurfaceRuntime::ensure_tab` and `tick` on the UI thread, and each call did a synchronous `serde_json` write plus `read_line` against the Servo sidecar over stdin/stdout. With even one visible tab, every frame stalled on cross-process IPC. Introduce `web_surface_worker.rs` — a per-profile worker thread that owns the `ServoLiveClient`, drains a coalescing request queue (latest Ensure/Poll per tab wins, no unbounded growth), and ships results back through a `std::sync::mpsc` channel. `WebSurfaceRuntime` now submits work non-blockingly and drains responses in `tick`; the UI thread never blocks on the sidecar. Adjacent in-flight cleanup riding along: hardware IOSurface rendering-context completion (sidecar `live_protocol`, `hardware_rendering_context`, GPUI BGRA surface shader), CSS viewport size + device pixel ratio plumbing into `ServoLiveFrame`, and the Send opt-ins for `CVPixelBuffer`-bearing types so frames can cross the thread boundary.
This commit is contained in:
@@ -2,8 +2,7 @@ use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
io::{self, BufRead},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use ely_domain::{DEFAULT_ZOOM_PERCENT, ProfileId, TabId, UrlText};
|
||||
@@ -11,7 +10,7 @@ use ely_servo_host::{
|
||||
IOSurfaceIdentity, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest,
|
||||
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest,
|
||||
RenderingContextKind, ResizeRequest, ScrollRequest, ServoHost, ServoSurfaceSize,
|
||||
SoftwareServoHost,
|
||||
SoftwareServoHost, WebViewState,
|
||||
};
|
||||
|
||||
use super::args::LiveArgs;
|
||||
@@ -24,12 +23,6 @@ use super::live_protocol::{
|
||||
};
|
||||
use super::perf::{FramePerfAggregator, FramePerfSummary, elapsed_ns};
|
||||
|
||||
/// Per-`Ensure` budget for Servo to paint after input dispatch.
|
||||
/// 250 ms catches the common click + paint round trip within the
|
||||
/// same `Ensure` instead of waiting for the next 16 ms `Poll`.
|
||||
const LIVE_FRAME_WAIT_TIMEOUT: Duration = Duration::from_millis(250);
|
||||
const LIVE_FRAME_WAIT_INTERVAL: Duration = Duration::from_millis(2);
|
||||
|
||||
pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> {
|
||||
let LiveArgs { profile_data_dir, iosurface_mach_service, rendering_context_kind } = args;
|
||||
fs::create_dir_all(&profile_data_dir)?;
|
||||
@@ -64,7 +57,7 @@ pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> {
|
||||
// matching stop is the `stdout.flush()` inside
|
||||
// `write_outcome`.
|
||||
let frame_started_at = Instant::now();
|
||||
let mut outcome = match serde_json::from_str::<LiveRequest>(&line) {
|
||||
let outcome = match serde_json::from_str::<LiveRequest>(&line) {
|
||||
Ok(request) => handle_request(
|
||||
&mut host,
|
||||
&mut sessions,
|
||||
@@ -75,7 +68,11 @@ pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> {
|
||||
Err(error) => Err(LiveSidecarError::Json(error)),
|
||||
};
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
send_surface_port_if_needed(iosurface_mach_sender.as_mut(), &mut outcome);
|
||||
let outcome = {
|
||||
let mut outcome = outcome;
|
||||
send_surface_port_if_needed(iosurface_mach_sender.as_mut(), &mut outcome);
|
||||
outcome
|
||||
};
|
||||
write_outcome(&mut stdout, &mut perf, &mut pending_summary, outcome, frame_started_at)?;
|
||||
}
|
||||
|
||||
@@ -153,12 +150,11 @@ fn handle_request(
|
||||
typed_text,
|
||||
};
|
||||
if apply_input(host, session, input)? {
|
||||
// Tell poll_frame to actually wait for Servo to paint
|
||||
// a response to this input. The visible-content gate
|
||||
// is bypassed on the hardware path inside poll_frame,
|
||||
// so we return on the first `has_pending_frame=true`
|
||||
// (~3 ms in practice) rather than burning the full
|
||||
// LIVE_FRAME_WAIT_TIMEOUT.
|
||||
// The app tick calls this sidecar synchronously from
|
||||
// GPUI's update path. Mark that a fresh frame is
|
||||
// desired, then let poll_frame take one event-loop
|
||||
// step; a later 16 ms app tick will poll again if
|
||||
// Servo has not painted yet.
|
||||
session.awaiting_visible_frame = true;
|
||||
}
|
||||
let webview_id = session.webview_id.clone();
|
||||
@@ -363,33 +359,24 @@ fn poll_frame(
|
||||
session: &mut LiveSession,
|
||||
rendering_context_kind: RenderingContextKind,
|
||||
) -> Result<LiveOutcome, LiveSidecarError> {
|
||||
let started_at = Instant::now();
|
||||
|
||||
loop {
|
||||
host.tick();
|
||||
let snapshot = host.snapshot(&session.webview_id)?;
|
||||
if snapshot.has_pending_frame() {
|
||||
let (outcome, has_visible_content) =
|
||||
paint_pending_frame(host, session, rendering_context_kind)?;
|
||||
if has_visible_content {
|
||||
session.awaiting_visible_frame = false;
|
||||
session.ever_visible_frame = true;
|
||||
return Ok(outcome);
|
||||
}
|
||||
if !session.awaiting_visible_frame {
|
||||
return Ok(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
if !session.awaiting_visible_frame {
|
||||
return Ok(LiveOutcome::empty());
|
||||
}
|
||||
if started_at.elapsed() >= LIVE_FRAME_WAIT_TIMEOUT {
|
||||
return Ok(LiveOutcome::empty());
|
||||
}
|
||||
|
||||
thread::sleep(LIVE_FRAME_WAIT_INTERVAL);
|
||||
host.tick();
|
||||
let snapshot = host.snapshot(&session.webview_id)?;
|
||||
if !snapshot.has_pending_frame() {
|
||||
return Ok(LiveOutcome::empty());
|
||||
}
|
||||
|
||||
let (outcome, has_visible_content) =
|
||||
paint_pending_frame(host, session, rendering_context_kind)?;
|
||||
if has_visible_content {
|
||||
session.awaiting_visible_frame = false;
|
||||
session.ever_visible_frame = true;
|
||||
return Ok(outcome);
|
||||
}
|
||||
if !session.awaiting_visible_frame {
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
Ok(LiveOutcome::empty())
|
||||
}
|
||||
|
||||
fn paint_pending_frame(
|
||||
@@ -418,7 +405,7 @@ fn paint_readback_frame(
|
||||
let encode_started_at = Instant::now();
|
||||
let has_visible_content = session.ever_visible_frame
|
||||
|| (frame.non_white_pixel_count() > 0 && frame.content_pixel_count() > 0);
|
||||
let report = LiveFrameReport::new(&snapshot, &frame);
|
||||
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio());
|
||||
let encode_ns = elapsed_ns(encode_started_at);
|
||||
let timings = PartialFrameTimings { paint_ns, encode_ns };
|
||||
Ok((LiveOutcome::from_frame(report, frame, timings), has_visible_content))
|
||||
@@ -429,17 +416,51 @@ fn paint_hardware_surface_frame(
|
||||
host: &mut SoftwareServoHost,
|
||||
session: &LiveSession,
|
||||
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
|
||||
if !session.ever_visible_frame {
|
||||
return paint_initial_hardware_surface_frame(host, session);
|
||||
}
|
||||
|
||||
let paint_started_at = Instant::now();
|
||||
host.paint_without_readback(&session.webview_id)?;
|
||||
let snapshot = host.snapshot(&session.webview_id)?;
|
||||
let paint_ns = elapsed_ns(paint_started_at);
|
||||
let encode_started_at = Instant::now();
|
||||
let report = LiveFrameReport::new_hardware_surface(&snapshot, session.width, session.height);
|
||||
let report = LiveFrameReport::new_hardware_surface(
|
||||
&snapshot,
|
||||
session.width,
|
||||
session.height,
|
||||
session.device_pixel_ratio(),
|
||||
);
|
||||
let encode_ns = elapsed_ns(encode_started_at);
|
||||
let timings = PartialFrameTimings { paint_ns, encode_ns };
|
||||
Ok((LiveOutcome::from_report(report, timings), true))
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
fn paint_initial_hardware_surface_frame(
|
||||
host: &mut SoftwareServoHost,
|
||||
session: &LiveSession,
|
||||
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
|
||||
let paint_started_at = Instant::now();
|
||||
host.paint(&session.webview_id)?;
|
||||
let snapshot = host.snapshot(&session.webview_id)?;
|
||||
let frame = host.last_rendered_frame()?;
|
||||
let paint_ns = elapsed_ns(paint_started_at);
|
||||
let encode_started_at = Instant::now();
|
||||
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio());
|
||||
let has_visible_content = frame.non_white_pixel_count() > 0
|
||||
&& frame.content_pixel_count() > 0
|
||||
&& hardware_snapshot_has_visible_document(&snapshot);
|
||||
let encode_ns = elapsed_ns(encode_started_at);
|
||||
let timings = PartialFrameTimings { paint_ns, encode_ns };
|
||||
Ok((LiveOutcome::from_frame(report, frame, timings), has_visible_content))
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
fn hardware_snapshot_has_visible_document(snapshot: &ely_servo_host::WebViewSnapshot) -> bool {
|
||||
snapshot.title().is_some() || matches!(snapshot.state(), WebViewState::Complete)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LiveSession {
|
||||
webview_id: ely_domain::WebViewId,
|
||||
@@ -468,12 +489,12 @@ struct LiveSession {
|
||||
}
|
||||
|
||||
impl LiveSession {
|
||||
fn new(webview_id: ely_domain::WebViewId, width: u32, height: u32) -> Self {
|
||||
fn new(webview_id: ely_domain::WebViewId, _width: u32, _height: u32) -> Self {
|
||||
Self {
|
||||
webview_id,
|
||||
requested_url: String::new(),
|
||||
width: width.max(1),
|
||||
height: height.max(1),
|
||||
width: 0,
|
||||
height: 0,
|
||||
page_zoom_percent: DEFAULT_ZOOM_PERCENT,
|
||||
hidpi_scale_milli: 0,
|
||||
scroll_x: 0,
|
||||
@@ -482,9 +503,32 @@ impl LiveSession {
|
||||
ever_visible_frame: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn device_pixel_ratio(&self) -> f32 {
|
||||
hidpi_scale_milli_to_f32(self.hidpi_scale_milli)
|
||||
}
|
||||
}
|
||||
|
||||
fn positive_scroll_component(current: i32, delta: i32) -> i32 {
|
||||
let value = i64::from(current) + i64::from(delta);
|
||||
value.clamp(0, i64::from(i32::MAX)) as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_live_session_forces_first_resize_after_hidpi() {
|
||||
let session = LiveSession::new(ely_domain::WebViewId::new(), 1280, 720);
|
||||
|
||||
assert_ne!(
|
||||
session.width, 1280,
|
||||
"first apply_layout must resize after hidpi has been pushed",
|
||||
);
|
||||
assert_ne!(
|
||||
session.height, 720,
|
||||
"first apply_layout must resize after hidpi has been pushed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_servo_host::SoftwareServoHost;
|
||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
||||
use ely_servo_host::{IOSurfaceHandle, IOSurfaceIdentity};
|
||||
use ely_servo_host::IOSurfaceHandle;
|
||||
use ely_servo_host::{IOSurfaceIdentity, SoftwareServoHost};
|
||||
|
||||
use super::live_protocol::{LiveOutcome, LiveSidecarError, PartialFrameTimings};
|
||||
use super::perf::{FramePerfAggregator, FramePerfSummary, FrameStageTimings, elapsed_ns};
|
||||
@@ -35,12 +35,18 @@ pub(super) fn populate_surface_fields(
|
||||
if outcome.response.frame.is_none() {
|
||||
return;
|
||||
}
|
||||
if outcome.frame.is_some() {
|
||||
return;
|
||||
}
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
{
|
||||
let Ok(Some(identity)) = host.peek_iosurface_identity(webview_id) else {
|
||||
return;
|
||||
};
|
||||
align_report_to_surface_identity(outcome, identity);
|
||||
if let Err(message) = require_report_matches_surface_identity(outcome, identity) {
|
||||
*outcome = LiveOutcome::error(message);
|
||||
return;
|
||||
}
|
||||
let handle = if surface_has_been_published(published_surface_ids, tab_id, identity) {
|
||||
None
|
||||
} else {
|
||||
@@ -105,11 +111,21 @@ fn handle_matches_identity(handle: IOSurfaceHandle, identity: IOSurfaceIdentity)
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
|
||||
fn align_report_to_surface_identity(outcome: &mut LiveOutcome, identity: IOSurfaceIdentity) {
|
||||
if let Some(frame) = outcome.response.frame.as_mut() {
|
||||
frame.width = identity.width;
|
||||
frame.height = identity.height;
|
||||
fn require_report_matches_surface_identity(
|
||||
outcome: &LiveOutcome,
|
||||
identity: IOSurfaceIdentity,
|
||||
) -> Result<(), String> {
|
||||
let Some(frame) = outcome.response.frame.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if frame.width == identity.width && frame.height == identity.height {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"servo hardware surface size {}x{} did not match frame report {}x{}",
|
||||
identity.width, identity.height, frame.width, frame.height,
|
||||
))
|
||||
}
|
||||
|
||||
/// Serialise the response then stream the optional raw RGBA frame on
|
||||
@@ -181,7 +197,7 @@ mod tests {
|
||||
live_protocol::{LiveFrameReport, LiveOutcome, PartialFrameTimings},
|
||||
perf::FramePerfAggregator,
|
||||
};
|
||||
use super::{align_report_to_surface_identity, surface_publication_for, write_outcome};
|
||||
use super::{require_report_matches_surface_identity, surface_publication_for, write_outcome};
|
||||
|
||||
#[test]
|
||||
fn unpublished_surface_without_handle_leaves_selector_empty() {
|
||||
@@ -235,17 +251,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardware_report_uses_surface_identity_dimensions() -> Result<(), Box<dyn Error>> {
|
||||
let mut outcome = LiveOutcome::from_report(
|
||||
fn hardware_report_mismatch_is_reported() -> Result<(), Box<dyn Error>> {
|
||||
let outcome = LiveOutcome::from_report(
|
||||
report_with_size(2180, 1586),
|
||||
PartialFrameTimings { paint_ns: 1_000, encode_ns: 2_000 },
|
||||
);
|
||||
|
||||
align_report_to_surface_identity(&mut outcome, identity(7, 2168, 1566));
|
||||
let error = match require_report_matches_surface_identity(&outcome, identity(7, 2168, 1566))
|
||||
{
|
||||
Ok(()) => return Err("mismatched IOSurface dimensions must be reported".into()),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
let report = outcome.response.frame.ok_or("report must remain present")?;
|
||||
assert_eq!(report.width, 2168);
|
||||
assert_eq!(report.height, 1566);
|
||||
assert_eq!(
|
||||
error,
|
||||
"servo hardware surface size 2168x1566 did not match frame report 2180x1586",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -314,6 +335,9 @@ mod tests {
|
||||
state: "complete",
|
||||
width,
|
||||
height,
|
||||
device_pixel_ratio: 1.0,
|
||||
css_viewport_width: width,
|
||||
css_viewport_height: height,
|
||||
rgba_byte_count: 0,
|
||||
non_white_pixel_count: 0,
|
||||
content_pixel_count: 0,
|
||||
|
||||
@@ -176,6 +176,9 @@ pub(super) struct LiveFrameReport {
|
||||
pub state: &'static str,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub device_pixel_ratio: f32,
|
||||
pub css_viewport_width: u32,
|
||||
pub css_viewport_height: u32,
|
||||
pub rgba_byte_count: usize,
|
||||
pub non_white_pixel_count: u64,
|
||||
pub content_pixel_count: u64,
|
||||
@@ -183,13 +186,18 @@ pub(super) struct LiveFrameReport {
|
||||
}
|
||||
|
||||
impl LiveFrameReport {
|
||||
pub fn new(snapshot: &WebViewSnapshot, frame: &RenderedFrame) -> Self {
|
||||
pub fn new(snapshot: &WebViewSnapshot, frame: &RenderedFrame, device_pixel_ratio: f32) -> Self {
|
||||
let (css_viewport_width, css_viewport_height) =
|
||||
css_viewport_size(frame.width(), frame.height(), device_pixel_ratio);
|
||||
Self {
|
||||
loaded_url: snapshot.url().map(str::to_string),
|
||||
title: snapshot.title().map(str::to_string),
|
||||
state: state_label(snapshot.state()),
|
||||
width: frame.width(),
|
||||
height: frame.height(),
|
||||
device_pixel_ratio,
|
||||
css_viewport_width,
|
||||
css_viewport_height,
|
||||
rgba_byte_count: frame.rgba_bytes().len(),
|
||||
non_white_pixel_count: frame.non_white_pixel_count(),
|
||||
content_pixel_count: frame.content_pixel_count(),
|
||||
@@ -200,13 +208,23 @@ impl LiveFrameReport {
|
||||
/// Build a report for the hardware IOSurface path. Pixel metrics
|
||||
/// are unavailable because the path skips framebuffer readback.
|
||||
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
|
||||
pub fn new_hardware_surface(snapshot: &WebViewSnapshot, width: u32, height: u32) -> Self {
|
||||
pub fn new_hardware_surface(
|
||||
snapshot: &WebViewSnapshot,
|
||||
width: u32,
|
||||
height: u32,
|
||||
device_pixel_ratio: f32,
|
||||
) -> Self {
|
||||
let (css_viewport_width, css_viewport_height) =
|
||||
css_viewport_size(width, height, device_pixel_ratio);
|
||||
Self {
|
||||
loaded_url: snapshot.url().map(str::to_string),
|
||||
title: snapshot.title().map(str::to_string),
|
||||
state: state_label(snapshot.state()),
|
||||
width,
|
||||
height,
|
||||
device_pixel_ratio,
|
||||
css_viewport_width,
|
||||
css_viewport_height,
|
||||
rgba_byte_count: 0,
|
||||
non_white_pixel_count: 0,
|
||||
content_pixel_count: 0,
|
||||
@@ -215,6 +233,18 @@ impl LiveFrameReport {
|
||||
}
|
||||
}
|
||||
|
||||
fn css_viewport_size(width: u32, height: u32, device_pixel_ratio: f32) -> (u32, u32) {
|
||||
let dpr = if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 {
|
||||
device_pixel_ratio
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
(
|
||||
((width as f32) / dpr).round().max(1.0) as u32,
|
||||
((height as f32) / dpr).round().max(1.0) as u32,
|
||||
)
|
||||
}
|
||||
|
||||
fn state_label(state: &WebViewState) -> &'static str {
|
||||
match state {
|
||||
WebViewState::Created => "created",
|
||||
|
||||
Reference in New Issue
Block a user