This commit is contained in:
2026-05-18 13:58:36 -04:00
parent d076dad356
commit 68a4507dbe
143 changed files with 15715 additions and 7204 deletions
@@ -1,332 +0,0 @@
use std::{
io::Write,
thread,
time::{Duration, Instant},
};
use ely_domain::TabId;
use ely_servo_host::{
KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PageZoomRequest,
PermissionRequest, ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize,
SoftwareServoHost, TouchTapRequest, WebViewSnapshot, WebViewState,
};
use thiserror::Error;
#[path = "ely_servo_sidecar/args.rs"]
mod args;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[path = "ely_servo_sidecar/iosurface_mach.rs"]
mod iosurface_mach;
#[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/live_session.rs"]
mod live_session;
#[path = "ely_servo_sidecar/perf.rs"]
mod perf;
#[path = "ely_servo_sidecar/report.rs"]
mod report;
use args::{SidecarCommand, SnapshotArgs};
use report::{SnapshotInputChanges, SnapshotReport};
const WAIT_ITERATIONS: usize = 5_000;
const WAIT_INTERVAL: Duration = Duration::from_millis(2);
const RENDER_TIMEOUT: Duration = Duration::from_secs(20);
const VISIBLE_FRAME_SETTLE_TIMEOUT: Duration = Duration::from_millis(700);
const INPUT_SETTLE_TIMEOUT: Duration = Duration::from_millis(700);
fn main() -> Result<(), SidecarError> {
match args::parse_env_command()? {
SidecarCommand::Live(args) => live::run_live(args).map_err(SidecarError::Live),
SidecarCommand::Snapshot(args) => run_snapshot(args),
}
}
#[derive(Debug, Error)]
enum SidecarError {
#[error("timed out rendering {url}: {snapshot:?}")]
RenderTimeout { url: String, snapshot: Box<WebViewSnapshot> },
#[error(transparent)]
Args(#[from] args::SidecarArgsError),
#[error(transparent)]
Host(#[from] ServoHostError),
#[error(transparent)]
Live(#[from] live::LiveSidecarError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> {
std::fs::create_dir_all(&args.profile_data_dir)?;
let mut host = SoftwareServoHost::new_with_config_dir(
ServoSurfaceSize::new(args.width, args.height),
Some(args.profile_data_dir.clone()),
)?;
let tab_id = TabId::new();
let webview_id = host.create_webview(tab_id.clone(), args.profile_id.clone())?;
apply_site_permissions(&mut host, &webview_id, &args)?;
host.set_page_zoom(PageZoomRequest {
webview_id: webview_id.clone(),
zoom_factor: f32::from(args.page_zoom_percent) / 100.0,
})?;
host.navigate(NavigationRequest {
webview_id: webview_id.clone(),
tab_id,
url: args.url.clone(),
})?;
let snapshot = wait_for_frame(&mut host, &webview_id, args.url.as_str())?;
let (snapshot, scroll_changed_frame) =
apply_scroll_if_requested(&mut host, &webview_id, &args, snapshot)?;
let (snapshot, click_changed_frame) =
apply_click_if_requested(&mut host, &webview_id, &args, snapshot)?;
let (snapshot, drag_changed_frame) =
apply_drag_if_requested(&mut host, &webview_id, &args, snapshot)?;
let (snapshot, touch_changed_frame) =
apply_touch_if_requested(&mut host, &webview_id, &args, snapshot)?;
let (snapshot, text_changed_frame) =
apply_text_if_requested(&mut host, &webview_id, &args, snapshot)?;
let frame = host.last_rendered_frame()?;
std::fs::write(&args.rgba_out, frame.rgba_bytes())?;
let mut stdout = std::io::stdout().lock();
serde_json::to_writer(
&mut stdout,
&SnapshotReport::new(
&args,
&snapshot,
&frame,
SnapshotInputChanges {
scroll: scroll_changed_frame,
click: click_changed_frame,
drag: drag_changed_frame,
touch: touch_changed_frame,
text: text_changed_frame,
},
),
)?;
stdout.write_all(b"\n")?;
stdout.flush()?;
std::process::exit(0);
}
fn apply_site_permissions(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
args: &SnapshotArgs,
) -> Result<(), SidecarError> {
for permission in &args.site_permissions {
host.set_permission(
PermissionRequest {
webview_id: webview_id.clone(),
profile_id: args.profile_id.clone(),
origin: permission.origin.clone(),
feature: permission.feature,
},
permission.decision.into(),
)?;
}
Ok(())
}
fn apply_scroll_if_requested(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
args: &SnapshotArgs,
snapshot: WebViewSnapshot,
) -> Result<(WebViewSnapshot, bool), SidecarError> {
if args.scroll_x == 0 && args.scroll_y == 0 {
return Ok((snapshot, false));
}
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.scroll(ScrollRequest {
webview_id: webview_id.clone(),
delta_x: args.scroll_x,
delta_y: args.scroll_y,
point_x: 0,
point_y: 0,
})?;
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
}
fn apply_click_if_requested(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
args: &SnapshotArgs,
snapshot: WebViewSnapshot,
) -> Result<(WebViewSnapshot, bool), SidecarError> {
let Some(click_point) = args.click_point else {
return Ok((snapshot, false));
};
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.click(MouseClickRequest {
webview_id: webview_id.clone(),
x: click_point.x,
y: click_point.y,
})?;
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
}
fn apply_drag_if_requested(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
args: &SnapshotArgs,
snapshot: WebViewSnapshot,
) -> Result<(WebViewSnapshot, bool), SidecarError> {
let Some(drag_points) = args.drag_points else {
return Ok((snapshot, false));
};
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.drag(MouseDragRequest {
webview_id: webview_id.clone(),
from_x: drag_points.from.x,
from_y: drag_points.from.y,
to_x: drag_points.to.x,
to_y: drag_points.to.y,
})?;
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
}
fn apply_touch_if_requested(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
args: &SnapshotArgs,
snapshot: WebViewSnapshot,
) -> Result<(WebViewSnapshot, bool), SidecarError> {
let Some(touch_point) = args.touch_point else {
return Ok((snapshot, false));
};
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.touch_tap(TouchTapRequest {
webview_id: webview_id.clone(),
x: touch_point.x,
y: touch_point.y,
})?;
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
}
fn apply_text_if_requested(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
args: &SnapshotArgs,
snapshot: WebViewSnapshot,
) -> Result<(WebViewSnapshot, bool), SidecarError> {
let Some(typed_text) = args.typed_text.as_ref() else {
return Ok((snapshot, false));
};
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.type_text(KeyboardTextRequest {
webview_id: webview_id.clone(),
text: typed_text.clone(),
})?;
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
}
fn wait_for_frame(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
url: &str,
) -> Result<WebViewSnapshot, SidecarError> {
let started_at = Instant::now();
let mut latest_rendered_snapshot = None;
let mut visible_frame_hash = None;
let mut visible_frame_last_changed_at = None;
for _ in 0..WAIT_ITERATIONS {
if started_at.elapsed() >= RENDER_TIMEOUT {
break;
}
host.tick();
let snapshot = host.snapshot(webview_id)?;
if snapshot.has_pending_frame() {
host.paint(webview_id)?;
}
let snapshot = host.snapshot(webview_id)?;
if let Ok(frame) = host.last_rendered_frame()
&& frame.non_white_pixel_count() > 0
&& frame.content_pixel_count() > 0
{
let current_hash = frame.sample_hash();
if visible_frame_hash != Some(current_hash) {
visible_frame_hash = Some(current_hash);
visible_frame_last_changed_at = Some(Instant::now());
}
if snapshot.state() == &WebViewState::Complete {
return Ok(snapshot);
}
latest_rendered_snapshot = Some(snapshot.clone());
if snapshot.url().is_some()
&& visible_frame_last_changed_at
.is_some_and(|changed_at| changed_at.elapsed() >= VISIBLE_FRAME_SETTLE_TIMEOUT)
{
return Ok(snapshot);
}
}
thread::sleep(WAIT_INTERVAL);
}
if let Some(snapshot) = latest_rendered_snapshot {
return Ok(snapshot);
}
Err(SidecarError::RenderTimeout {
url: url.to_string(),
snapshot: Box::new(host.snapshot(webview_id)?),
})
}
fn wait_for_changed_or_settled_frame(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
previous_frame_hash: u64,
) -> Result<(WebViewSnapshot, bool), SidecarError> {
let started_at = Instant::now();
let mut latest_snapshot = host.snapshot(webview_id)?;
for _ in 0..WAIT_ITERATIONS {
if started_at.elapsed() >= INPUT_SETTLE_TIMEOUT {
break;
}
host.tick();
let snapshot = host.snapshot(webview_id)?;
if snapshot.has_pending_frame() {
host.paint(webview_id)?;
}
latest_snapshot = host.snapshot(webview_id)?;
let changed_frame = host.last_rendered_frame().is_ok_and(|frame| {
frame.non_white_pixel_count() > 0 && frame.sample_hash() != previous_frame_hash
});
if changed_frame {
return Ok((latest_snapshot, true));
}
thread::sleep(WAIT_INTERVAL);
}
Ok((latest_snapshot, false))
}
@@ -1,395 +0,0 @@
use std::{env, num::ParseIntError, path::PathBuf};
use ely_domain::{
DEFAULT_ZOOM_PERCENT, ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature,
UrlText, validate_zoom_percent,
};
use ely_servo_host::RenderingContextKind;
use serde::Deserialize;
use thiserror::Error;
pub(super) enum SidecarCommand {
Live(LiveArgs),
Snapshot(SnapshotArgs),
}
pub(super) struct LiveArgs {
pub(super) profile_data_dir: PathBuf,
pub(super) iosurface_mach_service: Option<String>,
/// Rendering context the host's webviews are built against.
/// Defaults to [`RenderingContextKind::Software`], which keeps
/// the binary's behaviour bit-identical to pre-flag builds.
/// `Hardware` is only accepted when the `hardware-render`
/// feature is compiled in (otherwise the `SoftwareServoHost`
/// constructor returns `HardwareRenderUnavailable`).
pub(super) rendering_context_kind: RenderingContextKind,
}
pub(super) struct SnapshotArgs {
pub(super) url: UrlText,
pub(super) profile_id: ProfileId,
pub(super) profile_data_dir: PathBuf,
pub(super) rgba_out: PathBuf,
pub(super) width: u32,
pub(super) height: u32,
pub(super) scroll_x: i32,
pub(super) scroll_y: i32,
pub(super) page_zoom_percent: u16,
pub(super) click_point: Option<ClickPoint>,
pub(super) drag_points: Option<DragPoints>,
pub(super) touch_point: Option<ClickPoint>,
pub(super) typed_text: Option<String>,
pub(super) site_permissions: Vec<SidecarSitePermission>,
}
#[derive(Clone, Copy)]
pub(super) struct ClickPoint {
pub(super) x: u32,
pub(super) y: u32,
}
#[derive(Clone, Copy)]
pub(super) struct DragPoints {
pub(super) from: ClickPoint,
pub(super) to: ClickPoint,
}
pub(super) struct SidecarSitePermission {
pub(super) origin: SiteOrigin,
pub(super) feature: SitePermissionFeature,
pub(super) decision: SitePermissionDecision,
}
#[derive(Debug, Error)]
pub(super) enum SidecarArgsError {
#[error("missing sidecar command")]
MissingCommand,
#[error("unknown sidecar command: {value}")]
UnknownCommand { value: String },
#[error("missing argument value for {name}")]
MissingArgumentValue { name: &'static str },
#[error("missing required argument: {name}")]
MissingRequiredArgument { name: &'static str },
#[error("unknown argument: {value}")]
UnknownArgument { value: String },
#[error("{name} must be an integer: {value}")]
InvalidInteger {
name: &'static str,
value: String,
#[source]
source: ParseIntError,
},
#[error("{name} must be greater than zero")]
ZeroDimension { name: &'static str },
#[error("--click-x and --click-y must be provided together")]
IncompleteClickPoint,
#[error("--drag-from-x, --drag-from-y, --drag-to-x, and --drag-to-y must be provided together")]
IncompleteDragPoints,
#[error("--touch-x and --touch-y must be provided together")]
IncompleteTouchPoint,
#[error("{name} path is empty")]
EmptyPath { name: &'static str },
#[error("invalid --site-permission JSON: {value}")]
InvalidSitePermissionJson {
value: String,
#[source]
source: serde_json::Error,
},
#[error(
"invalid --rendering-context value: {value:?} (expected \"software\" or \
\"hardware\")"
)]
InvalidRenderingContext { value: String },
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
}
pub(super) fn parse_env_command() -> Result<SidecarCommand, SidecarArgsError> {
parse_command(env::args())
}
fn parse_command(
args: impl IntoIterator<Item = String>,
) -> Result<SidecarCommand, SidecarArgsError> {
let mut args = args.into_iter();
let _program_name = args.next();
let command = args.next().ok_or(SidecarArgsError::MissingCommand)?;
match command.as_str() {
"live" => parse_live_args(args).map(SidecarCommand::Live),
"snapshot" => parse_snapshot_args(args).map(SidecarCommand::Snapshot),
_ => Err(SidecarArgsError::UnknownCommand { value: command }),
}
}
fn parse_live_args(args: impl IntoIterator<Item = String>) -> Result<LiveArgs, SidecarArgsError> {
let mut args = args.into_iter();
let mut profile_data_dir = None;
let mut iosurface_mach_service = None;
let mut rendering_context_kind = RenderingContextKind::default();
while let Some(name) = args.next() {
match name.as_str() {
"--profile-data-dir" => {
profile_data_dir = Some(parse_path(
"--profile-data-dir",
next_argument(&mut args, "--profile-data-dir")?,
)?)
}
"--rendering-context" => {
let value = next_argument(&mut args, "--rendering-context")?;
rendering_context_kind = match value.as_str() {
"software" => RenderingContextKind::Software,
"hardware" => RenderingContextKind::Hardware,
_ => return Err(SidecarArgsError::InvalidRenderingContext { value }),
};
}
"--iosurface-mach-service" => {
iosurface_mach_service =
Some(next_argument(&mut args, "--iosurface-mach-service")?);
}
_ => return Err(SidecarArgsError::UnknownArgument { value: name }),
}
}
Ok(LiveArgs {
profile_data_dir: profile_data_dir
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?,
iosurface_mach_service,
rendering_context_kind,
})
}
fn parse_snapshot_args(
args: impl IntoIterator<Item = String>,
) -> Result<SnapshotArgs, SidecarArgsError> {
let mut args = args.into_iter();
let mut url = None;
let mut profile_id = None;
let mut profile_data_dir = None;
let mut rgba_out = None;
let mut width = None;
let mut height = None;
let mut scroll_x = 0;
let mut scroll_y = 0;
let mut page_zoom_percent = DEFAULT_ZOOM_PERCENT;
let mut click_x = None;
let mut click_y = None;
let mut drag_from_x = None;
let mut drag_from_y = None;
let mut drag_to_x = None;
let mut drag_to_y = None;
let mut touch_x = None;
let mut touch_y = None;
let mut typed_text = None;
let mut site_permissions = Vec::new();
while let Some(name) = args.next() {
match name.as_str() {
"--url" => url = Some(UrlText::parse(next_argument(&mut args, "--url")?)?),
"--profile-id" => {
profile_id = Some(ProfileId::parse(next_argument(&mut args, "--profile-id")?)?)
}
"--profile-data-dir" => {
profile_data_dir = Some(parse_path(
"--profile-data-dir",
next_argument(&mut args, "--profile-data-dir")?,
)?)
}
"--rgba-out" => {
rgba_out = Some(parse_path("--rgba-out", next_argument(&mut args, "--rgba-out")?)?)
}
"--width" => {
width = Some(parse_dimension("--width", next_argument(&mut args, "--width")?)?)
}
"--height" => {
height = Some(parse_dimension("--height", next_argument(&mut args, "--height")?)?)
}
"--scroll-x" => {
scroll_x =
parse_scroll_delta("--scroll-x", next_argument(&mut args, "--scroll-x")?)?
}
"--scroll-y" => {
scroll_y =
parse_scroll_delta("--scroll-y", next_argument(&mut args, "--scroll-y")?)?
}
"--page-zoom-percent" => {
page_zoom_percent = parse_zoom_percent(
"--page-zoom-percent",
next_argument(&mut args, "--page-zoom-percent")?,
)?
}
"--click-x" => {
click_x = Some(parse_click_coordinate(
"--click-x",
next_argument(&mut args, "--click-x")?,
)?)
}
"--click-y" => {
click_y = Some(parse_click_coordinate(
"--click-y",
next_argument(&mut args, "--click-y")?,
)?)
}
"--drag-from-x" => {
drag_from_x = Some(parse_click_coordinate(
"--drag-from-x",
next_argument(&mut args, "--drag-from-x")?,
)?)
}
"--drag-from-y" => {
drag_from_y = Some(parse_click_coordinate(
"--drag-from-y",
next_argument(&mut args, "--drag-from-y")?,
)?)
}
"--drag-to-x" => {
drag_to_x = Some(parse_click_coordinate(
"--drag-to-x",
next_argument(&mut args, "--drag-to-x")?,
)?)
}
"--drag-to-y" => {
drag_to_y = Some(parse_click_coordinate(
"--drag-to-y",
next_argument(&mut args, "--drag-to-y")?,
)?)
}
"--touch-x" => {
touch_x = Some(parse_click_coordinate(
"--touch-x",
next_argument(&mut args, "--touch-x")?,
)?)
}
"--touch-y" => {
touch_y = Some(parse_click_coordinate(
"--touch-y",
next_argument(&mut args, "--touch-y")?,
)?)
}
"--type-text" => typed_text = Some(next_argument(&mut args, "--type-text")?),
"--site-permission" => site_permissions
.push(parse_site_permission(next_argument(&mut args, "--site-permission")?)?),
_ => return Err(SidecarArgsError::UnknownArgument { value: name }),
}
}
let click_point = match (click_x, click_y) {
(Some(x), Some(y)) => Some(ClickPoint { x, y }),
(None, None) => None,
_ => return Err(SidecarArgsError::IncompleteClickPoint),
};
let drag_points = match (drag_from_x, drag_from_y, drag_to_x, drag_to_y) {
(Some(from_x), Some(from_y), Some(to_x), Some(to_y)) => Some(DragPoints {
from: ClickPoint { x: from_x, y: from_y },
to: ClickPoint { x: to_x, y: to_y },
}),
(None, None, None, None) => None,
_ => return Err(SidecarArgsError::IncompleteDragPoints),
};
let touch_point = match (touch_x, touch_y) {
(Some(x), Some(y)) => Some(ClickPoint { x, y }),
(None, None) => None,
_ => return Err(SidecarArgsError::IncompleteTouchPoint),
};
Ok(SnapshotArgs {
url: url.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--url" })?,
profile_id: profile_id
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-id" })?,
profile_data_dir: profile_data_dir
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?,
rgba_out: rgba_out
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--rgba-out" })?,
width: width.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--width" })?,
height: height.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--height" })?,
scroll_x,
scroll_y,
page_zoom_percent,
click_point,
drag_points,
touch_point,
typed_text,
site_permissions,
})
}
#[derive(Deserialize)]
struct SitePermissionArg {
origin: String,
feature: String,
decision: String,
}
fn parse_site_permission(value: String) -> Result<SidecarSitePermission, SidecarArgsError> {
let parsed: SitePermissionArg = serde_json::from_str(&value)
.map_err(|source| SidecarArgsError::InvalidSitePermissionJson { value, source })?;
Ok(SidecarSitePermission {
origin: SiteOrigin::parse(parsed.origin)?,
feature: SitePermissionFeature::parse(parsed.feature.as_str())?,
decision: SitePermissionDecision::parse(parsed.decision.as_str())?,
})
}
fn next_argument(
args: &mut impl Iterator<Item = String>,
name: &'static str,
) -> Result<String, SidecarArgsError> {
args.next().ok_or(SidecarArgsError::MissingArgumentValue { name })
}
fn parse_dimension(name: &'static str, value: String) -> Result<u32, SidecarArgsError> {
let dimension = value.parse::<u32>().map_err(|source| SidecarArgsError::InvalidInteger {
name,
value,
source,
})?;
if dimension == 0 {
return Err(SidecarArgsError::ZeroDimension { name });
}
Ok(dimension)
}
fn parse_scroll_delta(name: &'static str, value: String) -> Result<i32, SidecarArgsError> {
value.parse::<i32>().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source })
}
fn parse_click_coordinate(name: &'static str, value: String) -> Result<u32, SidecarArgsError> {
value.parse::<u32>().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source })
}
fn parse_zoom_percent(name: &'static str, value: String) -> Result<u16, SidecarArgsError> {
let percent = value.parse::<u16>().map_err(|source| SidecarArgsError::InvalidInteger {
name,
value,
source,
})?;
Ok(validate_zoom_percent(percent)?)
}
fn parse_path(name: &'static str, value: String) -> Result<PathBuf, SidecarArgsError> {
if value.trim().is_empty() {
return Err(SidecarArgsError::EmptyPath { name });
}
Ok(PathBuf::from(value))
}
#[cfg(test)]
#[path = "args_tests.rs"]
mod tests;
@@ -1,183 +0,0 @@
use std::{env, path::PathBuf};
use super::{SidecarArgsError, SidecarCommand, parse_command};
use ely_domain::{
DEFAULT_ZOOM_PERCENT, DomainError, ProfileId, SitePermissionDecision, SitePermissionFeature,
};
use ely_servo_host::RenderingContextKind;
#[test]
fn parses_snapshot_profile_identity() -> Result<(), SidecarArgsError> {
let profile_id = ProfileId::new();
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
let args = parse_snapshot_command(&profile_id, profile_data_dir.clone())?;
assert_eq!(args.profile_id, profile_id);
assert_eq!(args.profile_data_dir, profile_data_dir);
assert_eq!(args.page_zoom_percent, DEFAULT_ZOOM_PERCENT);
Ok(())
}
#[test]
fn rejects_invalid_snapshot_profile_id() {
let command = parse_command(
[
"ely_servo_sidecar",
"snapshot",
"--url",
"https://example.com",
"--profile-id",
"profile_invalid",
"--profile-data-dir",
"/tmp/profile",
"--rgba-out",
"/tmp/frame.rgba",
"--width",
"64",
"--height",
"64",
]
.into_iter()
.map(str::to_string),
);
assert!(matches!(command, Err(SidecarArgsError::Domain(DomainError::InvalidEntityId { .. }))));
}
#[test]
fn parses_snapshot_site_permissions() -> Result<(), SidecarArgsError> {
let profile_id = ProfileId::new();
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
command.push("--site-permission".to_string());
command.push(
r#"{"origin":"https://example.com","feature":"camera","decision":"allow-once"}"#
.to_string(),
);
let args = snapshot_args(parse_command(command)?);
assert_eq!(args.site_permissions.len(), 1);
let permission = &args.site_permissions[0];
assert_eq!(permission.origin.as_str(), "https://example.com");
assert_eq!(permission.feature, SitePermissionFeature::Camera);
assert_eq!(permission.decision, SitePermissionDecision::AllowOnce);
Ok(())
}
#[test]
fn parses_snapshot_page_zoom_percent() -> Result<(), SidecarArgsError> {
let profile_id = ProfileId::new();
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
command.push("--page-zoom-percent".to_string());
command.push("125".to_string());
let args = snapshot_args(parse_command(command)?);
assert_eq!(args.page_zoom_percent, 125);
Ok(())
}
#[test]
fn rejects_out_of_range_snapshot_page_zoom_percent() {
let profile_id = ProfileId::new();
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
command.push("--page-zoom-percent".to_string());
command.push("5".to_string());
assert!(matches!(
parse_command(command),
Err(SidecarArgsError::Domain(DomainError::InvalidZoomPercent { value: 5, .. }))
));
}
fn parse_snapshot_command(
profile_id: &ProfileId,
profile_data_dir: PathBuf,
) -> Result<super::SnapshotArgs, SidecarArgsError> {
Ok(snapshot_args(parse_command(snapshot_command_args(profile_id, profile_data_dir))?))
}
fn snapshot_args(command: SidecarCommand) -> super::SnapshotArgs {
match command {
SidecarCommand::Snapshot(args) => args,
SidecarCommand::Live(_) => unreachable!("expected snapshot command"),
}
}
fn snapshot_command_args(profile_id: &ProfileId, profile_data_dir: PathBuf) -> Vec<String> {
[
"ely_servo_sidecar".to_string(),
"snapshot".to_string(),
"--url".to_string(),
"https://example.com".to_string(),
"--profile-id".to_string(),
profile_id.as_str().to_string(),
"--profile-data-dir".to_string(),
profile_data_dir.display().to_string(),
"--rgba-out".to_string(),
"/tmp/frame.rgba".to_string(),
"--width".to_string(),
"64".to_string(),
"--height".to_string(),
"64".to_string(),
]
.into_iter()
.collect()
}
fn parse_live(extra_args: &[&str]) -> Result<super::LiveArgs, SidecarArgsError> {
let base = ["ely_servo_sidecar", "live", "--profile-data-dir", "/tmp/sidecar-live"];
let argv: Vec<String> =
base.iter().chain(extra_args.iter()).map(|s| (*s).to_string()).collect();
let SidecarCommand::Live(args) = parse_command(argv)? else {
return Err(SidecarArgsError::UnknownCommand {
value: "live-extracted-as-snapshot".into(),
});
};
Ok(args)
}
#[test]
fn live_defaults_rendering_context_to_software() -> Result<(), SidecarArgsError> {
let args = parse_live(&[])?;
assert_eq!(args.rendering_context_kind, RenderingContextKind::Software);
Ok(())
}
#[test]
fn live_accepts_explicit_software_rendering_context() -> Result<(), SidecarArgsError> {
let args = parse_live(&["--rendering-context", "software"])?;
assert_eq!(args.rendering_context_kind, RenderingContextKind::Software);
Ok(())
}
#[test]
fn live_accepts_explicit_hardware_rendering_context() -> Result<(), SidecarArgsError> {
let args = parse_live(&["--rendering-context", "hardware"])?;
assert_eq!(args.rendering_context_kind, RenderingContextKind::Hardware);
Ok(())
}
#[test]
fn live_accepts_iosurface_mach_service_name() -> Result<(), SidecarArgsError> {
let args = parse_live(&["--iosurface-mach-service", "com.ely.test.iosurface"])?;
assert_eq!(args.iosurface_mach_service.as_deref(), Some("com.ely.test.iosurface"));
Ok(())
}
#[test]
fn live_rejects_unknown_rendering_context_value() {
assert!(matches!(
parse_live(&["--rendering-context", "gpu"]),
Err(SidecarArgsError::InvalidRenderingContext { value }) if value == "gpu"
));
}
#[test]
fn live_requires_rendering_context_value() {
assert!(matches!(
parse_live(&["--rendering-context"]),
Err(SidecarArgsError::MissingArgumentValue { name: "--rendering-context" })
));
}
@@ -1,133 +0,0 @@
use std::{ffi::CString, mem, time::Duration};
use mach2::{
bootstrap::{bootstrap_look_up, bootstrap_port},
kern_return::KERN_SUCCESS,
mach_port::mach_port_deallocate,
message::{
MACH_MSG_SUCCESS, MACH_MSG_TYPE_COPY_SEND, MACH_MSG_TYPE_MOVE_SEND, MACH_MSGH_BITS,
MACH_MSGH_BITS_COMPLEX, MACH_SEND_MSG, MACH_SEND_TIMEOUT, mach_msg, mach_msg_body_t,
mach_msg_header_t, mach_msg_port_descriptor_t,
},
port::{MACH_PORT_NULL, mach_port_t},
traps::mach_task_self,
};
use thiserror::Error;
use super::live_protocol::{LiveOutcome, LiveSidecarError};
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
const SEND_TIMEOUT: Duration = Duration::from_secs(1);
pub(super) struct IOSurfaceMachSender {
send_port: mach_port_t,
}
#[derive(Debug, Error)]
pub(super) enum IOSurfaceMachError {
#[error("Mach service name contains an interior nul byte")]
InvalidServiceName,
#[error("bootstrap_look_up returned {code}")]
LookupService { code: i32 },
#[error("mach_msg send returned {code}")]
Send { code: i32 },
}
impl IOSurfaceMachSender {
pub(super) fn connect(service_name: &str) -> Result<Self, IOSurfaceMachError> {
let service_name =
CString::new(service_name).map_err(|_| IOSurfaceMachError::InvalidServiceName)?;
let mut send_port = MACH_PORT_NULL;
#[expect(unsafe_code)]
let result =
unsafe { bootstrap_look_up(bootstrap_port, service_name.as_ptr(), &mut send_port) };
if result != KERN_SUCCESS {
return Err(IOSurfaceMachError::LookupService { code: result });
}
Ok(Self { send_port })
}
pub(super) fn send_surface_port(
&mut self,
surface_id: u64,
mach_port: mach_port_t,
) -> Result<(), IOSurfaceMachError> {
let mut message = IOSurfacePortMessage {
header: mach_msg_header_t {
msgh_bits: MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0) | MACH_MSGH_BITS_COMPLEX,
msgh_size: mem::size_of::<IOSurfacePortMessage>() as u32,
msgh_remote_port: self.send_port,
msgh_local_port: MACH_PORT_NULL,
msgh_voucher_port: MACH_PORT_NULL,
msgh_id: IOSURFACE_PORT_MESSAGE_ID,
},
body: mach_msg_body_t { msgh_descriptor_count: 1 },
surface_port: mach_msg_port_descriptor_t::new(mach_port, MACH_MSG_TYPE_MOVE_SEND),
surface_id,
};
#[expect(unsafe_code)]
let result = unsafe {
mach_msg(
&mut message.header,
MACH_SEND_MSG | MACH_SEND_TIMEOUT,
message.header.msgh_size,
0,
MACH_PORT_NULL,
timeout_millis(SEND_TIMEOUT),
MACH_PORT_NULL,
)
};
if result != MACH_MSG_SUCCESS {
destroy_message(&mut message);
return Err(IOSurfaceMachError::Send { code: result });
}
Ok(())
}
}
impl Drop for IOSurfaceMachSender {
fn drop(&mut self) {
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
unsafe {
let _ = mach_port_deallocate(task, self.send_port);
}
}
}
pub(super) fn send_surface_port_if_needed(
sender: Option<&mut IOSurfaceMachSender>,
outcome: &mut Result<LiveOutcome, LiveSidecarError>,
) {
let (Some(sender), Ok(live_outcome)) = (sender, outcome.as_ref()) else {
return;
};
let Some(handle) = live_outcome.response.surface_handle else {
return;
};
if let Err(error) = sender.send_surface_port(handle.surface_id, handle.mach_port_name) {
*outcome = Err(LiveSidecarError::IOSurfaceMach(error));
}
}
#[repr(C)]
struct IOSurfacePortMessage {
header: mach_msg_header_t,
body: mach_msg_body_t,
surface_port: mach_msg_port_descriptor_t,
surface_id: u64,
}
fn timeout_millis(timeout: Duration) -> u32 {
u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(1)
}
fn destroy_message(message: &mut IOSurfacePortMessage) {
#[expect(unsafe_code)]
unsafe {
mach2::message::mach_msg_destroy(&mut message.header);
}
}
@@ -1,476 +0,0 @@
use std::{
collections::{HashMap, HashSet},
fs,
io::{self, BufRead},
time::Instant,
};
use ely_domain::{ProfileId, TabId, UrlText};
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
use ely_servo_host::ServoHostError;
use ely_servo_host::{
IOSurfaceIdentity, NavigationRequest, RenderingContextKind, ServoHost, ServoSurfaceSize,
SoftwareServoHost,
};
use super::args::LiveArgs;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
use super::iosurface_mach::{IOSurfaceMachSender, send_surface_port_if_needed};
use super::live_output::{populate_surface_fields, write_outcome};
pub(super) use super::live_protocol::LiveSidecarError;
use super::live_protocol::{LiveFrameReport, LiveOutcome, LiveRequest, PartialFrameTimings};
use super::live_session::{
LiveInput, LiveSession, apply_input, apply_layout, apply_permissions, ensure_session,
};
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 = true;
let require_client_ready_surfaces = iosurface_mach_service.is_some();
fs::create_dir_all(&profile_data_dir)?;
let context_label = rendering_context_label(rendering_context_kind);
let mut host = SoftwareServoHost::new_with_config_dir_and_kind(
ServoSurfaceSize::new(1, 1),
Some(profile_data_dir),
rendering_context_kind,
)?;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let mut iosurface_mach_sender =
iosurface_mach_service.as_deref().map(IOSurfaceMachSender::connect).transpose()?;
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
let _ = iosurface_mach_service;
let mut sessions = HashMap::new();
let mut perf =
FramePerfAggregator::new(context_label, FramePerfAggregator::DEFAULT_WINDOW_SIZE);
let mut pending_summary: Option<FramePerfSummary> = None;
let mut published_surface_ids: HashMap<String, HashSet<IOSurfaceIdentity>> = HashMap::new();
let stdin = io::stdin();
let mut stdout = io::stdout().lock();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
// `frame_started_at` is the honest start of the end-to-end
// frame: a request just arrived and we're about to do
// everything required to put bytes back on the pipe. The
// matching stop is the `stdout.flush()` inside
// `write_outcome`.
let frame_started_at = Instant::now();
let outcome = match serde_json::from_str::<LiveRequest>(&line) {
Ok(request) => handle_request(
&mut host,
&mut sessions,
&mut published_surface_ids,
rendering_context_kind,
publish_readback_surface_fields,
require_client_ready_surfaces,
request,
),
Err(error) => Err(LiveSidecarError::Json(error)),
};
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let outcome = {
let mut outcome = outcome;
send_surface_port_if_needed(iosurface_mach_sender.as_mut(), &mut outcome);
outcome
};
write_outcome(&mut stdout, &mut perf, &mut pending_summary, outcome, frame_started_at)?;
}
Ok(())
}
const fn rendering_context_label(kind: RenderingContextKind) -> &'static str {
match kind {
RenderingContextKind::Software => "software",
RenderingContextKind::Hardware => "hardware",
}
}
fn handle_request(
host: &mut SoftwareServoHost,
sessions: &mut HashMap<String, LiveSession>,
published_surface_ids: &mut HashMap<String, HashSet<IOSurfaceIdentity>>,
rendering_context_kind: RenderingContextKind,
publish_readback_surface_fields: bool,
require_client_ready_surfaces: bool,
request: LiveRequest,
) -> Result<LiveOutcome, LiveSidecarError> {
match request {
LiveRequest::Ensure {
tab_id,
profile_id,
url,
width,
height,
page_zoom_percent,
device_pixel_ratio,
scroll_delta_x,
scroll_delta_y,
scroll_point_x,
scroll_point_y,
click_x,
click_y,
hover_x,
hover_y,
typed_text,
site_permissions,
ready_surface_ids,
} => {
let tab = TabId::parse(tab_id.clone())?;
let profile = ProfileId::parse(profile_id)?;
let url = UrlText::parse(url)?;
let session =
ensure_session(host, sessions, tab_id.clone(), &tab, &profile, width, height)?;
if apply_layout(host, session, width, height, page_zoom_percent, device_pixel_ratio)? {
session.awaiting_visible_frame = true;
}
apply_permissions(host, session, &profile, site_permissions)?;
if session.requested_url != url.as_str() {
host.navigate(NavigationRequest {
webview_id: session.webview_id.clone(),
tab_id: tab,
url: url.clone(),
})?;
session.requested_url = url.as_str().to_string();
session.scroll_x = 0;
session.scroll_y = 0;
session.awaiting_visible_frame = true;
// New URL: the previous tab's pixels are no longer
// valid evidence that "we have visible content"; let
// the gate skip blank loading frames again.
session.ever_visible_frame = false;
}
let input = LiveInput {
scroll_delta_x,
scroll_delta_y,
scroll_point_x,
scroll_point_y,
click_x,
click_y,
hover_x,
hover_y,
typed_text,
};
if apply_input(host, session, input)? {
// The app tick calls this sidecar synchronously from
// GPUI's update path. Mark that a fresh frame is
// desired, then let poll_frame take one event-loop
// step; a later 16 ms app tick will poll again if
// Servo has not painted yet.
session.awaiting_visible_frame = true;
}
let webview_id = session.webview_id.clone();
let mut outcome = poll_frame(
host,
session,
rendering_context_kind,
payloadless_readiness(
&tab_id,
published_surface_ids,
&ready_surface_ids,
require_client_ready_surfaces,
),
)?;
populate_surface_fields(
host,
&webview_id,
&tab_id,
published_surface_ids,
publish_readback_surface_fields,
&mut outcome,
);
Ok(outcome)
}
LiveRequest::Poll { tab_id, ready_surface_ids } => {
let Some(session) = sessions.get_mut(&tab_id) else {
return Ok(LiveOutcome::empty());
};
let webview_id = session.webview_id.clone();
let mut outcome = poll_frame(
host,
session,
rendering_context_kind,
payloadless_readiness(
&tab_id,
published_surface_ids,
&ready_surface_ids,
require_client_ready_surfaces,
),
)?;
populate_surface_fields(
host,
&webview_id,
&tab_id,
published_surface_ids,
publish_readback_surface_fields,
&mut outcome,
);
Ok(outcome)
}
LiveRequest::Close { tab_id } => {
if let Some(session) = sessions.remove(&tab_id) {
host.close_webview(&session.webview_id);
}
published_surface_ids.remove(&tab_id);
Ok(LiveOutcome::empty())
}
}
}
fn poll_frame(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
rendering_context_kind: RenderingContextKind,
readiness: PayloadlessReadiness<'_>,
) -> Result<LiveOutcome, LiveSidecarError> {
host.tick();
let snapshot = host.snapshot(&session.webview_id)?;
let has_pending_frame = snapshot.has_pending_frame();
if !should_paint_live_frame(has_pending_frame, session.awaiting_visible_frame) {
return Ok(LiveOutcome::empty());
}
let (outcome, has_visible_content) =
paint_pending_frame(host, session, rendering_context_kind, readiness, has_pending_frame)?;
if has_visible_content {
session.awaiting_visible_frame = false;
session.ever_visible_frame = true;
return Ok(outcome);
}
if !session.awaiting_visible_frame {
return Ok(outcome);
}
Ok(LiveOutcome::empty())
}
fn should_paint_live_frame(has_pending_frame: bool, awaiting_visible_frame: bool) -> bool {
has_pending_frame || awaiting_visible_frame
}
fn paint_pending_frame(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
rendering_context_kind: RenderingContextKind,
readiness: PayloadlessReadiness<'_>,
has_pending_frame: bool,
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
let _ = readiness;
match rendering_context_kind {
RenderingContextKind::Software => paint_readback_frame(host, session, !has_pending_frame),
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
RenderingContextKind::Hardware => {
paint_hardware_surface_frame(host, session, readiness, has_pending_frame)
}
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
RenderingContextKind::Hardware => paint_readback_frame(host, session, !has_pending_frame),
}
}
fn paint_readback_frame(
host: &mut SoftwareServoHost,
session: &LiveSession,
wait_for_completion: bool,
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
let paint_started_at = Instant::now();
host.paint_with_readback(&session.webview_id, wait_for_completion)?;
let snapshot = host.snapshot(&session.webview_id)?;
let frame = host.last_rendered_frame()?;
let paint_ns = elapsed_ns(paint_started_at);
let encode_started_at = Instant::now();
let has_visible_content = session.ever_visible_frame
|| (frame.non_white_pixel_count() > 0 && frame.content_pixel_count() > 0);
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio());
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 paint_hardware_surface_frame(
host: &mut SoftwareServoHost,
session: &LiveSession,
readiness: PayloadlessReadiness<'_>,
has_pending_frame: bool,
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
if !session.ever_visible_frame {
return paint_initial_hardware_surface_frame(host, session, !has_pending_frame);
}
if !payloadless_surface_pool_ready(readiness, session.width, session.height) {
return paint_readback_frame(host, session, !has_pending_frame);
}
let (outcome, _) = paint_hardware_surface_report(host, session, !has_pending_frame)?;
Ok((outcome, true))
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn paint_initial_hardware_surface_frame(
host: &mut SoftwareServoHost,
session: &LiveSession,
wait_for_completion: bool,
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
let paint_started_at = Instant::now();
host.paint_with_readback(&session.webview_id, wait_for_completion)?;
let snapshot = host.snapshot(&session.webview_id)?;
let frame = host.last_rendered_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;
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 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(
readiness: PayloadlessReadiness<'_>,
width: u32,
height: u32,
) -> bool {
let Some(published) = readiness.published_surface_ids.get(readiness.tab_id) else {
return false;
};
let matching = published
.iter()
.filter(|identity| identity.width == width && identity.height == height)
.copied()
.collect::<Vec<_>>();
if matching.is_empty() {
return false;
}
!readiness.require_client_ready_surfaces
|| matching
.iter()
.any(|identity| readiness.ready_surface_ids.contains(&identity.surface_id))
}
#[derive(Clone, Copy)]
struct PayloadlessReadiness<'a> {
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
tab_id: &'a str,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
published_surface_ids: &'a HashMap<String, HashSet<IOSurfaceIdentity>>,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
ready_surface_ids: &'a [u64],
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
require_client_ready_surfaces: bool,
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
_marker: std::marker::PhantomData<&'a ()>,
}
fn payloadless_readiness<'a>(
tab_id: &'a str,
published_surface_ids: &'a HashMap<String, HashSet<IOSurfaceIdentity>>,
ready_surface_ids: &'a [u64],
require_client_ready_surfaces: bool,
) -> PayloadlessReadiness<'a> {
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
{
PayloadlessReadiness {
tab_id,
published_surface_ids,
ready_surface_ids,
require_client_ready_surfaces,
}
}
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
{
let _ = (tab_id, published_surface_ids, ready_surface_ids, require_client_ready_surfaces);
PayloadlessReadiness { _marker: std::marker::PhantomData }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn awaiting_visible_frame_forces_paint_without_pending_flag() {
assert!(should_paint_live_frame(false, true));
}
#[test]
fn idle_poll_waits_for_pending_frame() {
assert!(!should_paint_live_frame(false, false));
assert!(should_paint_live_frame(true, false));
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[test]
fn payloadless_pool_accepts_one_client_ready_surface() {
let published = published_identities([identity(7, 800, 600), identity(8, 800, 600)]);
assert!(!payloadless_surface_pool_ready(readiness(&published, &[], true), 800, 600));
assert!(payloadless_surface_pool_ready(readiness(&published, &[7], true), 800, 600));
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[test]
fn payloadless_pool_uses_published_surfaces_for_no_mach_clients() {
let published = published_identities([identity(7, 800, 600), identity(8, 800, 600)]);
assert!(payloadless_surface_pool_ready(readiness(&published, &[], false), 800, 600));
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn readiness<'a>(
published_surface_ids: &'a HashMap<String, HashSet<IOSurfaceIdentity>>,
ready_surface_ids: &'a [u64],
require_client_ready_surfaces: bool,
) -> PayloadlessReadiness<'a> {
PayloadlessReadiness {
tab_id: "tab",
published_surface_ids,
ready_surface_ids,
require_client_ready_surfaces,
}
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn published_identities(
identities: [IOSurfaceIdentity; 2],
) -> HashMap<String, HashSet<IOSurfaceIdentity>> {
let mut published = HashMap::new();
published.insert("tab".to_string(), identities.into_iter().collect());
published
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn identity(surface_id: u64, width: u32, height: u32) -> IOSurfaceIdentity {
IOSurfaceIdentity { surface_id, width, height }
}
}
@@ -1,348 +0,0 @@
use std::{
collections::{HashMap, HashSet},
io::Write,
time::{Duration, Instant},
};
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
use ely_servo_host::IOSurfaceHandle;
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`. Readback
/// warm-up frames publish IOSurface handles so the app can import them
/// on its dedicated importer thread before steady-state payloadless
/// frames select the rotating surface ids. Two pieces of state ride out
/// together:
///
/// * `current_surface_id` — set on every payload-bearing hardware
/// 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<IOSurfaceIdentity>>,
publish_readback_surface_fields: bool,
outcome: &mut LiveOutcome,
) {
if outcome.response.frame.is_none() {
return;
}
if outcome.frame.is_some() && !publish_readback_surface_fields {
return;
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
{
let Ok(Some(identity)) = host.peek_iosurface_identity(webview_id) else {
return;
};
if let Err(message) = require_report_matches_surface_identity(outcome, identity) {
*outcome = LiveOutcome::error(message);
return;
}
let handle = if surface_has_been_published(published_surface_ids, tab_id, identity) {
None
} else {
host.current_iosurface_handle(webview_id).ok().flatten()
};
let publication = surface_publication_for(published_surface_ids, tab_id, identity, handle);
outcome.response.current_surface_id = publication.current_surface_id;
outcome.response.surface_handle = publication.surface_handle;
}
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
{
let _ = (host, webview_id, tab_id, published_surface_ids, publish_readback_surface_fields);
}
}
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
fn surface_has_been_published(
published_surface_ids: &HashMap<String, HashSet<IOSurfaceIdentity>>,
tab_id: &str,
identity: IOSurfaceIdentity,
) -> bool {
published_surface_ids.get(tab_id).is_some_and(|published| published.contains(&identity))
}
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
#[derive(Clone, Copy)]
struct SurfacePublication {
current_surface_id: Option<u64>,
surface_handle: Option<IOSurfaceHandle>,
}
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
fn surface_publication_for(
published_surface_ids: &mut HashMap<String, HashSet<IOSurfaceIdentity>>,
tab_id: &str,
identity: IOSurfaceIdentity,
handle: Option<IOSurfaceHandle>,
) -> SurfacePublication {
if surface_has_been_published(published_surface_ids, tab_id, identity) {
return SurfacePublication {
current_surface_id: Some(identity.surface_id),
surface_handle: None,
};
}
let Some(handle) = handle.filter(|handle| handle_matches_identity(*handle, identity)) else {
return SurfacePublication { current_surface_id: None, surface_handle: None };
};
published_surface_ids
.entry(tab_id.to_string())
.or_default()
.insert(IOSurfaceIdentity::from_handle(handle));
SurfacePublication { current_surface_id: Some(handle.surface_id), surface_handle: Some(handle) }
}
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
fn handle_matches_identity(handle: IOSurfaceHandle, identity: IOSurfaceIdentity) -> bool {
handle.surface_id == identity.surface_id
&& handle.width == identity.width
&& handle.height == identity.height
}
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
fn require_report_matches_surface_identity(
outcome: &LiveOutcome,
identity: IOSurfaceIdentity,
) -> Result<(), String> {
let Some(frame) = outcome.response.frame.as_ref() else {
return Ok(());
};
if frame.width == identity.width && frame.height == identity.height {
return Ok(());
}
Err(format!(
"servo hardware surface size {}x{} did not match frame report {}x{}",
identity.width, identity.height, frame.width, frame.height,
))
}
/// 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.response.frame.is_some();
if let Some(summary) = pending_summary.take() {
outcome.response.perf = Some(summary);
}
// 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;
}
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(())
}
#[cfg(test)]
mod tests {
use std::{collections::HashMap, error::Error, time::Instant};
use ely_servo_host::{IOSurfaceHandle, IOSurfaceIdentity};
use super::super::{
live_protocol::{LiveFrameReport, LiveOutcome, PartialFrameTimings},
perf::FramePerfAggregator,
};
use super::{require_report_matches_surface_identity, surface_publication_for, write_outcome};
#[test]
fn unpublished_surface_without_handle_leaves_selector_empty() {
let mut published = HashMap::new();
let publication =
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), None);
assert_eq!(publication.current_surface_id, None);
assert!(publication.surface_handle.is_none());
assert!(published.is_empty());
}
#[test]
fn unpublished_surface_with_matching_handle_publishes_selector_and_handle() {
let mut published = HashMap::new();
let handle = handle(7, 800, 600);
let publication =
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(handle));
assert_eq!(publication.current_surface_id, Some(7));
assert_eq!(publication.surface_handle, Some(handle));
assert!(published.get("tab-1").is_some_and(|ids| ids.contains(&identity(7, 800, 600))));
}
#[test]
fn published_surface_reuses_selector_without_republishing_handle() {
let mut published = HashMap::new();
let handle = handle(7, 800, 600);
let _ =
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(handle));
let publication =
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), None);
assert_eq!(publication.current_surface_id, Some(7));
assert!(publication.surface_handle.is_none());
}
#[test]
fn same_surface_id_with_changed_dimensions_republishes_handle() {
let mut published = HashMap::new();
let initial = handle(7, 800, 600);
let resized = handle(7, 1024, 768);
let _ =
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(initial));
let publication =
surface_publication_for(&mut published, "tab-1", identity(7, 1024, 768), Some(resized));
assert_eq!(publication.current_surface_id, Some(7));
assert_eq!(publication.surface_handle, Some(resized));
}
#[test]
fn hardware_report_mismatch_is_reported() -> Result<(), Box<dyn Error>> {
let outcome = LiveOutcome::from_report(
report_with_size(2180, 1586),
PartialFrameTimings { paint_ns: 1_000, encode_ns: 2_000 },
);
let error = match require_report_matches_surface_identity(&outcome, identity(7, 2168, 1566))
{
Ok(()) => return Err("mismatched IOSurface dimensions must be reported".into()),
Err(error) => error,
};
assert_eq!(
error,
"servo hardware surface size 2168x1566 did not match frame report 2180x1586",
);
Ok(())
}
#[test]
fn mismatched_handle_leaves_surface_unpublished() {
let mut published = HashMap::new();
let publication = surface_publication_for(
&mut published,
"tab-1",
identity(7, 800, 600),
Some(handle(8, 800, 600)),
);
assert_eq!(publication.current_surface_id, None);
assert!(publication.surface_handle.is_none());
assert!(published.is_empty());
}
#[test]
fn payloadless_surface_report_records_perf_and_writes_no_rgba() -> Result<(), Box<dyn Error>> {
let mut outcome = LiveOutcome::from_report(
report_with_byte_count(16),
PartialFrameTimings { paint_ns: 1_000, encode_ns: 2_000 },
);
outcome.response.current_surface_id = Some(7);
let mut stdout = Vec::new();
let mut perf = FramePerfAggregator::new("hardware", 1);
let mut pending_summary = None;
write_outcome(&mut stdout, &mut perf, &mut pending_summary, Ok(outcome), Instant::now())?;
let Some(newline_index) = stdout.iter().position(|byte| *byte == b'\n') else {
return Err("response newline missing".into());
};
let line = std::str::from_utf8(&stdout[..newline_index])?;
let response: serde_json::Value = serde_json::from_str(line)?;
let rgba_byte_count = response
.get("frame")
.and_then(|frame| frame.get("rgba_byte_count"))
.and_then(serde_json::Value::as_u64);
assert_eq!(rgba_byte_count, Some(0));
assert!(stdout[newline_index + 1..].is_empty());
assert!(pending_summary.is_some());
Ok(())
}
fn identity(surface_id: u64, width: u32, height: u32) -> IOSurfaceIdentity {
IOSurfaceIdentity { surface_id, width, height }
}
fn handle(surface_id: u64, width: u32, height: u32) -> IOSurfaceHandle {
IOSurfaceHandle { mach_port_name: 42, surface_id, width, height }
}
fn report_with_byte_count(rgba_byte_count: usize) -> LiveFrameReport {
let mut report = report_with_size(2, 2);
report.rgba_byte_count = rgba_byte_count;
report
}
fn report_with_size(width: u32, height: u32) -> LiveFrameReport {
LiveFrameReport {
loaded_url: Some("https://example.com/".to_string()),
title: Some("Example".to_string()),
state: "complete",
width,
height,
device_pixel_ratio: 1.0,
css_viewport_width: width,
css_viewport_height: height,
rgba_byte_count: 0,
non_white_pixel_count: 0,
content_pixel_count: 0,
sample_hash: 0,
}
}
}
@@ -1,300 +0,0 @@
//! Wire types for the sidecar live loop. Split out of `live.rs` to
//! keep the hot loop and protocol surface in separate files.
use std::io;
use ely_servo_host::{
IOSurfaceHandle, RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
use super::iosurface_mach::IOSurfaceMachError;
use super::perf::FramePerfSummary;
#[derive(Deserialize)]
#[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,
/// Display scale factor reported by the host's window
/// (1.0 standard, 2.0 Retina). The sidecar plumbs this into
/// Servo's `WebView::set_hidpi_scale_factor` so CSS layout
/// happens at logical-pixel dimensions instead of physical.
/// Defaults to 1.0 for backward compatibility if a client
/// (e.g. the live perf bench) omits the field.
#[serde(default = "default_device_pixel_ratio")]
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>,
#[serde(default)]
hover_x: Option<u32>,
#[serde(default)]
hover_y: Option<u32>,
typed_text: Option<String>,
site_permissions: Vec<LiveSitePermission>,
#[serde(default)]
ready_surface_ids: Vec<u64>,
},
Poll {
tab_id: String,
#[serde(default)]
ready_surface_ids: Vec<u64>,
},
Close {
tab_id: String,
},
}
fn default_device_pixel_ratio() -> f32 {
1.0
}
#[derive(Deserialize)]
pub(super) struct LiveSitePermission {
pub origin: String,
pub feature: String,
pub decision: String,
}
/// Partial stage timings captured inside `poll_frame` before the
/// write phase. Combined with the write-stage duration measured by
/// `write_outcome` to form a full set of frame timings.
#[derive(Clone, Copy, Debug)]
pub(super) struct PartialFrameTimings {
pub paint_ns: u64,
pub encode_ns: u64,
}
/// A response plus an optional software RGBA payload and partial stage
/// timings. Software frames carry `RenderedFrame` so the write step
/// can stream its existing rgba slice straight onto the pipe; hardware
/// surface frames carry only a `LiveFrameReport`.
pub(super) struct LiveOutcome {
pub response: LiveResponse,
pub frame: Option<RenderedFrame>,
pub partial_timings: Option<PartialFrameTimings>,
}
impl LiveOutcome {
pub fn empty() -> Self {
Self { response: LiveResponse::empty(), frame: None, partial_timings: None }
}
pub fn error(message: String) -> Self {
Self { response: LiveResponse::error(message), frame: None, partial_timings: None }
}
pub fn from_frame(
report: LiveFrameReport,
frame: RenderedFrame,
partial_timings: PartialFrameTimings,
) -> Self {
Self {
response: LiveResponse::frame(report),
frame: Some(frame),
partial_timings: Some(partial_timings),
}
}
#[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),
frame: None,
partial_timings: Some(partial_timings),
}
}
}
#[derive(Serialize)]
pub(super) struct LiveResponse {
pub error: Option<String>,
pub frame: Option<LiveFrameReport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub perf: Option<FramePerfSummary>,
/// Populated on the first frame the sidecar emits for a given
/// surface — initial paint, after a resize, or whenever surfman
/// rotates its swap chain to a surface we haven't seen yet. The
/// receiver imports the IOSurface (via
/// `IOSurfaceLookupFromMachPort`) once per `surface_id` and caches
/// the resulting Metal texture. Always `None` on the software
/// path.
#[serde(skip_serializing_if = "Option::is_none")]
pub surface_handle: Option<IOSurfaceHandle>,
/// Populated on every hardware paint frame. Tells the receiver
/// which previously-imported IOSurface to sample THIS frame. The
/// surfman attached swap chain rotates between front/back
/// surfaces, so this id alternates between the values the receiver
/// has already imported. Always `None` on the software path.
#[serde(skip_serializing_if = "Option::is_none")]
pub current_surface_id: Option<u64>,
}
impl LiveResponse {
fn empty() -> Self {
Self {
error: None,
frame: None,
perf: None,
surface_handle: None,
current_surface_id: None,
}
}
fn frame(frame: LiveFrameReport) -> Self {
Self {
error: None,
frame: Some(frame),
perf: None,
surface_handle: None,
current_surface_id: None,
}
}
fn error(message: String) -> Self {
Self {
error: Some(message),
frame: None,
perf: None,
surface_handle: None,
current_surface_id: None,
}
}
}
#[derive(Serialize)]
pub(super) struct LiveFrameReport {
pub loaded_url: Option<String>,
pub title: Option<String>,
pub state: &'static str,
pub width: u32,
pub height: u32,
pub device_pixel_ratio: f32,
pub css_viewport_width: u32,
pub css_viewport_height: u32,
pub rgba_byte_count: usize,
pub non_white_pixel_count: u64,
pub content_pixel_count: u64,
pub sample_hash: u64,
}
impl LiveFrameReport {
pub fn new(snapshot: &WebViewSnapshot, frame: &RenderedFrame, device_pixel_ratio: f32) -> Self {
let (css_viewport_width, css_viewport_height) =
css_viewport_size(frame.width(), frame.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: frame.width(),
height: frame.height(),
device_pixel_ratio,
css_viewport_width,
css_viewport_height,
rgba_byte_count: frame.rgba_bytes().len(),
non_white_pixel_count: frame.non_white_pixel_count(),
content_pixel_count: frame.content_pixel_count(),
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) {
let dpr = if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 {
device_pixel_ratio
} else {
1.0
};
(
((width as f32) / dpr).round().max(1.0) as u32,
((height as f32) / dpr).round().max(1.0) as u32,
)
}
fn state_label(state: &WebViewState) -> &'static str {
match state {
WebViewState::Created => "created",
WebViewState::Loading => "loading",
WebViewState::Complete => "complete",
WebViewState::Sleeping => "sleeping",
WebViewState::Crashed => "crashed",
}
}
#[derive(Debug, Error)]
pub(super) enum LiveSidecarError {
#[error("live session is unavailable after creation")]
SessionUnavailable,
#[error("scroll input requires both scroll_point_x and scroll_point_y")]
IncompleteScrollPoint,
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
#[error(transparent)]
Host(#[from] ServoHostError),
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[error(transparent)]
IOSurfaceMach(#[from] IOSurfaceMachError),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn close_request_deserializes_from_wire() -> Result<(), serde_json::Error> {
let request =
serde_json::from_str::<LiveRequest>(r#"{"type":"close","tab_id":"tab-live-close"}"#)?;
assert!(matches!(
request,
LiveRequest::Close { tab_id } if tab_id == "tab-live-close"
));
Ok(())
}
}
@@ -1,240 +0,0 @@
use std::collections::HashMap;
use ely_domain::{DEFAULT_ZOOM_PERCENT, ProfileId, TabId};
use ely_servo_host::{
KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, PageZoomRequest, PermissionDecision,
PermissionRequest, ResizeRequest, ScrollRequest, ServoHost, ServoSurfaceSize,
SoftwareServoHost,
};
use super::live_protocol::{LiveSidecarError, LiveSitePermission};
#[derive(Clone)]
pub(super) struct LiveSession {
pub(super) webview_id: ely_domain::WebViewId,
pub(super) requested_url: String,
pub(super) width: u32,
pub(super) height: u32,
page_zoom_percent: u16,
/// Last hidpi factor pushed to Servo, encoded as `(scale × 1000)`.
/// Stored as a u32 so equality is cheap and stable across the
/// f32 jitter that JSON parsing can introduce. Init to 0 so the
/// first apply_layout call always pushes a real value.
hidpi_scale_milli: u32,
pub(super) scroll_x: i32,
pub(super) scroll_y: i32,
pub(super) awaiting_visible_frame: bool,
/// Sticky for the lifetime of a single URL: flipped to `true`
/// the first time `poll_frame` sees a paint with real content
/// (non-white, non-empty) and reset to `false` on every navigate.
/// After it's `true`, the visible-content gate stops gating:
/// scroll/click/hover/type all return on the first
/// `has_pending_frame=true` (~3 ms) instead of waiting the full
/// `LIVE_FRAME_WAIT_TIMEOUT`. The gate stays armed for the
/// initial paint of each new URL so loading frames are still
/// skipped.
pub(super) ever_visible_frame: bool,
}
impl LiveSession {
fn new(webview_id: ely_domain::WebViewId, _width: u32, _height: u32) -> Self {
Self {
webview_id,
requested_url: String::new(),
width: 0,
height: 0,
page_zoom_percent: DEFAULT_ZOOM_PERCENT,
hidpi_scale_milli: 0,
scroll_x: 0,
scroll_y: 0,
awaiting_visible_frame: false,
ever_visible_frame: false,
}
}
pub(super) fn device_pixel_ratio(&self) -> f32 {
hidpi_scale_milli_to_f32(self.hidpi_scale_milli)
}
}
pub(super) fn ensure_session<'a>(
host: &mut SoftwareServoHost,
sessions: &'a mut HashMap<String, LiveSession>,
key: String,
tab_id: &TabId,
profile_id: &ProfileId,
width: u32,
height: u32,
) -> Result<&'a mut LiveSession, LiveSidecarError> {
if !sessions.contains_key(&key) {
let webview_id = host.create_webview_with_size(
tab_id.clone(),
profile_id.clone(),
ServoSurfaceSize::new(width, height),
)?;
sessions.insert(key.clone(), LiveSession::new(webview_id, width, height));
}
sessions.get_mut(&key).ok_or(LiveSidecarError::SessionUnavailable)
}
pub(super) fn apply_layout(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
width: u32,
height: u32,
page_zoom_percent: u16,
device_pixel_ratio: f32,
) -> Result<bool, LiveSidecarError> {
let mut changed = false;
// Push the device pixel ratio BEFORE resize. Servo's WebView
// defaults hidpi to 1.0; without this the first layout treats
// physical-pixel viewport widths as CSS-pixel widths and the page
// lays out half the size you'd expect on a Retina display.
let hidpi_scale_milli = encode_hidpi_scale_milli(device_pixel_ratio);
if session.hidpi_scale_milli != hidpi_scale_milli {
host.set_hidpi_scale(ely_servo_host::HidpiScaleRequest {
webview_id: session.webview_id.clone(),
scale_factor: hidpi_scale_milli_to_f32(hidpi_scale_milli),
})?;
session.hidpi_scale_milli = hidpi_scale_milli;
changed = true;
}
if session.width != width || session.height != height {
host.resize(ResizeRequest { webview_id: session.webview_id.clone(), width, height })?;
session.width = width;
session.height = height;
changed = true;
}
if session.page_zoom_percent != page_zoom_percent {
host.set_page_zoom(PageZoomRequest {
webview_id: session.webview_id.clone(),
zoom_factor: f32::from(page_zoom_percent) / 100.0,
})?;
session.page_zoom_percent = page_zoom_percent;
changed = true;
}
Ok(changed)
}
pub(super) fn apply_permissions(
host: &mut SoftwareServoHost,
session: &LiveSession,
profile_id: &ProfileId,
permissions: Vec<LiveSitePermission>,
) -> Result<(), LiveSidecarError> {
for permission in permissions {
host.set_permission(
PermissionRequest {
webview_id: session.webview_id.clone(),
profile_id: profile_id.clone(),
origin: ely_domain::SiteOrigin::parse(permission.origin)?,
feature: ely_domain::SitePermissionFeature::parse(permission.feature.as_str())?,
},
PermissionDecision::from(ely_domain::SitePermissionDecision::parse(
permission.decision.as_str(),
)?),
)?;
}
Ok(())
}
pub(super) fn apply_input(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
input: LiveInput,
) -> Result<bool, LiveSidecarError> {
let mut changed = false;
if input.scroll_delta_x != 0 || input.scroll_delta_y != 0 {
let (point_x, point_y) = input.scroll_point()?;
host.scroll(ScrollRequest {
webview_id: session.webview_id.clone(),
delta_x: input.scroll_delta_x,
delta_y: input.scroll_delta_y,
point_x,
point_y,
})?;
session.scroll_x = positive_scroll_component(session.scroll_x, input.scroll_delta_x);
session.scroll_y = positive_scroll_component(session.scroll_y, input.scroll_delta_y);
changed = true;
}
if let (Some(x), Some(y)) = (input.hover_x, input.hover_y) {
host.hover(MouseHoverRequest { webview_id: session.webview_id.clone(), x, y })?;
changed = true;
}
if let (Some(x), Some(y)) = (input.click_x, input.click_y) {
host.click(MouseClickRequest { webview_id: session.webview_id.clone(), x, y })?;
changed = true;
}
if let Some(text) = input.typed_text {
host.type_text(KeyboardTextRequest { webview_id: session.webview_id.clone(), text })?;
changed = true;
}
Ok(changed)
}
pub(super) struct LiveInput {
pub(super) scroll_delta_x: i32,
pub(super) scroll_delta_y: i32,
pub(super) scroll_point_x: Option<u32>,
pub(super) scroll_point_y: Option<u32>,
pub(super) click_x: Option<u32>,
pub(super) click_y: Option<u32>,
pub(super) hover_x: Option<u32>,
pub(super) hover_y: Option<u32>,
pub(super) typed_text: Option<String>,
}
impl LiveInput {
fn scroll_point(&self) -> Result<(u32, u32), LiveSidecarError> {
let point = match (self.scroll_point_x, self.scroll_point_y) {
(Some(x), Some(y)) => (x, y),
_ => return Err(LiveSidecarError::IncompleteScrollPoint),
};
Ok(point)
}
}
fn encode_hidpi_scale_milli(scale: f32) -> u32 {
if !scale.is_finite() || scale <= 0.0 {
return 1_000;
}
let scaled = (scale * 1_000.0).round();
scaled.clamp(500.0, 5_000.0) as u32
}
fn hidpi_scale_milli_to_f32(milli: u32) -> f32 {
milli as f32 / 1_000.0
}
fn positive_scroll_component(current: i32, delta: i32) -> i32 {
let value = i64::from(current) + i64::from(delta);
value.clamp(0, i64::from(i32::MAX)) as i32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_live_session_forces_first_resize_after_hidpi() {
let session = LiveSession::new(ely_domain::WebViewId::new(), 1280, 720);
assert_ne!(
session.width, 1280,
"first apply_layout must resize after hidpi has been pushed",
);
assert_ne!(
session.height, 720,
"first apply_layout must resize after hidpi has been pushed",
);
}
}
@@ -1,257 +0,0 @@
//! Per-frame paint→encode→write stage timings for the live sidecar
//! loop, plus exact fixed-window percentile summaries so the main
//! process can read out p50/p95/p99 latencies.
//!
//! Why this lives next to `live.rs`: the sidecar already owns the hot
//! loop. Sampling here costs one `Instant::now()` per stage boundary.
//! Each stage keeps one preallocated window of nanosecond samples and
//! sorts only at the 60-frame summary boundary, so steady-state record
//! cost stays a single push per stage while p95 remains exact enough
//! for 120 fps gates.
use std::time::{Duration, Instant};
/// Per-frame stage timings captured by the live loop.
///
/// `total_ns` is the real wall-clock span from request arrival to the
/// stdout flush returning, so it captures every byte of overhead
/// outside paint/encode/write (snapshot reads, JSON parse, scratch
/// allocations). It is measured at the loop boundary, not summed.
#[derive(Clone, Copy, Debug)]
pub(super) struct FrameStageTimings {
pub paint_ns: u64,
pub encode_ns: u64,
pub write_ns: u64,
pub total_ns: u64,
}
impl FrameStageTimings {
pub(super) fn from_durations(
paint: Duration,
encode: Duration,
write: Duration,
total: Duration,
) -> Self {
Self {
paint_ns: duration_to_ns(paint),
encode_ns: duration_to_ns(encode),
write_ns: duration_to_ns(write),
total_ns: duration_to_ns(total),
}
}
}
fn duration_to_ns(duration: Duration) -> u64 {
u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)
}
/// Saturating elapsed-ns helper. `Instant::elapsed` is monotonic but
/// the cast can still overflow on the (impossible) hour-long frame.
pub(super) fn elapsed_ns(start: Instant) -> u64 {
duration_to_ns(start.elapsed())
}
#[derive(Debug)]
struct StageSamples {
values: Vec<u64>,
}
impl StageSamples {
fn new(window_size: usize) -> Self {
Self { values: Vec::with_capacity(window_size) }
}
fn record(&mut self, ns: u64) {
self.values.push(ns);
}
fn len(&self) -> usize {
self.values.len()
}
fn percentiles_us(&self) -> StagePercentiles {
let mut sorted = self.values.clone();
sorted.sort_unstable();
StagePercentiles {
p50: percentile_us(&sorted, 0.50),
p95: percentile_us(&sorted, 0.95),
p99: percentile_us(&sorted, 0.99),
}
}
fn reset(&mut self) {
self.values.clear();
}
}
#[derive(Clone, Copy)]
struct StagePercentiles {
p50: u64,
p95: u64,
p99: u64,
}
fn percentile_us(sorted_ns: &[u64], percentile: f64) -> u64 {
if sorted_ns.is_empty() {
return 0;
}
let target = ((sorted_ns.len() as f64) * percentile).ceil() as usize;
let index = target.max(1).min(sorted_ns.len()) - 1;
ns_to_us_ceil(sorted_ns[index])
}
fn ns_to_us_ceil(ns: u64) -> u64 {
ns.div_ceil(1_000)
}
/// Aggregates a rolling window of [`FrameStageTimings`] across N
/// frames, exposing one [`FramePerfSummary`] per window flush.
pub(super) struct FramePerfAggregator {
window_size: usize,
paint: StageSamples,
encode: StageSamples,
write: StageSamples,
total: StageSamples,
context_label: &'static str,
}
impl FramePerfAggregator {
pub(super) const DEFAULT_WINDOW_SIZE: u32 = 60;
pub(super) fn new(context_label: &'static str, window_size: u32) -> Self {
let window_size = usize::try_from(window_size.max(1)).unwrap_or(usize::MAX);
Self {
window_size,
paint: StageSamples::new(window_size),
encode: StageSamples::new(window_size),
write: StageSamples::new(window_size),
total: StageSamples::new(window_size),
context_label,
}
}
pub(super) fn record(&mut self, timings: FrameStageTimings) -> Option<FramePerfSummary> {
self.paint.record(timings.paint_ns);
self.encode.record(timings.encode_ns);
self.write.record(timings.write_ns);
self.total.record(timings.total_ns);
if self.paint.len() < self.window_size {
return None;
}
let paint = self.paint.percentiles_us();
let encode = self.encode.percentiles_us();
let write = self.write.percentiles_us();
let total = self.total.percentiles_us();
let summary = FramePerfSummary {
window: u32::try_from(self.paint.len()).unwrap_or(u32::MAX),
context: self.context_label,
paint_p50_us: paint.p50,
paint_p95_us: paint.p95,
paint_p99_us: paint.p99,
encode_p50_us: encode.p50,
encode_p95_us: encode.p95,
encode_p99_us: encode.p99,
write_p50_us: write.p50,
write_p95_us: write.p95,
write_p99_us: write.p99,
total_p50_us: total.p50,
total_p95_us: total.p95,
total_p99_us: total.p99,
};
self.paint.reset();
self.encode.reset();
self.write.reset();
self.total.reset();
Some(summary)
}
}
#[derive(Clone, Copy, Debug, serde::Serialize)]
pub(super) struct FramePerfSummary {
pub window: u32,
pub context: &'static str,
pub paint_p50_us: u64,
pub paint_p95_us: u64,
pub paint_p99_us: u64,
pub encode_p50_us: u64,
pub encode_p95_us: u64,
pub encode_p99_us: u64,
pub write_p50_us: u64,
pub write_p95_us: u64,
pub write_p99_us: u64,
pub total_p50_us: u64,
pub total_p95_us: u64,
pub total_p99_us: u64,
}
#[cfg(test)]
mod tests {
use super::{FramePerfAggregator, FrameStageTimings, percentile_us};
use std::time::Duration;
#[test]
fn percentile_us_uses_nearest_rank_and_ceils_microseconds() {
let sorted_ns = [1, 1_000, 1_001];
assert_eq!(percentile_us(&sorted_ns, 0.50), 1);
assert_eq!(percentile_us(&sorted_ns, 0.95), 2);
assert_eq!(percentile_us(&sorted_ns, 0.99), 2);
}
#[test]
fn aggregator_emits_summary_after_window_size_records() -> Result<(), &'static str> {
let mut aggregator =
FramePerfAggregator::new("software", FramePerfAggregator::DEFAULT_WINDOW_SIZE);
for index in 0..(FramePerfAggregator::DEFAULT_WINDOW_SIZE - 1) {
let result = aggregator.record(constant_timing());
assert!(result.is_none(), "should not flush at frame {index}");
}
let summary = aggregator
.record(constant_timing())
.ok_or("aggregator must flush at window boundary")?;
assert_eq!(summary.window, FramePerfAggregator::DEFAULT_WINDOW_SIZE);
assert_eq!(summary.context, "software");
Ok(())
}
#[test]
fn aggregator_resets_after_flush_so_next_window_starts_fresh() {
let mut aggregator = FramePerfAggregator::new("hardware", 2);
let _ = aggregator.record(constant_timing());
let summary = aggregator.record(constant_timing());
assert!(summary.is_some(), "expected first flush");
let after_flush = aggregator.record(constant_timing());
assert!(after_flush.is_none(), "aggregator must zero counters after flush");
}
#[test]
fn aggregator_percentiles_track_increasing_paint_durations() -> Result<(), &'static str> {
let mut aggregator = FramePerfAggregator::new("software", 4);
let paint_durations_us = [10u64, 100, 1_000, 10_000];
let mut summary = None;
for paint_us in paint_durations_us {
summary = aggregator.record(FrameStageTimings::from_durations(
Duration::from_micros(paint_us),
Duration::from_micros(1),
Duration::from_micros(1),
Duration::from_micros(paint_us + 2),
));
}
let summary = summary.ok_or("4-frame window must flush")?;
assert_eq!(summary.paint_p50_us, 100);
assert_eq!(summary.paint_p95_us, 10_000);
assert_eq!(summary.paint_p99_us, 10_000);
assert_eq!(summary.total_p50_us, 102);
assert_eq!(summary.total_p95_us, 10_002);
assert_eq!(summary.total_p99_us, 10_002);
Ok(())
}
fn constant_timing() -> FrameStageTimings {
FrameStageTimings::from_durations(
Duration::from_micros(2_000),
Duration::from_micros(500),
Duration::from_micros(100),
Duration::from_micros(2_600),
)
}
}
@@ -1,98 +0,0 @@
use ely_servo_host::{RenderedFrame, WebViewSnapshot, WebViewState};
use serde::Serialize;
use super::args::SnapshotArgs;
pub(super) struct SnapshotInputChanges {
pub(super) scroll: bool,
pub(super) click: bool,
pub(super) drag: bool,
pub(super) touch: bool,
pub(super) text: bool,
}
#[derive(Serialize)]
pub(super) struct SnapshotReport {
requested_url: String,
profile_id: String,
loaded_url: Option<String>,
title: Option<String>,
rgba_path: String,
state: &'static str,
width: u32,
height: u32,
rgba_byte_count: usize,
opaque_pixel_count: u64,
non_white_pixel_count: u64,
content_pixel_count: u64,
sample_hash: u64,
scroll_x: i32,
scroll_y: i32,
page_zoom_percent: u16,
scroll_changed_frame: bool,
click_x: Option<u32>,
click_y: Option<u32>,
click_changed_frame: bool,
drag_from_x: Option<u32>,
drag_from_y: Option<u32>,
drag_to_x: Option<u32>,
drag_to_y: Option<u32>,
drag_changed_frame: bool,
touch_x: Option<u32>,
touch_y: Option<u32>,
touch_changed_frame: bool,
typed_text_byte_count: usize,
text_changed_frame: bool,
}
impl SnapshotReport {
pub(super) fn new(
args: &SnapshotArgs,
snapshot: &WebViewSnapshot,
frame: &RenderedFrame,
changes: SnapshotInputChanges,
) -> Self {
Self {
requested_url: args.url.as_str().to_string(),
profile_id: snapshot.profile_id().as_str().to_string(),
loaded_url: snapshot.url().map(str::to_string),
title: snapshot.title().map(str::to_string),
rgba_path: args.rgba_out.display().to_string(),
state: state_label(snapshot.state()),
width: frame.width(),
height: frame.height(),
rgba_byte_count: frame.rgba_bytes().len(),
opaque_pixel_count: frame.opaque_pixel_count(),
non_white_pixel_count: frame.non_white_pixel_count(),
content_pixel_count: frame.content_pixel_count(),
sample_hash: frame.sample_hash(),
scroll_x: args.scroll_x,
scroll_y: args.scroll_y,
page_zoom_percent: args.page_zoom_percent,
scroll_changed_frame: changes.scroll,
click_x: args.click_point.map(|point| point.x),
click_y: args.click_point.map(|point| point.y),
click_changed_frame: changes.click,
drag_from_x: args.drag_points.map(|points| points.from.x),
drag_from_y: args.drag_points.map(|points| points.from.y),
drag_to_x: args.drag_points.map(|points| points.to.x),
drag_to_y: args.drag_points.map(|points| points.to.y),
drag_changed_frame: changes.drag,
touch_x: args.touch_point.map(|point| point.x),
touch_y: args.touch_point.map(|point| point.y),
touch_changed_frame: changes.touch,
typed_text_byte_count: args.typed_text.as_ref().map_or(0, String::len),
text_changed_frame: changes.text,
}
}
}
fn state_label(state: &WebViewState) -> &'static str {
match state {
WebViewState::Created => "created",
WebViewState::Loading => "loading",
WebViewState::Complete => "complete",
WebViewState::Sleeping => "sleeping",
WebViewState::Crashed => "crashed",
}
}