use std::{ env, io::{self, BufRead, BufReader, Read, Write}, path::PathBuf, process::{Child, ChildStdin, ChildStdout, Stdio}, }; /// Environment variable that lets the user pick the rendering context /// kind used by the spawned sidecar. Accepted values: `software` /// (default — bit-identical to pre-flag builds) and `hardware` (real /// GPU adapter via the vendored `HardwareOffscreenContext`; requires /// the sidecar binary to be compiled with the `hardware-render` /// feature). Anything else is silently dropped and the sidecar /// defaults to software so a typo'd value never blocks the browser /// from starting; the sidecar's own arg parser still errors loudly on /// an unrecognised value when set explicitly via the flag. const RENDERING_CONTEXT_ENV: &str = "ELY_SERVO_RENDERING_CONTEXT"; use ely_domain::SitePermissionDecision; use serde::{Deserialize, Serialize}; use thiserror::Error; use super::servo_sidecar_command::{SidecarCommandError, default_sidecar_command}; #[cfg(target_os = "macos")] use super::iosurface_metal::IOSurfaceCache; #[cfg(target_os = "macos")] use core_video::pixel_buffer::CVPixelBuffer; pub(crate) struct ServoLiveClient { child: Child, stdin: ChildStdin, stdout: BufReader, /// Cache of imported `CVPixelBuffer`s keyed by surface_id. Built /// lazily on the first `surface_handle` the sidecar publishes — /// software-path tabs never trigger construction. #[cfg(target_os = "macos")] iosurface_cache: IOSurfaceCache, } impl ServoLiveClient { pub fn new(profile_data_dir: PathBuf) -> Result { let command_target = default_sidecar_command()?; if let Some(path) = command_target.missing_binary_path() { return Err(ServoLiveError::SidecarBinaryUnavailable { path: path.to_path_buf() }); } let mut command = command_target.command(); command.arg("live").arg("--profile-data-dir").arg(profile_data_dir); if let Some(rendering_context) = rendering_context_from_env() { command.arg("--rendering-context").arg(rendering_context); } let mut child = command .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .map_err(ServoLiveError::Command)?; let stdin = child.stdin.take().ok_or(ServoLiveError::PipeUnavailable { name: "stdin" })?; let stdout = child.stdout.take().ok_or(ServoLiveError::PipeUnavailable { name: "stdout" })?; Ok(Self { child, stdin, stdout: BufReader::new(stdout), #[cfg(target_os = "macos")] iosurface_cache: IOSurfaceCache::new(), }) } pub fn ensure( &mut self, request: ServoLiveEnsureRequest, ) -> Result, ServoLiveError> { self.request(LiveRequest::Ensure { tab_id: request.tab_id, profile_id: request.profile_id, url: request.url, width: request.width, height: request.height, page_zoom_percent: request.page_zoom_percent, device_pixel_ratio: request.device_pixel_ratio, scroll_delta_x: request.scroll_delta_x, scroll_delta_y: request.scroll_delta_y, click_x: request.click_x, click_y: request.click_y, hover_x: request.hover_x, hover_y: request.hover_y, typed_text: request.typed_text, site_permissions: request.site_permissions, }) } pub fn poll(&mut self, tab_id: String) -> Result, ServoLiveError> { self.request(LiveRequest::Poll { tab_id }) } fn request(&mut self, request: LiveRequest) -> Result, ServoLiveError> { serde_json::to_writer(&mut self.stdin, &request)?; self.stdin.write_all(b"\n").map_err(ServoLiveError::Command)?; self.stdin.flush().map_err(ServoLiveError::Command)?; let mut line = String::new(); let bytes = self.stdout.read_line(&mut line).map_err(ServoLiveError::Command)?; if bytes == 0 { return Err(ServoLiveError::SidecarExited); } let response: LiveResponse = serde_json::from_str(&line)?; if let Some(error) = response.error { return Err(ServoLiveError::SidecarFailed { message: error }); } if let Some(perf) = response.perf.as_ref() { log_frame_perf(perf); } if let Some(handle) = response.surface_handle.as_ref() { log_iosurface_handle(handle); #[cfg(target_os = "macos")] self.import_iosurface_handle(handle); } if let Some(surface_id) = response.current_surface_id { log_iosurface_current(surface_id); } let Some(report) = response.frame else { return Ok(None); }; // Sanity bound the byte count advertised by the sidecar // header so a buggy or hostile sidecar can't park us on // `read_exact` for an arbitrarily-sized buffer. The honest // upper limit is `width * height * 4` (RGBA8); `0` is the // explicit "hardware path active, sample the IOSurface // instead" signal — anything else is a protocol violation. let pixel_byte_count = (report.width as u64) .saturating_mul(report.height as u64) .saturating_mul(4); let advertised = report.rgba_byte_count as u64; if advertised != 0 && advertised != pixel_byte_count { return Err(ServoLiveError::FrameBudgetExceeded { advertised: report.rgba_byte_count, pixel_budget: pixel_byte_count, width: report.width, height: report.height, }); } // Raw frame bytes follow the JSON header on the same pipe // ONLY when the sidecar didn't drop the payload for the // hardware path. `read_exact` drains BufReader's buffer first // (the line read never crosses the `\n` boundary) and then // pulls the rest straight from the child's stdout — no // fs::read, no temp file. let mut rgba_bytes = vec![0u8; report.rgba_byte_count]; if report.rgba_byte_count > 0 { self.stdout .read_exact(&mut rgba_bytes) .map_err(ServoLiveError::FrameRead)?; } let mut frame = ServoLiveFrame::from_parts(report, rgba_bytes); #[cfg(target_os = "macos")] if let Some(surface_id) = response.current_surface_id { frame.pixel_buffer = self.iosurface_cache.pixel_buffer_for(surface_id); } Ok(Some(frame)) } } 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. Failures are logged but don't error the /// request — the renderer falls back to the existing software /// `Arc` path when no pixel buffer is available, so /// the user always sees a frame. fn import_iosurface_handle(&mut self, handle: &LiveSurfaceHandle) { match self.iosurface_cache.import(handle.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; subsequent samples will miss", ), } } } 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) 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, #[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 companion: when present, the renderer can hand /// the underlying IOSurface straight to GPUI's Metal pipeline via /// `gpui::surface(...)` and skip the RGBA upload entirely. Always /// `None` on the software path and on non-macOS hosts. #[cfg(target_os = "macos")] pixel_buffer: Option, } impl ServoLiveFrame { fn from_parts(report: LiveFrameReport, rgba_bytes: Vec) -> Self { Self { loaded_url: report.loaded_url, title: report.title, render_state: report.state, width: report.width, height: report.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, if any. The renderer hands this to /// `gpui::surface(...)` to skip the RGBA→texture upload path. #[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 } #[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, #[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, } } } #[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 }, #[error(transparent)] Json(#[from] serde_json::Error), #[error(transparent)] SidecarCommand(#[from] SidecarCommandError), } #[derive(Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum LiveRequest { Ensure { tab_id: String, profile_id: String, url: String, width: u32, height: u32, page_zoom_percent: u16, device_pixel_ratio: f32, scroll_delta_x: i32, scroll_delta_y: i32, click_x: Option, click_y: Option, hover_x: Option, hover_y: Option, typed_text: Option, site_permissions: Vec, }, Poll { tab_id: String, }, } #[derive(Deserialize)] struct LiveResponse { error: Option, frame: Option, #[serde(default)] perf: Option, /// Hardware path only: present on the first frame after a new /// IOSurface is bound (initial paint, resize, surfman swap chain /// rotation). T10.4 will turn this into an imported Metal texture; /// for now we log it on the `ely::servo::iosurface` target so the /// pipeline is observable end-to-end without yet wiring it into /// the renderer. #[serde(default)] surface_handle: Option, /// Hardware path only: which previously-imported IOSurface to /// sample this frame. surfman's attached swap chain rotates the /// bound surface, so this id alternates between the values the /// receiver has already imported via `surface_handle`. #[serde(default)] current_surface_id: Option, } /// Wire mirror of `ely_servo_host::IOSurfaceHandle`. Duplicated rather /// than imported because `ely_app` only talks to the sidecar via /// stdin/stdout JSON — it has no crate dependency on `ely_servo_host` /// and adding one just to share a four-field struct would pull the /// Servo dep tree into the renderer process. #[derive(Clone, Copy, Debug, Deserialize)] struct LiveSurfaceHandle { mach_port_name: u32, surface_id: u64, width: u32, height: u32, } /// Aggregated frame-stage timings rolled up every N frames by the /// sidecar. We accept anything matching the wire shape and let the /// `tracing` event echo the percentiles verbatim — the sidecar is /// the source of truth for histogram boundaries. #[derive(Deserialize)] struct LiveFramePerfSummary { window: u32, context: String, paint_p50_us: u64, paint_p95_us: u64, paint_p99_us: u64, encode_p50_us: u64, encode_p95_us: u64, encode_p99_us: u64, write_p50_us: u64, write_p95_us: u64, write_p99_us: u64, total_p50_us: u64, total_p95_us: u64, total_p99_us: u64, } /// 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 /// is the rare `info` and this trace is the steady-state breadcrumb. fn log_iosurface_current(surface_id: u64) { tracing::trace!( target: "ely::servo::iosurface", surface_id, "iosurface_current", ); } /// Emit one structured `tracing` event per IOSurface handover, on a /// dedicated target so `RUST_LOG=ely::servo::iosurface=info` lights up /// the cross-process surface pipeline without pulling in everything /// else. The renderer (T10.4) will turn the same handle into an /// imported Metal texture; today the event is the observable contract /// that T10.3 plumbing is alive. fn log_iosurface_handle(handle: &LiveSurfaceHandle) { tracing::info!( target: "ely::servo::iosurface", mach_port_name = handle.mach_port_name, surface_id = handle.surface_id, width = handle.width, height = handle.height, "iosurface_handle", ); } /// Emit one structured `tracing` event per perf summary, on a /// dedicated target so `RUST_LOG=ely::servo::perf=info` flips the /// stream on without dragging the rest of the app along. Filtering /// happens upstream in the subscriber — this call is a single /// pointer + integer push. fn log_frame_perf(summary: &LiveFramePerfSummary) { tracing::info!( target: "ely::servo::perf", window = summary.window, context = %summary.context, paint_p50_us = summary.paint_p50_us, paint_p95_us = summary.paint_p95_us, paint_p99_us = summary.paint_p99_us, encode_p50_us = summary.encode_p50_us, encode_p95_us = summary.encode_p95_us, encode_p99_us = summary.encode_p99_us, write_p50_us = summary.write_p50_us, write_p95_us = summary.write_p95_us, write_p99_us = summary.write_p99_us, total_p50_us = summary.total_p50_us, total_p95_us = summary.total_p95_us, total_p99_us = summary.total_p99_us, "frame_perf", ); } #[derive(Deserialize)] struct LiveFrameReport { loaded_url: Option, title: Option, state: String, width: u32, height: u32, rgba_byte_count: usize, #[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, } /// Map `ELY_SERVO_RENDERING_CONTEXT` to a CLI argument value if it's /// one we recognise. Unset variable returns `None` (sidecar uses its /// own default of software); unknown value also returns `None` /// rather than `Some("garbage")` so a stale env var doesn't fail the /// sidecar startup. The sidecar's own arg parser is the source of /// truth for what values are valid — we just gate which ones we /// forward. fn rendering_context_from_env() -> Option<&'static str> { match env::var(RENDERING_CONTEXT_ENV).ok()?.to_lowercase().as_str() { "software" => Some("software"), "hardware" => Some("hardware"), _ => None, } }