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
@@ -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(),
}
}