diff --git a/crates/ely_app/src/services/servo_live.rs b/crates/ely_app/src/services/servo_live.rs index b552f23..ef17ea9 100644 --- a/crates/ely_app/src/services/servo_live.rs +++ b/crates/ely_app/src/services/servo_live.rs @@ -133,17 +133,17 @@ impl ServoLiveClient { if let Some(error) = response.error { return Err(ServoLiveError::SidecarFailed { message: error }); } + let surface_handle = response.surface_handle; + let current_surface_id = response.current_surface_id; if let Some(perf) = response.perf.as_ref() { log_frame_perf(perf); } - if let Some(handle) = response.surface_handle.as_ref() { + if let Some(handle) = 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 { + if let Some(surface_id) = current_surface_id { log_iosurface_current(surface_id); } @@ -182,7 +182,15 @@ impl ServoLiveClient { let mut frame = ServoLiveFrame::from_parts(report, rgba_bytes); #[cfg(target_os = "macos")] - if let Some(surface_id) = response.current_surface_id { + 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)?; + } + + #[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 }); 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 1148cf0..76f575f 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 @@ -7,7 +7,7 @@ use std::{ use ely_domain::{ProfileId, TabId, UrlText}; #[cfg(all(feature = "hardware-render", target_os = "macos"))] -use ely_servo_host::WebViewState; +use ely_servo_host::ServoHostError; use ely_servo_host::{ IOSurfaceIdentity, NavigationRequest, RenderingContextKind, ServoHost, ServoSurfaceSize, SoftwareServoHost, @@ -26,6 +26,7 @@ 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(); 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( @@ -64,6 +65,7 @@ pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> { &mut sessions, &mut published_surface_ids, rendering_context_kind, + publish_readback_surface_fields, request, ), Err(error) => Err(LiveSidecarError::Json(error)), @@ -92,6 +94,7 @@ fn handle_request( sessions: &mut HashMap, published_surface_ids: &mut HashMap>, rendering_context_kind: RenderingContextKind, + publish_readback_surface_fields: bool, request: LiveRequest, ) -> Result { match request { @@ -159,12 +162,14 @@ 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)?; + let mut outcome = + poll_frame(host, session, rendering_context_kind, &tab_id, published_surface_ids)?; populate_surface_fields( host, &webview_id, &tab_id, published_surface_ids, + publish_readback_surface_fields, &mut outcome, ); Ok(outcome) @@ -174,12 +179,14 @@ fn handle_request( return Ok(LiveOutcome::empty()); }; let webview_id = session.webview_id.clone(); - let mut outcome = poll_frame(host, session, rendering_context_kind)?; + let mut outcome = + poll_frame(host, session, rendering_context_kind, &tab_id, published_surface_ids)?; populate_surface_fields( host, &webview_id, &tab_id, published_surface_ids, + publish_readback_surface_fields, &mut outcome, ); Ok(outcome) @@ -198,6 +205,8 @@ fn poll_frame( host: &mut SoftwareServoHost, session: &mut LiveSession, rendering_context_kind: RenderingContextKind, + tab_id: &str, + published_surface_ids: &HashMap>, ) -> Result { host.tick(); let snapshot = host.snapshot(&session.webview_id)?; @@ -206,8 +215,14 @@ fn poll_frame( return Ok(LiveOutcome::empty()); } - let (outcome, has_visible_content) = - paint_pending_frame(host, session, rendering_context_kind, has_pending_frame)?; + let (outcome, has_visible_content) = paint_pending_frame( + host, + session, + rendering_context_kind, + tab_id, + published_surface_ids, + has_pending_frame, + )?; if has_visible_content { session.awaiting_visible_frame = false; session.ever_visible_frame = true; @@ -228,14 +243,23 @@ fn paint_pending_frame( host: &mut SoftwareServoHost, session: &mut LiveSession, rendering_context_kind: RenderingContextKind, + tab_id: &str, + published_surface_ids: &HashMap>, has_pending_frame: bool, ) -> Result<(LiveOutcome, bool), LiveSidecarError> { + #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] + let _ = (tab_id, published_surface_ids); + 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, has_pending_frame) - } + RenderingContextKind::Hardware => paint_hardware_surface_frame( + host, + session, + tab_id, + published_surface_ids, + has_pending_frame, + ), #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] RenderingContextKind::Hardware => paint_readback_frame(host, session, !has_pending_frame), } @@ -264,16 +288,22 @@ fn paint_readback_frame( fn paint_hardware_surface_frame( host: &mut SoftwareServoHost, session: &LiveSession, + tab_id: &str, + published_surface_ids: &HashMap>, has_pending_frame: bool, ) -> Result<(LiveOutcome, bool), LiveSidecarError> { if !session.ever_visible_frame { return paint_initial_hardware_surface_frame(host, session, !has_pending_frame); } - // Cross-process IOSurface lookup can block the app-side worker for - // seconds on macOS. Live app frames use readback so scroll/click - // input stays bounded by the paint barrier instead of the surface - // import path. - paint_readback_frame(host, session, !has_pending_frame) + if !payloadless_surface_pool_ready(published_surface_ids, tab_id, 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) { + return paint_readback_frame(host, session, true); + } + Ok((outcome, true)) } #[cfg(all(feature = "hardware-render", target_os = "macos"))] @@ -289,17 +319,61 @@ fn paint_initial_hardware_surface_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 has_visible_content = frame.non_white_pixel_count() > 0 && frame.content_pixel_count() > 0; 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) +fn paint_hardware_surface_report( + host: &mut SoftwareServoHost, + session: &LiveSession, + wait_for_completion: bool, +) -> Result<(LiveOutcome, IOSurfaceIdentity), LiveSidecarError> { + let paint_started_at = Instant::now(); + host.paint_without_readback_with_completion(&session.webview_id, wait_for_completion)?; + let snapshot = host.snapshot(&session.webview_id)?; + let identity = host.peek_iosurface_identity(&session.webview_id)?.ok_or_else(|| { + ServoHostError::HardwareSurfaceUnavailable { id: session.webview_id.clone() } + })?; + let paint_ns = elapsed_ns(paint_started_at); + let encode_started_at = Instant::now(); + let report = LiveFrameReport::from_surface( + &snapshot, + identity.width, + identity.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), identity)) +} + +#[cfg(all(feature = "hardware-render", target_os = "macos"))] +fn payloadless_surface_pool_ready( + published_surface_ids: &HashMap>, + tab_id: &str, + width: u32, + height: u32, +) -> bool { + published_surface_ids.get(tab_id).is_some_and(|published| { + published + .iter() + .filter(|identity| identity.width == width && identity.height == height) + .take(2) + .count() + >= 2 + }) +} + +#[cfg(all(feature = "hardware-render", target_os = "macos"))] +fn surface_has_been_published( + published_surface_ids: &HashMap>, + tab_id: &str, + identity: IOSurfaceIdentity, +) -> bool { + published_surface_ids.get(tab_id).is_some_and(|published| published.contains(&identity)) } #[cfg(test)] 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 4b48068..35a803b 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,8 +11,12 @@ 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`. Two -/// pieces of state ride out together: +/// 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 +/// together: /// /// * `current_surface_id` — set on every payload-bearing hardware /// frame so the receiver knows which previously-imported @@ -30,12 +34,13 @@ pub(super) fn populate_surface_fields( webview_id: &ely_domain::WebViewId, tab_id: &str, published_surface_ids: &mut HashMap>, + publish_readback_surface_fields: bool, outcome: &mut LiveOutcome, ) { if outcome.response.frame.is_none() { return; } - if outcome.frame.is_some() { + if outcome.frame.is_some() && !publish_readback_surface_fields { return; } #[cfg(all(feature = "hardware-render", target_os = "macos"))] @@ -58,7 +63,7 @@ pub(super) fn populate_surface_fields( } #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] { - let _ = (host, webview_id, tab_id, published_surface_ids); + let _ = (host, webview_id, tab_id, published_surface_ids, publish_readback_surface_fields); } } @@ -154,12 +159,9 @@ pub(super) fn write_outcome( if let Some(summary) = pending_summary.take() { outcome.response.perf = Some(summary); } - // Hardware path: receiver samples the IOSurface directly through - // its CVPixelBuffer cache, so the raw RGBA payload is dead - // weight. Drop it from the wire (and zero the byte count in the - // header so the client knows nothing follows). At 1080p × 60 fps - // that's 8 MB × 60 = ~480 MB/s of pipe traffic eliminated. - let drop_rgba_payload = outcome.response.current_surface_id.is_some(); + // Payloadless hardware frames carry only the IOSurface selector. + let drop_rgba_payload = + outcome.response.current_surface_id.is_some() && outcome.frame.is_none(); if drop_rgba_payload && let Some(report) = outcome.response.frame.as_mut() { report.rgba_byte_count = 0; } 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 fdbcecf..82290d0 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 @@ -103,7 +103,7 @@ impl LiveOutcome { } } - #[cfg(test)] + #[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))] pub fn from_report(report: LiveFrameReport, partial_timings: PartialFrameTimings) -> Self { Self { response: LiveResponse::frame(report), @@ -204,6 +204,31 @@ impl LiveFrameReport { sample_hash: frame.sample_hash(), } } + + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + pub fn from_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, + sample_hash: 0, + } + } } fn css_viewport_size(width: u32, height: u32, device_pixel_ratio: f32) -> (u32, u32) { diff --git a/crates/ely_servo_host/src/error.rs b/crates/ely_servo_host/src/error.rs index e1b9767..701e38b 100644 --- a/crates/ely_servo_host/src/error.rs +++ b/crates/ely_servo_host/src/error.rs @@ -33,6 +33,9 @@ pub enum ServoHostError { #[error("servo rendered frame is unavailable")] RenderedFrameUnavailable, + #[error("servo hardware surface is unavailable for {id}")] + HardwareSurfaceUnavailable { id: WebViewId }, + #[error("servo screenshot capture timed out for {id}")] ScreenshotTimedOut { id: WebViewId }, diff --git a/crates/ely_servo_host/src/runtime.rs b/crates/ely_servo_host/src/runtime.rs index 5064d93..b57c43a 100644 --- a/crates/ely_servo_host/src/runtime.rs +++ b/crates/ely_servo_host/src/runtime.rs @@ -65,13 +65,6 @@ impl SoftwareServoHost { } /// Construct the host with an explicit [`RenderingContextKind`]. - /// - /// `Hardware` requires the `hardware-render` feature; the call - /// fails with `ServoHostError::HardwareRenderUnavailable` if the - /// feature wasn't compiled in. This is the constructor the - /// sidecar binary will use once a `--rendering-context` CLI - /// flag lands; today the default path through `new` and - /// `new_with_config_dir` keeps the software behaviour unchanged. pub fn new_with_config_dir_and_kind( size: ServoSurfaceSize, config_dir: Option, @@ -106,12 +99,17 @@ impl SoftwareServoHost { self.create_webview_in_context(tab_id, profile_id, size) } - /// Paint and present the webview's current surface while leaving - /// framebuffer readback to callers that explicitly need RGBA - /// bytes. The live hardware path exports the just-presented - /// IOSurface from the rendering context. + /// Paint and present the current surface without RGBA readback. pub fn paint_without_readback(&mut self, webview_id: &WebViewId) -> Result<(), ServoHostError> { - self.paint_webview(webview_id, false, true).map(|_| ()) + self.paint_without_readback_with_completion(webview_id, true) + } + + pub fn paint_without_readback_with_completion( + &mut self, + webview_id: &WebViewId, + wait_for_completion: bool, + ) -> Result<(), ServoHostError> { + self.paint_webview(webview_id, false, wait_for_completion).map(|_| ()) } pub fn close_webview(&mut self, webview_id: &WebViewId) -> bool { @@ -151,16 +149,8 @@ impl SoftwareServoHost { let rendering_context = self.webview(webview_id)?.rendering_context.clone(); rendering_context.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?; rendering_context.prepare_for_rendering(); - // `webview.paint()` dispatches a render command to Servo's paint - // thread — it does NOT block until the framebuffer is consistent. - // Without a barrier, `read_rendered_frame` below races the paint - // thread and reliably reads the cleared-white state on data: URLs. - // Clear the pending-frame flag first so barrier callers can - // detect the *next* `notify_new_frame_ready` (the one our - // `paint()` triggers), then pump the event loop until Servo - // reports the new frame is ready or `paint_barrier_budget()` - // elapses. When the caller already observed a pending frame, - // readback can use that ready frame and skip the extra wait. + // Clear the pending-frame flag before `paint()` so barrier callers observe + // the next Servo frame-ready notification for this paint. { let webview = self.webview(webview_id)?; webview.delegate.mark_frame_presented(); @@ -228,12 +218,7 @@ impl ServoHost for SoftwareServoHost { // replacing the about:blank WebView so CSS viewport = physical surface / DPR. .hidpi_scale_factor(hidpi_scale_factor) .build(); - // Cosmetic: makes the freshly built WebView paint its - // first frame. The input-accepting invariant lives in - // `webview_for_input`; we deliberately do not re-show or - // re-focus on the `load()` branch so a background tab - // finishing a load cannot steal focus from the foreground - // tab between the user's mouse-down and the next render. + // The input-accepting invariant lives in `webview_for_input`. webview.webview.show(); webview.webview.focus(); } else { diff --git a/crates/ely_servo_host/tests/live_perf_bench.rs b/crates/ely_servo_host/tests/live_perf_bench.rs index f57de05..7386986 100644 --- a/crates/ely_servo_host/tests/live_perf_bench.rs +++ b/crates/ely_servo_host/tests/live_perf_bench.rs @@ -1,21 +1,9 @@ -//! Manually-invoked sidecar perf bench. Runs the live loop, scrolls a -//! tall page over N frames, and harvests the [`FramePerfSummary`] -//! entries the sidecar emits every window. Invoked once per kind: -//! -//! ```text -//! ELY_PERF_KIND=software ELY_PERF_FRAMES=240 \ -//! cargo test -p ely_servo_host --release --test live_perf_bench \ -//! --features servo-engine -- --ignored --nocapture run_live_bench -//! -//! ELY_PERF_KIND=hardware ELY_PERF_FRAMES=240 \ -//! cargo test -p ely_servo_host --release --test live_perf_bench \ -//! --features servo-engine,hardware-render -- --ignored --nocapture run_live_bench -//! ``` -//! -//! Marked `#[ignore]` so normal CI skips it; it shells out to the -//! sidecar binary and runs for tens of seconds. +//! Manual sidecar perf bench; ignored by normal CI. #![cfg(feature = "servo-engine")] +#[path = "live_perf_bench/pixels.rs"] +mod pixels; + use std::{ env, error::Error, @@ -43,18 +31,6 @@ div.row{height:80px;border-bottom:2px solid rgba(0,0,0,.5);color:white;font:24px \ "; -/// Solid red page used by the pixel-content sanity test. If the -/// sidecar's paint barrier (T15) does its job, every pixel of the -/// viewport reads back as approximately (255, 0, 0, 255) in Servo's -/// gl::RGBA byte order. If the framebuffer is still being read before -/// Servo paints, every byte is 255 (the initial clear-to-white state) -/// and the assertion catches it. -const SOLID_RED_DATA_URL: &str = - "data:text/html,"; - -const SOLID_BLUE_DATA_URL: &str = - "data:text/html,"; - #[derive(Deserialize, Debug)] struct LiveResponse { error: Option, @@ -161,8 +137,12 @@ fn run_live_bench() -> Result<(), Box> { ); if kind == "hardware" { assert!( - outcome.surface_handles.is_empty() && outcome.current_surface_ids.is_empty(), - "hardware live path uses bounded readback and must skip IOSurface selection" + !outcome.surface_handles.is_empty(), + "hardware live path must publish IOSurface handles" + ); + assert!( + !outcome.current_surface_ids.is_empty(), + "hardware live path must report current_surface_id selectors" ); } else { assert!( @@ -175,12 +155,19 @@ fn run_live_bench() -> Result<(), Box> { ); } let viewport_bytes = (1024u64) * (768u64) * 4; + let total_rgba_bytes = outcome.readback_rgba_bytes + outcome.surface_rgba_bytes; assert!( - outcome.readback_rgba_bytes >= viewport_bytes, - "{kind} path delivered only {} bytes — expected at least one full frame ({})", - outcome.readback_rgba_bytes, + total_rgba_bytes >= viewport_bytes, + "{kind} path delivered only {total_rgba_bytes} bytes — expected at least one full frame ({})", viewport_bytes, ); + if kind == "hardware" { + let full_readback_budget = viewport_bytes * u64::from(frames); + assert!( + total_rgba_bytes < full_readback_budget, + "hardware path stayed on full readback: {total_rgba_bytes} >= {full_readback_budget}" + ); + } Ok(()) } @@ -282,10 +269,10 @@ fn record_rgba_bytes( surface_rgba_bytes: &mut u64, ) { let rgba_byte_count = response.frame.as_ref().map_or(0, |frame| frame.rgba_byte_count as u64); - if response.current_surface_id.is_some() { - *surface_rgba_bytes += rgba_byte_count; - } else { + if rgba_byte_count > 0 { *readback_rgba_bytes += rgba_byte_count; + } else if response.current_surface_id.is_some() { + *surface_rgba_bytes += rgba_byte_count; } } @@ -474,25 +461,9 @@ fn record_summary(response: &LiveResponse, kind: &str, summaries: &mut Vec10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}", - "win", - "paint50", - "paint95", - "paint99", - "enc50", - "enc95", - "enc99", - "wr50", - "wr95", - "wr99", - "tot50", - "tot95", - "tot99", - ); for summary in summaries { eprintln!( - "{:<8} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10}", + "win={} paint={}/{}/{} encode={}/{}/{} write={}/{}/{} total={}/{}/{}", summary.window, summary.paint_p50_us, summary.paint_p95_us, @@ -517,178 +488,3 @@ fn cleanup(profile_data_dir: &PathBuf) -> Result<(), Box> { Err(error) => Err(error.into()), } } - -/// End-to-end pixel-content test. Drives the sidecar with a solid-red -/// HTML page, reads the frame off the wire, samples a handful of -/// pixels from the centre of the viewport, and asserts the RGBA -/// matches red. Catches three regressions in one shot: -/// * T15 paint barrier — if `read_to_image` runs before Servo -/// paints, every pixel is the framebuffer's clear-to-white state -/// `(255, 255, 255, 255)` and the red assertion fires. -/// * T13 hidpi — if the viewport is mis-scaled, the body might not -/// fill the canvas and the centre pixel would sample whatever's -/// outside. -/// * General pipeline rot — confirms `build_ensure` + JSON wire + -/// RGBA byte stream still delivers the bytes Servo painted. -#[test] -#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"] -fn red_data_url_yields_red_rgba() -> Result<(), Box> { - assert_solid_color_renders("software", SOLID_RED_DATA_URL, ColorTarget::Red)?; - Ok(()) -} - -#[test] -#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"] -fn blue_data_url_yields_blue_rgba() -> Result<(), Box> { - assert_solid_color_renders("software", SOLID_BLUE_DATA_URL, ColorTarget::Blue)?; - Ok(()) -} - -#[derive(Clone, Copy)] -enum ColorTarget { - Red, - Blue, -} - -impl ColorTarget { - fn label(self) -> &'static str { - match self { - ColorTarget::Red => "red", - ColorTarget::Blue => "blue", - } - } -} - -fn assert_solid_color_renders( - kind: &str, - url: &str, - target: ColorTarget, -) -> Result<(), Box> { - let profile_id = ProfileId::new(); - let tab = TabId::new(); - let profile_data_dir = env::temp_dir().join(format!( - "ely-pixel-{}-{}-{}", - std::process::id(), - target.label(), - profile_id.as_str(), - )); - fs::create_dir_all(&profile_data_dir)?; - - let mut child = spawn_sidecar(kind, &profile_data_dir)?; - let mut stdin = child.stdin.take().ok_or("sidecar stdin missing")?; - let stdout = child.stdout.take().ok_or("sidecar stdout missing")?; - let mut reader = BufReader::new(stdout); - - let outcome = drive_solid_color_render(&mut stdin, &mut reader, &tab, &profile_id, url, target); - - drop(stdin); - let _ = child.wait(); - cleanup(&profile_data_dir)?; - - outcome -} - -fn drive_solid_color_render( - stdin: &mut ChildStdin, - reader: &mut BufReader, - tab: &TabId, - profile_id: &ProfileId, - url: &str, - target: ColorTarget, -) -> Result<(), Box> { - // The navigate response itself is the one most likely to carry - // real pixels — the sidecar's `awaiting_visible_frame` is armed - // on a new URL and `poll_frame` will wait inside its own budget - // for Servo to paint. Send navigate, capture the bytes, then - // drive scroll iterations to give Servo additional repaint - // opportunities. The first matching frame wins. - let mut bytes = Vec::new(); - let mut report = None; - for iteration in 0..30 { - let scroll_y = if iteration == 0 { - 0 - } else if iteration % 2 == 1 { - 1 - } else { - -1 - }; - let request = build_ensure(tab, profile_id, url, 0, scroll_y, false); - write_request(stdin, &request)?; - let (response, response_bytes) = read_response_with_bytes(reader, RESPONSE_TIMEOUT)?; - if let Some(error) = response.error.as_ref() { - return Err(format!("sidecar error: {error}").into()); - } - if let Some(frame_report) = response.frame { - if !response_bytes.is_empty() - && sample_matches_target( - &response_bytes, - frame_report.width, - frame_report.height, - target, - ) - { - report = Some(frame_report); - bytes = response_bytes; - break; - } - if !response_bytes.is_empty() { - bytes = response_bytes; - report = Some(frame_report); - } - } - } - - let report = report.ok_or("never received a frame with bytes")?; - let width = report.width as usize; - let height = report.height as usize; - assert_eq!(bytes.len(), width * height * 4, "rgba byte count must match width × height × 4",); - - // Sample 9 evenly-spaced points in the inner quartile of the - // viewport. Solid backgrounds should pass every sample; if Servo - // is still painting initial-white we'll see (255, 255, 255, 255) - // across the grid and the per-pixel asserts will explain. - let mut samples = Vec::new(); - for fy in [1, 2, 3] { - for fx in [1, 2, 3] { - let x = width * fx / 4; - let y = height * fy / 4; - let idx = (y * width + x) * 4; - samples.push((x, y, bytes[idx], bytes[idx + 1], bytes[idx + 2], bytes[idx + 3])); - } - } - eprintln!("[pixel sample {}] {:?}", target.label(), samples); - - let mut hits = 0; - for (_x, _y, r, g, b, _a) in &samples { - if matches_color(*r, *g, *b, target) { - hits += 1; - } - } - assert!( - hits >= 5, - "expected ≥5/9 centre-quadrant pixels to be {} after rendering {}; got samples {:?}", - target.label(), - url, - samples, - ); - Ok(()) -} - -fn sample_matches_target(bytes: &[u8], width: u32, height: u32, target: ColorTarget) -> bool { - let w = width as usize; - let h = height as usize; - if bytes.len() < w * h * 4 || w == 0 || h == 0 { - return false; - } - let cx = w / 2; - let cy = h / 2; - let idx = (cy * w + cx) * 4; - matches_color(bytes[idx], bytes[idx + 1], bytes[idx + 2], target) -} - -fn matches_color(r: u8, g: u8, b: u8, target: ColorTarget) -> bool { - match target { - ColorTarget::Red => r >= 200 && g <= 60 && b <= 60, - ColorTarget::Blue => r <= 60 && g <= 60 && b >= 200, - } -} diff --git a/crates/ely_servo_host/tests/live_perf_bench/pixels.rs b/crates/ely_servo_host/tests/live_perf_bench/pixels.rs new file mode 100644 index 0000000..33ad1e8 --- /dev/null +++ b/crates/ely_servo_host/tests/live_perf_bench/pixels.rs @@ -0,0 +1,163 @@ +use std::{ + env, + error::Error, + fs, + io::BufReader, + process::{ChildStdin, ChildStdout}, +}; + +use ely_domain::{ProfileId, TabId}; + +use super::{ + RESPONSE_TIMEOUT, build_ensure, cleanup, read_response_with_bytes, spawn_sidecar, write_request, +}; + +const SOLID_RED_DATA_URL: &str = + "data:text/html,"; +const SOLID_BLUE_DATA_URL: &str = + "data:text/html,"; + +#[test] +#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"] +fn red_data_url_yields_red_rgba() -> Result<(), Box> { + assert_solid_color_renders("software", SOLID_RED_DATA_URL, ColorTarget::Red) +} + +#[test] +#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"] +fn blue_data_url_yields_blue_rgba() -> Result<(), Box> { + assert_solid_color_renders("software", SOLID_BLUE_DATA_URL, ColorTarget::Blue) +} + +#[derive(Clone, Copy)] +enum ColorTarget { + Red, + Blue, +} + +impl ColorTarget { + fn label(self) -> &'static str { + match self { + ColorTarget::Red => "red", + ColorTarget::Blue => "blue", + } + } +} + +fn assert_solid_color_renders( + kind: &str, + url: &str, + target: ColorTarget, +) -> Result<(), Box> { + let profile_id = ProfileId::new(); + let tab = TabId::new(); + let profile_data_dir = env::temp_dir().join(format!( + "ely-pixel-{}-{}-{}", + std::process::id(), + target.label(), + profile_id.as_str(), + )); + fs::create_dir_all(&profile_data_dir)?; + + let mut child = spawn_sidecar(kind, &profile_data_dir)?; + let mut stdin = child.stdin.take().ok_or("sidecar stdin missing")?; + let stdout = child.stdout.take().ok_or("sidecar stdout missing")?; + let mut reader = BufReader::new(stdout); + let outcome = drive_solid_color_render(&mut stdin, &mut reader, &tab, &profile_id, url, target); + + drop(stdin); + let _ = child.wait(); + cleanup(&profile_data_dir)?; + outcome +} + +fn drive_solid_color_render( + stdin: &mut ChildStdin, + reader: &mut BufReader, + tab: &TabId, + profile_id: &ProfileId, + url: &str, + target: ColorTarget, +) -> Result<(), Box> { + let mut bytes = Vec::new(); + let mut report = None; + for iteration in 0..30 { + let scroll_y = if iteration == 0 { + 0 + } else if iteration % 2 == 1 { + 1 + } else { + -1 + }; + let request = build_ensure(tab, profile_id, url, 0, scroll_y, false); + write_request(stdin, &request)?; + let (response, response_bytes) = read_response_with_bytes(reader, RESPONSE_TIMEOUT)?; + if let Some(error) = response.error.as_ref() { + return Err(format!("sidecar error: {error}").into()); + } + if let Some(frame_report) = response.frame { + if !response_bytes.is_empty() + && sample_matches_target( + &response_bytes, + frame_report.width, + frame_report.height, + target, + ) + { + report = Some(frame_report); + bytes = response_bytes; + break; + } + if !response_bytes.is_empty() { + bytes = response_bytes; + report = Some(frame_report); + } + } + } + + let report = report.ok_or("never received a frame with bytes")?; + let width = report.width as usize; + let height = report.height as usize; + assert_eq!(bytes.len(), width * height * 4, "rgba byte count must match width * height * 4",); + + let mut samples = Vec::new(); + for fy in [1, 2, 3] { + for fx in [1, 2, 3] { + let x = width * fx / 4; + let y = height * fy / 4; + let idx = (y * width + x) * 4; + samples.push((x, y, bytes[idx], bytes[idx + 1], bytes[idx + 2], bytes[idx + 3])); + } + } + eprintln!("[pixel sample {}] {:?}", target.label(), samples); + + let hits = + samples.iter().filter(|(_x, _y, r, g, b, _a)| matches_color(*r, *g, *b, target)).count(); + assert!( + hits >= 5, + "expected at least 5/9 center-quadrant pixels to be {} after rendering {}; got samples {:?}", + target.label(), + url, + samples, + ); + Ok(()) +} + +fn sample_matches_target(bytes: &[u8], width: u32, height: u32, target: ColorTarget) -> bool { + let w = width as usize; + let h = height as usize; + if bytes.len() < w * h * 4 || w == 0 || h == 0 { + return false; + } + let cx = w / 2; + let cy = h / 2; + let idx = (cy * w + cx) * 4; + matches_color(bytes[idx], bytes[idx + 1], bytes[idx + 2], target) +} + +fn matches_color(r: u8, g: u8, b: u8, target: ColorTarget) -> bool { + match target { + ColorTarget::Red => r >= 200 && g <= 60 && b <= 60, + ColorTarget::Blue => r <= 60 && g <= 60 && b >= 200, + } +}