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
+11 -1
View File
@@ -15,7 +15,6 @@ ed25519-dalek.workspace = true
ely_browser_core = { path = "../ely_browser_core" }
ely_design_system = { path = "../ely_design_system" }
ely_domain = { path = "../ely_domain" }
ely_servo_host = { path = "../ely_servo_host", features = ["servo-engine"] }
ely_sync_client = { path = "../ely_sync_client" }
gpui.workspace = true
gpui-component.workspace = true
@@ -24,13 +23,24 @@ image.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
tempfile.workspace = true
thiserror.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
ureq.workspace = true
url.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
core-video = "0.4"
io-surface = "0.16"
mach2 = "0.6"
objc2-core-foundation = { version = "0.3.2", features = ["CFBase", "CFDictionary", "CFNumber", "CFString"] }
objc2-io-surface = "0.3.2"
uuid.workspace = true
[dev-dependencies]
ely_servo_host = { path = "../ely_servo_host" }
gpui = { workspace = true, features = ["test-support"] }
[build-dependencies]
+79 -31
View File
@@ -11,24 +11,19 @@ fn main() -> Result<(), Box<dyn Error>> {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
let workspace_root = workspace_root(&manifest_dir)?;
let workspace_manifest_path = workspace_root.join("Cargo.toml");
let git_head_path = workspace_root.join(".git/HEAD");
let workspace_manifest = read_workspace_manifest(&workspace_manifest_path)?;
let workspace = table(&workspace_manifest, "workspace")?;
let package = table(workspace, "package")?;
let dependencies = table(workspace, "dependencies")?;
emit_cargo_directive(format!("cargo:rerun-if-changed={}", workspace_manifest_path.display()))?;
emit_cargo_directive(format!("cargo:rerun-if-changed={}", git_head_path.display()))?;
if let Some(ref_path) = git_head_ref(&git_head_path)? {
emit_cargo_directive(format!(
"cargo:rerun-if-changed={}",
workspace_root.join(".git").join(ref_path).display()
))?;
}
emit_cargo_directive("cargo:rerun-if-env-changed=ELY_BUILD_REVISION")?;
emit_env("ELY_BUILD_REVISION", &git_revision(workspace_root)?)?;
emit_env("ELY_BUILD_REVISION", &build_revision(workspace_root)?)?;
emit_env("ELY_WORKSPACE_LICENSE", string_value(package, "license")?)?;
emit_env("ELY_WORKSPACE_MANIFEST", path_value(&workspace_manifest_path)?)?;
if env::var("PROFILE").as_deref() == Ok("debug") {
emit_env("ELY_WORKSPACE_MANIFEST", path_value(&workspace_manifest_path)?)?;
}
emit_env("ELY_GPUI_VERSION", dependency_version(dependencies, "gpui")?)?;
emit_env("ELY_GPUI_COMPONENT_VERSION", dependency_version(dependencies, "gpui-component")?)?;
emit_env("ELY_SERVO_VERSION", dependency_version(dependencies, "servo")?)?;
@@ -90,39 +85,92 @@ fn dependency_version<'a>(
}
}
fn git_revision(workspace_root: &Path) -> Result<String, Box<dyn Error>> {
let output = Command::new("git")
.args(["rev-parse", "--short=12", "HEAD"])
.current_dir(workspace_root)
.output()?;
if !output.status.success() {
return Err(
io::Error::new(io::ErrorKind::InvalidData, "git revision is unavailable").into()
);
fn build_revision(workspace_root: &Path) -> Result<String, Box<dyn Error>> {
match env::var("ELY_BUILD_REVISION") {
Ok(revision) => return validated_revision(revision),
Err(env::VarError::NotUnicode(_)) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"ELY_BUILD_REVISION is not UTF-8",
)
.into());
}
Err(env::VarError::NotPresent) => {}
}
let revision = String::from_utf8(output.stdout)?.trim().to_string();
if revision.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "git revision is empty").into());
match fs::symlink_metadata(workspace_root.join(".git")) {
Ok(_) => git_revision(workspace_root),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
validated_revision(format!("source-{}", env::var("CARGO_PKG_VERSION")?))
}
Err(error) => Err(error.into()),
}
Ok(revision)
}
fn git_head_ref(git_head_path: &Path) -> Result<Option<String>, Box<dyn Error>> {
let head = fs::read_to_string(git_head_path)?;
let Some(ref_path) = head.trim().strip_prefix("ref: ") else {
fn git_revision(workspace_root: &Path) -> Result<String, Box<dyn Error>> {
emit_git_watch(workspace_root, "HEAD")?;
emit_git_watch(workspace_root, "packed-refs")?;
if let Some(reference) = git_optional_output(workspace_root, &["symbolic-ref", "-q", "HEAD"])? {
emit_git_watch(workspace_root, &reference)?;
}
let revision = git_output(workspace_root, &["rev-parse", "--short=12", "HEAD"])?;
validated_revision(revision)
}
fn emit_git_watch(workspace_root: &Path, git_path: &str) -> Result<(), Box<dyn Error>> {
let path = PathBuf::from(git_output(workspace_root, &["rev-parse", "--git-path", git_path])?);
let path = if path.is_absolute() { path } else { workspace_root.join(path) };
emit_cargo_directive(format!("cargo:rerun-if-changed={}", path.display()))
}
fn git_output(workspace_root: &Path, arguments: &[&str]) -> Result<String, Box<dyn Error>> {
let output = Command::new("git").args(arguments).current_dir(workspace_root).output()?;
if !output.status.success() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("git {} failed", arguments.join(" ")),
)
.into());
}
String::from_utf8(output.stdout).map(|value| value.trim().to_string()).map_err(Into::into)
}
fn git_optional_output(
workspace_root: &Path,
arguments: &[&str],
) -> Result<Option<String>, Box<dyn Error>> {
let output = Command::new("git").args(arguments).current_dir(workspace_root).output()?;
if output.status.success() {
return String::from_utf8(output.stdout)
.map(|value| Some(value.trim().to_string()))
.map_err(Into::into);
}
if output.status.code() == Some(1) {
return Ok(None);
};
Ok(Some(ref_path.to_string()))
}
Err(io::Error::new(io::ErrorKind::InvalidData, format!("git {} failed", arguments.join(" ")))
.into())
}
fn validated_revision(revision: String) -> Result<String, Box<dyn Error>> {
let revision = revision.trim();
validate_env_value("ELY_BUILD_REVISION", revision)?;
Ok(revision.to_string())
}
fn emit_env(key: &str, value: &str) -> Result<(), Box<dyn Error>> {
if value.trim().is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("{key} is empty")).into());
}
validate_env_value(key, value)?;
emit_cargo_directive(format!("cargo:rustc-env={key}={value}"))?;
Ok(())
}
fn validate_env_value(key: &str, value: &str) -> Result<(), Box<dyn Error>> {
if value.trim().is_empty() || value.contains('\r') || value.contains('\n') {
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("invalid {key}")).into());
}
Ok(())
}
fn emit_cargo_directive(directive: impl AsRef<str>) -> Result<(), Box<dyn Error>> {
writeln!(io::stdout(), "{}", directive.as_ref())?;
Ok(())
@@ -0,0 +1,341 @@
#![cfg(target_os = "macos")]
use std::{
collections::BTreeMap,
ffi::CString,
mem,
time::{Duration, Instant},
};
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_TIMEOUT_NONE, MACH_MSG_TYPE_MAKE_SEND,
MACH_MSG_TYPE_MOVE_SEND, MACH_MSGH_BITS_COMPLEX, 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,
};
use thiserror::Error;
use uuid::Uuid;
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
const SERVICE_PREFIX: &str = "com.ely.browser.iosurface";
const MAX_PENDING_SURFACE_PORTS: usize = 16;
pub(crate) struct IOSurfaceMachReceiver {
service_name: String,
receive_port: mach_port_t,
pending_ports: BTreeMap<u64, mach_port_t>,
}
#[derive(Debug, Error)]
pub(crate) enum IOSurfaceMachError {
#[error("Mach service name contains an interior nul byte")]
InvalidServiceName,
#[error("mach_port_allocate returned {code}")]
AllocatePort { code: i32 },
#[error("mach_port_insert_right returned {code}")]
InsertSendRight { code: i32 },
#[error("bootstrap_register returned {code}")]
RegisterService { code: i32 },
#[error("mach_msg receive timed out for IOSurface surface {surface_id:#x}")]
ReceiveTimedOut { surface_id: u64 },
#[error("mach_msg receive returned {code}")]
Receive { code: i32 },
#[error("received unexpected Mach message id {message_id}")]
UnexpectedMessage { message_id: i32 },
#[error("received invalid IOSurface Mach message")]
InvalidMessage,
}
impl IOSurfaceMachReceiver {
pub(crate) fn new() -> Result<Self, IOSurfaceMachError> {
let service_name = unique_service_name();
let service_name_c = CString::new(service_name.as_str())
.map_err(|_| IOSurfaceMachError::InvalidServiceName)?;
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(IOSurfaceMachError::AllocatePort { code: allocate });
}
#[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(IOSurfaceMachError::InsertSendRight { code: insert });
}
#[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(IOSurfaceMachError::RegisterService { code: register });
}
Ok(Self { service_name, receive_port, pending_ports: BTreeMap::new() })
}
pub(crate) fn service_name(&self) -> &str {
self.service_name.as_str()
}
pub(crate) fn receive_port_for_surface(
&mut self,
surface_id: u64,
timeout: Duration,
) -> Result<mach_port_t, IOSurfaceMachError> {
if let Some(port) = self.pending_ports.remove(&surface_id) {
return Ok(port);
}
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id });
}
let Some(received) = self.receive_one(remaining)? else {
return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id });
};
if received.surface_id == surface_id {
return Ok(received.mach_port);
}
if let Some(discarded) = insert_pending_port(
&mut self.pending_ports,
received.surface_id,
received.mach_port,
) {
deallocate_port(discarded);
}
}
}
fn receive_one(
&self,
timeout: Duration,
) -> Result<Option<ReceivedSurfacePort>, IOSurfaceMachError> {
#[expect(unsafe_code)]
let mut received_message: ReceivedIOSurfacePortMessage = unsafe { mem::zeroed() };
#[expect(unsafe_code)]
let result = unsafe {
mach_msg(
&mut received_message.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 Ok(None);
}
if result != MACH_MSG_SUCCESS {
return Err(IOSurfaceMachError::Receive { code: result });
}
let message = &mut received_message.message;
if message.header.msgh_id != IOSURFACE_PORT_MESSAGE_ID {
let message_id = message.header.msgh_id;
destroy_message(message);
return Err(IOSurfaceMachError::UnexpectedMessage { message_id });
}
if !is_valid_surface_port_message(message) {
destroy_message(message);
return Err(IOSurfaceMachError::InvalidMessage);
}
Ok(Some(ReceivedSurfacePort {
surface_id: message.surface_id,
mach_port: message.surface_port.name,
}))
}
}
fn insert_pending_port(
pending_ports: &mut BTreeMap<u64, mach_port_t>,
surface_id: u64,
mach_port: mach_port_t,
) -> Option<mach_port_t> {
if let Some(replaced) = pending_ports.insert(surface_id, mach_port) {
return Some(replaced);
}
if pending_ports.len() > MAX_PENDING_SURFACE_PORTS
&& let Some(evicted_id) = pending_ports.keys().next().copied()
{
return pending_ports.remove(&evicted_id);
}
None
}
impl Drop for IOSurfaceMachReceiver {
fn drop(&mut self) {
for port in std::mem::take(&mut self.pending_ports).into_values() {
deallocate_port(port);
}
destroy_port(self.receive_port);
}
}
struct ReceivedSurfacePort {
surface_id: u64,
mach_port: mach_port_t,
}
#[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 is_valid_surface_port_message(message: &IOSurfacePortMessage) -> bool {
message.header.msgh_bits & MACH_MSGH_BITS_COMPLEX != 0
&& message.header.msgh_size == mem::size_of::<IOSurfacePortMessage>() as u32
&& message.body.msgh_descriptor_count == 1
&& message.surface_port.type_ == MACH_MSG_PORT_DESCRIPTOR as u8
&& message.surface_port.disposition == MACH_MSG_TYPE_MOVE_SEND as u8
&& message.surface_port.name != MACH_PORT_NULL
}
fn unique_service_name() -> String {
format!("{SERVICE_PREFIX}.{}", Uuid::now_v7().as_simple())
}
fn timeout_millis(timeout: Duration) -> u32 {
u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(MACH_MSG_TIMEOUT_NONE + 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) {
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
unsafe {
let _ = mach_port_deallocate(task, port);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn surface_port_message() -> IOSurfacePortMessage {
IOSurfacePortMessage {
header: mach_msg_header_t {
msgh_bits: MACH_MSGH_BITS_COMPLEX,
msgh_size: mem::size_of::<IOSurfacePortMessage>() as u32,
msgh_id: IOSURFACE_PORT_MESSAGE_ID,
..mach_msg_header_t::default()
},
body: mach_msg_body_t { msgh_descriptor_count: 1 },
surface_port: mach_msg_port_descriptor_t::new(42, MACH_MSG_TYPE_MOVE_SEND),
surface_id: 7,
}
}
#[test]
fn surface_port_message_requires_kernel_translated_complex_descriptor() {
let mut message = surface_port_message();
assert!(is_valid_surface_port_message(&message));
message.header.msgh_bits &= !MACH_MSGH_BITS_COMPLEX;
assert!(!is_valid_surface_port_message(&message));
message = surface_port_message();
message.header.msgh_size -= 1;
assert!(!is_valid_surface_port_message(&message));
message = surface_port_message();
message.surface_port.disposition = mach2::message::MACH_MSG_TYPE_COPY_SEND as u8;
assert!(!is_valid_surface_port_message(&message));
}
#[test]
fn receive_rejects_simple_message_with_forged_port_descriptor() -> Result<(), IOSurfaceMachError>
{
let receiver = IOSurfaceMachReceiver::new()?;
let mut message = surface_port_message();
message.header.msgh_bits =
mach2::message::MACH_MSGH_BITS(mach2::message::MACH_MSG_TYPE_COPY_SEND, 0);
message.header.msgh_remote_port = receiver.receive_port;
message.surface_port.name = receiver.receive_port;
#[expect(unsafe_code)]
let send = unsafe {
mach_msg(
&mut message.header,
mach2::message::MACH_SEND_MSG,
message.header.msgh_size,
0,
MACH_PORT_NULL,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL,
)
};
assert_eq!(send, MACH_MSG_SUCCESS);
assert!(matches!(
receiver.receive_one(Duration::from_secs(1)),
Err(IOSurfaceMachError::InvalidMessage)
));
Ok(())
}
#[test]
fn pending_port_map_evicts_lowest_surface_at_capacity() {
let mut ports = BTreeMap::new();
for surface_id in 0..=MAX_PENDING_SURFACE_PORTS as u64 {
let discarded = insert_pending_port(&mut ports, surface_id, surface_id as mach_port_t);
if surface_id < MAX_PENDING_SURFACE_PORTS as u64 {
assert_eq!(discarded, None);
} else {
assert_eq!(discarded, Some(0));
}
}
assert_eq!(ports.len(), MAX_PENDING_SURFACE_PORTS);
assert!(!ports.contains_key(&0));
assert!(ports.contains_key(&(MAX_PENDING_SURFACE_PORTS as u64)));
}
}
@@ -0,0 +1,419 @@
#![cfg(target_os = "macos")]
use std::{
collections::{BTreeMap, VecDeque},
sync::{Arc, Weak},
};
use core_foundation::base::TCFType as _;
use core_video::pixel_buffer::CVPixelBuffer;
#[allow(deprecated)]
use io_surface::IOSurface;
use mach2::{mach_port::mach_port_deallocate, traps::mach_task_self};
use objc2_core_foundation::CFRetained;
use objc2_io_surface::IOSurfaceRef;
use thiserror::Error;
pub(crate) struct IOSurfaceCache {
surfaces: BTreeMap<u64, CachedSurface>,
insertion_order: VecDeque<u64>,
}
const MAX_CACHED_SURFACES: usize = 16;
struct CachedSurface {
iosurface: CFRetained<IOSurfaceRef>,
pending_backing: Option<Arc<HardwareSurfaceBacking>>,
active_backing: Weak<HardwareSurfaceBacking>,
width: u32,
height: u32,
}
#[derive(Debug, Error)]
pub(crate) enum SurfaceImportError {
#[error("IOSurfaceLookupFromMachPort returned null for port 0x{port:x}")]
LookupFailed { port: u32 },
#[error("CVPixelBufferCreateWithIOSurface returned status {status}")]
PixelBufferBuildFailed { status: i32 },
}
pub(crate) struct ImportedPixelBuffer {
pub(crate) system_surface_id: u64,
pub(crate) iosurface: CFRetained<IOSurfaceRef>,
pub(crate) backing: Arc<HardwareSurfaceBacking>,
}
pub(crate) struct HardwareSurfaceBacking {
pixel_buffer: CVPixelBuffer,
}
// SAFETY: `CVPixelBuffer` is a retained, read-only CoreVideo frame here.
// CoreFoundation retain/release is thread-safe, and both the runtime and GPU
// paths only clone the handle or submit the immutable IOSurface-backed image.
#[expect(unsafe_code)]
unsafe impl Send for HardwareSurfaceBacking {}
// SAFETY: The backing exposes immutable pixel-buffer metadata and clones.
// IOSurface lifetime tracking remains owned by CoreVideo's retained buffer.
#[expect(unsafe_code)]
unsafe impl Sync for HardwareSurfaceBacking {}
impl HardwareSurfaceBacking {
pub(crate) fn new(pixel_buffer: CVPixelBuffer) -> Arc<Self> {
Arc::new(Self { pixel_buffer })
}
pub(crate) fn pixel_buffer(&self) -> &CVPixelBuffer {
&self.pixel_buffer
}
}
impl IOSurfaceCache {
pub(crate) fn new() -> Self {
Self { surfaces: BTreeMap::new(), insertion_order: VecDeque::new() }
}
#[cfg(test)]
fn import(&mut self, mach_port_name: u32, surface_id: u64) -> Result<(), SurfaceImportError> {
let imported = import_pixel_buffer_from_mach_port(mach_port_name)?;
self.insert_imported(surface_id, imported);
Ok(())
}
pub(crate) fn insert_imported(&mut self, surface_id: u64, imported: ImportedPixelBuffer) {
let width = imported.backing.pixel_buffer().get_width() as u32;
let height = imported.backing.pixel_buffer().get_height() as u32;
if self
.surfaces
.get(&surface_id)
.is_some_and(|cached| cached.width == width && cached.height == height)
{
return;
}
if !self.surfaces.contains_key(&surface_id) {
while self.surfaces.len() >= MAX_CACHED_SURFACES {
if !self.evict_preferred_surface() {
break;
}
}
self.insertion_order.push_back(surface_id);
}
self.surfaces.insert(
surface_id,
CachedSurface {
iosurface: imported.iosurface,
pending_backing: Some(imported.backing),
active_backing: Weak::new(),
width,
height,
},
);
}
pub(crate) fn hardware_surface_for(
&mut self,
surface_id: u64,
) -> Result<Option<Arc<HardwareSurfaceBacking>>, SurfaceImportError> {
let Some(cached) = self.surfaces.get_mut(&surface_id) else {
return Ok(None);
};
let backing = cached
.active_backing
.upgrade()
.or_else(|| cached.pending_backing.take())
.map(Ok)
.unwrap_or_else(|| build_hardware_surface_backing(&cached.iosurface))?;
cached.active_backing = Arc::downgrade(&backing);
self.trim_to_capacity();
Ok(Some(backing))
}
pub(crate) fn surface_ids(&self) -> Vec<u64> {
self.surfaces.keys().copied().collect()
}
#[cfg(test)]
fn cached_surface_count(&self) -> usize {
self.surfaces.len()
}
fn trim_to_capacity(&mut self) {
while self.surfaces.len() > MAX_CACHED_SURFACES {
if !self.evict_preferred_surface() {
break;
}
}
}
fn evict_preferred_surface(&mut self) -> bool {
let inactive_index = self.insertion_order.iter().position(|surface_id| {
self.surfaces
.get(surface_id)
.is_none_or(|cached| cached.active_backing.upgrade().is_none())
});
let surface_id = match inactive_index {
Some(index) => self.insertion_order.remove(index),
None => self.insertion_order.pop_front(),
};
let Some(surface_id) = surface_id else {
return false;
};
self.surfaces.remove(&surface_id);
true
}
}
pub(crate) fn import_pixel_buffer_from_mach_port(
mach_port_name: u32,
) -> Result<ImportedPixelBuffer, SurfaceImportError> {
let result = build_pixel_buffer_from_mach_port(mach_port_name);
deallocate_mach_port(mach_port_name);
result
}
fn build_pixel_buffer_from_mach_port(
mach_port_name: u32,
) -> Result<ImportedPixelBuffer, SurfaceImportError> {
let Some(iosurface) = objc2_io_surface::IOSurfaceRef::lookup_from_mach_port(mach_port_name)
else {
return Err(SurfaceImportError::LookupFailed { port: mach_port_name });
};
let backing = build_hardware_surface_backing(&iosurface)?;
Ok(ImportedPixelBuffer { system_surface_id: u64::from(iosurface.id()), iosurface, backing })
}
fn build_hardware_surface_backing(
iosurface: &CFRetained<IOSurfaceRef>,
) -> Result<Arc<HardwareSurfaceBacking>, SurfaceImportError> {
let raw_ptr: *const std::ffi::c_void =
(&**iosurface) as *const objc2_io_surface::IOSurfaceRef as *const std::ffi::c_void;
#[allow(deprecated)]
let io_surface_view: IOSurface = {
#[expect(unsafe_code)]
unsafe {
IOSurface::wrap_under_get_rule(raw_ptr as io_surface::IOSurfaceRef)
}
};
let pixel_buffer = CVPixelBuffer::from_io_surface(&io_surface_view, None)
.map_err(|status| SurfaceImportError::PixelBufferBuildFailed { status })?;
Ok(HardwareSurfaceBacking::new(pixel_buffer))
}
fn deallocate_mach_port(port: u32) {
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
let _ = unsafe { mach_port_deallocate(task, port) };
}
#[cfg(test)]
mod tests {
use std::os::raw::c_void;
use std::sync::Arc;
use objc2_core_foundation::{
CFDictionary, CFIndex, CFNumber, CFString, kCFAllocatorDefault,
kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks,
};
use objc2_io_surface::{
IOSurfaceRef, kIOSurfaceBytesPerElement, kIOSurfaceBytesPerRow, kIOSurfaceHeight,
kIOSurfacePixelFormat, kIOSurfaceWidth,
};
use super::IOSurfaceCache;
fn build_local_iosurface(
width: u32,
height: u32,
) -> Result<objc2_core_foundation::CFRetained<IOSurfaceRef>, String> {
let pixel_format = i32::from_be_bytes(*b"BGRA");
let bytes_per_element = 4_i32;
let bytes_per_row = (width as i32) * bytes_per_element;
let width_num = CFNumber::new_i32(width as i32);
let height_num = CFNumber::new_i32(height as i32);
let bpe_num = CFNumber::new_i32(bytes_per_element);
let bpr_num = CFNumber::new_i32(bytes_per_row);
let pf_num = CFNumber::new_i32(pixel_format);
#[expect(unsafe_code)]
unsafe {
let keys: [&CFString; 5] = [
kIOSurfaceWidth,
kIOSurfaceHeight,
kIOSurfaceBytesPerElement,
kIOSurfaceBytesPerRow,
kIOSurfacePixelFormat,
];
let values: [&CFNumber; 5] = [&width_num, &height_num, &bpe_num, &bpr_num, &pf_num];
let properties = CFDictionary::new(
kCFAllocatorDefault,
keys.as_ptr() as *mut *const c_void,
values.as_ptr() as *mut *const c_void,
keys.len() as CFIndex,
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks,
)
.ok_or_else(|| "CFDictionaryCreate returned null".to_string())?;
IOSurfaceRef::new(&properties)
.ok_or_else(|| "IOSurfaceCreate returned null".to_string())
}
}
#[test]
fn imports_iosurface_into_pixel_buffer_cache() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let iosurface = build_local_iosurface(64, 48)?;
let surface_id = u64::from(iosurface.id());
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
.map_err(|error| error.to_string())?;
assert_eq!(imported.system_surface_id, surface_id);
cache.insert_imported(surface_id, imported);
let surface = cache
.hardware_surface_for(surface_id)
.map_err(|error| error.to_string())?
.ok_or_else(|| "imported pixel buffer was missing".to_string())?;
assert_eq!(surface.pixel_buffer().get_width(), 64);
assert_eq!(surface.pixel_buffer().get_height(), 48);
assert_eq!(cache.cached_surface_count(), 1);
Ok(())
}
#[test]
fn repeated_surface_id_reuses_cache_entry() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let iosurface = build_local_iosurface(64, 48)?;
let surface_id = u64::from(iosurface.id());
cache
.import(iosurface.create_mach_port(), surface_id)
.map_err(|error| error.to_string())?;
cache
.import(iosurface.create_mach_port(), surface_id)
.map_err(|error| error.to_string())?;
assert_eq!(cache.cached_surface_count(), 1);
Ok(())
}
#[test]
fn active_surface_backing_reuses_and_releases_use_count() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let iosurface = build_local_iosurface(64, 48)?;
let surface_id = u64::from(iosurface.id());
let baseline_use_count = iosurface.use_count();
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
.map_err(|error| error.to_string())?;
assert_eq!(iosurface.use_count(), baseline_use_count + 1);
cache.insert_imported(surface_id, imported);
let first = cache
.hardware_surface_for(surface_id)
.map_err(|error| error.to_string())?
.ok_or_else(|| "first hardware surface was missing".to_string())?;
let second = cache
.hardware_surface_for(surface_id)
.map_err(|error| error.to_string())?
.ok_or_else(|| "second hardware surface was missing".to_string())?;
assert!(Arc::ptr_eq(&first, &second));
assert_eq!(iosurface.use_count(), baseline_use_count + 1);
drop(first);
drop(second);
assert_eq!(iosurface.use_count(), baseline_use_count);
let rebuilt = cache
.hardware_surface_for(surface_id)
.map_err(|error| error.to_string())?
.ok_or_else(|| "rebuilt hardware surface was missing".to_string())?;
assert_eq!(iosurface.use_count(), baseline_use_count + 1);
drop(rebuilt);
assert_eq!(iosurface.use_count(), baseline_use_count);
drop(cache);
assert_eq!(iosurface.use_count(), baseline_use_count);
Ok(())
}
#[test]
fn cache_evicts_oldest_surface_at_capacity() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let mut surface_ids = Vec::new();
for _ in 0..=super::MAX_CACHED_SURFACES {
let iosurface = build_local_iosurface(8, 8)?;
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
.map_err(|error| error.to_string())?;
surface_ids.push(imported.system_surface_id);
cache.insert_imported(imported.system_surface_id, imported);
}
assert_eq!(cache.cached_surface_count(), super::MAX_CACHED_SURFACES);
assert!(
cache
.hardware_surface_for(surface_ids[0])
.map_err(|error| error.to_string())?
.is_none()
);
assert!(
cache
.hardware_surface_for(surface_ids[super::MAX_CACHED_SURFACES])
.map_err(|error| error.to_string())?
.is_some()
);
assert!(!cache.surface_ids().contains(&surface_ids[0]));
Ok(())
}
#[test]
fn cache_stays_bounded_while_gpu_backings_are_active() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let mut surface_ids = Vec::new();
let mut native_surfaces = Vec::new();
let mut active_backings = Vec::new();
for _ in 0..super::MAX_CACHED_SURFACES {
let iosurface = build_local_iosurface(8, 8)?;
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
.map_err(|error| error.to_string())?;
surface_ids.push(imported.system_surface_id);
native_surfaces.push(iosurface);
cache.insert_imported(imported.system_surface_id, imported);
active_backings.push(
cache
.hardware_surface_for(*surface_ids.last().ok_or("surface id was missing")?)
.map_err(|error| error.to_string())?
.ok_or("active surface backing was missing")?,
);
}
let overflow = build_local_iosurface(8, 8)?;
let overflow = super::import_pixel_buffer_from_mach_port(overflow.create_mach_port())
.map_err(|error| error.to_string())?;
let overflow_id = overflow.system_surface_id;
cache.insert_imported(overflow_id, overflow);
assert_eq!(cache.cached_surface_count(), super::MAX_CACHED_SURFACES);
assert!(!cache.surface_ids().contains(&surface_ids[0]));
assert_eq!(active_backings[0].pixel_buffer().get_width(), 8);
assert!(
cache
.hardware_surface_for(surface_ids[0])
.map_err(|error| error.to_string())?
.is_none()
);
assert!(
cache.hardware_surface_for(overflow_id).map_err(|error| error.to_string())?.is_some()
);
cache
.import(native_surfaces[0].create_mach_port(), surface_ids[0])
.map_err(|error| error.to_string())?;
let reimported = cache
.hardware_surface_for(surface_ids[0])
.map_err(|error| error.to_string())?
.ok_or("reimported surface backing was missing")?;
assert_eq!(cache.cached_surface_count(), super::MAX_CACHED_SURFACES);
assert!(cache.surface_ids().contains(&surface_ids[0]));
assert!(!Arc::ptr_eq(&active_backings[0], &reimported));
Ok(())
}
}
+6
View File
@@ -1,11 +1,17 @@
pub mod download_checksums;
pub mod download_files;
pub mod http_downloads;
#[cfg(target_os = "macos")]
pub(crate) mod iosurface_mach;
#[cfg(target_os = "macos")]
pub(crate) mod iosurface_metal;
pub mod plugin_package_store;
pub mod plugin_packages;
pub mod plugin_signatures;
pub(crate) mod profile_identity;
pub mod servo_live;
pub(crate) mod servo_profile_data;
mod servo_sidecar_command;
pub(crate) use servo_profile_data::ProfileDataMode;
@@ -0,0 +1,166 @@
use std::{
fs,
io::{self, Write},
path::{Path, PathBuf},
};
use ely_domain::ProfileId;
use thiserror::Error;
#[cfg(not(test))]
use super::servo_profile_data::{create_profile_data_dir, default_profile_data_root};
#[cfg(not(test))]
const DEFAULT_PROFILE_ID_FILE: &str = "profile-id";
#[cfg(not(test))]
const DEFAULT_PROFILE_DIRECTORY: &str = "default";
#[cfg(test)]
pub(crate) fn default_standard_profile_id() -> Result<ProfileId, ProfileIdentityError> {
static TEST_PROFILE_ID: std::sync::OnceLock<ProfileId> = std::sync::OnceLock::new();
Ok(TEST_PROFILE_ID.get_or_init(ProfileId::new).clone())
}
#[cfg(not(test))]
pub(crate) fn default_standard_profile_id() -> Result<ProfileId, ProfileIdentityError> {
let root = default_profile_data_root().ok_or(ProfileIdentityError::DataRootUnavailable)?;
let profile_id = load_or_create_profile_id(
&root.join(DEFAULT_PROFILE_DIRECTORY).join(DEFAULT_PROFILE_ID_FILE),
)?;
create_profile_data_dir(&root, &profile_id)?;
Ok(profile_id)
}
fn load_or_create_profile_id(path: &Path) -> Result<ProfileId, ProfileIdentityError> {
match read_profile_id(path) {
Ok(profile_id) => return Ok(profile_id),
Err(ProfileIdentityError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
let parent = path
.parent()
.ok_or_else(|| ProfileIdentityError::ParentUnavailable { path: path.to_path_buf() })?;
create_private_directory(parent)?;
let profile_id = ProfileId::new();
let mut file = tempfile::NamedTempFile::new_in(parent)?;
file.write_all(profile_id.as_str().as_bytes())?;
file.write_all(b"\n")?;
file.as_file().sync_all()?;
match file.persist_noclobber(path) {
Ok(_) => {
sync_parent_directory(parent)?;
Ok(profile_id)
}
Err(error) if error.error.kind() == io::ErrorKind::AlreadyExists => read_profile_id(path),
Err(error) => Err(error.error.into()),
}
}
fn create_private_directory(path: &Path) -> Result<(), io::Error> {
fs::create_dir_all(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
#[cfg(unix)]
fn sync_parent_directory(path: &Path) -> Result<(), io::Error> {
fs::File::open(path)?.sync_all()
}
#[cfg(not(unix))]
fn sync_parent_directory(_path: &Path) -> Result<(), io::Error> {
Ok(())
}
fn read_profile_id(path: &Path) -> Result<ProfileId, ProfileIdentityError> {
let raw = fs::read_to_string(path)?;
ProfileId::parse(raw.trim()).map_err(ProfileIdentityError::Domain)
}
#[derive(Debug, Error)]
pub(crate) enum ProfileIdentityError {
#[cfg(not(test))]
#[error("profile data root is unavailable")]
DataRootUnavailable,
#[error("profile identity parent directory is unavailable for {path}")]
ParentUnavailable { path: PathBuf },
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
}
#[cfg(test)]
mod tests {
use super::load_or_create_profile_id;
use crate::services::servo_profile_data::profile_data_dir;
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
#[test]
fn default_profile_identity_survives_restart() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let root = directory.path().join("profiles");
let path = root.join("default").join("profile-id");
let first_start = load_or_create_profile_id(&path)?;
let second_start = load_or_create_profile_id(&path)?;
let mut first_config = InitialBrowserConfig::ely_defaults()?;
first_config.profile_id = Some(first_start.clone());
let first_core = BrowserCore::new(first_config)?;
let mut second_config = InitialBrowserConfig::ely_defaults()?;
second_config.profile_id = Some(second_start.clone());
let second_core = BrowserCore::new(second_config)?;
assert_eq!(first_start, second_start);
assert_eq!(std::fs::read_to_string(&path)?.trim(), first_start.as_str());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(path.parent().ok_or("missing marker parent")?)?
.permissions()
.mode()
& 0o777,
0o700
);
}
assert_eq!(first_core.active_tab()?.profile_id(), second_core.active_tab()?.profile_id());
assert_eq!(
profile_data_dir(&root, first_core.active_tab()?.profile_id()),
profile_data_dir(&root, second_core.active_tab()?.profile_id()),
);
Ok(())
}
#[test]
fn concurrent_starts_converge_on_one_profile_identity() -> Result<(), Box<dyn std::error::Error>>
{
let directory = tempfile::tempdir()?;
let path = std::sync::Arc::new(directory.path().join("default").join("profile-id"));
let threads = (0..4)
.map(|_| {
let path = path.clone();
std::thread::spawn(move || load_or_create_profile_id(&path))
})
.collect::<Vec<_>>();
let mut identities = Vec::new();
for thread in threads {
identities.push(
thread
.join()
.map_err(|_| std::io::Error::other("profile identity thread panicked"))??,
);
}
assert!(identities.windows(2).all(|pair| pair[0] == pair[1]));
Ok(())
}
}
+396 -429
View File
@@ -1,498 +1,465 @@
use std::{collections::BTreeMap, path::PathBuf};
use ely_domain::{
ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, TabId, UrlText, WebViewId,
};
use ely_servo_host::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest,
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest,
ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost,
use std::{
path::PathBuf,
process::{Child, Stdio},
thread,
time::{Duration, Instant},
};
#[cfg(target_os = "macos")]
use std::collections::BTreeSet;
#[cfg(target_os = "macos")]
#[path = "servo_live_iosurface_importer.rs"]
mod iosurface_importer;
#[path = "servo_live_ipc.rs"]
mod ipc;
#[path = "servo_live_types.rs"]
mod types;
#[path = "servo_live_wire.rs"]
mod wire;
pub(crate) use types::{
ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveSitePermission,
};
/// Servo's `WebViewBuilder` defaults the hidpi (device → CSS) scale to
/// 1.0 — see the contract documented on
/// [`ely_servo_host::HidpiScaleRequest`]. Mirroring the post-build state
/// in [`DirectWebViewSession`] makes the diff inside `apply_viewport`
/// the source of truth for whether `set_hidpi_scale` has to run.
const SERVO_DEFAULT_DEVICE_PIXEL_RATIO: f32 = 1.0;
use self::{
ipc::{IpcReply, ServoLiveIpc, validate_frame_layout},
wire::{LIVE_PROTOCOL_VERSION, LiveRequest, LiveSurfaceHandle},
};
use super::servo_sidecar_command::{
SidecarRenderingContext, default_sidecar_command, rendering_context_from_env,
};
/// Servo's `WebViewBuilder` likewise defaults page zoom to 1.0 (100%).
const SERVO_DEFAULT_PAGE_ZOOM_PERCENT: u16 = 100;
#[cfg(target_os = "macos")]
use super::iosurface_metal::IOSurfaceCache;
#[cfg(target_os = "macos")]
use iosurface_importer::{IOSurfaceImportResult, IOSurfaceImportWorker};
const SIDECAR_TIMEOUTS: SidecarTimeouts = SidecarTimeouts {
request: Duration::from_secs(10),
shutdown: Duration::from_secs(2),
exit: Duration::from_secs(2),
};
#[derive(Clone, Copy)]
struct SidecarTimeouts {
request: Duration,
shutdown: Duration,
exit: Duration,
}
pub(crate) struct ServoLiveClient {
host: SoftwareServoHost,
sessions: BTreeMap<String, DirectWebViewSession>,
child: Child,
ipc: ServoLiveIpc,
timeouts: SidecarTimeouts,
active: bool,
#[cfg(target_os = "macos")]
iosurface_cache: IOSurfaceCache,
#[cfg(target_os = "macos")]
iosurface_importer: Option<IOSurfaceImportWorker>,
#[cfg(target_os = "macos")]
pending_surface_ids: BTreeSet<u64>,
}
impl ServoLiveClient {
pub fn new(profile_data_dir: PathBuf) -> Result<Self, ServoLiveError> {
let host = SoftwareServoHost::new_with_config_dir(
ServoSurfaceSize::new(1, 1),
Some(profile_data_dir),
)?;
Ok(Self { host, sessions: BTreeMap::new() })
let rendering_context = rendering_context_from_env();
let command_target = default_sidecar_command()?;
if let Some(path) = command_target.missing_binary_path() {
return Err(ServoLiveError::SidecarBinaryUnavailable { path: path.to_path_buf() });
}
let mut command = command_target.command();
command.arg("live").arg("--profile-data-dir").arg(profile_data_dir);
command.arg("--rendering-context").arg(rendering_context.cli_arg());
#[cfg(target_os = "macos")]
let iosurface_importer = if rendering_context == SidecarRenderingContext::Hardware {
let receiver = super::iosurface_mach::IOSurfaceMachReceiver::new()?;
command.arg("--iosurface-mach-service").arg(receiver.service_name());
Some(
IOSurfaceImportWorker::new(receiver)
.map_err(ServoLiveError::IOSurfaceImportWorker)?,
)
} else {
None
};
let child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(ServoLiveError::Command)?;
let mut client = Self::from_spawned_child(child, SIDECAR_TIMEOUTS)?;
#[cfg(target_os = "macos")]
{
client.iosurface_importer = iosurface_importer;
}
Ok(client)
}
fn from_spawned_child(
mut child: Child,
timeouts: SidecarTimeouts,
) -> Result<Self, ServoLiveError> {
let Some(stdin) = child.stdin.take() else {
terminate_child(&mut child, timeouts.exit);
return Err(ServoLiveError::PipeUnavailable { name: "stdin" });
};
let Some(stdout) = child.stdout.take() else {
drop(stdin);
terminate_child(&mut child, timeouts.exit);
return Err(ServoLiveError::PipeUnavailable { name: "stdout" });
};
let mut client = Self {
child,
ipc: ServoLiveIpc::spawn(stdin, stdout),
timeouts,
active: true,
#[cfg(target_os = "macos")]
iosurface_cache: IOSurfaceCache::new(),
#[cfg(target_os = "macos")]
iosurface_importer: None,
#[cfg(target_os = "macos")]
pending_surface_ids: BTreeSet::new(),
};
if let Err(error) = client.handshake() {
client.terminate();
return Err(error);
}
Ok(client)
}
pub fn ensure(
&mut self,
request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
let tab_id = TabId::parse(request.tab_id.clone())?;
let profile_id = ProfileId::parse(request.profile_id.clone())?;
let requested_url = UrlText::parse(request.url.clone())?;
let webview_id = self.ensure_webview(&request, &tab_id, &profile_id)?;
self.apply_viewport(&request, &webview_id)?;
self.apply_permissions(&request, &webview_id, &profile_id)?;
self.apply_navigation(&request, &webview_id, tab_id, requested_url)?;
self.apply_input(&request, &webview_id)?;
// Match Servo's `examples/winit_minimal.rs`: spin the event loop on
// the embedder-side hot path, never paint. Servo's public
// rendering contract says `notify_new_frame_ready` is the signal
// for `WebView::paint`; URL/title/load-status callbacks are
// metadata updates and travel through snapshots. Painting here
// would present before Servo has composited the navigated page,
// which produced per-redirect white flashes.
self.host.tick();
if self.session_uses_native_surface(&request.tab_id) {
return self.presented_frame_from_session(&request.tab_id, &webview_id).map(Some);
}
Ok(None)
#[cfg(target_os = "macos")]
self.drain_iosurface_imports()?;
validate_frame_layout(request.width, request.height)?;
let ready_surface_ids = self.ready_surface_ids();
let pending_surface_ids = self.pending_surface_ids();
self.request(LiveRequest::Ensure {
tab_id: request.tab_id,
profile_id: request.profile_id,
url: request.url,
width: request.width,
height: request.height,
page_zoom_percent: request.page_zoom_percent,
device_pixel_ratio: request.device_pixel_ratio,
scroll_delta_x: request.scroll_delta_x,
scroll_delta_y: request.scroll_delta_y,
scroll_point_x: request.scroll_point_x,
scroll_point_y: request.scroll_point_y,
click_x: request.click_x,
click_y: request.click_y,
hover_x: request.hover_x,
hover_y: request.hover_y,
typed_text: request.typed_text,
site_permissions: request.site_permissions,
ready_surface_ids,
pending_surface_ids,
})
}
pub fn poll(&mut self, tab_id: String) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
let Some(session) = self.sessions.get(&tab_id) else {
return Ok(None);
};
let webview_id = session.webview_id.clone();
let uses_native_surface = session.native_surface_id.is_some();
self.host.tick();
let snapshot = self.host.snapshot(&webview_id)?;
if !snapshot.has_pending_frame() && !snapshot.has_pending_metadata() {
return Ok(None);
}
if uses_native_surface {
if snapshot.has_pending_frame() {
self.host.paint_without_readback_with_completion(&webview_id, false)?;
}
return self.presented_frame_from_session(&tab_id, &webview_id).map(Some);
}
if snapshot.has_pending_frame() {
self.host.paint(&webview_id)?;
}
self.rendered_frame_from_session(&tab_id, &webview_id)
#[cfg(target_os = "macos")]
self.drain_iosurface_imports()?;
self.request(LiveRequest::Poll {
tab_id,
ready_surface_ids: self.ready_surface_ids(),
pending_surface_ids: self.pending_surface_ids(),
})
}
pub fn close(&mut self, tab_id: String) -> Result<(), ServoLiveError> {
let Some(session) = self.sessions.remove(&tab_id) else {
return Ok(());
};
self.host.close_webview(&session.webview_id);
Ok(())
self.request(LiveRequest::Close { tab_id }).map(|_| ())
}
fn ensure_webview(
&mut self,
request: &ServoLiveEnsureRequest,
tab_id: &TabId,
profile_id: &ProfileId,
) -> Result<WebViewId, ServoLiveError> {
if self
.sessions
.get(&request.tab_id)
.is_some_and(|session| session.profile_id != *profile_id)
&& let Some(session) = self.sessions.remove(&request.tab_id)
fn handshake(&mut self) -> Result<(), ServoLiveError> {
let reply = self.exchange(
LiveRequest::Handshake { protocol_version: LIVE_PROTOCOL_VERSION },
self.timeouts.request,
"handshake",
)?;
if reply.frame.is_some()
|| reply.surface_handle.is_some()
|| reply.current_surface_id.is_some()
{
self.host.close_webview(&session.webview_id);
return Err(ServoLiveError::InvalidResponse {
message: "handshake response contains a frame",
});
}
let native_surface_id =
request.native_surface.as_ref().map(gpui::NativeSurfaceHandle::identity);
if self
.sessions
.get(&request.tab_id)
.is_some_and(|session| session.native_surface_id != native_surface_id)
&& let Some(session) = self.sessions.remove(&request.tab_id)
{
self.host.close_webview(&session.webview_id);
match reply.error {
Some(message) => Err(ServoLiveError::SidecarFailed { message }),
None => Ok(()),
}
if let Some(session) = self.sessions.get(&request.tab_id) {
return Ok(session.webview_id.clone());
}
let surface_size = ServoSurfaceSize::new(request.width, request.height);
let webview_id = match request.native_surface.as_ref() {
Some(native_surface) => self.host.create_webview_with_native_surface(
tab_id.clone(),
profile_id.clone(),
surface_size,
native_surface,
)?,
None => self.host.create_webview_with_size(
tab_id.clone(),
profile_id.clone(),
surface_size,
)?,
};
self.sessions.insert(
request.tab_id.clone(),
DirectWebViewSession {
webview_id: webview_id.clone(),
profile_id: profile_id.clone(),
requested_url: None,
width: request.width,
height: request.height,
// Servo's WebViewBuilder defaults page zoom to 1.0 (100%) and
// hidpi scale to 1.0; record both as Servo's actual post-build
// state so `apply_viewport` pushes the embedder-requested
// values on the first ensure. Without this, a request whose
// zoom/DPR happens to equal the cached request value would
// bypass `set_page_zoom` / `set_hidpi_scale` and leave the
// WebView at Servo's defaults — on Retina that collapses CSS
// pixels onto device pixels and renders pages at half size.
page_zoom_percent: SERVO_DEFAULT_PAGE_ZOOM_PERCENT,
device_pixel_ratio: SERVO_DEFAULT_DEVICE_PIXEL_RATIO,
native_surface_id,
},
);
Ok(webview_id)
}
fn apply_viewport(
fn request(&mut self, request: LiveRequest) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
let reply = self.exchange(request, self.timeouts.request, "request")?;
if let Some(message) = reply.error {
return Err(ServoLiveError::SidecarFailed { message });
}
#[cfg(target_os = "macos")]
if let Some(handle) = reply.surface_handle {
self.queue_iosurface_handle(handle)?;
self.drain_iosurface_imports()?;
}
let mut frame = reply.frame;
#[cfg(target_os = "macos")]
if let (Some(frame), Some(surface_id)) = (frame.as_mut(), reply.current_surface_id) {
let hardware_surface =
self.iosurface_cache.hardware_surface_for(surface_id).map_err(|error| {
ServoLiveError::IOSurfaceBackingFailed {
surface_id,
message: error.to_string(),
}
})?;
if hardware_surface.is_none() && !frame.has_software_payload() {
return Ok(None);
}
frame.set_hardware_surface(surface_id, hardware_surface);
}
Ok(frame)
}
fn exchange(
&mut self,
request: &ServoLiveEnsureRequest,
webview_id: &WebViewId,
) -> Result<(), ServoLiveError> {
let Some(session) = self.sessions.get_mut(&request.tab_id) else {
request: LiveRequest,
timeout: Duration,
operation: &'static str,
) -> Result<IpcReply, ServoLiveError> {
if !self.active {
return Err(ServoLiveError::SidecarExited);
}
let reply = match self.ipc.exchange(request, timeout, operation) {
Ok(reply) => reply,
Err(error) => {
self.terminate();
return Err(error);
}
};
if reply.protocol_version != Some(LIVE_PROTOCOL_VERSION) {
let error = ServoLiveError::ProtocolVersionMismatch {
expected: LIVE_PROTOCOL_VERSION,
actual: reply.protocol_version,
};
self.terminate();
return Err(error);
}
Ok(reply)
}
fn shutdown(&mut self) {
if !self.active {
return;
}
let acknowledged = self
.ipc
.exchange(LiveRequest::Shutdown, self.timeouts.shutdown, "shutdown")
.ok()
.is_some_and(|reply| {
reply.protocol_version == Some(LIVE_PROTOCOL_VERSION)
&& reply.error.is_none()
&& reply.frame.is_none()
&& reply.surface_handle.is_none()
&& reply.current_surface_id.is_none()
});
if acknowledged && wait_for_exit(&mut self.child, self.timeouts.exit) {
self.active = false;
self.ipc.close_and_join(self.timeouts.exit);
return;
}
self.terminate();
}
fn terminate(&mut self) {
if self.active {
terminate_child(&mut self.child, self.timeouts.exit);
self.active = false;
}
self.ipc.close_and_join(self.timeouts.exit);
}
}
#[cfg(not(target_os = "macos"))]
impl ServoLiveClient {
fn ready_surface_ids(&self) -> Vec<u64> {
Vec::new()
}
fn pending_surface_ids(&self) -> Vec<u64> {
Vec::new()
}
}
#[cfg(target_os = "macos")]
impl ServoLiveClient {
fn ready_surface_ids(&self) -> Vec<u64> {
self.iosurface_cache.surface_ids()
}
fn pending_surface_ids(&self) -> Vec<u64> {
self.pending_surface_ids.iter().copied().collect()
}
fn queue_iosurface_handle(&mut self, handle: LiveSurfaceHandle) -> Result<(), ServoLiveError> {
let Some(importer) = self.iosurface_importer.as_ref() else {
return Err(ServoLiveError::IOSurfaceImportFailed {
surface_id: handle.surface_id,
mach_port_name: handle.mach_port_name,
message: "IOSurface import worker is unavailable".to_string(),
});
};
let surface_id = handle.surface_id;
importer.submit(handle).map_err(|failure| ServoLiveError::IOSurfaceImportFailed {
surface_id: failure.surface_id,
mach_port_name: failure.mach_port_name,
message: failure.message,
})?;
self.pending_surface_ids.insert(surface_id);
Ok(())
}
fn drain_iosurface_imports(&mut self) -> Result<(), ServoLiveError> {
let Some(importer) = self.iosurface_importer.as_ref() else {
return Ok(());
};
let change = ViewportChange::between(request, session);
if let Some((width, height)) = change.resize {
self.host.resize(ResizeRequest { webview_id: webview_id.clone(), width, height })?;
session.width = width;
session.height = height;
}
if let Some(scale_factor) = change.set_hidpi {
self.host.set_hidpi_scale(HidpiScaleRequest {
webview_id: webview_id.clone(),
scale_factor,
})?;
session.device_pixel_ratio = scale_factor;
}
if let Some(page_zoom_percent) = change.set_page_zoom {
self.host.set_page_zoom(PageZoomRequest {
webview_id: webview_id.clone(),
zoom_factor: f32::from(page_zoom_percent) / 100.0,
})?;
session.page_zoom_percent = page_zoom_percent;
}
Ok(())
apply_iosurface_import_results(
&mut self.iosurface_cache,
&mut self.pending_surface_ids,
importer.drain(),
)
}
}
fn apply_permissions(
&mut self,
request: &ServoLiveEnsureRequest,
webview_id: &WebViewId,
profile_id: &ProfileId,
) -> Result<(), ServoLiveError> {
for permission in &request.site_permissions {
let origin = SiteOrigin::parse(permission.origin.clone())?;
let feature = SitePermissionFeature::parse(permission.feature.as_str())?;
let decision = SitePermissionDecision::parse(permission.decision.as_str())?;
self.host.set_permission(
PermissionRequest {
webview_id: webview_id.clone(),
profile_id: profile_id.clone(),
origin,
feature,
},
PermissionDecision::from(decision),
)?;
}
Ok(())
}
fn apply_navigation(
&mut self,
request: &ServoLiveEnsureRequest,
webview_id: &WebViewId,
tab_id: TabId,
requested_url: UrlText,
) -> Result<(), ServoLiveError> {
// Servo's `set_history` fires `notify_url_changed` on *every*
// history mutation — full navigations, redirects, in-page
// links, and JS-driven `history.pushState` / `replaceState`.
// Our embedder fans that back through `WebSurfaceUrlChange`
// into `tab.url`, which makes the next `ensure_surface` see a
// "different" URL and call back into this method. Without a
// guard we then send Servo `WebView::load(new_url)` for a URL
// Servo just informed us it is *already* at — and `load` is a
// hard navigation that aborts the live document, clears the
// surface, and refetches.
//
// The google.com homepage `replaceState`s a `?zx=<timestamp>`
// every second; under the unguarded path that turned into a
// load-clear-refetch cycle per second, i.e. the continuous
// white flash. Servo's own `webview.url()` (driven by
// `set_history`) is the source of truth — if it already
// matches the requested URL, this URL change came *from*
// Servo and only needs an embedder-side bookkeeping sync.
let servo_current_url = self.host.snapshot(webview_id)?.url().map(str::to_string);
if servo_current_url.as_deref() == Some(requested_url.as_str()) {
if let Some(session) = self.sessions.get_mut(&request.tab_id) {
session.requested_url = Some(requested_url.as_str().to_string());
#[cfg(target_os = "macos")]
fn apply_iosurface_import_results(
cache: &mut IOSurfaceCache,
pending_surface_ids: &mut BTreeSet<u64>,
results: Vec<IOSurfaceImportResult>,
) -> Result<(), ServoLiveError> {
let mut first_failure = None;
for result in results {
let surface_id = match &result {
IOSurfaceImportResult::Imported(imported) => imported.surface_id,
IOSurfaceImportResult::Failed(failure) => failure.surface_id,
};
pending_surface_ids.remove(&surface_id);
match result {
IOSurfaceImportResult::Imported(imported) => {
cache.insert_imported(imported.surface_id, imported.imported);
}
return Ok(());
}
let should_navigate = self
.sessions
.get(&request.tab_id)
.and_then(|session| session.requested_url.as_deref())
.is_none_or(|current| current != requested_url.as_str());
if should_navigate {
self.host.navigate(NavigationRequest {
webview_id: webview_id.clone(),
tab_id,
url: requested_url.clone(),
})?;
if let Some(session) = self.sessions.get_mut(&request.tab_id) {
session.requested_url = Some(requested_url.as_str().to_string());
IOSurfaceImportResult::Failed(failure) if first_failure.is_none() => {
first_failure = Some(ServoLiveError::IOSurfaceImportFailed {
surface_id: failure.surface_id,
mach_port_name: failure.mach_port_name,
message: failure.message,
});
}
IOSurfaceImportResult::Failed(_) => {}
}
Ok(())
}
first_failure.map_or(Ok(()), Err)
}
fn apply_input(
&mut self,
request: &ServoLiveEnsureRequest,
webview_id: &WebViewId,
) -> Result<(), ServoLiveError> {
if request.scroll_delta_x != 0 || request.scroll_delta_y != 0 {
let point_x = request.scroll_point_x.ok_or(ServoLiveError::MissingScrollPoint)?;
let point_y = request.scroll_point_y.ok_or(ServoLiveError::MissingScrollPoint)?;
self.host.scroll(ScrollRequest {
webview_id: webview_id.clone(),
delta_x: request.scroll_delta_x,
delta_y: request.scroll_delta_y,
point_x,
point_y,
})?;
}
if let (Some(x), Some(y)) = (request.hover_x, request.hover_y) {
self.host.hover(MouseHoverRequest { webview_id: webview_id.clone(), x, y })?;
}
if let (Some(x), Some(y)) = (request.click_x, request.click_y) {
self.host.click(MouseClickRequest { webview_id: webview_id.clone(), x, y })?;
}
if let Some(text) = request.typed_text.as_ref() {
self.host.type_text(KeyboardTextRequest {
webview_id: webview_id.clone(),
text: text.clone(),
})?;
}
Ok(())
}
fn presented_frame_from_session(
&self,
tab_id: &str,
webview_id: &WebViewId,
) -> Result<ServoLiveFrame, ServoLiveError> {
let Some(session) = self.sessions.get(tab_id) else {
return Err(ServoLiveError::Host(ely_servo_host::ServoHostError::WebViewNotFound {
id: webview_id.clone(),
}));
};
if session.native_surface_id.is_some() {
let snapshot = self.host.snapshot_and_mark_metadata_observed(webview_id)?;
return Ok(ServoLiveFrame::from_presented(
snapshot,
session.width,
session.height,
session.device_pixel_ratio,
));
}
Err(ServoLiveError::NativeSurfaceUnavailable)
}
fn rendered_frame_from_session(
&self,
tab_id: &str,
webview_id: &WebViewId,
) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
let Some(session) = self.sessions.get(tab_id) else {
return Err(ServoLiveError::Host(ServoHostError::WebViewNotFound {
id: webview_id.clone(),
}));
};
let rendered_frame = match self.host.last_rendered_frame() {
Ok(frame) => frame,
Err(ServoHostError::RenderedFrameUnavailable) => return Ok(None),
Err(error) => return Err(ServoLiveError::Host(error)),
};
let snapshot = self.host.snapshot_and_mark_metadata_observed(webview_id)?;
Ok(Some(ServoLiveFrame::from_rendered(
snapshot,
rendered_frame,
session.device_pixel_ratio,
)))
}
fn session_uses_native_surface(&self, tab_id: &str) -> bool {
self.sessions.get(tab_id).is_some_and(|session| session.native_surface_id.is_some())
impl Drop for ServoLiveClient {
fn drop(&mut self) {
self.shutdown();
}
}
struct DirectWebViewSession {
webview_id: WebViewId,
profile_id: ProfileId,
requested_url: Option<String>,
width: u32,
height: u32,
page_zoom_percent: u16,
device_pixel_ratio: f32,
native_surface_id: Option<usize>,
}
/// Calls that `apply_viewport` needs to make to bring the underlying
/// Servo WebView state in line with the embedder's request. Extracted
/// as a pure value so the diff decision is testable without a live
/// `SoftwareServoHost` — see `tests` below.
#[derive(Debug, Default, PartialEq)]
struct ViewportChange {
resize: Option<(u32, u32)>,
set_hidpi: Option<f32>,
set_page_zoom: Option<u16>,
}
impl ViewportChange {
fn between(request: &ServoLiveEnsureRequest, session: &DirectWebViewSession) -> Self {
Self {
resize: (session.width != request.width || session.height != request.height)
.then_some((request.width, request.height)),
set_hidpi: (session.device_pixel_ratio != request.device_pixel_ratio)
.then_some(request.device_pixel_ratio),
set_page_zoom: (session.page_zoom_percent != request.page_zoom_percent)
.then_some(request.page_zoom_percent),
fn wait_for_exit(child: &mut Child, timeout: Duration) -> bool {
let started_at = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => return true,
Ok(None) => {}
Err(_) => return false,
}
if started_at.elapsed() >= timeout {
return false;
}
thread::sleep(Duration::from_millis(5));
}
}
#[cfg(test)]
fn terminate_child(child: &mut Child, timeout: Duration) {
if child.try_wait().ok().flatten().is_none() {
let _ = child.kill();
}
let _ = wait_for_exit(child, timeout);
}
#[cfg(all(test, unix))]
mod tests {
use std::{error::Error, process::Command};
use super::*;
use ely_domain::WebViewId;
fn session(width: u32, height: u32, dpr: f32, zoom: u16) -> DirectWebViewSession {
DirectWebViewSession {
webview_id: WebViewId::new(),
profile_id: ProfileId::new(),
requested_url: None,
width,
height,
page_zoom_percent: zoom,
device_pixel_ratio: dpr,
native_surface_id: Some(0xC0FFEE),
}
}
const TEST_TIMEOUTS: SidecarTimeouts = SidecarTimeouts {
request: Duration::from_millis(50),
shutdown: Duration::from_millis(50),
exit: Duration::from_millis(250),
};
fn request(width: u32, height: u32, dpr: f32, zoom: u16) -> ServoLiveEnsureRequest {
ServoLiveEnsureRequest {
tab_id: String::from("tab"),
profile_id: String::from("profile"),
url: String::from("https://example.com/"),
width,
height,
page_zoom_percent: zoom,
device_pixel_ratio: dpr,
native_surface: None,
scroll_delta_x: 0,
scroll_delta_y: 0,
scroll_point_x: None,
scroll_point_y: None,
click_x: None,
click_y: None,
hover_x: None,
hover_y: None,
typed_text: None,
site_permissions: Vec::new(),
}
#[test]
fn startup_rejects_a_sidecar_without_the_current_protocol() -> Result<(), Box<dyn Error>> {
let child = spawn_script(
"IFS= read -r request; printf '%s\\n' '{\"error\":\"unknown request\",\"frame\":null}'",
)?;
let result = ServoLiveClient::from_spawned_child(child, TEST_TIMEOUTS);
assert!(matches!(result, Err(ServoLiveError::ProtocolVersionMismatch { .. })));
Ok(())
}
#[test]
fn fresh_retina_session_pushes_hidpi_only() {
// A 1440×900 Retina viewport arrives as physical 2880×1800 with
// DPR=2.0. `ensure_webview` stamps the session with Servo's
// post-build defaults (1.0 DPR, 100 % zoom) and the request's
// physical dimensions, so only the hidpi push should fire.
let change = ViewportChange::between(
&request(2880, 1800, 2.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
&session(2880, 1800, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
);
assert_eq!(
change,
ViewportChange { resize: None, set_hidpi: Some(2.0), set_page_zoom: None },
);
fn request_timeout_kills_and_reaps_a_hung_sidecar() -> Result<(), Box<dyn Error>> {
let mut client = hung_sidecar_client()?;
let started_at = Instant::now();
let result = client.poll("tab-hung".to_string());
assert!(matches!(result, Err(ServoLiveError::RequestTimedOut { .. })));
assert!(started_at.elapsed() < Duration::from_secs(1));
assert!(client.child.try_wait()?.is_some());
Ok(())
}
#[test]
fn fresh_standard_dpi_session_pushes_nothing() {
// On a 1.0-DPR display the fresh session already matches the
// request — Servo's defaults are exactly what we asked for.
let change = ViewportChange::between(
&request(1280, 720, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
&session(1280, 720, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
);
assert_eq!(change, ViewportChange::default());
fn shutdown_timeout_kills_and_reaps_a_hung_sidecar() -> Result<(), Box<dyn Error>> {
let mut client = hung_sidecar_client()?;
let started_at = Instant::now();
client.shutdown();
assert!(started_at.elapsed() < Duration::from_secs(1));
assert!(client.child.try_wait()?.is_some());
Ok(())
}
#[test]
fn page_zoom_change_pushes_only_zoom() {
// User toggled zoom to 150 % on a session that's already at the
// right size and DPR — only `set_page_zoom` should fire.
let change = ViewportChange::between(
&request(2880, 1800, 2.0, 150),
&session(2880, 1800, 2.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
);
assert_eq!(
change,
ViewportChange { resize: None, set_hidpi: None, set_page_zoom: Some(150) },
fn hung_sidecar_client() -> Result<ServoLiveClient, ServoLiveError> {
let script = format!(
"IFS= read -r request; printf '%s\\n' '{{\"protocol_version\":{LIVE_PROTOCOL_VERSION},\"error\":null,\"frame\":null}}'; while IFS= read -r request; do :; done"
);
let child = spawn_script(&script).map_err(ServoLiveError::Command)?;
ServoLiveClient::from_spawned_child(child, TEST_TIMEOUTS)
}
#[test]
fn cross_monitor_dpr_change_pushes_only_hidpi() {
// A window dragged from a 2.0-DPR monitor to a 1.0-DPR one keeps
// the same physical surface (GPUI re-emits the viewport at the
// new scale) — only the DPR push should fire.
let change = ViewportChange::between(
&request(2880, 1800, 1.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
&session(2880, 1800, 2.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
);
assert_eq!(
change,
ViewportChange { resize: None, set_hidpi: Some(1.0), set_page_zoom: None },
);
fn spawn_script(script: &str) -> Result<Child, std::io::Error> {
Command::new("sh")
.arg("-c")
.arg(script)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
}
}
#[cfg(all(test, target_os = "macos"))]
#[path = "servo_live_iosurface_tests.rs"]
mod iosurface_tests;
@@ -0,0 +1,260 @@
#![cfg(target_os = "macos")]
use std::{
io,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread::{self, JoinHandle},
time::Duration,
};
use crate::services::{
iosurface_mach::IOSurfaceMachReceiver,
iosurface_metal::{ImportedPixelBuffer, import_pixel_buffer_from_mach_port},
};
use super::wire::LiveSurfaceHandle;
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(1);
const IMPORT_QUEUE_CAPACITY: usize = 16;
pub(super) struct IOSurfaceImportWorker {
request_tx: Option<mpsc::SyncSender<LiveSurfaceHandle>>,
result_rx: mpsc::Receiver<IOSurfaceImportResult>,
shutdown: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl IOSurfaceImportWorker {
pub(super) fn new(receiver: IOSurfaceMachReceiver) -> Result<Self, io::Error> {
let (request_tx, request_rx) = mpsc::sync_channel(IMPORT_QUEUE_CAPACITY);
let (result_tx, result_rx) = mpsc::channel();
let shutdown = Arc::new(AtomicBool::new(false));
let worker_shutdown = shutdown.clone();
let thread = thread::Builder::new()
.name("ely-iosurface-import".to_string())
.spawn(move || run_import_worker(receiver, request_rx, result_tx, worker_shutdown))?;
Ok(Self { request_tx: Some(request_tx), result_rx, shutdown, thread: Some(thread) })
}
pub(super) fn submit(&self, handle: LiveSurfaceHandle) -> Result<(), IOSurfaceImportFailure> {
let Some(request_tx) = self.request_tx.as_ref() else {
return Err(IOSurfaceImportFailure::worker_stopped(handle));
};
request_tx.send(handle).map_err(|error| IOSurfaceImportFailure::worker_stopped(error.0))
}
pub(super) fn drain(&self) -> Vec<IOSurfaceImportResult> {
let mut results = Vec::new();
while let Ok(result) = self.result_rx.try_recv() {
results.push(result);
}
results
}
}
impl Drop for IOSurfaceImportWorker {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Release);
self.request_tx.take();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
pub(super) enum IOSurfaceImportResult {
Imported(ImportedIOSurface),
Failed(IOSurfaceImportFailure),
}
pub(super) struct ImportedIOSurface {
pub(super) surface_id: u64,
pub(super) imported: ImportedPixelBuffer,
}
// SAFETY: `ImportedPixelBuffer` owns retained IOSurface and CVPixelBuffer
// handles plus a use-count guard. CoreFoundation retain/release and IOSurface
// use-count operations are thread-safe while this value crosses the channel.
#[expect(unsafe_code)]
unsafe impl Send for ImportedIOSurface {}
pub(super) struct IOSurfaceImportFailure {
pub(super) surface_id: u64,
pub(super) mach_port_name: u32,
pub(super) message: String,
}
impl IOSurfaceImportFailure {
fn worker_stopped(handle: LiveSurfaceHandle) -> Self {
Self::queue_rejected(handle, "IOSurface import worker stopped")
}
fn queue_rejected(handle: LiveSurfaceHandle, message: &str) -> Self {
Self {
surface_id: handle.surface_id,
mach_port_name: handle.mach_port_name,
message: message.to_string(),
}
}
}
fn run_import_worker(
mut receiver: IOSurfaceMachReceiver,
request_rx: mpsc::Receiver<LiveSurfaceHandle>,
result_tx: mpsc::Sender<IOSurfaceImportResult>,
shutdown: Arc<AtomicBool>,
) {
run_import_requests(request_rx, result_tx, shutdown, |handle| {
import_surface_handle(&mut receiver, handle)
});
}
fn run_import_requests(
request_rx: mpsc::Receiver<LiveSurfaceHandle>,
result_tx: mpsc::Sender<IOSurfaceImportResult>,
shutdown: Arc<AtomicBool>,
mut import: impl FnMut(LiveSurfaceHandle) -> IOSurfaceImportResult,
) {
while let Ok(handle) = request_rx.recv() {
if shutdown.load(Ordering::Acquire) {
return;
}
let result = import(handle);
if shutdown.load(Ordering::Acquire) || result_tx.send(result).is_err() {
return;
}
}
}
fn import_surface_handle(
receiver: &mut IOSurfaceMachReceiver,
handle: LiveSurfaceHandle,
) -> IOSurfaceImportResult {
let mach_port_name = match receiver.receive_port_for_surface(handle.surface_id, RECEIVE_TIMEOUT)
{
Ok(mach_port_name) => mach_port_name,
Err(error) => {
return IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
surface_id: handle.surface_id,
mach_port_name: handle.mach_port_name,
message: error.to_string(),
});
}
};
match import_pixel_buffer_from_mach_port(mach_port_name) {
Ok(imported)
if imported_surface_matches(
&handle,
imported.system_surface_id,
imported.backing.pixel_buffer().get_width(),
imported.backing.pixel_buffer().get_height(),
) =>
{
IOSurfaceImportResult::Imported(ImportedIOSurface {
surface_id: handle.surface_id,
imported,
})
}
Ok(imported) => IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
surface_id: handle.surface_id,
mach_port_name,
message: format!(
"IOSurface id/size {:#x} {}x{} did not match handle {:#x} {}x{}",
imported.system_surface_id,
imported.backing.pixel_buffer().get_width(),
imported.backing.pixel_buffer().get_height(),
handle.surface_id,
handle.width,
handle.height,
),
}),
Err(error) => IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
surface_id: handle.surface_id,
mach_port_name,
message: error.to_string(),
}),
}
}
fn imported_surface_matches(
handle: &LiveSurfaceHandle,
system_surface_id: u64,
width: usize,
height: usize,
) -> bool {
system_surface_id == handle.surface_id
&& width == handle.width as usize
&& height == handle.height as usize
}
#[cfg(test)]
mod tests {
use std::{cell::Cell, sync::mpsc};
use super::*;
#[test]
fn shutdown_stops_after_current_import() {
let (request_tx, request_rx) = mpsc::channel();
let (result_tx, result_rx) = mpsc::channel();
let shutdown = Arc::new(AtomicBool::new(false));
for surface_id in 1..=3 {
assert!(request_tx.send(handle(surface_id)).is_ok());
}
drop(request_tx);
let processed = Cell::new(0);
let import_shutdown = shutdown.clone();
run_import_requests(request_rx, result_tx, shutdown, |handle| {
processed.set(processed.get() + 1);
import_shutdown.store(true, Ordering::Release);
IOSurfaceImportResult::Failed(IOSurfaceImportFailure::worker_stopped(handle))
});
assert_eq!(processed.get(), 1);
assert!(result_rx.try_recv().is_err());
}
#[test]
fn imported_surface_requires_matching_system_id_and_dimensions() {
assert!(imported_surface_matches(&handle(7), 7, 64, 48));
assert!(!imported_surface_matches(&handle(8), 7, 64, 48));
let mut wrong_size = handle(7);
wrong_size.width = 96;
assert!(!imported_surface_matches(&wrong_size, 7, 64, 48));
}
#[test]
fn submit_backpressures_surface_burst_at_queue_capacity() {
let (request_tx, request_rx) = mpsc::sync_channel(IMPORT_QUEUE_CAPACITY);
let (_result_tx, result_rx) = mpsc::channel();
let worker = IOSurfaceImportWorker {
request_tx: Some(request_tx),
result_rx,
shutdown: Arc::new(AtomicBool::new(false)),
thread: None,
};
for surface_id in 0..IMPORT_QUEUE_CAPACITY as u64 {
assert!(worker.submit(handle(surface_id)).is_ok());
}
let (release_tx, release_rx) = mpsc::channel();
let consumer = std::thread::spawn(move || {
let received = request_rx.recv();
let _ = release_rx.recv();
received
});
assert!(worker.submit(handle(99)).is_ok());
assert!(release_tx.send(()).is_ok());
assert!(consumer.join().is_ok());
}
fn handle(surface_id: u64) -> LiveSurfaceHandle {
LiveSurfaceHandle { mach_port_name: surface_id as u32, surface_id, width: 64, height: 48 }
}
}
@@ -0,0 +1,30 @@
use std::collections::BTreeSet;
use super::{
ServoLiveError, apply_iosurface_import_results,
iosurface_importer::{IOSurfaceImportFailure, IOSurfaceImportResult},
};
use crate::services::iosurface_metal::IOSurfaceCache;
#[test]
fn import_failure_clears_every_completed_pending_surface() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let mut pending = BTreeSet::from([1, 2, 3]);
let results = vec![failed_import(1), failed_import(2), failed_import(3)];
let Err(error) = apply_iosurface_import_results(&mut cache, &mut pending, results) else {
return Err("the failed import batch succeeded".to_string());
};
assert!(matches!(error, ServoLiveError::IOSurfaceImportFailed { surface_id: 1, .. }));
assert!(pending.is_empty());
Ok(())
}
fn failed_import(surface_id: u64) -> IOSurfaceImportResult {
IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
surface_id,
mach_port_name: surface_id as u32,
message: format!("surface {surface_id} failed"),
})
}
@@ -0,0 +1,313 @@
use std::{
io::{BufRead, BufReader, Read, Write},
process::{ChildStdin, ChildStdout},
sync::mpsc::{self, Receiver, Sender, SyncSender},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
use super::{
ServoLiveError, ServoLiveFrame,
wire::{
LiveRequest, LiveResponse, LiveSurfaceHandle, MAX_FRAME_BYTE_COUNT, MAX_FRAME_DIMENSION,
},
};
const MAX_RESPONSE_HEADER_BYTES: usize = 256 * 1024;
pub(super) struct ServoLiveIpc {
requests: Option<Sender<IpcRequest>>,
thread: Option<JoinHandle<()>>,
}
pub(super) struct IpcReply {
pub(super) protocol_version: Option<u32>,
pub(super) error: Option<String>,
pub(super) frame: Option<ServoLiveFrame>,
pub(super) surface_handle: Option<LiveSurfaceHandle>,
pub(super) current_surface_id: Option<u64>,
}
struct IpcRequest {
request: LiveRequest,
response: SyncSender<Result<IpcReply, ServoLiveError>>,
}
impl ServoLiveIpc {
pub(super) fn spawn(stdin: ChildStdin, stdout: ChildStdout) -> Self {
let (requests, receiver) = mpsc::channel();
let thread = thread::spawn(move || run_io(stdin, BufReader::new(stdout), receiver));
Self { requests: Some(requests), thread: Some(thread) }
}
pub(super) fn exchange(
&self,
request: LiveRequest,
timeout: Duration,
operation: &'static str,
) -> Result<IpcReply, ServoLiveError> {
let requests = self.requests.as_ref().ok_or(ServoLiveError::SidecarExited)?;
let (response, receiver) = mpsc::sync_channel(1);
requests
.send(IpcRequest { request, response })
.map_err(|_| ServoLiveError::SidecarExited)?;
receiver.recv_timeout(timeout).map_err(|error| match error {
mpsc::RecvTimeoutError::Timeout => {
ServoLiveError::RequestTimedOut { operation, timeout_millis: timeout.as_millis() }
}
mpsc::RecvTimeoutError::Disconnected => ServoLiveError::SidecarExited,
})?
}
pub(super) fn close_and_join(&mut self, timeout: Duration) {
self.requests.take();
let Some(thread) = self.thread.take() else {
return;
};
let started_at = Instant::now();
while !thread.is_finished() && started_at.elapsed() < timeout {
thread::sleep(Duration::from_millis(5));
}
if thread.is_finished() {
let _ = thread.join();
}
}
}
fn run_io(
mut stdin: ChildStdin,
mut stdout: BufReader<ChildStdout>,
requests: Receiver<IpcRequest>,
) {
while let Ok(message) = requests.recv() {
let should_shutdown = matches!(message.request, LiveRequest::Shutdown);
let response = exchange_pipes(&mut stdin, &mut stdout, &message.request);
let should_stop = should_shutdown || response.is_err();
let _ = message.response.send(response);
if should_stop {
break;
}
}
}
fn exchange_pipes(
stdin: &mut ChildStdin,
stdout: &mut BufReader<ChildStdout>,
request: &LiveRequest,
) -> Result<IpcReply, ServoLiveError> {
serde_json::to_writer(&mut *stdin, request)?;
stdin.write_all(b"\n").map_err(ServoLiveError::Command)?;
stdin.flush().map_err(ServoLiveError::Command)?;
read_reply(stdout)
}
fn read_reply(stdout: &mut impl BufRead) -> Result<IpcReply, ServoLiveError> {
let mut line = String::new();
let bytes = Read::by_ref(stdout)
.take((MAX_RESPONSE_HEADER_BYTES + 1) as u64)
.read_line(&mut line)
.map_err(ServoLiveError::Command)?;
if bytes == 0 {
return Err(ServoLiveError::SidecarExited);
}
if bytes > MAX_RESPONSE_HEADER_BYTES {
return Err(ServoLiveError::ResponseHeaderTooLarge { limit: MAX_RESPONSE_HEADER_BYTES });
}
if !line.ends_with('\n') {
return Err(ServoLiveError::InvalidResponse {
message: "response header is missing its newline delimiter",
});
}
let response: LiveResponse = serde_json::from_str(&line)?;
if response.error.is_some()
&& (response.frame.is_some()
|| response.surface_handle.is_some()
|| response.current_surface_id.is_some())
{
return Err(ServoLiveError::InvalidResponse {
message: "error response contains a frame or hardware surface",
});
}
if response.frame.is_none()
&& (response.surface_handle.is_some() || response.current_surface_id.is_some())
{
return Err(ServoLiveError::InvalidResponse {
message: "hardware surface response is missing its frame report",
});
}
if let Some(handle) = response.surface_handle.as_ref() {
if response.current_surface_id != Some(handle.surface_id) {
return Err(ServoLiveError::InvalidResponse {
message: "surface_handle does not match current_surface_id",
});
}
if response
.frame
.as_ref()
.is_none_or(|frame| frame.width != handle.width || frame.height != handle.height)
{
return Err(ServoLiveError::InvalidResponse {
message: "surface_handle dimensions do not match frame report",
});
}
}
let frame = response.frame.map(|report| {
let rgba_byte_count = validate_frame_layout(report.width, report.height)?;
let hardware_frame = report.rgba_byte_count == 0;
if hardware_frame && response.current_surface_id.is_none() {
return Err(ServoLiveError::InvalidResponse {
message: "hardware frame is missing current_surface_id",
});
}
if !hardware_frame
&& (response.surface_handle.is_some() || response.current_surface_id.is_some())
{
return Err(ServoLiveError::InvalidResponse {
message: "software frame contains hardware surface metadata",
});
}
if !hardware_frame && report.rgba_byte_count != rgba_byte_count {
return Err(ServoLiveError::InvalidFrameByteCount {
advertised: report.rgba_byte_count,
expected: rgba_byte_count as u64,
width: report.width,
height: report.height,
});
}
let rgba_bytes = if hardware_frame {
None
} else {
let mut bytes = Vec::new();
bytes.try_reserve_exact(rgba_byte_count).map_err(|source| {
ServoLiveError::FrameAllocation { bytes: rgba_byte_count, source }
})?;
bytes.resize(rgba_byte_count, 0);
stdout.read_exact(&mut bytes).map_err(ServoLiveError::FrameRead)?;
Some(bytes)
};
Ok(ServoLiveFrame::from_parts(report, rgba_bytes))
});
Ok(IpcReply {
protocol_version: response.protocol_version,
error: response.error,
frame: frame.transpose()?,
surface_handle: response.surface_handle,
current_surface_id: response.current_surface_id,
})
}
pub(super) fn validate_frame_layout(width: u32, height: u32) -> Result<usize, ServoLiveError> {
if width == 0 || height == 0 || width > MAX_FRAME_DIMENSION || height > MAX_FRAME_DIMENSION {
return Err(ServoLiveError::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(ServoLiveError::InvalidFrameDimensions {
width,
height,
max_dimension: MAX_FRAME_DIMENSION,
})?;
if bytes > MAX_FRAME_BYTE_COUNT as u64 {
return Err(ServoLiveError::FrameByteLimitExceeded { bytes, limit: MAX_FRAME_BYTE_COUNT });
}
Ok(bytes as usize)
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use super::*;
#[test]
fn frame_layout_rejects_oversized_frames() {
assert!(matches!(
validate_frame_layout(MAX_FRAME_DIMENSION, MAX_FRAME_DIMENSION),
Err(ServoLiveError::FrameByteLimitExceeded { .. })
));
assert!(matches!(
validate_frame_layout(MAX_FRAME_DIMENSION + 1, 1),
Err(ServoLiveError::InvalidFrameDimensions { .. })
));
}
#[test]
fn reply_rejects_oversized_frame_before_readback_allocation() {
let header = format!(
"{{\"protocol_version\":2,\"error\":null,\"frame\":{{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",\"width\":{0},\"height\":{0},\"device_pixel_ratio\":1.0,\"css_viewport_width\":{0},\"css_viewport_height\":{0},\"rgba_byte_count\":1073741824,\"pixels_changed\":true}}}}\n",
MAX_FRAME_DIMENSION
);
let mut input = Cursor::new(header.into_bytes());
assert!(matches!(
read_reply(&mut input),
Err(ServoLiveError::FrameByteLimitExceeded { .. })
));
}
#[cfg(target_os = "macos")]
#[test]
fn hardware_reply_uses_surface_without_rgba_allocation() -> Result<(), ServoLiveError> {
let header = concat!(
"{\"protocol_version\":2,\"error\":null,",
"\"surface_handle\":{\"mach_port_name\":91,\"surface_id\":7,\"width\":64,\"height\":48},",
"\"current_surface_id\":7,",
"\"frame\":{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",",
"\"width\":64,\"height\":48,\"device_pixel_ratio\":1.0,",
"\"css_viewport_width\":64,\"css_viewport_height\":48,",
"\"rgba_byte_count\":0,\"pixels_changed\":true}}\n"
);
let mut input = Cursor::new(header.as_bytes());
let reply = read_reply(&mut input)?;
let Some(frame) = reply.frame else {
return Err(ServoLiveError::InvalidResponse { message: "test frame was missing" });
};
assert_eq!(reply.current_surface_id, Some(7));
assert_eq!(reply.surface_handle.map(|handle| handle.surface_id), Some(7));
assert!(frame.into_rgba_bytes().is_none());
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn hardware_reply_rejects_mismatched_surface_identity() {
let header = hardware_header(8, 64, 48);
let mut input = Cursor::new(header.into_bytes());
assert!(matches!(
read_reply(&mut input),
Err(ServoLiveError::InvalidResponse {
message: "surface_handle does not match current_surface_id"
})
));
}
#[cfg(target_os = "macos")]
#[test]
fn hardware_reply_rejects_mismatched_surface_dimensions() {
let header = hardware_header(7, 96, 72);
let mut input = Cursor::new(header.into_bytes());
assert!(matches!(
read_reply(&mut input),
Err(ServoLiveError::InvalidResponse {
message: "surface_handle dimensions do not match frame report"
})
));
}
#[cfg(target_os = "macos")]
fn hardware_header(current_surface_id: u64, handle_width: u32, handle_height: u32) -> String {
format!(
"{{\"protocol_version\":2,\"error\":null,\"surface_handle\":{{\"mach_port_name\":91,\"surface_id\":7,\"width\":{handle_width},\"height\":{handle_height}}},\"current_surface_id\":{current_surface_id},\"frame\":{{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",\"width\":64,\"height\":48,\"device_pixel_ratio\":1.0,\"css_viewport_width\":64,\"css_viewport_height\":48,\"rgba_byte_count\":0,\"pixels_changed\":true}}}}\n"
)
}
}
+246 -70
View File
@@ -1,9 +1,21 @@
#[cfg(target_os = "macos")]
use std::sync::Arc;
use std::{collections::TryReserveError, io, path::PathBuf};
use ely_domain::SitePermissionDecision;
use ely_servo_host::{RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState};
use gpui::NativeSurfaceHandle;
use serde::Serialize;
use thiserror::Error;
use super::wire::LiveFrameReport;
use crate::services::servo_sidecar_command::SidecarCommandError;
#[cfg(target_os = "macos")]
use crate::services::iosurface_mach::IOSurfaceMachError;
#[cfg(target_os = "macos")]
use crate::services::iosurface_metal::HardwareSurfaceBacking;
#[cfg(all(test, target_os = "macos"))]
use core_video::pixel_buffer::CVPixelBuffer;
pub(crate) struct ServoLiveEnsureRequest {
pub(crate) tab_id: String,
pub(crate) profile_id: String,
@@ -11,10 +23,8 @@ pub(crate) struct ServoLiveEnsureRequest {
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) page_zoom_percent: u16,
/// Display scale factor (1.0 standard, 2.0 Retina). Servo's
/// WebView lays out CSS pixels = device pixels / hidpi factor.
/// Display scale factor used to derive Servo's CSS viewport.
pub(crate) device_pixel_ratio: f32,
pub(crate) native_surface: Option<NativeSurfaceHandle>,
pub(crate) scroll_delta_x: i32,
pub(crate) scroll_delta_y: i32,
pub(crate) scroll_point_x: Option<u32>,
@@ -53,6 +63,7 @@ pub(crate) struct ServoLiveFrame {
device_pixel_ratio: f32,
css_viewport_width: u32,
css_viewport_height: u32,
pixels_changed: bool,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -60,62 +71,63 @@ pub(crate) struct ServoLiveFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: u64,
rgba_bytes: Option<Vec<u8>>,
#[cfg(target_os = "macos")]
hardware_surface: Option<Arc<HardwareSurfaceBacking>>,
#[cfg(target_os = "macos")]
hardware_surface_id: Option<u64>,
}
impl ServoLiveFrame {
pub(super) fn from_presented(
snapshot: WebViewSnapshot,
width: u32,
height: u32,
device_pixel_ratio: f32,
) -> Self {
let (css_viewport_width, css_viewport_height) =
css_viewport_size(width, height, device_pixel_ratio);
pub(super) fn from_parts(report: LiveFrameReport, rgba_bytes: Option<Vec<u8>>) -> Self {
let (css_viewport_width, css_viewport_height) = css_viewport_size_from_report(&report);
Self {
loaded_url: snapshot.url().map(str::to_string),
title: snapshot.title().map(str::to_string),
render_state: render_state_label(snapshot.state()).to_string(),
width,
height,
device_pixel_ratio,
loaded_url: report.loaded_url,
title: report.title,
render_state: report.state,
width: report.width,
height: report.height,
device_pixel_ratio: report.device_pixel_ratio,
css_viewport_width,
css_viewport_height,
pixels_changed: report.pixels_changed,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: 1,
non_white_pixel_count: report.non_white_pixel_count,
#[cfg(all(test, feature = "live-site-smoke"))]
content_pixel_count: 1,
content_pixel_count: report.content_pixel_count,
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: 0,
rgba_bytes: None,
sample_hash: report.sample_hash,
rgba_bytes,
#[cfg(target_os = "macos")]
hardware_surface: None,
#[cfg(target_os = "macos")]
hardware_surface_id: None,
}
}
pub(super) fn from_rendered(
snapshot: WebViewSnapshot,
rendered_frame: RenderedFrame,
device_pixel_ratio: f32,
) -> Self {
let width = rendered_frame.width();
let height = rendered_frame.height();
let (css_viewport_width, css_viewport_height) =
css_viewport_size(width, height, device_pixel_ratio);
Self {
loaded_url: snapshot.url().map(str::to_string),
title: snapshot.title().map(str::to_string),
render_state: render_state_label(snapshot.state()).to_string(),
width,
height,
device_pixel_ratio,
css_viewport_width,
css_viewport_height,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: rendered_frame.non_white_pixel_count(),
#[cfg(all(test, feature = "live-site-smoke"))]
content_pixel_count: rendered_frame.content_pixel_count(),
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: rendered_frame.sample_hash(),
rgba_bytes: Some(rendered_frame.rgba_bytes().to_vec()),
}
#[cfg(target_os = "macos")]
pub(super) fn set_hardware_surface(
&mut self,
surface_id: u64,
surface: Option<Arc<HardwareSurfaceBacking>>,
) {
self.hardware_surface_id = surface.as_ref().map(|_| surface_id);
self.hardware_surface = surface;
}
#[cfg(target_os = "macos")]
#[must_use]
pub fn hardware_surface_id(&self) -> Option<u64> {
self.hardware_surface_id
}
#[cfg(target_os = "macos")]
#[must_use]
pub(crate) fn hardware_surface(&self) -> Option<&Arc<HardwareSurfaceBacking>> {
self.hardware_surface.as_ref()
}
pub(super) fn has_software_payload(&self) -> bool {
self.rgba_bytes.is_some()
}
#[must_use]
@@ -158,6 +170,11 @@ impl ServoLiveFrame {
self.css_viewport_height
}
#[must_use]
pub fn pixels_changed(&self) -> bool {
self.pixels_changed
}
#[cfg(all(test, feature = "live-site-smoke"))]
#[must_use]
pub fn non_white_pixel_count(&self) -> u64 {
@@ -183,18 +200,29 @@ impl ServoLiveFrame {
#[cfg(test)]
pub(crate) fn for_test(width: u32, height: u32, rgba_bytes: Vec<u8>) -> Self {
Self::for_test_with_render_state(width, height, rgba_bytes, "complete")
}
#[cfg(test)]
pub(crate) fn for_test_with_render_state(
width: u32,
height: u32,
rgba_bytes: Vec<u8>,
render_state: &str,
) -> Self {
#[cfg(all(test, feature = "live-site-smoke"))]
let summary =
ely_servo_host::RenderedFrameSummary::from_rgba_bytes(width, height, &rgba_bytes);
Self {
loaded_url: Some("https://example.com/".to_string()),
title: Some("Example".to_string()),
render_state: "complete".to_string(),
render_state: render_state.to_string(),
width,
height,
device_pixel_ratio: 1.0,
css_viewport_width: width,
css_viewport_height: height,
pixels_changed: true,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: summary.non_white_pixel_count(),
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -202,49 +230,197 @@ impl ServoLiveFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: summary.sample_hash(),
rgba_bytes: Some(rgba_bytes),
#[cfg(target_os = "macos")]
hardware_surface: None,
#[cfg(target_os = "macos")]
hardware_surface_id: None,
}
}
}
fn render_state_label(state: &WebViewState) -> &'static str {
match state {
WebViewState::Created => "created",
WebViewState::Loading => "loading",
WebViewState::Complete => "complete",
WebViewState::Sleeping => "sleeping",
WebViewState::Crashed => "crashed",
#[cfg(test)]
pub(crate) fn for_test_with_title(
width: u32,
height: u32,
rgba_bytes: Vec<u8>,
render_state: &str,
title: &str,
) -> Self {
let mut frame = Self::for_test_with_render_state(width, height, rgba_bytes, render_state);
frame.title = Some(title.to_string());
frame
}
#[cfg(all(test, target_os = "macos"))]
pub(crate) fn for_test_with_pixel_buffer(
width: u32,
height: u32,
surface_id: u64,
pixel_buffer: CVPixelBuffer,
) -> Self {
Self::for_test_with_hardware_change(width, height, surface_id, pixel_buffer, true)
}
#[cfg(all(test, target_os = "macos"))]
pub(crate) fn for_test_with_hardware_change(
width: u32,
height: u32,
surface_id: u64,
pixel_buffer: CVPixelBuffer,
pixels_changed: bool,
) -> Self {
let mut frame = Self::for_test(width, height, Vec::new());
frame.rgba_bytes = None;
frame.hardware_surface = Some(HardwareSurfaceBacking::new(pixel_buffer));
frame.hardware_surface_id = Some(surface_id);
frame.pixels_changed = pixels_changed;
frame
}
}
fn css_viewport_size(width: u32, height: u32, device_pixel_ratio: f32) -> (u32, u32) {
let scale = if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 {
device_pixel_ratio
fn css_viewport_size_from_report(report: &LiveFrameReport) -> (u32, u32) {
let scale = if report.device_pixel_ratio.is_finite() && report.device_pixel_ratio > 0.0 {
report.device_pixel_ratio
} else {
1.0
};
let fallback_width = ((report.width as f32) / scale).round().max(1.0) as u32;
let fallback_height = ((report.height as f32) / scale).round().max(1.0) as u32;
(
((width as f32) / scale).round().max(1.0) as u32,
((height as f32) / scale).round().max(1.0) as u32,
if report.css_viewport_width > 0 { report.css_viewport_width } else { fallback_width },
if report.css_viewport_height > 0 { report.css_viewport_height } else { fallback_height },
)
}
#[derive(Debug, Error)]
pub(crate) enum ServoLiveError {
#[error("servo native surface is unavailable")]
NativeSurfaceUnavailable,
#[error("servo sidecar binary is unavailable at {path}")]
SidecarBinaryUnavailable { path: PathBuf },
#[error("servo scroll input is missing a viewport point")]
MissingScrollPoint,
#[error("failed to run servo live sidecar: {0}")]
Command(#[source] io::Error),
#[error("servo live sidecar pipe is unavailable: {name}")]
PipeUnavailable { name: &'static str },
#[error("servo live sidecar exited")]
SidecarExited,
#[error("servo live sidecar protocol mismatch: expected {expected}, received {actual:?}")]
ProtocolVersionMismatch { expected: u32, actual: Option<u32> },
#[error("servo live sidecar {operation} timed out after {timeout_millis} ms")]
RequestTimedOut { operation: &'static str, timeout_millis: u128 },
#[error("servo live sidecar failed: {message}")]
SidecarFailed { message: String },
#[error("servo live sidecar response header exceeded {limit} bytes")]
ResponseHeaderTooLarge { limit: usize },
#[error("failed to read servo live frame bytes: {0}")]
FrameRead(#[source] io::Error),
#[error(
"servo live frame dimensions {width}x{height} exceed the {max_dimension}px dimension limit"
)]
InvalidFrameDimensions { width: u32, height: u32, max_dimension: u32 },
#[error("servo live frame requires {bytes} bytes; the limit is {limit}")]
FrameByteLimitExceeded { bytes: u64, limit: usize },
#[error(
"servo live sidecar advertised {advertised} frame bytes for {width}x{height}; expected {expected}"
)]
InvalidFrameByteCount { advertised: usize, expected: u64, width: u32, height: u32 },
#[error("failed to reserve {bytes} bytes for a servo live frame: {source}")]
FrameAllocation {
bytes: usize,
#[source]
source: TryReserveError,
},
#[error("invalid servo live sidecar response: {message}")]
InvalidResponse { message: &'static str },
#[cfg(target_os = "macos")]
#[error(
"servo live IOSurface import failed for surface {surface_id:#x} mach port 0x{mach_port_name:x}: {message}"
)]
IOSurfaceImportFailed { surface_id: u64, mach_port_name: u32, message: String },
#[cfg(target_os = "macos")]
#[error("servo live IOSurface backing failed for surface {surface_id:#x}: {message}")]
IOSurfaceBackingFailed { surface_id: u64, message: String },
#[cfg(target_os = "macos")]
#[error("failed to spawn servo live IOSurface importer: {0}")]
IOSurfaceImportWorker(#[source] io::Error),
#[cfg(target_os = "macos")]
#[error(transparent)]
IOSurfaceMach(#[from] IOSurfaceMachError),
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
Json(#[from] serde_json::Error),
#[error(transparent)]
Host(#[from] ServoHostError),
SidecarCommand(#[from] SidecarCommandError),
}
impl ServoLiveError {
pub(crate) fn is_runtime_unavailable(&self) -> bool {
matches!(self, Self::Host(ServoHostError::RuntimeAlreadyStarted))
match self {
Self::SidecarExited
| Self::ProtocolVersionMismatch { .. }
| Self::RequestTimedOut { .. }
| Self::ResponseHeaderTooLarge { .. }
| Self::InvalidFrameDimensions { .. }
| Self::FrameByteLimitExceeded { .. }
| Self::InvalidFrameByteCount { .. }
| Self::FrameAllocation { .. }
| Self::InvalidResponse { .. }
| Self::Json(_) => true,
#[cfg(target_os = "macos")]
Self::IOSurfaceImportFailed { .. }
| Self::IOSurfaceBackingFailed { .. }
| Self::IOSurfaceImportWorker(_)
| Self::IOSurfaceMach(_) => true,
Self::Command(error) | Self::FrameRead(error) => matches!(
error.kind(),
io::ErrorKind::BrokenPipe
| io::ErrorKind::ConnectionAborted
| io::ErrorKind::ConnectionReset
| io::ErrorKind::UnexpectedEof
),
Self::SidecarBinaryUnavailable { .. }
| Self::PipeUnavailable { .. }
| Self::SidecarFailed { .. }
| Self::SidecarCommand(_) => false,
}
}
}
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
#[test]
fn iosurface_transport_failures_invalidate_runtime() {
let errors = [
ServoLiveError::IOSurfaceImportFailed {
surface_id: 7,
mach_port_name: 11,
message: "injected".to_string(),
},
ServoLiveError::IOSurfaceImportWorker(io::Error::other("injected")),
ServoLiveError::IOSurfaceMach(IOSurfaceMachError::InvalidMessage),
ServoLiveError::IOSurfaceBackingFailed {
surface_id: 7,
message: "injected".to_string(),
},
];
assert!(errors.iter().all(ServoLiveError::is_runtime_unavailable));
}
}
@@ -0,0 +1,141 @@
use serde::{Deserialize, Serialize};
use super::ServoLiveSitePermission;
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(Serialize)]
#[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,
page_zoom_percent: u16,
device_pixel_ratio: f32,
scroll_delta_x: i32,
scroll_delta_y: i32,
scroll_point_x: Option<u32>,
scroll_point_y: Option<u32>,
click_x: Option<u32>,
click_y: Option<u32>,
hover_x: Option<u32>,
hover_y: Option<u32>,
typed_text: Option<String>,
site_permissions: Vec<ServoLiveSitePermission>,
ready_surface_ids: Vec<u64>,
pending_surface_ids: Vec<u64>,
},
Poll {
tab_id: String,
ready_surface_ids: Vec<u64>,
pending_surface_ids: Vec<u64>,
},
Close {
tab_id: String,
},
Shutdown,
}
#[derive(Deserialize)]
pub(super) struct LiveResponse {
#[serde(default)]
pub(super) protocol_version: Option<u32>,
pub(super) error: Option<String>,
pub(super) frame: Option<LiveFrameReport>,
#[serde(default)]
pub(super) surface_handle: Option<LiveSurfaceHandle>,
#[serde(default)]
pub(super) current_surface_id: Option<u64>,
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub(super) struct LiveSurfaceHandle {
pub(super) mach_port_name: u32,
pub(super) surface_id: u64,
pub(super) width: u32,
pub(super) height: u32,
}
#[derive(Deserialize)]
pub(super) struct LiveFrameReport {
pub(super) loaded_url: Option<String>,
pub(super) title: Option<String>,
pub(super) state: String,
pub(super) width: u32,
pub(super) height: u32,
#[serde(default = "default_device_pixel_ratio")]
pub(super) device_pixel_ratio: f32,
#[serde(default)]
pub(super) css_viewport_width: u32,
#[serde(default)]
pub(super) css_viewport_height: u32,
pub(super) rgba_byte_count: usize,
pub(super) pixels_changed: bool,
#[cfg(all(test, feature = "live-site-smoke"))]
#[serde(default)]
pub(super) non_white_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
#[serde(default)]
pub(super) content_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
#[serde(default)]
pub(super) sample_hash: u64,
}
fn default_device_pixel_ratio() -> f32 {
1.0
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{LIVE_PROTOCOL_VERSION, LiveRequest};
#[test]
fn handshake_request_serializes_protocol_version() -> Result<(), serde_json::Error> {
let value = serde_json::to_value(LiveRequest::Handshake {
protocol_version: LIVE_PROTOCOL_VERSION,
})?;
assert_eq!(value, json!({"type": "handshake", "protocol_version": LIVE_PROTOCOL_VERSION}));
Ok(())
}
#[test]
fn close_request_serializes_to_wire() -> Result<(), serde_json::Error> {
let value =
serde_json::to_value(LiveRequest::Close { tab_id: "tab-live-close".to_string() })?;
assert_eq!(value, json!({"type": "close", "tab_id": "tab-live-close"}));
Ok(())
}
#[test]
fn poll_serializes_hardware_surface_states() -> Result<(), serde_json::Error> {
let value = serde_json::to_value(LiveRequest::Poll {
tab_id: "tab-live".to_string(),
ready_surface_ids: vec![7, 11],
pending_surface_ids: vec![13],
})?;
assert_eq!(
value,
json!({
"type": "poll",
"tab_id": "tab-live",
"ready_surface_ids": [7, 11],
"pending_surface_ids": [13]
})
);
Ok(())
}
}
+339 -27
View File
@@ -1,16 +1,66 @@
use std::{
env,
fs::{File, OpenOptions},
io,
path::{Path, PathBuf},
time::{SystemTime, SystemTimeError, UNIX_EPOCH},
};
use directories::ProjectDirs;
use ely_domain::{ProfileId, ProfileKind};
use ely_domain::ProfileId;
const ELY_QUALIFIER: &str = "com";
const ELY_ORGANIZATION: &str = "elydora";
const ELY_APPLICATION: &str = "ELY Browser";
const DEFAULT_STANDARD_PROFILE_DIR: &str = "default";
const TRANSIENT_PROFILE_ROOT: &str = "ely-browser-servo-profiles";
const TRANSIENT_PROFILE_PREFIX: &str = "profile-";
const TRANSIENT_PROFILE_LEASE: &str = ".lease";
const TRANSIENT_ROOT_LOCK: &str = ".lock";
pub(crate) struct TransientProfileDataDir {
directory: Option<tempfile::TempDir>,
lease: Option<File>,
root: PathBuf,
path: PathBuf,
}
impl TransientProfileDataDir {
pub(crate) fn path(&self) -> &Path {
&self.path
}
pub(crate) fn close(mut self) -> Result<(), io::Error> {
self.remove()
}
fn remove(&mut self) -> Result<(), io::Error> {
let Some(directory) = self.directory.take() else {
return Ok(());
};
let path = directory.keep();
let root_lock = match open_lock_file(&self.root.join(TRANSIENT_ROOT_LOCK)) {
Ok(root_lock) => root_lock,
Err(error) => {
self.lease.take();
return Err(error);
}
};
if let Err(error) = lock_file(&root_lock) {
self.lease.take();
return Err(error);
}
self.lease.take();
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
}
impl Drop for TransientProfileDataDir {
fn drop(&mut self) {
let _ = self.remove();
}
}
pub(crate) fn default_profile_data_root() -> Option<PathBuf> {
ProjectDirs::from(ELY_QUALIFIER, ELY_ORGANIZATION, ELY_APPLICATION)
@@ -21,27 +71,147 @@ pub(crate) fn profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId)
profile_data_root.join(profile_id.as_str()).join("servo")
}
pub(crate) fn sync_profile_data_dir(
pub(crate) fn sync_profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId) -> PathBuf {
profile_data_dir(profile_data_root, profile_id)
}
pub(crate) fn create_profile_data_dir(
profile_data_root: &Path,
profile_id: &ProfileId,
profile_name: &str,
profile_kind: &ProfileKind,
) -> PathBuf {
if profile_name == "Default" && matches!(profile_kind, ProfileKind::Standard) {
return profile_data_root.join(DEFAULT_STANDARD_PROFILE_DIR).join("servo");
) -> Result<PathBuf, io::Error> {
let profile_dir = profile_data_root.join(profile_id.as_str());
let servo_dir = profile_dir.join("servo");
std::fs::create_dir_all(&servo_dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&profile_dir, std::fs::Permissions::from_mode(0o700))?;
std::fs::set_permissions(&servo_dir, std::fs::Permissions::from_mode(0o700))?;
}
profile_data_dir(profile_data_root, profile_id)
Ok(servo_dir)
}
pub(crate) fn cleanup_stale_transient_profile_data_dirs() -> Result<(), io::Error> {
cleanup_stale_transient_profile_data_dirs_at(&transient_profile_data_root()?)
}
pub(crate) fn transient_profile_data_dir(
profile_id: &ProfileId,
) -> Result<PathBuf, SystemTimeError> {
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
Ok(env::temp_dir().join("ely-browser-servo-profiles").join(format!(
"{}-{}-{timestamp}",
std::process::id(),
profile_id.as_str()
)))
) -> Result<TransientProfileDataDir, io::Error> {
let root = transient_profile_data_root()?;
create_private_directory(&root)?;
let root_lock = open_lock_file(&root.join(TRANSIENT_ROOT_LOCK))?;
lock_file(&root_lock)?;
cleanup_stale_transient_profile_data_dirs_locked(&root, |path| std::fs::remove_dir_all(path))?;
let directory = tempfile::Builder::new()
.prefix(&format!("{TRANSIENT_PROFILE_PREFIX}{}-", profile_id.as_str()))
.tempdir_in(&root)?;
create_private_directory(directory.path())?;
let lease = open_lock_file(&directory.path().join(TRANSIENT_PROFILE_LEASE))?;
lock_file(&lease)?;
let path = directory.path().to_path_buf();
drop(root_lock);
Ok(TransientProfileDataDir { directory: Some(directory), lease: Some(lease), root, path })
}
fn transient_profile_data_root() -> Result<PathBuf, io::Error> {
let project_dirs = ProjectDirs::from(ELY_QUALIFIER, ELY_ORGANIZATION, ELY_APPLICATION)
.ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "ELY project directory unavailable")
})?;
let base = project_dirs.runtime_dir().unwrap_or_else(|| project_dirs.cache_dir());
Ok(base.join(TRANSIENT_PROFILE_ROOT))
}
fn cleanup_stale_transient_profile_data_dirs_at(root: &Path) -> Result<(), io::Error> {
create_private_directory(root)?;
let root_lock = open_lock_file(&root.join(TRANSIENT_ROOT_LOCK))?;
lock_file(&root_lock)?;
cleanup_stale_transient_profile_data_dirs_locked(root, |path| std::fs::remove_dir_all(path))
}
fn cleanup_stale_transient_profile_data_dirs_locked(
root: &Path,
remove_dir: impl Fn(&Path) -> Result<(), io::Error>,
) -> Result<(), io::Error> {
for entry in std::fs::read_dir(root)? {
let entry = entry?;
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
Err(error) => return Err(error),
};
if !file_type.is_dir()
|| !entry.file_name().to_string_lossy().starts_with(TRANSIENT_PROFILE_PREFIX)
{
continue;
}
let lease = match open_lock_file(&entry.path().join(TRANSIENT_PROFILE_LEASE)) {
Ok(lease) => lease,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
remove_directory_if_present(&remove_dir, &entry.path())?;
continue;
}
Err(error) => return Err(error),
};
if try_lock_file(&lease)? {
remove_directory_if_present(&remove_dir, &entry.path())?;
}
}
Ok(())
}
fn remove_directory_if_present(
remove_dir: &impl Fn(&Path) -> Result<(), io::Error>,
path: &Path,
) -> Result<(), io::Error> {
match remove_dir(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
fn open_lock_file(path: &Path) -> Result<File, io::Error> {
OpenOptions::new().create(true).read(true).write(true).truncate(false).open(path)
}
fn lock_file(file: &File) -> Result<(), io::Error> {
file.lock()
}
fn try_lock_file(file: &File) -> Result<bool, io::Error> {
match file.try_lock() {
Ok(()) => Ok(true),
Err(std::fs::TryLockError::WouldBlock) => Ok(false),
Err(std::fs::TryLockError::Error(error)) => Err(error),
}
}
fn create_private_directory(path: &Path) -> Result<(), io::Error> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
match std::fs::create_dir(path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
let metadata = std::fs::symlink_metadata(path)?;
if !metadata.file_type().is_dir() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("private directory path is not a directory: {}", path.display()),
));
}
}
Err(error) => return Err(error),
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
@@ -52,19 +222,46 @@ pub(crate) enum ProfileDataMode {
#[cfg(test)]
mod tests {
use super::{profile_data_dir, sync_profile_data_dir};
use ely_domain::{ProfileId, ProfileKind};
use std::{cell::Cell, io};
use super::{
TRANSIENT_PROFILE_LEASE, TRANSIENT_PROFILE_PREFIX,
cleanup_stale_transient_profile_data_dirs_at,
cleanup_stale_transient_profile_data_dirs_locked, create_profile_data_dir, open_lock_file,
profile_data_dir, sync_profile_data_dir, transient_profile_data_dir,
transient_profile_data_root,
};
use ely_domain::ProfileId;
#[test]
fn default_standard_sync_profile_dir_is_stable() {
fn default_standard_sync_profile_dir_keeps_profile_identity() {
let root = std::path::Path::new("/profiles");
let first_id = ProfileId::new();
let second_id = ProfileId::new();
assert_eq!(
sync_profile_data_dir(root, &first_id, "Default", &ProfileKind::Standard),
sync_profile_data_dir(root, &second_id, "Default", &ProfileKind::Standard)
);
assert_eq!(sync_profile_data_dir(root, &first_id), profile_data_dir(root, &first_id));
assert_eq!(sync_profile_data_dir(root, &second_id), profile_data_dir(root, &second_id));
assert_ne!(profile_data_dir(root, &first_id), profile_data_dir(root, &second_id));
}
#[test]
fn persistent_profile_directory_is_private() -> Result<(), std::io::Error> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let servo_dir = create_profile_data_dir(directory.path(), &profile_id)?;
assert_eq!(servo_dir, profile_data_dir(directory.path(), &profile_id));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(directory.path().join(profile_id.as_str()))?.permissions().mode()
& 0o777,
0o700
);
assert_eq!(std::fs::metadata(servo_dir)?.permissions().mode() & 0o777, 0o700);
}
Ok(())
}
#[test]
@@ -72,9 +269,124 @@ mod tests {
let root = std::path::Path::new("/profiles");
let profile_id = ProfileId::new();
assert_eq!(sync_profile_data_dir(root, &profile_id), profile_data_dir(root, &profile_id));
}
#[test]
fn transient_profile_directory_is_private_and_removed_on_close() -> Result<(), io::Error> {
let directory = transient_profile_data_dir(&ProfileId::new())?;
let path = directory.path().to_path_buf();
assert!(path.is_dir());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&path)?.permissions().mode() & 0o777, 0o700);
}
directory.close()?;
assert!(!path.exists());
Ok(())
}
#[test]
fn transient_profile_root_uses_the_user_project_directory() -> Result<(), io::Error> {
let project_dirs = directories::ProjectDirs::from(
super::ELY_QUALIFIER,
super::ELY_ORGANIZATION,
super::ELY_APPLICATION,
)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "project directory unavailable"))?;
let expected_base = project_dirs.runtime_dir().unwrap_or_else(|| project_dirs.cache_dir());
assert_eq!(
sync_profile_data_dir(root, &profile_id, "Personal", &ProfileKind::Standard),
profile_data_dir(root, &profile_id)
transient_profile_data_root()?,
expected_base.join(super::TRANSIENT_PROFILE_ROOT)
);
Ok(())
}
#[test]
fn startup_cleanup_preserves_leased_profile_and_removes_stale_profile() -> Result<(), io::Error>
{
let root = tempfile::tempdir()?;
let active = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}active"));
let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale"));
std::fs::create_dir_all(&active)?;
std::fs::create_dir_all(&stale)?;
let active_lease = open_lock_file(&active.join(TRANSIENT_PROFILE_LEASE))?;
super::lock_file(&active_lease)?;
open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?;
cleanup_stale_transient_profile_data_dirs_at(root.path())?;
assert!(active.is_dir());
assert!(!stale.exists());
Ok(())
}
#[test]
fn cleanup_failure_keeps_stale_profile_for_retry() -> Result<(), io::Error> {
let root = tempfile::tempdir()?;
let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale"));
std::fs::create_dir_all(&stale)?;
open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?;
let attempts = Cell::new(0_u8);
let result = cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| {
attempts.set(attempts.get() + 1);
if attempts.get() == 1 {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected failure"));
}
std::fs::remove_dir_all(path)
});
let Err(error) = result else {
return Err(io::Error::other("the first cleanup attempt succeeded"));
};
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert!(stale.is_dir());
cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| {
std::fs::remove_dir_all(path)
})?;
assert!(!stale.exists());
Ok(())
}
#[test]
fn cleanup_accepts_a_profile_removed_concurrently() -> Result<(), io::Error> {
let root = tempfile::tempdir()?;
let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale"));
std::fs::create_dir(&stale)?;
open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?;
cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| {
std::fs::remove_dir_all(path)?;
Err(io::Error::new(io::ErrorKind::NotFound, "removed by cleanup worker"))
})?;
assert!(!stale.exists());
Ok(())
}
#[cfg(unix)]
#[test]
fn cleanup_rejects_a_symlinked_transient_root() -> Result<(), io::Error> {
use std::os::unix::fs::symlink;
let parent = tempfile::tempdir()?;
let victim = tempfile::tempdir()?;
let stale = victim.path().join(format!("{TRANSIENT_PROFILE_PREFIX}victim"));
std::fs::create_dir(&stale)?;
let root = parent.path().join("transient-root");
symlink(victim.path(), &root)?;
let Err(error) = cleanup_stale_transient_profile_data_dirs_at(&root) else {
return Err(io::Error::other("symlinked transient root was accepted"));
};
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
assert!(stale.is_dir());
Ok(())
}
}
@@ -0,0 +1,147 @@
use std::{
env, io,
path::{Path, PathBuf},
process::Command,
};
use thiserror::Error;
const SIDECAR_PATH_ENV: &str = "ELY_SERVO_SIDECAR";
const RENDERING_CONTEXT_ENV: &str = "ELY_SERVO_RENDERING_CONTEXT";
#[derive(Clone, Debug)]
pub(super) struct SidecarCommandTarget(PathBuf);
impl SidecarCommandTarget {
pub(super) fn command(&self) -> Command {
Command::new(&self.0)
}
pub(super) fn missing_binary_path(&self) -> Option<&Path> {
(!self.0.is_file()).then_some(self.0.as_path())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum SidecarRenderingContext {
Software,
Hardware,
}
impl SidecarRenderingContext {
pub(super) fn cli_arg(self) -> &'static str {
match self {
Self::Software => "software",
Self::Hardware => "hardware",
}
}
}
#[derive(Debug, Error)]
pub(crate) enum SidecarCommandError {
#[error("current executable path is unavailable: {0}")]
CurrentExecutable(#[source] io::Error),
#[error("current executable directory is unavailable for {path}")]
CurrentExecutableDirectoryUnavailable { path: PathBuf },
}
pub(super) fn default_sidecar_command() -> Result<SidecarCommandTarget, SidecarCommandError> {
if let Some(path) = env::var_os(SIDECAR_PATH_ENV) {
return Ok(SidecarCommandTarget(PathBuf::from(path)));
}
let current_exe = env::current_exe().map_err(SidecarCommandError::CurrentExecutable)?;
let exe_dir = current_exe.parent().ok_or_else(|| {
SidecarCommandError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() }
})?;
let adjacent_sidecar = exe_dir.join(sidecar_binary_name());
let workspace_manifest = workspace_manifest_path();
if adjacent_sidecar.is_file() || is_macos_app_bundle_exe_dir(exe_dir) {
return Ok(SidecarCommandTarget(adjacent_sidecar));
}
if let Some(target_sidecar) =
workspace_manifest.as_deref().and_then(workspace_target_sidecar_path)
{
return Ok(SidecarCommandTarget(target_sidecar));
}
Ok(SidecarCommandTarget(adjacent_sidecar))
}
pub(super) fn rendering_context_from_env() -> SidecarRenderingContext {
let raw = env::var(RENDERING_CONTEXT_ENV).ok();
rendering_context_selection(raw.as_deref())
}
fn rendering_context_selection(raw: Option<&str>) -> SidecarRenderingContext {
match raw.map(str::to_lowercase).as_deref() {
Some("software") => SidecarRenderingContext::Software,
Some("hardware") => SidecarRenderingContext::Hardware,
_ if cfg!(target_os = "macos") => SidecarRenderingContext::Hardware,
_ => SidecarRenderingContext::Software,
}
}
fn workspace_manifest_path() -> Option<PathBuf> {
option_env!("ELY_WORKSPACE_MANIFEST").filter(|path| !path.is_empty()).map(PathBuf::from)
}
fn workspace_target_sidecar_path(manifest_path: &Path) -> Option<PathBuf> {
let profile = if cfg!(debug_assertions) { "debug" } else { "release" };
Some(manifest_path.parent()?.join("target").join(profile).join(sidecar_binary_name()))
}
fn sidecar_binary_name() -> String {
format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX)
}
fn is_macos_app_bundle_exe_dir(path: &Path) -> bool {
path.file_name().is_some_and(|name| name == "MacOS")
&& path
.parent()
.is_some_and(|contents| contents.file_name().is_some_and(|name| name == "Contents"))
&& path
.parent()
.and_then(Path::parent)
.is_some_and(|bundle| bundle.extension().is_some_and(|extension| extension == "app"))
}
#[cfg(test)]
mod tests {
use super::{
SidecarRenderingContext, is_macos_app_bundle_exe_dir, rendering_context_selection,
sidecar_binary_name, workspace_target_sidecar_path,
};
#[test]
fn workspace_sidecar_path_uses_current_build_profile() {
let manifest = std::path::Path::new("/workspace/Cargo.toml");
let profile = if cfg!(debug_assertions) { "debug" } else { "release" };
assert_eq!(
workspace_target_sidecar_path(manifest),
Some(
std::path::Path::new("/workspace/target").join(profile).join(sidecar_binary_name())
)
);
}
#[test]
fn rendering_context_follows_explicit_environment_selection() {
let hardware = rendering_context_selection(Some("HARDWARE"));
let software = rendering_context_selection(Some("software"));
assert_eq!(hardware, SidecarRenderingContext::Hardware);
assert_eq!(software, SidecarRenderingContext::Software);
}
#[test]
fn recognizes_macos_app_bundle_executable_directory() {
assert!(is_macos_app_bundle_exe_dir(std::path::Path::new(
"/tmp/ELY Browser.app/Contents/MacOS"
)));
assert!(!is_macos_app_bundle_exe_dir(std::path::Path::new("/tmp/target/debug")));
}
}
+2 -12
View File
@@ -104,12 +104,7 @@ impl ElyShell {
};
return;
};
let profile_dir = sync_profile_data_dir(
&profile_root,
&active_profile.id,
&active_profile.name,
&active_profile.kind,
);
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile.id);
self.auth_flow_phase = AuthFlowPhase::Verifying { email: email.clone() };
let tx = self.sync_inbox_tx.clone();
spawn_verify_otp(email, normalized_otp, profile_dir, tx);
@@ -127,12 +122,7 @@ impl ElyShell {
let Some(profile_root) = default_profile_data_root() else {
return;
};
let profile_dir = sync_profile_data_dir(
&profile_root,
&active_profile.id,
&active_profile.name,
&active_profile.kind,
);
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile.id);
match SyncEngine::for_profile_dir(&profile_dir, "ELY", sync_platform_label()) {
Ok(mut engine) => {
let _ = engine.install_bearer("");
@@ -271,6 +271,6 @@ fn identical_live_frames_share_render_image_arc() -> Result<(), String> {
Arc::as_ptr(first_image),
Arc::as_ptr(second_image),
);
assert!(first.has_same_software_render_as(&second));
assert!(first.has_same_render_as(&second));
Ok(())
}
@@ -89,7 +89,7 @@ fn render_about_rows(snapshot: &BrowserSnapshot) -> AnyElement {
format!("scheme: {DEEP_LINK_SCHEME}; auth: {AUTH_CALLBACK_URL}"),
))
.child(about_row(IconName::Info, "Version", APP_VERSION, "Cargo package version"))
.child(about_row(IconName::GitHub, "Build", BUILD_REVISION, "Git revision"))
.child(about_row(IconName::GitHub, "Build", BUILD_REVISION, "Build source"))
.child(about_row(
IconName::Frame,
"GPUI",
@@ -257,7 +257,7 @@ fn render_compatibility_rows(
format!("gpui {GPUI_VERSION}"),
"Native shell renderer",
))
.child(compatibility_row(IconName::GitHub, "Build", BUILD_REVISION, "Git revision"))
.child(compatibility_row(IconName::GitHub, "Build", BUILD_REVISION, "Build source"))
.child(compatibility_row(
IconName::CircleCheck,
"Site Permissions",
+20 -10
View File
@@ -121,15 +121,28 @@ pub struct ElyShell {
impl ElyShell {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::new_with_config(InitialBrowserConfig::ely_defaults(), window, cx)
let config = InitialBrowserConfig::ely_defaults()
.map_err(|error| error.to_string())
.and_then(|mut config| {
config.profile_id = Some(
crate::services::profile_identity::default_standard_profile_id()
.map_err(|error| error.to_string())?,
);
Ok(config)
});
Self::new_with_config(config, window, cx)
}
pub fn new_private(window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::new_with_config(InitialBrowserConfig::private_window(), window, cx)
Self::new_with_config(
InitialBrowserConfig::private_window().map_err(|error| error.to_string()),
window,
cx,
)
}
fn new_with_config(
config: Result<InitialBrowserConfig, ely_domain::DomainError>,
config: Result<InitialBrowserConfig, String>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -192,14 +205,11 @@ impl ElyShell {
},
);
let state = match config.and_then(|config| {
BrowserCore::new(config).map_err(|error| match error {
ely_browser_core::CoreError::Domain(source) => source,
_ => ely_domain::DomainError::InvalidCommand,
})
}) {
let state = match config
.and_then(|config| BrowserCore::new(config).map_err(|error| error.to_string()))
{
Ok(core) => ShellState::Ready(Box::new(core)),
Err(error) => ShellState::StartupError(error.to_string()),
Err(error) => ShellState::StartupError(error),
};
let (sync_inbox_tx, sync_inbox_rx) = std::sync::mpsc::channel();
+1 -8
View File
@@ -269,19 +269,12 @@ impl ElyShell {
return;
};
let active_profile_id = snapshot.active_profile_id.clone();
let active_profile_name = snapshot.active_profile_name.clone();
let active_profile_kind = snapshot.active_profile_kind.clone();
let device_name = format!("ELY · {}", snapshot.active_profile_name);
let Some(profile_root) = default_profile_data_root() else {
tracing::warn!(target: "ely::sync", "profile data root is unavailable");
return;
};
let profile_dir = sync_profile_data_dir(
&profile_root,
&active_profile_id,
&active_profile_name,
&active_profile_kind,
);
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile_id);
let bytes = match core.build_sync_snapshot_bytes() {
Ok(bytes) => bytes,
Err(error) => {
+40 -22
View File
@@ -116,8 +116,6 @@ impl ElyShell {
let profile_dir = crate::services::servo_profile_data::sync_profile_data_dir(
&profile_root,
&snapshot.active_profile_id,
&snapshot.active_profile_name,
&snapshot.active_profile_kind,
);
if snapshot.active_profile_name == "Default"
&& matches!(snapshot.active_profile_kind, ProfileKind::Standard)
@@ -237,27 +235,18 @@ fn migrate_legacy_default_sync_dir(profile_root: &Path, stable_profile_dir: &Pat
return;
}
let Ok(entries) = std::fs::read_dir(profile_root) else {
return;
};
for entry in entries.flatten() {
let candidate = entry.path().join("servo").join("sync");
if candidate == stable_sync_dir {
continue;
}
if !bearer_token_file_present(&candidate.join("bearer.token")) {
continue;
}
if let Err(error) = copy_dir_recursive(&candidate, &stable_sync_dir) {
tracing::warn!(
target: "ely::sync",
error = %error,
source = %candidate.display(),
"legacy sync profile migration failed",
);
}
let candidate = profile_root.join("default").join("servo").join("sync");
if !bearer_token_file_present(&candidate.join("bearer.token")) {
return;
}
if let Err(error) = copy_dir_recursive(&candidate, &stable_sync_dir) {
tracing::warn!(
target: "ely::sync",
error = %error,
source = %candidate.display(),
"legacy sync profile migration failed",
);
}
}
fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()> {
@@ -277,7 +266,7 @@ fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()>
#[cfg(test)]
mod tests {
use super::bearer_token_file_present;
use super::{bearer_token_file_present, migrate_legacy_default_sync_dir};
#[test]
fn bearer_token_file_presence_requires_bytes() -> Result<(), Box<dyn std::error::Error>> {
@@ -297,4 +286,33 @@ mod tests {
std::fs::remove_dir_all(dir)?;
Ok(())
}
#[test]
fn known_default_sync_directory_is_migrated() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let legacy = directory.path().join("default/servo/sync");
let stable = directory.path().join("profile_stable/servo");
std::fs::create_dir_all(&legacy)?;
std::fs::write(legacy.join("bearer.token"), "default-token")?;
migrate_legacy_default_sync_dir(directory.path(), &stable);
assert_eq!(std::fs::read_to_string(stable.join("sync/bearer.token"))?, "default-token");
Ok(())
}
#[test]
fn custom_profile_bearer_is_ignored_during_default_migration()
-> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let custom = directory.path().join("profile_custom/servo/sync");
let stable = directory.path().join("profile_stable/servo");
std::fs::create_dir_all(&custom)?;
std::fs::write(custom.join("bearer.token"), "custom-token")?;
migrate_legacy_default_sync_dir(directory.path(), &stable);
assert!(!stable.join("sync/bearer.token").exists());
Ok(())
}
}
+98 -89
View File
@@ -35,10 +35,23 @@ impl WebSurfaceStore {
Self { runtime, surfaces: BTreeMap::new(), keyboard_focus: None }
}
#[cfg(test)]
pub(super) fn state(&self, tab_id: &TabId) -> Option<&WebSurfaceState> {
self.surfaces.get(tab_id).and_then(|surface| surface.state.as_ref())
}
pub(super) fn state_for_scope(
&self,
tab_id: &TabId,
profile_id: &ely_domain::ProfileId,
profile_data_mode: ProfileDataMode,
) -> Option<&WebSurfaceState> {
self.surfaces
.get(tab_id)
.filter(|surface| surface.has_scope(profile_id, profile_data_mode))
.and_then(|surface| surface.state.as_ref())
}
pub(super) fn ensure_surface(
&mut self,
tab: &BrowserTab,
@@ -53,23 +66,28 @@ impl WebSurfaceStore {
else {
return false;
};
let native_surface =
self.surfaces.get(tab.id()).and_then(|surface| surface.native_surface.clone());
#[cfg(not(test))]
let Some(native_surface) = native_surface else {
return false;
};
let ensure_key = WebSurfaceEnsureKey::new(
requested_url.clone(),
size,
#[cfg(test)]
native_surface.as_ref(),
#[cfg(not(test))]
Some(&native_surface),
tab.profile_id().clone(),
profile_data_mode,
tab.zoom_percent(),
permissions,
);
if self.surfaces.get(tab.id()).is_some_and(|surface| !surface.should_ensure(&ensure_key)) {
let scope_changed = self.surface_mut(tab.id()).reset_for_scope_change(&ensure_key);
if scope_changed
&& self.keyboard_focus.as_ref().is_some_and(|focus| focus.tab_id == *tab.id())
{
self.keyboard_focus = None;
}
let runtime_has_session =
self.runtime.has_session(tab.id(), tab.profile_id(), profile_data_mode);
if runtime_has_session
&& self
.surfaces
.get(tab.id())
.is_some_and(|surface| !surface.should_ensure(&ensure_key))
{
return false;
}
// Once the page has ensured at least once, defer further ensures
@@ -89,31 +107,23 @@ impl WebSurfaceStore {
{
return false;
}
let input = self.take_pending_input(tab.id(), requested_url.as_str());
let previous_frame =
self.previous_ready_frame(tab.id(), requested_url.as_str(), tab.zoom_percent());
if let Err(message) =
self.runtime.prepare_tab_scope(tab.id(), tab.profile_id(), profile_data_mode)
{
return self.record_ensure_failure(
tab.id(),
ensure_key,
requested_url,
previous_frame,
message,
);
}
let input = self.take_pending_input(tab.id(), requested_url.as_str());
#[cfg(test)]
let ensure_result = match native_surface {
Some(native_surface) => self.runtime.ensure_tab_with_native_surface(
tab,
size,
native_surface,
profile_data_mode,
permissions,
input,
),
None => self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input),
};
#[cfg(not(test))]
let ensure_result = self.runtime.ensure_tab_with_native_surface(
tab,
size,
native_surface,
profile_data_mode,
permissions,
input,
);
let ensure_result =
self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input);
match ensure_result {
Ok(result) => {
@@ -127,9 +137,35 @@ impl WebSurfaceStore {
}
false
}
Err(message) => {
self.surface_mut(tab.id()).mark_ensured(ensure_key);
self.surface_mut(tab.id()).state = Some(WebSurfaceState::Failed { message });
Err(message) => self.record_ensure_failure(
tab.id(),
ensure_key,
requested_url,
previous_frame,
message,
),
}
}
fn record_ensure_failure(
&mut self,
tab_id: &TabId,
ensure_key: WebSurfaceEnsureKey,
requested_url: String,
previous_frame: Option<WebSurfaceFrame>,
message: String,
) -> bool {
self.surface_mut(tab_id).mark_ensured(ensure_key);
match previous_frame {
Some(previous_frame) => {
self.surface_mut(tab_id).state = Some(WebSurfaceState::Loading {
requested_url,
previous_frame: Some(previous_frame),
});
false
}
None => {
self.surface_mut(tab_id).state = Some(WebSurfaceState::Failed { message });
true
}
}
@@ -137,6 +173,13 @@ impl WebSurfaceStore {
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> WebSurfaceTickResult {
let frames = self.runtime.tick(visible_tab_ids);
self.apply_runtime_frames(frames)
}
fn apply_runtime_frames(
&mut self,
frames: Vec<WebSurfaceRuntimeFrame>,
) -> WebSurfaceTickResult {
let mut result = WebSurfaceTickResult::default();
for frame in frames {
@@ -146,29 +189,12 @@ impl WebSurfaceStore {
self.surfaces.get(&tab_id).and_then(|surface| surface.state.as_ref()),
Some(WebSurfaceState::Ready(_))
);
match self.initial_display_gate_message(&tab_id, &frame, had_ready) {
Ok(()) => {}
Err(message) => {
if !had_ready {
self.surface_mut(&tab_id).state =
Some(WebSurfaceState::Failed { message });
result.changed = true;
}
continue;
}
}
if self.should_hold_initial_frame(&tab_id, &frame, had_ready) {
if !had_ready {
self.surface_mut(&tab_id).state = Some(WebSurfaceState::Loading {
requested_url: frame.requested_url.clone(),
previous_frame: None,
});
result.changed = true;
}
continue;
}
let metadata = self.surface_mut(&tab_id).changed_page_metadata(&tab_id, &frame);
result.page_metadata.extend(metadata);
result.url_changes.extend(url_change);
if self.should_hold_initial_frame(&tab_id, &frame, had_ready) {
continue;
}
if self
.surfaces
.get(&tab_id)
@@ -177,7 +203,6 @@ impl WebSurfaceStore {
self.surface_mut(&tab_id).state = Some(WebSurfaceState::Ready(*frame));
result.changed = true;
}
result.url_changes.extend(url_change);
}
WebSurfaceRuntimeFrame::Failed { tab_id, message } => {
let had_ready = matches!(
@@ -303,32 +328,11 @@ impl WebSurfaceStore {
frame: &WebSurfaceFrame,
has_previous_frame: bool,
) -> bool {
!has_previous_frame
frame.render_state() != "complete"
&& !has_previous_frame
&& self
.previous_ready_frame(tab_id, frame.requested_url.as_str(), frame.zoom_percent())
.is_none()
&& matches!(frame.has_visible_content_for_initial_display(), Ok(false))
}
fn initial_display_gate_message(
&self,
tab_id: &TabId,
frame: &WebSurfaceFrame,
has_previous_frame: bool,
) -> Result<(), String> {
if has_previous_frame
|| self
.previous_ready_frame(tab_id, frame.requested_url.as_str(), frame.zoom_percent())
.is_some()
{
return Ok(());
}
frame.has_visible_content_for_initial_display().map(|_| ()).map_err(|error| {
format!(
"Servo hardware surface initial content check failed for {}: {error}",
frame.requested_url
)
})
}
pub(super) fn surface_mut(&mut self, tab_id: &TabId) -> &mut PerTabSurface {
@@ -349,14 +353,7 @@ impl WebSurfaceStore {
}
#[cfg(test)]
pub(super) fn clear_viewport_resize_debounce_for_test(&mut self, tab_id: &TabId) {
if let Some(surface) = self.surfaces.get_mut(tab_id) {
surface.clear_viewport_resize_debounce_for_test();
}
}
#[cfg(test)]
pub(super) fn flush_runtime_for_test(&self) {
pub(super) fn flush_runtime_for_test(&mut self) {
self.runtime.flush_for_test();
}
}
@@ -372,3 +369,15 @@ mod tests;
#[cfg(all(test, feature = "live-site-smoke"))]
#[path = "web_surface_live_site_tests.rs"]
mod web_surface_live_site_tests;
#[cfg(all(test, feature = "live-site-smoke"))]
#[path = "web_surface_profile_isolation_tests.rs"]
mod web_surface_profile_isolation_tests;
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
#[path = "web_surface_hardware_import_tests.rs"]
mod web_surface_hardware_import_tests;
#[cfg(test)]
#[path = "web_surface_scope_tests.rs"]
mod web_surface_scope_tests;
+31 -13
View File
@@ -41,7 +41,10 @@ impl WebSurfacePollCadence {
}
}
pub(super) fn note_frame(&mut self, render_state: &str, now: Instant) {
pub(super) fn note_frame(&mut self, render_state: &str, pixels_changed: bool, now: Instant) {
if pixels_changed {
extend_deadline(&mut self.settle_active_until, now + FRAME_SETTLE_WINDOW);
}
let phase = WebSurfaceRenderPhase::from_render_state(render_state);
match phase {
WebSurfaceRenderPhase::Created | WebSurfaceRenderPhase::Loading
@@ -173,7 +176,7 @@ mod tests {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("loading", start);
cadence.note_frame("loading", true, start);
cadence.note_poll_submitted(start);
assert!(cadence.next_poll_delay(start) <= FRAME_BUDGET_120HZ);
@@ -209,7 +212,7 @@ mod tests {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("complete", start);
cadence.note_frame("complete", true, start);
cadence.note_poll_submitted(start + Duration::from_millis(300));
assert!(!cadence.should_poll(start + Duration::from_millis(379)));
@@ -222,7 +225,7 @@ mod tests {
let mut cadence = WebSurfacePollCadence::default();
cadence.note_ensure(WebSurfaceInputKind::Idle, true, start);
cadence.note_frame("complete", start + Duration::from_secs(1));
cadence.note_frame("complete", true, start + Duration::from_secs(1));
cadence.note_poll_submitted(start + Duration::from_millis(1_300));
assert_eq!(
@@ -237,7 +240,7 @@ mod tests {
let mut cadence = WebSurfacePollCadence::default();
cadence.note_ensure(WebSurfaceInputKind::Idle, true, start);
cadence.note_frame("loading", start + Duration::from_secs(1));
cadence.note_frame("loading", true, start + Duration::from_secs(1));
cadence.note_poll_submitted(start + Duration::from_millis(1_300));
assert_eq!(
@@ -252,7 +255,7 @@ mod tests {
let mut cadence = WebSurfacePollCadence::default();
cadence.note_ensure(WebSurfaceInputKind::Scroll, false, start);
cadence.note_frame("complete", start + Duration::from_millis(100));
cadence.note_frame("complete", true, start + Duration::from_millis(100));
cadence.note_poll_submitted(start + Duration::from_millis(500));
assert_eq!(
@@ -267,7 +270,7 @@ mod tests {
let mut cadence = WebSurfacePollCadence::default();
cadence.note_ensure(WebSurfaceInputKind::Scroll, false, start);
cadence.note_frame("loading", start + Duration::from_millis(100));
cadence.note_frame("loading", true, start + Duration::from_millis(100));
cadence.note_poll_submitted(start + Duration::from_millis(500));
assert_eq!(
@@ -281,8 +284,8 @@ mod tests {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("complete", start);
cadence.note_frame("complete", start + Duration::from_millis(200));
cadence.note_frame("complete", false, start);
cadence.note_frame("complete", false, start + Duration::from_millis(200));
cadence.note_poll_submitted(start + Duration::from_millis(260));
assert!(!cadence.should_poll(start + Duration::from_millis(339)));
@@ -294,8 +297,8 @@ mod tests {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("loading", start);
cadence.note_frame("loading", start + Duration::from_millis(200));
cadence.note_frame("loading", false, start);
cadence.note_frame("loading", false, start + Duration::from_millis(200));
cadence.note_poll_submitted(start + Duration::from_millis(5_010));
assert_eq!(
@@ -309,10 +312,25 @@ mod tests {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("sleeping", start);
cadence.note_frame("sleeping", start + Duration::from_millis(200));
cadence.note_frame("sleeping", false, start);
cadence.note_frame("sleeping", false, start + Duration::from_millis(200));
cadence.note_poll_submitted(start + Duration::from_millis(610));
assert_eq!(cadence.next_poll_delay(start + Duration::from_millis(610)), IDLE_POLL_INTERVAL);
}
#[test]
fn repeated_pixel_frames_keep_animation_cadence_active() {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("complete", true, start);
cadence.note_frame("complete", true, start + Duration::from_millis(240));
cadence.note_poll_submitted(start + Duration::from_millis(300));
assert_eq!(
cadence.next_poll_delay(start + Duration::from_millis(300)),
ACTIVE_POLL_INTERVAL
);
}
}
@@ -1,6 +1,6 @@
use ely_browser_core::{BrowserCore, BrowserSnapshot};
use ely_domain::{BrowserTab, ProfileKind, TabId, UrlText};
use gpui::{AnyElement, Bounds, Context, NativeSurfaceHandle, Pixels, Point};
use gpui::{AnyElement, Bounds, Context, Pixels, Point};
use crate::services::ProfileDataMode;
@@ -26,11 +26,11 @@ impl ElyShell {
cx: &mut Context<Self>,
) -> AnyElement {
let state_entity = cx.entity().clone();
if profile_data_mode_for(tab, snapshot).is_none() {
let Some(profile_data_mode) = profile_data_mode_for(tab, snapshot) else {
return render_failed_web_surface(tab, "Profile context is unavailable.", state_entity);
}
};
match self.web_surfaces.state(tab.id()) {
match self.web_surfaces.state_for_scope(tab.id(), tab.profile_id(), profile_data_mode) {
Some(WebSurfaceState::Ready(frame)) => {
render_ready_web_surface(frame, tab, state_entity, bottom_corner_radius)
}
@@ -102,25 +102,6 @@ impl ElyShell {
}
}
pub(super) fn record_external_web_surface(
&mut self,
tab_id: TabId,
bounds: Bounds<Pixels>,
scale_factor: f32,
native_surface: NativeSurfaceHandle,
cx: &mut Context<Self>,
) {
let viewport_changed =
self.web_surfaces.record_viewport_size(&tab_id, bounds, scale_factor)
== WebSurfaceInputOutcome::Applied;
let surface_changed = self.web_surfaces.record_native_surface(&tab_id, native_surface)
== WebSurfaceInputOutcome::Applied;
if viewport_changed || surface_changed {
self.flush_external_web_surface_tick(cx);
}
}
pub(super) fn scroll_external_web_viewport(
&mut self,
tab_id: TabId,
+127 -22
View File
@@ -3,20 +3,31 @@ use std::hash::Hasher;
use std::sync::Arc;
use ahash::AHasher;
#[cfg(target_os = "macos")]
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
use gpui::RenderImage;
use image::{ImageBuffer, Rgba};
use thiserror::Error;
#[cfg(target_os = "macos")]
use crate::services::iosurface_metal::HardwareSurfaceBacking;
use crate::services::servo_live::ServoLiveFrame;
thread_local! {
/// Single-slot cache for byte-identical software frames.
/// AHash keeps the 1080p hash pass below the frame budget while
/// avoiding repeated GPUI texture allocation for idle pages.
static LAST_FRAME_IMAGE: RefCell<Option<(u64, Arc<RenderImage>)>> =
static LAST_FRAME_IMAGE: RefCell<Option<CachedRenderImage>> =
const { RefCell::new(None) };
}
struct CachedRenderImage {
width: u32,
height: u32,
bytes_hash: u64,
image: Arc<RenderImage>,
}
#[cfg(all(test, feature = "live-site-smoke"))]
use super::web_surface_geometry::WebSurfaceSize;
use super::web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset};
@@ -37,6 +48,7 @@ pub(super) struct WebSurfaceFrame {
zoom_percent: u16,
click_point: Option<WebSurfaceClickPoint>,
typed_text: Option<String>,
pixels_changed: bool,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -44,6 +56,10 @@ pub(super) struct WebSurfaceFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: u64,
pub(super) image: Option<Arc<RenderImage>>,
#[cfg(target_os = "macos")]
pub(super) hardware_surface: Option<Arc<HardwareSurfaceBacking>>,
#[cfg(target_os = "macos")]
hardware_surface_id: Option<u64>,
}
impl WebSurfaceFrame {
@@ -53,6 +69,10 @@ impl WebSurfaceFrame {
zoom_percent: u16,
frame: ServoLiveFrame,
) -> Result<Self, WebSurfaceError> {
#[cfg(target_os = "macos")]
let hardware_surface = frame.hardware_surface().cloned();
#[cfg(target_os = "macos")]
let hardware_surface_id = frame.hardware_surface_id();
Self::from_parts(WebSurfaceFrameParts {
requested_url,
loaded_url: frame.loaded_url().map(str::to_string),
@@ -67,6 +87,7 @@ impl WebSurfaceFrame {
zoom_percent,
click_point: None,
typed_text: None,
pixels_changed: frame.pixels_changed(),
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: frame.non_white_pixel_count(),
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -74,10 +95,26 @@ impl WebSurfaceFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: frame.sample_hash(),
rgba_bytes: frame.into_rgba_bytes(),
#[cfg(target_os = "macos")]
hardware_surface,
#[cfg(target_os = "macos")]
hardware_surface_id,
})
}
fn from_parts(parts: WebSurfaceFrameParts) -> Result<Self, WebSurfaceError> {
#[cfg(target_os = "macos")]
if let Some(surface) = parts.hardware_surface.as_ref() {
validate_hardware_pixel_buffer(surface.pixel_buffer(), parts.width, parts.height)?;
}
#[cfg(target_os = "macos")]
let has_hardware_surface = parts.hardware_surface.is_some();
#[cfg(not(target_os = "macos"))]
let has_hardware_surface = false;
if parts.rgba_bytes.is_none() && !has_hardware_surface {
return Err(WebSurfaceError::MissingRenderablePayload);
}
#[cfg(all(test, feature = "live-site-smoke"))]
let pixel_sample = pixel_sample_for_parts(&parts)?;
@@ -108,6 +145,7 @@ impl WebSurfaceFrame {
zoom_percent: parts.zoom_percent,
click_point: parts.click_point,
typed_text: parts.typed_text,
pixels_changed: parts.pixels_changed,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: pixel_sample.non_white_pixel_count,
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -115,6 +153,10 @@ impl WebSurfaceFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: pixel_sample.sample_hash,
image,
#[cfg(target_os = "macos")]
hardware_surface: parts.hardware_surface,
#[cfg(target_os = "macos")]
hardware_surface_id: parts.hardware_surface_id,
})
}
@@ -158,7 +200,6 @@ impl WebSurfaceFrame {
(self.css_viewport_width, self.css_viewport_height)
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn render_state(&self) -> &str {
self.render_state.as_str()
}
@@ -180,15 +221,26 @@ impl WebSurfaceFrame {
self.title.as_deref()
}
pub(super) fn has_same_software_render_as(&self, other: &Self) -> bool {
pub(super) fn has_same_render_as(&self, other: &Self) -> bool {
#[cfg(target_os = "macos")]
if self.hardware_surface.is_some() || other.hardware_surface.is_some() {
return self.hardware_surface.is_some()
&& other.hardware_surface.is_some()
&& self.hardware_surface_id == other.hardware_surface_id
&& !other.pixels_changed
&& self.metadata_matches(other);
}
let image_matches = match (self.image.as_ref(), other.image.as_ref()) {
(Some(image), Some(other_image)) => Arc::ptr_eq(image, other_image),
(None, None) => true,
_ => false,
};
image_matches
&& self.requested_url == other.requested_url
image_matches && self.metadata_matches(other)
}
fn metadata_matches(&self, other: &Self) -> bool {
self.requested_url == other.requested_url
&& self.loaded_url == other.loaded_url
&& self.title == other.title
&& self.render_state == other.render_state
@@ -203,17 +255,6 @@ impl WebSurfaceFrame {
&& self.typed_text == other.typed_text
}
pub(super) fn has_visible_content_for_initial_display(&self) -> Result<bool, WebSurfaceError> {
#[cfg(all(test, feature = "live-site-smoke"))]
{
Ok(self.non_white_pixel_count > 0 && self.content_pixel_count > 0)
}
#[cfg(not(all(test, feature = "live-site-smoke")))]
{
Ok(true)
}
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn non_white_pixel_count(&self) -> u64 {
self.non_white_pixel_count
@@ -229,9 +270,21 @@ impl WebSurfaceFrame {
self.sample_hash
}
#[cfg(all(test, feature = "live-site-smoke"))]
#[cfg(test)]
pub(super) fn has_hardware_surface(&self) -> bool {
false
#[cfg(target_os = "macos")]
{
self.hardware_surface.is_some()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
pub(super) fn hardware_surface_id_for_test(&self) -> Option<u64> {
self.hardware_surface_id
}
}
@@ -249,6 +302,7 @@ struct WebSurfaceFrameParts {
zoom_percent: u16,
click_point: Option<WebSurfaceClickPoint>,
typed_text: Option<String>,
pixels_changed: bool,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -256,6 +310,10 @@ struct WebSurfaceFrameParts {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: u64,
rgba_bytes: Option<Vec<u8>>,
#[cfg(target_os = "macos")]
hardware_surface: Option<Arc<HardwareSurfaceBacking>>,
#[cfg(target_os = "macos")]
hardware_surface_id: Option<u64>,
}
#[derive(Debug, Error)]
@@ -264,6 +322,42 @@ pub(super) enum WebSurfaceError {
InvalidFrameBuffer { width: u32, height: u32 },
#[error("servo live frame did not include renderable pixels")]
MissingRenderablePayload,
#[cfg(target_os = "macos")]
#[error(
"servo hardware surface size {actual_width}x{actual_height} did not match frame report {expected_width}x{expected_height}"
)]
HardwareSurfaceSizeMismatch {
expected_width: u32,
expected_height: u32,
actual_width: usize,
actual_height: usize,
},
#[cfg(target_os = "macos")]
#[error("servo hardware surface pixel format 0x{actual:x} is unsupported; expected 32BGRA")]
UnsupportedHardwareSurfaceFormat { actual: u32 },
}
#[cfg(target_os = "macos")]
fn validate_hardware_pixel_buffer(
pixel_buffer: &CVPixelBuffer,
expected_width: u32,
expected_height: u32,
) -> Result<(), WebSurfaceError> {
let actual_width = pixel_buffer.get_width();
let actual_height = pixel_buffer.get_height();
if actual_width != expected_width as usize || actual_height != expected_height as usize {
return Err(WebSurfaceError::HardwareSurfaceSizeMismatch {
expected_width,
expected_height,
actual_width,
actual_height,
});
}
let actual_format = pixel_buffer.get_pixel_format();
if actual_format != kCVPixelFormatType_32BGRA {
return Err(WebSurfaceError::UnsupportedHardwareSurfaceFormat { actual: actual_format });
}
Ok(())
}
/// Swap byte 0 and byte 2 of every 4-byte pixel, converting Servo's
@@ -307,16 +401,27 @@ fn resolve_render_image(
) -> Result<Arc<RenderImage>, WebSurfaceError> {
LAST_FRAME_IMAGE.with(|cache| -> Result<Arc<RenderImage>, WebSurfaceError> {
let mut cache = cache.borrow_mut();
if let Some((cached_hash, cached_image)) = cache.as_ref()
&& *cached_hash == bytes_hash
if let Some(cached) = cache.as_ref()
&& cached.width == width
&& cached.height == height
&& cached.bytes_hash == bytes_hash
&& cached.image.as_bytes(0) == Some(rgba_bytes.as_slice())
{
return Ok(cached_image.clone());
return Ok(cached.image.clone());
}
let image_buffer = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, rgba_bytes)
.ok_or(WebSurfaceError::InvalidFrameBuffer { width, height })?;
let new_image = Arc::new(RenderImage::new([image::Frame::new(image_buffer)]));
*cache = Some((bytes_hash, new_image.clone()));
*cache = Some(CachedRenderImage { width, height, bytes_hash, image: new_image.clone() });
Ok(new_image)
})
}
#[cfg(test)]
#[path = "web_surface_frame_cache_tests.rs"]
mod cache_tests;
#[cfg(all(test, target_os = "macos"))]
#[path = "web_surface_frame_hardware_tests.rs"]
mod hardware_tests;
@@ -0,0 +1,32 @@
use std::sync::Arc;
use super::resolve_render_image;
#[test]
fn cache_key_includes_frame_dimensions() -> Result<(), super::WebSurfaceError> {
let bytes = vec![0, 0, 0, 255, 255, 255, 255, 255];
let first = resolve_render_image(1, 2, bytes.clone(), 41)?;
let reshaped = resolve_render_image(2, 1, bytes, 41)?;
assert!(!Arc::ptr_eq(&first, &reshaped));
Ok(())
}
#[test]
fn cache_verifies_bytes_after_hash_match() -> Result<(), super::WebSurfaceError> {
let first = resolve_render_image(1, 1, vec![1, 2, 3, 255], 99)?;
let collision = resolve_render_image(1, 1, vec![3, 2, 1, 255], 99)?;
assert!(!Arc::ptr_eq(&first, &collision));
Ok(())
}
#[test]
fn identical_frame_reuses_render_image() -> Result<(), super::WebSurfaceError> {
let bytes = vec![1, 2, 3, 255];
let first = resolve_render_image(1, 1, bytes.clone(), 7)?;
let repeated = resolve_render_image(1, 1, bytes, 7)?;
assert!(Arc::ptr_eq(&first, &repeated));
Ok(())
}
@@ -0,0 +1,105 @@
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
use crate::services::servo_live::ServoLiveFrame;
use super::{WebSurfaceError, WebSurfaceFrame};
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
#[test]
fn hardware_frame_keeps_pixel_buffer_without_software_image() -> Result<(), WebSurfaceError> {
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
let live_frame = ServoLiveFrame::for_test_with_pixel_buffer(64, 48, 7, pixel_buffer);
let frame = WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
live_frame,
)?;
assert!(frame.has_hardware_surface());
assert!(frame.hardware_surface.is_some());
assert!(frame.image.is_none());
Ok(())
}
#[test]
fn hardware_frame_identity_participates_in_equality() -> Result<(), WebSurfaceError> {
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
let first = WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_pixel_buffer(64, 48, 7, pixel_buffer.clone()),
)?;
let second = WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_pixel_buffer(64, 48, 8, pixel_buffer),
)?;
assert!(!first.has_same_render_as(&second));
Ok(())
}
#[test]
fn same_hardware_surface_with_new_pixels_triggers_repaint() -> Result<(), WebSurfaceError> {
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
let first = hardware_frame(7, pixel_buffer.clone(), false)?;
let changed = hardware_frame(7, pixel_buffer, true)?;
assert!(!first.has_same_render_as(&changed));
Ok(())
}
#[test]
fn metadata_only_hardware_frame_reuses_same_surface() -> Result<(), WebSurfaceError> {
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
let first = hardware_frame(7, pixel_buffer.clone(), true)?;
let metadata_only = hardware_frame(7, pixel_buffer, false)?;
assert!(first.has_same_render_as(&metadata_only));
Ok(())
}
#[test]
fn hardware_frame_validates_reported_dimensions() -> Result<(), String> {
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
.map_err(|status| format!("test pixel buffer creation failed: {status}"))?;
let live_frame = ServoLiveFrame::for_test_with_pixel_buffer(96, 72, 7, pixel_buffer);
assert!(matches!(
WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
live_frame,
),
Err(WebSurfaceError::HardwareSurfaceSizeMismatch { .. })
));
Ok(())
}
fn hardware_frame(
surface_id: u64,
pixel_buffer: CVPixelBuffer,
pixels_changed: bool,
) -> Result<WebSurfaceFrame, WebSurfaceError> {
WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_hardware_change(
64,
48,
surface_id,
pixel_buffer,
pixels_changed,
),
)
}
@@ -0,0 +1,212 @@
use std::{
env,
error::Error,
ffi::OsString,
path::{Path, PathBuf},
process::{Command, Stdio},
sync::Arc,
thread,
time::{Duration, Instant},
};
use core_video::pixel_buffer::kCVPixelFormatType_32BGRA;
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use gpui::{Bounds, SurfaceLease, point, px, size, submit_surface_to_metal_for_test};
use objc2_io_surface::IOSurfaceRef;
use crate::{
services::ProfileDataMode,
shell::web_surface_frame::WebSurfaceFrame,
shell::web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState},
};
use super::{WebSurfaceStore, web_surface_profile_isolation_tests::ProfileProbeServer};
const CHILD_ENV: &str = "ELY_APP_HARDWARE_IOSURFACE_CHILD";
const TEST_NAME: &str = concat!(
"shell::web_surface::web_surface_hardware_import_tests::",
"web_surface_imports_hardware_iosurface",
);
const PROBE_TITLE: &str = "request=empty|document=hardware|storage=hardware|cache=cache-1";
const PROBE_WIDTH: u32 = 640;
const PROBE_HEIGHT: u32 = 480;
const PROBE_TIMEOUT: Duration = Duration::from_secs(20);
#[test]
fn web_surface_imports_hardware_iosurface() -> Result<(), Box<dyn Error>> {
if env::var_os(CHILD_ENV).is_some() {
return run_hardware_iosurface_probe();
}
let sidecar = build_hardware_sidecar()?;
let output = Command::new(env::current_exe()?)
.arg(TEST_NAME)
.arg("--exact")
.arg("--test-threads=1")
.env(CHILD_ENV, "1")
.env("ELY_SERVO_RENDERING_CONTEXT", "hardware")
.env("ELY_SERVO_SIDECAR", sidecar)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
if output.status.success() && stdout.contains("running 1 test") {
return Ok(());
}
Err(format!(
"isolated hardware IOSurface test failed\nstatus: {}\nstdout: {}\nstderr: {}",
output.status,
stdout,
String::from_utf8_lossy(&output.stderr),
)
.into())
}
fn build_hardware_sidecar() -> Result<PathBuf, Box<dyn Error>> {
let workspace_manifest = PathBuf::from(env!("ELY_WORKSPACE_MANIFEST"));
let workspace_root = workspace_manifest.parent().ok_or("missing workspace root")?;
let cargo = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"));
let output = Command::new(cargo)
.args([
"build",
"--locked",
"-p",
"ely_servo_host",
"--features",
"servo-engine,hardware-render",
"--bin",
"ely_servo_sidecar",
])
.current_dir(workspace_root)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
if !output.status.success() {
return Err(format!(
"hardware sidecar build failed\nstatus: {}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
)
.into());
}
let sidecar = sidecar_binary_path(workspace_root);
if !sidecar.is_file() {
return Err(format!("hardware sidecar is missing at {}", sidecar.display()).into());
}
Ok(sidecar)
}
fn sidecar_binary_path(workspace_root: &Path) -> PathBuf {
let mut target_dir = env::var_os("CARGO_TARGET_DIR").map_or_else(
|| workspace_root.join("target"),
|path| {
let path = PathBuf::from(path);
if path.is_absolute() { path } else { workspace_root.join(path) }
},
);
if let Some(target) = env::var_os("CARGO_BUILD_TARGET") {
target_dir.push(target);
}
target_dir.join("debug").join(format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX))
}
fn run_hardware_iosurface_probe() -> Result<(), Box<dyn Error>> {
let mut server = ProfileProbeServer::start()?;
let mut store = WebSurfaceStore::new();
let url = format!("{}/probe?value=hardware", server.origin());
let tab = BrowserTab::new(
TabId::new(),
SpaceId::new(),
ProfileId::new(),
"Hardware IOSurface probe",
UrlText::parse(&url)?,
);
assert_eq!(
store.record_viewport_size(tab.id(), probe_bounds(), 1.0),
WebSurfaceInputOutcome::Applied,
);
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
let frame = wait_for_hardware_surface(&mut store, &tab)?;
store.close_surface(tab.id());
store.flush_runtime_for_test();
assert_hardware_lease_lifecycle(frame)?;
drop(store);
server.finish()
}
fn wait_for_hardware_surface(
store: &mut WebSurfaceStore,
tab: &BrowserTab,
) -> Result<WebSurfaceFrame, Box<dyn Error>> {
let started_at = Instant::now();
let mut last_frame = None;
loop {
store.tick(std::slice::from_ref(tab.id()));
match store.state(tab.id()) {
Some(WebSurfaceState::Ready(frame)) => {
last_frame = Some(format!(
"title={:?}, state={}, hardware={}",
frame.title(),
frame.render_state(),
frame.has_hardware_surface(),
));
if frame.title() == Some(PROBE_TITLE) && frame.has_hardware_surface() {
return Ok(frame.clone());
}
}
Some(WebSurfaceState::Failed { message }) => {
return Err(format!("hardware IOSurface probe failed: {message}").into());
}
Some(WebSurfaceState::Loading { .. }) | None => {}
}
if started_at.elapsed() >= PROBE_TIMEOUT {
return Err(format!(
"timed out waiting for imported hardware IOSurface; last frame: {last_frame:?}",
)
.into());
}
thread::sleep(Duration::from_millis(2));
}
}
fn assert_hardware_lease_lifecycle(frame: WebSurfaceFrame) -> Result<(), Box<dyn Error>> {
let hardware_surface = frame.hardware_surface.clone().ok_or("hardware backing is missing")?;
let pixel_buffer = hardware_surface.pixel_buffer();
assert_eq!(pixel_buffer.get_pixel_format(), kCVPixelFormatType_32BGRA);
let surface_id = frame
.hardware_surface_id_for_test()
.and_then(|surface_id| u32::try_from(surface_id).ok())
.ok_or("hardware IOSurface ID is invalid")?;
let iosurface = IOSurfaceRef::lookup(surface_id).ok_or("hardware IOSurface lookup failed")?;
let active_use_count = iosurface.use_count();
let released_use_count = active_use_count
.checked_sub(1)
.ok_or("hardware IOSurface use count was not incremented")?;
assert_eq!(Arc::strong_count(&hardware_surface), 2);
let weak_backing = Arc::downgrade(&hardware_surface);
let submission = submit_surface_to_metal_for_test(
pixel_buffer.clone(),
SurfaceLease::from_arc(hardware_surface.clone()),
)?;
drop(frame);
drop(hardware_surface);
assert_eq!(weak_backing.strong_count(), 1);
assert_eq!(iosurface.use_count(), active_use_count);
submission.finish()?;
assert_eq!(weak_backing.strong_count(), 0);
assert_eq!(iosurface.use_count(), released_use_count);
Ok(())
}
fn probe_bounds() -> Bounds<gpui::Pixels> {
Bounds::new(point(px(0.0), px(0.0)), size(px(PROBE_WIDTH as f32), px(PROBE_HEIGHT as f32)))
}
+1 -19
View File
@@ -1,7 +1,7 @@
use std::time::Instant;
use ely_domain::TabId;
use gpui::{Bounds, NativeSurfaceHandle, Pixels, Point};
use gpui::{Bounds, Pixels, Point};
use super::{
web_surface::WebSurfaceStore,
@@ -104,24 +104,6 @@ impl WebSurfaceStore {
}
}
pub(super) fn record_native_surface(
&mut self,
tab_id: &TabId,
native_surface: NativeSurfaceHandle,
) -> WebSurfaceInputOutcome {
let surface = self.surface_mut(tab_id);
if surface
.native_surface
.as_ref()
.is_some_and(|current| current.identity() == native_surface.identity())
{
return WebSurfaceInputOutcome::NoChange;
}
surface.native_surface = Some(native_surface);
surface.last_ensure_key = None;
WebSurfaceInputOutcome::Applied
}
pub(super) fn record_hover_point(
&mut self,
tab_id: &TabId,
@@ -92,6 +92,7 @@ fn run_isolated_live_site_test(
let output = Command::new(env::current_exe()?)
.arg(test_name)
.env(LIVE_SITE_CHILD_ENV, "1")
.env("ELY_SERVO_RENDERING_CONTEXT", "software")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
@@ -124,6 +124,7 @@ mod tests {
profile_name: "Default".to_string(),
profile_color_hex: 0x26251e,
profile_kind: ely_domain::ProfileKind::Standard,
profile_id: None,
new_tab_destination: Default::default(),
})?;
core.open_tab(UrlText::parse(url)?);
@@ -0,0 +1,357 @@
use std::{
error::Error,
io::{self, Read, Write},
net::{TcpListener, TcpStream},
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use gpui::{Bounds, point, px, size};
use crate::{
services::ProfileDataMode,
shell::web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState},
};
use super::WebSurfaceStore;
const PROBE_WIDTH: u32 = 640;
const PROBE_HEIGHT: u32 = 480;
const PROBE_TIMEOUT: Duration = Duration::from_secs(20);
#[test]
fn web_surface_keeps_profile_site_data_isolated() -> Result<(), Box<dyn Error>> {
let mut server = ProfileProbeServer::start()?;
let mut store = WebSurfaceStore::new();
let profile_a = ProfileId::new();
let profile_b = ProfileId::new();
let tab_a_seed = render_probe(
&mut store,
&profile_a,
&format!("{}/probe?value=alpha", server.origin()),
"request=empty|document=alpha|storage=alpha|cache=cache-1",
)?;
let tab_b_seed = render_probe(
&mut store,
&profile_b,
&format!("{}/probe?value=beta", server.origin()),
"request=empty|document=beta|storage=beta|cache=cache-2",
)?;
let tab_a_inspect = render_probe(
&mut store,
&profile_a,
&format!("{}/probe?inspect=alpha", server.origin()),
"request=alpha|document=alpha|storage=alpha|cache=cache-1",
)?;
let tab_b_inspect = render_probe(
&mut store,
&profile_b,
&format!("{}/probe?inspect=beta", server.origin()),
"request=beta|document=beta|storage=beta|cache=cache-2",
)?;
assert_eq!(server.cache_request_count(), 2);
store.close_surface(&tab_a_seed);
store.close_surface(&tab_a_inspect);
store.flush_runtime_for_test();
let tab_a_reopened = render_probe(
&mut store,
&profile_a,
&format!("{}/probe?value=gamma", server.origin()),
"request=empty|document=gamma|storage=gamma|cache=cache-3",
)?;
assert_eq!(server.cache_request_count(), 3);
for tab_id in [tab_b_seed, tab_b_inspect, tab_a_reopened] {
store.close_surface(&tab_id);
}
store.flush_runtime_for_test();
drop(store);
server.finish()?;
Ok(())
}
fn render_probe(
store: &mut WebSurfaceStore,
profile_id: &ProfileId,
url: &str,
expected_title: &str,
) -> Result<TabId, Box<dyn Error>> {
let tab = BrowserTab::new(
TabId::new(),
SpaceId::new(),
profile_id.clone(),
"Profile probe",
UrlText::parse(url)?,
);
assert_eq!(
store.record_viewport_size(tab.id(), probe_bounds(), 1.0),
WebSurfaceInputOutcome::Applied,
);
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
let started_at = Instant::now();
let mut last_title = None;
loop {
store.tick(std::slice::from_ref(tab.id()));
match store.state(tab.id()) {
Some(WebSurfaceState::Ready(frame)) => {
last_title = frame.title().map(str::to_string);
if frame.title() == Some(expected_title) {
return Ok(tab.id().clone());
}
}
Some(WebSurfaceState::Failed { message }) => {
return Err(format!("profile probe failed for {url}: {message}").into());
}
Some(WebSurfaceState::Loading { .. }) | None => {}
}
if started_at.elapsed() >= PROBE_TIMEOUT {
return Err(format!(
"timed out waiting for profile probe title `{expected_title}` at {url}; last title: {last_title:?}",
)
.into());
}
thread::sleep(Duration::from_millis(2));
}
}
fn probe_bounds() -> Bounds<gpui::Pixels> {
Bounds::new(point(px(0.0), px(0.0)), size(px(PROBE_WIDTH as f32), px(PROBE_HEIGHT as f32)))
}
pub(super) struct ProfileProbeServer {
origin: String,
cache_requests: Arc<AtomicUsize>,
shutdown: Arc<AtomicBool>,
error: Arc<Mutex<Option<String>>>,
thread: Option<JoinHandle<()>>,
}
impl ProfileProbeServer {
pub(super) fn start() -> Result<Self, Box<dyn Error>> {
let listener = TcpListener::bind("127.0.0.1:0")?;
listener.set_nonblocking(true)?;
let address = listener.local_addr()?;
let cache_requests = Arc::new(AtomicUsize::new(0));
let shutdown = Arc::new(AtomicBool::new(false));
let error = Arc::new(Mutex::new(None));
let thread_cache_requests = cache_requests.clone();
let thread_shutdown = shutdown.clone();
let thread_error = error.clone();
let thread = thread::Builder::new().name("ely-profile-probe-server".to_string()).spawn(
move || {
serve_profile_probes(
listener,
thread_cache_requests,
thread_shutdown,
thread_error,
);
},
)?;
Ok(Self {
origin: format!("http://{address}"),
cache_requests,
shutdown,
error,
thread: Some(thread),
})
}
pub(super) fn origin(&self) -> &str {
self.origin.as_str()
}
fn cache_request_count(&self) -> usize {
self.cache_requests.load(Ordering::SeqCst)
}
pub(super) fn finish(&mut self) -> Result<(), Box<dyn Error>> {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(thread) = self.thread.take()
&& thread.join().is_err()
{
return Err("profile probe server thread panicked".into());
}
let error = self.error.lock().map_err(|_| "profile probe error lock was poisoned")?.take();
match error {
Some(message) => Err(message.into()),
None => Ok(()),
}
}
}
impl Drop for ProfileProbeServer {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn serve_profile_probes(
listener: TcpListener,
cache_requests: Arc<AtomicUsize>,
shutdown: Arc<AtomicBool>,
error: Arc<Mutex<Option<String>>>,
) {
while !shutdown.load(Ordering::SeqCst) {
match listener.accept() {
Ok((stream, _)) => {
let connection_cache_requests = cache_requests.clone();
let connection_error = error.clone();
_ = thread::spawn(move || {
if let Err(server_error) = serve_connection(stream, &connection_cache_requests)
{
record_server_error(&connection_error, server_error.to_string());
}
});
}
Err(server_error) if server_error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(2));
}
Err(server_error) => {
record_server_error(&error, server_error.to_string());
return;
}
}
}
}
fn record_server_error(error: &Arc<Mutex<Option<String>>>, message: String) {
if let Ok(mut slot) = error.lock() {
*slot = Some(message);
}
}
fn serve_connection(mut stream: TcpStream, cache_requests: &AtomicUsize) -> Result<(), io::Error> {
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
let request = read_http_request(&mut stream)?;
let request_text = String::from_utf8_lossy(&request);
let path = request_text
.lines()
.next()
.and_then(|line| line.split_ascii_whitespace().nth(1))
.unwrap_or("/");
if path.starts_with("/cache-token") {
let request_number = cache_requests.fetch_add(1, Ordering::SeqCst) + 1;
return write_http_response(
&mut stream,
"200 OK",
"text/plain; charset=utf-8",
"public, max-age=3600, immutable",
None,
format!("cache-{request_number}").as_bytes(),
);
}
if path.starts_with("/probe") {
let request_cookie = cookie_value(&request_text, "ely_profile").unwrap_or("empty");
let seed = query_value(path, "value");
let set_cookie =
seed.map(|value| format!("ely_profile={value}; Path=/; Max-Age=3600; SameSite=Lax"));
let body = profile_probe_html(request_cookie);
return write_http_response(
&mut stream,
"200 OK",
"text/html; charset=utf-8",
"no-store",
set_cookie.as_deref(),
body.as_bytes(),
);
}
write_http_response(
&mut stream,
"404 Not Found",
"text/plain; charset=utf-8",
"no-store",
None,
b"missing",
)
}
fn read_http_request(stream: &mut TcpStream) -> Result<Vec<u8>, io::Error> {
let mut request = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let read = stream.read(&mut buffer)?;
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
if request.len() > 64 * 1024 {
return Err(io::Error::other("profile probe request exceeded 64 KiB"));
}
}
Ok(request)
}
fn write_http_response(
stream: &mut TcpStream,
status: &str,
content_type: &str,
cache_control: &str,
set_cookie: Option<&str>,
body: &[u8],
) -> Result<(), io::Error> {
let cookie_header =
set_cookie.map(|cookie| format!("Set-Cookie: {cookie}\r\n")).unwrap_or_default();
let headers = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nCache-Control: {cache_control}\r\n{cookie_header}Content-Length: {}\r\nConnection: close\r\n\r\n",
body.len(),
);
stream.write_all(headers.as_bytes())?;
stream.write_all(body)?;
stream.flush()?;
Ok(())
}
fn cookie_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
request.lines().find_map(|line| {
let (header, value) = line.split_once(':')?;
if !header.eq_ignore_ascii_case("cookie") {
return None;
}
value.split(';').find_map(|pair| {
let (cookie_name, cookie_value) = pair.trim().split_once('=')?;
(cookie_name == name).then_some(cookie_value)
})
})
}
fn query_value<'a>(path: &'a str, name: &str) -> Option<&'a str> {
let (_, query) = path.split_once('?')?;
query.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == name).then_some(value)
})
}
fn profile_probe_html(request_cookie: &str) -> String {
format!(
"<!doctype html><title>loading</title><pre id=state>loading</pre><script>\
const seed = new URLSearchParams(location.search).get('value');\
if (seed) localStorage.setItem('ely_profile', seed);\
const cookie = () => (document.cookie.match(/(?:^|; )ely_profile=([^;]*)/) || [,'empty'])[1];\
fetch('/cache-token', {{cache:'force-cache'}}).then(response => response.text()).then(cache => {{\
const title = 'request={request_cookie}|document=' + cookie() + '|storage=' + (localStorage.getItem('ely_profile') || 'empty') + '|cache=' + cache;\
document.title = title; document.getElementById('state').textContent = title;\
}});</script>",
)
}
+234 -185
View File
@@ -1,16 +1,16 @@
use std::{
collections::BTreeMap,
fs,
path::PathBuf,
thread::JoinHandle,
time::{Duration, Instant},
};
use ely_domain::{BrowserTab, TabId};
use gpui::NativeSurfaceHandle;
use crate::services::{
ProfileDataMode,
servo_live::{ServoLiveClient, ServoLiveEnsureRequest, ServoLiveSitePermission},
servo_profile_data::cleanup_stale_transient_profile_data_dirs,
};
use super::{
@@ -25,44 +25,54 @@ use super::{
scroll_wire_fields,
},
web_surface_state::WebSurfacePendingInput,
web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, WorkerResponse},
web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, RequestGeneration, WorkerResponse},
};
use cleanup::{ScopedWorker, shutdown_scoped_worker};
pub(super) use super::web_surface_runtime_session::{
WebSurfaceEnsureResult, WebSurfaceRuntimeFrame, WebSurfaceUrlChange, WebSurfaceUrlChangeKind,
};
pub(super) struct WebSurfaceRuntime {
worker: Option<ScopedWorker>,
direct_client: Option<ScopedDirectClient>,
pending_direct_responses: Vec<WorkerResponse>,
workers: BTreeMap<WebSurfaceRuntimeScope, ScopedWorker>,
sessions: BTreeMap<TabId, WebSurfaceSession>,
retry_state: BTreeMap<WebSurfaceRuntimeScope, ScopeRetryState>,
retired_workers: Vec<JoinHandle<Result<(), String>>>,
transient_cleanup_error: Option<String>,
client_factory: LiveRuntimeClientFactory,
last_generation: u64,
}
const SIDECAR_RESTART_BASE_DELAY: Duration = Duration::from_millis(250);
const SIDECAR_RESTART_MAX_DELAY: Duration = Duration::from_secs(5);
impl WebSurfaceRuntime {
pub(super) fn new() -> Self {
let transient_cleanup_error =
cleanup_stale_transient_profile_data_dirs().err().map(|error| error.to_string());
Self {
worker: None,
direct_client: None,
pending_direct_responses: Vec::new(),
workers: BTreeMap::new(),
sessions: BTreeMap::new(),
retry_state: BTreeMap::new(),
retired_workers: Vec::new(),
transient_cleanup_error,
client_factory: new_servo_live_client,
last_generation: 0,
}
}
#[cfg(test)]
pub(super) fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self {
Self {
worker: None,
direct_client: None,
pending_direct_responses: Vec::new(),
workers: BTreeMap::new(),
sessions: BTreeMap::new(),
retry_state: BTreeMap::new(),
retired_workers: Vec::new(),
transient_cleanup_error: None,
client_factory,
last_generation: 0,
}
}
#[cfg(test)]
pub(super) fn ensure_tab(
&mut self,
tab: &BrowserTab,
@@ -70,48 +80,11 @@ impl WebSurfaceRuntime {
profile_data_mode: ProfileDataMode,
permissions: &[WebSurfaceSitePermission],
input: WebSurfacePendingInput,
) -> Result<WebSurfaceEnsureResult, String> {
self.ensure_tab_inner(tab, size, None, profile_data_mode, permissions, input)
}
pub(super) fn ensure_tab_with_native_surface(
&mut self,
tab: &BrowserTab,
size: WebSurfaceSize,
native_surface: NativeSurfaceHandle,
profile_data_mode: ProfileDataMode,
permissions: &[WebSurfaceSitePermission],
input: WebSurfacePendingInput,
) -> Result<WebSurfaceEnsureResult, String> {
self.ensure_tab_inner(
tab,
size,
Some(native_surface),
profile_data_mode,
permissions,
input,
)
}
fn ensure_tab_inner(
&mut self,
tab: &BrowserTab,
size: WebSurfaceSize,
native_surface: Option<NativeSurfaceHandle>,
profile_data_mode: ProfileDataMode,
permissions: &[WebSurfaceSitePermission],
input: WebSurfacePendingInput,
) -> Result<WebSurfaceEnsureResult, String> {
let scope = WebSurfaceRuntimeScope::new(tab.profile_id().clone(), profile_data_mode);
let use_direct_client = native_surface.is_some();
if use_direct_client {
self.ensure_direct_client(scope.clone())?;
} else {
self.ensure_worker(scope.clone())?;
}
self.prepare_tab_scope(tab.id(), tab.profile_id(), profile_data_mode)?;
let requested_url = tab.url().as_str().to_string();
let tab_id_string = tab.id().as_str().to_string();
let zoom_percent = tab.zoom_percent();
let enqueued_at = input.enqueued_at;
let input_kind = pending_input_kind(&input);
@@ -119,6 +92,7 @@ impl WebSurfaceRuntime {
scroll_wire_fields(input.scroll_delta, input.scroll_point)?;
let user_navigation_input = input_requests_history_navigation(&input);
let next_scroll_offset = input.scroll_offset;
let generation = self.next_request_generation();
let submitted_at = Instant::now();
let started_loading = {
@@ -134,6 +108,7 @@ impl WebSurfaceRuntime {
session.size = size;
session.zoom_percent = zoom_percent;
session.scroll_offset = next_scroll_offset;
session.generation = Some(generation);
session.cadence.note_ensure(input_kind, started_loading, submitted_at);
started_loading
};
@@ -146,7 +121,6 @@ impl WebSurfaceRuntime {
height: size.height,
page_zoom_percent: zoom_percent,
device_pixel_ratio: size.device_pixel_ratio_f32(),
native_surface,
scroll_delta_x,
scroll_delta_y,
scroll_point_x,
@@ -159,15 +133,10 @@ impl WebSurfaceRuntime {
site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(),
};
if use_direct_client {
let response = self.ensure_direct(request, tab_id_string.clone())?;
self.pending_direct_responses.extend(response);
} else {
let Some(scoped) = self.worker.as_ref() else {
return Err("Servo worker was created but is no longer registered".to_string());
};
scoped.worker.submit_ensure(request);
}
let Some(scoped) = self.workers.get(&scope) else {
return Err("Servo sidecar worker was created but is no longer registered".to_string());
};
scoped.worker.submit_ensure(generation, request);
if let Some(session) = self.sessions.get_mut(tab.id()) {
session.cadence.note_poll_submitted(submitted_at);
}
@@ -177,42 +146,45 @@ impl WebSurfaceRuntime {
}
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec<WebSurfaceRuntimeFrame> {
self.reap_retired_workers(false);
let mut frames = Vec::new();
let now = Instant::now();
let mut responses = std::mem::take(&mut self.pending_direct_responses);
responses.extend(
self.worker.as_ref().map(|scoped| scoped.worker.drain_responses()).unwrap_or_default(),
);
let runtime_unavailable = self.collect_responses(responses, now, &mut frames);
if runtime_unavailable {
self.remove_worker();
self.remove_direct_client();
let scopes = self.workers.keys().cloned().collect::<Vec<_>>();
let mut unavailable_scopes = Vec::new();
for scope in scopes {
let responses = self
.workers
.get(&scope)
.map(|scoped| scoped.worker.drain_responses())
.unwrap_or_default();
if self.collect_responses(&scope, responses, now, &mut frames) {
unavailable_scopes.push(scope);
}
}
for scope in unavailable_scopes {
self.invalidate_scope(&scope, now);
}
let poll_now = Instant::now();
let mut direct_polls = Vec::new();
for tab_id in visible_tab_ids {
let Some(session) = self.sessions.get_mut(tab_id) else {
let Some(scope) = self.sessions.get(tab_id).and_then(|session| {
session.cadence.should_poll(poll_now).then(|| session.scope.clone())
}) else {
continue;
};
if !session.cadence.should_poll(poll_now) {
if !self.workers.contains_key(&scope) {
continue;
}
if let Some(scoped) = self.worker.as_ref() {
let _ = scoped.worker.submit_poll(tab_id.as_str().to_string());
let generation = self.next_request_generation();
let submitted = self.workers.get(&scope).is_some_and(|scoped| {
scoped.worker.submit_poll(generation, tab_id.as_str().to_string())
});
if let Some(session) = self.sessions.get_mut(tab_id) {
if submitted {
session.generation = Some(generation);
}
session.cadence.note_poll_submitted(poll_now);
} else if self.direct_client.is_some() {
direct_polls.push(tab_id.as_str().to_string());
session.cadence.note_poll_submitted(poll_now);
}
}
if !direct_polls.is_empty() {
let (responses, runtime_unavailable) = self.poll_direct(direct_polls);
let unavailable_from_responses =
self.collect_responses(responses, Instant::now(), &mut frames);
if runtime_unavailable || unavailable_from_responses {
self.remove_direct_client();
}
}
@@ -227,95 +199,104 @@ impl WebSurfaceRuntime {
visible_tab_ids
.iter()
.filter_map(|tab_id| self.sessions.get(tab_id))
.filter(|_| self.worker.is_some() || self.direct_client.is_some())
.filter(|session| self.workers.contains_key(&session.scope))
.map(|session| session.cadence.next_poll_delay(now))
.min()
}
pub(super) fn close_tab(&mut self, tab_id: &TabId) {
if self.sessions.remove(tab_id).is_none() {
let Some(session) = self.sessions.remove(tab_id) else {
return;
}
let direct_result = self
.direct_client
.as_mut()
.map(|scoped| scoped.client.close(tab_id.as_str().to_string()));
if direct_result.as_ref().is_some_and(|result| {
result.as_ref().is_err_and(|error| error.is_runtime_unavailable())
}) {
self.remove_direct_client();
}
if let Some(scoped) = self.worker.as_ref() {
};
let has_remaining_session =
self.sessions.values().any(|candidate| candidate.scope == session.scope);
if session.scope.is_transient() && !has_remaining_session {
self.remove_worker(&session.scope);
} else if let Some(scoped) = self.workers.get(&session.scope) {
scoped.worker.submit_close(tab_id.as_str().to_string());
}
}
pub(super) fn has_session(
&self,
tab_id: &TabId,
profile_id: &ely_domain::ProfileId,
profile_data_mode: ProfileDataMode,
) -> bool {
self.sessions.get(tab_id).is_some_and(|session| {
session.scope == WebSurfaceRuntimeScope::new(profile_id.clone(), profile_data_mode)
&& self.workers.contains_key(&session.scope)
})
}
pub(super) fn prepare_tab_scope(
&mut self,
tab_id: &TabId,
profile_id: &ely_domain::ProfileId,
profile_data_mode: ProfileDataMode,
) -> Result<(), String> {
let scope = WebSurfaceRuntimeScope::new(profile_id.clone(), profile_data_mode);
self.detach_tab_from_previous_scope(tab_id, &scope);
self.ensure_worker(scope.clone())?;
session_for_scope(&mut self.sessions, tab_id, scope);
Ok(())
}
fn ensure_worker(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
if self.worker.is_some() {
self.reap_retired_workers(false);
if self.workers.contains_key(&scope) {
return Ok(());
}
if scope.is_transient() && self.transient_cleanup_error.is_some() {
match cleanup_stale_transient_profile_data_dirs() {
Ok(()) => self.transient_cleanup_error = None,
Err(error) => {
let message = error.to_string();
self.transient_cleanup_error = Some(message.clone());
return Err(message);
}
}
}
let now = Instant::now();
if let Some(retry) = self.retry_state.get(&scope)
&& now < retry.retry_after
{
return Err("Servo sidecar restart is cooling down".to_string());
}
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
let client_factory = self.client_factory;
let worker = LiveRuntimeWorker::new(move || client_factory(config_dir))?;
self.worker = Some(ScopedWorker { worker, transient_profile_data_dir });
Ok(())
}
fn ensure_direct_client(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
if self.direct_client.is_some() {
return Ok(());
}
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
let client = (self.client_factory)(config_dir)?;
self.direct_client = Some(ScopedDirectClient { client, transient_profile_data_dir });
Ok(())
}
fn ensure_direct(
&mut self,
request: ServoLiveEnsureRequest,
tab_id: String,
) -> Result<Option<WorkerResponse>, String> {
let Some(scoped) = self.direct_client.as_mut() else {
return Err("Servo client was created but is no longer registered".to_string());
};
match scoped.client.ensure(request) {
Ok(Some(frame)) => Ok(Some(WorkerResponse::Frame { tab_id, frame })),
Ok(None) => Ok(None),
let worker = match LiveRuntimeWorker::new(move || client_factory(config_dir)) {
Ok(worker) => worker,
Err(error) => {
let message = error.to_string();
if error.is_runtime_unavailable() {
self.remove_direct_client();
}
Err(message)
self.note_scope_failure(&scope, now);
return Err(error);
}
}
};
self.workers.insert(scope, ScopedWorker { worker, transient_profile_data_dir });
Ok(())
}
fn poll_direct(&mut self, tab_ids: Vec<String>) -> (Vec<WorkerResponse>, bool) {
let Some(scoped) = self.direct_client.as_mut() else {
return (Vec::new(), false);
fn detach_tab_from_previous_scope(&mut self, tab_id: &TabId, scope: &WebSurfaceRuntimeScope) {
let Some(previous_scope) = self.sessions.get(tab_id).map(|session| session.scope.clone())
else {
return;
};
let mut responses = Vec::new();
let mut runtime_unavailable = false;
for tab_id in tab_ids {
match scoped.client.poll(tab_id.clone()) {
Ok(Some(frame)) => responses.push(WorkerResponse::Frame { tab_id, frame }),
Ok(None) => {}
Err(error) if error.is_runtime_unavailable() => {
runtime_unavailable = true;
responses.push(WorkerResponse::RuntimeUnavailable);
}
Err(error) => {
responses.push(WorkerResponse::Failed { tab_id, message: error.to_string() })
}
}
if &previous_scope == scope {
return;
}
self.sessions.remove(tab_id);
let has_remaining_session =
self.sessions.values().any(|candidate| candidate.scope == previous_scope);
if previous_scope.is_transient() && !has_remaining_session {
self.remove_worker(&previous_scope);
} else if let Some(scoped) = self.workers.get(&previous_scope) {
scoped.worker.submit_close(tab_id.as_str().to_string());
}
(responses, runtime_unavailable)
}
fn collect_responses(
&mut self,
scope: &WebSurfaceRuntimeScope,
responses: Vec<WorkerResponse>,
now: Instant,
frames: &mut Vec<WebSurfaceRuntimeFrame>,
@@ -323,18 +304,23 @@ impl WebSurfaceRuntime {
let mut runtime_unavailable = false;
for response in responses {
match response {
WorkerResponse::Frame { tab_id, frame } => {
WorkerResponse::Frame { generation, tab_id, frame } => {
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
continue;
};
let session = match self.sessions.get_mut(&tab_id_obj) {
Some(session) => session,
None => continue,
if !self.sessions.get(&tab_id_obj).is_some_and(|session| {
&session.scope == scope && session.generation == Some(generation)
}) {
continue;
}
self.note_scope_success(scope);
let Some(session) = self.sessions.get_mut(&tab_id_obj) else {
continue;
};
let requested_url = session.requested_url.clone();
let scroll_offset = session.scroll_offset;
let zoom_percent = session.zoom_percent;
session.cadence.note_frame(frame.render_state(), now);
session.cadence.note_frame(frame.render_state(), frame.pixels_changed(), now);
match WebSurfaceFrame::from_live_frame(
requested_url.clone(),
scroll_offset,
@@ -356,15 +342,17 @@ impl WebSurfaceRuntime {
}),
}
}
WorkerResponse::Failed { tab_id, message } => {
WorkerResponse::Failed { generation, tab_id, message } => {
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
continue;
};
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
}
WorkerResponse::RuntimeUnavailable => {
runtime_unavailable = true;
if self.sessions.get(&tab_id_obj).is_some_and(|session| {
&session.scope == scope && session.generation == Some(generation)
}) {
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
}
}
WorkerResponse::RuntimeUnavailable => runtime_unavailable = true,
}
}
runtime_unavailable
@@ -374,9 +362,15 @@ impl WebSurfaceRuntime {
self.sessions.keys().find(|key| key.as_str() == tab_id).cloned()
}
fn next_request_generation(&mut self) -> RequestGeneration {
assert!(self.last_generation < u64::MAX, "web surface request generation exhausted");
self.last_generation += 1;
RequestGeneration::new(self.last_generation)
}
#[cfg(test)]
pub(super) fn client_count_for_test(&self) -> usize {
usize::from(self.worker.is_some()) + usize::from(self.direct_client.is_some())
self.workers.len()
}
#[cfg(test)]
@@ -385,39 +379,72 @@ impl WebSurfaceRuntime {
}
#[cfg(test)]
pub(super) fn flush_for_test(&self) {
if let Some(scoped) = self.worker.as_ref() {
pub(super) fn flush_for_test(&mut self) {
for scoped in self.workers.values() {
scoped.worker.wait_until_idle();
}
self.reap_retired_workers(true);
}
fn remove_worker(&mut self) {
let Some(scoped) = self.worker.take() else {
fn remove_worker(&mut self, scope: &WebSurfaceRuntimeScope) {
let Some(scoped) = self.workers.remove(scope) else {
return;
};
let ScopedWorker { worker, transient_profile_data_dir } = scoped;
drop(worker);
if let Some(path) = transient_profile_data_dir {
let _ = fs::remove_dir_all(path);
if scoped.transient_profile_data_dir.is_some() {
match std::thread::Builder::new()
.name("ely-servo-profile-cleanup".to_string())
.spawn(move || shutdown_scoped_worker(scoped))
{
Ok(handle) => self.retired_workers.push(handle),
Err(error) => self.transient_cleanup_error = Some(error.to_string()),
}
} else {
let _ = shutdown_scoped_worker(scoped);
}
}
fn remove_direct_client(&mut self) {
let Some(scoped) = self.direct_client.take() else {
return;
};
let ScopedDirectClient { client, transient_profile_data_dir } = scoped;
drop(client);
if let Some(path) = transient_profile_data_dir {
let _ = fs::remove_dir_all(path);
fn reap_retired_workers(&mut self, wait_for_all: bool) {
let mut pending = Vec::new();
for handle in self.retired_workers.drain(..) {
if !wait_for_all && !handle.is_finished() {
pending.push(handle);
continue;
}
match handle.join() {
Ok(Ok(())) => {}
Ok(Err(error)) => self.transient_cleanup_error = Some(error),
Err(_) => {
self.transient_cleanup_error =
Some("Servo profile cleanup thread panicked".to_string());
}
}
}
self.retired_workers = pending;
}
fn invalidate_scope(&mut self, scope: &WebSurfaceRuntimeScope, now: Instant) {
self.remove_worker(scope);
self.sessions.retain(|_, session| &session.scope != scope);
self.note_scope_failure(scope, now);
}
fn note_scope_failure(&mut self, scope: &WebSurfaceRuntimeScope, now: Instant) {
let retry = ScopeRetryState::after_failure(self.retry_state.get(scope), now);
self.retry_state.insert(scope.clone(), retry);
}
fn note_scope_success(&mut self, scope: &WebSurfaceRuntimeScope) {
self.retry_state.remove(scope);
}
}
impl Drop for WebSurfaceRuntime {
fn drop(&mut self) {
self.remove_worker();
self.remove_direct_client();
let scopes = self.workers.keys().cloned().collect::<Vec<_>>();
for scope in scopes {
self.remove_worker(&scope);
}
self.reap_retired_workers(true);
}
}
@@ -430,16 +457,38 @@ fn new_servo_live_client(config_dir: PathBuf) -> Result<Box<dyn LiveRuntimeClien
.map_err(|error| error.to_string())
}
struct ScopedWorker {
worker: LiveRuntimeWorker,
transient_profile_data_dir: Option<PathBuf>,
#[derive(Clone, Copy, Debug)]
struct ScopeRetryState {
failure_count: u32,
retry_after: Instant,
}
struct ScopedDirectClient {
client: Box<dyn LiveRuntimeClient>,
transient_profile_data_dir: Option<PathBuf>,
impl ScopeRetryState {
fn after_failure(previous: Option<&Self>, now: Instant) -> Self {
let failure_count = previous.map_or(1, |state| state.failure_count.saturating_add(1));
let shift = failure_count.saturating_sub(1).min(31);
let multiplier = 1_u32 << shift;
let delay =
SIDECAR_RESTART_BASE_DELAY.saturating_mul(multiplier).min(SIDECAR_RESTART_MAX_DELAY);
Self { failure_count, retry_after: now + delay }
}
}
#[path = "web_surface_runtime_cleanup.rs"]
mod cleanup;
#[cfg(test)]
#[path = "web_surface_runtime_backpressure_tests.rs"]
mod backpressure_tests;
#[cfg(test)]
#[path = "web_surface_runtime_cleanup_tests.rs"]
mod cleanup_tests;
#[cfg(test)]
#[path = "web_surface_runtime_generation_tests.rs"]
mod generation_tests;
#[cfg(test)]
#[path = "web_surface_runtime_retry_tests.rs"]
mod retry_tests;
#[cfg(test)]
#[path = "web_surface_runtime_tests.rs"]
mod tests;
@@ -0,0 +1,161 @@
use std::{
path::PathBuf,
sync::{Mutex, mpsc},
thread,
time::Duration,
};
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use crate::services::{
ProfileDataMode,
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
};
use super::{
super::{
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
web_surface_state::WebSurfacePendingInput,
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
},
WebSurfaceRuntime,
};
struct SlowPollClient;
struct BlockingEnsureClient {
ensure_started_tx: mpsc::Sender<()>,
release_ensure_rx: mpsc::Receiver<()>,
}
struct BlockingEnsureSetup {
ensure_started_tx: mpsc::Sender<()>,
release_ensure_rx: mpsc::Receiver<()>,
}
static BLOCKING_ENSURE_SETUP: Mutex<Option<BlockingEnsureSetup>> = Mutex::new(None);
impl LiveRuntimeClient for SlowPollClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
thread::sleep(Duration::from_millis(100));
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
impl LiveRuntimeClient for BlockingEnsureClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
self.ensure_started_tx.send(()).map_err(|error| error.to_string())?;
self.release_ensure_rx.recv().map_err(|error| error.to_string())?;
Ok(Some(ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])))
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Err(LiveRuntimeClientError::Message("unexpected poll".to_string()))
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
fn slow_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
Ok(Box::new(SlowPollClient))
}
fn blocking_ensure_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
let setup = BLOCKING_ENSURE_SETUP
.lock()
.map_err(|_| "blocking ensure setup lock was poisoned".to_string())?
.take()
.ok_or_else(|| "blocking ensure setup was missing".to_string())?;
Ok(Box::new(BlockingEnsureClient {
ensure_started_tx: setup.ensure_started_tx,
release_ensure_rx: setup.release_ensure_rx,
}))
}
#[test]
fn rejected_tick_poll_preserves_in_flight_ensure_generation() -> Result<(), String> {
let (ensure_started_tx, ensure_started_rx) = mpsc::channel();
let (release_ensure_tx, release_ensure_rx) = mpsc::channel();
*BLOCKING_ENSURE_SETUP
.lock()
.map_err(|_| "blocking ensure setup lock was poisoned".to_string())? =
Some(BlockingEnsureSetup { ensure_started_tx, release_ensure_rx });
let mut runtime = WebSurfaceRuntime::new_with_client_factory(blocking_ensure_client_factory);
let tab = web_tab("Blocked ensure")?;
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
ensure_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
thread::sleep(Duration::from_millis(10));
assert!(runtime.tick(std::slice::from_ref(tab.id())).is_empty());
release_ensure_tx.send(()).map_err(|error| error.to_string())?;
runtime.flush_for_test();
let frames = runtime.tick(std::slice::from_ref(tab.id()));
assert!(matches!(
frames.as_slice(),
[super::WebSurfaceRuntimeFrame::Ready { tab_id, .. }] if tab_id == tab.id()
));
Ok(())
}
#[test]
fn pending_poll_advances_deadline_under_worker_backpressure() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(slow_client_factory);
let tab = web_tab("Backpressure")?;
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test();
thread::sleep(Duration::from_millis(10));
runtime.tick(std::slice::from_ref(tab.id()));
thread::sleep(Duration::from_millis(10));
runtime.tick(std::slice::from_ref(tab.id()));
let delay = runtime
.next_poll_delay(std::slice::from_ref(tab.id()), std::time::Instant::now())
.ok_or_else(|| "visible tab lost its poll deadline".to_string())?;
assert!(delay > Duration::ZERO, "backpressured poll must not schedule a zero-delay timer");
Ok(())
}
fn web_tab(title: &str) -> Result<BrowserTab, String> {
Ok(BrowserTab::new(
TabId::new(),
SpaceId::new(),
ProfileId::new(),
title,
UrlText::parse("https://example.com/backpressure").map_err(|error| error.to_string())?,
))
}
fn surface_size() -> WebSurfaceSize {
WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 }
}
fn pending_input() -> WebSurfacePendingInput {
WebSurfacePendingInput {
enqueued_at: None,
scroll_offset: WebSurfaceScrollOffset::default(),
scroll_delta: None,
scroll_point: None,
click_point: None,
hover_point: None,
typed_text: None,
}
}
@@ -0,0 +1,19 @@
use crate::services::servo_profile_data::TransientProfileDataDir;
use super::LiveRuntimeWorker;
pub(super) struct ScopedWorker {
pub(super) worker: LiveRuntimeWorker,
pub(super) transient_profile_data_dir: Option<TransientProfileDataDir>,
}
pub(super) fn shutdown_scoped_worker(scoped: ScopedWorker) -> Result<(), String> {
let ScopedWorker { worker, transient_profile_data_dir } = scoped;
drop(worker);
let Some(directory) = transient_profile_data_dir else {
return Ok(());
};
directory
.close()
.map_err(|error| format!("failed to remove transient Servo profile data: {error}"))
}
@@ -0,0 +1,173 @@
use std::{
path::PathBuf,
sync::{
Mutex,
atomic::{AtomicUsize, Ordering},
mpsc,
},
time::Duration,
};
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use crate::{
services::{
ProfileDataMode,
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
},
shell::{
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
web_surface_state::WebSurfacePendingInput,
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
},
};
use super::WebSurfaceRuntime;
static FACTORY_CALLS: AtomicUsize = AtomicUsize::new(0);
static DROP_SETUP: Mutex<Option<DropSetup>> = Mutex::new(None);
static FACTORY_PATHS: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
struct DropSetup {
started_tx: mpsc::Sender<()>,
release_rx: mpsc::Receiver<()>,
}
struct BlockingDropClient {
started_tx: mpsc::Sender<()>,
release_rx: mpsc::Receiver<()>,
}
impl LiveRuntimeClient for BlockingDropClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
impl Drop for BlockingDropClient {
fn drop(&mut self) {
let _ = self.started_tx.send(());
let _ = self.release_rx.recv();
}
}
struct EmptyClient;
impl LiveRuntimeClient for EmptyClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
#[test]
fn private_worker_creation_uses_fresh_storage_during_previous_cleanup() -> Result<(), String> {
FACTORY_CALLS.store(0, Ordering::SeqCst);
FACTORY_PATHS.lock().map_err(|_| "factory paths lock was poisoned")?.clear();
let (started_tx, started_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
*DROP_SETUP.lock().map_err(|_| "drop setup lock was poisoned")? =
Some(DropSetup { started_tx, release_rx });
let mut runtime = WebSurfaceRuntime::new_with_client_factory(cleanup_client_factory);
let first = web_tab(ProfileId::new())?;
let second = web_tab(ProfileId::new())?;
runtime.ensure_tab(&first, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test();
runtime.close_tab(first.id());
started_rx
.recv_timeout(Duration::from_secs(2))
.map_err(|error| format!("cleanup did not start: {error}"))?;
runtime.ensure_tab(
&second,
surface_size(),
ProfileDataMode::Transient,
&[],
pending_input(),
)?;
let deadline = std::time::Instant::now() + Duration::from_secs(2);
while FACTORY_CALLS.load(Ordering::SeqCst) < 2 && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(2));
}
assert_eq!(FACTORY_CALLS.load(Ordering::SeqCst), 2);
let paths = FACTORY_PATHS.lock().map_err(|_| "factory paths lock was poisoned")?.clone();
assert_eq!(paths.len(), 2);
assert_ne!(paths[0], paths[1]);
assert!(paths.iter().all(|path| path.is_dir()));
release_tx.send(()).map_err(|error| error.to_string())?;
runtime.flush_for_test();
assert!(!paths[0].exists());
assert!(paths[1].is_dir());
Ok(())
}
fn cleanup_client_factory(
config_dir: std::path::PathBuf,
) -> Result<Box<dyn LiveRuntimeClient>, String> {
FACTORY_PATHS
.lock()
.map_err(|_| "factory paths lock was poisoned".to_string())?
.push(config_dir);
if FACTORY_CALLS.fetch_add(1, Ordering::SeqCst) == 0 {
let setup = DROP_SETUP
.lock()
.map_err(|_| "drop setup lock was poisoned".to_string())?
.take()
.ok_or_else(|| "drop setup was missing".to_string())?;
Ok(Box::new(BlockingDropClient {
started_tx: setup.started_tx,
release_rx: setup.release_rx,
}))
} else {
Ok(Box::new(EmptyClient))
}
}
fn web_tab(profile_id: ProfileId) -> Result<BrowserTab, String> {
Ok(BrowserTab::new(
TabId::new(),
SpaceId::new(),
profile_id,
"Private",
UrlText::parse("https://example.com/private").map_err(|error| error.to_string())?,
))
}
fn surface_size() -> WebSurfaceSize {
WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 }
}
fn pending_input() -> WebSurfacePendingInput {
WebSurfacePendingInput {
enqueued_at: None,
scroll_offset: WebSurfaceScrollOffset::default(),
scroll_delta: None,
scroll_point: None,
click_point: None,
hover_point: None,
typed_text: None,
}
}
@@ -0,0 +1,323 @@
use std::{
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
time::Instant,
};
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use crate::services::{
ProfileDataMode,
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
};
use super::{
super::{
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
web_surface_state::WebSurfacePendingInput,
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError, WorkerResponse},
},
WebSurfaceRuntime, WebSurfaceRuntimeFrame, WebSurfaceRuntimeScope,
};
struct EmptyClient;
static FAILING_FACTORY_CALLS: AtomicUsize = AtomicUsize::new(0);
static FAILING_FACTORY_SHUTDOWNS: AtomicUsize = AtomicUsize::new(0);
impl LiveRuntimeClient for EmptyClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
struct CloseRecordingClient;
impl LiveRuntimeClient for CloseRecordingClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
impl Drop for CloseRecordingClient {
fn drop(&mut self) {
FAILING_FACTORY_SHUTDOWNS.fetch_add(1, Ordering::SeqCst);
}
}
#[test]
fn late_frame_from_a_is_discarded_after_a_to_b() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
let tab_id = TabId::new();
let profile_a = ProfileId::new();
let profile_b = ProfileId::new();
let scope_a = scope(&profile_a);
let tab_a = web_tab(tab_id.clone(), profile_a, "https://example.com/a")?;
let tab_b = web_tab(tab_id.clone(), profile_b, "https://example.com/b")?;
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
let generation_a = runtime
.sessions
.get(&tab_id)
.and_then(|session| session.generation)
.ok_or_else(|| "profile A ensure generation was missing".to_string())?;
runtime.ensure_tab(&tab_b, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
let generation_b = runtime
.sessions
.get(&tab_id)
.and_then(|session| session.generation)
.ok_or_else(|| "profile B ensure generation was missing".to_string())?;
let mut frames = Vec::new();
runtime.collect_responses(
&scope_a,
vec![WorkerResponse::Frame {
generation: generation_a,
tab_id: tab_id.as_str().to_string(),
frame: live_frame(),
}],
Instant::now(),
&mut frames,
);
assert!(generation_a < generation_b);
assert!(frames.is_empty());
Ok(())
}
#[test]
fn generation_blocks_aba_frames_and_failures() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
let tab_id = TabId::new();
let profile_a = ProfileId::new();
let profile_b = ProfileId::new();
let scope_a = scope(&profile_a);
let tab_a = web_tab(tab_id.clone(), profile_a, "https://example.com/a")?;
let tab_b = web_tab(tab_id.clone(), profile_b, "https://example.com/b")?;
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
let first_a = current_generation(&runtime, &tab_id)?;
runtime.ensure_tab(&tab_b, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
let generation_b = current_generation(&runtime, &tab_id)?;
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
let second_a = current_generation(&runtime, &tab_id)?;
let mut frames = Vec::new();
runtime.collect_responses(
&scope_a,
vec![
WorkerResponse::Frame {
generation: first_a,
tab_id: tab_id.as_str().to_string(),
frame: live_frame(),
},
WorkerResponse::Failed {
generation: first_a,
tab_id: tab_id.as_str().to_string(),
message: "stale failure".to_string(),
},
],
Instant::now(),
&mut frames,
);
assert!(frames.is_empty());
runtime.collect_responses(
&scope_a,
vec![WorkerResponse::Frame {
generation: second_a,
tab_id: tab_id.as_str().to_string(),
frame: live_frame(),
}],
Instant::now(),
&mut frames,
);
assert!(first_a < generation_b && generation_b < second_a);
assert!(matches!(
frames.as_slice(),
[WebSurfaceRuntimeFrame::Ready { tab_id: ready_tab_id, .. }] if ready_tab_id == &tab_id
));
Ok(())
}
#[test]
fn scope_creation_failure_invalidates_previous_session_generation() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
let tab_id = TabId::new();
let profile_a = ProfileId::new();
let profile_b = ProfileId::new();
let scope_a = scope(&profile_a);
let scope_b = scope(&profile_b);
let tab_a = web_tab(tab_id.clone(), profile_a, "https://example.com/a")?;
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
let generation_a = current_generation(&runtime, &tab_id)?;
runtime.retry_state.insert(
scope_b,
super::ScopeRetryState {
failure_count: 1,
retry_after: Instant::now() + std::time::Duration::from_secs(1),
},
);
assert!(runtime.prepare_tab_scope(&tab_id, &profile_b, ProfileDataMode::Transient).is_err());
assert!(!runtime.sessions.contains_key(&tab_id));
let mut frames = Vec::new();
runtime.collect_responses(
&scope_a,
vec![WorkerResponse::Frame {
generation: generation_a,
tab_id: tab_id.as_str().to_string(),
frame: live_frame(),
}],
Instant::now(),
&mut frames,
);
assert!(frames.is_empty());
Ok(())
}
#[test]
fn stale_frame_preserves_scope_backoff_until_current_frame() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
let tab_id = TabId::new();
let profile_id = ProfileId::new();
let scope = scope(&profile_id);
let tab = web_tab(tab_id.clone(), profile_id, "https://example.com/current")?;
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test();
let current = current_generation(&runtime, &tab_id)?;
runtime.note_scope_failure(&scope, Instant::now());
let mut frames = Vec::new();
runtime.collect_responses(
&scope,
vec![WorkerResponse::Frame {
generation: super::super::web_surface_worker::RequestGeneration::new(0),
tab_id: tab_id.as_str().to_string(),
frame: live_frame(),
}],
Instant::now(),
&mut frames,
);
assert!(frames.is_empty());
assert!(runtime.retry_state.contains_key(&scope));
runtime.collect_responses(
&scope,
vec![WorkerResponse::Frame {
generation: current,
tab_id: tab_id.as_str().to_string(),
frame: live_frame(),
}],
Instant::now(),
&mut frames,
);
assert!(matches!(frames.as_slice(), [WebSurfaceRuntimeFrame::Ready { .. }]));
assert!(!runtime.retry_state.contains_key(&scope));
Ok(())
}
#[test]
fn previous_scope_shuts_down_when_new_worker_factory_fails_async() -> Result<(), String> {
FAILING_FACTORY_CALLS.store(0, Ordering::SeqCst);
FAILING_FACTORY_SHUTDOWNS.store(0, Ordering::SeqCst);
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fail_second_client_factory);
let tab_id = TabId::new();
let tab_a = web_tab(tab_id.clone(), ProfileId::new(), "https://example.com/a")?;
let tab_b = web_tab(tab_id, ProfileId::new(), "https://example.com/b")?;
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test();
runtime.ensure_tab(&tab_b, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test();
let frames = runtime.tick(std::slice::from_ref(tab_b.id()));
assert_eq!(FAILING_FACTORY_SHUTDOWNS.load(Ordering::SeqCst), 1);
assert!(matches!(
frames.as_slice(),
[WebSurfaceRuntimeFrame::Failed { message, .. }]
if message == "injected client creation failure"
));
Ok(())
}
fn current_generation(
runtime: &WebSurfaceRuntime,
tab_id: &TabId,
) -> Result<super::super::web_surface_worker::RequestGeneration, String> {
runtime
.sessions
.get(tab_id)
.and_then(|session| session.generation)
.ok_or_else(|| "session generation was missing".to_string())
}
fn empty_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
Ok(Box::new(EmptyClient))
}
fn fail_second_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
if FAILING_FACTORY_CALLS.fetch_add(1, Ordering::SeqCst) == 0 {
Ok(Box::new(CloseRecordingClient))
} else {
Err("injected client creation failure".to_string())
}
}
fn scope(profile_id: &ProfileId) -> WebSurfaceRuntimeScope {
WebSurfaceRuntimeScope::new(profile_id.clone(), ProfileDataMode::Transient)
}
fn web_tab(tab_id: TabId, profile_id: ProfileId, url: &str) -> Result<BrowserTab, String> {
Ok(BrowserTab::new(
tab_id,
SpaceId::new(),
profile_id,
"Web",
UrlText::parse(url).map_err(|error| error.to_string())?,
))
}
fn surface_size() -> WebSurfaceSize {
WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 }
}
fn pending_input() -> WebSurfacePendingInput {
WebSurfacePendingInput {
enqueued_at: None,
scroll_offset: WebSurfaceScrollOffset::default(),
scroll_delta: None,
scroll_point: None,
click_point: None,
hover_point: None,
typed_text: None,
}
}
fn live_frame() -> ServoLiveFrame {
ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])
}
@@ -0,0 +1,77 @@
use std::time::{Duration, Instant};
use ely_domain::{ProfileId, TabId};
use crate::services::{ProfileDataMode, servo_live::ServoLiveFrame};
use super::{
super::{
web_surface_runtime_session::session_for_scope,
web_surface_worker::{RequestGeneration, WorkerResponse},
},
SIDECAR_RESTART_BASE_DELAY, SIDECAR_RESTART_MAX_DELAY, ScopeRetryState, WebSurfaceRuntime,
WebSurfaceRuntimeScope,
};
#[test]
fn retry_delay_grows_exponentially_and_caps() {
let now = Instant::now();
let expected = [
Duration::from_millis(250),
Duration::from_millis(500),
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
SIDECAR_RESTART_MAX_DELAY,
SIDECAR_RESTART_MAX_DELAY,
];
let mut state = None;
for delay in expected {
let next = ScopeRetryState::after_failure(state.as_ref(), now);
assert_eq!(next.retry_after.duration_since(now), delay);
state = Some(next);
}
assert_eq!(SIDECAR_RESTART_BASE_DELAY, Duration::from_millis(250));
}
#[test]
fn retry_state_is_scoped() {
let mut runtime = WebSurfaceRuntime::new();
let scope_a = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
let scope_b = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
let now = Instant::now();
runtime.note_scope_failure(&scope_a, now);
runtime.note_scope_failure(&scope_a, now);
runtime.note_scope_failure(&scope_b, now);
assert_eq!(runtime.retry_state.get(&scope_a).map(|state| state.failure_count), Some(2));
assert_eq!(runtime.retry_state.get(&scope_b).map(|state| state.failure_count), Some(1));
assert_eq!(runtime.retry_state.get(&scope_b).map(|state| state.failure_count), Some(1));
}
#[test]
fn successful_frame_resets_scope_retry_state() {
let mut runtime = WebSurfaceRuntime::new();
let scope = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
let tab_id = TabId::new();
let generation = RequestGeneration::new(1);
session_for_scope(&mut runtime.sessions, &tab_id, scope.clone()).generation = Some(generation);
runtime.note_scope_failure(&scope, Instant::now());
let mut frames = Vec::new();
runtime.collect_responses(
&scope,
vec![WorkerResponse::Frame {
generation,
tab_id: tab_id.as_str().to_string(),
frame: ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255]),
}],
Instant::now(),
&mut frames,
);
assert!(!runtime.retry_state.contains_key(&scope));
assert_eq!(frames.len(), 1);
}
@@ -1,16 +1,19 @@
use std::{collections::BTreeMap, fs, path::PathBuf};
use ely_domain::{ProfileId, TabId};
use std::{collections::BTreeMap, path::PathBuf};
use crate::services::{
ProfileDataMode,
servo_profile_data::{default_profile_data_root, profile_data_dir, transient_profile_data_dir},
servo_profile_data::{
TransientProfileDataDir, create_profile_data_dir, default_profile_data_root,
transient_profile_data_dir,
},
};
use ely_domain::{ProfileId, TabId};
use super::{
web_surface_cadence::WebSurfacePollCadence,
web_surface_frame::WebSurfaceFrame,
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
web_surface_worker::RequestGeneration,
};
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
@@ -23,6 +26,10 @@ impl WebSurfaceRuntimeScope {
pub(super) fn new(profile_id: ProfileId, profile_data_mode: ProfileDataMode) -> Self {
Self { profile_id, profile_data_mode }
}
pub(super) fn is_transient(&self) -> bool {
self.profile_data_mode == ProfileDataMode::Transient
}
}
#[derive(Clone)]
@@ -33,6 +40,7 @@ pub(super) struct WebSurfaceSession {
pub(super) zoom_percent: u16,
pub(super) scroll_offset: WebSurfaceScrollOffset,
pub(super) pending_user_navigation: bool,
pub(super) generation: Option<RequestGeneration>,
pub(super) cadence: WebSurfacePollCadence,
}
@@ -45,6 +53,7 @@ impl WebSurfaceSession {
zoom_percent: 0,
scroll_offset: WebSurfaceScrollOffset::default(),
pending_user_navigation: false,
generation: None,
cadence: WebSurfacePollCadence::default(),
}
}
@@ -110,20 +119,20 @@ pub(super) enum WebSurfaceUrlChangeKind {
pub(super) fn config_dir_for_scope(
scope: &WebSurfaceRuntimeScope,
) -> Result<(PathBuf, Option<PathBuf>), String> {
) -> Result<(PathBuf, Option<TransientProfileDataDir>), String> {
match scope.profile_data_mode {
ProfileDataMode::Persistent => {
let root = default_profile_data_root()
.ok_or_else(|| "Profile data root is unavailable".to_string())?;
let config_dir = profile_data_dir(&root, &scope.profile_id);
fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?;
let config_dir = create_profile_data_dir(&root, &scope.profile_id)
.map_err(|error| error.to_string())?;
Ok((config_dir, None))
}
ProfileDataMode::Transient => {
let config_dir =
let transient_dir =
transient_profile_data_dir(&scope.profile_id).map_err(|error| error.to_string())?;
fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?;
Ok((config_dir.clone(), Some(config_dir)))
let config_dir = transient_dir.path().to_path_buf();
Ok((config_dir, Some(transient_dir)))
}
}
}
@@ -1,6 +1,9 @@
use std::{
collections::BTreeMap,
sync::atomic::{AtomicUsize, Ordering},
sync::{
Mutex,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
@@ -21,15 +24,28 @@ use super::*;
use crate::services::servo_live::{ServoLiveEnsureRequest, ServoLiveFrame};
static FAKE_CLOSE_COUNT: AtomicUsize = AtomicUsize::new(0);
static FAKE_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
static IDLE_SKIP_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
static RECOVERY_FACTORY_COUNT: AtomicUsize = AtomicUsize::new(0);
static FAILING_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
static REPEATED_FRAME_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
static CLOSE_CONFIG_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
#[test]
fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), String> {
fn transient_scope_owns_removable_servo_storage() -> Result<(), String> {
let scope = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
let (config_dir, guard) = config_dir_for_scope(&scope)?;
let Some(guard) = guard else {
return Err("transient scope did not return a storage guard".to_string());
};
assert_eq!(guard.path(), config_dir);
guard.close().map_err(|error| error.to_string())?;
assert!(!config_dir.exists());
Ok(())
}
#[test]
fn runtime_routes_profile_scopes_to_distinct_clients() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fake_client_factory);
let first_profile = ProfileId::new();
let second_profile = ProfileId::new();
@@ -60,7 +76,7 @@ fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), Stri
runtime.flush_for_test();
assert_eq!(runtime.client_count_for_test(), 1);
assert_eq!(runtime.client_count_for_test(), 2);
assert_eq!(
runtime.session_scope_for_test(first_tab.id()),
Some(&WebSurfaceRuntimeScope::new(first_profile, ProfileDataMode::Transient)),
@@ -73,22 +89,64 @@ fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), Stri
}
#[test]
fn close_tab_removes_session_and_closes_client() -> Result<(), String> {
let before = FAKE_CLOSE_COUNT.load(Ordering::SeqCst);
fn delayed_frame_from_previous_profile_scope_is_ignored() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fake_client_factory);
let tab = web_tab(TabId::new(), ProfileId::new(), "https://example.com/close")?;
let tab_id = TabId::new();
let first_profile = ProfileId::new();
let second_profile = ProfileId::new();
let first_scope =
WebSurfaceRuntimeScope::new(first_profile.clone(), ProfileDataMode::Transient);
let second_scope = WebSurfaceRuntimeScope::new(second_profile, ProfileDataMode::Transient);
let first_tab = web_tab(tab_id.clone(), first_profile, "https://example.com/shared")?;
runtime.ensure_tab(
&first_tab,
surface_size(),
ProfileDataMode::Transient,
&[],
pending_input(),
)?;
runtime.flush_for_test();
session_for_scope(&mut runtime.sessions, &tab_id, second_scope);
let mut frames = Vec::new();
let unavailable = runtime.collect_responses(
&first_scope,
vec![WorkerResponse::Frame {
generation: RequestGeneration::new(1),
tab_id: tab_id.as_str().to_string(),
frame: repeated_live_frame(),
}],
std::time::Instant::now(),
&mut frames,
);
assert!(!unavailable);
assert!(frames.is_empty());
Ok(())
}
#[test]
fn close_last_transient_tab_removes_session_worker_and_profile_data() -> Result<(), String> {
*CLOSE_CONFIG_DIR.lock().map_err(|_| "close config lock was poisoned")? = None;
let mut runtime = WebSurfaceRuntime::new_with_client_factory(close_client_factory);
let tab = web_tab(TabId::new(), ProfileId::new(), "https://example.com/close")?;
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test();
let config_dir = CLOSE_CONFIG_DIR
.lock()
.map_err(|_| "close config lock was poisoned")?
.clone()
.ok_or_else(|| "close client config directory was missing".to_string())?;
assert!(config_dir.is_dir());
runtime.close_tab(tab.id());
runtime.flush_for_test();
assert_eq!(runtime.session_scope_for_test(tab.id()), None);
assert_eq!(FAKE_CLOSE_COUNT.load(Ordering::SeqCst), before + 1);
assert_eq!(runtime.client_count_for_test(), 0);
assert!(!config_dir.exists());
runtime.close_tab(tab.id());
runtime.flush_for_test();
assert_eq!(FAKE_CLOSE_COUNT.load(Ordering::SeqCst), before + 1);
assert_eq!(runtime.client_count_for_test(), 0);
Ok(())
}
@@ -207,6 +265,7 @@ fn runtime_unavailable_removes_dead_runtime_client() -> Result<(), String> {
assert_eq!(runtime.client_count_for_test(), 0);
let next_tab = web_tab(TabId::new(), profile, "https://example.com/next")?;
std::thread::sleep(super::SIDECAR_RESTART_BASE_DELAY + Duration::from_millis(10));
runtime.ensure_tab(
&next_tab,
surface_size(),
@@ -222,7 +281,7 @@ fn runtime_unavailable_removes_dead_runtime_client() -> Result<(), String> {
}
#[test]
fn failed_surface_ensure_waits_for_a_new_key_before_retrying() -> Result<(), String> {
fn failed_surface_ensure_retries_same_key_after_cooldown() -> Result<(), String> {
FAILING_ENSURE_COUNT.store(0, Ordering::SeqCst);
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
failing_client_factory,
@@ -239,21 +298,7 @@ fn failed_surface_ensure_waits_for_a_new_key_before_retrying() -> Result<(), Str
assert!(tick.changed, "the failing client must surface a state change via tick");
assert_eq!(FAILING_ENSURE_COUNT.load(Ordering::SeqCst), 1);
assert!(!store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
store.flush_runtime_for_test();
let _ = store.tick(&[tab.id().clone()]);
assert_eq!(FAILING_ENSURE_COUNT.load(Ordering::SeqCst), 1);
assert_eq!(
store.record_viewport_size(tab.id(), resized_viewport_bounds(), 1.0),
WebSurfaceInputOutcome::Applied,
);
// `record_viewport_size` stamps the viewport-resize debounce on a
// genuine size transition; in production the poll cadence retries
// `ensure_surface` once the gesture settles, but a synchronous
// unit test can't advance the clock — clear the timestamp so we
// exercise the retry-on-new-key business rule in isolation.
store.clear_viewport_resize_debounce_for_test(tab.id());
std::thread::sleep(super::SIDECAR_RESTART_BASE_DELAY + Duration::from_millis(10));
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
store.flush_runtime_for_test();
let _ = store.tick(&[tab.id().clone()]);
@@ -305,7 +350,6 @@ impl LiveRuntimeClient for FakeLiveRuntimeClient {
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
FAKE_CLOSE_COUNT.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
@@ -387,6 +431,14 @@ fn fake_client_factory(
Ok(Box::new(FakeLiveRuntimeClient))
}
fn close_client_factory(
config_dir: std::path::PathBuf,
) -> Result<Box<dyn LiveRuntimeClient>, String> {
*CLOSE_CONFIG_DIR.lock().map_err(|_| "close config lock was poisoned".to_string())? =
Some(config_dir);
Ok(Box::new(FakeLiveRuntimeClient))
}
fn idle_skip_client_factory(
_config_dir: std::path::PathBuf,
) -> Result<Box<dyn LiveRuntimeClient>, String> {
@@ -435,13 +487,6 @@ fn viewport_bounds() -> gpui::Bounds<gpui::Pixels> {
)
}
fn resized_viewport_bounds() -> gpui::Bounds<gpui::Pixels> {
gpui::Bounds::new(
gpui::point(gpui::px(0.0), gpui::px(0.0)),
gpui::size(gpui::px(720.0), gpui::px(480.0)),
)
}
fn pending_input() -> WebSurfacePendingInput {
WebSurfacePendingInput {
enqueued_at: None,
@@ -0,0 +1,234 @@
use std::{error::Error, sync::Mutex};
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use gpui::{Bounds, point, px, size};
use crate::services::{
ProfileDataMode,
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
};
use super::super::{
web_surface_frame::WebSurfaceFrame,
web_surface_geometry::WebSurfaceScrollOffset,
web_surface_runtime::{
WebSurfaceRuntime, WebSurfaceRuntimeFrame, WebSurfaceUrlChange, WebSurfaceUrlChangeKind,
},
web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState},
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
};
use super::WebSurfaceStore;
static ENSURES: Mutex<Vec<RecordedEnsure>> = Mutex::new(Vec::new());
#[derive(Debug)]
struct RecordedEnsure {
profile_id: String,
had_input: bool,
}
struct RecordingClient;
impl LiveRuntimeClient for RecordingClient {
fn ensure(
&mut self,
request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
ENSURES.lock().map_err(|_| "ensure recorder lock was poisoned".to_string())?.push(
RecordedEnsure {
profile_id: request.profile_id,
had_input: request.scroll_delta_x != 0
|| request.scroll_delta_y != 0
|| request.click_x.is_some()
|| request.click_y.is_some()
|| request.hover_x.is_some()
|| request.hover_y.is_some()
|| request.typed_text.is_some(),
},
);
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
fn recording_client_factory(
_config_dir: std::path::PathBuf,
) -> Result<Box<dyn LiveRuntimeClient>, String> {
Ok(Box::new(RecordingClient))
}
#[test]
fn profile_scope_change_clears_pixels_input_and_focus_before_ensure() -> Result<(), Box<dyn Error>>
{
ENSURES.lock().map_err(|_| "ensure recorder lock was poisoned")?.clear();
let runtime = WebSurfaceRuntime::new_with_client_factory(recording_client_factory);
let mut store = WebSurfaceStore::new_with_runtime(runtime);
let tab_id = TabId::new();
let url = UrlText::parse("https://example.com/shared")?;
let profile_a = ProfileId::new();
let profile_b = ProfileId::new();
let tab_a =
BrowserTab::new(tab_id.clone(), SpaceId::new(), profile_a.clone(), "Shared", url.clone());
let tab_b = BrowserTab::new(tab_id, SpaceId::new(), profile_b.clone(), "Shared", url);
assert_eq!(
store.record_viewport_size(tab_a.id(), viewport_bounds(), 1.0),
WebSurfaceInputOutcome::Applied,
);
assert!(store.ensure_surface(&tab_a, ProfileDataMode::Transient, &[]));
store.flush_runtime_for_test();
let frame = WebSurfaceFrame::from_live_frame(
tab_a.url().as_str().to_string(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test(1, 1, vec![0, 0, 0, 255]),
)?;
store.surface_mut(tab_a.id()).state = Some(WebSurfaceState::Ready(frame));
assert!(
store.state_for_scope(tab_a.id(), &profile_b, ProfileDataMode::Transient).is_none(),
"a model scope change must hide old pixels before the next ensure",
);
assert_eq!(
store
.record_click_point(tab_a.id(), tab_a.url().as_str(), point(px(120.0), px(80.0)), 1.0,),
WebSurfaceInputOutcome::Applied,
);
assert_eq!(
store.record_typed_text(tab_a.id(), tab_a.url().as_str(), "secret"),
WebSurfaceInputOutcome::Applied,
);
assert!(store.ensure_surface(&tab_b, ProfileDataMode::Transient, &[]));
store.flush_runtime_for_test();
assert!(store.keyboard_focus.is_none());
assert!(matches!(
store.state(tab_b.id()),
Some(WebSurfaceState::Loading { previous_frame: None, .. })
));
let ensures = ENSURES.lock().map_err(|_| "ensure recorder lock was poisoned")?;
assert_eq!(ensures.len(), 2);
assert_eq!(ensures[0].profile_id, profile_a.as_str());
assert_eq!(ensures[1].profile_id, profile_b.as_str());
assert!(!ensures[1].had_input);
Ok(())
}
fn viewport_bounds() -> Bounds<gpui::Pixels> {
Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)))
}
#[test]
fn navigation_holds_loading_white_frame_and_accepts_complete_white_page()
-> Result<(), Box<dyn Error>> {
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
recording_client_factory,
));
let tab_id = TabId::new();
let requested_url = "https://example.com/white".to_string();
let previous = WebSurfaceFrame::from_live_frame(
requested_url.clone(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test(1, 1, vec![0, 0, 0, 255]),
)?;
store.surface_mut(&tab_id).state = Some(WebSurfaceState::Loading {
requested_url: requested_url.clone(),
previous_frame: Some(previous),
});
let loading_white = WebSurfaceFrame::from_live_frame(
requested_url.clone(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_render_state(1, 1, vec![255; 4], "loading"),
)?;
let complete_white = WebSurfaceFrame::from_live_frame(
requested_url,
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_render_state(1, 1, vec![255; 4], "complete"),
)?;
assert!(store.should_hold_initial_frame(&tab_id, &loading_white, false));
assert!(!store.should_hold_initial_frame(&tab_id, &complete_white, false));
Ok(())
}
#[test]
fn progressive_loading_frame_replaces_an_earlier_ready_frame() -> Result<(), Box<dyn Error>> {
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
recording_client_factory,
));
let tab_id = TabId::new();
let requested_url = "https://example.com/progressive".to_string();
let first_loading = WebSurfaceFrame::from_live_frame(
requested_url.clone(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_render_state(1, 1, vec![16, 32, 64, 255], "loading"),
)?;
store.surface_mut(&tab_id).state = Some(WebSurfaceState::Ready(first_loading));
let next_loading = WebSurfaceFrame::from_live_frame(
requested_url,
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_render_state(1, 1, vec![64, 32, 16, 255], "loading"),
)?;
assert!(!store.should_hold_initial_frame(&tab_id, &next_loading, true));
Ok(())
}
#[test]
fn held_loading_frame_preserves_previous_pixels_and_delivers_metadata() -> Result<(), Box<dyn Error>>
{
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
recording_client_factory,
));
let tab_id = TabId::new();
let requested_url = "https://example.com/held".to_string();
let previous = WebSurfaceFrame::from_live_frame(
requested_url.clone(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_title(1, 1, vec![0, 0, 0, 255], "complete", "Previous"),
)?;
store.surface_mut(&tab_id).state = Some(WebSurfaceState::Loading {
requested_url: requested_url.clone(),
previous_frame: Some(previous),
});
let held = WebSurfaceFrame::from_live_frame(
requested_url,
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_title(1, 1, vec![255; 4], "loading", "Updated"),
)?;
let result = store.apply_runtime_frames(vec![WebSurfaceRuntimeFrame::Ready {
tab_id: tab_id.clone(),
frame: Box::new(held),
url_change: Some(WebSurfaceUrlChange {
tab_id: tab_id.clone(),
loaded_url: "https://example.com/redirected".to_string(),
kind: WebSurfaceUrlChangeKind::Observed,
}),
}]);
assert!(matches!(
store.state(&tab_id),
Some(WebSurfaceState::Loading { previous_frame: Some(frame), .. })
if frame.title() == Some("Previous")
));
assert!(matches!(
result.page_metadata.as_slice(),
[metadata] if metadata.title.as_deref() == Some("Updated")
));
assert_eq!(result.url_changes.len(), 1);
Ok(())
}
+83 -24
View File
@@ -1,7 +1,9 @@
use std::time::{Duration, Instant};
use ely_domain::TabId;
use gpui::{Bounds, NativeSurfaceHandle, Pixels};
use ely_domain::{ProfileId, TabId};
use gpui::{Bounds, Pixels};
use crate::services::ProfileDataMode;
use super::{
web_surface_cadence::ACTIVE_POLL_INTERVAL,
@@ -133,7 +135,6 @@ pub(super) struct PerTabSurface {
/// every GPUI paint does not translate into a Servo framebuffer
/// destroy/recreate every frame (the "page flashing" symptom).
viewport_size_changed_at: Option<Instant>,
pub(super) native_surface: Option<NativeSurfaceHandle>,
pub(super) last_ensure_key: Option<WebSurfaceEnsureKey>,
pub(super) hover_point: Option<WebSurfaceClickPoint>,
last_hover_enqueued_at: Option<Instant>,
@@ -154,7 +155,6 @@ impl PerTabSurface {
viewport_bounds: None,
viewport_size: None,
viewport_size_changed_at: None,
native_surface: None,
last_ensure_key: None,
hover_point: None,
last_hover_enqueued_at: None,
@@ -196,6 +196,39 @@ impl PerTabSurface {
self.last_ensure_key.as_ref() != Some(key) || self.has_pending_input()
}
pub(super) fn has_scope(
&self,
profile_id: &ProfileId,
profile_data_mode: ProfileDataMode,
) -> bool {
self.last_ensure_key.as_ref().is_some_and(|key| {
key.profile_id == *profile_id && key.profile_data_mode == profile_data_mode
})
}
pub(super) fn reset_for_scope_change(&mut self, key: &WebSurfaceEnsureKey) -> bool {
let Some(previous_key) = self.last_ensure_key.as_ref() else {
return false;
};
if previous_key.has_same_scope(key) {
return false;
}
self.last_ensure_key = None;
self.hover_point = None;
self.last_hover_enqueued_at = None;
self.click_point = None;
self.pending_scroll_delta = None;
self.pending_scroll_point = None;
self.pending_input_started_at = None;
self.scroll_offset = None;
self.typed_text = None;
self.state = None;
self.last_input_flushed_at = None;
self.metadata_tracker = WebSurfaceMetadataTracker::default();
true
}
/// True when the viewport bounds have changed within the last
/// [`VIEWPORT_RESIZE_DEBOUNCE`] window. The renderer treats this
/// as "the user is mid-animation" and holds off telling Servo to
@@ -217,23 +250,13 @@ impl PerTabSurface {
self.viewport_size_changed_at = Some(now);
}
/// Test-only escape hatch: simulate the trailing edge of a resize
/// animation by clearing the debounce timestamp, so a synchronous
/// `record + ensure` sequence in tests behaves as if the gesture
/// has fully settled. Production code drives this via the poll
/// cadence and time elapsing — tests can't advance an `Instant`.
#[cfg(test)]
pub(super) fn clear_viewport_resize_debounce_for_test(&mut self) {
self.viewport_size_changed_at = None;
}
pub(super) fn mark_ensured(&mut self, key: WebSurfaceEnsureKey) {
self.last_ensure_key = Some(key);
}
pub(super) fn matches_ready(&self, frame: &WebSurfaceFrame) -> bool {
match self.state.as_ref() {
Some(WebSurfaceState::Ready(current)) => current.has_same_software_render_as(frame),
Some(WebSurfaceState::Ready(current)) => current.has_same_render_as(frame),
_ => false,
}
}
@@ -275,7 +298,8 @@ const VIEWPORT_RESIZE_DEBOUNCE: Duration = Duration::from_millis(80);
pub(super) struct WebSurfaceEnsureKey {
requested_url: String,
size: WebSurfaceSize,
native_surface_id: Option<usize>,
profile_id: ProfileId,
profile_data_mode: ProfileDataMode,
zoom_percent: u16,
permissions: Vec<WebSurfaceSitePermission>,
}
@@ -284,18 +308,24 @@ impl WebSurfaceEnsureKey {
pub(super) fn new(
requested_url: String,
size: WebSurfaceSize,
native_surface: Option<&NativeSurfaceHandle>,
profile_id: ProfileId,
profile_data_mode: ProfileDataMode,
zoom_percent: u16,
permissions: &[WebSurfaceSitePermission],
) -> Self {
Self {
requested_url,
size,
native_surface_id: native_surface.map(NativeSurfaceHandle::identity),
profile_id,
profile_data_mode,
zoom_percent,
permissions: permissions.to_vec(),
}
}
fn has_same_scope(&self, other: &Self) -> bool {
self.profile_id == other.profile_id && self.profile_data_mode == other.profile_data_mode
}
}
#[cfg(test)]
@@ -306,7 +336,7 @@ mod tests {
#[test]
fn unchanged_surface_without_input_skips_ensure() {
let key = ensure_key("https://example.com/", 800, 600);
let key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
let mut surface = PerTabSurface::new();
assert!(surface.should_ensure(&key));
@@ -318,7 +348,7 @@ mod tests {
#[test]
fn pending_input_forces_ensure_even_when_key_matches() {
let key = ensure_key("https://example.com/", 800, 600);
let key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
let mut surface = PerTabSurface::new();
surface.mark_ensured(key.clone());
surface.pending_scroll_delta =
@@ -329,8 +359,31 @@ mod tests {
#[test]
fn viewport_change_forces_ensure() {
let old_key = ensure_key("https://example.com/", 800, 600);
let new_key = ensure_key("https://example.com/", 1024, 768);
let profile_id = ProfileId::new();
let old_key = ensure_key("https://example.com/", 800, 600, &profile_id);
let new_key = ensure_key("https://example.com/", 1024, 768, &profile_id);
let mut surface = PerTabSurface::new();
surface.mark_ensured(old_key);
assert!(surface.should_ensure(&new_key));
}
#[test]
fn profile_change_forces_ensure() {
let old_key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
let new_key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
let mut surface = PerTabSurface::new();
surface.mark_ensured(old_key);
assert!(surface.should_ensure(&new_key));
}
#[test]
fn profile_data_mode_change_forces_ensure() {
let profile_id = ProfileId::new();
let old_key = ensure_key("https://example.com/", 800, 600, &profile_id);
let mut new_key = old_key.clone();
new_key.profile_data_mode = ProfileDataMode::Transient;
let mut surface = PerTabSurface::new();
surface.mark_ensured(old_key);
@@ -348,11 +401,17 @@ mod tests {
assert!(!surface.input_flush_is_throttled(start + Duration::from_millis(8)));
}
fn ensure_key(url: &str, width: u32, height: u32) -> WebSurfaceEnsureKey {
fn ensure_key(
url: &str,
width: u32,
height: u32,
profile_id: &ProfileId,
) -> WebSurfaceEnsureKey {
WebSurfaceEnsureKey::new(
url.to_string(),
WebSurfaceSize { width, height, device_pixel_ratio_percent: 100 },
None,
profile_id.clone(),
ProfileDataMode::Persistent,
100,
&[],
)
+42 -30
View File
@@ -1,8 +1,10 @@
use ely_domain::{BrowserTab, TabId};
use gpui::{
AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, Styled, Window, canvas, div, native_surface, px, rgb,
AnyElement, App, Entity, ImageSource, InteractiveElement, IntoElement, MouseButton, ObjectFit,
ParentElement, Pixels, Styled, StyledImage, Window, canvas, div, img, px, rgb,
};
#[cfg(target_os = "macos")]
use gpui::{Corners, SurfaceLease, surface};
use super::{
ElyShell, web_surface_frame::WebSurfaceFrame,
@@ -11,15 +13,44 @@ use super::{
use ely_design_system::colors;
pub(super) fn render_ready_web_surface(
_frame: &WebSurfaceFrame,
frame: &WebSurfaceFrame,
tab: &BrowserTab,
state_entity: Entity<ElyShell>,
bottom_corner_radius: Pixels,
) -> AnyElement {
#[cfg(target_os = "macos")]
if let Some(hardware_surface) = frame.hardware_surface.as_ref() {
return render_web_surface(
tab,
state_entity,
surface(hardware_surface.pixel_buffer().clone())
.lease(SurfaceLease::from_arc(hardware_surface.clone()))
.size_full()
.corner_radii(Corners {
top_left: px(0.0),
top_right: px(0.0),
bottom_left: bottom_corner_radius,
bottom_right: bottom_corner_radius,
})
.object_fit(ObjectFit::Fill),
);
}
let Some(image) = frame.image.as_ref() else {
return render_web_surface(
tab,
state_entity,
error_page("Web surface frame did not include renderable pixels."),
);
};
render_web_surface(
tab,
state_entity.clone(),
render_native_web_surface(tab, state_entity, bottom_corner_radius),
state_entity,
div()
.size_full()
.overflow_hidden()
.rounded_bl(bottom_corner_radius)
.rounded_br(bottom_corner_radius)
.child(img(ImageSource::Render(image.clone())).size_full().object_fit(ObjectFit::Fill)),
)
}
@@ -30,8 +61,12 @@ pub(super) fn render_loading_web_surface(
) -> AnyElement {
render_web_surface(
tab,
state_entity.clone(),
render_native_web_surface(tab, state_entity, bottom_corner_radius),
state_entity,
div()
.size_full()
.overflow_hidden()
.rounded_bl(bottom_corner_radius)
.rounded_br(bottom_corner_radius),
)
}
@@ -85,29 +120,6 @@ fn render_web_surface(
.into_any_element()
}
fn render_native_web_surface(
tab: &BrowserTab,
state_entity: Entity<ElyShell>,
bottom_corner_radius: Pixels,
) -> impl IntoElement {
let tab_id = tab.id().clone();
let element_id = ElementId::Name(format!("web-surface-{}", tab_id.as_str()).into());
// `bottom_corner_radius` is wired through the GPUI `native_surface`
// patch to the AppKit overlay's `CALayer.cornerRadius`, so the
// canvas follows the same rounded edge as its containing panel
// instead of painting past it. The top corners stay flat because
// the topbar / pane header sits flush above the canvas.
native_surface(element_id, move |surface, bounds, window: &mut Window, cx: &mut App| {
let scale_factor = window.scale_factor();
state_entity.update(cx, |shell, cx| {
shell.record_external_web_surface(tab_id.clone(), bounds, scale_factor, surface, cx);
});
})
.size_full()
.rounded_bl(bottom_corner_radius)
.rounded_br(bottom_corner_radius)
}
fn render_input_overlay(
tab_id: TabId,
url: String,
+203 -86
View File
@@ -1,5 +1,5 @@
use std::{
collections::BTreeMap,
collections::{BTreeMap, VecDeque},
io,
sync::{Arc, Condvar, Mutex, mpsc},
thread::JoinHandle,
@@ -9,11 +9,10 @@ use crate::services::servo_live::{
ServoLiveClient, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame,
};
/// Blocking surface for the embedded Servo runtime.
/// Blocking transport for one profile-scoped Servo sidecar.
///
/// Production wraps [`ServoLiveClient`] directly; tests substitute a
/// fake. The contract: every call is blocking and may run for tens of
/// milliseconds. Implementations live on the worker thread.
/// Production wraps [`ServoLiveClient`]; tests substitute a fake. Every
/// call may block on sidecar IPC, so implementations live on a worker.
pub(super) trait LiveRuntimeClient {
fn ensure(
&mut self,
@@ -57,7 +56,7 @@ impl LiveRuntimeClientError {
impl std::fmt::Display for LiveRuntimeClientError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RuntimeUnavailable => formatter.write_str("servo live runtime is unavailable"),
Self::RuntimeUnavailable => formatter.write_str("servo sidecar runtime is unavailable"),
Self::Message(message) => formatter.write_str(message),
}
}
@@ -86,41 +85,57 @@ impl From<io::Error> for LiveRuntimeClientError {
/// Output of a worker request.
pub(super) enum WorkerResponse {
Frame { tab_id: String, frame: ServoLiveFrame },
Failed { tab_id: String, message: String },
Frame { generation: RequestGeneration, tab_id: String, frame: ServoLiveFrame },
Failed { generation: RequestGeneration, tab_id: String, message: String },
RuntimeUnavailable,
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(super) struct RequestGeneration(u64);
impl RequestGeneration {
pub(super) const fn new(value: u64) -> Self {
Self(value)
}
}
enum WorkerRequest {
Ensure(ServoLiveEnsureRequest),
Poll { tab_id: String },
Ensure { generation: RequestGeneration, request: ServoLiveEnsureRequest },
Poll { generation: RequestGeneration, tab_id: String },
}
impl WorkerRequest {
fn tab_id(&self) -> &str {
match self {
Self::Ensure { request, .. } => request.tab_id.as_str(),
Self::Poll { tab_id, .. } => tab_id.as_str(),
}
}
fn failure_parts(self) -> (RequestGeneration, String) {
match self {
Self::Ensure { generation, request } => (generation, request.tab_id),
Self::Poll { generation, tab_id } => (generation, tab_id),
}
}
}
struct WorkerQueue {
/// Latest request per tab. A new submission for a tab replaces any
/// earlier in-flight-but-not-yet-started request, so a flurry of
/// scrolls never piles up — the worker always processes the most
/// recent frame's worth of inputs.
pending: BTreeMap<String, WorkerRequest>,
/// Close orders. Sent after pending is cleared for that tab so the
/// worker never closes a tab that still has live frames in flight.
closes: Vec<String>,
/// True while the worker is processing a request. `wait_until_idle`
/// uses this alongside the queue emptiness to know when all
/// previously-submitted work has actually run.
/// Ordered inputs queue; idle and hover updates coalesce at the tail.
pending: BTreeMap<String, VecDeque<WorkerRequest>>,
/// Round-robin tab order, with each pending tab represented once.
ready_tabs: VecDeque<String>,
closes: VecDeque<String>,
in_flight: bool,
in_flight_tab: Option<String>,
initialization_failure: Option<String>,
shutdown: bool,
}
/// Owns a [`LiveRuntimeClient`] on a dedicated OS thread and exposes
/// a non-blocking API: submit ensure/poll/close, then drain responses.
///
/// The UI thread never blocks on Servo. Submissions push into a
/// coalescing queue (latest request per tab wins). The worker thread
/// drains the queue, runs the blocking calls, and emits responses on a
/// `std::sync::mpsc` channel that the UI thread reads with `try_recv`.
/// Runs one blocking profile client behind a non-blocking fair queue.
pub(super) struct LiveRuntimeWorker {
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
response_tx: mpsc::Sender<WorkerResponse>,
response_rx: mpsc::Receiver<WorkerResponse>,
thread: Option<JoinHandle<()>>,
}
@@ -132,46 +147,35 @@ impl LiveRuntimeWorker {
let queue = Arc::new((
Mutex::new(WorkerQueue {
pending: BTreeMap::new(),
closes: Vec::new(),
ready_tabs: VecDeque::new(),
closes: VecDeque::new(),
in_flight: false,
in_flight_tab: None,
initialization_failure: None,
shutdown: false,
}),
Condvar::new(),
));
let (response_tx, response_rx) = mpsc::channel();
let (init_tx, init_rx) = mpsc::channel();
let queue_for_thread = queue.clone();
let response_for_thread = response_tx.clone();
let thread = std::thread::Builder::new()
.name("ely-servo-runtime".to_string())
.spawn(move || {
let client = match client_factory() {
Ok(client) => {
let _ = init_tx.send(Ok(()));
client
}
Err(error) => {
let _ = init_tx.send(Err(error));
return;
}
};
run_worker(client, queue_for_thread, response_tx);
.spawn(move || match client_factory() {
Ok(client) => run_worker(client, queue_for_thread, response_for_thread),
Err(error) => {
fail_worker_initialization(queue_for_thread, &response_for_thread, error);
}
})
.map_err(|error| format!("failed to spawn servo live worker thread: {error}"))?;
match init_rx.recv() {
Ok(Ok(())) => {}
Ok(Err(error)) => {
let _ = thread.join();
return Err(error);
}
Err(error) => {
let _ = thread.join();
return Err(format!("servo live worker initialization failed: {error}"));
}
}
Ok(Self { queue, response_rx, thread: Some(thread) })
Ok(Self { queue, response_tx, response_rx, thread: Some(thread) })
}
pub(super) fn submit_ensure(&self, request: ServoLiveEnsureRequest) {
pub(super) fn submit_ensure(
&self,
generation: RequestGeneration,
request: ServoLiveEnsureRequest,
) {
let tab_id = request.tab_id.clone();
let (lock, cvar) = &*self.queue;
let mut q = match lock.lock() {
@@ -181,11 +185,31 @@ impl LiveRuntimeWorker {
if q.shutdown {
return;
}
q.pending.insert(tab_id, WorkerRequest::Ensure(request));
if let Some(message) = q.initialization_failure.clone() {
drop(q);
let _ = self.response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
return;
}
let mut request = WorkerRequest::Ensure { generation, request };
if let Some(pending) = q.pending.get_mut(&tab_id) {
let replace_tail = pending.back().is_some_and(|tail| {
matches!(tail, WorkerRequest::Poll { .. })
|| (!request_has_ordered_input(&request) && !request_has_ordered_input(tail))
});
if replace_tail && let Some(tail) = pending.back_mut() {
preserve_latest_hover(&mut request, tail);
*tail = request;
} else {
pending.push_back(request);
}
} else {
q.pending.insert(tab_id.clone(), VecDeque::from([request]));
q.ready_tabs.push_back(tab_id);
}
cvar.notify_one();
}
pub(super) fn submit_poll(&self, tab_id: String) -> bool {
pub(super) fn submit_poll(&self, generation: RequestGeneration, tab_id: String) -> bool {
let (lock, cvar) = &*self.queue;
let mut q = match lock.lock() {
Ok(guard) => guard,
@@ -194,15 +218,25 @@ impl LiveRuntimeWorker {
if q.shutdown {
return false;
}
if let Some(message) = q.initialization_failure.clone() {
drop(q);
let _ = self.response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
return true;
}
// A pending Ensure already produces the latest frame after its
// run; don't downgrade it to a Poll. Only insert if nothing is
// queued.
let inserted = match q.pending.entry(tab_id.clone()) {
std::collections::btree_map::Entry::Vacant(entry) => {
entry.insert(WorkerRequest::Poll { tab_id });
true
}
std::collections::btree_map::Entry::Occupied(_) => false,
let inserted = if q.pending.contains_key(&tab_id)
|| q.in_flight_tab.as_deref() == Some(tab_id.as_str())
{
false
} else {
q.pending.insert(
tab_id.clone(),
VecDeque::from([WorkerRequest::Poll { generation, tab_id: tab_id.clone() }]),
);
q.ready_tabs.push_back(tab_id);
true
};
cvar.notify_one();
inserted
@@ -217,8 +251,12 @@ impl LiveRuntimeWorker {
if q.shutdown {
return;
}
if q.initialization_failure.is_some() {
return;
}
q.pending.remove(&tab_id);
q.closes.push(tab_id);
q.ready_tabs.retain(|ready_tab_id| ready_tab_id != &tab_id);
q.closes.push_back(tab_id);
cvar.notify_one();
}
@@ -230,10 +268,7 @@ impl LiveRuntimeWorker {
out
}
/// Test-only barrier. Blocks the caller until the worker has
/// drained everything currently submitted. Production code never
/// waits — the whole point of the worker is that the UI thread
/// progresses without IPC latency.
/// Test-only barrier for all submitted work.
#[cfg(test)]
pub(super) fn wait_until_idle(&self) {
let (lock, cvar) = &*self.queue;
@@ -254,10 +289,12 @@ impl Drop for LiveRuntimeWorker {
fn drop(&mut self) {
{
let (lock, cvar) = &*self.queue;
if let Ok(mut q) = lock.lock() {
q.shutdown = true;
cvar.notify_all();
}
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
q.shutdown = true;
cvar.notify_all();
}
if let Some(handle) = self.thread.take() {
let _ = handle.join();
@@ -265,12 +302,41 @@ impl Drop for LiveRuntimeWorker {
}
}
fn fail_worker_initialization(
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
response_tx: &mpsc::Sender<WorkerResponse>,
message: String,
) {
let (lock, cvar) = &*queue;
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
q.initialization_failure = Some(message.clone());
q.ready_tabs.clear();
q.closes.clear();
q.in_flight = false;
q.in_flight_tab = None;
let pending = std::mem::take(&mut q.pending);
for request in pending.into_values().flatten() {
let (generation, tab_id) = request.failure_parts();
let _ = response_tx.send(WorkerResponse::Failed {
generation,
tab_id,
message: message.clone(),
});
}
let _ = response_tx.send(WorkerResponse::RuntimeUnavailable);
cvar.notify_all();
}
fn run_worker(
mut client: Box<dyn LiveRuntimeClient>,
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
response_tx: mpsc::Sender<WorkerResponse>,
) {
let (lock, cvar) = &*queue;
let mut last_dispatched_tab = None;
loop {
let work = {
let mut q = match lock.lock() {
@@ -278,6 +344,7 @@ fn run_worker(
Err(poisoned) => poisoned.into_inner(),
};
q.in_flight = false;
q.in_flight_tab = None;
cvar.notify_all();
while q.pending.is_empty() && q.closes.is_empty() && !q.shutdown {
q = match cvar.wait(q) {
@@ -288,19 +355,39 @@ fn run_worker(
if q.shutdown {
return;
}
let next = if let Some(close_id) = q.closes.pop() {
let next = if let Some(close_id) = q.closes.pop_front() {
Work::Close(close_id)
} else {
let key = match q.pending.keys().next().cloned() {
Some(key) => key,
if q.ready_tabs.len() > 1
&& q.ready_tabs.front() == last_dispatched_tab.as_ref()
&& let Some(last_tab) = q.ready_tabs.pop_front()
{
q.ready_tabs.push_back(last_tab);
}
let tab_id = match q.ready_tabs.pop_front() {
Some(tab_id) => tab_id,
None => continue,
};
let Some(request) = q.pending.remove(&key) else {
continue;
let (request, has_more) = match q.pending.get_mut(&tab_id) {
Some(pending) => match pending.pop_front() {
Some(request) => (request, !pending.is_empty()),
None => continue,
},
None => continue,
};
if has_more {
q.ready_tabs.push_back(tab_id.clone());
} else {
q.pending.remove(&tab_id);
}
last_dispatched_tab = Some(tab_id);
Work::Request(request)
};
q.in_flight = true;
q.in_flight_tab = match &next {
Work::Close(_) => None,
Work::Request(request) => Some(request.tab_id().to_string()),
};
next
};
@@ -309,25 +396,23 @@ fn run_worker(
let _ = client.close(tab_id);
false
}
Work::Request(WorkerRequest::Ensure(request)) => {
Work::Request(WorkerRequest::Ensure { generation, request }) => {
let tab_id = request.tab_id.clone();
dispatch_result(&response_tx, tab_id, client.ensure(request))
dispatch_result(&response_tx, generation, tab_id, client.ensure(request))
}
Work::Request(WorkerRequest::Poll { tab_id }) => {
Work::Request(WorkerRequest::Poll { generation, tab_id }) => {
let request_tab_id = tab_id.clone();
dispatch_result(&response_tx, request_tab_id, client.poll(tab_id))
dispatch_result(&response_tx, generation, request_tab_id, client.poll(tab_id))
}
};
if exit_after_dispatch {
// Release the in-flight flag and wake any flush waiter
// before exiting so wait_until_idle doesn't block forever
// on a thread that has already returned.
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
q.in_flight = false;
q.in_flight_tab = None;
cvar.notify_all();
return;
}
@@ -343,19 +428,20 @@ enum Work {
/// `true` when the worker should exit.
fn dispatch_result(
response_tx: &mpsc::Sender<WorkerResponse>,
generation: RequestGeneration,
tab_id: String,
result: Result<Option<ServoLiveFrame>, LiveRuntimeClientError>,
) -> bool {
match result {
Ok(Some(frame)) => {
let _ = response_tx.send(WorkerResponse::Frame { tab_id, frame });
let _ = response_tx.send(WorkerResponse::Frame { generation, tab_id, frame });
false
}
Ok(None) => false,
Err(error) => {
let unavailable = error.is_runtime_unavailable();
let message = error.to_string();
let _ = response_tx.send(WorkerResponse::Failed { tab_id, message });
let _ = response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
if unavailable {
let _ = response_tx.send(WorkerResponse::RuntimeUnavailable);
return true;
@@ -364,3 +450,34 @@ fn dispatch_result(
}
}
}
fn request_has_ordered_input(request: &WorkerRequest) -> bool {
let WorkerRequest::Ensure { request, .. } = request else {
return false;
};
request.scroll_delta_x != 0
|| request.scroll_delta_y != 0
|| request.scroll_point_x.is_some()
|| request.scroll_point_y.is_some()
|| request.click_x.is_some()
|| request.click_y.is_some()
|| request.typed_text.is_some()
}
fn preserve_latest_hover(latest: &mut WorkerRequest, previous: &WorkerRequest) {
let (
WorkerRequest::Ensure { request: latest, .. },
WorkerRequest::Ensure { request: previous, .. },
) = (latest, previous)
else {
return;
};
if latest.hover_x.is_none() && latest.hover_y.is_none() {
latest.hover_x = previous.hover_x;
latest.hover_y = previous.hover_y;
}
}
#[cfg(test)]
#[path = "web_surface_worker_tests.rs"]
mod tests;
@@ -0,0 +1,361 @@
use std::{
sync::{Arc, Mutex, mpsc},
time::Duration,
};
use crate::services::servo_live::{ServoLiveEnsureRequest, ServoLiveFrame};
use super::{
LiveRuntimeClient, LiveRuntimeClientError, LiveRuntimeWorker, RequestGeneration, WorkerResponse,
};
#[derive(Clone, Debug, Eq, PartialEq)]
enum RecordedInput {
Idle(String),
Scroll(String),
Click(String),
Text(String),
Hover(String),
}
struct SlowRecordingClient {
calls: Arc<Mutex<Vec<RecordedInput>>>,
first_started_tx: Option<mpsc::Sender<()>>,
release_first_rx: mpsc::Receiver<()>,
return_frame: bool,
}
struct GenerationClient;
impl LiveRuntimeClient for GenerationClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(Some(ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])))
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Err(LiveRuntimeClientError::Message("poll failed".to_string()))
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
impl LiveRuntimeClient for SlowRecordingClient {
fn ensure(
&mut self,
request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
let input = recorded_input(&request);
self.calls.lock().map_err(|_| "call recorder lock was poisoned".to_string())?.push(input);
if let Some(first_started_tx) = self.first_started_tx.take() {
first_started_tx.send(()).map_err(|error| error.to_string())?;
self.release_first_rx.recv().map_err(|error| error.to_string())?;
}
Ok(self.return_frame.then(|| ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])))
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
#[test]
fn queued_edge_inputs_for_one_tab_are_preserved() -> Result<(), String> {
let calls = Arc::new(Mutex::new(Vec::new()));
let (first_started_tx, first_started_rx) = mpsc::channel();
let (release_first_tx, release_first_rx) = mpsc::channel();
let client_calls = calls.clone();
let worker = LiveRuntimeWorker::new(move || {
Ok(Box::new(SlowRecordingClient {
calls: client_calls,
first_started_tx: Some(first_started_tx),
release_first_rx,
return_frame: false,
}))
})?;
worker.submit_ensure(
RequestGeneration::new(1),
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
first_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
for (generation, input) in [
RecordedInput::Scroll("tab-a".to_string()),
RecordedInput::Click("tab-a".to_string()),
RecordedInput::Text("tab-a".to_string()),
RecordedInput::Hover("tab-a".to_string()),
]
.into_iter()
.enumerate()
{
worker.submit_ensure(
RequestGeneration::new(generation as u64 + 2),
ensure_request("tab-a", input),
);
}
release_first_tx.send(()).map_err(|error| error.to_string())?;
worker.wait_until_idle();
assert_eq!(
*calls.lock().map_err(|_| "call recorder lock was poisoned".to_string())?,
vec![
RecordedInput::Idle("tab-a".to_string()),
RecordedInput::Scroll("tab-a".to_string()),
RecordedInput::Click("tab-a".to_string()),
RecordedInput::Text("tab-a".to_string()),
RecordedInput::Hover("tab-a".to_string()),
]
);
Ok(())
}
#[test]
fn queued_tabs_are_dispatched_round_robin() -> Result<(), String> {
let calls = Arc::new(Mutex::new(Vec::new()));
let (first_started_tx, first_started_rx) = mpsc::channel();
let (release_first_tx, release_first_rx) = mpsc::channel();
let client_calls = calls.clone();
let worker = LiveRuntimeWorker::new(move || {
Ok(Box::new(SlowRecordingClient {
calls: client_calls,
first_started_tx: Some(first_started_tx),
release_first_rx,
return_frame: false,
}))
})?;
worker.submit_ensure(
RequestGeneration::new(1),
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
first_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
worker.submit_ensure(
RequestGeneration::new(2),
ensure_request("tab-a", RecordedInput::Click("tab-a".to_string())),
);
worker.submit_ensure(
RequestGeneration::new(3),
ensure_request("tab-b", RecordedInput::Click("tab-b".to_string())),
);
worker.submit_ensure(
RequestGeneration::new(4),
ensure_request("tab-a", RecordedInput::Text("tab-a".to_string())),
);
release_first_tx.send(()).map_err(|error| error.to_string())?;
worker.wait_until_idle();
assert_eq!(
*calls.lock().map_err(|_| "call recorder lock was poisoned".to_string())?,
vec![
RecordedInput::Idle("tab-a".to_string()),
RecordedInput::Click("tab-b".to_string()),
RecordedInput::Click("tab-a".to_string()),
RecordedInput::Text("tab-a".to_string()),
]
);
Ok(())
}
#[test]
fn responses_keep_their_request_generation() -> Result<(), String> {
let worker = LiveRuntimeWorker::new(|| Ok(Box::new(GenerationClient)))?;
let ensure_generation = RequestGeneration::new(41);
let poll_generation = RequestGeneration::new(42);
worker.submit_ensure(
ensure_generation,
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
worker.wait_until_idle();
assert!(matches!(
worker.drain_responses().as_slice(),
[WorkerResponse::Frame { generation, tab_id, .. }]
if *generation == ensure_generation && tab_id == "tab-a"
));
assert!(worker.submit_poll(poll_generation, "tab-a".to_string()));
worker.wait_until_idle();
assert!(matches!(
worker.drain_responses().as_slice(),
[WorkerResponse::Failed { generation, tab_id, message }]
if *generation == poll_generation && tab_id == "tab-a" && message == "poll failed"
));
Ok(())
}
#[test]
fn hover_updates_coalesce_to_latest_state() -> Result<(), String> {
let calls = Arc::new(Mutex::new(Vec::new()));
let (first_started_tx, first_started_rx) = mpsc::channel();
let (release_first_tx, release_first_rx) = mpsc::channel();
let client_calls = calls.clone();
let worker = LiveRuntimeWorker::new(move || {
Ok(Box::new(SlowRecordingClient {
calls: client_calls,
first_started_tx: Some(first_started_tx),
release_first_rx,
return_frame: false,
}))
})?;
worker.submit_ensure(
RequestGeneration::new(1),
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
first_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
for generation in 2..=101 {
worker.submit_ensure(
RequestGeneration::new(generation),
ensure_request("tab-a", RecordedInput::Hover("tab-a".to_string())),
);
}
release_first_tx.send(()).map_err(|error| error.to_string())?;
worker.wait_until_idle();
assert_eq!(
*calls.lock().map_err(|_| "call recorder lock was poisoned".to_string())?,
vec![RecordedInput::Idle("tab-a".to_string()), RecordedInput::Hover("tab-a".to_string()),]
);
Ok(())
}
#[test]
fn poll_is_rejected_while_same_tab_ensure_is_in_flight() -> Result<(), String> {
let calls = Arc::new(Mutex::new(Vec::new()));
let (first_started_tx, first_started_rx) = mpsc::channel();
let (release_first_tx, release_first_rx) = mpsc::channel();
let worker = LiveRuntimeWorker::new(move || {
Ok(Box::new(SlowRecordingClient {
calls,
first_started_tx: Some(first_started_tx),
release_first_rx,
return_frame: true,
}))
})?;
let ensure_generation = RequestGeneration::new(1);
worker.submit_ensure(
ensure_generation,
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
first_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
assert!(!worker.submit_poll(RequestGeneration::new(2), "tab-a".to_string()));
release_first_tx.send(()).map_err(|error| error.to_string())?;
worker.wait_until_idle();
assert!(matches!(
worker.drain_responses().as_slice(),
[WorkerResponse::Frame { generation, .. }] if *generation == ensure_generation
));
Ok(())
}
#[test]
fn worker_creation_does_not_wait_for_client_factory() -> Result<(), String> {
let (factory_started_tx, factory_started_rx) = mpsc::channel();
let (release_factory_tx, release_factory_rx) = mpsc::channel();
let worker = LiveRuntimeWorker::new(move || {
factory_started_tx.send(()).map_err(|error| error.to_string())?;
release_factory_rx.recv().map_err(|error| error.to_string())?;
Ok(Box::new(GenerationClient))
})?;
worker.submit_ensure(
RequestGeneration::new(1),
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
factory_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
release_factory_tx.send(()).map_err(|error| error.to_string())?;
worker.wait_until_idle();
assert!(matches!(worker.drain_responses().as_slice(), [WorkerResponse::Frame { .. }]));
Ok(())
}
#[test]
fn factory_failure_rejects_queued_request() -> Result<(), String> {
let worker = LiveRuntimeWorker::new(|| -> Result<Box<dyn LiveRuntimeClient>, String> {
Err("injected factory failure".to_string())
})?;
worker.submit_ensure(
RequestGeneration::new(1),
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
worker.wait_until_idle();
let responses = worker.drain_responses();
assert!(responses.iter().any(|response| matches!(response, WorkerResponse::Failed { message, .. } if message == "injected factory failure")));
assert!(
responses.iter().any(|response| matches!(response, WorkerResponse::RuntimeUnavailable))
);
Ok(())
}
#[test]
#[expect(
clippy::expect_used,
clippy::panic,
reason = "this test deliberately poisons the worker queue mutex"
)]
fn poisoned_queue_still_shuts_down_worker() -> Result<(), String> {
let worker = LiveRuntimeWorker::new(|| Ok(Box::new(GenerationClient)))?;
let queue = worker.queue.clone();
let poison_result = std::thread::spawn(move || {
let (lock, _) = &*queue;
let _guard = lock.lock().expect("queue lock should begin healthy");
panic!("injected queue poison");
})
.join();
assert!(poison_result.is_err());
drop(worker);
Ok(())
}
fn recorded_input(request: &ServoLiveEnsureRequest) -> RecordedInput {
let tab_id = request.tab_id.clone();
if request.scroll_delta_x != 0 || request.scroll_delta_y != 0 {
RecordedInput::Scroll(tab_id)
} else if request.click_x.is_some() {
RecordedInput::Click(tab_id)
} else if request.typed_text.is_some() {
RecordedInput::Text(tab_id)
} else if request.hover_x.is_some() {
RecordedInput::Hover(tab_id)
} else {
RecordedInput::Idle(tab_id)
}
}
fn ensure_request(tab_id: &str, input: RecordedInput) -> ServoLiveEnsureRequest {
let scroll = matches!(input, RecordedInput::Scroll(_));
let click = matches!(input, RecordedInput::Click(_));
let text = matches!(input, RecordedInput::Text(_));
let hover = matches!(input, RecordedInput::Hover(_));
ServoLiveEnsureRequest {
tab_id: tab_id.to_string(),
profile_id: "profile".to_string(),
url: "https://example.com/".to_string(),
width: 640,
height: 480,
page_zoom_percent: 100,
device_pixel_ratio: 1.0,
scroll_delta_x: i32::from(scroll),
scroll_delta_y: i32::from(scroll),
scroll_point_x: scroll.then_some(1),
scroll_point_y: scroll.then_some(1),
click_x: click.then_some(1),
click_y: click.then_some(1),
hover_x: hover.then_some(1),
hover_y: hover.then_some(1),
typed_text: text.then(|| "text".to_string()),
site_permissions: Vec::new(),
}
}
+7
View File
@@ -70,6 +70,7 @@ pub struct InitialBrowserConfig {
pub profile_name: String,
pub profile_color_hex: u32,
pub profile_kind: ProfileKind,
pub profile_id: Option<ProfileId>,
pub new_tab_destination: NewTabDestination,
}
@@ -82,6 +83,7 @@ impl InitialBrowserConfig {
profile_name: "Default".to_string(),
profile_color_hex: 0x26251e,
profile_kind: ProfileKind::Standard,
profile_id: None,
new_tab_destination: NewTabDestination::default(),
})
}
@@ -94,6 +96,7 @@ impl InitialBrowserConfig {
profile_name: "Private".to_string(),
profile_color_hex: 0x807d72,
profile_kind: ProfileKind::Private,
profile_id: None,
new_tab_destination: NewTabDestination::default(),
})
}
@@ -181,6 +184,10 @@ impl BrowserCore {
pub fn new(config: InitialBrowserConfig) -> Result<Self, CoreError> {
let profile =
Profile::new(config.profile_name, config.profile_color_hex, config.profile_kind);
let profile = match config.profile_id {
Some(profile_id) => Profile::restore(profile_id, profile),
None => profile,
};
let active_profile_id = profile.id().clone();
let space = Space::new(
config.space_name,
+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()
}