Split Servo live rendering modules

This commit is contained in:
2026-05-12 23:38:13 -04:00
parent 0db1caad69
commit 316bf6f9a4
7 changed files with 476 additions and 440 deletions
+8 -156
View File
@@ -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<u32>,
scroll_point_y: Option<u32>,
click_x: Option<u32>,
click_y: Option<u32>,
hover_x: Option<u32>,
hover_y: Option<u32>,
typed_text: Option<String>,
site_permissions: Vec<ServoLiveSitePermission>,
},
Poll {
tab_id: String,
},
}
#[derive(Deserialize)]
struct LiveResponse {
error: Option<String>,
frame: Option<LiveFrameReport>,
#[serde(default)]
perf: Option<LiveFramePerfSummary>,
/// 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<LiveSurfaceHandle>,
/// 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<u64>,
}
/// 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<String>,
title: Option<String>,
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`
@@ -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<u32>,
scroll_point_y: Option<u32>,
click_x: Option<u32>,
click_y: Option<u32>,
hover_x: Option<u32>,
hover_y: Option<u32>,
typed_text: Option<String>,
site_permissions: Vec<ServoLiveSitePermission>,
},
Poll {
tab_id: String,
},
}
#[derive(Deserialize)]
pub(super) struct LiveResponse {
pub(super) error: Option<String>,
pub(super) frame: Option<LiveFrameReport>,
#[serde(default)]
pub(super) perf: Option<LiveFramePerfSummary>,
/// 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<LiveSurfaceHandle>,
/// 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<u64>,
}
/// 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<String>,
pub(super) title: Option<String>,
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",
);
}
@@ -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"]
@@ -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<String, HashSet<u64>>,
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<FramePerfSummary>,
outcome: Result<LiveOutcome, LiveSidecarError>,
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<String, LiveSession>,
@@ -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<String, HashSet<u64>>,
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<FramePerfSummary>,
outcome: Result<LiveOutcome, LiveSidecarError>,
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(())
}
+8 -173
View File
@@ -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<f32, DeviceIndependentPixel,
/// DevicePixel>`. 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<f32, DeviceIndependentPixel, DevicePixel> {
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<Duration> = OnceLock::new();
*BUDGET.get_or_init(|| {
let ms = env::var("ELY_PAINT_BARRIER_MS")
.ok()
.and_then(|raw| raw.parse::<u64>().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<u32> {
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<dyn RenderingContext>,
#[cfg(feature = "hardware-render")]
hardware_context: Option<Rc<crate::HardwareOffscreenContext>>,
}
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<RenderingContextHandles, ServoHostError> {
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<RenderedFrame, ServoHostError> {
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()))
}
}
@@ -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<f32, DeviceIndependentPixel,
/// DevicePixel>`. 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<f32, DeviceIndependentPixel, DevicePixel> {
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<Duration> = OnceLock::new();
*BUDGET.get_or_init(|| {
let ms = env::var("ELY_PAINT_BARRIER_MS")
.ok()
.and_then(|raw| raw.parse::<u64>().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<u32> {
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<dyn RenderingContext>,
#[cfg(feature = "hardware-render")]
pub(super) hardware_context: Option<Rc<crate::HardwareOffscreenContext>>,
}
impl SoftwareServoHost {
pub(super) fn new_rendering_context(
&self,
size: ServoSurfaceSize,
) -> Result<RenderingContextHandles, ServoHostError> {
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<RenderedFrame, ServoHostError> {
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()))
}
}