feat(servo): isolate profiles with hardware sidecars

This commit is contained in:
2026-07-09 20:27:22 -04:00
parent 78dc86b18e
commit c28ec2bee8
92 changed files with 10306 additions and 1485 deletions
+22
View File
@@ -18,19 +18,41 @@ servo-engine = [
"dep:servo",
"dep:url",
]
hardware-render = [
"servo-engine",
"dep:gleam",
"dep:glow",
"dep:image",
"dep:mach2",
"dep:objc2-io-surface",
"dep:surfman",
]
[[bin]]
name = "ely_servo_sidecar"
path = "src/bin/ely_servo_sidecar.rs"
required-features = ["servo-engine"]
[dependencies]
dpi = { workspace = true, optional = true }
ely_domain = { path = "../ely_domain" }
euclid = { version = "0.22", optional = true }
gleam = { version = "0.15", optional = true }
glow = { version = "0.17", optional = true }
image = { workspace = true, optional = true }
naga = { version = "26.0.0", features = ["termcolor"], optional = true }
raw-window-handle = { version = "0.6", optional = true }
rustls = { version = "0.23.40", default-features = false, features = ["std", "aws_lc_rs"], optional = true }
serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
servo = { workspace = true, optional = true }
surfman = { version = "0.13", features = ["chains"], optional = true }
thiserror.workspace = true
url = { workspace = true, optional = true }
[target.'cfg(target_os = "macos")'.dependencies]
mach2 = { version = "0.6", optional = true }
objc2-io-surface = { version = "0.3.2", default-features = false, features = ["std", "libc", "objc2", "objc2-core-foundation", "IOSurfaceRef", "IOSurfaceTypes"], optional = true }
[lints]
workspace = true
@@ -0,0 +1,36 @@
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;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[path = "ely_servo_sidecar/live_surface.rs"]
mod live_surface;
use args::SidecarCommand;
fn main() -> Result<(), SidecarError> {
match args::parse_env_command()? {
SidecarCommand::Live(args) => live::run(args)?,
}
Ok(())
}
#[derive(Debug, Error)]
enum SidecarError {
#[error(transparent)]
Args(#[from] args::SidecarArgsError),
#[error(transparent)]
Live(#[from] live_protocol::LiveSidecarError),
}
@@ -0,0 +1,166 @@
use std::{env, path::PathBuf};
use thiserror::Error;
pub(super) enum SidecarCommand {
Live(LiveArgs),
}
pub(super) struct LiveArgs {
pub(super) profile_data_dir: PathBuf,
pub(super) rendering_context: SidecarRenderingContext,
pub(super) iosurface_mach_service: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) enum SidecarRenderingContext {
#[default]
Software,
Hardware,
}
#[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} path is empty")]
EmptyPath { name: &'static str },
#[error("invalid rendering context: {value}")]
InvalidRenderingContext { value: String },
#[error("{name} value is empty")]
EmptyValue { name: &'static str },
}
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),
_ => 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 rendering_context = SidecarRenderingContext::Software;
let mut iosurface_mach_service = None;
while let Some(name) = args.next() {
match name.as_str() {
"--profile-data-dir" => {
let value = next_argument(&mut args, "--profile-data-dir")?;
profile_data_dir = Some(parse_path("--profile-data-dir", value)?);
}
"--rendering-context" => {
let value = next_argument(&mut args, "--rendering-context")?;
rendering_context = match value.as_str() {
"software" => SidecarRenderingContext::Software,
"hardware" => SidecarRenderingContext::Hardware,
_ => return Err(SidecarArgsError::InvalidRenderingContext { value }),
};
}
"--iosurface-mach-service" => {
let value = next_argument(&mut args, "--iosurface-mach-service")?;
iosurface_mach_service = Some(parse_nonempty("--iosurface-mach-service", value)?);
}
_ => return Err(SidecarArgsError::UnknownArgument { value: name }),
}
}
let profile_data_dir = profile_data_dir
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?;
Ok(LiveArgs { profile_data_dir, rendering_context, iosurface_mach_service })
}
fn next_argument(
args: &mut impl Iterator<Item = String>,
name: &'static str,
) -> Result<String, SidecarArgsError> {
args.next().ok_or(SidecarArgsError::MissingArgumentValue { name })
}
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))
}
fn parse_nonempty(name: &'static str, value: String) -> Result<String, SidecarArgsError> {
if value.trim().is_empty() {
return Err(SidecarArgsError::EmptyValue { name });
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_live_profile_data_directory() -> Result<(), SidecarArgsError> {
let command = parse_command([
"ely_servo_sidecar".to_string(),
"live".to_string(),
"--profile-data-dir".to_string(),
"/tmp/ely-profile".to_string(),
])?;
let SidecarCommand::Live(args) = command;
assert_eq!(args.profile_data_dir, PathBuf::from("/tmp/ely-profile"));
assert_eq!(args.rendering_context, SidecarRenderingContext::Software);
assert_eq!(args.iosurface_mach_service, None);
Ok(())
}
#[test]
fn parses_hardware_rendering_context_and_mach_service() -> Result<(), SidecarArgsError> {
let command = parse_command([
"ely_servo_sidecar".to_string(),
"live".to_string(),
"--profile-data-dir".to_string(),
"/tmp/ely-profile".to_string(),
"--rendering-context".to_string(),
"hardware".to_string(),
"--iosurface-mach-service".to_string(),
"com.ely.browser.iosurface.test".to_string(),
])?;
let SidecarCommand::Live(args) = command;
assert_eq!(args.rendering_context, SidecarRenderingContext::Hardware);
assert_eq!(args.iosurface_mach_service.as_deref(), Some("com.ely.browser.iosurface.test"));
Ok(())
}
#[test]
fn requires_profile_data_directory() {
let result = parse_command(["ely_servo_sidecar".to_string(), "live".to_string()]);
assert!(matches!(
result,
Err(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })
));
}
}
@@ -0,0 +1,116 @@
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;
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);
}
}
}
#[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);
}
}
@@ -0,0 +1,419 @@
use std::{
collections::HashMap,
fs,
io::{self, BufRead},
};
use ely_domain::{ProfileId, TabId, UrlText};
use ely_servo_host::{
NavigationRequest, RenderingContextKind, ServoHost, ServoSurfaceSize, SoftwareServoHost,
};
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
use super::live_surface::HardwareSurfaceTransport;
use super::{
args::{LiveArgs, SidecarRenderingContext},
live_output::write_outcome,
live_protocol::{
LIVE_PROTOCOL_VERSION, LiveFrameReport, LiveOutcome, LiveRequest, LiveSidecarError,
validated_frame_byte_count,
},
live_session::{
LiveInput, LiveSession, apply_input, apply_layout, apply_permissions, bind_profile,
ensure_session,
},
};
pub(super) fn run(args: LiveArgs) -> Result<(), LiveSidecarError> {
let LiveArgs { profile_data_dir, rendering_context, iosurface_mach_service } = args;
fs::create_dir_all(&profile_data_dir)?;
let rendering_context_kind = match rendering_context {
SidecarRenderingContext::Software => RenderingContextKind::Software,
SidecarRenderingContext::Hardware => RenderingContextKind::Hardware,
};
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let mut hardware_transport = match rendering_context {
SidecarRenderingContext::Software => None,
SidecarRenderingContext::Hardware => {
let service = iosurface_mach_service
.as_deref()
.ok_or(LiveSidecarError::IOSurfaceMachServiceRequired)?;
Some(HardwareSurfaceTransport::connect(service)?)
}
};
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
drop(iosurface_mach_service);
let mut host = SoftwareServoHost::new_with_config_dir_and_kind(
ServoSurfaceSize::new(1, 1),
Some(profile_data_dir),
rendering_context_kind,
)?;
let mut sessions = HashMap::new();
let mut active_profile = None;
let mut handshake_complete = false;
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;
}
let request = serde_json::from_str::<LiveRequest>(&line);
let should_shutdown =
request.as_ref().is_ok_and(|request| matches!(request, LiveRequest::Shutdown));
let outcome = request.map_err(LiveSidecarError::from).and_then(|request| {
handle_request(
&mut host,
&mut sessions,
&mut active_profile,
&mut handshake_complete,
rendering_context_kind,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
hardware_transport.as_mut(),
request,
)
});
write_outcome(&mut stdout, outcome)?;
if should_shutdown {
break;
}
}
Ok(())
}
fn handle_request(
host: &mut SoftwareServoHost,
sessions: &mut HashMap<String, LiveSession>,
active_profile: &mut Option<ProfileId>,
handshake_complete: &mut bool,
rendering_context_kind: RenderingContextKind,
#[cfg(all(feature = "hardware-render", target_os = "macos"))] hardware_transport: Option<
&mut HardwareSurfaceTransport,
>,
request: LiveRequest,
) -> Result<LiveOutcome, LiveSidecarError> {
if !*handshake_complete
&& !matches!(&request, LiveRequest::Handshake { .. } | LiveRequest::Shutdown)
{
return Err(LiveSidecarError::ProtocolHandshakeRequired);
}
match request {
LiveRequest::Handshake { protocol_version } => {
if protocol_version != LIVE_PROTOCOL_VERSION {
return Err(LiveSidecarError::ProtocolVersionMismatch {
expected: LIVE_PROTOCOL_VERSION,
actual: protocol_version,
});
}
*handshake_complete = true;
Ok(LiveOutcome::empty())
}
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,
pending_surface_ids,
} => {
validated_frame_byte_count(width, height)?;
let tab = TabId::parse(tab_id.clone())?;
let profile = ProfileId::parse(profile_id)?;
bind_profile(active_profile, &profile)?;
let url = UrlText::parse(url)?;
let session =
ensure_session(host, sessions, tab_id.clone(), &tab, &profile, width, height)?;
apply_layout(host, session, width, height, page_zoom_percent, device_pixel_ratio)?;
apply_permissions(host, session, &profile, site_permissions)?;
if session.requested_url != url.as_str() {
let servo_current_url =
host.snapshot(&session.webview_id)?.url().map(str::to_string);
if servo_current_url.as_deref() == Some(url.as_str()) {
session.requested_url = url.as_str().to_string();
} else {
session.clear_presented_frame();
host.navigate(NavigationRequest {
webview_id: session.webview_id.clone(),
tab_id: tab,
url: url.clone(),
})?;
session.requested_url = url.as_str().to_string();
}
}
apply_input(
host,
session,
LiveInput {
scroll_delta_x,
scroll_delta_y,
scroll_point_x,
scroll_point_y,
click_x,
click_y,
hover_x,
hover_y,
typed_text,
},
)?;
let webview_id = session.webview_id.clone();
let outcome = poll_frame(
host,
session,
rendering_context_kind,
&ready_surface_ids,
&pending_surface_ids,
)?;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let outcome = {
let mut outcome = outcome;
if let Some(transport) = hardware_transport {
transport.publish_frame(
host,
&tab_id,
&webview_id,
&ready_surface_ids,
&pending_surface_ids,
&mut outcome,
)?;
}
outcome
};
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
drop((webview_id, ready_surface_ids, pending_surface_ids));
Ok(outcome)
}
LiveRequest::Poll { tab_id, ready_surface_ids, pending_surface_ids } => {
let Some(session) = sessions.get_mut(&tab_id) else {
return Ok(LiveOutcome::empty());
};
let webview_id = session.webview_id.clone();
let outcome = poll_frame(
host,
session,
rendering_context_kind,
&ready_surface_ids,
&pending_surface_ids,
)?;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let outcome = {
let mut outcome = outcome;
if let Some(transport) = hardware_transport {
transport.publish_frame(
host,
&tab_id,
&webview_id,
&ready_surface_ids,
&pending_surface_ids,
&mut outcome,
)?;
}
outcome
};
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
drop((webview_id, ready_surface_ids, pending_surface_ids));
Ok(outcome)
}
LiveRequest::Close { tab_id } => {
if let Some(session) = sessions.remove(&tab_id) {
host.close_webview(&session.webview_id);
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
if let Some(transport) = hardware_transport {
transport.close_tab(&tab_id);
}
Ok(LiveOutcome::empty())
}
LiveRequest::Shutdown => Ok(LiveOutcome::empty()),
}
}
fn poll_frame(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
rendering_context_kind: RenderingContextKind,
ready_surface_ids: &[u64],
pending_surface_ids: &[u64],
) -> Result<LiveOutcome, LiveSidecarError> {
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
let _ = (ready_surface_ids, pending_surface_ids);
match rendering_context_kind {
RenderingContextKind::Software => poll_software_frame(host, session),
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
RenderingContextKind::Hardware => {
poll_hardware_frame(host, session, ready_surface_ids, pending_surface_ids)
}
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
RenderingContextKind::Hardware => poll_software_frame(host, session),
}
}
fn poll_software_frame(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
) -> Result<LiveOutcome, LiveSidecarError> {
host.tick();
let snapshot = host.snapshot(&session.webview_id)?;
let has_pending_frame = snapshot.has_pending_frame();
if !has_pending_frame {
if snapshot.has_pending_metadata()
&& let Some(frame) = session.last_frame.clone()
{
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
let report =
LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), false);
return Ok(LiveOutcome::frame(report, frame));
}
return Ok(LiveOutcome::empty());
}
host.paint_with_readback(&session.webview_id)?;
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
let frame = host.last_rendered_frame()?;
session.last_frame = Some(frame.clone());
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), true);
Ok(LiveOutcome::frame(report, frame))
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn poll_hardware_frame(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
ready_surface_ids: &[u64],
pending_surface_ids: &[u64],
) -> Result<LiveOutcome, LiveSidecarError> {
host.acknowledge_iosurfaces(&session.webview_id, ready_surface_ids)?;
let (last_surface_became_ready, last_surface_is_missing) = session
.last_surface
.map(|identity| {
let was_ready = session.last_surface_ready;
let ready = ready_surface_ids.contains(&identity.surface_id);
let pending = pending_surface_ids.contains(&identity.surface_id);
session.last_surface_ready = ready;
(ready && !was_ready, !ready && !pending)
})
.unwrap_or((false, false));
host.tick();
let snapshot = host.snapshot(&session.webview_id)?;
match hardware_poll_action(
snapshot.has_pending_frame(),
snapshot.has_pending_metadata(),
last_surface_became_ready,
last_surface_is_missing,
session.last_surface.is_some(),
session.last_surface_ready,
) {
HardwarePollAction::ReplaySurface => {
let identity = session.last_surface.ok_or_else(|| {
ely_servo_host::ServoHostError::HardwareSurfaceUnavailable {
id: session.webview_id.clone(),
}
})?;
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
let report = LiveFrameReport::from_surface(
&snapshot,
identity.width,
identity.height,
session.device_pixel_ratio(),
false,
);
return Ok(LiveOutcome::surface(report));
}
HardwarePollAction::Empty => return Ok(LiveOutcome::empty()),
HardwarePollAction::PaintFrame => {}
}
host.paint_without_readback(&session.webview_id)?;
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
let identity = host.peek_iosurface_identity(&session.webview_id)?.ok_or_else(|| {
ely_servo_host::ServoHostError::HardwareSurfaceUnavailable {
id: session.webview_id.clone(),
}
})?;
session.last_surface = Some(identity);
session.last_surface_ready = ready_surface_ids.contains(&identity.surface_id);
let report = LiveFrameReport::from_surface(
&snapshot,
identity.width,
identity.height,
session.device_pixel_ratio(),
true,
);
Ok(LiveOutcome::surface(report))
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum HardwarePollAction {
ReplaySurface,
PaintFrame,
Empty,
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn hardware_poll_action(
has_pending_frame: bool,
has_pending_metadata: bool,
last_surface_became_ready: bool,
last_surface_is_missing: bool,
has_last_surface: bool,
last_surface_ready: bool,
) -> HardwarePollAction {
if has_last_surface && (last_surface_became_ready || last_surface_is_missing) {
HardwarePollAction::ReplaySurface
} else if has_last_surface && !last_surface_ready {
HardwarePollAction::Empty
} else if has_last_surface && !has_pending_frame && has_pending_metadata {
HardwarePollAction::ReplaySurface
} else if has_pending_frame {
HardwarePollAction::PaintFrame
} else {
HardwarePollAction::Empty
}
}
#[cfg(all(test, feature = "hardware-render", target_os = "macos"))]
mod tests {
use super::{HardwarePollAction, hardware_poll_action};
#[test]
fn newly_ready_surface_replays_before_pending_frame() {
assert_eq!(
hardware_poll_action(true, false, true, false, true, true),
HardwarePollAction::ReplaySurface
);
assert_eq!(
hardware_poll_action(true, false, false, false, true, true),
HardwarePollAction::PaintFrame
);
}
#[test]
fn awaiting_ready_surface_backpressures_pending_frame() {
assert_eq!(
hardware_poll_action(true, true, false, false, true, false),
HardwarePollAction::Empty
);
}
#[test]
fn missing_surface_replays_before_first_ready() {
assert_eq!(
hardware_poll_action(true, false, false, true, true, false),
HardwarePollAction::ReplaySurface
);
}
}
@@ -0,0 +1,64 @@
use std::io::Write;
use super::live_protocol::{LiveOutcome, LiveSidecarError, validated_frame_byte_count};
pub(super) fn write_outcome(
stdout: &mut impl Write,
outcome: Result<LiveOutcome, LiveSidecarError>,
) -> Result<(), LiveSidecarError> {
let mut outcome = match outcome {
Ok(outcome) => outcome,
Err(error) => LiveOutcome::error(error.to_string()),
};
if let Some(frame) = outcome.frame.as_ref()
&& let Err(error) = validate_frame(frame.width(), frame.height(), frame.rgba_bytes().len())
{
outcome = LiveOutcome::error(error.to_string());
}
serde_json::to_writer(&mut *stdout, &outcome.response)?;
stdout.write_all(b"\n")?;
if let Some(frame) = outcome.frame.as_ref() {
stdout.write_all(frame.rgba_bytes())?;
}
stdout.flush()?;
Ok(())
}
fn validate_frame(width: u32, height: u32, actual: usize) -> Result<(), LiveSidecarError> {
let expected = validated_frame_byte_count(width, height)?;
if expected != actual {
return Err(LiveSidecarError::FrameByteCountMismatch { expected, actual });
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mismatched_frame_becomes_header_only_error() -> Result<(), LiveSidecarError> {
let frame = ely_servo_host::RenderedFrame::from_rgba_bytes(2, 2, vec![0; 4]);
let snapshot = ely_servo_host::WebViewSnapshot::new(
ely_domain::WebViewId::new(),
ely_domain::TabId::new(),
ely_domain::ProfileId::new(),
ely_servo_host::WebViewState::Complete,
None,
None,
ely_servo_host::WebViewSnapshotPending::new(false, false),
);
let report =
super::super::live_protocol::LiveFrameReport::new(&snapshot, &frame, 1.0, true);
let mut output = Vec::new();
write_outcome(&mut output, Ok(LiveOutcome::frame(report, frame)))?;
assert!(output.ends_with(b"\n"));
let response: serde_json::Value = serde_json::from_slice(&output)?;
assert!(response["error"].as_str().is_some());
assert!(response["frame"].is_null());
Ok(())
}
}
@@ -0,0 +1,381 @@
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;
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 2;
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(super) enum LiveRequest {
Handshake {
protocol_version: u32,
},
Ensure {
tab_id: String,
profile_id: String,
url: String,
width: u32,
height: u32,
#[serde(default = "default_zoom_percent")]
page_zoom_percent: u16,
#[serde(default = "default_device_pixel_ratio")]
device_pixel_ratio: f32,
#[serde(default)]
scroll_delta_x: i32,
#[serde(default)]
scroll_delta_y: i32,
#[serde(default)]
scroll_point_x: Option<u32>,
#[serde(default)]
scroll_point_y: Option<u32>,
#[serde(default)]
click_x: Option<u32>,
#[serde(default)]
click_y: Option<u32>,
#[serde(default)]
hover_x: Option<u32>,
#[serde(default)]
hover_y: Option<u32>,
#[serde(default)]
typed_text: Option<String>,
#[serde(default)]
site_permissions: Vec<LiveSitePermission>,
#[serde(default)]
ready_surface_ids: Vec<u64>,
#[serde(default)]
pending_surface_ids: Vec<u64>,
},
Poll {
tab_id: String,
#[serde(default)]
ready_surface_ids: Vec<u64>,
#[serde(default)]
pending_surface_ids: Vec<u64>,
},
Close {
tab_id: String,
},
Shutdown,
}
const fn default_zoom_percent() -> u16 {
ely_domain::DEFAULT_ZOOM_PERCENT
}
const fn default_device_pixel_ratio() -> f32 {
1.0
}
#[derive(Debug, Deserialize)]
pub(super) struct LiveSitePermission {
pub(super) origin: String,
pub(super) feature: String,
pub(super) decision: String,
}
pub(super) struct LiveOutcome {
pub(super) response: LiveResponse,
pub(super) frame: Option<RenderedFrame>,
}
impl LiveOutcome {
pub(super) fn empty() -> Self {
Self { response: LiveResponse::empty(), frame: None }
}
pub(super) fn error(message: String) -> Self {
Self { response: LiveResponse::error(message), frame: None }
}
pub(super) fn frame(report: LiveFrameReport, frame: RenderedFrame) -> Self {
Self { response: LiveResponse::frame(report), frame: Some(frame) }
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) fn surface(report: LiveFrameReport) -> Self {
Self { response: LiveResponse::frame(report), frame: None }
}
}
#[derive(Debug, Serialize)]
pub(super) struct LiveResponse {
pub(super) protocol_version: u32,
pub(super) error: Option<String>,
pub(super) frame: Option<LiveFrameReport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) surface_handle: Option<IOSurfaceHandle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) current_surface_id: Option<u64>,
}
impl LiveResponse {
fn empty() -> Self {
Self {
protocol_version: LIVE_PROTOCOL_VERSION,
error: None,
frame: None,
surface_handle: None,
current_surface_id: None,
}
}
fn error(message: String) -> Self {
Self {
protocol_version: LIVE_PROTOCOL_VERSION,
error: Some(message),
frame: None,
surface_handle: None,
current_surface_id: None,
}
}
fn frame(frame: LiveFrameReport) -> Self {
Self {
protocol_version: LIVE_PROTOCOL_VERSION,
error: None,
frame: Some(frame),
surface_handle: None,
current_surface_id: None,
}
}
}
#[derive(Debug, Serialize)]
pub(super) struct LiveFrameReport {
pub(super) loaded_url: Option<String>,
pub(super) title: Option<String>,
pub(super) state: &'static str,
pub(super) width: u32,
pub(super) height: u32,
pub(super) device_pixel_ratio: f32,
pub(super) css_viewport_width: u32,
pub(super) css_viewport_height: u32,
pub(super) rgba_byte_count: usize,
pub(super) pixels_changed: bool,
pub(super) non_white_pixel_count: u64,
pub(super) content_pixel_count: u64,
pub(super) sample_hash: u64,
}
impl LiveFrameReport {
pub(super) fn new(
snapshot: &WebViewSnapshot,
frame: &RenderedFrame,
device_pixel_ratio: f32,
pixels_changed: bool,
) -> Self {
let device_pixel_ratio = normalized_device_pixel_ratio(device_pixel_ratio);
let css_viewport_width = css_dimension(frame.width(), device_pixel_ratio);
let css_viewport_height = css_dimension(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(),
pixels_changed,
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(super) fn from_surface(
snapshot: &WebViewSnapshot,
width: u32,
height: u32,
device_pixel_ratio: f32,
pixels_changed: bool,
) -> Self {
let device_pixel_ratio = normalized_device_pixel_ratio(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_dimension(width, device_pixel_ratio),
css_viewport_height: css_dimension(height, device_pixel_ratio),
rgba_byte_count: 0,
pixels_changed,
non_white_pixel_count: 0,
content_pixel_count: 0,
sample_hash: 0,
}
}
}
fn normalized_device_pixel_ratio(value: f32) -> f32 {
if value.is_finite() && value > 0.0 { value.clamp(0.5, 5.0) } else { 1.0 }
}
fn css_dimension(value: u32, device_pixel_ratio: f32) -> u32 {
((value as f32) / device_pixel_ratio).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 protocol handshake is required before sidecar requests")]
ProtocolHandshakeRequired,
#[error("live protocol mismatch: expected {expected}, received {actual}")]
ProtocolVersionMismatch { expected: u32, actual: u32 },
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[error("hardware rendering requires --iosurface-mach-service")]
IOSurfaceMachServiceRequired,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[error(
"hardware surface {surface_id} size {surface_width}x{surface_height} does not match frame report {frame_width}x{frame_height}"
)]
HardwareSurfaceReportMismatch {
surface_id: u64,
surface_width: u32,
surface_height: u32,
frame_width: u32,
frame_height: u32,
},
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[error("hardware IOSurface handle does not match the presented surface")]
HardwareSurfaceHandleMismatch,
#[error("sidecar process is already bound to profile {expected}; received {actual}")]
ProfileMismatch { expected: String, actual: String },
#[error("{input} requires both x and y coordinates")]
IncompletePoint { input: &'static str },
#[error("frame dimensions overflow the protocol byte count: {width}x{height}")]
FrameDimensionsOverflow { width: u32, height: u32 },
#[error("frame dimensions {width}x{height} exceed the {max_dimension}px dimension limit")]
InvalidFrameDimensions { width: u32, height: u32, max_dimension: u32 },
#[error("frame requires {bytes} bytes; the protocol limit is {limit}")]
FrameByteLimitExceeded { bytes: u64, limit: usize },
#[error("frame byte count mismatch: expected {expected}, received {actual}")]
FrameByteCountMismatch { expected: usize, actual: usize },
#[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),
}
pub(super) fn validated_frame_byte_count(
width: u32,
height: u32,
) -> Result<usize, LiveSidecarError> {
if width == 0 || height == 0 || width > MAX_FRAME_DIMENSION || height > MAX_FRAME_DIMENSION {
return Err(LiveSidecarError::InvalidFrameDimensions {
width,
height,
max_dimension: MAX_FRAME_DIMENSION,
});
}
let bytes = u64::from(width)
.checked_mul(u64::from(height))
.and_then(|pixels| pixels.checked_mul(4))
.ok_or(LiveSidecarError::FrameDimensionsOverflow { width, height })?;
if bytes > MAX_FRAME_BYTE_COUNT as u64 {
return Err(LiveSidecarError::FrameByteLimitExceeded {
bytes,
limit: MAX_FRAME_BYTE_COUNT,
});
}
Ok(bytes as usize)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ensure_defaults_optional_input_fields() -> Result<(), serde_json::Error> {
let request = serde_json::from_str::<LiveRequest>(
r#"{"type":"ensure","tab_id":"tab","profile_id":"profile","url":"https://example.com","width":800,"height":600}"#,
)?;
assert!(matches!(
request,
LiveRequest::Ensure {
page_zoom_percent: 100,
device_pixel_ratio: 1.0,
scroll_delta_x: 0,
scroll_delta_y: 0,
ready_surface_ids,
pending_surface_ids,
..
} if ready_surface_ids.is_empty() && pending_surface_ids.is_empty()
));
Ok(())
}
#[test]
fn handshake_deserializes_protocol_version() -> Result<(), serde_json::Error> {
let request =
serde_json::from_str::<LiveRequest>(r#"{"type":"handshake","protocol_version":2}"#)?;
assert!(matches!(request, LiveRequest::Handshake { protocol_version: 2 }));
Ok(())
}
#[test]
fn frame_layout_enforces_dimension_and_byte_limits() {
assert!(matches!(
validated_frame_byte_count(MAX_FRAME_DIMENSION + 1, 1),
Err(LiveSidecarError::InvalidFrameDimensions { .. })
));
assert!(matches!(
validated_frame_byte_count(MAX_FRAME_DIMENSION, MAX_FRAME_DIMENSION),
Err(LiveSidecarError::FrameByteLimitExceeded { .. })
));
}
#[test]
fn shutdown_deserializes_from_wire() -> Result<(), serde_json::Error> {
let request = serde_json::from_str::<LiveRequest>(r#"{"type":"shutdown"}"#)?;
assert!(matches!(request, LiveRequest::Shutdown));
Ok(())
}
}
@@ -0,0 +1,231 @@
use std::collections::{HashMap, hash_map::Entry};
use ely_domain::{ProfileId, TabId, validate_zoom_percent};
use ely_servo_host::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, PageZoomRequest,
PermissionDecision, PermissionRequest, RenderedFrame, ResizeRequest, ScrollRequest, ServoHost,
ServoSurfaceSize, SoftwareServoHost,
};
use super::live_protocol::{LiveSidecarError, LiveSitePermission};
pub(super) struct LiveSession {
pub(super) webview_id: ely_domain::WebViewId,
pub(super) requested_url: String,
width: u32,
height: u32,
page_zoom_percent: u16,
hidpi_scale_milli: u32,
pub(super) last_frame: Option<RenderedFrame>,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) last_surface: Option<ely_servo_host::IOSurfaceIdentity>,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) last_surface_ready: bool,
}
impl LiveSession {
fn new(webview_id: ely_domain::WebViewId) -> Self {
Self {
webview_id,
requested_url: String::new(),
width: 0,
height: 0,
page_zoom_percent: ely_domain::DEFAULT_ZOOM_PERCENT,
hidpi_scale_milli: 0,
last_frame: None,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
last_surface: None,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
last_surface_ready: false,
}
}
pub(super) fn device_pixel_ratio(&self) -> f32 {
self.hidpi_scale_milli as f32 / 1_000.0
}
pub(super) fn clear_presented_frame(&mut self) {
self.last_frame = None;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
{
self.last_surface = None;
self.last_surface_ready = false;
}
}
}
pub(super) fn bind_profile(
active_profile: &mut Option<ProfileId>,
profile: &ProfileId,
) -> Result<(), LiveSidecarError> {
match active_profile {
Some(expected) if expected != profile => Err(LiveSidecarError::ProfileMismatch {
expected: expected.to_string(),
actual: profile.to_string(),
}),
Some(_) => Ok(()),
None => {
*active_profile = Some(profile.clone());
Ok(())
}
}
}
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> {
match sessions.entry(key) {
Entry::Occupied(entry) => Ok(entry.into_mut()),
Entry::Vacant(entry) => {
let webview_id = host.create_webview_with_size(
tab_id.clone(),
profile_id.clone(),
ServoSurfaceSize::new(width, height),
)?;
Ok(entry.insert(LiveSession::new(webview_id)))
}
}
}
pub(super) fn apply_layout(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
width: u32,
height: u32,
page_zoom_percent: u16,
device_pixel_ratio: f32,
) -> Result<(), LiveSidecarError> {
let page_zoom_percent = validate_zoom_percent(page_zoom_percent)?;
let hidpi_scale_milli = encode_hidpi_scale_milli(device_pixel_ratio);
if session.hidpi_scale_milli != hidpi_scale_milli {
session.clear_presented_frame();
host.set_hidpi_scale(HidpiScaleRequest {
webview_id: session.webview_id.clone(),
scale_factor: hidpi_scale_milli as f32 / 1_000.0,
})?;
session.hidpi_scale_milli = hidpi_scale_milli;
}
if session.width != width || session.height != height {
session.clear_presented_frame();
host.resize(ResizeRequest { webview_id: session.webview_id.clone(), width, height })?;
session.width = width;
session.height = height;
}
if session.page_zoom_percent != page_zoom_percent {
session.clear_presented_frame();
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;
}
Ok(())
}
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)?,
},
PermissionDecision::from(ely_domain::SitePermissionDecision::parse(
&permission.decision,
)?),
)?;
}
Ok(())
}
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>,
}
pub(super) fn apply_input(
host: &mut SoftwareServoHost,
session: &LiveSession,
input: LiveInput,
) -> Result<(), LiveSidecarError> {
if input.scroll_delta_x != 0 || input.scroll_delta_y != 0 {
let (point_x, point_y) =
paired_point("scroll input", input.scroll_point_x, input.scroll_point_y)?;
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,
})?;
}
if input.hover_x.is_some() || input.hover_y.is_some() {
let (x, y) = paired_point("hover input", input.hover_x, input.hover_y)?;
host.hover(MouseHoverRequest { webview_id: session.webview_id.clone(), x, y })?;
}
if input.click_x.is_some() || input.click_y.is_some() {
let (x, y) = paired_point("click input", input.click_x, input.click_y)?;
host.click(MouseClickRequest { webview_id: session.webview_id.clone(), x, y })?;
}
if let Some(text) = input.typed_text {
host.type_text(KeyboardTextRequest { webview_id: session.webview_id.clone(), text })?;
}
Ok(())
}
fn paired_point(
input: &'static str,
x: Option<u32>,
y: Option<u32>,
) -> Result<(u32, u32), LiveSidecarError> {
match (x, y) {
(Some(x), Some(y)) => Ok((x, y)),
_ => Err(LiveSidecarError::IncompletePoint { input }),
}
}
fn encode_hidpi_scale_milli(scale: f32) -> u32 {
if scale.is_finite() && scale > 0.0 {
(scale * 1_000.0).round().clamp(500.0, 5_000.0) as u32
} else {
1_000
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn process_binds_to_first_profile() -> Result<(), LiveSidecarError> {
let first = ProfileId::new();
let second = ProfileId::new();
let mut active = None;
bind_profile(&mut active, &first)?;
assert!(matches!(
bind_profile(&mut active, &second),
Err(LiveSidecarError::ProfileMismatch { .. })
));
Ok(())
}
}
@@ -0,0 +1,169 @@
use std::collections::{HashMap, HashSet};
use ely_servo_host::{IOSurfaceIdentity, SoftwareServoHost};
use super::{
iosurface_mach::IOSurfaceMachSender,
live_protocol::{LiveOutcome, LiveSidecarError},
};
pub(super) struct HardwareSurfaceTransport {
sender: IOSurfaceMachSender,
publications: SurfacePublications,
}
#[derive(Default)]
struct SurfacePublications {
by_tab: HashMap<String, HashMap<IOSurfaceIdentity, PublicationState>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PublicationState {
AwaitingReady,
Ready,
}
impl HardwareSurfaceTransport {
pub(super) fn connect(service_name: &str) -> Result<Self, LiveSidecarError> {
Ok(Self {
sender: IOSurfaceMachSender::connect(service_name)?,
publications: SurfacePublications::default(),
})
}
pub(super) fn publish_frame(
&mut self,
host: &SoftwareServoHost,
tab_id: &str,
webview_id: &ely_domain::WebViewId,
ready_surface_ids: &[u64],
pending_surface_ids: &[u64],
outcome: &mut LiveOutcome,
) -> Result<(), LiveSidecarError> {
self.publications.sync_client_state(tab_id, ready_surface_ids, pending_surface_ids);
let Some(report) = outcome.response.frame.as_ref() else {
return Ok(());
};
let identity = host.peek_iosurface_identity(webview_id)?.ok_or_else(|| {
ely_servo_host::ServoHostError::HardwareSurfaceUnavailable { id: webview_id.clone() }
})?;
validate_report(identity, report.width, report.height)?;
outcome.response.current_surface_id = Some(identity.surface_id);
if self.publications.contains(tab_id, identity) {
return Ok(());
}
let handle = host.current_iosurface_handle(webview_id)?.ok_or_else(|| {
ely_servo_host::ServoHostError::HardwareSurfaceUnavailable { id: webview_id.clone() }
})?;
if IOSurfaceIdentity::from_handle(handle) != identity {
return Err(LiveSidecarError::HardwareSurfaceHandleMismatch);
}
self.sender.send_surface_port(handle.surface_id, handle.mach_port_name)?;
outcome.response.surface_handle = Some(handle);
self.publications.insert(tab_id, identity);
Ok(())
}
pub(super) fn close_tab(&mut self, tab_id: &str) {
self.publications.remove(tab_id);
}
}
impl SurfacePublications {
fn sync_client_state(
&mut self,
tab_id: &str,
ready_surface_ids: &[u64],
pending_surface_ids: &[u64],
) {
let ready: HashSet<u64> = ready_surface_ids.iter().copied().collect();
let pending: HashSet<u64> = pending_surface_ids.iter().copied().collect();
let Some(surfaces) = self.by_tab.get_mut(tab_id) else {
return;
};
surfaces.retain(|identity, state| match state {
PublicationState::AwaitingReady => {
if ready.contains(&identity.surface_id) {
*state = PublicationState::Ready;
}
ready.contains(&identity.surface_id) || pending.contains(&identity.surface_id)
}
PublicationState::Ready => ready.contains(&identity.surface_id),
});
}
fn contains(&self, tab_id: &str, identity: IOSurfaceIdentity) -> bool {
self.by_tab.get(tab_id).is_some_and(|surfaces| surfaces.contains_key(&identity))
}
fn insert(&mut self, tab_id: &str, identity: IOSurfaceIdentity) {
self.by_tab
.entry(tab_id.to_string())
.or_default()
.insert(identity, PublicationState::AwaitingReady);
}
fn remove(&mut self, tab_id: &str) {
self.by_tab.remove(tab_id);
}
}
fn validate_report(
identity: IOSurfaceIdentity,
frame_width: u32,
frame_height: u32,
) -> Result<(), LiveSidecarError> {
if identity.width == frame_width && identity.height == frame_height {
return Ok(());
}
Err(LiveSidecarError::HardwareSurfaceReportMismatch {
surface_id: identity.surface_id,
surface_width: identity.width,
surface_height: identity.height,
frame_width,
frame_height,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn surface_publication_tracks_pending_ready_and_evicted_states() {
let identity = IOSurfaceIdentity { surface_id: 7, width: 800, height: 600 };
let mut publications = SurfacePublications::default();
publications.insert("tab", identity);
publications.sync_client_state("tab", &[], &[7]);
assert!(publications.contains("tab", identity));
publications.sync_client_state("tab", &[7], &[]);
assert!(publications.contains("tab", identity));
publications.sync_client_state("tab", &[], &[]);
assert!(!publications.contains("tab", identity));
publications.insert("tab", identity);
publications.sync_client_state("tab", &[], &[7]);
assert!(publications.contains("tab", identity));
publications.sync_client_state("tab", &[7], &[]);
assert!(publications.contains("tab", identity));
}
#[test]
fn seventeenth_surface_missing_before_ready_is_republished() {
let mut publications = SurfacePublications::default();
let identities = (1..=17)
.map(|surface_id| IOSurfaceIdentity { surface_id, width: 64, height: 48 })
.collect::<Vec<_>>();
for identity in &identities {
publications.insert("tab", *identity);
}
publications.sync_client_state("tab", &[], &(1..=17).collect::<Vec<_>>());
publications.sync_client_state("tab", &[], &(2..=17).collect::<Vec<_>>());
assert!(!publications.contains("tab", identities[0]));
assert!(publications.contains("tab", identities[16]));
}
}
+9
View File
@@ -24,6 +24,15 @@ pub enum ServoHostError {
#[error("servo rendering context could not be made current")]
RenderingContextNotCurrent,
#[error(
"hardware rendering requires the `hardware-render` feature; rebuild with \
--features servo-engine,hardware-render"
)]
HardwareRenderUnavailable,
#[error("servo rendered frame is unavailable")]
RenderedFrameUnavailable,
#[error("servo hardware surface is unavailable for {id}")]
HardwareSurfaceUnavailable { id: WebViewId },
}
@@ -0,0 +1,466 @@
//! Headless hardware rendering context backed by Surfman.
//!
//! Servo keeps its Surfman constructor private. This module mirrors the small
//! portion required to create a hardware `SurfaceType::Generic` context and,
//! on macOS, retain the presented IOSurface for cross-process import.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;
use dpi::PhysicalSize;
use euclid::Size2D;
use gleam::gl::{self, Gl};
use image::RgbaImage;
use servo::{DeviceIntRect, RenderingContext};
#[cfg(target_os = "macos")]
use surfman::cgl::surface::NativeSurface;
use surfman::chains::{PreserveBuffer, SwapChain, SwapChainAPI};
use surfman::{
Connection, Context, ContextAttributeFlags, ContextAttributes, Device, Error as SurfmanError,
GLApi, NativeWidget, Surface, SurfaceAccess, SurfaceType,
};
#[cfg(target_os = "macos")]
use crate::{IOSurfaceHandle, IOSurfaceIdentity};
/// A hardware-backed offscreen Servo rendering context.
pub struct HardwareOffscreenContext {
size: Cell<PhysicalSize<u32>>,
inner: SurfmanInner,
swap_chain: SwapChain<Device>,
#[cfg(target_os = "macos")]
held_presented_surfaces: RefCell<Vec<HeldPresentedSurface>>,
#[cfg(target_os = "macos")]
last_presented_iosurface: RefCell<Option<PresentedIOSurface>>,
}
impl HardwareOffscreenContext {
/// Creates a hardware context with a generic offscreen surface.
pub fn new(size: PhysicalSize<u32>) -> Result<Self, SurfmanError> {
if size.width == 0 || size.height == 0 {
return Err(SurfmanError::Failed);
}
let connection = Connection::new()?;
let adapter = connection.create_adapter()?;
let inner = SurfmanInner::new(&connection, &adapter)?;
let surface = inner.create_surface(SurfaceType::Generic {
size: Size2D::new(size.width as i32, size.height as i32),
})?;
inner.bind_surface(surface)?;
inner.make_current()?;
let swap_chain = inner.create_attached_swap_chain()?;
Ok(Self {
size: Cell::new(size),
inner,
swap_chain,
#[cfg(target_os = "macos")]
held_presented_surfaces: RefCell::new(Vec::new()),
#[cfg(target_os = "macos")]
last_presented_iosurface: RefCell::new(None),
})
}
}
impl Drop for HardwareOffscreenContext {
fn drop(&mut self) {
let device = self.inner.device.borrow();
let context = &mut self.inner.context.borrow_mut();
#[cfg(target_os = "macos")]
self.destroy_held_presented_surfaces(&device, context);
let _ = self.swap_chain.destroy(&device, context);
}
}
impl RenderingContext for HardwareOffscreenContext {
fn prepare_for_rendering(&self) {
self.inner.prepare_for_rendering();
}
fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option<RgbaImage> {
self.inner.read_to_image(source_rectangle)
}
fn size(&self) -> PhysicalSize<u32> {
self.size.get()
}
fn resize(&self, size: PhysicalSize<u32>) {
if self.size.get() == size || size.width == 0 || size.height == 0 {
return;
}
let device = self.inner.device.borrow();
let context = &mut self.inner.context.borrow_mut();
#[cfg(target_os = "macos")]
self.destroy_held_presented_surfaces(&device, context);
let surfman_size = Size2D::new(size.width as i32, size.height as i32);
if self.swap_chain.resize(&device, context, surfman_size).is_ok() {
self.size.set(size);
}
}
fn present(&self) {
let device = self.inner.device.borrow();
let context = &mut self.inner.context.borrow_mut();
#[cfg(target_os = "macos")]
self.recycle_acknowledged_surfaces();
#[cfg(target_os = "macos")]
self.last_presented_iosurface.borrow_mut().take();
if self.swap_chain.swap_buffers(&device, context, PreserveBuffer::No).is_err() {
return;
}
#[cfg(target_os = "macos")]
{
self.capture_presented_iosurface(&device);
self.recycle_acknowledged_surfaces();
}
}
fn make_current(&self) -> Result<(), SurfmanError> {
self.inner.make_current()
}
fn gleam_gl_api(&self) -> Rc<dyn Gl> {
self.inner.gleam_gl.clone()
}
fn glow_gl_api(&self) -> Arc<glow::Context> {
self.inner.glow_gl.clone()
}
fn connection(&self) -> Option<Connection> {
Some(self.inner.device.borrow().connection())
}
}
#[cfg(target_os = "macos")]
impl HardwareOffscreenContext {
/// Returns the identity of the most recently presented IOSurface.
pub fn peek_iosurface_identity(&self) -> Result<Option<IOSurfaceIdentity>, SurfmanError> {
Ok(self.last_presented_iosurface.borrow().as_ref().map(|surface| surface.identity))
}
/// Creates a Mach send right for the most recently presented IOSurface.
pub fn current_iosurface_mach_port(&self) -> Result<IOSurfaceHandle, SurfmanError> {
let presented = self.last_presented_iosurface.borrow();
let presented = presented.as_ref().ok_or(SurfmanError::Failed)?;
let mach_port_name = presented.native.0.create_mach_port();
if mach_port_name == 0 {
return Err(SurfmanError::Failed);
}
Ok(IOSurfaceHandle {
mach_port_name,
surface_id: presented.identity.surface_id,
width: presented.identity.width,
height: presented.identity.height,
})
}
/// Marks IOSurface IDs reported ready after import by the app process.
///
/// The ready acknowledgement confirms import. Acknowledged surfaces remain
/// retained while current or while `IOSurfaceIsInUse` reports active
/// consumer work. Recycling begins after that consumer work completes.
pub fn acknowledge_iosurfaces(&self, surface_ids: &[u64]) {
{
let mut held = self.held_presented_surfaces.borrow_mut();
for surface in held.iter_mut() {
if surface_ids.contains(&surface.presented.identity.surface_id) {
surface.acknowledged = true;
}
}
}
self.recycle_acknowledged_surfaces();
}
fn capture_presented_iosurface(&self, device: &Device) {
let Some(surface) = self.swap_chain.take_pending_surface() else {
self.last_presented_iosurface.borrow_mut().take();
return;
};
let info = device.surface_info(&surface);
let native = device.native_surface(&surface);
let identity = IOSurfaceIdentity {
surface_id: u64::from(native.0.id()),
width: u32::try_from(info.size.width).unwrap_or(0),
height: u32::try_from(info.size.height).unwrap_or(0),
};
let presented = PresentedIOSurface { identity, native };
self.held_presented_surfaces.borrow_mut().push(HeldPresentedSurface {
surface,
presented: presented.clone(),
acknowledged: false,
});
self.last_presented_iosurface.replace(Some(presented));
}
fn recycle_acknowledged_surfaces(&self) {
let current_id = self
.last_presented_iosurface
.borrow()
.as_ref()
.map(|surface| surface.identity.surface_id);
let mut held = self.held_presented_surfaces.borrow_mut();
let mut index = 0;
while index < held.len() {
let surface = &held[index];
let can_recycle = surface.acknowledged
&& Some(surface.presented.identity.surface_id) != current_id
&& !surface.presented.native.0.is_in_use();
if can_recycle {
let surface = held.swap_remove(index);
self.swap_chain.recycle_surface(surface.surface);
} else {
index += 1;
}
}
}
fn destroy_held_presented_surfaces(&self, device: &Device, context: &mut Context) {
self.last_presented_iosurface.borrow_mut().take();
let held = self.held_presented_surfaces.take();
for mut surface in held {
let _ = device.destroy_surface(context, &mut surface.surface);
}
}
}
#[cfg(target_os = "macos")]
struct HeldPresentedSurface {
surface: Surface,
presented: PresentedIOSurface,
acknowledged: bool,
}
#[cfg(target_os = "macos")]
#[derive(Clone)]
struct PresentedIOSurface {
identity: IOSurfaceIdentity,
native: NativeSurface,
}
struct SurfmanInner {
gleam_gl: Rc<dyn Gl>,
glow_gl: Arc<glow::Context>,
device: RefCell<Device>,
context: RefCell<Context>,
}
impl Drop for SurfmanInner {
fn drop(&mut self) {
let device = self.device.borrow();
let context = &mut self.context.borrow_mut();
let _ = device.destroy_context(context);
}
}
impl SurfmanInner {
fn new(connection: &Connection, adapter: &surfman::Adapter) -> Result<Self, SurfmanError> {
let device = connection.create_device(adapter)?;
let flags = ContextAttributeFlags::ALPHA
| ContextAttributeFlags::DEPTH
| ContextAttributeFlags::STENCIL;
let gl_api = connection.gl_api();
let version = match gl_api {
GLApi::GLES => surfman::GLVersion { major: 3, minor: 0 },
GLApi::GL => surfman::GLVersion { major: 3, minor: 2 },
};
let descriptor = device.create_context_descriptor(&ContextAttributes { flags, version })?;
let context = device.create_context(&descriptor, None)?;
// Surfman owns the current platform GL implementation and supplies matching ABI symbols.
#[expect(unsafe_code)]
let gleam_gl = match gl_api {
GLApi::GL => unsafe {
gl::GlFns::load_with(|name| device.get_proc_address(&context, name))
},
GLApi::GLES => unsafe {
gl::GlesFns::load_with(|name| device.get_proc_address(&context, name))
},
};
// The loader remains valid for the lifetime of the Surfman device and context below.
#[expect(unsafe_code)]
let glow_gl = unsafe {
glow::Context::from_loader_function(|name| device.get_proc_address(&context, name))
};
Ok(Self {
gleam_gl,
glow_gl: Arc::new(glow_gl),
device: RefCell::new(device),
context: RefCell::new(context),
})
}
fn create_surface(
&self,
surface_type: SurfaceType<NativeWidget>,
) -> Result<Surface, SurfmanError> {
self.device.borrow().create_surface(
&self.context.borrow(),
SurfaceAccess::GPUOnly,
surface_type,
)
}
fn bind_surface(&self, surface: Surface) -> Result<(), SurfmanError> {
let device = self.device.borrow();
let context = &mut self.context.borrow_mut();
device.bind_surface_to_context(context, surface).map_err(|(error, mut surface)| {
let _ = device.destroy_surface(context, &mut surface);
error
})
}
fn create_attached_swap_chain(&self) -> Result<SwapChain<Device>, SurfmanError> {
SwapChain::create_attached(
&self.device.borrow(),
&mut self.context.borrow_mut(),
SurfaceAccess::GPUOnly,
)
}
fn make_current(&self) -> Result<(), SurfmanError> {
self.device.borrow().make_context_current(&self.context.borrow())
}
fn framebuffer_id(&self) -> u32 {
self.device
.borrow()
.context_surface_info(&self.context.borrow())
.unwrap_or(None)
.and_then(|info| info.framebuffer_object)
.map_or(0, |framebuffer| framebuffer.0.into())
}
fn prepare_for_rendering(&self) {
self.gleam_gl.bind_framebuffer(gl::FRAMEBUFFER, self.framebuffer_id());
}
fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option<RgbaImage> {
self.gleam_gl.bind_framebuffer(gl::FRAMEBUFFER, self.framebuffer_id());
self.gleam_gl.bind_vertex_array(0);
let mut pixels = self.gleam_gl.read_pixels(
source_rectangle.min.x,
source_rectangle.min.y,
source_rectangle.width(),
source_rectangle.height(),
gl::RGBA,
gl::UNSIGNED_BYTE,
);
if self.gleam_gl.get_error() != gl::NO_ERROR {
return None;
}
let rectangle = source_rectangle.to_usize();
let stride = rectangle.width().checked_mul(4)?;
let original = pixels.clone();
for y in 0..rectangle.height() {
let destination_start = y.checked_mul(stride)?;
let source_start = rectangle.height().checked_sub(y + 1)?.checked_mul(stride)?;
let destination_end = destination_start.checked_add(stride)?;
let source_end = source_start.checked_add(stride)?;
pixels
.get_mut(destination_start..destination_end)?
.copy_from_slice(original.get(source_start..source_end)?);
}
RgbaImage::from_raw(rectangle.width() as u32, rectangle.height() as u32, pixels)
}
}
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
fn present(context: &HardwareOffscreenContext) -> Result<IOSurfaceIdentity, String> {
context.make_current().map_err(|error| format!("make current failed: {error:?}"))?;
context.prepare_for_rendering();
context.present();
context
.peek_iosurface_identity()
.map_err(|error| format!("identity probe failed: {error:?}"))?
.ok_or_else(|| "present did not expose an IOSurface".to_string())
}
fn held_ids(context: &HardwareOffscreenContext) -> Vec<u64> {
context
.held_presented_surfaces
.borrow()
.iter()
.map(|surface| surface.presented.identity.surface_id)
.collect()
}
#[test]
fn acknowledgement_recycles_only_noncurrent_surfaces() -> Result<(), String> {
let context = HardwareOffscreenContext::new(PhysicalSize::new(64, 48))
.map_err(|error| format!("hardware context creation failed: {error:?}"))?;
let first = present(&context)?;
let second = present(&context)?;
assert_ne!(first.surface_id, second.surface_id);
assert_eq!(held_ids(&context).len(), 2);
context.acknowledge_iosurfaces(&[first.surface_id]);
assert_eq!(held_ids(&context), vec![second.surface_id]);
context.acknowledge_iosurfaces(&[second.surface_id]);
assert_eq!(held_ids(&context), vec![second.surface_id]);
Ok(())
}
#[test]
fn acknowledged_surface_waits_for_iosurface_use_to_finish() -> Result<(), String> {
let context = HardwareOffscreenContext::new(PhysicalSize::new(64, 48))
.map_err(|error| format!("hardware context creation failed: {error:?}"))?;
let first = present(&context)?;
let second = present(&context)?;
let native = context
.held_presented_surfaces
.borrow()
.iter()
.find(|surface| surface.presented.identity == first)
.map(|surface| surface.presented.native.0.clone())
.ok_or_else(|| "first IOSurface was not retained".to_string())?;
native.increment_use_count();
context.acknowledge_iosurfaces(&[first.surface_id]);
let retained_while_in_use = held_ids(&context).contains(&first.surface_id);
native.decrement_use_count();
assert!(retained_while_in_use);
context.acknowledge_iosurfaces(&[]);
assert_eq!(held_ids(&context), vec![second.surface_id]);
Ok(())
}
#[test]
fn resize_destroys_all_retained_presentations() -> Result<(), String> {
let context = HardwareOffscreenContext::new(PhysicalSize::new(64, 48))
.map_err(|error| format!("hardware context creation failed: {error:?}"))?;
let _ = present(&context)?;
let _ = present(&context)?;
assert_eq!(held_ids(&context).len(), 2);
context.resize(PhysicalSize::new(96, 72));
assert!(held_ids(&context).is_empty());
assert_eq!(context.size(), PhysicalSize::new(96, 72));
assert_eq!(
context
.peek_iosurface_identity()
.map_err(|error| format!("identity probe failed: {error:?}"))?,
None
);
let resized = present(&context)?;
assert_eq!((resized.width, resized.height), (96, 72));
Ok(())
}
}
@@ -0,0 +1,29 @@
//! Cross-process IOSurface descriptors used by the macOS hardware path.
/// A send right for importing the current IOSurface in another process.
///
/// `surface_id` is the system IOSurface ID. The receiver owns the transferred
/// Mach send right and must deallocate it after importing the surface.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[cfg_attr(feature = "servo-engine", derive(serde::Deserialize, serde::Serialize))]
pub struct IOSurfaceHandle {
pub mach_port_name: u32,
pub surface_id: u64,
pub width: u32,
pub height: u32,
}
/// Stable identity of a presented IOSurface without creating a Mach port.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct IOSurfaceIdentity {
pub surface_id: u64,
pub width: u32,
pub height: u32,
}
impl IOSurfaceIdentity {
#[must_use]
pub fn from_handle(handle: IOSurfaceHandle) -> Self {
Self { surface_id: handle.surface_id, width: handle.width, height: handle.height }
}
}
+6
View File
@@ -1,5 +1,8 @@
mod error;
#[cfg(feature = "hardware-render")]
mod hardware_rendering_context;
mod host;
mod iosurface_handle;
#[cfg(feature = "servo-engine")]
mod keyboard;
#[cfg(feature = "servo-engine")]
@@ -14,11 +17,14 @@ mod runtime_waker;
mod runtime_webview;
pub use error::ServoHostError;
#[cfg(feature = "hardware-render")]
pub use hardware_rendering_context::HardwareOffscreenContext;
pub use host::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest,
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame,
RenderedFrameSummary, ResizeRequest, ScrollRequest, ServoHost, TouchTapRequest,
WebViewSnapshot, WebViewSnapshotPending, WebViewState,
};
pub use iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity};
#[cfg(feature = "servo-engine")]
pub use runtime::{RenderingContextKind, ServoSurfaceSize, SoftwareServoHost};
+6 -1
View File
@@ -19,6 +19,9 @@ use servo::{
#[path = "runtime_context.rs"]
mod runtime_context;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[path = "runtime_hardware.rs"]
mod runtime_hardware;
#[path = "runtime_paint.rs"]
mod runtime_paint;
@@ -365,7 +368,7 @@ impl ServoHost for SoftwareServoHost {
}
fn paint(&mut self, webview_id: &WebViewId) -> Result<(), ServoHostError> {
self.paint_with_readback(webview_id, true)
self.paint_with_readback(webview_id)
}
fn last_rendered_frame(&self) -> Result<RenderedFrame, ServoHostError> {
@@ -408,6 +411,8 @@ impl SoftwareServoHost {
tab_id,
profile_id,
rendering_context: handles.rendering_context,
#[cfg(feature = "hardware-render")]
hardware_context: handles.hardware_context,
webview,
delegate,
requested_url: None,
+30 -58
View File
@@ -1,10 +1,4 @@
use std::{
env,
rc::Rc,
sync::OnceLock,
thread,
time::{Duration, Instant},
};
use std::rc::Rc;
use dpi::PhysicalSize;
use euclid::Scale;
@@ -17,9 +11,6 @@ use servo::{
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).
@@ -34,17 +25,6 @@ pub(super) fn hidpi_scale_from_factor(
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,
@@ -67,12 +47,15 @@ impl ServoSurfaceSize {
pub enum RenderingContextKind {
#[default]
Software,
Hardware,
}
/// Pair of rendering-context handles produced by
/// [`SoftwareServoHost::new_rendering_context`].
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 {
@@ -89,8 +72,28 @@ impl SoftwareServoHost {
rendering_context
.make_current()
.map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
Ok(RenderingContextHandles { rendering_context })
Ok(RenderingContextHandles {
rendering_context,
#[cfg(feature = "hardware-render")]
hardware_context: None,
})
}
#[cfg(feature = "hardware-render")]
RenderingContextKind::Hardware => {
let hardware_context = Rc::new(
crate::HardwareOffscreenContext::new(size.physical())
.map_err(|_| ServoHostError::RenderingContextUnavailable)?,
);
hardware_context
.make_current()
.map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
Ok(RenderingContextHandles {
rendering_context: hardware_context.clone(),
hardware_context: Some(hardware_context),
})
}
#[cfg(not(feature = "hardware-render"))]
RenderingContextKind::Hardware => Err(ServoHostError::HardwareRenderUnavailable),
}
}
@@ -113,42 +116,11 @@ impl SoftwareServoHost {
.map_err(|_| ServoHostError::RenderingContextUnavailable)?,
);
rendering_context.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
Ok(RenderingContextHandles { rendering_context })
}
/// 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);
}
Ok(RenderingContextHandles {
rendering_context,
#[cfg(feature = "hardware-render")]
hardware_context: None,
})
}
pub(super) fn read_rendered_frame(
@@ -0,0 +1,51 @@
use ely_domain::WebViewId;
use super::SoftwareServoHost;
use crate::{IOSurfaceHandle, IOSurfaceIdentity, ServoHostError};
impl SoftwareServoHost {
/// Marks IOSurface IDs reported ready after import by the app process.
///
/// `IOSurfaceIsInUse == false` supplies the consumer-completion check used
/// before the rendering context recycles an acknowledged surface.
pub fn acknowledge_iosurfaces(
&self,
webview_id: &WebViewId,
surface_ids: &[u64],
) -> Result<(), ServoHostError> {
let webview = self.webview(webview_id)?;
if let Some(hardware_context) = webview.hardware_context.as_ref() {
hardware_context.acknowledge_iosurfaces(surface_ids);
}
Ok(())
}
/// Returns the most recently presented IOSurface identity for a hardware webview.
pub fn peek_iosurface_identity(
&self,
webview_id: &WebViewId,
) -> Result<Option<IOSurfaceIdentity>, ServoHostError> {
let webview = self.webview(webview_id)?;
let Some(hardware_context) = webview.hardware_context.as_ref() else {
return Ok(None);
};
hardware_context
.peek_iosurface_identity()
.map_err(|_| ServoHostError::HardwareSurfaceUnavailable { id: webview_id.clone() })
}
/// Creates a Mach send right for the most recently presented IOSurface.
pub fn current_iosurface_handle(
&self,
webview_id: &WebViewId,
) -> Result<Option<IOSurfaceHandle>, ServoHostError> {
let webview = self.webview(webview_id)?;
let Some(hardware_context) = webview.hardware_context.as_ref() else {
return Ok(None);
};
hardware_context
.current_iosurface_mach_port()
.map(Some)
.map_err(|_| ServoHostError::HardwareSurfaceUnavailable { id: webview_id.clone() })
}
}
@@ -20,7 +20,6 @@ pub(super) fn send_mouse_click(webview: &WebView, x: u32, y: u32) {
pub(super) fn send_mouse_drag(webview: &WebView, from_x: u32, from_y: u32, to_x: u32, to_y: u32) {
let from = point(from_x, from_y);
let to = point(to_x, to_y);
webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(from)));
send_mouse_button(webview, MouseButtonAction::Down, from);
webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(to)));
send_mouse_button(webview, MouseButtonAction::Up, to);
+30 -26
View File
@@ -1,7 +1,7 @@
use ely_domain::WebViewId;
use super::SoftwareServoHost;
use crate::{RenderedFrame, ServoHostError};
use crate::{RenderedFrame, ServoHostError, runtime_webview::HostWebViewDelegate};
/// Repaint and present coordination for [`SoftwareServoHost`].
///
@@ -13,35 +13,20 @@ use crate::{RenderedFrame, ServoHostError};
impl SoftwareServoHost {
/// Paint and present the current surface without RGBA readback.
pub fn paint_without_readback(&mut self, webview_id: &WebViewId) -> Result<(), ServoHostError> {
self.paint_without_readback_with_completion(webview_id, true)
}
pub fn paint_without_readback_with_completion(
&mut self,
webview_id: &WebViewId,
wait_for_completion: bool,
) -> Result<(), ServoHostError> {
self.paint_webview(webview_id, false, wait_for_completion).map(|_| ())
self.paint_webview(webview_id, false).map(|_| ())
}
fn paint_webview(
&mut self,
webview_id: &WebViewId,
capture_frame: bool,
wait_for_completion: bool,
) -> Result<Option<RenderedFrame>, ServoHostError> {
let rendering_context = self.webview(webview_id)?.rendering_context.clone();
rendering_context.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
rendering_context.prepare_for_rendering();
// Clear the pending-frame flag before `paint()` so barrier callers observe
// the next Servo frame-ready notification for this paint.
{
let webview = self.webview(webview_id)?;
webview.delegate.mark_frame_presented();
webview.webview.paint();
}
if wait_for_completion {
self.wait_for_paint_completion(webview_id);
consume_pending_then_paint(&webview.delegate, || webview.webview.paint());
}
let rendered_frame = if capture_frame {
Some(Self::read_rendered_frame(rendering_context.as_ref())?)
@@ -49,20 +34,39 @@ impl SoftwareServoHost {
None
};
rendering_context.present();
self.webview(webview_id)?.delegate.mark_frame_presented();
Ok(rendered_frame)
}
pub fn paint_with_readback(
&mut self,
webview_id: &WebViewId,
wait_for_completion: bool,
) -> Result<(), ServoHostError> {
let Some(rendered_frame) = self.paint_webview(webview_id, true, wait_for_completion)?
else {
pub fn paint_with_readback(&mut self, webview_id: &WebViewId) -> Result<(), ServoHostError> {
let Some(rendered_frame) = self.paint_webview(webview_id, true)? else {
return Err(ServoHostError::RenderedFrameUnavailable);
};
self.last_rendered_frame = Some(rendered_frame);
Ok(())
}
}
fn consume_pending_then_paint(delegate: &HostWebViewDelegate, paint: impl FnOnce()) {
delegate.mark_frame_presented();
paint();
}
#[cfg(test)]
mod tests {
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use ely_domain::ProfileId;
use super::{HostWebViewDelegate, consume_pending_then_paint};
#[test]
fn frame_arriving_during_paint_remains_pending() {
let delegate =
HostWebViewDelegate::new(ProfileId::new(), Rc::new(RefCell::new(HashMap::new())));
delegate.mark_frame_ready();
consume_pending_then_paint(&delegate, || delegate.mark_frame_ready());
assert!(delegate.has_pending_frame());
}
}
+7 -1
View File
@@ -13,6 +13,8 @@ pub(super) struct HostWebView {
pub(super) tab_id: TabId,
pub(super) profile_id: ProfileId,
pub(super) rendering_context: Rc<dyn RenderingContext>,
#[cfg(feature = "hardware-render")]
pub(super) hardware_context: Option<Rc<crate::HardwareOffscreenContext>>,
pub(super) webview: WebView,
pub(super) delegate: Rc<HostWebViewDelegate>,
pub(super) requested_url: Option<String>,
@@ -128,6 +130,10 @@ impl HostWebViewDelegate {
self.has_pending_frame.set(false);
}
pub(super) fn mark_frame_ready(&self) {
self.has_pending_frame.set(true);
}
pub(super) fn mark_metadata_observed(&self) {
self.has_pending_metadata.set(false);
}
@@ -147,7 +153,7 @@ impl WebViewDelegate for HostWebViewDelegate {
}
fn notify_new_frame_ready(&self, _webview: WebView) {
self.has_pending_frame.set(true);
self.mark_frame_ready();
}
fn notify_crashed(&self, _webview: WebView, _reason: String, _backtrace: Option<String>) {
@@ -0,0 +1,144 @@
#![cfg(feature = "hardware-render")]
use dpi::PhysicalSize;
use ely_servo_host::{HardwareOffscreenContext, IOSurfaceHandle, IOSurfaceIdentity};
use servo::RenderingContext;
#[cfg(target_os = "macos")]
const HARDWARE_HOST_CHILD_ENV: &str = "ELY_SERVO_HARDWARE_HOST_CHILD";
#[test]
fn identity_uses_handle_dimensions() {
let handle = IOSurfaceHandle { mach_port_name: 7, surface_id: 11, width: 640, height: 480 };
assert_eq!(
IOSurfaceIdentity::from_handle(handle),
IOSurfaceIdentity { surface_id: 11, width: 640, height: 480 }
);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn hardware_context_constructs() -> Result<(), String> {
let context = HardwareOffscreenContext::new(PhysicalSize::new(64, 64))
.map_err(|error| format!("hardware context creation failed: {error:?}"))?;
assert_eq!(context.size(), PhysicalSize::new(64, 64));
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn presented_surface_exposes_iosurface_identity_and_mach_port() -> Result<(), String> {
let size = PhysicalSize::new(256, 192);
let context = HardwareOffscreenContext::new(size)
.map_err(|error| format!("hardware context creation failed: {error:?}"))?;
assert_eq!(
context
.peek_iosurface_identity()
.map_err(|error| format!("identity probe failed: {error:?}"))?,
None
);
context.make_current().map_err(|error| format!("make current failed: {error:?}"))?;
context.prepare_for_rendering();
context.present();
let identity = context
.peek_iosurface_identity()
.map_err(|error| format!("identity probe failed: {error:?}"))?
.ok_or_else(|| "present did not expose an IOSurface".to_string())?;
let handle = context
.current_iosurface_mach_port()
.map_err(|error| format!("Mach port creation failed: {error:?}"))?;
assert_ne!(handle.mach_port_name, 0);
assert_eq!(identity, IOSurfaceIdentity::from_handle(handle));
assert_eq!(identity.width, size.width);
assert_eq!(identity.height, size.height);
assert_eq!(deallocate_mach_port(handle.mach_port_name), mach2::kern_return::KERN_SUCCESS);
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn hardware_host_paints_and_presents_an_iosurface() -> Result<(), Box<dyn std::error::Error>> {
use std::env;
use std::process::{Command, Stdio};
if env::var_os(HARDWARE_HOST_CHILD_ENV).is_some() {
return exercise_hardware_host();
}
let output = Command::new(env::current_exe()?)
.arg("--exact")
.arg("hardware_host_paints_and_presents_an_iosurface")
.env(HARDWARE_HOST_CHILD_ENV, "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
if output.status.success() {
return Ok(());
}
Err(format!(
"hardware host child failed\nstatus: {}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.into())
}
#[cfg(target_os = "macos")]
fn exercise_hardware_host() -> Result<(), Box<dyn std::error::Error>> {
use std::thread;
use std::time::Duration;
use ely_domain::{ProfileId, TabId, UrlText};
use ely_servo_host::{
NavigationRequest, RenderingContextKind, ServoHost, ServoSurfaceSize, SoftwareServoHost,
};
let size = ServoSurfaceSize::new(320, 240);
let mut host = SoftwareServoHost::new_with_config_dir_and_kind(
size,
None,
RenderingContextKind::Hardware,
)?;
let tab_id = TabId::new();
let webview_id = host.create_webview(tab_id.clone(), ProfileId::new())?;
host.navigate(NavigationRequest {
webview_id: webview_id.clone(),
tab_id,
url: UrlText::parse("data:text/html,%3Cbody%20style%3D%27background%3A%230369a1%27%3E")?,
})?;
for _ in 0..5_000 {
host.tick();
if host.snapshot(&webview_id)?.has_pending_frame() {
host.paint_without_readback(&webview_id)?;
if host.peek_iosurface_identity(&webview_id)?.is_some() {
let handle = host
.current_iosurface_handle(&webview_id)?
.ok_or("hardware webview did not expose an IOSurface handle")?;
assert_eq!(handle.width, 320);
assert_eq!(handle.height, 240);
assert_eq!(
deallocate_mach_port(handle.mach_port_name),
mach2::kern_return::KERN_SUCCESS
);
return Ok(());
}
}
thread::sleep(Duration::from_millis(2));
}
Err("timed out waiting for a hardware IOSurface frame".into())
}
#[cfg(target_os = "macos")]
#[expect(unsafe_code)]
fn deallocate_mach_port(name: u32) -> i32 {
// `name` is a live send right minted by IOSurfaceCreateMachPort in this task.
unsafe { mach2::mach_port::mach_port_deallocate(mach2::traps::mach_task_self(), name) }
}
+248
View File
@@ -0,0 +1,248 @@
#![cfg(feature = "servo-engine")]
use std::{
error::Error,
io, thread,
time::{Duration, Instant},
};
use ely_domain::{ProfileId, TabId};
use serde_json::json;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[path = "sidecar/mach_receiver.rs"]
mod mach_receiver;
#[path = "sidecar/support.rs"]
mod support;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
use mach_receiver::{MachSurfaceReceiver, verify_iosurface};
use support::{
HEIGHT, LIVE_PROTOCOL_VERSION, MAX_FRAME_DIMENSION, RESPONSE_TIMEOUT, Sidecar, TestDirectory,
TestServer, WIDTH, ensure_request,
};
#[test]
fn live_sidecar_streams_rgba_and_flushes_profile_storage_on_shutdown() -> Result<(), Box<dyn Error>>
{
let server = TestServer::start()?;
let root = TestDirectory::new()?;
let persisted_dir = root.path().join("persisted");
let fresh_dir = root.path().join("fresh");
let profile_id = ProfileId::new();
let mut writer = Sidecar::spawn(&persisted_dir)?;
let stored = writer
.ensure_and_wait_visible(&profile_id, &server.url("/set"), "stored-cookie-yes-storage-yes")
.map_err(|error| io::Error::other(format!("{error}; server={}", server.diagnostics())))?;
assert_eq!(stored.width, WIDTH);
assert_eq!(stored.height, HEIGHT);
assert!(stored.non_white_pixel_count > 0);
assert!(stored.content_pixel_count > 0);
assert_ne!(stored.sample_hash, 0);
writer.shutdown()?;
assert!(persisted_dir.join("cookie_jar.json").is_file());
assert!(persisted_dir.join("localstorage.json").is_file());
assert!(persisted_dir.join("webstorage").is_dir());
let mut reader = Sidecar::spawn(&persisted_dir)?;
reader.ensure_and_wait_visible(
&profile_id,
&server.url("/read"),
"read-cookie-yes-storage-yes",
)?;
reader.shutdown()?;
let mut fresh = Sidecar::spawn(&fresh_dir)?;
fresh.ensure_and_wait_visible(
&profile_id,
&server.url("/read"),
"read-cookie-no-storage-no",
)?;
fresh.shutdown()?;
Ok(())
}
#[test]
fn servo_originated_history_url_does_not_trigger_a_second_navigation() -> Result<(), Box<dyn Error>>
{
let server = TestServer::start()?;
let root = TestDirectory::new()?;
let profile_id = ProfileId::new();
let tab_id = TabId::new();
let initial_url = server.url("/history");
let history_url = server.url("/history?state=1");
let mut sidecar = Sidecar::spawn(root.path())?;
let ensure = ensure_request(&tab_id, &profile_id, &initial_url);
let mut response = sidecar.exchange(&ensure)?;
let started_at = Instant::now();
loop {
if let Some(error) = response.error {
return Err(io::Error::other(format!("sidecar response error: {error}")).into());
}
if response.frame.as_ref().is_some_and(|frame| {
frame.loaded_url.as_deref() == Some(history_url.as_str())
&& frame.title.as_deref() == Some("history-ready")
}) {
break;
}
if started_at.elapsed() >= RESPONSE_TIMEOUT {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("timed out waiting for history URL {history_url}"),
)
.into());
}
thread::sleep(Duration::from_millis(2));
response = sidecar.exchange(&json!({ "type": "poll", "tab_id": tab_id.as_str() }))?;
}
sidecar.exchange(&ensure_request(&tab_id, &profile_id, &history_url))?;
for _ in 0..20 {
thread::sleep(Duration::from_millis(5));
sidecar.exchange(&json!({ "type": "poll", "tab_id": tab_id.as_str() }))?;
}
assert_eq!(server.request_count("/history"), 1, "{}", server.diagnostics());
sidecar.shutdown()?;
Ok(())
}
#[test]
fn live_sidecar_delivers_a_valid_white_page() -> Result<(), Box<dyn Error>> {
let server = TestServer::start()?;
let root = TestDirectory::new()?;
let profile_id = ProfileId::new();
let mut sidecar = Sidecar::spawn(root.path())?;
let frame = sidecar.ensure_and_wait(&profile_id, &server.url("/white"), "white-ready")?;
assert_eq!(frame.non_white_pixel_count, 0);
assert_eq!(frame.content_pixel_count, 0);
sidecar.shutdown()?;
Ok(())
}
#[test]
fn live_sidecar_rejects_an_incompatible_protocol() -> Result<(), Box<dyn Error>> {
let root = TestDirectory::new()?;
let mut sidecar = Sidecar::spawn_without_handshake(root.path())?;
let response = sidecar.exchange(&json!({
"type": "handshake",
"protocol_version": LIVE_PROTOCOL_VERSION + 1,
}))?;
assert_eq!(response.protocol_version, Some(LIVE_PROTOCOL_VERSION));
assert!(response.error.as_deref().is_some_and(|error| error.contains("protocol mismatch")));
Ok(())
}
#[test]
fn live_sidecar_rejects_oversized_frame_dimensions() -> Result<(), Box<dyn Error>> {
let root = TestDirectory::new()?;
let profile_id = ProfileId::new();
let tab_id = TabId::new();
let mut sidecar = Sidecar::spawn(root.path())?;
let mut ensure = ensure_request(&tab_id, &profile_id, "about:blank");
ensure["width"] = json!(MAX_FRAME_DIMENSION + 1);
let response = sidecar.exchange(&ensure)?;
assert!(response.error.as_deref().is_some_and(|error| error.contains("dimension limit")));
sidecar.shutdown()?;
Ok(())
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[test]
fn hardware_sidecar_transfers_a_real_iosurface_mach_descriptor() -> Result<(), Box<dyn Error>> {
let receiver = MachSurfaceReceiver::new()?;
let server = TestServer::start()?;
let root = TestDirectory::new()?;
let profile_id = ProfileId::new();
let tab_id = TabId::new();
let mut sidecar = Sidecar::spawn_hardware(root.path(), receiver.service_name())?;
let mut response =
sidecar.exchange(&ensure_request(&tab_id, &profile_id, &server.url("/white")))?;
let started_at = Instant::now();
let imported_surface_id = loop {
if let Some(error) = response.error {
return Err(io::Error::other(format!("hardware sidecar error: {error}")).into());
}
if let (Some(frame), Some(handle), Some(current_surface_id)) =
(response.frame.as_ref(), response.surface_handle, response.current_surface_id)
{
assert_eq!(frame.rgba_byte_count, 0);
assert_eq!(current_surface_id, handle.surface_id);
assert_eq!((frame.width, frame.height), (handle.width, handle.height));
assert_ne!(handle.mach_port_name, 0);
let received_port = receiver.receive(handle.surface_id, Duration::from_secs(2))?;
verify_iosurface(received_port, handle.width, handle.height)?;
break handle.surface_id;
}
if started_at.elapsed() >= RESPONSE_TIMEOUT {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"timed out waiting for a hardware IOSurface frame",
)
.into());
}
thread::sleep(Duration::from_millis(2));
response = sidecar.exchange(&json!({
"type": "poll",
"tab_id": tab_id.as_str(),
"ready_surface_ids": [],
"pending_surface_ids": [],
}))?;
};
let replay = sidecar.exchange(&json!({
"type": "poll",
"tab_id": tab_id.as_str(),
"ready_surface_ids": [imported_surface_id],
"pending_surface_ids": [],
}))?;
assert_eq!(replay.current_surface_id, Some(imported_surface_id));
assert!(replay.surface_handle.is_none());
assert_eq!(replay.frame.as_ref().map(|frame| frame.rgba_byte_count), Some(0));
let republished = sidecar.exchange(&json!({
"type": "poll",
"tab_id": tab_id.as_str(),
"ready_surface_ids": [],
"pending_surface_ids": [],
}))?;
let republished_handle = republished
.surface_handle
.ok_or_else(|| io::Error::other("evicted IOSurface was not republished"))?;
assert_eq!(republished.current_surface_id, Some(imported_surface_id));
assert_eq!(republished_handle.surface_id, imported_surface_id);
let republished_port = receiver.receive(imported_surface_id, Duration::from_secs(2))?;
verify_iosurface(republished_port, republished_handle.width, republished_handle.height)?;
let pending = sidecar.exchange(&json!({
"type": "poll",
"tab_id": tab_id.as_str(),
"ready_surface_ids": [],
"pending_surface_ids": [imported_surface_id],
}))?;
assert!(pending.frame.is_none());
assert!(pending.surface_handle.is_none());
let reimported = sidecar.exchange(&json!({
"type": "poll",
"tab_id": tab_id.as_str(),
"ready_surface_ids": [imported_surface_id],
"pending_surface_ids": [],
}))?;
assert_eq!(reimported.current_surface_id, Some(imported_surface_id));
assert!(reimported.surface_handle.is_none());
assert_eq!(reimported.frame.as_ref().map(|frame| frame.rgba_byte_count), Some(0));
sidecar.shutdown()?;
Ok(())
}
@@ -0,0 +1,176 @@
use std::{error::Error, ffi::CString, io, mem, time::Duration};
use mach2::{
bootstrap::{bootstrap_port, bootstrap_register},
kern_return::KERN_SUCCESS,
mach_port::{
mach_port_allocate, mach_port_deallocate, mach_port_destroy, mach_port_insert_right,
},
message::{
MACH_MSG_PORT_DESCRIPTOR, MACH_MSG_SUCCESS, MACH_MSG_TYPE_MAKE_SEND, MACH_RCV_MSG,
MACH_RCV_TIMED_OUT, MACH_RCV_TIMEOUT, mach_msg, mach_msg_body_t, mach_msg_header_t,
mach_msg_port_descriptor_t, mach_msg_trailer_t,
},
port::{MACH_PORT_NULL, MACH_PORT_RIGHT_RECEIVE, mach_port_t},
traps::mach_task_self,
};
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
pub(super) struct MachSurfaceReceiver {
service_name: String,
receive_port: mach_port_t,
}
impl MachSurfaceReceiver {
pub(super) fn new() -> Result<Self, Box<dyn Error>> {
let service_name =
format!("com.ely.browser.iosurface.test.{}", ely_domain::ProfileId::new().as_str());
let service_name_c = CString::new(service_name.as_str())?;
let mut receive_port = MACH_PORT_NULL;
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
let allocate =
unsafe { mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &mut receive_port) };
if allocate != KERN_SUCCESS {
return Err(io::Error::other(format!("mach_port_allocate returned {allocate}")).into());
}
#[expect(unsafe_code)]
let insert = unsafe {
mach_port_insert_right(task, receive_port, receive_port, MACH_MSG_TYPE_MAKE_SEND)
};
if insert != KERN_SUCCESS {
destroy_port(receive_port);
return Err(
io::Error::other(format!("mach_port_insert_right returned {insert}")).into()
);
}
#[expect(unsafe_code)]
#[allow(deprecated)]
let register = unsafe {
bootstrap_register(bootstrap_port, service_name_c.as_ptr() as *mut _, receive_port)
};
if register != KERN_SUCCESS {
destroy_port(receive_port);
return Err(io::Error::other(format!("bootstrap_register returned {register}")).into());
}
Ok(Self { service_name, receive_port })
}
pub(super) fn service_name(&self) -> &str {
self.service_name.as_str()
}
pub(super) fn receive(
&self,
expected_surface_id: u64,
timeout: Duration,
) -> Result<mach_port_t, Box<dyn Error>> {
#[expect(unsafe_code)]
let mut received: ReceivedIOSurfacePortMessage = unsafe { mem::zeroed() };
#[expect(unsafe_code)]
let result = unsafe {
mach_msg(
&mut received.message.header,
MACH_RCV_MSG | MACH_RCV_TIMEOUT,
0,
mem::size_of::<ReceivedIOSurfacePortMessage>() as u32,
self.receive_port,
timeout_millis(timeout),
MACH_PORT_NULL,
)
};
if result == MACH_RCV_TIMED_OUT {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("Mach receive timed out for surface {expected_surface_id}"),
)
.into());
}
if result != MACH_MSG_SUCCESS {
return Err(io::Error::other(format!("mach_msg receive returned {result}")).into());
}
let message = &mut received.message;
if message.header.msgh_id != IOSURFACE_PORT_MESSAGE_ID
|| message.body.msgh_descriptor_count != 1
|| message.surface_port.type_ != MACH_MSG_PORT_DESCRIPTOR as u8
|| message.surface_port.name == MACH_PORT_NULL
|| message.surface_id != expected_surface_id
{
destroy_message(message);
return Err(io::Error::other("received invalid IOSurface Mach message").into());
}
Ok(message.surface_port.name)
}
}
impl Drop for MachSurfaceReceiver {
fn drop(&mut self) {
destroy_port(self.receive_port);
}
}
pub(super) fn verify_iosurface(
mach_port: mach_port_t,
width: u32,
height: u32,
) -> Result<(), Box<dyn Error>> {
let surface = objc2_io_surface::IOSurfaceRef::lookup_from_mach_port(mach_port)
.ok_or_else(|| io::Error::other("IOSurfaceLookupFromMachPort returned null"))?;
let actual_width = u32::try_from(surface.width())?;
let actual_height = u32::try_from(surface.height())?;
deallocate_port(mach_port)?;
if actual_width != width || actual_height != height {
return Err(io::Error::other(format!(
"imported IOSurface was {actual_width}x{actual_height}; expected {width}x{height}"
))
.into());
}
Ok(())
}
#[repr(C)]
struct IOSurfacePortMessage {
header: mach_msg_header_t,
body: mach_msg_body_t,
surface_port: mach_msg_port_descriptor_t,
surface_id: u64,
}
#[repr(C)]
struct ReceivedIOSurfacePortMessage {
message: IOSurfacePortMessage,
_trailer: mach_msg_trailer_t,
}
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);
}
}
fn destroy_port(port: mach_port_t) {
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
unsafe {
let _ = mach_port_destroy(task, port);
}
}
fn deallocate_port(port: mach_port_t) -> Result<(), Box<dyn Error>> {
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
let result = unsafe { mach_port_deallocate(task, port) };
if result != KERN_SUCCESS {
return Err(io::Error::other(format!("mach_port_deallocate returned {result}")).into());
}
Ok(())
}
@@ -0,0 +1,488 @@
use std::{
error::Error,
fs,
io::{self, BufRead, BufReader, Read, Write},
net::{SocketAddr, TcpListener, TcpStream},
path::{Path, PathBuf},
process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus, Stdio},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant},
};
use ely_domain::{ProfileId, TabId};
use serde_json::{Value, json};
pub(super) const WIDTH: u32 = 360;
pub(super) const HEIGHT: u32 = 240;
pub(super) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(20);
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 2;
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
pub(super) fn ensure_request(tab_id: &TabId, profile_id: &ProfileId, url: &str) -> Value {
json!({
"type": "ensure",
"tab_id": tab_id.as_str(),
"profile_id": profile_id.as_str(),
"url": url,
"width": WIDTH,
"height": HEIGHT,
"page_zoom_percent": 100,
"device_pixel_ratio": 1.0,
"site_permissions": [],
})
}
pub(super) struct Sidecar {
child: Child,
stdin: Option<ChildStdin>,
stdout: BufReader<ChildStdout>,
stderr: Option<ChildStderr>,
}
impl Sidecar {
pub(super) fn spawn(profile_data_dir: &Path) -> Result<Self, Box<dyn Error>> {
let mut sidecar = Self::spawn_without_handshake(profile_data_dir)?;
sidecar.handshake()?;
Ok(sidecar)
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) fn spawn_hardware(
profile_data_dir: &Path,
mach_service: &str,
) -> Result<Self, Box<dyn Error>> {
let mut sidecar = Self::spawn_process(
profile_data_dir,
&["--rendering-context", "hardware", "--iosurface-mach-service", mach_service],
)?;
sidecar.handshake()?;
Ok(sidecar)
}
fn handshake(&mut self) -> Result<(), Box<dyn Error>> {
let response = self.exchange(&json!({
"type": "handshake",
"protocol_version": LIVE_PROTOCOL_VERSION,
}))?;
if response.protocol_version != Some(LIVE_PROTOCOL_VERSION) || response.error.is_some() {
return Err(io::Error::other("sidecar protocol handshake failed").into());
}
Ok(())
}
pub(super) fn spawn_without_handshake(profile_data_dir: &Path) -> Result<Self, Box<dyn Error>> {
Self::spawn_process(profile_data_dir, &[])
}
fn spawn_process(profile_data_dir: &Path, extra_args: &[&str]) -> Result<Self, Box<dyn Error>> {
let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"));
command.arg("live").arg("--profile-data-dir").arg(profile_data_dir);
command.args(extra_args);
let mut child =
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
let stdin =
child.stdin.take().ok_or_else(|| io::Error::other("sidecar stdin was not piped"))?;
let stdout =
child.stdout.take().ok_or_else(|| io::Error::other("sidecar stdout was not piped"))?;
let stderr = child.stderr.take();
Ok(Self { child, stdin: Some(stdin), stdout: BufReader::new(stdout), stderr })
}
pub(super) fn ensure_and_wait(
&mut self,
profile_id: &ProfileId,
url: &str,
expected_title: &str,
) -> Result<FramePacket, Box<dyn Error>> {
self.ensure_and_wait_matching(profile_id, url, expected_title, |_| true)
}
pub(super) fn ensure_and_wait_visible(
&mut self,
profile_id: &ProfileId,
url: &str,
expected_title: &str,
) -> Result<FramePacket, Box<dyn Error>> {
self.ensure_and_wait_matching(profile_id, url, expected_title, |frame| {
frame.non_white_pixel_count > 0 && frame.content_pixel_count > 0
})
}
fn ensure_and_wait_matching(
&mut self,
profile_id: &ProfileId,
url: &str,
expected_title: &str,
matches_frame: impl Fn(&FramePacket) -> bool,
) -> Result<FramePacket, Box<dyn Error>> {
let tab_id = TabId::new();
let ensure = ensure_request(&tab_id, profile_id, url);
let mut response = self.exchange(&ensure)?;
let started_at = Instant::now();
let mut latest_title = None;
loop {
if let Some(error) = response.error {
return Err(io::Error::other(format!("sidecar response error: {error}")).into());
}
if let Some(frame) = response.frame {
latest_title = frame.title.clone();
if frame.title.as_deref() == Some(expected_title) && matches_frame(&frame) {
return Ok(frame);
}
}
if started_at.elapsed() >= RESPONSE_TIMEOUT {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"timed out waiting for title {expected_title:?}; latest={latest_title:?}"
),
)
.into());
}
thread::sleep(Duration::from_millis(2));
response = self.exchange(&json!({ "type": "poll", "tab_id": tab_id.as_str() }))?;
}
}
pub(super) fn exchange(&mut self, request: &Value) -> Result<WireResponse, Box<dyn Error>> {
let stdin = self
.stdin
.as_mut()
.ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "sidecar stdin is closed"))?;
serde_json::to_writer(&mut *stdin, request)?;
stdin.write_all(b"\n")?;
stdin.flush()?;
read_response(&mut self.stdout)
}
pub(super) fn shutdown(&mut self) -> Result<(), Box<dyn Error>> {
let response = self.exchange(&json!({ "type": "shutdown" }))?;
if response.protocol_version != Some(LIVE_PROTOCOL_VERSION)
|| response.error.is_some()
|| response.frame.is_some()
{
return Err(
io::Error::other("shutdown response must be an empty acknowledgement").into()
);
}
self.stdin.take();
let status = wait_for_exit(&mut self.child, RESPONSE_TIMEOUT)?;
if status.success() {
return Ok(());
}
let mut stderr = String::new();
if let Some(mut pipe) = self.stderr.take() {
pipe.read_to_string(&mut stderr)?;
}
Err(io::Error::other(format!("sidecar exited with {status}: {stderr}")).into())
}
}
impl Drop for Sidecar {
fn drop(&mut self) {
if self.child.try_wait().ok().flatten().is_none() {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
}
fn wait_for_exit(child: &mut Child, timeout: Duration) -> Result<ExitStatus, Box<dyn Error>> {
let started_at = Instant::now();
loop {
if let Some(status) = child.try_wait()? {
return Ok(status);
}
if started_at.elapsed() >= timeout {
child.kill()?;
let _ = child.wait();
return Err(
io::Error::new(io::ErrorKind::TimedOut, "sidecar shutdown timed out").into()
);
}
thread::sleep(Duration::from_millis(5));
}
}
pub(super) struct WireResponse {
pub(super) protocol_version: Option<u32>,
pub(super) error: Option<String>,
pub(super) frame: Option<FramePacket>,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) surface_handle: Option<SurfaceHandle>,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) current_surface_id: Option<u64>,
}
pub(super) struct FramePacket {
pub(super) loaded_url: Option<String>,
pub(super) title: Option<String>,
pub(super) width: u32,
pub(super) height: u32,
pub(super) non_white_pixel_count: u64,
pub(super) content_pixel_count: u64,
pub(super) sample_hash: u64,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) rgba_byte_count: usize,
_rgba: Vec<u8>,
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[derive(Clone, Copy, Debug)]
pub(super) struct SurfaceHandle {
pub(super) mach_port_name: u32,
pub(super) surface_id: u64,
pub(super) width: u32,
pub(super) height: u32,
}
fn read_response(stdout: &mut BufReader<ChildStdout>) -> Result<WireResponse, Box<dyn Error>> {
let mut line = String::new();
if stdout.read_line(&mut line)? == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "sidecar response ended").into());
}
let header: Value = serde_json::from_str(&line)?;
let protocol_version = header
.get("protocol_version")
.and_then(Value::as_u64)
.and_then(|value| value.try_into().ok());
let error = header.get("error").and_then(Value::as_str).map(str::to_string);
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let surface_handle = header
.get("surface_handle")
.filter(|value| !value.is_null())
.map(|handle| {
Ok::<_, Box<dyn Error>>(SurfaceHandle {
mach_port_name: u32_field(handle, "mach_port_name")?,
surface_id: u64_field(handle, "surface_id")?,
width: u32_field(handle, "width")?,
height: u32_field(handle, "height")?,
})
})
.transpose()?;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let current_surface_id = header.get("current_surface_id").and_then(Value::as_u64);
let Some(frame) = header.get("frame").filter(|value| !value.is_null()) else {
return Ok(WireResponse {
protocol_version,
error,
frame: None,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
surface_handle,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
current_surface_id,
});
};
let width = u32_field(frame, "width")?;
let height = u32_field(frame, "height")?;
let rgba_byte_count = usize_field(frame, "rgba_byte_count")?;
let expected = usize::try_from(width)?
.checked_mul(usize::try_from(height)?)
.and_then(|pixels| pixels.checked_mul(4))
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "frame size overflow"))?;
if (rgba_byte_count != 0 && rgba_byte_count != expected)
|| rgba_byte_count > MAX_FRAME_BYTE_COUNT
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid frame byte count {rgba_byte_count}; expected {expected}"),
)
.into());
}
let mut rgba = vec![0; rgba_byte_count];
stdout.read_exact(&mut rgba)?;
let packet = FramePacket {
loaded_url: frame.get("loaded_url").and_then(Value::as_str).map(str::to_string),
title: frame.get("title").and_then(Value::as_str).map(str::to_string),
width,
height,
non_white_pixel_count: u64_field(frame, "non_white_pixel_count")?,
content_pixel_count: u64_field(frame, "content_pixel_count")?,
sample_hash: u64_field(frame, "sample_hash")?,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
rgba_byte_count,
_rgba: rgba,
};
Ok(WireResponse {
protocol_version,
error,
frame: Some(packet),
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
surface_handle,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
current_surface_id,
})
}
fn u32_field(value: &Value, name: &str) -> Result<u32, Box<dyn Error>> {
Ok(u32::try_from(u64_field(value, name)?)?)
}
fn usize_field(value: &Value, name: &str) -> Result<usize, Box<dyn Error>> {
Ok(usize::try_from(u64_field(value, name)?)?)
}
fn u64_field(value: &Value, name: &str) -> Result<u64, Box<dyn Error>> {
value.get(name).and_then(Value::as_u64).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, format!("missing frame field {name}")).into()
})
}
pub(super) struct TestDirectory(PathBuf);
impl TestDirectory {
pub(super) fn new() -> Result<Self, io::Error> {
let path = std::env::temp_dir().join(format!(
"ely-sidecar-test-{}-{}",
std::process::id(),
ProfileId::new()
));
fs::create_dir_all(&path)?;
Ok(Self(path))
}
pub(super) fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
pub(super) struct TestServer {
address: SocketAddr,
stop: Arc<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
diagnostics: Arc<Mutex<ServerDiagnostics>>,
}
#[derive(Default)]
struct ServerDiagnostics {
requests: Vec<String>,
errors: Vec<String>,
}
impl TestServer {
pub(super) fn start() -> Result<Self, io::Error> {
let listener = TcpListener::bind(("127.0.0.1", 0))?;
listener.set_nonblocking(true)?;
let address = listener.local_addr()?;
let stop = Arc::new(AtomicBool::new(false));
let thread_stop = stop.clone();
let diagnostics = Arc::new(Mutex::new(ServerDiagnostics::default()));
let thread_diagnostics = diagnostics.clone();
let thread = thread::spawn(move || serve(listener, &thread_stop, &thread_diagnostics));
Ok(Self { address, stop, thread: Some(thread), diagnostics })
}
pub(super) fn url(&self, path: &str) -> String {
format!("http://{}{path}", self.address)
}
pub(super) fn diagnostics(&self) -> String {
self.diagnostics.lock().map_or_else(
|_| "lock poisoned".to_string(),
|diagnostics| {
format!("requests={:?}, errors={:?}", diagnostics.requests, diagnostics.errors)
},
)
}
pub(super) fn request_count(&self, path: &str) -> usize {
self.diagnostics.lock().map_or(0, |diagnostics| {
diagnostics
.requests
.iter()
.filter(|request| {
request.split_whitespace().nth(1).is_some_and(|url| {
url == path
|| url.strip_prefix(path).is_some_and(|suffix| suffix.starts_with('?'))
})
})
.count()
})
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
let _ = TcpStream::connect(self.address);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn serve(listener: TcpListener, stop: &AtomicBool, diagnostics: &Mutex<ServerDiagnostics>) {
while !stop.load(Ordering::Acquire) {
match listener.accept() {
Ok((stream, _)) => {
if let Err(error) = serve_connection(stream, diagnostics)
&& let Ok(mut diagnostics) = diagnostics.lock()
{
diagnostics.errors.push(error.to_string());
}
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(2));
}
Err(_) => return,
}
}
}
fn serve_connection(
stream: TcpStream,
diagnostics: &Mutex<ServerDiagnostics>,
) -> Result<(), io::Error> {
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
let mut reader = BufReader::new(stream);
let mut request_line = String::new();
reader.read_line(&mut request_line)?;
if let Ok(mut diagnostics) = diagnostics.lock() {
diagnostics.requests.push(request_line.trim().to_string());
}
loop {
let mut header_line = String::new();
if reader.read_line(&mut header_line)? == 0 || header_line == "\r\n" {
break;
}
}
let path = request_line.split_whitespace().nth(1).unwrap_or("/");
let is_set = path == "/set";
let body = if is_set {
SET_PAGE
} else if path.starts_with("/history") {
HISTORY_PAGE
} else if path == "/white" {
WHITE_PAGE
} else {
READ_PAGE
};
let cookie_header =
if is_set { "Set-Cookie: ely_cookie=persisted; Path=/; SameSite=Lax\r\n" } else { "" };
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\n{cookie_header}Connection: close\r\n\r\n{body}",
body.len()
);
reader.get_mut().write_all(response.as_bytes())?;
reader.get_mut().flush()
}
const SET_PAGE: &str = r#"<!doctype html><title>loading</title><style>body{font:24px sans-serif;color:#111;background:#fff}</style><body>Profile persistence</body><script>localStorage.setItem('ely_storage','persisted');const cookie=document.cookie.includes('ely_cookie=persisted')?'yes':'no';const storage=localStorage.getItem('ely_storage')==='persisted'?'yes':'no';document.title=`stored-cookie-${cookie}-storage-${storage}`;</script>"#;
const READ_PAGE: &str = r#"<!doctype html><title>loading</title><style>body{font:24px sans-serif;color:#111;background:#fff}</style><body>Profile persistence</body><script>const cookie=document.cookie.includes('ely_cookie=persisted')?'yes':'no';const storage=localStorage.getItem('ely_storage')==='persisted'?'yes':'no';document.title=`read-cookie-${cookie}-storage-${storage}`;</script>"#;
const HISTORY_PAGE: &str = r#"<!doctype html><title>loading</title><style>body{font:24px sans-serif;color:#111;background:#fff}</style><body>History mutation</body><script>history.replaceState({},'', '/history?state=1');document.title='history-ready';</script>"#;
const WHITE_PAGE: &str = r#"<!doctype html><title>white-ready</title><style>html,body{margin:0;width:100%;height:100%;background:#fff}</style>"#;
+53 -120
View File
@@ -4,8 +4,6 @@ use std::{
env,
error::Error,
process::{Command, Stdio},
thread,
time::Duration,
};
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature, TabId, UrlText};
@@ -27,9 +25,9 @@ const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[
const SOFTWARE_HOST_CHILD_ENV: &str = "ELY_SERVO_SOFTWARE_HOST_CHILD";
const DPR_VIEWPORT_CHILD_ENV: &str = "ELY_SERVO_DPR_VIEWPORT_CHILD";
const CLICK_PROBE_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EClick%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Clicked%27%3Bthis.textContent%3D%27Clicked%27%3B%22%3ETap%3C%2Fbutton%3E";
const DRAG_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3EDrag%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20id%3Dbox%3EDrag%3C%2Fbutton%3E%3Cscript%3Elet%20dragging%3Dfalse%3Bconst%20box%3Ddocument.getElementById%28%27box%27%29%3BaddEventListener%28%27mousedown%27%2Cevent%3D%3E%7Bif%28event.target%3D%3D%3Dbox%29%7Bdragging%3Dtrue%3B%7D%7D%29%3BaddEventListener%28%27mousemove%27%2Cevent%3D%3E%7Bif%28dragging%26%26event.clientX%3E280%29%7Bdocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Dragged%27%3Bbox.textContent%3D%27Dragged%27%3B%7D%7D%29%3BaddEventListener%28%27mouseup%27%2C%28%29%3D%3E%7Bdragging%3Dfalse%3B%7D%29%3B%3C%2Fscript%3E";
const TOUCH_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3ETouch%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3Btouch-action%3Amanipulation%3B%7D%3C%2Fstyle%3E%3Cbutton%20ontouchstart%3D%22document.body.dataset.touch%3D%27start%27%3B%22%20onpointerdown%3D%22if%28%21document.body.dataset.pointerType%29%7Bdocument.body.dataset.pointerType%3Devent.pointerType%3B%7D%22%20onclick%3D%22if%28document.body.dataset.pointerType%21%3D%3D%27touch%27%29%7Bdocument.title%3Ddocument.body.dataset.pointerType%3Breturn%3B%7Ddocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E";
const TEXT_PROBE_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EText%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3Bfont%3A28px%20sans-serif%3B%7Dinput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A260px%3Bheight%3A70px%3Bfont%3A28px%20sans-serif%3B%7Doutput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A180px%3Bfont%3A32px%20sans-serif%3B%7D%3C%2Fstyle%3E%3Cinput%20id%3Dq%20autofocus%20oninput%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.getElementById%28%27out%27%29.textContent%3Dthis.value%3B%22%3E%3Coutput%20id%3Dout%3Eempty%3C%2Foutput%3E";
const DRAG_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3EDrag%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f6d365%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20id%3Dbox%3EDrag%3C%2Fbutton%3E%3Cscript%3Elet%20dragging%3Dfalse%3Bconst%20box%3Ddocument.getElementById%28%27box%27%29%3BaddEventListener%28%27mousedown%27%2Cevent%3D%3E%7Bif%28event.target%3D%3D%3Dbox%29%7Bdragging%3Dtrue%3B%7D%7D%29%3BaddEventListener%28%27mousemove%27%2Cevent%3D%3E%7Bif%28dragging%26%26event.clientX%3E280%29%7Bdocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Dragged%27%3Bbox.textContent%3D%27Dragged%27%3B%7D%7D%29%3BaddEventListener%28%27mouseup%27%2C%28%29%3D%3E%7Bdragging%3Dfalse%3B%7D%29%3B%3C%2Fscript%3E";
const TOUCH_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3ETouch%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23c7f5d9%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3Btouch-action%3Amanipulation%3B%7D%3C%2Fstyle%3E%3Cbutton%20ontouchstart%3D%22document.body.dataset.touch%3D%27start%27%3B%22%20onpointerdown%3D%22if%28%21document.body.dataset.pointerType%29%7Bdocument.body.dataset.pointerType%3Devent.pointerType%3B%7D%22%20onclick%3D%22if%28document.body.dataset.pointerType%21%3D%3D%27touch%27%29%7Bdocument.title%3Ddocument.body.dataset.pointerType%3Breturn%3B%7Ddocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E";
const TEXT_PROBE_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EText%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23d9e8ff%3Bfont%3A28px%20sans-serif%3B%7Dinput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A260px%3Bheight%3A70px%3Bfont%3A28px%20sans-serif%3B%7Doutput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A180px%3Bfont%3A32px%20sans-serif%3B%7D%3C%2Fstyle%3E%3Cinput%20id%3Dq%20autofocus%20oninput%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.getElementById%28%27out%27%29.textContent%3Dthis.value%3B%22%3E%3Coutput%20id%3Dout%3Eempty%3C%2Foutput%3E";
const TEXT_PROBE_VALUE: &str = "ely42";
struct PrdSiteCompatibilityCase {
@@ -258,15 +256,27 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.click(MouseClickRequest { webview_id: webview_id.clone(), x: 160, y: 120 })?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;
let snapshot = wait_for_rendered_webview_with_title(
&mut host,
&webview_id,
Some(previous_frame_hash),
"Clicked",
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_eq!(snapshot.title(), Some("Clicked"), "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html clicked", 1)?;
assert_ne!(host.last_rendered_frame()?.sample_hash(), previous_frame_hash);
let tab_id = TabId::new();
let url = UrlText::parse(DRAG_PROBE_URL)?;
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, url })?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, None)?;
let snapshot = wait_for_rendered_webview_with_center_pixel(
&mut host,
&webview_id,
Some(previous_frame_hash),
[246, 211, 101],
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html drag", 1)?;
@@ -278,21 +288,38 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
to_x: 320,
to_y: 120,
})?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;
let snapshot = wait_for_rendered_webview_with_title(
&mut host,
&webview_id,
Some(previous_frame_hash),
"Dragged",
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_eq!(snapshot.title(), Some("Dragged"), "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html dragged", 1)?;
assert_ne!(host.last_rendered_frame()?.sample_hash(), previous_frame_hash);
let tab_id = TabId::new();
let url = UrlText::parse(TOUCH_PROBE_URL)?;
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, url })?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, None)?;
let snapshot = wait_for_rendered_webview_with_center_pixel(
&mut host,
&webview_id,
Some(previous_frame_hash),
[199, 245, 217],
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html touch", 1)?;
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.touch_tap(TouchTapRequest { webview_id: webview_id.clone(), x: 160, y: 120 })?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;
let snapshot = wait_for_rendered_webview_with_title(
&mut host,
&webview_id,
Some(previous_frame_hash),
"Touched",
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_eq!(snapshot.title(), Some("Touched"), "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html touched", 1)?;
@@ -300,8 +327,14 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
let tab_id = TabId::new();
let url = UrlText::parse(TEXT_PROBE_URL)?;
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, url })?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, None)?;
let snapshot = wait_for_rendered_webview_with_center_pixel(
&mut host,
&webview_id,
Some(previous_frame_hash),
[217, 232, 255],
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html input", 1)?;
@@ -311,7 +344,12 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
webview_id: webview_id.clone(),
text: TEXT_PROBE_VALUE.to_string(),
})?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;
let snapshot = wait_for_rendered_webview_with_center_pixel(
&mut host,
&webview_id,
Some(previous_frame_hash),
[0, 57, 255],
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html typed", 1)?;
assert_ne!(host.last_rendered_frame()?.sample_hash(), previous_frame_hash);
@@ -390,111 +428,6 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
Ok(())
}
fn wait_for_rendered_webview(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
previous_frame_hash: Option<u64>,
) -> Result<ely_servo_host::WebViewSnapshot, Box<dyn Error>> {
let mut painted_since_request = false;
for _ in 0..5_000 {
host.tick();
let snapshot = host.snapshot(webview_id)?;
if snapshot.has_pending_frame() {
host.paint(webview_id)?;
painted_since_request = true;
}
let snapshot = host.snapshot(webview_id)?;
let has_rendered_current_request = host.last_rendered_frame().is_ok_and(|frame| {
painted_since_request
&& Some(frame.sample_hash()) != previous_frame_hash
&& frame.non_white_pixel_count() > 0
});
if snapshot.state() == &WebViewState::Complete && has_rendered_current_request {
return Ok(snapshot);
}
thread::sleep(Duration::from_millis(2));
}
Err(format!("timed out waiting for rendered webview: {:?}", host.snapshot(webview_id)?).into())
}
fn assert_rendered_frame_has_content(
host: &SoftwareServoHost,
label: &str,
minimum_content_pixels: u64,
) -> Result<(), Box<dyn Error>> {
assert_rendered_frame_has_dimensions_and_content(
host,
label,
INITIAL_WIDTH,
INITIAL_HEIGHT,
minimum_content_pixels,
)
}
fn assert_rendered_frame_has_dimensions_and_content(
host: &SoftwareServoHost,
label: &str,
expected_width: u32,
expected_height: u32,
minimum_content_pixels: u64,
) -> Result<(), Box<dyn Error>> {
let frame = host.last_rendered_frame()?;
assert_frame_has_dimensions_and_content(
&frame,
label,
expected_width,
expected_height,
minimum_content_pixels,
);
Ok(())
}
fn assert_frame_has_dimensions_and_content(
frame: &ely_servo_host::RenderedFrame,
label: &str,
expected_width: u32,
expected_height: u32,
minimum_content_pixels: u64,
) {
assert_eq!(frame.width(), expected_width, "{label}: {frame:?}");
assert_eq!(frame.height(), expected_height, "{label}: {frame:?}");
assert!(frame.opaque_pixel_count() > 0, "{label}: {frame:?}");
assert!(frame.non_white_pixel_count() > 0, "{label}: {frame:?}");
assert!(frame.content_pixel_count() >= minimum_content_pixels, "{label}: {frame:?}");
assert_ne!(frame.sample_hash(), 0, "{label}: {frame:?}");
}
fn center_pixel_rgb(frame: &ely_servo_host::RenderedFrame) -> [u8; 3] {
let x = frame.width() / 2;
let y = frame.height() / 2;
let index = ((y * frame.width() + x) * 4) as usize;
let rgba = &frame.rgba_bytes()[index..index + 4];
[rgba[0], rgba[1], rgba[2]]
}
fn viewport_probe_url(min_width_threshold: u32) -> String {
let html = format!(
"<!doctype html><title>DPR Probe</title><style>\
html,body{{margin:0;width:100%;height:100%;background:rgb(238,32,77);}}\
@media (min-width:{min_width_threshold}px){{html,body{{background:rgb(0,57,255);}}}}\
</style>",
);
format!("data:text/html,{}", percent_encode_for_data_url(&html))
}
fn percent_encode_for_data_url(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
#[path = "software_host/support.rs"]
mod support;
use support::*;
@@ -0,0 +1,158 @@
use std::{error::Error, thread, time::Duration};
use ely_servo_host::{RenderedFrame, ServoHost, SoftwareServoHost, WebViewSnapshot, WebViewState};
use super::{INITIAL_HEIGHT, INITIAL_WIDTH};
pub(super) fn wait_for_rendered_webview(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
previous_frame_hash: Option<u64>,
) -> Result<WebViewSnapshot, Box<dyn Error>> {
wait_for_rendered_webview_matching(host, webview_id, previous_frame_hash, |_| true, |_| true)
}
pub(super) fn wait_for_rendered_webview_with_title(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
previous_frame_hash: Option<u64>,
expected_title: &str,
) -> Result<WebViewSnapshot, Box<dyn Error>> {
wait_for_rendered_webview_matching(
host,
webview_id,
previous_frame_hash,
|snapshot| snapshot.title() == Some(expected_title),
|_| true,
)
}
pub(super) fn wait_for_rendered_webview_with_center_pixel(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
previous_frame_hash: Option<u64>,
expected_rgb: [u8; 3],
) -> Result<WebViewSnapshot, Box<dyn Error>> {
wait_for_rendered_webview_matching(
host,
webview_id,
previous_frame_hash,
|_| true,
|frame| center_pixel_rgb(frame) == expected_rgb,
)
}
fn wait_for_rendered_webview_matching(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
previous_frame_hash: Option<u64>,
snapshot_matches: impl Fn(&WebViewSnapshot) -> bool,
frame_matches: impl Fn(&RenderedFrame) -> bool,
) -> Result<WebViewSnapshot, Box<dyn Error>> {
let mut painted_since_request = false;
for _ in 0..5_000 {
host.tick();
let snapshot = host.snapshot(webview_id)?;
if snapshot.has_pending_frame() {
host.paint(webview_id)?;
painted_since_request = true;
}
let snapshot = host.snapshot(webview_id)?;
let has_rendered_current_request = host.last_rendered_frame().is_ok_and(|frame| {
painted_since_request
&& Some(frame.sample_hash()) != previous_frame_hash
&& frame.non_white_pixel_count() > 0
&& frame_matches(&frame)
});
if snapshot.state() == &WebViewState::Complete
&& has_rendered_current_request
&& snapshot_matches(&snapshot)
{
return Ok(snapshot);
}
thread::sleep(Duration::from_millis(2));
}
Err(format!("timed out waiting for rendered webview: {:?}", host.snapshot(webview_id)?).into())
}
pub(super) fn assert_rendered_frame_has_content(
host: &SoftwareServoHost,
label: &str,
minimum_content_pixels: u64,
) -> Result<(), Box<dyn Error>> {
assert_rendered_frame_has_dimensions_and_content(
host,
label,
INITIAL_WIDTH,
INITIAL_HEIGHT,
minimum_content_pixels,
)
}
pub(super) fn assert_rendered_frame_has_dimensions_and_content(
host: &SoftwareServoHost,
label: &str,
expected_width: u32,
expected_height: u32,
minimum_content_pixels: u64,
) -> Result<(), Box<dyn Error>> {
let frame = host.last_rendered_frame()?;
assert_frame_has_dimensions_and_content(
&frame,
label,
expected_width,
expected_height,
minimum_content_pixels,
);
Ok(())
}
fn assert_frame_has_dimensions_and_content(
frame: &RenderedFrame,
label: &str,
expected_width: u32,
expected_height: u32,
minimum_content_pixels: u64,
) {
assert_eq!(frame.width(), expected_width, "{label}: {frame:?}");
assert_eq!(frame.height(), expected_height, "{label}: {frame:?}");
assert!(frame.opaque_pixel_count() > 0, "{label}: {frame:?}");
assert!(frame.non_white_pixel_count() > 0, "{label}: {frame:?}");
assert!(frame.content_pixel_count() >= minimum_content_pixels, "{label}: {frame:?}");
assert_ne!(frame.sample_hash(), 0, "{label}: {frame:?}");
}
pub(super) fn center_pixel_rgb(frame: &RenderedFrame) -> [u8; 3] {
let x = frame.width() / 2;
let y = frame.height() / 2;
let index = ((y * frame.width() + x) * 4) as usize;
let rgba = &frame.rgba_bytes()[index..index + 4];
[rgba[0], rgba[1], rgba[2]]
}
pub(super) fn viewport_probe_url(min_width_threshold: u32) -> String {
let html = format!(
"<!doctype html><title>DPR Probe</title><style>\
html,body{{margin:0;width:100%;height:100%;background:rgb(238,32,77);}}\
@media (min-width:{min_width_threshold}px){{html,body{{background:rgb(0,57,255);}}}}\
</style>",
);
format!("data:text/html,{}", percent_encode_for_data_url(&html))
}
fn percent_encode_for_data_url(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}