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:
2026-05-15 16:41:40 -04:00
parent f4c650c4d8
commit 90c029eddb
29 changed files with 2113 additions and 496 deletions
@@ -40,6 +40,15 @@ pub(crate) struct IOSurfaceCache {
pixel_buffers: HashMap<u64, CachedPixelBuffer>,
}
// SAFETY: CVPixelBuffer wraps CVPixelBufferRef, a CoreFoundation type
// Apple documents as safe to share across threads. The cache is owned
// by ServoLiveClient which now lives on the LiveRuntimeWorker thread,
// so the auto-Send check (rightly) rejects the raw pointer inside the
// crate's `CVPixelBuffer`. The pointer is atomically refcounted CFTypeRef
// and only mutated via Mach IPC, which is itself thread-safe.
#[expect(unsafe_code)]
unsafe impl Send for IOSurfaceCache {}
struct CachedPixelBuffer {
pixel_buffer: CVPixelBuffer,
width: u32,
+66
View File
@@ -289,6 +289,9 @@ pub(crate) struct ServoLiveFrame {
render_state: String,
width: u32,
height: u32,
device_pixel_ratio: f32,
css_viewport_width: u32,
css_viewport_height: u32,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -302,14 +305,26 @@ pub(crate) struct ServoLiveFrame {
pixel_buffer: Option<CVPixelBuffer>,
}
// SAFETY: CVPixelBuffer wraps CVPixelBufferRef, a CoreFoundation type
// Apple documents as safe to share across threads. The Rust core-video
// crate does not mark it Send, so the worker thread needs this opt-in
// to ship hardware frames back to the UI thread via mpsc::Sender.
#[cfg(target_os = "macos")]
#[expect(unsafe_code)]
unsafe impl Send for ServoLiveFrame {}
impl ServoLiveFrame {
fn from_parts(report: LiveFrameReport, rgba_bytes: Vec<u8>) -> Self {
let (css_viewport_width, css_viewport_height) = css_viewport_size_from_report(&report);
Self {
loaded_url: report.loaded_url,
title: report.title,
render_state: report.state,
width: report.width,
height: report.height,
device_pixel_ratio: report.device_pixel_ratio,
css_viewport_width,
css_viewport_height,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: report.non_white_pixel_count,
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -355,6 +370,21 @@ impl ServoLiveFrame {
self.height
}
#[must_use]
pub fn device_pixel_ratio(&self) -> f32 {
self.device_pixel_ratio
}
#[must_use]
pub fn css_viewport_width(&self) -> u32 {
self.css_viewport_width
}
#[must_use]
pub fn css_viewport_height(&self) -> u32 {
self.css_viewport_height
}
#[cfg(all(test, feature = "live-site-smoke"))]
#[must_use]
pub fn non_white_pixel_count(&self) -> u64 {
@@ -386,6 +416,9 @@ impl ServoLiveFrame {
render_state: "complete".to_string(),
width,
height,
device_pixel_ratio: 1.0,
css_viewport_width: width,
css_viewport_height: height,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: 0,
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -410,6 +443,9 @@ impl ServoLiveFrame {
render_state: "complete".to_string(),
width,
height,
device_pixel_ratio: 1.0,
css_viewport_width: width,
css_viewport_height: height,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: 0,
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -422,6 +458,20 @@ impl ServoLiveFrame {
}
}
fn css_viewport_size_from_report(report: &LiveFrameReport) -> (u32, u32) {
let dpr = if report.device_pixel_ratio.is_finite() && report.device_pixel_ratio > 0.0 {
report.device_pixel_ratio
} else {
1.0
};
let fallback_width = ((report.width as f32) / dpr).round().max(1.0) as u32;
let fallback_height = ((report.height as f32) / dpr).round().max(1.0) as u32;
(
if report.css_viewport_width > 0 { report.css_viewport_width } else { fallback_width },
if report.css_viewport_height > 0 { report.css_viewport_height } else { fallback_height },
)
}
#[derive(Debug, Error)]
pub(crate) enum ServoLiveError {
#[error("servo sidecar binary is unavailable at {path}")]
@@ -469,3 +519,19 @@ pub(crate) enum ServoLiveError {
#[error(transparent)]
SidecarCommand(#[from] SidecarCommandError),
}
impl ServoLiveError {
pub(crate) fn is_sidecar_process_unusable(&self) -> bool {
match self {
Self::SidecarExited => true,
Self::Command(error) | Self::FrameRead(error) => matches!(
error.kind(),
io::ErrorKind::BrokenPipe
| io::ErrorKind::ConnectionAborted
| io::ErrorKind::ConnectionReset
| io::ErrorKind::UnexpectedEof
),
_ => false,
}
}
}
@@ -96,6 +96,12 @@ pub(super) struct LiveFrameReport {
pub(super) state: String,
pub(super) width: u32,
pub(super) height: u32,
#[serde(default = "default_device_pixel_ratio")]
pub(super) device_pixel_ratio: f32,
#[serde(default)]
pub(super) css_viewport_width: u32,
#[serde(default)]
pub(super) css_viewport_height: u32,
pub(super) rgba_byte_count: usize,
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) non_white_pixel_count: u64,
@@ -105,6 +111,10 @@ pub(super) struct LiveFrameReport {
pub(super) sample_hash: u64,
}
fn default_device_pixel_ratio() -> f32 {
1.0
}
/// Per-frame tag that tells the renderer which already-imported
/// `MTLTexture` to sample. Emitted at `trace` instead of `info` because
/// it fires every frame on the hardware path; the import event above
@@ -89,14 +89,19 @@ pub(super) fn default_sidecar_command() -> Result<SidecarCommandTarget, SidecarC
SidecarCommandError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() }
})?;
let adjacent_sidecar = exe_dir.join(sidecar_binary_name());
if adjacent_sidecar.is_file() && is_macos_app_bundle_exe_dir(exe_dir) {
return Ok(SidecarCommandTarget::Binary(adjacent_sidecar));
}
let workspace_manifest = workspace_manifest_path();
let workspace_target_sidecar =
workspace_manifest.as_ref().and_then(|path| workspace_target_sidecar_path(path));
let adjacent_is_workspace_target =
workspace_target_sidecar.as_ref().is_some_and(|path| path == &adjacent_sidecar);
let workspace_target_sidecar_exists =
workspace_target_sidecar.as_ref().is_some_and(|path| path.is_file());
let prefer_cargo_hardware_sidecar = rendering_context_from_env()
== SidecarRenderingContext::Hardware
&& adjacent_is_workspace_target
&& (adjacent_is_workspace_target || workspace_target_sidecar_exists)
&& workspace_manifest.as_ref().is_some_and(|path| path.is_file());
if adjacent_sidecar.is_file() && !prefer_cargo_hardware_sidecar {
return Ok(SidecarCommandTarget::Binary(adjacent_sidecar));
@@ -155,11 +160,22 @@ fn sidecar_binary_name() -> String {
format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX)
}
fn is_macos_app_bundle_exe_dir(path: &Path) -> bool {
path.file_name().is_some_and(|name| name == "MacOS")
&& path
.parent()
.is_some_and(|contents| contents.file_name().is_some_and(|name| name == "Contents"))
&& path
.parent()
.and_then(Path::parent)
.is_some_and(|bundle| bundle.extension().is_some_and(|extension| extension == "app"))
}
#[cfg(test)]
mod tests {
use super::{
HARDWARE_SIDECAR_FEATURES, SOFTWARE_SIDECAR_FEATURES, SidecarRenderingContext,
rendering_context_selection,
is_macos_app_bundle_exe_dir, rendering_context_selection,
};
#[test]
@@ -180,6 +196,14 @@ mod tests {
assert_eq!(context.sidecar_features(), SOFTWARE_SIDECAR_FEATURES);
}
#[test]
fn recognizes_macos_app_bundle_executable_directory() {
assert!(is_macos_app_bundle_exe_dir(std::path::Path::new(
"/tmp/ELY Browser.app/Contents/MacOS"
)));
assert!(!is_macos_app_bundle_exe_dir(std::path::Path::new("/tmp/target/debug")));
}
#[cfg(target_os = "macos")]
#[test]
fn defaults_to_hardware_rendering_context_on_macos() {