From 316bf6f9a46db6189f32f14637a13fca15ed6e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 12 May 2026 23:38:13 -0400 Subject: [PATCH] Split Servo live rendering modules --- crates/ely_app/src/services/servo_live.rs | 164 +--------------- .../ely_app/src/services/servo_live_wire.rs | 158 +++++++++++++++ .../src/bin/ely_servo_sidecar.rs | 2 + .../src/bin/ely_servo_sidecar/live.rs | 114 +---------- .../src/bin/ely_servo_sidecar/live_output.rs | 115 +++++++++++ crates/ely_servo_host/src/runtime.rs | 181 +---------------- crates/ely_servo_host/src/runtime_context.rs | 182 ++++++++++++++++++ 7 files changed, 476 insertions(+), 440 deletions(-) create mode 100644 crates/ely_app/src/services/servo_live_wire.rs create mode 100644 crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs create mode 100644 crates/ely_servo_host/src/runtime_context.rs diff --git a/crates/ely_app/src/services/servo_live.rs b/crates/ely_app/src/services/servo_live.rs index 7fde30d..f5cc5a1 100644 --- a/crates/ely_app/src/services/servo_live.rs +++ b/crates/ely_app/src/services/servo_live.rs @@ -17,10 +17,17 @@ use std::{ const RENDERING_CONTEXT_ENV: &str = "ELY_SERVO_RENDERING_CONTEXT"; use ely_domain::SitePermissionDecision; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use thiserror::Error; +#[path = "servo_live_wire.rs"] +mod wire; + use super::servo_sidecar_command::{SidecarCommandError, default_sidecar_command}; +use wire::{ + LiveFrameReport, LiveRequest, LiveResponse, LiveSurfaceHandle, log_frame_perf, + log_iosurface_current, log_iosurface_handle, +}; #[cfg(target_os = "macos")] use super::iosurface_metal::IOSurfaceCache; @@ -397,161 +404,6 @@ pub(crate) enum ServoLiveError { 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, - scroll_point_x: Option, - scroll_point_y: Option, - 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` diff --git a/crates/ely_app/src/services/servo_live_wire.rs b/crates/ely_app/src/services/servo_live_wire.rs new file mode 100644 index 0000000..2a1fd20 --- /dev/null +++ b/crates/ely_app/src/services/servo_live_wire.rs @@ -0,0 +1,158 @@ +use serde::{Deserialize, Serialize}; + +use super::ServoLiveSitePermission; + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) 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, + scroll_point_x: Option, + scroll_point_y: Option, + click_x: Option, + click_y: Option, + hover_x: Option, + hover_y: Option, + typed_text: Option, + site_permissions: Vec, + }, + Poll { + tab_id: String, + }, +} + +#[derive(Deserialize)] +pub(super) struct LiveResponse { + pub(super) error: Option, + pub(super) frame: Option, + #[serde(default)] + pub(super) 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)] + pub(super) 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)] + pub(super) 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)] +pub(super) struct LiveSurfaceHandle { + pub(super) mach_port_name: u32, + pub(super) surface_id: u64, + pub(super) width: u32, + pub(super) 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)] +pub(super) 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, +} + +#[derive(Deserialize)] +pub(super) struct LiveFrameReport { + pub(super) loaded_url: Option, + pub(super) title: Option, + pub(super) state: String, + pub(super) width: u32, + pub(super) height: u32, + pub(super) rgba_byte_count: usize, + #[cfg(all(test, feature = "live-site-smoke"))] + pub(super) non_white_pixel_count: u64, + #[cfg(all(test, feature = "live-site-smoke"))] + pub(super) content_pixel_count: u64, + #[cfg(all(test, feature = "live-site-smoke"))] + pub(super) sample_hash: 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. +pub(super) 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. +pub(super) 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. +pub(super) 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", + ); +} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs index 1b274f4..6b4f1bb 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs @@ -16,6 +16,8 @@ use thiserror::Error; mod args; #[path = "ely_servo_sidecar/live.rs"] mod live; +#[path = "ely_servo_sidecar/live_output.rs"] +mod live_output; #[path = "ely_servo_sidecar/live_protocol.rs"] mod live_protocol; #[path = "ely_servo_sidecar/perf.rs"] 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 aaa4b2f..45270f5 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 @@ -1,7 +1,7 @@ use std::{ collections::{HashMap, HashSet}, fs, - io::{self, BufRead, Write}, + io::{self, BufRead}, thread, time::{Duration, Instant}, }; @@ -14,11 +14,12 @@ use ely_servo_host::{ }; use super::args::LiveArgs; +use super::live_output::{populate_surface_fields, write_outcome}; pub(super) use super::live_protocol::LiveSidecarError; use super::live_protocol::{ LiveFrameReport, LiveOutcome, LiveRequest, LiveSitePermission, PartialFrameTimings, }; -use super::perf::{FramePerfAggregator, FramePerfSummary, FrameStageTimings, elapsed_ns}; +use super::perf::{FramePerfAggregator, FramePerfSummary, elapsed_ns}; /// Per-`Ensure` budget for Servo to paint after input dispatch. /// 250 ms catches the common click + paint round trip within the @@ -179,115 +180,6 @@ fn handle_request( } } -/// Populate the hardware surface protocol fields on `outcome`. Two -/// pieces of state ride out together: -/// -/// * `current_surface_id` — set on every payload-bearing hardware -/// frame so the receiver knows which previously-imported -/// `MTLTexture` to sample THIS frame. surfman's attached swap -/// chain rotates front/back surfaces, so this alternates between -/// a small set of ids. -/// * `surface_handle` — populated only the first time the sidecar -/// sees a given `surface_id`; the receiver imports the IOSurface -/// once and caches the resulting Metal texture. Minting a fresh -/// mach port per frame would leak ports — `IOSurfaceCreateMachPort` -/// hands out a new send right each call and they don't free -/// automatically until the receiver `mach_port_deallocate`s. -fn populate_surface_fields( - host: &SoftwareServoHost, - webview_id: &ely_domain::WebViewId, - tab_id: &str, - published_surface_ids: &mut HashMap>, - outcome: &mut LiveOutcome, -) { - if outcome.frame.is_none() { - return; - } - #[cfg(all(feature = "hardware-render", target_os = "macos"))] - { - let Ok(Some(identity)) = host.peek_iosurface_identity(webview_id) else { - return; - }; - outcome.response.current_surface_id = Some(identity.surface_id); - let seen = published_surface_ids.entry(tab_id.to_string()).or_default(); - if seen.contains(&identity.surface_id) { - return; - } - let Ok(Some(handle)) = host.current_iosurface_handle(webview_id) else { - return; - }; - seen.insert(handle.surface_id); - outcome.response.surface_handle = Some(handle); - } - #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] - { - let _ = (host, webview_id, tab_id, published_surface_ids); - } -} - -/// Serialise the response then stream the optional raw RGBA frame on -/// the same stdout pipe. The client reads the JSON line, takes -/// `rgba_byte_count` from the report, then reads that many bytes -/// from the same stream — no temp file round-trip. -/// -/// After the bytes hit the pipe we fold paint/encode/write/total -/// timings into the aggregator. `total_ns` is the wall-clock span -/// from `frame_started_at` (request arrival) to the stdout flush -/// returning, so it captures every per-frame cost outside the three -/// measured stages. Any summary the aggregator emits is stashed on -/// `pending_summary` and rides out on the *next* response, because -/// the protocol is one-line-per-response and an unsolicited summary -/// line would desync the main process's read loop. -fn write_outcome( - stdout: &mut impl Write, - perf: &mut FramePerfAggregator, - pending_summary: &mut Option, - outcome: Result, - frame_started_at: Instant, -) -> Result<(), LiveSidecarError> { - let mut outcome = outcome.unwrap_or_else(|error| LiveOutcome::error(error.to_string())); - let partial_timings = outcome.partial_timings.take(); - let frame_present = outcome.frame.is_some(); - 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(); - if drop_rgba_payload - && let Some(report) = outcome.response.frame.as_mut() - { - report.rgba_byte_count = 0; - } - let write_started_at = Instant::now(); - serde_json::to_writer(&mut *stdout, &outcome.response)?; - stdout.write_all(b"\n")?; - if !drop_rgba_payload - && let Some(frame) = outcome.frame.as_ref() - { - stdout.write_all(frame.rgba_bytes())?; - } - stdout.flush()?; - if frame_present { - let write_ns = elapsed_ns(write_started_at); - let total_ns = elapsed_ns(frame_started_at); - let partial = partial_timings.unwrap_or(PartialFrameTimings { paint_ns: 0, encode_ns: 0 }); - let timings = FrameStageTimings::from_durations( - Duration::from_nanos(partial.paint_ns), - Duration::from_nanos(partial.encode_ns), - Duration::from_nanos(write_ns), - Duration::from_nanos(total_ns), - ); - if let Some(summary) = perf.record(timings) { - *pending_summary = Some(summary); - } - } - Ok(()) -} - fn ensure_session<'a>( host: &mut SoftwareServoHost, sessions: &'a mut HashMap, 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 new file mode 100644 index 0000000..8629f1d --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs @@ -0,0 +1,115 @@ +use std::{ + collections::{HashMap, HashSet}, + io::Write, + time::{Duration, Instant}, +}; + +use ely_servo_host::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: +/// +/// * `current_surface_id` — set on every payload-bearing hardware +/// frame so the receiver knows which previously-imported +/// `MTLTexture` to sample THIS frame. surfman's attached swap +/// chain rotates front/back surfaces, so this alternates between +/// a small set of ids. +/// * `surface_handle` — populated only the first time the sidecar +/// sees a given `surface_id`; the receiver imports the IOSurface +/// once and caches the resulting Metal texture. Minting a fresh +/// mach port per frame would leak ports — `IOSurfaceCreateMachPort` +/// hands out a new send right each call and they don't free +/// automatically until the receiver `mach_port_deallocate`s. +pub(super) fn populate_surface_fields( + host: &SoftwareServoHost, + webview_id: &ely_domain::WebViewId, + tab_id: &str, + published_surface_ids: &mut HashMap>, + outcome: &mut LiveOutcome, +) { + if outcome.frame.is_none() { + return; + } + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + { + let Ok(Some(identity)) = host.peek_iosurface_identity(webview_id) else { + return; + }; + outcome.response.current_surface_id = Some(identity.surface_id); + let seen = published_surface_ids.entry(tab_id.to_string()).or_default(); + if seen.contains(&identity.surface_id) { + return; + } + let Ok(Some(handle)) = host.current_iosurface_handle(webview_id) else { + return; + }; + seen.insert(handle.surface_id); + outcome.response.surface_handle = Some(handle); + } + #[cfg(not(all(feature = "hardware-render", target_os = "macos")))] + { + let _ = (host, webview_id, tab_id, published_surface_ids); + } +} + +/// Serialise the response then stream the optional raw RGBA frame on +/// the same stdout pipe. The client reads the JSON line, takes +/// `rgba_byte_count` from the report, then reads that many bytes +/// from the same stream — no temp file round-trip. +/// +/// After the bytes hit the pipe we fold paint/encode/write/total +/// timings into the aggregator. `total_ns` is the wall-clock span +/// from `frame_started_at` (request arrival) to the stdout flush +/// returning, so it captures every per-frame cost outside the three +/// measured stages. Any summary the aggregator emits is stashed on +/// `pending_summary` and rides out on the *next* response, because +/// the protocol is one-line-per-response and an unsolicited summary +/// line would desync the main process's read loop. +pub(super) fn write_outcome( + stdout: &mut impl Write, + perf: &mut FramePerfAggregator, + pending_summary: &mut Option, + outcome: Result, + frame_started_at: Instant, +) -> Result<(), LiveSidecarError> { + let mut outcome = outcome.unwrap_or_else(|error| LiveOutcome::error(error.to_string())); + let partial_timings = outcome.partial_timings.take(); + let frame_present = outcome.frame.is_some(); + 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(); + if drop_rgba_payload && let Some(report) = outcome.response.frame.as_mut() { + report.rgba_byte_count = 0; + } + let write_started_at = Instant::now(); + serde_json::to_writer(&mut *stdout, &outcome.response)?; + stdout.write_all(b"\n")?; + if !drop_rgba_payload && let Some(frame) = outcome.frame.as_ref() { + stdout.write_all(frame.rgba_bytes())?; + } + stdout.flush()?; + if frame_present { + let write_ns = elapsed_ns(write_started_at); + let total_ns = elapsed_ns(frame_started_at); + let partial = partial_timings.unwrap_or(PartialFrameTimings { paint_ns: 0, encode_ns: 0 }); + let timings = FrameStageTimings::from_durations( + Duration::from_nanos(partial.paint_ns), + Duration::from_nanos(partial.encode_ns), + Duration::from_nanos(write_ns), + Duration::from_nanos(total_ns), + ); + if let Some(summary) = perf.record(timings) { + *pending_summary = Some(summary); + } + } + Ok(()) +} diff --git a/crates/ely_servo_host/src/runtime.rs b/crates/ely_servo_host/src/runtime.rs index d8fd50d..d605baa 100644 --- a/crates/ely_servo_host/src/runtime.rs +++ b/crates/ely_servo_host/src/runtime.rs @@ -1,11 +1,10 @@ use std::{ cell::RefCell, collections::HashMap, - env, path::PathBuf, rc::Rc, sync::{ - Arc, OnceLock, + Arc, atomic::{AtomicBool, Ordering}, }, thread, @@ -14,24 +13,16 @@ use std::{ use dpi::PhysicalSize; use ely_domain::{ProfileId, TabId, WebViewId}; -use euclid::Scale; use servo::{ - DeviceIndependentPixel, DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePixel, DevicePoint, - DeviceVector2D, Opts, RenderingContext, Scroll, Servo, ServoBuilder, WebViewBuilder, - WebViewPoint, WebViewVector, + DevicePoint, DeviceVector2D, Opts, Scroll, Servo, ServoBuilder, WebViewBuilder, WebViewPoint, + WebViewVector, }; -/// Wrap an `f32` scale factor in Servo's typed `Scale`. The clamp guards against `NaN`/`inf` reaching Servo's -/// layout (which assumes a positive finite scale). -fn hidpi_scale_from_factor(scale_factor: f32) -> Scale { - let safe = if scale_factor.is_finite() && scale_factor > 0.0 { - scale_factor.clamp(0.5, 5.0) - } else { - 1.0 - }; - Scale::new(safe) -} +#[path = "runtime_context.rs"] +mod runtime_context; + +use runtime_context::hidpi_scale_from_factor; +pub use runtime_context::{RenderingContextKind, ServoSurfaceSize}; use url::Url; use crate::{ @@ -51,72 +42,6 @@ static SERVO_RUNTIME_STARTED: AtomicBool = AtomicBool::new(false); const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(20); const SCREENSHOT_POLL_INTERVAL: Duration = Duration::from_millis(2); -/// Default upper bound on how long `paint()` will spin the Servo event -/// loop waiting for `notify_new_frame_ready` after dispatching -/// `webview.paint()`. 32 ms is two 60 Hz frames — enough headroom for -/// the paint thread to land a real framebuffer before we read it back, -/// short enough that a stuck paint can't stall the input/render loop. -/// Overridable via `ELY_PAINT_BARRIER_MS`; `0` disables the barrier and -/// restores the pre-T15 "fire and read" behaviour. -const DEFAULT_PAINT_BARRIER_MS: u64 = 32; -const PAINT_BARRIER_POLL_INTERVAL: Duration = Duration::from_millis(2); - -fn paint_barrier_budget() -> Duration { - static BUDGET: OnceLock = OnceLock::new(); - *BUDGET.get_or_init(|| { - let ms = env::var("ELY_PAINT_BARRIER_MS") - .ok() - .and_then(|raw| raw.parse::().ok()) - .unwrap_or(DEFAULT_PAINT_BARRIER_MS); - Duration::from_millis(ms) - }) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct ServoSurfaceSize { - width: u32, - height: u32, -} - -impl ServoSurfaceSize { - #[must_use] - pub fn new(width: u32, height: u32) -> Self { - Self { width: width.max(1), height: height.max(1) } - } - - fn physical(self) -> PhysicalSize { - PhysicalSize { width: self.width, height: self.height } - } -} - -/// Selects the `RenderingContext` implementation each webview gets. -/// -/// `Software` uses Servo's built-in `SoftwareRenderingContext`, which -/// rasterises on the CPU. `Hardware` uses the vendored -/// [`HardwareOffscreenContext`](crate::HardwareOffscreenContext), -/// which rasterises through the real GPU adapter against a -/// `SurfaceType::Generic` offscreen surface. The `Hardware` variant -/// is only available when the `hardware-render` feature is enabled; -/// requesting it without the feature is a configuration error -/// surfaced via `ServoHostError::HardwareRenderUnavailable`. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum RenderingContextKind { - #[default] - Software, - Hardware, -} - -/// Pair of rendering-context handles produced by -/// [`SoftwareServoHost::new_rendering_context`]. The trait-object -/// handle drives Servo's compositor; the concrete hardware handle is -/// kept on the side so the host can call macOS-specific methods -/// (IOSurface mach port extraction) without downcasting. -struct RenderingContextHandles { - rendering_context: Rc, - #[cfg(feature = "hardware-render")] - hardware_context: Option>, -} - pub struct SoftwareServoHost { servo: Servo, default_surface_size: ServoSurfaceSize, @@ -517,42 +442,6 @@ impl SoftwareServoHost { .map_err(|_| ServoHostError::RenderingContextUnavailable) } - fn new_rendering_context( - &self, - size: ServoSurfaceSize, - ) -> Result { - match self.rendering_context_kind { - RenderingContextKind::Software => { - let rendering_context = Rc::new( - servo::SoftwareRenderingContext::new(size.physical()) - .map_err(|_| ServoHostError::RenderingContextUnavailable)?, - ); - rendering_context - .make_current() - .map_err(|_| ServoHostError::RenderingContextNotCurrent)?; - Ok(RenderingContextHandles { - rendering_context, - #[cfg(feature = "hardware-render")] - hardware_context: None, - }) - } - #[cfg(feature = "hardware-render")] - RenderingContextKind::Hardware => { - let hardware = Rc::new( - crate::HardwareOffscreenContext::new(size.physical()) - .map_err(|_| ServoHostError::RenderingContextUnavailable)?, - ); - hardware.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?; - Ok(RenderingContextHandles { - rendering_context: hardware.clone(), - hardware_context: Some(hardware), - }) - } - #[cfg(not(feature = "hardware-render"))] - RenderingContextKind::Hardware => Err(ServoHostError::HardwareRenderUnavailable), - } - } - fn webview(&self, webview_id: &WebViewId) -> Result<&HostWebView, ServoHostError> { self.webviews .get(webview_id) @@ -578,58 +467,4 @@ impl SoftwareServoHost { webview.webview.focus(); Ok(webview) } - - /// Spin Servo's event loop until the webview's delegate observes a - /// fresh `notify_new_frame_ready` callback (i.e. the framebuffer is - /// consistent for readback) or [`paint_barrier_budget`] elapses. The - /// caller is responsible for clearing the pending-frame flag before - /// dispatching `webview.paint()`; otherwise this returns immediately - /// off the *previous* frame and the race is preserved. - /// - /// Returns silently on timeout — `paint()` falls through to - /// `read_rendered_frame` so callers still get whatever pixels the - /// rendering context currently holds. That keeps the fast path open - /// when `ELY_PAINT_BARRIER_MS=0` disables the budget entirely, and - /// matches the pre-T15 behaviour on the (rare) case where Servo - /// can't land a frame inside two refresh intervals. - fn wait_for_paint_completion(&mut self, webview_id: &WebViewId) { - let budget = paint_barrier_budget(); - if budget.is_zero() { - return; - } - let started_at = Instant::now(); - loop { - self.servo.spin_event_loop(); - let ready = self - .webviews - .get(webview_id) - .is_some_and(|webview| webview.delegate.has_pending_frame()); - if ready { - return; - } - if started_at.elapsed() >= budget { - return; - } - thread::sleep(PAINT_BARRIER_POLL_INTERVAL); - } - } - - fn read_rendered_frame( - rendering_context: &dyn RenderingContext, - ) -> Result { - let size = rendering_context.size(); - let width = - i32::try_from(size.width).map_err(|_| ServoHostError::RenderedFrameUnavailable)?; - let height = - i32::try_from(size.height).map_err(|_| ServoHostError::RenderedFrameUnavailable)?; - let frame_rect = DeviceIntRect::from_origin_and_size( - DeviceIntPoint::new(0, 0), - DeviceIntSize::new(width, height), - ); - let image = rendering_context - .read_to_image(frame_rect) - .ok_or(ServoHostError::RenderedFrameUnavailable)?; - - Ok(RenderedFrame::from_rgba_bytes(size.width, size.height, image.into_raw())) - } } diff --git a/crates/ely_servo_host/src/runtime_context.rs b/crates/ely_servo_host/src/runtime_context.rs new file mode 100644 index 0000000..ceeab56 --- /dev/null +++ b/crates/ely_servo_host/src/runtime_context.rs @@ -0,0 +1,182 @@ +use std::{ + env, + rc::Rc, + sync::OnceLock, + thread, + time::{Duration, Instant}, +}; + +use dpi::PhysicalSize; +use euclid::Scale; +use servo::{ + DeviceIndependentPixel, DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePixel, + RenderingContext, +}; + +use super::SoftwareServoHost; +use crate::{RenderedFrame, ServoHostError}; + +const DEFAULT_PAINT_BARRIER_MS: u64 = 32; +const PAINT_BARRIER_POLL_INTERVAL: Duration = Duration::from_millis(2); + +/// Wrap an `f32` scale factor in Servo's typed `Scale`. The clamp guards against `NaN`/`inf` reaching Servo's +/// layout (which assumes a positive finite scale). +pub(super) fn hidpi_scale_from_factor( + scale_factor: f32, +) -> Scale { + let safe = if scale_factor.is_finite() && scale_factor > 0.0 { + scale_factor.clamp(0.5, 5.0) + } else { + 1.0 + }; + Scale::new(safe) +} + +fn paint_barrier_budget() -> Duration { + static BUDGET: OnceLock = OnceLock::new(); + *BUDGET.get_or_init(|| { + let ms = env::var("ELY_PAINT_BARRIER_MS") + .ok() + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(DEFAULT_PAINT_BARRIER_MS); + Duration::from_millis(ms) + }) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ServoSurfaceSize { + width: u32, + height: u32, +} + +impl ServoSurfaceSize { + #[must_use] + pub fn new(width: u32, height: u32) -> Self { + Self { width: width.max(1), height: height.max(1) } + } + + pub(super) fn physical(self) -> PhysicalSize { + PhysicalSize { width: self.width, height: self.height } + } +} + +/// Selects the `RenderingContext` implementation each webview gets. +/// +/// `Software` uses Servo's built-in `SoftwareRenderingContext`, which +/// rasterises on the CPU. `Hardware` uses the vendored +/// [`HardwareOffscreenContext`](crate::HardwareOffscreenContext), +/// which rasterises through the real GPU adapter against a +/// `SurfaceType::Generic` offscreen surface. The `Hardware` variant +/// is only available when the `hardware-render` feature is enabled; +/// requesting it without the feature is a configuration error +/// surfaced via `ServoHostError::HardwareRenderUnavailable`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RenderingContextKind { + #[default] + Software, + Hardware, +} + +/// Pair of rendering-context handles produced by +/// [`SoftwareServoHost::new_rendering_context`]. The trait-object +/// handle drives Servo's compositor; the concrete hardware handle is +/// kept on the side so the host can call macOS-specific methods +/// (IOSurface mach port extraction) without downcasting. +pub(super) struct RenderingContextHandles { + pub(super) rendering_context: Rc, + #[cfg(feature = "hardware-render")] + pub(super) hardware_context: Option>, +} + +impl SoftwareServoHost { + pub(super) fn new_rendering_context( + &self, + size: ServoSurfaceSize, + ) -> Result { + match self.rendering_context_kind { + RenderingContextKind::Software => { + let rendering_context = Rc::new( + servo::SoftwareRenderingContext::new(size.physical()) + .map_err(|_| ServoHostError::RenderingContextUnavailable)?, + ); + rendering_context + .make_current() + .map_err(|_| ServoHostError::RenderingContextNotCurrent)?; + Ok(RenderingContextHandles { + rendering_context, + #[cfg(feature = "hardware-render")] + hardware_context: None, + }) + } + #[cfg(feature = "hardware-render")] + RenderingContextKind::Hardware => { + let hardware = Rc::new( + crate::HardwareOffscreenContext::new(size.physical()) + .map_err(|_| ServoHostError::RenderingContextUnavailable)?, + ); + hardware.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?; + Ok(RenderingContextHandles { + rendering_context: hardware.clone(), + hardware_context: Some(hardware), + }) + } + #[cfg(not(feature = "hardware-render"))] + RenderingContextKind::Hardware => Err(ServoHostError::HardwareRenderUnavailable), + } + } + + /// Spin Servo's event loop until the webview's delegate observes a + /// fresh `notify_new_frame_ready` callback (i.e. the framebuffer is + /// consistent for readback) or [`paint_barrier_budget`] elapses. The + /// caller is responsible for clearing the pending-frame flag before + /// dispatching `webview.paint()`; otherwise this returns immediately + /// off the *previous* frame and the race is preserved. + /// + /// Returns silently on timeout — `paint()` falls through to + /// `read_rendered_frame` so callers still get whatever pixels the + /// rendering context currently holds. That keeps the fast path open + /// when `ELY_PAINT_BARRIER_MS=0` disables the budget entirely, and + /// matches the pre-T15 behaviour on the (rare) case where Servo + /// can't land a frame inside two refresh intervals. + pub(super) fn wait_for_paint_completion(&mut self, webview_id: &ely_domain::WebViewId) { + let budget = paint_barrier_budget(); + if budget.is_zero() { + return; + } + let started_at = Instant::now(); + loop { + self.servo.spin_event_loop(); + let ready = self + .webviews + .get(webview_id) + .is_some_and(|webview| webview.delegate.has_pending_frame()); + if ready { + return; + } + if started_at.elapsed() >= budget { + return; + } + thread::sleep(PAINT_BARRIER_POLL_INTERVAL); + } + } + + pub(super) fn read_rendered_frame( + rendering_context: &dyn RenderingContext, + ) -> Result { + let size = rendering_context.size(); + let width = + i32::try_from(size.width).map_err(|_| ServoHostError::RenderedFrameUnavailable)?; + let height = + i32::try_from(size.height).map_err(|_| ServoHostError::RenderedFrameUnavailable)?; + let frame_rect = DeviceIntRect::from_origin_and_size( + DeviceIntPoint::new(0, 0), + DeviceIntSize::new(width, height), + ); + let image = rendering_context + .read_to_image(frame_rect) + .ok_or(ServoHostError::RenderedFrameUnavailable)?; + + Ok(RenderedFrame::from_rgba_bytes(size.width, size.height, image.into_raw())) + } +}