From 54fcce29f390da5590c998470ddd3e1475d1311e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 16 May 2026 03:17:45 -0400 Subject: [PATCH] perf(app): gate payloadless surfaces on app imports --- .../ely_app/src/services/iosurface_metal.rs | 71 ++- crates/ely_app/src/services/servo_live.rs | 461 ++++-------------- .../services/servo_live_iosurface_importer.rs | 147 ++++++ .../ely_app/src/services/servo_live_types.rs | 308 ++++++++++++ .../ely_app/src/services/servo_live_wire.rs | 2 + .../src/bin/ely_servo_sidecar/live.rs | 176 +++++-- .../src/bin/ely_servo_sidecar/live_output.rs | 9 +- .../bin/ely_servo_sidecar/live_protocol.rs | 4 + 8 files changed, 748 insertions(+), 430 deletions(-) create mode 100644 crates/ely_app/src/services/servo_live_iosurface_importer.rs create mode 100644 crates/ely_app/src/services/servo_live_types.rs diff --git a/crates/ely_app/src/services/iosurface_metal.rs b/crates/ely_app/src/services/iosurface_metal.rs index 0907afa..38001fc 100644 --- a/crates/ely_app/src/services/iosurface_metal.rs +++ b/crates/ely_app/src/services/iosurface_metal.rs @@ -73,33 +73,18 @@ impl IOSurfaceCache { /// dimensions: duplicate handles for the same sized IOSurface are /// discarded, while a resized IOSurface that reuses the same /// `surface_id` replaces the cached pixel buffer. + #[cfg(test)] pub fn import( &mut self, mach_port_name: u32, surface_id: u64, ) -> Result<(), SurfaceImportError> { - let Some(iosurface) = objc2_io_surface::IOSurfaceRef::lookup_from_mach_port(mach_port_name) - else { - return Err(SurfaceImportError::LookupFailed { port: mach_port_name }); - }; + let pixel_buffer = import_pixel_buffer_from_mach_port(mach_port_name)?; + self.insert_pixel_buffer(surface_id, pixel_buffer); + Ok(()) + } - // Both objc2-io-surface and the legacy `io_surface` crate wrap - // the same C `__IOSurface` pointer. CVPixelBufferCreateWithIOSurface - // (via core-video) expects the legacy crate's wrapper. Reach for - // the raw pointer and let TCFType CFRetain it independently so - // both Rust handles can drop without double-freeing. - let raw_ptr: *const std::ffi::c_void = - (&*iosurface) as *const objc2_io_surface::IOSurfaceRef as *const std::ffi::c_void; - #[allow(deprecated)] - let io_surface_view: IOSurface = { - #[expect(unsafe_code)] - unsafe { - IOSurface::wrap_under_get_rule(raw_ptr as io_surface::IOSurfaceRef) - } - }; - - let pixel_buffer = CVPixelBuffer::from_io_surface(&io_surface_view, None) - .map_err(|status| SurfaceImportError::PixelBufferBuildFailed { status })?; + pub(crate) fn insert_pixel_buffer(&mut self, surface_id: u64, pixel_buffer: CVPixelBuffer) { let width = pixel_buffer.get_width() as u32; let height = pixel_buffer.get_height() as u32; @@ -108,13 +93,10 @@ impl IOSurfaceCache { .get(&surface_id) .is_some_and(|cached| cached.width == width && cached.height == height) { - deallocate_mach_port(mach_port_name); - return Ok(()); + return; } self.pixel_buffers.insert(surface_id, CachedPixelBuffer { pixel_buffer, width, height }); - deallocate_mach_port(mach_port_name); - Ok(()) } /// Look up an already-imported pixel buffer by `surface_id`. The @@ -127,12 +109,51 @@ impl IOSurfaceCache { self.pixel_buffers.get(&surface_id).map(|cached| cached.pixel_buffer.clone()) } + pub(crate) fn surface_ids(&self) -> Vec { + self.pixel_buffers.keys().copied().collect() + } + #[cfg(test)] pub fn cached_surface_count(&self) -> usize { self.pixel_buffers.len() } } +pub(crate) fn import_pixel_buffer_from_mach_port( + mach_port_name: u32, +) -> Result { + let result = build_pixel_buffer_from_mach_port(mach_port_name); + deallocate_mach_port(mach_port_name); + result +} + +fn build_pixel_buffer_from_mach_port( + mach_port_name: u32, +) -> Result { + let Some(iosurface) = objc2_io_surface::IOSurfaceRef::lookup_from_mach_port(mach_port_name) + else { + return Err(SurfaceImportError::LookupFailed { port: mach_port_name }); + }; + + // Both objc2-io-surface and the legacy `io_surface` crate wrap + // the same C `__IOSurface` pointer. CVPixelBufferCreateWithIOSurface + // (via core-video) expects the legacy crate's wrapper. Reach for + // the raw pointer and let TCFType CFRetain it independently so + // both Rust handles can drop without double-freeing. + let raw_ptr: *const std::ffi::c_void = + (&*iosurface) as *const objc2_io_surface::IOSurfaceRef as *const std::ffi::c_void; + #[allow(deprecated)] + let io_surface_view: IOSurface = { + #[expect(unsafe_code)] + unsafe { + IOSurface::wrap_under_get_rule(raw_ptr as io_surface::IOSurfaceRef) + } + }; + + CVPixelBuffer::from_io_surface(&io_surface_view, None) + .map_err(|status| SurfaceImportError::PixelBufferBuildFailed { status }) +} + /// Release one send right against the mach port we received. The /// IOSurface itself stays alive because the `CVPixelBuffer` (or the /// sidecar's surfman) still retain it. diff --git a/crates/ely_app/src/services/servo_live.rs b/crates/ely_app/src/services/servo_live.rs index ef17ea9..0efcd25 100644 --- a/crates/ely_app/src/services/servo_live.rs +++ b/crates/ely_app/src/services/servo_live.rs @@ -1,36 +1,37 @@ use std::{ - io::{self, BufRead, BufReader, Read, Write}, + io::{BufRead, BufReader, Read, Write}, path::PathBuf, process::{Child, ChildStdin, ChildStdout, Stdio}, - time::Duration, }; +#[cfg(target_os = "macos")] +#[path = "servo_live_iosurface_importer.rs"] +mod iosurface_importer; +#[path = "servo_live_types.rs"] +mod types; /// Environment variable that lets the user pick the rendering context /// kind used by the spawned sidecar. Accepted values: `software` /// and `hardware`. macOS defaults to the hardware path and receives /// IOSurface mach send rights over a side Mach channel. -use ely_domain::SitePermissionDecision; -use serde::Serialize; -use thiserror::Error; - #[path = "servo_live_wire.rs"] mod wire; +pub(crate) use types::{ + ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveSitePermission, +}; + use super::servo_sidecar_command::{ - SidecarCommandError, SidecarRenderingContext, default_sidecar_command, - rendering_context_from_env, + SidecarRenderingContext, default_sidecar_command, rendering_context_from_env, }; use wire::{ - LiveFrameReport, LiveRequest, LiveResponse, LiveSurfaceHandle, log_frame_perf, - log_iosurface_current, log_iosurface_handle, + LiveRequest, LiveResponse, LiveSurfaceHandle, log_frame_perf, log_iosurface_current, + log_iosurface_handle, }; -#[cfg(target_os = "macos")] -use super::iosurface_mach::{IOSurfaceMachError, IOSurfaceMachReceiver}; #[cfg(target_os = "macos")] use super::iosurface_metal::IOSurfaceCache; #[cfg(target_os = "macos")] -use core_video::pixel_buffer::CVPixelBuffer; +use iosurface_importer::{IOSurfaceImportResult, IOSurfaceImportWorker}; pub(crate) struct ServoLiveClient { child: Child, @@ -42,7 +43,7 @@ pub(crate) struct ServoLiveClient { #[cfg(target_os = "macos")] iosurface_cache: IOSurfaceCache, #[cfg(target_os = "macos")] - iosurface_receiver: Option, + iosurface_importer: Option, } impl ServoLiveClient { @@ -57,10 +58,13 @@ impl ServoLiveClient { command.arg("live").arg("--profile-data-dir").arg(profile_data_dir); command.arg("--rendering-context").arg(rendering_context.cli_arg()); #[cfg(target_os = "macos")] - let iosurface_receiver = if rendering_context == SidecarRenderingContext::Hardware { - let receiver = IOSurfaceMachReceiver::new()?; + let iosurface_importer = if rendering_context == SidecarRenderingContext::Hardware { + let receiver = super::iosurface_mach::IOSurfaceMachReceiver::new()?; command.arg("--iosurface-mach-service").arg(receiver.service_name()); - Some(receiver) + Some( + IOSurfaceImportWorker::new(receiver) + .map_err(ServoLiveError::IOSurfaceImportWorker)?, + ) } else { None }; @@ -81,7 +85,7 @@ impl ServoLiveClient { #[cfg(target_os = "macos")] iosurface_cache: IOSurfaceCache::new(), #[cfg(target_os = "macos")] - iosurface_receiver, + iosurface_importer, }) } @@ -89,6 +93,9 @@ impl ServoLiveClient { &mut self, request: ServoLiveEnsureRequest, ) -> Result, ServoLiveError> { + #[cfg(target_os = "macos")] + self.drain_iosurface_imports()?; + let ready_surface_ids = self.ready_surface_ids(); self.request(LiveRequest::Ensure { tab_id: request.tab_id, profile_id: request.profile_id, @@ -107,11 +114,15 @@ impl ServoLiveClient { hover_y: request.hover_y, typed_text: request.typed_text, site_permissions: request.site_permissions, + ready_surface_ids, }) } pub fn poll(&mut self, tab_id: String) -> Result, ServoLiveError> { - self.request(LiveRequest::Poll { tab_id }) + #[cfg(target_os = "macos")] + self.drain_iosurface_imports()?; + let ready_surface_ids = self.ready_surface_ids(); + self.request(LiveRequest::Poll { tab_id, ready_surface_ids }) } pub fn close(&mut self, tab_id: String) -> Result<(), ServoLiveError> { @@ -183,363 +194,93 @@ impl ServoLiveClient { #[cfg(target_os = "macos")] if let Some(handle) = surface_handle.as_ref() { - // Drain the stdout payload before IOSurface import so the - // sidecar cannot block writing RGBA bytes while this worker - // is inside IOSurfaceLookupFromMachPort. - self.import_iosurface_handle(handle)?; + self.queue_iosurface_handle(*handle)?; + self.drain_iosurface_imports()?; } #[cfg(target_os = "macos")] if let Some(surface_id) = current_surface_id { let pixel_buffer = self.iosurface_cache.pixel_buffer_for(surface_id); if pixel_buffer.is_none() && !has_software_payload { - return Err(ServoLiveError::IOSurfacePixelBufferMissing { surface_id }); + return Ok(None); } - frame.pixel_buffer = pixel_buffer; + frame.set_pixel_buffer(pixel_buffer); } Ok(Some(frame)) } } +#[cfg(not(target_os = "macos"))] +impl ServoLiveClient { + fn ready_surface_ids(&self) -> Vec { + Vec::new() + } +} + +#[cfg(target_os = "macos")] +impl ServoLiveClient { + fn ready_surface_ids(&self) -> Vec { + self.iosurface_cache.surface_ids() + } + + fn queue_iosurface_handle(&mut self, handle: LiveSurfaceHandle) -> Result<(), ServoLiveError> { + let Some(importer) = self.iosurface_importer.as_ref() else { + return Err(ServoLiveError::IOSurfaceImportFailed { + surface_id: handle.surface_id, + mach_port_name: handle.mach_port_name, + message: "IOSurface import worker is unavailable".to_string(), + }); + }; + importer.submit(handle).map_err(|failure| ServoLiveError::IOSurfaceImportFailed { + surface_id: failure.surface_id, + mach_port_name: failure.mach_port_name, + message: failure.message, + }) + } + + fn drain_iosurface_imports(&mut self) -> Result<(), ServoLiveError> { + let Some(importer) = self.iosurface_importer.as_ref() else { + return Ok(()); + }; + for result in importer.drain() { + match result { + IOSurfaceImportResult::Imported(imported) => { + self.iosurface_cache + .insert_pixel_buffer(imported.surface_id, imported.pixel_buffer); + tracing::info!( + target: "ely::servo::iosurface", + surface_id = imported.surface_id, + width = imported.width, + height = imported.height, + "imported IOSurface into CVPixelBuffer cache", + ); + } + IOSurfaceImportResult::Failed(failure) => { + tracing::warn!( + target: "ely::servo::iosurface", + surface_id = failure.surface_id, + width = failure.width, + height = failure.height, + mach_port_name = failure.mach_port_name, + message = %failure.message, + "IOSurface import worker failed", + ); + return Err(ServoLiveError::IOSurfaceImportFailed { + surface_id: failure.surface_id, + mach_port_name: failure.mach_port_name, + message: failure.message, + }); + } + } + } + Ok(()) + } +} + impl Drop for ServoLiveClient { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } - -#[cfg(target_os = "macos")] -impl ServoLiveClient { - /// Convert the sidecar's `surface_handle` into a `CVPixelBuffer` - /// in the local cache. A later frame with a missing pixel buffer - /// becomes a web-surface error instead of a blank ready frame. - fn import_iosurface_handle( - &mut self, - handle: &LiveSurfaceHandle, - ) -> Result<(), ServoLiveError> { - let mach_port_name = match self.iosurface_receiver.as_mut() { - Some(receiver) => { - receiver.receive_port_for_surface(handle.surface_id, Duration::from_secs(1))? - } - None => handle.mach_port_name, - }; - match self.iosurface_cache.import(mach_port_name, handle.surface_id) { - Ok(()) => tracing::info!( - target: "ely::servo::iosurface", - surface_id = handle.surface_id, - width = handle.width, - height = handle.height, - "imported IOSurface into CVPixelBuffer cache", - ), - Err(error) => { - tracing::warn!( - target: "ely::servo::iosurface", - error = %error, - surface_id = handle.surface_id, - "IOSurface→CVPixelBuffer import failed", - ); - return Err(ServoLiveError::IOSurfaceImportFailed { - surface_id: handle.surface_id, - mach_port_name, - message: error.to_string(), - }); - } - } - Ok(()) - } -} - -pub(crate) struct ServoLiveEnsureRequest { - pub(crate) tab_id: String, - pub(crate) profile_id: String, - pub(crate) url: String, - pub(crate) width: u32, - pub(crate) height: u32, - pub(crate) page_zoom_percent: u16, - /// Display scale factor (1.0 standard, 2.0 Retina). Servo's - /// WebView lays out CSS pixels = device pixels / hidpi factor; - /// without this, a Retina viewport gets desktop-CSS-pixel layout - /// and every visible element renders at half its expected size. - pub(crate) device_pixel_ratio: f32, - pub(crate) scroll_delta_x: i32, - pub(crate) scroll_delta_y: i32, - pub(crate) scroll_point_x: Option, - pub(crate) scroll_point_y: Option, - pub(crate) click_x: Option, - pub(crate) click_y: Option, - pub(crate) hover_x: Option, - pub(crate) hover_y: Option, - pub(crate) typed_text: Option, - pub(crate) site_permissions: Vec, -} - -#[derive(Clone, Debug, Serialize)] -pub(crate) struct ServoLiveSitePermission { - pub(crate) origin: String, - pub(crate) feature: String, - pub(crate) decision: String, -} - -impl ServoLiveSitePermission { - pub fn new( - origin: impl Into, - feature: impl Into, - decision: SitePermissionDecision, - ) -> Self { - Self { origin: origin.into(), feature: feature.into(), decision: decision.as_str().into() } - } -} - -pub(crate) struct ServoLiveFrame { - loaded_url: Option, - title: Option, - 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"))] - content_pixel_count: u64, - #[cfg(all(test, feature = "live-site-smoke"))] - sample_hash: u64, - rgba_bytes: Vec, - /// Hardware-path surface: the imported IOSurface published by the - /// sidecar, wrapped as a CVPixelBuffer for GPUI's `surface(...)`. - #[cfg(target_os = "macos")] - pixel_buffer: Option, -} - -// 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) -> 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"))] - content_pixel_count: report.content_pixel_count, - #[cfg(all(test, feature = "live-site-smoke"))] - sample_hash: report.sample_hash, - rgba_bytes, - #[cfg(target_os = "macos")] - pixel_buffer: None, - } - } - - /// Returns the imported `CVPixelBuffer` matching the frame's - /// current hardware surface. - #[cfg(target_os = "macos")] - #[must_use] - pub fn pixel_buffer(&self) -> Option<&CVPixelBuffer> { - self.pixel_buffer.as_ref() - } - - #[must_use] - pub fn loaded_url(&self) -> Option<&str> { - self.loaded_url.as_deref() - } - - #[must_use] - pub fn title(&self) -> Option<&str> { - self.title.as_deref() - } - - #[must_use] - pub fn render_state(&self) -> &str { - self.render_state.as_str() - } - - #[must_use] - pub fn width(&self) -> u32 { - self.width - } - - #[must_use] - pub fn height(&self) -> u32 { - 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 { - self.non_white_pixel_count - } - - #[cfg(all(test, feature = "live-site-smoke"))] - #[must_use] - pub fn content_pixel_count(&self) -> u64 { - self.content_pixel_count - } - - #[cfg(all(test, feature = "live-site-smoke"))] - #[must_use] - pub fn sample_hash(&self) -> u64 { - self.sample_hash - } - - #[must_use] - pub fn into_rgba_bytes(self) -> Vec { - self.rgba_bytes - } - - #[cfg(test)] - pub(crate) fn for_test(width: u32, height: u32, rgba_bytes: Vec) -> Self { - Self { - loaded_url: Some("https://example.com/".to_string()), - title: Some("Example".to_string()), - 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"))] - content_pixel_count: 0, - #[cfg(all(test, feature = "live-site-smoke"))] - sample_hash: 0, - rgba_bytes, - #[cfg(target_os = "macos")] - pixel_buffer: None, - } - } - - #[cfg(all(test, target_os = "macos"))] - pub(crate) fn for_test_with_pixel_buffer( - width: u32, - height: u32, - pixel_buffer: CVPixelBuffer, - ) -> Self { - Self { - loaded_url: Some("https://example.com/".to_string()), - title: Some("Example".to_string()), - 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"))] - content_pixel_count: 0, - #[cfg(all(test, feature = "live-site-smoke"))] - sample_hash: 0, - rgba_bytes: Vec::new(), - pixel_buffer: Some(pixel_buffer), - } - } -} - -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}")] - SidecarBinaryUnavailable { path: PathBuf }, - - #[error("failed to run servo live sidecar: {0}")] - Command(#[source] io::Error), - - #[error("servo live sidecar pipe is unavailable: {name}")] - PipeUnavailable { name: &'static str }, - - #[error("servo live sidecar exited")] - SidecarExited, - - #[error("servo live sidecar failed: {message}")] - SidecarFailed { message: String }, - - #[error("failed to read servo live frame bytes: {0}")] - FrameRead(#[source] io::Error), - - #[error( - "servo live sidecar advertised {advertised} frame bytes which exceeds \ - the {width}x{height} pixel budget ({pixel_budget} bytes)" - )] - FrameBudgetExceeded { advertised: usize, pixel_budget: u64, width: u32, height: u32 }, - - #[cfg(target_os = "macos")] - #[error( - "servo live IOSurface import failed for surface {surface_id:#x} \ - mach port 0x{mach_port_name:x}: {message}" - )] - IOSurfaceImportFailed { surface_id: u64, mach_port_name: u32, message: String }, - - #[cfg(target_os = "macos")] - #[error("servo live IOSurface {surface_id:#x} was selected before its pixel buffer import")] - IOSurfacePixelBufferMissing { surface_id: u64 }, - - #[cfg(target_os = "macos")] - #[error(transparent)] - IOSurfaceMach(#[from] IOSurfaceMachError), - - #[error(transparent)] - Json(#[from] serde_json::Error), - - #[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, - } - } -} diff --git a/crates/ely_app/src/services/servo_live_iosurface_importer.rs b/crates/ely_app/src/services/servo_live_iosurface_importer.rs new file mode 100644 index 0000000..4a208e6 --- /dev/null +++ b/crates/ely_app/src/services/servo_live_iosurface_importer.rs @@ -0,0 +1,147 @@ +#![cfg(target_os = "macos")] + +use std::{ + io, + sync::mpsc, + thread::{self, JoinHandle}, + time::Duration, +}; + +use core_video::pixel_buffer::CVPixelBuffer; + +use crate::services::{ + iosurface_mach::IOSurfaceMachReceiver, iosurface_metal::import_pixel_buffer_from_mach_port, +}; + +use super::wire::LiveSurfaceHandle; + +const RECEIVE_TIMEOUT: Duration = Duration::from_secs(1); + +pub(super) struct IOSurfaceImportWorker { + request_tx: Option>, + result_rx: mpsc::Receiver, + thread: Option>, +} + +impl IOSurfaceImportWorker { + pub(super) fn new(receiver: IOSurfaceMachReceiver) -> Result { + let (request_tx, request_rx) = mpsc::channel(); + let (result_tx, result_rx) = mpsc::channel(); + let thread = thread::Builder::new() + .name("ely-iosurface-import".to_string()) + .spawn(move || run_import_worker(receiver, request_rx, result_tx))?; + + Ok(Self { request_tx: Some(request_tx), result_rx, thread: Some(thread) }) + } + + pub(super) fn submit(&self, handle: LiveSurfaceHandle) -> Result<(), IOSurfaceImportFailure> { + let Some(request_tx) = self.request_tx.as_ref() else { + return Err(IOSurfaceImportFailure::worker_stopped(handle)); + }; + request_tx.send(handle).map_err(|error| IOSurfaceImportFailure::worker_stopped(error.0)) + } + + pub(super) fn drain(&self) -> Vec { + let mut results = Vec::new(); + while let Ok(result) = self.result_rx.try_recv() { + results.push(result); + } + results + } +} + +impl Drop for IOSurfaceImportWorker { + fn drop(&mut self) { + self.request_tx.take(); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +pub(super) enum IOSurfaceImportResult { + Imported(ImportedIOSurface), + Failed(IOSurfaceImportFailure), +} + +pub(super) struct ImportedIOSurface { + pub(super) surface_id: u64, + pub(super) width: u32, + pub(super) height: u32, + pub(super) pixel_buffer: CVPixelBuffer, +} + +// SAFETY: CVPixelBuffer is a CoreFoundation object with atomic +// retain/release semantics. This wrapper crosses from the importer +// thread to the live worker thread; GPUI presentation already receives +// the same handle through ServoLiveFrame's Send contract. +#[expect(unsafe_code)] +unsafe impl Send for ImportedIOSurface {} + +pub(super) struct IOSurfaceImportFailure { + pub(super) surface_id: u64, + pub(super) width: u32, + pub(super) height: u32, + pub(super) mach_port_name: u32, + pub(super) message: String, +} + +impl IOSurfaceImportFailure { + fn worker_stopped(handle: LiveSurfaceHandle) -> Self { + Self { + surface_id: handle.surface_id, + width: handle.width, + height: handle.height, + mach_port_name: handle.mach_port_name, + message: "IOSurface import worker stopped".to_string(), + } + } +} + +fn run_import_worker( + mut receiver: IOSurfaceMachReceiver, + request_rx: mpsc::Receiver, + result_tx: mpsc::Sender, +) { + while let Ok(handle) = request_rx.recv() { + let result = import_surface_handle(&mut receiver, handle); + if result_tx.send(result).is_err() { + return; + } + } +} + +fn import_surface_handle( + receiver: &mut IOSurfaceMachReceiver, + handle: LiveSurfaceHandle, +) -> IOSurfaceImportResult { + let mach_port_name = match receiver.receive_port_for_surface(handle.surface_id, RECEIVE_TIMEOUT) + { + Ok(mach_port_name) => mach_port_name, + Err(error) => { + return IOSurfaceImportResult::Failed(IOSurfaceImportFailure { + surface_id: handle.surface_id, + width: handle.width, + height: handle.height, + mach_port_name: handle.mach_port_name, + message: error.to_string(), + }); + } + }; + + match import_pixel_buffer_from_mach_port(mach_port_name) { + Ok(pixel_buffer) => IOSurfaceImportResult::Imported(ImportedIOSurface { + surface_id: handle.surface_id, + width: handle.width, + height: handle.height, + pixel_buffer, + }), + Err(error) => IOSurfaceImportResult::Failed(IOSurfaceImportFailure { + surface_id: handle.surface_id, + width: handle.width, + height: handle.height, + mach_port_name, + message: error.to_string(), + }), + } +} diff --git a/crates/ely_app/src/services/servo_live_types.rs b/crates/ely_app/src/services/servo_live_types.rs new file mode 100644 index 0000000..db9f0e4 --- /dev/null +++ b/crates/ely_app/src/services/servo_live_types.rs @@ -0,0 +1,308 @@ +use std::{io, path::PathBuf}; + +use ely_domain::SitePermissionDecision; +use serde::Serialize; +use thiserror::Error; + +use super::wire::LiveFrameReport; +use crate::services::servo_sidecar_command::SidecarCommandError; + +#[cfg(target_os = "macos")] +use crate::services::iosurface_mach::IOSurfaceMachError; +#[cfg(target_os = "macos")] +use core_video::pixel_buffer::CVPixelBuffer; + +pub(crate) struct ServoLiveEnsureRequest { + pub(crate) tab_id: String, + pub(crate) profile_id: String, + pub(crate) url: String, + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) page_zoom_percent: u16, + /// Display scale factor (1.0 standard, 2.0 Retina). Servo's + /// WebView lays out CSS pixels = device pixels / hidpi factor; + /// without this, a Retina viewport gets desktop-CSS-pixel layout + /// and every visible element renders at half its expected size. + pub(crate) device_pixel_ratio: f32, + pub(crate) scroll_delta_x: i32, + pub(crate) scroll_delta_y: i32, + pub(crate) scroll_point_x: Option, + pub(crate) scroll_point_y: Option, + pub(crate) click_x: Option, + pub(crate) click_y: Option, + pub(crate) hover_x: Option, + pub(crate) hover_y: Option, + pub(crate) typed_text: Option, + pub(crate) site_permissions: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct ServoLiveSitePermission { + pub(crate) origin: String, + pub(crate) feature: String, + pub(crate) decision: String, +} + +impl ServoLiveSitePermission { + pub fn new( + origin: impl Into, + feature: impl Into, + decision: SitePermissionDecision, + ) -> Self { + Self { origin: origin.into(), feature: feature.into(), decision: decision.as_str().into() } + } +} + +pub(crate) struct ServoLiveFrame { + loaded_url: Option, + title: Option, + 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"))] + content_pixel_count: u64, + #[cfg(all(test, feature = "live-site-smoke"))] + sample_hash: u64, + rgba_bytes: Vec, + #[cfg(target_os = "macos")] + pixel_buffer: Option, +} + +// 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 { + pub(super) fn from_parts(report: LiveFrameReport, rgba_bytes: Vec) -> 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"))] + content_pixel_count: report.content_pixel_count, + #[cfg(all(test, feature = "live-site-smoke"))] + sample_hash: report.sample_hash, + rgba_bytes, + #[cfg(target_os = "macos")] + pixel_buffer: None, + } + } + + #[cfg(target_os = "macos")] + pub(super) fn set_pixel_buffer(&mut self, pixel_buffer: Option) { + self.pixel_buffer = pixel_buffer; + } + + #[cfg(target_os = "macos")] + #[must_use] + pub fn pixel_buffer(&self) -> Option<&CVPixelBuffer> { + self.pixel_buffer.as_ref() + } + + #[must_use] + pub fn loaded_url(&self) -> Option<&str> { + self.loaded_url.as_deref() + } + + #[must_use] + pub fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + #[must_use] + pub fn render_state(&self) -> &str { + self.render_state.as_str() + } + + #[must_use] + pub fn width(&self) -> u32 { + self.width + } + + #[must_use] + pub fn height(&self) -> u32 { + 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 { + self.non_white_pixel_count + } + + #[cfg(all(test, feature = "live-site-smoke"))] + #[must_use] + pub fn content_pixel_count(&self) -> u64 { + self.content_pixel_count + } + + #[cfg(all(test, feature = "live-site-smoke"))] + #[must_use] + pub fn sample_hash(&self) -> u64 { + self.sample_hash + } + + #[must_use] + pub fn into_rgba_bytes(self) -> Vec { + self.rgba_bytes + } + + #[cfg(test)] + pub(crate) fn for_test(width: u32, height: u32, rgba_bytes: Vec) -> Self { + Self { + loaded_url: Some("https://example.com/".to_string()), + title: Some("Example".to_string()), + 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"))] + content_pixel_count: 0, + #[cfg(all(test, feature = "live-site-smoke"))] + sample_hash: 0, + rgba_bytes, + #[cfg(target_os = "macos")] + pixel_buffer: None, + } + } + + #[cfg(all(test, target_os = "macos"))] + pub(crate) fn for_test_with_pixel_buffer( + width: u32, + height: u32, + pixel_buffer: CVPixelBuffer, + ) -> Self { + Self { + loaded_url: Some("https://example.com/".to_string()), + title: Some("Example".to_string()), + 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"))] + content_pixel_count: 0, + #[cfg(all(test, feature = "live-site-smoke"))] + sample_hash: 0, + rgba_bytes: Vec::new(), + pixel_buffer: Some(pixel_buffer), + } + } +} + +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}")] + SidecarBinaryUnavailable { path: PathBuf }, + + #[error("failed to run servo live sidecar: {0}")] + Command(#[source] io::Error), + + #[error("servo live sidecar pipe is unavailable: {name}")] + PipeUnavailable { name: &'static str }, + + #[error("servo live sidecar exited")] + SidecarExited, + + #[error("servo live sidecar failed: {message}")] + SidecarFailed { message: String }, + + #[error("failed to read servo live frame bytes: {0}")] + FrameRead(#[source] io::Error), + + #[error( + "servo live sidecar advertised {advertised} frame bytes which exceeds \ + the {width}x{height} pixel budget ({pixel_budget} bytes)" + )] + FrameBudgetExceeded { advertised: usize, pixel_budget: u64, width: u32, height: u32 }, + + #[cfg(target_os = "macos")] + #[error( + "servo live IOSurface import failed for surface {surface_id:#x} \ + mach port 0x{mach_port_name:x}: {message}" + )] + IOSurfaceImportFailed { surface_id: u64, mach_port_name: u32, message: String }, + + #[cfg(target_os = "macos")] + #[error("failed to spawn servo live IOSurface importer: {0}")] + IOSurfaceImportWorker(#[source] io::Error), + + #[cfg(target_os = "macos")] + #[error(transparent)] + IOSurfaceMach(#[from] IOSurfaceMachError), + + #[error(transparent)] + Json(#[from] serde_json::Error), + + #[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, + } + } +} diff --git a/crates/ely_app/src/services/servo_live_wire.rs b/crates/ely_app/src/services/servo_live_wire.rs index 27d32f2..226b8a4 100644 --- a/crates/ely_app/src/services/servo_live_wire.rs +++ b/crates/ely_app/src/services/servo_live_wire.rs @@ -23,9 +23,11 @@ pub(super) enum LiveRequest { hover_y: Option, typed_text: Option, site_permissions: Vec, + ready_surface_ids: Vec, }, Poll { tab_id: String, + ready_surface_ids: Vec, }, Close { tab_id: String, diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs index 76f575f..6dd07a0 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs @@ -26,7 +26,8 @@ use super::perf::{FramePerfAggregator, FramePerfSummary, elapsed_ns}; pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> { let LiveArgs { profile_data_dir, iosurface_mach_service, rendering_context_kind } = args; - let publish_readback_surface_fields = iosurface_mach_service.is_none(); + let publish_readback_surface_fields = true; + let require_client_ready_surfaces = iosurface_mach_service.is_some(); fs::create_dir_all(&profile_data_dir)?; let context_label = rendering_context_label(rendering_context_kind); let mut host = SoftwareServoHost::new_with_config_dir_and_kind( @@ -66,6 +67,7 @@ pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> { &mut published_surface_ids, rendering_context_kind, publish_readback_surface_fields, + require_client_ready_surfaces, request, ), Err(error) => Err(LiveSidecarError::Json(error)), @@ -95,6 +97,7 @@ fn handle_request( published_surface_ids: &mut HashMap>, rendering_context_kind: RenderingContextKind, publish_readback_surface_fields: bool, + require_client_ready_surfaces: bool, request: LiveRequest, ) -> Result { match request { @@ -116,6 +119,7 @@ fn handle_request( hover_y, typed_text, site_permissions, + ready_surface_ids, } => { let tab = TabId::parse(tab_id.clone())?; let profile = ProfileId::parse(profile_id)?; @@ -162,8 +166,17 @@ fn handle_request( session.awaiting_visible_frame = true; } let webview_id = session.webview_id.clone(); - let mut outcome = - poll_frame(host, session, rendering_context_kind, &tab_id, published_surface_ids)?; + let mut outcome = poll_frame( + host, + session, + rendering_context_kind, + payloadless_readiness( + &tab_id, + published_surface_ids, + &ready_surface_ids, + require_client_ready_surfaces, + ), + )?; populate_surface_fields( host, &webview_id, @@ -174,13 +187,22 @@ fn handle_request( ); Ok(outcome) } - LiveRequest::Poll { tab_id } => { + LiveRequest::Poll { tab_id, ready_surface_ids } => { let Some(session) = sessions.get_mut(&tab_id) else { return Ok(LiveOutcome::empty()); }; let webview_id = session.webview_id.clone(); - let mut outcome = - poll_frame(host, session, rendering_context_kind, &tab_id, published_surface_ids)?; + let mut outcome = poll_frame( + host, + session, + rendering_context_kind, + payloadless_readiness( + &tab_id, + published_surface_ids, + &ready_surface_ids, + require_client_ready_surfaces, + ), + )?; populate_surface_fields( host, &webview_id, @@ -205,8 +227,7 @@ fn poll_frame( host: &mut SoftwareServoHost, session: &mut LiveSession, rendering_context_kind: RenderingContextKind, - tab_id: &str, - published_surface_ids: &HashMap>, + readiness: PayloadlessReadiness<'_>, ) -> Result { host.tick(); let snapshot = host.snapshot(&session.webview_id)?; @@ -215,14 +236,8 @@ fn poll_frame( return Ok(LiveOutcome::empty()); } - let (outcome, has_visible_content) = paint_pending_frame( - host, - session, - rendering_context_kind, - tab_id, - published_surface_ids, - has_pending_frame, - )?; + let (outcome, has_visible_content) = + paint_pending_frame(host, session, rendering_context_kind, readiness, has_pending_frame)?; if has_visible_content { session.awaiting_visible_frame = false; session.ever_visible_frame = true; @@ -243,23 +258,18 @@ fn paint_pending_frame( host: &mut SoftwareServoHost, session: &mut LiveSession, rendering_context_kind: RenderingContextKind, - tab_id: &str, - published_surface_ids: &HashMap>, + readiness: PayloadlessReadiness<'_>, has_pending_frame: bool, ) -> Result<(LiveOutcome, bool), LiveSidecarError> { #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] - let _ = (tab_id, published_surface_ids); + let _ = readiness; match rendering_context_kind { RenderingContextKind::Software => paint_readback_frame(host, session, !has_pending_frame), #[cfg(all(feature = "hardware-render", target_os = "macos"))] - RenderingContextKind::Hardware => paint_hardware_surface_frame( - host, - session, - tab_id, - published_surface_ids, - has_pending_frame, - ), + RenderingContextKind::Hardware => { + paint_hardware_surface_frame(host, session, readiness, has_pending_frame) + } #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] RenderingContextKind::Hardware => paint_readback_frame(host, session, !has_pending_frame), } @@ -288,19 +298,17 @@ fn paint_readback_frame( fn paint_hardware_surface_frame( host: &mut SoftwareServoHost, session: &LiveSession, - tab_id: &str, - published_surface_ids: &HashMap>, + readiness: PayloadlessReadiness<'_>, has_pending_frame: bool, ) -> Result<(LiveOutcome, bool), LiveSidecarError> { if !session.ever_visible_frame { return paint_initial_hardware_surface_frame(host, session, !has_pending_frame); } - if !payloadless_surface_pool_ready(published_surface_ids, tab_id, session.width, session.height) - { + if !payloadless_surface_pool_ready(readiness, session.width, session.height) { return paint_readback_frame(host, session, !has_pending_frame); } let (outcome, identity) = paint_hardware_surface_report(host, session, !has_pending_frame)?; - if !surface_has_been_published(published_surface_ids, tab_id, identity) { + if !surface_has_been_published(readiness.published_surface_ids, readiness.tab_id, identity) { return paint_readback_frame(host, session, true); } Ok((outcome, true)) @@ -352,19 +360,62 @@ fn paint_hardware_surface_report( #[cfg(all(feature = "hardware-render", target_os = "macos"))] fn payloadless_surface_pool_ready( - published_surface_ids: &HashMap>, - tab_id: &str, + readiness: PayloadlessReadiness<'_>, width: u32, height: u32, ) -> bool { - published_surface_ids.get(tab_id).is_some_and(|published| { - published + let Some(published) = readiness.published_surface_ids.get(readiness.tab_id) else { + return false; + }; + let matching = published + .iter() + .filter(|identity| identity.width == width && identity.height == height) + .take(2) + .copied() + .collect::>(); + if matching.len() < 2 { + return false; + } + !readiness.require_client_ready_surfaces + || matching .iter() - .filter(|identity| identity.width == width && identity.height == height) - .take(2) - .count() - >= 2 - }) + .all(|identity| readiness.ready_surface_ids.contains(&identity.surface_id)) +} + +#[derive(Clone, Copy)] +struct PayloadlessReadiness<'a> { + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + tab_id: &'a str, + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + published_surface_ids: &'a HashMap>, + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + ready_surface_ids: &'a [u64], + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + require_client_ready_surfaces: bool, + #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] + _marker: std::marker::PhantomData<&'a ()>, +} + +fn payloadless_readiness<'a>( + tab_id: &'a str, + published_surface_ids: &'a HashMap>, + ready_surface_ids: &'a [u64], + require_client_ready_surfaces: bool, +) -> PayloadlessReadiness<'a> { + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + { + PayloadlessReadiness { + tab_id, + published_surface_ids, + ready_surface_ids, + require_client_ready_surfaces, + } + } + #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] + { + let _ = (tab_id, published_surface_ids, ready_surface_ids, require_client_ready_surfaces); + PayloadlessReadiness { _marker: std::marker::PhantomData } + } } #[cfg(all(feature = "hardware-render", target_os = "macos"))] @@ -390,4 +441,49 @@ mod tests { assert!(!should_paint_live_frame(false, false)); assert!(should_paint_live_frame(true, false)); } + + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + #[test] + fn payloadless_pool_waits_for_client_ready_surfaces_when_required() { + let published = published_identities([identity(7, 800, 600), identity(8, 800, 600)]); + + assert!(!payloadless_surface_pool_ready(readiness(&published, &[7], true), 800, 600)); + assert!(payloadless_surface_pool_ready(readiness(&published, &[7, 8], true), 800, 600)); + } + + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + #[test] + fn payloadless_pool_uses_published_surfaces_for_no_mach_clients() { + let published = published_identities([identity(7, 800, 600), identity(8, 800, 600)]); + + assert!(payloadless_surface_pool_ready(readiness(&published, &[], false), 800, 600)); + } + + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + fn readiness<'a>( + published_surface_ids: &'a HashMap>, + ready_surface_ids: &'a [u64], + require_client_ready_surfaces: bool, + ) -> PayloadlessReadiness<'a> { + PayloadlessReadiness { + tab_id: "tab", + published_surface_ids, + ready_surface_ids, + require_client_ready_surfaces, + } + } + + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + fn published_identities( + identities: [IOSurfaceIdentity; 2], + ) -> HashMap> { + let mut published = HashMap::new(); + published.insert("tab".to_string(), identities.into_iter().collect()); + published + } + + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + fn identity(surface_id: u64, width: u32, height: u32) -> IOSurfaceIdentity { + IOSurfaceIdentity { surface_id, width, height } + } } diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs index 35a803b..38736e9 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs @@ -11,11 +11,10 @@ use ely_servo_host::{IOSurfaceIdentity, SoftwareServoHost}; use super::live_protocol::{LiveOutcome, LiveSidecarError, PartialFrameTimings}; use super::perf::{FramePerfAggregator, FramePerfSummary, FrameStageTimings, elapsed_ns}; -/// Populate the hardware surface protocol fields on `outcome`. Mach -/// app clients keep readback frames free of surface fields because -/// synchronous IOSurface import can block the live worker; the no-Mach -/// bench path publishes readback warm-up handles so it can validate -/// payloadless steady-state frames. Two pieces of state ride out +/// Populate the hardware surface protocol fields on `outcome`. Readback +/// warm-up frames publish IOSurface handles so the app can import them +/// on its dedicated importer thread before steady-state payloadless +/// frames select the rotating surface ids. Two pieces of state ride out /// together: /// /// * `current_surface_id` — set on every payload-bearing hardware diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs index 82290d0..f367c49 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs @@ -43,9 +43,13 @@ pub(super) enum LiveRequest { hover_y: Option, typed_text: Option, site_permissions: Vec, + #[serde(default)] + ready_surface_ids: Vec, }, Poll { tab_id: String, + #[serde(default)] + ready_surface_ids: Vec, }, Close { tab_id: String,