This commit is contained in:
2026-05-18 13:58:36 -04:00
parent d076dad356
commit 68a4507dbe
143 changed files with 15715 additions and 7204 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
- name: Test Servo engine host
run: cargo test -p ely_servo_host --features servo-engine --test software_host
- name: Test PRD site compatibility
- name: Test embedded PRD site compatibility
run: scripts/verify_prd_site_rendering.sh
- name: Audit source file size
Generated
+4 -162
View File
@@ -2243,24 +2243,17 @@ name = "ely_app"
version = "0.1.0"
dependencies = [
"ahash",
"core-foundation 0.10.0",
"core-video",
"directories",
"ed25519-dalek",
"ely_browser_core",
"ely_design_system",
"ely_domain",
"ely_servo_host",
"ely_sync_client",
"gpui",
"gpui-component",
"gpui-component-assets",
"image",
"io-surface",
"mach2",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
"objc2-io-surface",
"serde",
"serde_json",
"sha2",
@@ -2270,7 +2263,6 @@ dependencies = [
"tracing-subscriber",
"ureq",
"url",
"uuid",
]
[[package]]
@@ -2309,16 +2301,12 @@ dependencies = [
"dpi",
"ely_domain",
"euclid",
"gleam",
"glow",
"image",
"log",
"mach2",
"objc2-io-surface",
"naga 26.0.0",
"raw-window-handle",
"rustls",
"serde",
"serde_json",
"servo",
"surfman",
"thiserror 2.0.18",
"url",
]
@@ -2498,17 +2486,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
dependencies = [
"serde",
"serde_core",
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -3521,8 +3498,6 @@ dependencies = [
[[package]]
name = "gpui_http_client"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23822b0a6d2c5e6a42507980a0ab3848610ea908942c8ef98187f646f690335e"
dependencies = [
"anyhow",
"async-compression",
@@ -3593,8 +3568,6 @@ dependencies = [
[[package]]
name = "gpui_sum_tree"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4f3bedd573fafafa13d1200b356c588cf094fb2786e3684bb3f5ea59b549fa9"
dependencies = [
"arrayvec",
"log",
@@ -3604,8 +3577,6 @@ dependencies = [
[[package]]
name = "gpui_util"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68faea25903ae524de9af83990b9aa51bcbc8dd085929ac0aea7fd41905e05c3"
dependencies = [
"anyhow",
"async-fs",
@@ -5288,7 +5259,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
dependencies = [
"serde_core",
"value-bag",
]
@@ -6142,8 +6112,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.1",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -6158,7 +6126,6 @@ dependencies = [
"libc",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
@@ -8052,15 +8019,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_fmt"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_json"
version = "1.0.149"
@@ -10186,84 +10144,6 @@ dependencies = [
"x11-dl",
]
[[package]]
name = "sval"
version = "2.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eb9318255ebd817902d7e279d8f8e39b35b1b9954decd5eb9ea0e30e5fd2b6a"
[[package]]
name = "sval_buffer"
version = "2.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12571299185e653fdb0fbfe36cd7f6529d39d4e747a60b15a3f34574b7b97c61"
dependencies = [
"sval",
"sval_ref",
]
[[package]]
name = "sval_dynamic"
version = "2.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39526f24e997706c0de7f03fb7371f7f5638b66a504ded508e20ad173d0a3677"
dependencies = [
"sval",
]
[[package]]
name = "sval_fmt"
version = "2.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "933dd3bb26965d682280fcc49400ac2a05036f4ee1e6dbd61bf8402d5a5c3a54"
dependencies = [
"itoa",
"ryu",
"sval",
]
[[package]]
name = "sval_json"
version = "2.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0cda08f6d5c9948024a6551077557b1fdcc3880ff2f20ae839667d2ec2d87ed"
dependencies = [
"itoa",
"ryu",
"sval",
]
[[package]]
name = "sval_nested"
version = "2.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88d49d5e6c1f9fd0e53515819b03a97ca4eb1bff5c8ee097c43391c09ecfb19f"
dependencies = [
"sval",
"sval_buffer",
"sval_ref",
]
[[package]]
name = "sval_ref"
version = "2.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14f876c5a78405375b4e19cbb9554407513b59c93dea12dc6a4af4e1d30899ca"
dependencies = [
"sval",
]
[[package]]
name = "sval_serde"
version = "2.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f9ccd3b7f7200239a655e517dd3fd48d960b9111ad24bd6a5e055bef17607c7"
dependencies = [
"serde_core",
"sval",
"sval_nested",
]
[[package]]
name = "svg_fmt"
version = "0.4.5"
@@ -11061,12 +10941,6 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.20.0"
@@ -11381,36 +11255,6 @@ name = "value-bag"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0"
dependencies = [
"value-bag-serde1",
"value-bag-sval2",
]
[[package]]
name = "value-bag-serde1"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16530907bfe2999a1773ca5900a65101e092c70f642f25cc23ca0c43573262c5"
dependencies = [
"erased-serde",
"serde_core",
"serde_fmt",
]
[[package]]
name = "value-bag-sval2"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d00ae130edd690eaa877e4f40605d534790d1cf1d651e7685bd6a144521b251f"
dependencies = [
"sval",
"sval_buffer",
"sval_dynamic",
"sval_fmt",
"sval_json",
"sval_ref",
"sval_serde",
]
[[package]]
name = "vcpkg"
@@ -13304,8 +13148,6 @@ dependencies = [
[[package]]
name = "zed-sum-tree"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35"
dependencies = [
"arrayvec",
"log",
+5 -1
View File
@@ -37,8 +37,12 @@ url = "2.5.4"
uuid = { version = "1.12.1", features = ["v7"] }
[patch.crates-io]
# Local GPUI 0.2.2 patch: macOS `surface(CVPixelBuffer)` accepts Servo's BGRA IOSurfaces.
# Local GPUI 0.2.2 patch used by the native shell while the upstream crate is pinned.
gpui = { path = "third_party/gpui" }
gpui_http_client = { path = "third_party/gpui_support/gpui_http_client" }
gpui_sum_tree = { path = "third_party/gpui_support/gpui_sum_tree" }
gpui_util = { path = "third_party/gpui_support/gpui_util" }
zed-sum-tree = { path = "third_party/gpui_support/zed-sum-tree" }
[workspace.lints.rust]
unsafe_code = "deny"
+2 -2
View File
@@ -14,8 +14,8 @@ cargo fmt --all --check
cargo check --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --all-targets
cargo check -p ely_servo_host --features servo-engine,hardware-render --all-targets
cargo clippy -p ely_servo_host --features servo-engine,hardware-render --all-targets -- -D warnings
cargo check -p ely_servo_host --features servo-engine --all-targets
cargo clippy -p ely_servo_host --features servo-engine --all-targets -- -D warnings
cargo test -p ely_servo_host --features servo-engine --test software_host
scripts/verify_prd_site_rendering.sh
scripts/verify_windows_app_manifest.sh
+1 -14
View File
@@ -15,6 +15,7 @@ ed25519-dalek.workspace = true
ely_browser_core = { path = "../ely_browser_core" }
ely_design_system = { path = "../ely_design_system" }
ely_domain = { path = "../ely_domain" }
ely_servo_host = { path = "../ely_servo_host", features = ["servo-engine"] }
ely_sync_client = { path = "../ely_sync_client" }
gpui.workspace = true
gpui-component.workspace = true
@@ -29,20 +30,6 @@ tracing-subscriber.workspace = true
ureq.workspace = true
url.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
# Pin to the same minor as gpui 0.2.2 so we share its `CVPixelBuffer`
# type — gpui's `Surface::From<CVPixelBuffer>` only matches the
# version it built against.
core-video = "0.4"
io-surface = "0.16"
mach2 = "0.6"
objc2 = "0.6"
objc2-core-foundation = { version = "0.3.2", features = ["CFBase", "CFDictionary", "CFNumber", "CFString"] }
objc2-foundation = { version = "0.3.1", features = ["NSDictionary", "NSString", "NSValue"] }
objc2-io-surface = "0.3.2"
uuid.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
+8 -6
View File
@@ -324,15 +324,17 @@ fn quit(_: &Quit, cx: &mut App) {
cx.quit();
}
const DEFAULT_TRACING_FILTER: &str = "ely_app=info,ely_servo_host=warn,ely=info";
/// Install the global tracing subscriber. `RUST_LOG` drives the
/// filter; absent it, only `warn` and above leak through so day-to-day
/// runs stay quiet. The perf target is silent by default —
/// `RUST_LOG=ely::servo::perf=info` flips on the frame-time stream
/// without touching the rest of the app. We swallow re-init errors so
/// tests that share the process state with main don't blow up.
/// filter; absent it, app-owned targets stay visible and Servo internals
/// stay opt-in. `RUST_LOG=ely::servo::perf=info` flips on the frame-time
/// stream. We swallow re-init errors so tests that share process state
/// with main can reuse this path.
fn init_tracing() {
use tracing_subscriber::{EnvFilter, fmt};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"));
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(DEFAULT_TRACING_FILTER));
let _ = fmt().with_env_filter(filter).with_target(true).try_init();
}
@@ -1,233 +0,0 @@
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_destroy, mach_port_insert_right},
message::{
MACH_MSG_PORT_DESCRIPTOR, MACH_MSG_SUCCESS, MACH_MSG_TIMEOUT_NONE, MACH_MSG_TYPE_MAKE_SEND,
MACH_RCV_MSG, MACH_RCV_TIMED_OUT, MACH_RCV_TIMEOUT, mach_msg, mach_msg_body_t,
mach_msg_header_t, mach_msg_port_descriptor_t, mach_msg_trailer_t,
},
port::{MACH_PORT_NULL, MACH_PORT_RIGHT_RECEIVE, mach_port_t},
traps::mach_task_self,
};
use thiserror::Error;
use uuid::Uuid;
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
const SERVICE_PREFIX: &str = "com.ely.browser.iosurface";
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);
}
self.pending_ports.insert(received.surface_id, received.mach_port);
}
}
fn receive_one(
&self,
timeout: Duration,
) -> Result<Option<ReceivedSurfacePort>, IOSurfaceMachError> {
#[expect(unsafe_code)]
let mut received_message: ReceivedIOSurfacePortMessage = unsafe { mem::zeroed() };
let timeout_ms = timeout_millis(timeout);
#[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_ms,
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 {
destroy_message(message);
return Err(IOSurfaceMachError::UnexpectedMessage {
message_id: message.header.msgh_id,
});
}
if message.body.msgh_descriptor_count != 1
|| message.surface_port.type_ != MACH_MSG_PORT_DESCRIPTOR as u8
|| message.surface_port.name == MACH_PORT_NULL
{
destroy_message(message);
return Err(IOSurfaceMachError::InvalidMessage);
}
Ok(Some(ReceivedSurfacePort {
surface_id: message.surface_id,
mach_port: message.surface_port.name,
}))
}
}
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 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 _ = mach2::mach_port::mach_port_deallocate(task, port);
}
}
@@ -1,306 +0,0 @@
//! macOS-only import of cross-process IOSurface handles into
//! `CVPixelBuffer`s that preserve the sidecar's IOSurface identity.
//!
//! `T10.4` originally imported the IOSurface into an `MTLTexture`
//! directly. GPUI 0.2.2 exposes `Window::paint_surface` /
//! `elements::surface::Surface` for `CVPixelBuffer`; the local GPUI
//! patch adds a BGRA fragment pipeline for Servo's hardware
//! IOSurfaces, so this cache is the renderer-side handoff point.
//!
//! Lifetime contract:
//!
//! * `IOSurfaceCreateMachPort` (sidecar side) gives the receiver a
//! send right whose refcount is 1 in our task. After we resolve
//! the surface and wrap it in a CVPixelBuffer, the mach port has
//! done its job.
//! * `CVPixelBufferCreateWithIOSurface` retains the IOSurface for
//! the pixel buffer's lifetime. We `mach_port_deallocate`
//! immediately so the receiver process doesn't accumulate idle
//! mach send rights.
//! * Dropping `IOSurfaceCache` releases every cached
//! `CVPixelBuffer`, which in turn releases each retained
//! IOSurface. The sidecar still holds its own retain via surfman,
//! so the IOSurface itself outlives our cache for as long as the
//! sidecar keeps painting.
#![cfg(target_os = "macos")]
use std::collections::HashMap;
use core_foundation::base::TCFType as _;
use core_video::pixel_buffer::CVPixelBuffer;
#[allow(deprecated)]
use io_surface::IOSurface;
use thiserror::Error;
/// Cache of imported `CVPixelBuffer`s keyed by IOSurface identity.
/// Constructed lazily by the renderer-side client on the first
/// hardware-path frame.
pub(crate) struct IOSurfaceCache {
pixel_buffers: HashMap<u64, CachedPixelBuffer>,
}
// SAFETY: CVPixelBuffer wraps CVPixelBufferRef, a CoreFoundation type
// Apple documents as safe to share across threads. The cache is owned
// by ServoLiveClient which now lives on the LiveRuntimeWorker thread,
// so the auto-Send check (rightly) rejects the raw pointer inside the
// crate's `CVPixelBuffer`. The pointer is atomically refcounted CFTypeRef
// and only mutated via Mach IPC, which is itself thread-safe.
#[expect(unsafe_code)]
unsafe impl Send for IOSurfaceCache {}
struct CachedPixelBuffer {
pixel_buffer: CVPixelBuffer,
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 },
}
impl IOSurfaceCache {
pub fn new() -> Self {
Self { pixel_buffers: HashMap::new() }
}
/// Import an IOSurface published by the sidecar's
/// `surface_handle` field. Idempotent on `surface_id` plus pixel
/// dimensions: duplicate handles for the same sized IOSurface are
/// discarded, while a resized IOSurface that reuses the same
/// `surface_id` replaces the cached pixel buffer.
#[cfg(test)]
pub fn import(
&mut self,
mach_port_name: u32,
surface_id: u64,
) -> Result<(), SurfaceImportError> {
let pixel_buffer = import_pixel_buffer_from_mach_port(mach_port_name)?;
self.insert_pixel_buffer(surface_id, pixel_buffer);
Ok(())
}
pub(crate) fn insert_pixel_buffer(&mut self, surface_id: u64, pixel_buffer: CVPixelBuffer) {
let width = pixel_buffer.get_width() as u32;
let height = pixel_buffer.get_height() as u32;
if self
.pixel_buffers
.get(&surface_id)
.is_some_and(|cached| cached.width == width && cached.height == height)
{
return;
}
self.pixel_buffers.insert(surface_id, CachedPixelBuffer { pixel_buffer, width, height });
}
/// Look up an already-imported pixel buffer by `surface_id`. The
/// receiver's per-frame `current_surface_id` selects which of the
/// swap chain's rotating front/back surfaces to sample. Returns a
/// clone (CVPixelBuffer is reference-counted; cloning is a cheap
/// atomic increment) so the caller can hand it to GPUI's
/// `surface(...)` element without holding a borrow on the cache.
pub fn pixel_buffer_for(&self, surface_id: u64) -> Option<CVPixelBuffer> {
self.pixel_buffers.get(&surface_id).map(|cached| cached.pixel_buffer.clone())
}
pub(crate) fn surface_ids(&self) -> Vec<u64> {
self.pixel_buffers.keys().copied().collect()
}
#[cfg(test)]
pub fn cached_surface_count(&self) -> usize {
self.pixel_buffers.len()
}
}
pub(crate) fn import_pixel_buffer_from_mach_port(
mach_port_name: u32,
) -> Result<CVPixelBuffer, 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<CVPixelBuffer, SurfaceImportError> {
let Some(iosurface) = objc2_io_surface::IOSurfaceRef::lookup_from_mach_port(mach_port_name)
else {
return Err(SurfaceImportError::LookupFailed { port: mach_port_name });
};
// Both objc2-io-surface and the legacy `io_surface` crate wrap
// the same C `__IOSurface` pointer. CVPixelBufferCreateWithIOSurface
// (via core-video) expects the legacy crate's wrapper. Reach for
// the raw pointer and let TCFType CFRetain it independently so
// both Rust handles can drop without double-freeing.
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)
}
};
CVPixelBuffer::from_io_surface(&io_surface_view, None)
.map_err(|status| SurfaceImportError::PixelBufferBuildFailed { status })
}
/// Release one send right against the mach port we received. The
/// IOSurface itself stays alive because the `CVPixelBuffer` (or the
/// sidecar's surfman) still retain it.
fn deallocate_mach_port(port: u32) {
#[expect(unsafe_code)]
let result = unsafe { mach_port_deallocate(mach_task_self_, port) };
if result != KERN_SUCCESS {
tracing::warn!(
target: "ely::servo::iosurface",
mach_port_name = port,
kern_result = result,
"mach_port_deallocate returned non-success",
);
}
}
const KERN_SUCCESS: i32 = 0;
#[expect(unsafe_code)]
unsafe extern "C" {
/// Global mach task port for the running process. Defined in
/// `mach/mach_init.h` as `extern mach_port_t mach_task_self_;`.
static mach_task_self_: u32;
/// Releases one send right against `name` within `task`. We only
/// ever call this with our own task; the IOSurface keeps its
/// retain via the CVPixelBuffer so this just frees our port slot.
fn mach_port_deallocate(task: u32, name: u32) -> i32;
}
#[cfg(test)]
mod tests {
use super::IOSurfaceCache;
use objc2_core_foundation::{
CFDictionary, CFIndex, CFNumber, CFRetained, CFString, kCFAllocatorDefault,
kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks,
};
use objc2_io_surface::{
IOSurfaceRef, kIOSurfaceBytesPerElement, kIOSurfaceBytesPerRow, kIOSurfaceHeight,
kIOSurfacePixelFormat, kIOSurfaceWidth,
};
use std::os::raw::c_void;
/// Build a CPU-backed IOSurface from scratch, the same way
/// surfman's macOS backend does.
///
/// BGRA8 (four-cc '32BGRA'), width + height + bytes_per_element
/// + bytes_per_row live in a Core Foundation properties dictionary.
///
/// The pointer-casts mirror
/// `surfman::platform::macos::system::surface::create_io_surface`.
fn build_local_iosurface(width: u32, height: u32) -> Result<CFRetained<IOSurfaceRef>, String> {
let pixel_format: i32 = i32::from_be_bytes(*b"BGRA");
let bytes_per_element: i32 = 4;
let bytes_per_row: i32 = (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 keys_ptr: *mut *const c_void = keys.as_ptr() as *mut *const c_void;
let values_ptr: *mut *const c_void = values.as_ptr() as *mut *const c_void;
let properties = CFDictionary::new(
kCFAllocatorDefault,
keys_ptr,
values_ptr,
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_local_iosurface_into_pixel_buffer() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let iosurface = build_local_iosurface(64, 48)?;
let mach_port = iosurface.create_mach_port();
assert!(mach_port != 0, "IOSurfaceCreateMachPort must yield a real port");
let surface_id: u64 = 0xDEAD_BEEFu64;
cache.import(mach_port, surface_id).map_err(|error| error.to_string())?;
let pixel_buffer = cache
.pixel_buffer_for(surface_id)
.ok_or_else(|| "imported pixel buffer was missing".to_string())?;
assert_eq!(
pixel_buffer.get_width() as u32,
64,
"CVPixelBuffer width must match the source IOSurface",
);
assert_eq!(
pixel_buffer.get_height() as u32,
48,
"CVPixelBuffer height must match the source IOSurface",
);
assert_eq!(cache.cached_surface_count(), 1);
Ok(())
}
#[test]
fn second_import_with_same_surface_id_is_idempotent() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let iosurface = build_local_iosurface(64, 48)?;
let port_a = iosurface.create_mach_port();
let port_b = iosurface.create_mach_port();
assert!(port_a != 0 && port_b != 0 && port_a != port_b);
cache.import(port_a, 0xAAAA_AAAA).map_err(|error| error.to_string())?;
// Same surface_id → defensive dedup path; port_b is deallocated
// without minting a duplicate CVPixelBuffer.
cache.import(port_b, 0xAAAA_AAAA).map_err(|error| error.to_string())?;
assert_eq!(cache.cached_surface_count(), 1);
Ok(())
}
#[test]
fn same_surface_id_with_changed_dimensions_replaces_pixel_buffer() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let initial = build_local_iosurface(64, 48)?;
let resized = build_local_iosurface(96, 72)?;
let surface_id = 0xBBBB_BBBB;
cache.import(initial.create_mach_port(), surface_id).map_err(|error| error.to_string())?;
cache.import(resized.create_mach_port(), surface_id).map_err(|error| error.to_string())?;
let pixel_buffer = cache
.pixel_buffer_for(surface_id)
.ok_or_else(|| "resized pixel buffer was missing".to_string())?;
assert_eq!(pixel_buffer.get_width() as u32, 96);
assert_eq!(pixel_buffer.get_height() as u32, 72);
assert_eq!(cache.cached_surface_count(), 1);
Ok(())
}
}
-5
View File
@@ -1,16 +1,11 @@
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 mod servo_live;
pub(crate) mod servo_profile_data;
mod servo_sidecar_command;
pub(crate) use servo_profile_data::ProfileDataMode;
+262 -248
View File
@@ -1,286 +1,300 @@
use std::{
io::{BufRead, BufReader, Read, Write},
path::PathBuf,
process::{Child, ChildStdin, ChildStdout, Stdio},
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, ServoSurfaceSize, SoftwareServoHost,
};
#[cfg(target_os = "macos")]
#[path = "servo_live_iosurface_importer.rs"]
mod iosurface_importer;
#[path = "servo_live_types.rs"]
mod types;
/// Environment variable that lets the user pick the rendering context
/// kind used by the spawned sidecar. Accepted values: `software`
/// and `hardware`. macOS defaults to the hardware path and receives
/// IOSurface mach send rights over a side Mach channel.
#[path = "servo_live_wire.rs"]
mod wire;
pub(crate) use types::{
ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveSitePermission,
};
use super::servo_sidecar_command::{
SidecarRenderingContext, default_sidecar_command, rendering_context_from_env,
};
use wire::{
LiveRequest, LiveResponse, LiveSurfaceHandle, log_frame_perf, log_iosurface_current,
log_iosurface_handle,
};
#[cfg(target_os = "macos")]
use super::iosurface_metal::IOSurfaceCache;
#[cfg(target_os = "macos")]
use iosurface_importer::{IOSurfaceImportResult, IOSurfaceImportWorker};
pub(crate) struct ServoLiveClient {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
/// Cache of imported `CVPixelBuffer`s keyed by surface_id. Built
/// lazily on the first `surface_handle` the sidecar publishes —
/// software-path tabs never trigger construction.
#[cfg(target_os = "macos")]
iosurface_cache: IOSurfaceCache,
#[cfg(target_os = "macos")]
iosurface_importer: Option<IOSurfaceImportWorker>,
host: SoftwareServoHost,
sessions: BTreeMap<String, DirectWebViewSession>,
}
impl ServoLiveClient {
pub fn new(profile_data_dir: PathBuf) -> Result<Self, ServoLiveError> {
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 rendering_context = rendering_context_from_env();
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 mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(ServoLiveError::Command)?;
let stdin = child.stdin.take().ok_or(ServoLiveError::PipeUnavailable { name: "stdin" })?;
let stdout =
child.stdout.take().ok_or(ServoLiveError::PipeUnavailable { name: "stdout" })?;
Ok(Self {
child,
stdin,
stdout: BufReader::new(stdout),
#[cfg(target_os = "macos")]
iosurface_cache: IOSurfaceCache::new(),
#[cfg(target_os = "macos")]
iosurface_importer,
})
let host = SoftwareServoHost::new_with_config_dir(
ServoSurfaceSize::new(1, 1),
Some(profile_data_dir),
)?;
Ok(Self { host, sessions: BTreeMap::new() })
}
pub fn ensure(
&mut self,
request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
#[cfg(target_os = "macos")]
self.drain_iosurface_imports()?;
let ready_surface_ids = self.ready_surface_ids();
self.request(LiveRequest::Ensure {
tab_id: request.tab_id,
profile_id: request.profile_id,
url: request.url,
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)?;
self.host.tick();
if !self.session_uses_native_surface(&request.tab_id) {
return Err(ServoLiveError::NativeSurfaceUnavailable);
}
self.host.paint_without_readback_with_completion(&webview_id, false)?;
let frame = self.frame_from_session(&request.tab_id, &webview_id)?;
Ok(Some(frame))
}
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();
if !self.host.snapshot(&webview_id)?.has_pending_frame() {
return Ok(None);
}
if !uses_native_surface {
return Err(ServoLiveError::NativeSurfaceUnavailable);
}
self.host.paint_without_readback_with_completion(&webview_id, false)?;
self.frame_from_session(&tab_id, &webview_id).map(Some)
}
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(())
}
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)
{
self.host.close_webview(&session.webview_id);
}
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);
}
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,
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,
})
native_surface_id,
},
);
Ok(webview_id)
}
pub fn poll(&mut self, tab_id: String) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
#[cfg(target_os = "macos")]
self.drain_iosurface_imports()?;
let ready_surface_ids = self.ready_surface_ids();
self.request(LiveRequest::Poll { tab_id, ready_surface_ids })
}
pub fn close(&mut self, tab_id: String) -> Result<(), ServoLiveError> {
self.request(LiveRequest::Close { tab_id }).map(|_| ())
}
fn request(&mut self, request: LiveRequest) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
serde_json::to_writer(&mut self.stdin, &request)?;
self.stdin.write_all(b"\n").map_err(ServoLiveError::Command)?;
self.stdin.flush().map_err(ServoLiveError::Command)?;
let mut line = String::new();
let bytes = self.stdout.read_line(&mut line).map_err(ServoLiveError::Command)?;
if bytes == 0 {
return Err(ServoLiveError::SidecarExited);
}
let response: LiveResponse = serde_json::from_str(&line)?;
if let Some(error) = response.error {
return Err(ServoLiveError::SidecarFailed { message: error });
}
let surface_handle = response.surface_handle;
let current_surface_id = response.current_surface_id;
if let Some(perf) = response.perf.as_ref() {
log_frame_perf(perf);
}
if let Some(handle) = surface_handle.as_ref() {
log_iosurface_handle(handle);
}
if let Some(surface_id) = current_surface_id {
log_iosurface_current(surface_id);
}
let Some(report) = response.frame else {
return Ok(None);
};
// Sanity bound the byte count advertised by the sidecar
// header so a buggy or hostile sidecar can't park us on
// `read_exact` for an arbitrarily-sized buffer. The honest
// upper limit is `width * height * 4` (RGBA8); `0` is the
// explicit "hardware path active, sample the IOSurface"
// signal; any other byte count is a protocol violation.
let pixel_byte_count =
(report.width as u64).saturating_mul(report.height as u64).saturating_mul(4);
let advertised = report.rgba_byte_count as u64;
if advertised != 0 && advertised != pixel_byte_count {
return Err(ServoLiveError::FrameBudgetExceeded {
advertised: report.rgba_byte_count,
pixel_budget: pixel_byte_count,
width: report.width,
height: report.height,
});
}
// Raw frame bytes follow the JSON header on the same pipe for
// software frames. `read_exact` drains BufReader's buffer first
// (the line read never crosses the `\n` boundary) and then
// pulls the rest straight from the child's stdout.
let mut rgba_bytes = vec![0u8; report.rgba_byte_count];
if report.rgba_byte_count > 0 {
self.stdout.read_exact(&mut rgba_bytes).map_err(ServoLiveError::FrameRead)?;
}
let has_software_payload = report.rgba_byte_count > 0;
let mut frame = ServoLiveFrame::from_parts(report, rgba_bytes);
#[cfg(target_os = "macos")]
if let Some(handle) = surface_handle.as_ref() {
self.queue_iosurface_handle(*handle)?;
self.drain_iosurface_imports()?;
}
#[cfg(target_os = "macos")]
if let Some(surface_id) = current_surface_id {
let pixel_buffer = self.iosurface_cache.pixel_buffer_for(surface_id);
if pixel_buffer.is_none() && !has_software_payload {
return Ok(None);
}
frame.set_pixel_buffer(pixel_buffer);
}
Ok(Some(frame))
}
}
#[cfg(not(target_os = "macos"))]
impl ServoLiveClient {
fn ready_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 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(),
});
};
importer.submit(handle).map_err(|failure| ServoLiveError::IOSurfaceImportFailed {
surface_id: failure.surface_id,
mach_port_name: failure.mach_port_name,
message: failure.message,
})
}
fn drain_iosurface_imports(&mut self) -> Result<(), ServoLiveError> {
let Some(importer) = self.iosurface_importer.as_ref() else {
fn apply_viewport(
&mut self,
request: &ServoLiveEnsureRequest,
webview_id: &WebViewId,
) -> Result<(), ServoLiveError> {
let Some(session) = self.sessions.get_mut(&request.tab_id) else {
return Ok(());
};
for result in importer.drain() {
match result {
IOSurfaceImportResult::Imported(imported) => {
self.iosurface_cache
.insert_pixel_buffer(imported.surface_id, imported.pixel_buffer);
tracing::info!(
target: "ely::servo::iosurface",
surface_id = imported.surface_id,
width = imported.width,
height = imported.height,
"imported IOSurface into CVPixelBuffer cache",
);
if session.width != request.width || session.height != request.height {
self.host.resize(ResizeRequest {
webview_id: webview_id.clone(),
width: request.width,
height: request.height,
})?;
session.width = request.width;
session.height = request.height;
}
IOSurfaceImportResult::Failed(failure) => {
tracing::warn!(
target: "ely::servo::iosurface",
surface_id = failure.surface_id,
width = failure.width,
height = failure.height,
mach_port_name = failure.mach_port_name,
message = %failure.message,
"IOSurface import worker failed",
);
return Err(ServoLiveError::IOSurfaceImportFailed {
surface_id: failure.surface_id,
mach_port_name: failure.mach_port_name,
message: failure.message,
});
if session.device_pixel_ratio != request.device_pixel_ratio {
self.host.set_hidpi_scale(HidpiScaleRequest {
webview_id: webview_id.clone(),
scale_factor: request.device_pixel_ratio,
})?;
session.device_pixel_ratio = request.device_pixel_ratio;
}
if session.page_zoom_percent != request.page_zoom_percent {
self.host.set_page_zoom(PageZoomRequest {
webview_id: webview_id.clone(),
zoom_factor: f32::from(request.page_zoom_percent) / 100.0,
})?;
session.page_zoom_percent = request.page_zoom_percent;
}
Ok(())
}
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> {
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());
}
}
Ok(())
}
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,
})?;
}
impl Drop for ServoLiveClient {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
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 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(webview_id)?;
return Ok(ServoLiveFrame::from_presented(
snapshot,
session.width,
session.height,
session.device_pixel_ratio,
));
}
Err(ServoLiveError::NativeSurfaceUnavailable)
}
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())
}
}
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>,
}
@@ -1,147 +0,0 @@
#![cfg(target_os = "macos")]
use std::{
io,
sync::mpsc,
thread::{self, JoinHandle},
time::Duration,
};
use core_video::pixel_buffer::CVPixelBuffer;
use crate::services::{
iosurface_mach::IOSurfaceMachReceiver, iosurface_metal::import_pixel_buffer_from_mach_port,
};
use super::wire::LiveSurfaceHandle;
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(1);
pub(super) struct IOSurfaceImportWorker {
request_tx: Option<mpsc::Sender<LiveSurfaceHandle>>,
result_rx: mpsc::Receiver<IOSurfaceImportResult>,
thread: Option<JoinHandle<()>>,
}
impl IOSurfaceImportWorker {
pub(super) fn new(receiver: IOSurfaceMachReceiver) -> Result<Self, io::Error> {
let (request_tx, request_rx) = mpsc::channel();
let (result_tx, result_rx) = mpsc::channel();
let thread = thread::Builder::new()
.name("ely-iosurface-import".to_string())
.spawn(move || run_import_worker(receiver, request_rx, result_tx))?;
Ok(Self { request_tx: Some(request_tx), result_rx, 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.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) width: u32,
pub(super) height: u32,
pub(super) pixel_buffer: CVPixelBuffer,
}
// SAFETY: CVPixelBuffer is a CoreFoundation object with atomic
// retain/release semantics. This wrapper crosses from the importer
// thread to the live worker thread; GPUI presentation already receives
// the same handle through ServoLiveFrame's Send contract.
#[expect(unsafe_code)]
unsafe impl Send for ImportedIOSurface {}
pub(super) struct IOSurfaceImportFailure {
pub(super) surface_id: u64,
pub(super) width: u32,
pub(super) height: u32,
pub(super) mach_port_name: u32,
pub(super) message: String,
}
impl IOSurfaceImportFailure {
fn worker_stopped(handle: LiveSurfaceHandle) -> Self {
Self {
surface_id: handle.surface_id,
width: handle.width,
height: handle.height,
mach_port_name: handle.mach_port_name,
message: "IOSurface import worker stopped".to_string(),
}
}
}
fn run_import_worker(
mut receiver: IOSurfaceMachReceiver,
request_rx: mpsc::Receiver<LiveSurfaceHandle>,
result_tx: mpsc::Sender<IOSurfaceImportResult>,
) {
while let Ok(handle) = request_rx.recv() {
let result = import_surface_handle(&mut receiver, handle);
if 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,
width: handle.width,
height: handle.height,
mach_port_name: handle.mach_port_name,
message: error.to_string(),
});
}
};
match import_pixel_buffer_from_mach_port(mach_port_name) {
Ok(pixel_buffer) => IOSurfaceImportResult::Imported(ImportedIOSurface {
surface_id: handle.surface_id,
width: handle.width,
height: handle.height,
pixel_buffer,
}),
Err(error) => IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
surface_id: handle.surface_id,
width: handle.width,
height: handle.height,
mach_port_name,
message: error.to_string(),
}),
}
}
+48 -137
View File
@@ -1,17 +1,9 @@
use std::{io, path::PathBuf};
use ely_domain::SitePermissionDecision;
use ely_servo_host::{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 core_video::pixel_buffer::CVPixelBuffer;
pub(crate) struct ServoLiveEnsureRequest {
pub(crate) tab_id: String,
pub(crate) profile_id: String,
@@ -20,10 +12,9 @@ pub(crate) struct ServoLiveEnsureRequest {
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;
/// without this, a Retina viewport gets desktop-CSS-pixel layout
/// and every visible element renders at half its expected size.
/// WebView lays out CSS pixels = device pixels / hidpi factor.
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>,
@@ -68,54 +59,37 @@ pub(crate) struct ServoLiveFrame {
content_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: u64,
rgba_bytes: Vec<u8>,
#[cfg(target_os = "macos")]
pixel_buffer: Option<CVPixelBuffer>,
rgba_bytes: Option<Vec<u8>>,
}
// SAFETY: CVPixelBuffer wraps CVPixelBufferRef, a CoreFoundation type
// Apple documents as safe to share across threads. The Rust core-video
// crate does not mark it Send, so the worker thread needs this opt-in
// to ship hardware frames back to the UI thread via mpsc::Sender.
#[cfg(target_os = "macos")]
#[expect(unsafe_code)]
unsafe impl Send for ServoLiveFrame {}
impl ServoLiveFrame {
pub(super) fn from_parts(report: LiveFrameReport, rgba_bytes: Vec<u8>) -> Self {
let (css_viewport_width, css_viewport_height) = css_viewport_size_from_report(&report);
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);
Self {
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,
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: report.non_white_pixel_count,
non_white_pixel_count: 1,
#[cfg(all(test, feature = "live-site-smoke"))]
content_pixel_count: report.content_pixel_count,
content_pixel_count: 1,
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: report.sample_hash,
rgba_bytes,
#[cfg(target_os = "macos")]
pixel_buffer: None,
sample_hash: 0,
rgba_bytes: None,
}
}
#[cfg(target_os = "macos")]
pub(super) fn set_pixel_buffer(&mut self, pixel_buffer: Option<CVPixelBuffer>) {
self.pixel_buffer = pixel_buffer;
}
#[cfg(target_os = "macos")]
#[must_use]
pub fn pixel_buffer(&self) -> Option<&CVPixelBuffer> {
self.pixel_buffer.as_ref()
}
#[must_use]
pub fn loaded_url(&self) -> Option<&str> {
self.loaded_url.as_deref()
@@ -175,7 +149,7 @@ impl ServoLiveFrame {
}
#[must_use]
pub fn into_rgba_bytes(self) -> Vec<u8> {
pub fn into_rgba_bytes(self) -> Option<Vec<u8>> {
self.rgba_bytes
}
@@ -196,113 +170,50 @@ impl ServoLiveFrame {
content_pixel_count: 0,
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: 0,
rgba_bytes,
#[cfg(target_os = "macos")]
pixel_buffer: None,
}
}
#[cfg(all(test, target_os = "macos"))]
pub(crate) fn for_test_with_pixel_buffer(
width: u32,
height: u32,
pixel_buffer: CVPixelBuffer,
) -> Self {
Self {
loaded_url: Some("https://example.com/".to_string()),
title: Some("Example".to_string()),
render_state: "complete".to_string(),
width,
height,
device_pixel_ratio: 1.0,
css_viewport_width: width,
css_viewport_height: height,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: 0,
#[cfg(all(test, feature = "live-site-smoke"))]
content_pixel_count: 0,
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: 0,
rgba_bytes: Vec::new(),
pixel_buffer: Some(pixel_buffer),
rgba_bytes: Some(rgba_bytes),
}
}
}
fn css_viewport_size_from_report(report: &LiveFrameReport) -> (u32, u32) {
let dpr = if report.device_pixel_ratio.is_finite() && report.device_pixel_ratio > 0.0 {
report.device_pixel_ratio
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",
}
}
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
} else {
1.0
};
let fallback_width = ((report.width as f32) / dpr).round().max(1.0) as u32;
let fallback_height = ((report.height as f32) / dpr).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 },
((width as f32) / scale).round().max(1.0) as u32,
((height as f32) / scale).round().max(1.0) as u32,
)
}
#[derive(Debug, Error)]
pub(crate) enum ServoLiveError {
#[error("servo sidecar binary is unavailable at {path}")]
SidecarBinaryUnavailable { path: PathBuf },
#[error("servo native surface is unavailable")]
NativeSurfaceUnavailable,
#[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 failed: {message}")]
SidecarFailed { message: String },
#[error("failed to read servo live frame bytes: {0}")]
FrameRead(#[source] io::Error),
#[error(
"servo live sidecar advertised {advertised} frame bytes which exceeds \
the {width}x{height} pixel budget ({pixel_budget} bytes)"
)]
FrameBudgetExceeded { advertised: usize, pixel_budget: u64, width: u32, height: u32 },
#[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("failed to spawn servo live IOSurface importer: {0}")]
IOSurfaceImportWorker(#[source] io::Error),
#[cfg(target_os = "macos")]
#[error(transparent)]
IOSurfaceMach(#[from] IOSurfaceMachError),
#[error("servo scroll input is missing a viewport point")]
MissingScrollPoint,
#[error(transparent)]
Json(#[from] serde_json::Error),
Domain(#[from] ely_domain::DomainError),
#[error(transparent)]
SidecarCommand(#[from] SidecarCommandError),
Host(#[from] ServoHostError),
}
impl ServoLiveError {
pub(crate) fn is_sidecar_process_unusable(&self) -> bool {
match self {
Self::SidecarExited => true,
Self::Command(error) | Self::FrameRead(error) => matches!(
error.kind(),
io::ErrorKind::BrokenPipe
| io::ErrorKind::ConnectionAborted
| io::ErrorKind::ConnectionReset
| io::ErrorKind::UnexpectedEof
),
_ => false,
}
pub(crate) fn is_runtime_unavailable(&self) -> bool {
matches!(self, Self::Host(ServoHostError::RuntimeAlreadyStarted))
}
}
@@ -1,189 +0,0 @@
use serde::{Deserialize, Serialize};
use super::ServoLiveSitePermission;
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(super) enum LiveRequest {
Ensure {
tab_id: String,
profile_id: String,
url: String,
width: u32,
height: u32,
page_zoom_percent: u16,
device_pixel_ratio: f32,
scroll_delta_x: i32,
scroll_delta_y: i32,
scroll_point_x: Option<u32>,
scroll_point_y: Option<u32>,
click_x: Option<u32>,
click_y: Option<u32>,
hover_x: Option<u32>,
hover_y: Option<u32>,
typed_text: Option<String>,
site_permissions: Vec<ServoLiveSitePermission>,
ready_surface_ids: Vec<u64>,
},
Poll {
tab_id: String,
ready_surface_ids: Vec<u64>,
},
Close {
tab_id: String,
},
}
#[derive(Deserialize)]
pub(super) struct LiveResponse {
pub(super) error: Option<String>,
pub(super) frame: Option<LiveFrameReport>,
#[serde(default)]
pub(super) perf: Option<LiveFramePerfSummary>,
/// Hardware path only: present on the first frame after a new
/// IOSurface is bound (initial paint, resize, surfman swap chain
/// rotation). T10.4 will turn this into an imported Metal texture;
/// for now we log it on the `ely::servo::iosurface` target so the
/// pipeline is observable end-to-end without yet wiring it into
/// the renderer.
#[serde(default)]
pub(super) surface_handle: Option<LiveSurfaceHandle>,
/// Hardware path only: which previously-imported IOSurface to
/// sample this frame. surfman's attached swap chain rotates the
/// bound surface, so this id alternates between the values the
/// receiver has already imported via `surface_handle`.
#[serde(default)]
pub(super) current_surface_id: Option<u64>,
}
/// Wire mirror of `ely_servo_host::IOSurfaceHandle`. Duplicated rather
/// than imported because `ely_app` only talks to the sidecar via
/// stdin/stdout JSON — it has no crate dependency on `ely_servo_host`
/// and adding one just to share a four-field struct would pull the
/// Servo dep tree into the renderer process.
#[derive(Clone, Copy, Debug, Deserialize)]
pub(super) struct LiveSurfaceHandle {
pub(super) mach_port_name: u32,
pub(super) surface_id: u64,
pub(super) width: u32,
pub(super) height: u32,
}
/// Aggregated frame-stage timings rolled up every N frames by the
/// sidecar. We accept anything matching the wire shape and let the
/// `tracing` event echo the percentiles verbatim — the sidecar is
/// the source of truth for histogram boundaries.
#[derive(Deserialize)]
pub(super) struct LiveFramePerfSummary {
window: u32,
context: String,
paint_p50_us: u64,
paint_p95_us: u64,
paint_p99_us: u64,
encode_p50_us: u64,
encode_p95_us: u64,
encode_p99_us: u64,
write_p50_us: u64,
write_p95_us: u64,
write_p99_us: u64,
total_p50_us: u64,
total_p95_us: u64,
total_p99_us: u64,
}
#[derive(Deserialize)]
pub(super) struct LiveFrameReport {
pub(super) loaded_url: Option<String>,
pub(super) title: Option<String>,
pub(super) state: String,
pub(super) width: u32,
pub(super) height: u32,
#[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,
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) non_white_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) content_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) sample_hash: u64,
}
fn default_device_pixel_ratio() -> f32 {
1.0
}
/// Per-frame tag that tells the renderer which already-imported
/// `MTLTexture` to sample. Emitted at `trace` instead of `info` because
/// it fires every frame on the hardware path; the import event above
/// is the rare `info` and this trace is the steady-state breadcrumb.
pub(super) fn log_iosurface_current(surface_id: u64) {
tracing::trace!(
target: "ely::servo::iosurface",
surface_id,
"iosurface_current",
);
}
/// Emit one structured `tracing` event per IOSurface handover, on a
/// dedicated target so `RUST_LOG=ely::servo::iosurface=info` lights up
/// the cross-process surface pipeline without pulling in everything
/// else. The renderer (T10.4) will turn the same handle into an
/// imported Metal texture; today the event is the observable contract
/// that T10.3 plumbing is alive.
pub(super) fn log_iosurface_handle(handle: &LiveSurfaceHandle) {
tracing::info!(
target: "ely::servo::iosurface",
mach_port_name = handle.mach_port_name,
surface_id = handle.surface_id,
width = handle.width,
height = handle.height,
"iosurface_handle",
);
}
/// Emit one structured `tracing` event per perf summary, on a
/// dedicated target so `RUST_LOG=ely::servo::perf=info` flips the
/// stream on without dragging the rest of the app along. Filtering
/// happens upstream in the subscriber — this call is a single
/// pointer + integer push.
pub(super) fn log_frame_perf(summary: &LiveFramePerfSummary) {
tracing::info!(
target: "ely::servo::perf",
window = summary.window,
context = %summary.context,
paint_p50_us = summary.paint_p50_us,
paint_p95_us = summary.paint_p95_us,
paint_p99_us = summary.paint_p99_us,
encode_p50_us = summary.encode_p50_us,
encode_p95_us = summary.encode_p95_us,
encode_p99_us = summary.encode_p99_us,
write_p50_us = summary.write_p50_us,
write_p95_us = summary.write_p95_us,
write_p99_us = summary.write_p99_us,
total_p50_us = summary.total_p50_us,
total_p95_us = summary.total_p95_us,
total_p99_us = summary.total_p99_us,
"frame_perf",
);
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[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(())
}
}
@@ -1,220 +0,0 @@
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";
const SOFTWARE_SIDECAR_FEATURES: &str = "servo-engine";
const HARDWARE_SIDECAR_FEATURES: &str = "servo-engine,hardware-render";
#[derive(Clone, Debug)]
pub(super) enum SidecarCommandTarget {
Binary(PathBuf),
Cargo { manifest_path: PathBuf },
}
impl SidecarCommandTarget {
pub(super) fn command(&self) -> Command {
match self {
Self::Binary(path) => Command::new(path),
Self::Cargo { manifest_path } => {
let mut command = Command::new("cargo");
command
.arg("run")
.arg("--quiet")
.arg("--manifest-path")
.arg(manifest_path)
.arg("-p")
.arg("ely_servo_host")
.arg("--features")
.arg(sidecar_features_from_env())
.arg("--bin")
.arg("ely_servo_sidecar")
.arg("--");
command
}
}
}
pub(super) fn missing_binary_path(&self) -> Option<&Path> {
match self {
Self::Binary(path) if !path.is_file() => Some(path.as_path()),
Self::Binary(_) | Self::Cargo { .. } => None,
}
}
}
#[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",
}
}
fn sidecar_features(self) -> &'static str {
match self {
Self::Software => SOFTWARE_SIDECAR_FEATURES,
Self::Hardware => HARDWARE_SIDECAR_FEATURES,
}
}
}
#[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::Binary(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());
if adjacent_sidecar.is_file() && is_macos_app_bundle_exe_dir(exe_dir) {
return Ok(SidecarCommandTarget::Binary(adjacent_sidecar));
}
let workspace_manifest = workspace_manifest_path();
let workspace_target_sidecar =
workspace_manifest.as_ref().and_then(|path| workspace_target_sidecar_path(path));
let adjacent_is_workspace_target =
workspace_target_sidecar.as_ref().is_some_and(|path| path == &adjacent_sidecar);
let workspace_target_sidecar_exists =
workspace_target_sidecar.as_ref().is_some_and(|path| path.is_file());
let prefer_cargo_hardware_sidecar = rendering_context_from_env()
== SidecarRenderingContext::Hardware
&& (adjacent_is_workspace_target || workspace_target_sidecar_exists)
&& workspace_manifest.as_ref().is_some_and(|path| path.is_file());
if adjacent_sidecar.is_file() && !prefer_cargo_hardware_sidecar {
return Ok(SidecarCommandTarget::Binary(adjacent_sidecar));
}
if let Some(manifest_path) = workspace_manifest {
if let Some(target_sidecar) = workspace_target_sidecar
&& target_sidecar.is_file()
&& !prefer_cargo_hardware_sidecar
{
return Ok(SidecarCommandTarget::Binary(target_sidecar));
}
if manifest_path.is_file() {
return Ok(SidecarCommandTarget::Cargo { manifest_path });
}
}
Ok(SidecarCommandTarget::Binary(adjacent_sidecar))
}
fn workspace_manifest_path() -> Option<PathBuf> {
option_env!("ELY_WORKSPACE_MANIFEST").map(PathBuf::from)
}
pub(super) fn rendering_context_from_env() -> SidecarRenderingContext {
let raw = env::var(RENDERING_CONTEXT_ENV).ok();
rendering_context_selection(raw.as_deref())
}
fn sidecar_features_from_env() -> &'static str {
rendering_context_from_env().sidecar_features()
}
fn rendering_context_selection(raw: Option<&str>) -> SidecarRenderingContext {
match raw.map(str::to_lowercase).as_deref() {
Some("software") => SidecarRenderingContext::Software,
Some("hardware") => SidecarRenderingContext::Hardware,
_ => default_rendering_context(),
}
}
fn default_rendering_context() -> SidecarRenderingContext {
if cfg!(target_os = "macos") {
SidecarRenderingContext::Hardware
} else {
SidecarRenderingContext::Software
}
}
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::{
HARDWARE_SIDECAR_FEATURES, SOFTWARE_SIDECAR_FEATURES, SidecarRenderingContext,
is_macos_app_bundle_exe_dir, rendering_context_selection,
};
#[test]
fn hardware_rendering_context_enables_hardware_sidecar_feature() {
let context = rendering_context_selection(Some("hardware"));
assert_eq!(context, SidecarRenderingContext::Hardware);
assert_eq!(context.sidecar_features(), HARDWARE_SIDECAR_FEATURES);
assert_eq!(
rendering_context_selection(Some("HARDWARE")),
SidecarRenderingContext::Hardware
);
}
#[test]
fn software_rendering_context_uses_software_sidecar_feature() {
let context = rendering_context_selection(Some("software"));
assert_eq!(context, SidecarRenderingContext::Software);
assert_eq!(context.sidecar_features(), SOFTWARE_SIDECAR_FEATURES);
}
#[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")));
}
#[cfg(target_os = "macos")]
#[test]
fn defaults_to_hardware_rendering_context_on_macos() {
assert_eq!(rendering_context_selection(None), SidecarRenderingContext::Hardware);
assert_eq!(rendering_context_selection(Some("garbage")), SidecarRenderingContext::Hardware);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn defaults_to_software_rendering_context_off_macos() {
assert_eq!(rendering_context_selection(None), SidecarRenderingContext::Software);
assert_eq!(rendering_context_selection(Some("garbage")), SidecarRenderingContext::Software);
}
}
@@ -1,9 +1,9 @@
//! GPUI test harness for the input pipeline.
//!
//! Twelve sidecar-side commits and one shell-side commit had all claimed to
//! Twelve renderer-side commits and one shell-side commit had all claimed to
//! fix "click does nothing" while the user kept reporting the same symptom.
//! The roundtable consensus: every store-layer test passed GREEN, every
//! sidecar integration test passed GREEN, but nothing in the repo exercised
//! renderer integration test passed GREEN, but nothing in the repo exercised
//! the real GPUI event tree (`render_input_overlay` + window-level mouse
//! handlers + sidebar capture interactions). This module is that missing
//! holdout set.
@@ -984,8 +984,7 @@ async fn baseline_overlay_div_receives_simulated_click(cx: &mut TestAppContext)
/// **byte-identical RGBA payloads must produce the same underlying
/// `Arc<RenderImage>`**. Today they do not — every `from_live_frame`
/// blindly reallocates. The fix path is either dedup the upload
/// against the last bytes or switch to `OffscreenRenderingContext` +
/// IOSurface so the GPU texture is the source of truth.
/// against the last bytes or switch to direct platform-surface presentation.
///
/// Regression guard: with the single-slot `LAST_FRAME_IMAGE` cache in
/// `web_surface_frame.rs`, two `ServoLiveFrame` inputs carrying
@@ -1028,7 +1027,7 @@ fn identical_live_frames_share_render_image_arc() -> Result<(), String> {
distinct Arc<RenderImage> instances (first={:p}, second={:p}). \
WebSurfaceFrame::from_parts must dedup the upload against the \
previous frame's bytes, or the rendering pipeline must switch \
to a GPU-side source of truth (IOSurface) so per-frame host \
to direct platform-surface presentation so per-frame host \
allocations stop entirely.",
Arc::as_ptr(first_image),
Arc::as_ptr(second_image),
+58 -4
View File
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use ely_domain::{BrowserTab, TabId};
use gpui::{Bounds, Pixels, Point};
use gpui::{Bounds, NativeSurfaceHandle, Pixels, Point};
use crate::services::ProfileDataMode;
@@ -56,8 +56,22 @@ impl WebSurfaceStore {
else {
return false;
};
let ensure_key =
WebSurfaceEnsureKey::new(requested_url.clone(), size, tab.zoom_percent(), permissions);
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.zoom_percent(),
permissions,
);
if self.surfaces.get(tab.id()).is_some_and(|surface| !surface.should_ensure(&ensure_key)) {
return false;
}
@@ -65,7 +79,29 @@ impl WebSurfaceStore {
let previous_frame =
self.previous_ready_frame(tab.id(), requested_url.as_str(), tab.zoom_percent());
match self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input) {
#[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,
);
match ensure_result {
Ok(result) => {
self.surface_mut(tab.id()).mark_ensured(ensure_key);
if result.started_loading {
@@ -241,6 +277,24 @@ impl WebSurfaceStore {
WebSurfaceInputOutcome::Applied
}
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,
@@ -1,6 +1,6 @@
use ely_browser_core::{BrowserCore, BrowserSnapshot};
use ely_domain::{BrowserTab, ProfileKind, TabId, UrlText};
use gpui::{AnyElement, Bounds, Context, Pixels, Point};
use gpui::{AnyElement, Bounds, Context, NativeSurfaceHandle, Pixels, Point};
use crate::services::ProfileDataMode;
@@ -101,6 +101,25 @@ 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,
+15 -191
View File
@@ -3,10 +3,6 @@ use std::hash::Hasher;
use std::sync::Arc;
use ahash::AHasher;
#[cfg(target_os = "macos")]
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
use core_video::{pixel_buffer::kCVPixelBufferLock_ReadOnly, r#return::kCVReturnSuccess};
use gpui::RenderImage;
use image::{ImageBuffer, Rgba};
use thiserror::Error;
@@ -47,11 +43,7 @@ pub(super) struct WebSurfaceFrame {
content_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: u64,
/// Software-path image built from RGBA bytes.
pub(super) image: Option<Arc<RenderImage>>,
/// Hardware-path IOSurface imported from the sidecar.
#[cfg(target_os = "macos")]
pub(super) pixel_buffer: Option<CVPixelBuffer>,
}
impl WebSurfaceFrame {
@@ -61,8 +53,6 @@ impl WebSurfaceFrame {
zoom_percent: u16,
frame: ServoLiveFrame,
) -> Result<Self, WebSurfaceError> {
#[cfg(target_os = "macos")]
let pixel_buffer = frame.pixel_buffer().cloned();
Self::from_parts(WebSurfaceFrameParts {
requested_url,
loaded_url: frame.loaded_url().map(str::to_string),
@@ -84,45 +74,24 @@ impl WebSurfaceFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: frame.sample_hash(),
rgba_bytes: frame.into_rgba_bytes(),
#[cfg(target_os = "macos")]
pixel_buffer,
})
}
fn from_parts(parts: WebSurfaceFrameParts) -> Result<Self, WebSurfaceError> {
#[cfg(target_os = "macos")]
let has_pixel_buffer = parts.pixel_buffer.is_some();
#[cfg(not(target_os = "macos"))]
let has_pixel_buffer = false;
if parts.rgba_bytes.is_empty() && !has_pixel_buffer {
return Err(WebSurfaceError::MissingRenderablePayload);
}
#[cfg(target_os = "macos")]
if let Some(pixel_buffer) = parts.pixel_buffer.as_ref() {
validate_hardware_pixel_buffer(pixel_buffer, parts.width, parts.height)?;
}
#[cfg(all(test, feature = "live-site-smoke"))]
let pixel_sample = pixel_sample_for_parts(&parts)?;
let image = if parts.rgba_bytes.is_empty() {
None
} else {
// Servo's `read_pixels(gl::RGBA, gl::UNSIGNED_BYTE)` writes
// R-G-B-A in memory order. GPUI's `RenderImage` is documented
// as "in BGRA format" and uploads via
// `MTLPixelFormat::BGRA8Unorm`, which reads B-G-R-A. Hand the bytes across
// unchanged and the Metal sampler treats R as B (and vice
// versa) — every coloured pixel renders with R and B swapped.
// Swap once here so the rest of the pipeline (dedup hash,
// image buffer, GPU upload) all operate on the same BGRA
// representation.
let mut bytes = parts.rgba_bytes;
let image = match parts.rgba_bytes {
Some(mut bytes) => {
if bytes.is_empty() {
return Err(WebSurfaceError::MissingRenderablePayload);
}
// Servo's RGBA8 readback is converted once for GPUI's BGRA upload path.
swap_red_blue_in_place(&mut bytes);
let bytes_hash = rgba_hash(&bytes);
Some(resolve_render_image(parts.width, parts.height, bytes, bytes_hash)?)
}
None => None,
};
Ok(Self {
@@ -146,8 +115,6 @@ impl WebSurfaceFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: pixel_sample.sample_hash,
image,
#[cfg(target_os = "macos")]
pixel_buffer: parts.pixel_buffer,
})
}
@@ -214,15 +181,13 @@ impl WebSurfaceFrame {
}
pub(super) fn has_same_software_render_as(&self, other: &Self) -> bool {
#[cfg(target_os = "macos")]
if self.pixel_buffer.is_some() || other.pixel_buffer.is_some() {
return false;
}
let (Some(image), Some(other_image)) = (self.image.as_ref(), other.image.as_ref()) else {
return false;
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,
};
Arc::ptr_eq(image, other_image)
image_matches
&& self.requested_url == other.requested_url
&& self.loaded_url == other.loaded_url
&& self.title == other.title
@@ -239,7 +204,6 @@ impl WebSurfaceFrame {
}
pub(super) fn has_visible_content_for_initial_display(&self) -> Result<bool, WebSurfaceError> {
// Sidecar readback suppresses blank initial frames before publication.
#[cfg(all(test, feature = "live-site-smoke"))]
{
Ok(self.non_white_pixel_count > 0 && self.content_pixel_count > 0)
@@ -267,16 +231,9 @@ impl WebSurfaceFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn has_hardware_surface(&self) -> bool {
#[cfg(target_os = "macos")]
{
self.pixel_buffer.is_some()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
}
struct WebSurfaceFrameParts {
requested_url: String,
@@ -298,67 +255,15 @@ struct WebSurfaceFrameParts {
content_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: u64,
rgba_bytes: Vec<u8>,
#[cfg(target_os = "macos")]
pixel_buffer: Option<CVPixelBuffer>,
rgba_bytes: Option<Vec<u8>>,
}
#[derive(Debug, Error)]
pub(super) enum WebSurfaceError {
#[error("invalid servo frame buffer for {width}x{height}")]
InvalidFrameBuffer { width: u32, height: u32 },
#[error("servo live frame did not include a software image or hardware IOSurface")]
#[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(all(test, feature = "live-site-smoke", target_os = "macos"))]
#[error("servo hardware surface lock failed with status {status}")]
HardwareSurfaceLockFailed { status: i32 },
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
#[error("servo hardware surface unlock failed with status {status}")]
HardwareSurfaceUnlockFailed { status: i32 },
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
#[error("servo hardware surface base address is unavailable")]
HardwareSurfaceBaseAddressUnavailable,
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
#[error("servo hardware surface row stride {bytes_per_row} is too small for width {width}")]
HardwareSurfaceRowStrideTooSmall { width: usize, bytes_per_row: usize },
}
#[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
@@ -387,11 +292,6 @@ struct WebSurfacePixelSample {
fn pixel_sample_for_parts(
parts: &WebSurfaceFrameParts,
) -> Result<WebSurfacePixelSample, WebSurfaceError> {
#[cfg(target_os = "macos")]
if let Some(pixel_buffer) = parts.pixel_buffer.as_ref() {
return sample_hardware_pixel_buffer(pixel_buffer);
}
Ok(WebSurfacePixelSample {
non_white_pixel_count: parts.non_white_pixel_count,
content_pixel_count: parts.content_pixel_count,
@@ -399,82 +299,6 @@ fn pixel_sample_for_parts(
})
}
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
fn sample_hardware_pixel_buffer(
pixel_buffer: &CVPixelBuffer,
) -> Result<WebSurfacePixelSample, WebSurfaceError> {
let lock_status = pixel_buffer.lock_base_address(kCVPixelBufferLock_ReadOnly);
if lock_status != kCVReturnSuccess {
return Err(WebSurfaceError::HardwareSurfaceLockFailed { status: lock_status });
}
let sample = sample_locked_hardware_pixel_buffer(pixel_buffer);
let unlock_status = pixel_buffer.unlock_base_address(kCVPixelBufferLock_ReadOnly);
if unlock_status != kCVReturnSuccess {
return Err(WebSurfaceError::HardwareSurfaceUnlockFailed { status: unlock_status });
}
sample
}
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
fn sample_locked_hardware_pixel_buffer(
pixel_buffer: &CVPixelBuffer,
) -> Result<WebSurfacePixelSample, WebSurfaceError> {
let width = pixel_buffer.get_width();
let height = pixel_buffer.get_height();
let bytes_per_row = pixel_buffer.get_bytes_per_row();
let row_width = width.saturating_mul(4);
if bytes_per_row < row_width {
return Err(WebSurfaceError::HardwareSurfaceRowStrideTooSmall { width, bytes_per_row });
}
#[expect(unsafe_code)]
let base_address = unsafe { pixel_buffer.get_base_address() };
if base_address.is_null() {
return Err(WebSurfaceError::HardwareSurfaceBaseAddressUnavailable);
}
let byte_len = bytes_per_row.saturating_mul(height);
#[expect(unsafe_code)]
let bytes = unsafe { std::slice::from_raw_parts(base_address.cast::<u8>(), byte_len) };
Ok(sample_bgra_rows(bytes, width, height, bytes_per_row))
}
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
fn sample_bgra_rows(
bytes: &[u8],
width: usize,
height: usize,
bytes_per_row: usize,
) -> WebSurfacePixelSample {
let mut non_white_pixel_count = 0;
let mut content_pixel_count = 0;
let mut sample_hash = 0xcbf29ce484222325_u64;
for y in 0..height {
let row_start = y * bytes_per_row;
let row = &bytes[row_start..row_start + width * 4];
for (x, pixel) in row.chunks_exact(4).enumerate() {
let [blue, green, red, alpha] = [pixel[0], pixel[1], pixel[2], pixel[3]];
if alpha > 0 && (red < 245 || green < 245 || blue < 245) {
non_white_pixel_count += 1;
}
if alpha > 0 && (red < 220 || green < 220 || blue < 220) {
content_pixel_count += 1;
}
if (y * width + x).is_multiple_of(97) {
for byte in [red, green, blue, alpha] {
sample_hash ^= u64::from(byte);
sample_hash = sample_hash.wrapping_mul(0x100000001b3);
}
}
}
}
WebSurfacePixelSample { non_white_pixel_count, content_pixel_count, sample_hash }
}
fn resolve_render_image(
width: u32,
height: u32,
+233 -82
View File
@@ -6,6 +6,7 @@ use std::{
};
use ely_domain::{BrowserTab, ProfileId, TabId};
use gpui::NativeSurfaceHandle;
use crate::services::{
ProfileDataMode,
@@ -23,7 +24,9 @@ use super::{
};
pub(super) struct WebSurfaceRuntime {
workers: BTreeMap<WebSurfaceRuntimeScope, ScopedWorker>,
worker: Option<ScopedWorker>,
direct_client: Option<ScopedDirectClient>,
pending_direct_responses: Vec<WorkerResponse>,
sessions: BTreeMap<TabId, WebSurfaceSession>,
client_factory: LiveRuntimeClientFactory,
}
@@ -31,7 +34,9 @@ pub(super) struct WebSurfaceRuntime {
impl WebSurfaceRuntime {
pub(super) fn new() -> Self {
Self {
workers: BTreeMap::new(),
worker: None,
direct_client: None,
pending_direct_responses: Vec::new(),
sessions: BTreeMap::new(),
client_factory: new_servo_live_client,
}
@@ -39,9 +44,16 @@ impl WebSurfaceRuntime {
#[cfg(test)]
pub(super) fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self {
Self { workers: BTreeMap::new(), sessions: BTreeMap::new(), client_factory }
Self {
worker: None,
direct_client: None,
pending_direct_responses: Vec::new(),
sessions: BTreeMap::new(),
client_factory,
}
}
#[cfg(test)]
pub(super) fn ensure_tab(
&mut self,
tab: &BrowserTab,
@@ -49,11 +61,48 @@ 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())?;
}
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);
@@ -88,6 +137,7 @@ 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,
@@ -100,10 +150,15 @@ impl WebSurfaceRuntime {
site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(),
};
let Some(scoped) = self.workers.get(&scope) else {
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);
}
if let Some(session) = self.sessions.get_mut(tab.id()) {
session.cadence.note_poll_submitted(submitted_at);
}
@@ -114,15 +169,149 @@ impl WebSurfaceRuntime {
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec<WebSurfaceRuntimeFrame> {
let mut frames = Vec::new();
let mut dead_scopes = Vec::new();
let scopes: Vec<WebSurfaceRuntimeScope> = self.workers.keys().cloned().collect();
let now = Instant::now();
for scope in scopes {
let responses = self
.workers
.get(&scope)
.map(|scoped| scoped.worker.drain_responses())
.unwrap_or_default();
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 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 {
continue;
};
if !session.cadence.should_poll(poll_now) {
continue;
}
if let Some(scoped) = self.worker.as_ref() {
let _ = scoped.worker.submit_poll(tab_id.as_str().to_string());
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();
}
}
frames
}
pub(super) fn next_poll_delay(
&self,
visible_tab_ids: &[TabId],
now: Instant,
) -> Option<Duration> {
visible_tab_ids
.iter()
.filter_map(|tab_id| self.sessions.get(tab_id))
.filter(|_| self.worker.is_some() || self.direct_client.is_some())
.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() {
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() {
scoped.worker.submit_close(tab_id.as_str().to_string());
}
}
fn ensure_worker(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
if self.worker.is_some() {
return Ok(());
}
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),
Err(error) => {
let message = error.to_string();
if error.is_runtime_unavailable() {
self.remove_direct_client();
}
Err(message)
}
}
}
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);
};
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() })
}
}
}
(responses, runtime_unavailable)
}
fn collect_responses(
&mut self,
responses: Vec<WorkerResponse>,
now: Instant,
frames: &mut Vec<WebSurfaceRuntimeFrame>,
) -> bool {
let mut runtime_unavailable = false;
for response in responses {
match response {
WorkerResponse::Frame { tab_id, frame } => {
@@ -144,11 +333,8 @@ impl WebSurfaceRuntime {
frame,
) {
Ok(frame) => {
let url_change = session.url_change_for(
&tab_id_obj,
requested_url.as_str(),
&frame,
);
let url_change =
session.url_change_for(&tab_id_obj, requested_url.as_str(), &frame);
frames.push(WebSurfaceRuntimeFrame::Ready {
tab_id: tab_id_obj,
frame: Box::new(frame),
@@ -167,63 +353,12 @@ impl WebSurfaceRuntime {
};
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
}
WorkerResponse::SidecarExited => dead_scopes.push(scope.clone()),
WorkerResponse::RuntimeUnavailable => {
runtime_unavailable = true;
}
}
}
for scope in dead_scopes {
self.workers.remove(&scope);
}
let poll_now = Instant::now();
for tab_id in visible_tab_ids {
let Some(session) = self.sessions.get_mut(tab_id) else {
continue;
};
if !session.cadence.should_poll(poll_now) {
continue;
}
let Some(scoped) = self.workers.get(&session.scope) else {
continue;
};
let _ = scoped.worker.submit_poll(tab_id.as_str().to_string());
session.cadence.note_poll_submitted(poll_now);
}
frames
}
pub(super) fn next_poll_delay(
&self,
visible_tab_ids: &[TabId],
now: Instant,
) -> Option<Duration> {
visible_tab_ids
.iter()
.filter_map(|tab_id| self.sessions.get(tab_id))
.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) {
let Some(session) = self.sessions.remove(tab_id) else {
return;
};
if let Some(scoped) = self.workers.get(&session.scope) {
scoped.worker.submit_close(tab_id.as_str().to_string());
}
}
fn ensure_worker(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
if self.workers.contains_key(&scope) {
return Ok(());
}
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
let client = (self.client_factory)(config_dir)?;
let worker = LiveRuntimeWorker::new(client)?;
self.workers.insert(scope, ScopedWorker { worker, transient_profile_data_dir });
Ok(())
runtime_unavailable
}
fn lookup_session_tab_id(&self, tab_id: &str) -> Option<TabId> {
@@ -232,7 +367,7 @@ impl WebSurfaceRuntime {
#[cfg(test)]
pub(super) fn client_count_for_test(&self) -> usize {
self.workers.len()
usize::from(self.worker.is_some()) + usize::from(self.direct_client.is_some())
}
#[cfg(test)]
@@ -242,23 +377,34 @@ impl WebSurfaceRuntime {
#[cfg(test)]
pub(super) fn flush_for_test(&self) {
for scoped in self.workers.values() {
if let Some(scoped) = self.worker.as_ref() {
scoped.worker.wait_until_idle();
}
}
fn remove_worker(&mut self) {
let Some(scoped) = self.worker.take() else {
return;
};
if let Some(path) = scoped.transient_profile_data_dir {
let _ = fs::remove_dir_all(path);
}
}
fn remove_direct_client(&mut self) {
let Some(scoped) = self.direct_client.take() else {
return;
};
if let Some(path) = scoped.transient_profile_data_dir {
let _ = fs::remove_dir_all(path);
}
}
}
impl Drop for WebSurfaceRuntime {
fn drop(&mut self) {
let transient_profile_data_dirs = self
.workers
.values()
.filter_map(|scoped| scoped.transient_profile_data_dir.clone())
.collect::<Vec<_>>();
self.workers.clear();
for path in transient_profile_data_dirs {
let _ = fs::remove_dir_all(path);
}
self.remove_worker();
self.remove_direct_client();
}
}
@@ -276,6 +422,11 @@ struct ScopedWorker {
transient_profile_data_dir: Option<PathBuf>,
}
struct ScopedDirectClient {
client: Box<dyn LiveRuntimeClient>,
transient_profile_data_dir: Option<PathBuf>,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(super) struct WebSurfaceRuntimeScope {
profile_id: ProfileId,
@@ -29,7 +29,7 @@ static FAILING_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
static REPEATED_FRAME_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
#[test]
fn runtime_keeps_independent_clients_for_profile_scopes() -> Result<(), String> {
fn runtime_shares_direct_servo_client_across_profile_scopes() -> 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 +60,7 @@ fn runtime_keeps_independent_clients_for_profile_scopes() -> Result<(), String>
runtime.flush_for_test();
assert_eq!(runtime.client_count_for_test(), 2);
assert_eq!(runtime.client_count_for_test(), 1);
assert_eq!(
runtime.session_scope_for_test(first_tab.id()),
Some(&WebSurfaceRuntimeScope::new(first_profile, ProfileDataMode::Transient)),
@@ -183,7 +183,7 @@ fn identical_ready_software_frame_keeps_tick_unchanged() -> Result<(), String> {
}
#[test]
fn sidecar_exit_removes_dead_runtime_client() -> Result<(), String> {
fn runtime_unavailable_removes_dead_runtime_client() -> Result<(), String> {
RECOVERY_FACTORY_COUNT.store(0, Ordering::SeqCst);
let mut runtime = WebSurfaceRuntime::new_with_client_factory(recovery_client_factory);
let profile = ProfileId::new();
@@ -281,7 +281,7 @@ fn session_scope_change_resets_tab_state() {
struct FakeLiveRuntimeClient;
struct IdleSkipLiveRuntimeClient;
struct SidecarExitLiveRuntimeClient;
struct RuntimeUnavailableLiveRuntimeClient;
struct FailingLiveRuntimeClient;
struct RepeatedFrameLiveRuntimeClient;
@@ -322,20 +322,20 @@ impl LiveRuntimeClient for IdleSkipLiveRuntimeClient {
}
}
impl LiveRuntimeClient for SidecarExitLiveRuntimeClient {
impl LiveRuntimeClient for RuntimeUnavailableLiveRuntimeClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Err(LiveRuntimeClientError::SidecarExited)
Err(LiveRuntimeClientError::RuntimeUnavailable)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Err(LiveRuntimeClientError::SidecarExited)
Err(LiveRuntimeClientError::RuntimeUnavailable)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Err(LiveRuntimeClientError::SidecarExited)
Err(LiveRuntimeClientError::RuntimeUnavailable)
}
}
@@ -345,7 +345,7 @@ impl LiveRuntimeClient for FailingLiveRuntimeClient {
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
FAILING_ENSURE_COUNT.fetch_add(1, Ordering::SeqCst);
Err(LiveRuntimeClientError::SidecarExited)
Err(LiveRuntimeClientError::RuntimeUnavailable)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
@@ -392,7 +392,7 @@ fn recovery_client_factory(
) -> Result<Box<dyn LiveRuntimeClient>, String> {
let factory_call = RECOVERY_FACTORY_COUNT.fetch_add(1, Ordering::SeqCst);
if factory_call == 0 {
return Ok(Box::new(SidecarExitLiveRuntimeClient));
return Ok(Box::new(RuntimeUnavailableLiveRuntimeClient));
}
Ok(Box::new(FakeLiveRuntimeClient))
}
+13 -2
View File
@@ -1,7 +1,7 @@
use std::time::{Duration, Instant};
use ely_domain::TabId;
use gpui::{Bounds, Pixels};
use gpui::{Bounds, NativeSurfaceHandle, Pixels};
use super::{
web_surface_cadence::ACTIVE_POLL_INTERVAL,
@@ -126,6 +126,7 @@ pub(super) struct WebSurfaceTickResult {
pub(super) struct PerTabSurface {
pub(super) viewport_bounds: Option<Bounds<Pixels>>,
pub(super) viewport_size: Option<WebSurfaceSize>,
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>,
@@ -145,6 +146,7 @@ impl PerTabSurface {
Self {
viewport_bounds: None,
viewport_size: None,
native_surface: None,
last_ensure_key: None,
hover_point: None,
last_hover_enqueued_at: None,
@@ -228,6 +230,7 @@ const HOVER_INPUT_MIN_INTERVAL: Duration = Duration::from_millis(32);
pub(super) struct WebSurfaceEnsureKey {
requested_url: String,
size: WebSurfaceSize,
native_surface_id: Option<usize>,
zoom_percent: u16,
permissions: Vec<WebSurfaceSitePermission>,
}
@@ -236,10 +239,17 @@ impl WebSurfaceEnsureKey {
pub(super) fn new(
requested_url: String,
size: WebSurfaceSize,
native_surface: Option<&NativeSurfaceHandle>,
zoom_percent: u16,
permissions: &[WebSurfaceSitePermission],
) -> Self {
Self { requested_url, size, zoom_percent, permissions: permissions.to_vec() }
Self {
requested_url,
size,
native_surface_id: native_surface.map(NativeSurfaceHandle::identity),
zoom_percent,
permissions: permissions.to_vec(),
}
}
}
@@ -297,6 +307,7 @@ mod tests {
WebSurfaceEnsureKey::new(
url.to_string(),
WebSurfaceSize { width, height, device_pixel_ratio_percent: 100 },
None,
100,
&[],
)
+3 -137
View File
@@ -108,7 +108,7 @@ fn scroll_after_click_keeps_keyboard_focus_and_typed_text() -> Result<(), Box<dy
assert_eq!(
input.scroll_delta.map(|delta| (delta.x(), delta.y())),
Some((0, 140)),
"scroll delta should reach the sidecar",
"scroll delta should reach the Servo runtime",
);
assert_eq!(input.scroll_offset.y(), 140);
assert!(
@@ -118,7 +118,7 @@ fn scroll_after_click_keeps_keyboard_focus_and_typed_text() -> Result<(), Box<dy
assert_eq!(
input.typed_text.as_deref(),
Some("hi"),
"buffered keystrokes from before AND after the scroll must reach the sidecar",
"buffered keystrokes from before AND after the scroll must reach the Servo runtime",
);
Ok(())
}
@@ -421,141 +421,7 @@ fn empty_live_frame_payload_is_rejected() -> Result<(), String> {
Ok(_) => return Err("empty Servo frame payload reached Ready state".to_string()),
Err(error) => error,
};
assert_eq!(
error.to_string(),
"servo live frame did not include a software image or hardware IOSurface",
);
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn hardware_live_frame_with_pixel_buffer_skips_software_image() -> Result<(), String> {
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
use crate::services::servo_live::ServoLiveFrame;
use crate::shell::web_surface_frame::WebSurfaceFrame;
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 1, 1, None)
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
let live = ServoLiveFrame::for_test_with_pixel_buffer(1, 1, pixel_buffer);
let frame = WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
live,
)
.map_err(|error| error.to_string())?;
assert!(frame.image.is_none(), "hardware frame should use the CVPixelBuffer surface path");
assert!(frame.pixel_buffer.is_some(), "hardware frame should carry the imported CVPixelBuffer");
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn hardware_live_frame_rejects_mismatched_surface_size() -> Result<(), String> {
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
use crate::services::servo_live::ServoLiveFrame;
use crate::shell::web_surface_frame::WebSurfaceFrame;
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 2, 1, None)
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
let live = ServoLiveFrame::for_test_with_pixel_buffer(1, 1, pixel_buffer);
let result = WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
live,
);
let error = match result {
Ok(_) => return Err("mismatched hardware surface reached Ready state".to_string()),
Err(error) => error,
};
assert_eq!(error.to_string(), "servo hardware surface size 2x1 did not match frame report 1x1",);
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn hardware_live_frame_rejects_unsupported_surface_format() -> Result<(), String> {
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_420YpCbCr8BiPlanarFullRange};
use crate::services::servo_live::ServoLiveFrame;
use crate::shell::web_surface_frame::WebSurfaceFrame;
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
let pixel_buffer =
CVPixelBuffer::new(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange, 2, 2, None)
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
let live = ServoLiveFrame::for_test_with_pixel_buffer(2, 2, pixel_buffer);
let result = WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
live,
);
let error = match result {
Ok(_) => return Err("unsupported hardware surface format reached Ready state".to_string()),
Err(error) => error,
};
assert_eq!(
error.to_string(),
"servo hardware surface pixel format 0x34323066 is unsupported; expected 32BGRA",
);
Ok(())
}
#[cfg(all(target_os = "macos", feature = "live-site-smoke"))]
#[test]
fn hardware_live_frame_samples_bgra_surface_pixels() -> Result<(), String> {
use core_video::{
pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA},
r#return::kCVReturnSuccess,
};
use crate::services::servo_live::ServoLiveFrame;
use crate::shell::web_surface_frame::WebSurfaceFrame;
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 2, 1, None)
.map_err(|status| format!("CVPixelBufferCreate returned status {status}"))?;
let lock_status = pixel_buffer.lock_base_address(0);
if lock_status != kCVReturnSuccess {
return Err(format!("CVPixelBufferLockBaseAddress returned status {lock_status}"));
}
let bytes_per_row = pixel_buffer.get_bytes_per_row();
#[expect(unsafe_code)]
unsafe {
let base_address = pixel_buffer.get_base_address().cast::<u8>();
let bytes = std::slice::from_raw_parts_mut(base_address, bytes_per_row);
bytes[0..8].copy_from_slice(&[
0, 0, 255, 255, // red in BGRA memory order
255, 255, 255, 255,
]);
}
let unlock_status = pixel_buffer.unlock_base_address(0);
if unlock_status != kCVReturnSuccess {
return Err(format!("CVPixelBufferUnlockBaseAddress returned status {unlock_status}"));
}
let live = ServoLiveFrame::for_test_with_pixel_buffer(2, 1, pixel_buffer);
let frame = WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
live,
)
.map_err(|error| error.to_string())?;
assert_eq!(frame.non_white_pixel_count(), 1);
assert_eq!(frame.content_pixel_count(), 1);
assert_ne!(frame.sample_hash(), 0);
assert_eq!(error.to_string(), "servo live frame did not include renderable pixels",);
Ok(())
}
+18 -38
View File
@@ -1,51 +1,28 @@
use ely_domain::{BrowserTab, TabId};
use gpui::{
AnyElement, App, Corners, Entity, ImageSource, InteractiveElement, IntoElement, MouseButton,
ObjectFit, ParentElement, Styled, StyledImage, Window, canvas, div, img, px, rgb, surface,
AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
ParentElement, Styled, Window, canvas, div, native_surface, px, rgb,
};
use super::{
ElyShell, web_surface_frame::WebSurfaceFrame,
web_surface_geometry::servo_scroll_delta_from_wheel_delta,
};
use ely_design_system::{colors, spacing};
use ely_design_system::colors;
pub(super) fn render_ready_web_surface(
frame: &WebSurfaceFrame,
_frame: &WebSurfaceFrame,
tab: &BrowserTab,
state_entity: Entity<ElyShell>,
) -> AnyElement {
#[cfg(target_os = "macos")]
if let Some(pixel_buffer) = frame.pixel_buffer.as_ref() {
return render_web_surface(
tab,
state_entity,
surface(pixel_buffer.clone())
.size_full()
.corner_radii(web_surface_corner_radii())
.object_fit(ObjectFit::Fill),
);
}
if let Some(image) = frame.image.as_ref() {
return render_web_surface(
tab,
state_entity,
img(ImageSource::Render(image.clone())).size_full().object_fit(ObjectFit::Fill),
);
}
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))
}
pub(super) fn render_loading_web_surface(
tab: &BrowserTab,
state_entity: Entity<ElyShell>,
) -> AnyElement {
render_web_surface(tab, state_entity, div().size_full())
render_web_surface(tab, state_entity.clone(), render_native_web_surface(tab, state_entity))
}
pub(super) fn render_failed_web_surface(
@@ -56,15 +33,6 @@ pub(super) fn render_failed_web_surface(
render_web_surface(tab, state_entity, error_page(message))
}
fn web_surface_corner_radii() -> Corners<gpui::Pixels> {
Corners {
top_left: px(0.0),
top_right: px(0.0),
bottom_right: px(spacing::RADIUS_CARD),
bottom_left: px(spacing::RADIUS_CARD),
}
}
fn error_page(message: &str) -> impl IntoElement {
div()
.size_full()
@@ -107,6 +75,18 @@ fn render_web_surface(
.into_any_element()
}
fn render_native_web_surface(tab: &BrowserTab, state_entity: Entity<ElyShell>) -> impl IntoElement {
let tab_id = tab.id().clone();
let element_id = ElementId::Name(format!("web-surface-{}", tab_id.as_str()).into());
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()
}
fn render_input_overlay(
tab_id: TabId,
url: String,
+42 -19
View File
@@ -9,13 +9,12 @@ use crate::services::servo_live::{
ServoLiveClient, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame,
};
/// IPC surface for the per-profile Servo sidecar.
/// Blocking surface for the embedded Servo runtime.
///
/// 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, never the
/// UI thread.
pub(super) trait LiveRuntimeClient: Send {
/// milliseconds. Implementations live on the worker thread.
pub(super) trait LiveRuntimeClient {
fn ensure(
&mut self,
request: ServoLiveEnsureRequest,
@@ -45,20 +44,20 @@ impl LiveRuntimeClient for ServoLiveClient {
#[derive(Debug)]
pub(super) enum LiveRuntimeClientError {
SidecarExited,
RuntimeUnavailable,
Message(String),
}
impl LiveRuntimeClientError {
pub(super) fn is_sidecar_exited(&self) -> bool {
matches!(self, Self::SidecarExited)
pub(super) fn is_runtime_unavailable(&self) -> bool {
matches!(self, Self::RuntimeUnavailable)
}
}
impl std::fmt::Display for LiveRuntimeClientError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SidecarExited => formatter.write_str("servo live sidecar exited"),
Self::RuntimeUnavailable => formatter.write_str("servo live runtime is unavailable"),
Self::Message(message) => formatter.write_str(message),
}
}
@@ -72,8 +71,8 @@ impl From<String> for LiveRuntimeClientError {
impl From<ServoLiveError> for LiveRuntimeClientError {
fn from(error: ServoLiveError) -> Self {
if error.is_sidecar_process_unusable() {
return Self::SidecarExited;
if error.is_runtime_unavailable() {
return Self::RuntimeUnavailable;
}
Self::Message(error.to_string())
}
@@ -89,7 +88,7 @@ impl From<io::Error> for LiveRuntimeClientError {
pub(super) enum WorkerResponse {
Frame { tab_id: String, frame: ServoLiveFrame },
Failed { tab_id: String, message: String },
SidecarExited,
RuntimeUnavailable,
}
enum WorkerRequest {
@@ -116,9 +115,9 @@ struct WorkerQueue {
/// 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 IPC. Submissions push into a
/// 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 IPC, and emits responses on a
/// drains the queue, runs the blocking calls, and emits responses on a
/// `std::sync::mpsc` channel that the UI thread reads with `try_recv`.
pub(super) struct LiveRuntimeWorker {
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
@@ -127,7 +126,9 @@ pub(super) struct LiveRuntimeWorker {
}
impl LiveRuntimeWorker {
pub(super) fn new(client: Box<dyn LiveRuntimeClient>) -> Result<Self, String> {
pub(super) fn new(
client_factory: impl FnOnce() -> Result<Box<dyn LiveRuntimeClient>, String> + Send + 'static,
) -> Result<Self, String> {
let queue = Arc::new((
Mutex::new(WorkerQueue {
pending: BTreeMap::new(),
@@ -138,13 +139,35 @@ impl LiveRuntimeWorker {
Condvar::new(),
));
let (response_tx, response_rx) = mpsc::channel();
let (init_tx, init_rx) = mpsc::channel();
let queue_for_thread = queue.clone();
let thread = std::thread::Builder::new()
.name("ely-servo-live".to_string())
.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);
})
.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) })
}
@@ -317,7 +340,7 @@ enum Work {
}
/// Forward a single client result to the response channel. Returns
/// `true` when the worker should exit (sidecar process died).
/// `true` when the worker should exit.
fn dispatch_result(
response_tx: &mpsc::Sender<WorkerResponse>,
tab_id: String,
@@ -330,11 +353,11 @@ fn dispatch_result(
}
Ok(None) => false,
Err(error) => {
let exited = error.is_sidecar_exited();
let unavailable = error.is_runtime_unavailable();
let message = error.to_string();
let _ = response_tx.send(WorkerResponse::Failed { tab_id, message });
if exited {
let _ = response_tx.send(WorkerResponse::SidecarExited);
if unavailable {
let _ = response_tx.send(WorkerResponse::RuntimeUnavailable);
return true;
}
false
+13 -24
View File
@@ -7,41 +7,30 @@ rust-version.workspace = true
[features]
default = []
servo-engine = ["dep:dpi", "dep:euclid", "dep:serde", "dep:serde_json", "dep:servo", "dep:url"]
hardware-render = [
"servo-engine",
"dep:gleam",
"dep:glow",
"dep:image",
"dep:log",
"dep:mach2",
"dep:surfman",
"dep:objc2-io-surface",
servo-engine = [
"dep:dpi",
"dep:euclid",
"dep:naga",
"dep:raw-window-handle",
"dep:rustls",
"dep:serde",
"dep:serde_json",
"dep:servo",
"dep:url",
]
[[bin]]
name = "ely_servo_sidecar"
path = "src/bin/ely_servo_sidecar.rs"
required-features = ["servo-engine"]
[dependencies]
dpi = { workspace = true, optional = true }
ely_domain = { path = "../ely_domain" }
euclid = { version = "0.22", optional = true }
gleam = { version = "0.15", optional = true }
glow = { version = "0.16", optional = true }
image = { workspace = true, optional = true }
log = { version = "0.4", optional = true }
naga = { version = "26.0.0", features = ["termcolor"], optional = true }
raw-window-handle = { version = "0.6", optional = true }
rustls = { version = "0.23.40", default-features = false, features = ["std", "aws_lc_rs"], optional = true }
serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
servo = { workspace = true, optional = true }
surfman = { version = "0.11", optional = true }
thiserror.workspace = true
url = { workspace = true, optional = true }
[target.'cfg(target_os = "macos")'.dependencies]
mach2 = { version = "0.6", optional = true }
objc2-io-surface = { version = "0.3.2", optional = true }
[lints]
workspace = true
@@ -1,332 +0,0 @@
use std::{
io::Write,
thread,
time::{Duration, Instant},
};
use ely_domain::TabId;
use ely_servo_host::{
KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest, PageZoomRequest,
PermissionRequest, ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize,
SoftwareServoHost, TouchTapRequest, WebViewSnapshot, WebViewState,
};
use thiserror::Error;
#[path = "ely_servo_sidecar/args.rs"]
mod args;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[path = "ely_servo_sidecar/iosurface_mach.rs"]
mod iosurface_mach;
#[path = "ely_servo_sidecar/live.rs"]
mod live;
#[path = "ely_servo_sidecar/live_output.rs"]
mod live_output;
#[path = "ely_servo_sidecar/live_protocol.rs"]
mod live_protocol;
#[path = "ely_servo_sidecar/live_session.rs"]
mod live_session;
#[path = "ely_servo_sidecar/perf.rs"]
mod perf;
#[path = "ely_servo_sidecar/report.rs"]
mod report;
use args::{SidecarCommand, SnapshotArgs};
use report::{SnapshotInputChanges, SnapshotReport};
const WAIT_ITERATIONS: usize = 5_000;
const WAIT_INTERVAL: Duration = Duration::from_millis(2);
const RENDER_TIMEOUT: Duration = Duration::from_secs(20);
const VISIBLE_FRAME_SETTLE_TIMEOUT: Duration = Duration::from_millis(700);
const INPUT_SETTLE_TIMEOUT: Duration = Duration::from_millis(700);
fn main() -> Result<(), SidecarError> {
match args::parse_env_command()? {
SidecarCommand::Live(args) => live::run_live(args).map_err(SidecarError::Live),
SidecarCommand::Snapshot(args) => run_snapshot(args),
}
}
#[derive(Debug, Error)]
enum SidecarError {
#[error("timed out rendering {url}: {snapshot:?}")]
RenderTimeout { url: String, snapshot: Box<WebViewSnapshot> },
#[error(transparent)]
Args(#[from] args::SidecarArgsError),
#[error(transparent)]
Host(#[from] ServoHostError),
#[error(transparent)]
Live(#[from] live::LiveSidecarError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> {
std::fs::create_dir_all(&args.profile_data_dir)?;
let mut host = SoftwareServoHost::new_with_config_dir(
ServoSurfaceSize::new(args.width, args.height),
Some(args.profile_data_dir.clone()),
)?;
let tab_id = TabId::new();
let webview_id = host.create_webview(tab_id.clone(), args.profile_id.clone())?;
apply_site_permissions(&mut host, &webview_id, &args)?;
host.set_page_zoom(PageZoomRequest {
webview_id: webview_id.clone(),
zoom_factor: f32::from(args.page_zoom_percent) / 100.0,
})?;
host.navigate(NavigationRequest {
webview_id: webview_id.clone(),
tab_id,
url: args.url.clone(),
})?;
let snapshot = wait_for_frame(&mut host, &webview_id, args.url.as_str())?;
let (snapshot, scroll_changed_frame) =
apply_scroll_if_requested(&mut host, &webview_id, &args, snapshot)?;
let (snapshot, click_changed_frame) =
apply_click_if_requested(&mut host, &webview_id, &args, snapshot)?;
let (snapshot, drag_changed_frame) =
apply_drag_if_requested(&mut host, &webview_id, &args, snapshot)?;
let (snapshot, touch_changed_frame) =
apply_touch_if_requested(&mut host, &webview_id, &args, snapshot)?;
let (snapshot, text_changed_frame) =
apply_text_if_requested(&mut host, &webview_id, &args, snapshot)?;
let frame = host.last_rendered_frame()?;
std::fs::write(&args.rgba_out, frame.rgba_bytes())?;
let mut stdout = std::io::stdout().lock();
serde_json::to_writer(
&mut stdout,
&SnapshotReport::new(
&args,
&snapshot,
&frame,
SnapshotInputChanges {
scroll: scroll_changed_frame,
click: click_changed_frame,
drag: drag_changed_frame,
touch: touch_changed_frame,
text: text_changed_frame,
},
),
)?;
stdout.write_all(b"\n")?;
stdout.flush()?;
std::process::exit(0);
}
fn apply_site_permissions(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
args: &SnapshotArgs,
) -> Result<(), SidecarError> {
for permission in &args.site_permissions {
host.set_permission(
PermissionRequest {
webview_id: webview_id.clone(),
profile_id: args.profile_id.clone(),
origin: permission.origin.clone(),
feature: permission.feature,
},
permission.decision.into(),
)?;
}
Ok(())
}
fn apply_scroll_if_requested(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
args: &SnapshotArgs,
snapshot: WebViewSnapshot,
) -> Result<(WebViewSnapshot, bool), SidecarError> {
if args.scroll_x == 0 && args.scroll_y == 0 {
return Ok((snapshot, false));
}
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.scroll(ScrollRequest {
webview_id: webview_id.clone(),
delta_x: args.scroll_x,
delta_y: args.scroll_y,
point_x: 0,
point_y: 0,
})?;
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
}
fn apply_click_if_requested(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
args: &SnapshotArgs,
snapshot: WebViewSnapshot,
) -> Result<(WebViewSnapshot, bool), SidecarError> {
let Some(click_point) = args.click_point else {
return Ok((snapshot, false));
};
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.click(MouseClickRequest {
webview_id: webview_id.clone(),
x: click_point.x,
y: click_point.y,
})?;
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
}
fn apply_drag_if_requested(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
args: &SnapshotArgs,
snapshot: WebViewSnapshot,
) -> Result<(WebViewSnapshot, bool), SidecarError> {
let Some(drag_points) = args.drag_points else {
return Ok((snapshot, false));
};
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.drag(MouseDragRequest {
webview_id: webview_id.clone(),
from_x: drag_points.from.x,
from_y: drag_points.from.y,
to_x: drag_points.to.x,
to_y: drag_points.to.y,
})?;
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
}
fn apply_touch_if_requested(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
args: &SnapshotArgs,
snapshot: WebViewSnapshot,
) -> Result<(WebViewSnapshot, bool), SidecarError> {
let Some(touch_point) = args.touch_point else {
return Ok((snapshot, false));
};
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.touch_tap(TouchTapRequest {
webview_id: webview_id.clone(),
x: touch_point.x,
y: touch_point.y,
})?;
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
}
fn apply_text_if_requested(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
args: &SnapshotArgs,
snapshot: WebViewSnapshot,
) -> Result<(WebViewSnapshot, bool), SidecarError> {
let Some(typed_text) = args.typed_text.as_ref() else {
return Ok((snapshot, false));
};
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.type_text(KeyboardTextRequest {
webview_id: webview_id.clone(),
text: typed_text.clone(),
})?;
wait_for_changed_or_settled_frame(host, webview_id, previous_frame_hash)
}
fn wait_for_frame(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
url: &str,
) -> Result<WebViewSnapshot, SidecarError> {
let started_at = Instant::now();
let mut latest_rendered_snapshot = None;
let mut visible_frame_hash = None;
let mut visible_frame_last_changed_at = None;
for _ in 0..WAIT_ITERATIONS {
if started_at.elapsed() >= RENDER_TIMEOUT {
break;
}
host.tick();
let snapshot = host.snapshot(webview_id)?;
if snapshot.has_pending_frame() {
host.paint(webview_id)?;
}
let snapshot = host.snapshot(webview_id)?;
if let Ok(frame) = host.last_rendered_frame()
&& frame.non_white_pixel_count() > 0
&& frame.content_pixel_count() > 0
{
let current_hash = frame.sample_hash();
if visible_frame_hash != Some(current_hash) {
visible_frame_hash = Some(current_hash);
visible_frame_last_changed_at = Some(Instant::now());
}
if snapshot.state() == &WebViewState::Complete {
return Ok(snapshot);
}
latest_rendered_snapshot = Some(snapshot.clone());
if snapshot.url().is_some()
&& visible_frame_last_changed_at
.is_some_and(|changed_at| changed_at.elapsed() >= VISIBLE_FRAME_SETTLE_TIMEOUT)
{
return Ok(snapshot);
}
}
thread::sleep(WAIT_INTERVAL);
}
if let Some(snapshot) = latest_rendered_snapshot {
return Ok(snapshot);
}
Err(SidecarError::RenderTimeout {
url: url.to_string(),
snapshot: Box::new(host.snapshot(webview_id)?),
})
}
fn wait_for_changed_or_settled_frame(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
previous_frame_hash: u64,
) -> Result<(WebViewSnapshot, bool), SidecarError> {
let started_at = Instant::now();
let mut latest_snapshot = host.snapshot(webview_id)?;
for _ in 0..WAIT_ITERATIONS {
if started_at.elapsed() >= INPUT_SETTLE_TIMEOUT {
break;
}
host.tick();
let snapshot = host.snapshot(webview_id)?;
if snapshot.has_pending_frame() {
host.paint(webview_id)?;
}
latest_snapshot = host.snapshot(webview_id)?;
let changed_frame = host.last_rendered_frame().is_ok_and(|frame| {
frame.non_white_pixel_count() > 0 && frame.sample_hash() != previous_frame_hash
});
if changed_frame {
return Ok((latest_snapshot, true));
}
thread::sleep(WAIT_INTERVAL);
}
Ok((latest_snapshot, false))
}
@@ -1,395 +0,0 @@
use std::{env, num::ParseIntError, path::PathBuf};
use ely_domain::{
DEFAULT_ZOOM_PERCENT, ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature,
UrlText, validate_zoom_percent,
};
use ely_servo_host::RenderingContextKind;
use serde::Deserialize;
use thiserror::Error;
pub(super) enum SidecarCommand {
Live(LiveArgs),
Snapshot(SnapshotArgs),
}
pub(super) struct LiveArgs {
pub(super) profile_data_dir: PathBuf,
pub(super) iosurface_mach_service: Option<String>,
/// Rendering context the host's webviews are built against.
/// Defaults to [`RenderingContextKind::Software`], which keeps
/// the binary's behaviour bit-identical to pre-flag builds.
/// `Hardware` is only accepted when the `hardware-render`
/// feature is compiled in (otherwise the `SoftwareServoHost`
/// constructor returns `HardwareRenderUnavailable`).
pub(super) rendering_context_kind: RenderingContextKind,
}
pub(super) struct SnapshotArgs {
pub(super) url: UrlText,
pub(super) profile_id: ProfileId,
pub(super) profile_data_dir: PathBuf,
pub(super) rgba_out: PathBuf,
pub(super) width: u32,
pub(super) height: u32,
pub(super) scroll_x: i32,
pub(super) scroll_y: i32,
pub(super) page_zoom_percent: u16,
pub(super) click_point: Option<ClickPoint>,
pub(super) drag_points: Option<DragPoints>,
pub(super) touch_point: Option<ClickPoint>,
pub(super) typed_text: Option<String>,
pub(super) site_permissions: Vec<SidecarSitePermission>,
}
#[derive(Clone, Copy)]
pub(super) struct ClickPoint {
pub(super) x: u32,
pub(super) y: u32,
}
#[derive(Clone, Copy)]
pub(super) struct DragPoints {
pub(super) from: ClickPoint,
pub(super) to: ClickPoint,
}
pub(super) struct SidecarSitePermission {
pub(super) origin: SiteOrigin,
pub(super) feature: SitePermissionFeature,
pub(super) decision: SitePermissionDecision,
}
#[derive(Debug, Error)]
pub(super) enum SidecarArgsError {
#[error("missing sidecar command")]
MissingCommand,
#[error("unknown sidecar command: {value}")]
UnknownCommand { value: String },
#[error("missing argument value for {name}")]
MissingArgumentValue { name: &'static str },
#[error("missing required argument: {name}")]
MissingRequiredArgument { name: &'static str },
#[error("unknown argument: {value}")]
UnknownArgument { value: String },
#[error("{name} must be an integer: {value}")]
InvalidInteger {
name: &'static str,
value: String,
#[source]
source: ParseIntError,
},
#[error("{name} must be greater than zero")]
ZeroDimension { name: &'static str },
#[error("--click-x and --click-y must be provided together")]
IncompleteClickPoint,
#[error("--drag-from-x, --drag-from-y, --drag-to-x, and --drag-to-y must be provided together")]
IncompleteDragPoints,
#[error("--touch-x and --touch-y must be provided together")]
IncompleteTouchPoint,
#[error("{name} path is empty")]
EmptyPath { name: &'static str },
#[error("invalid --site-permission JSON: {value}")]
InvalidSitePermissionJson {
value: String,
#[source]
source: serde_json::Error,
},
#[error(
"invalid --rendering-context value: {value:?} (expected \"software\" or \
\"hardware\")"
)]
InvalidRenderingContext { value: String },
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
}
pub(super) fn parse_env_command() -> Result<SidecarCommand, SidecarArgsError> {
parse_command(env::args())
}
fn parse_command(
args: impl IntoIterator<Item = String>,
) -> Result<SidecarCommand, SidecarArgsError> {
let mut args = args.into_iter();
let _program_name = args.next();
let command = args.next().ok_or(SidecarArgsError::MissingCommand)?;
match command.as_str() {
"live" => parse_live_args(args).map(SidecarCommand::Live),
"snapshot" => parse_snapshot_args(args).map(SidecarCommand::Snapshot),
_ => Err(SidecarArgsError::UnknownCommand { value: command }),
}
}
fn parse_live_args(args: impl IntoIterator<Item = String>) -> Result<LiveArgs, SidecarArgsError> {
let mut args = args.into_iter();
let mut profile_data_dir = None;
let mut iosurface_mach_service = None;
let mut rendering_context_kind = RenderingContextKind::default();
while let Some(name) = args.next() {
match name.as_str() {
"--profile-data-dir" => {
profile_data_dir = Some(parse_path(
"--profile-data-dir",
next_argument(&mut args, "--profile-data-dir")?,
)?)
}
"--rendering-context" => {
let value = next_argument(&mut args, "--rendering-context")?;
rendering_context_kind = match value.as_str() {
"software" => RenderingContextKind::Software,
"hardware" => RenderingContextKind::Hardware,
_ => return Err(SidecarArgsError::InvalidRenderingContext { value }),
};
}
"--iosurface-mach-service" => {
iosurface_mach_service =
Some(next_argument(&mut args, "--iosurface-mach-service")?);
}
_ => return Err(SidecarArgsError::UnknownArgument { value: name }),
}
}
Ok(LiveArgs {
profile_data_dir: profile_data_dir
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?,
iosurface_mach_service,
rendering_context_kind,
})
}
fn parse_snapshot_args(
args: impl IntoIterator<Item = String>,
) -> Result<SnapshotArgs, SidecarArgsError> {
let mut args = args.into_iter();
let mut url = None;
let mut profile_id = None;
let mut profile_data_dir = None;
let mut rgba_out = None;
let mut width = None;
let mut height = None;
let mut scroll_x = 0;
let mut scroll_y = 0;
let mut page_zoom_percent = DEFAULT_ZOOM_PERCENT;
let mut click_x = None;
let mut click_y = None;
let mut drag_from_x = None;
let mut drag_from_y = None;
let mut drag_to_x = None;
let mut drag_to_y = None;
let mut touch_x = None;
let mut touch_y = None;
let mut typed_text = None;
let mut site_permissions = Vec::new();
while let Some(name) = args.next() {
match name.as_str() {
"--url" => url = Some(UrlText::parse(next_argument(&mut args, "--url")?)?),
"--profile-id" => {
profile_id = Some(ProfileId::parse(next_argument(&mut args, "--profile-id")?)?)
}
"--profile-data-dir" => {
profile_data_dir = Some(parse_path(
"--profile-data-dir",
next_argument(&mut args, "--profile-data-dir")?,
)?)
}
"--rgba-out" => {
rgba_out = Some(parse_path("--rgba-out", next_argument(&mut args, "--rgba-out")?)?)
}
"--width" => {
width = Some(parse_dimension("--width", next_argument(&mut args, "--width")?)?)
}
"--height" => {
height = Some(parse_dimension("--height", next_argument(&mut args, "--height")?)?)
}
"--scroll-x" => {
scroll_x =
parse_scroll_delta("--scroll-x", next_argument(&mut args, "--scroll-x")?)?
}
"--scroll-y" => {
scroll_y =
parse_scroll_delta("--scroll-y", next_argument(&mut args, "--scroll-y")?)?
}
"--page-zoom-percent" => {
page_zoom_percent = parse_zoom_percent(
"--page-zoom-percent",
next_argument(&mut args, "--page-zoom-percent")?,
)?
}
"--click-x" => {
click_x = Some(parse_click_coordinate(
"--click-x",
next_argument(&mut args, "--click-x")?,
)?)
}
"--click-y" => {
click_y = Some(parse_click_coordinate(
"--click-y",
next_argument(&mut args, "--click-y")?,
)?)
}
"--drag-from-x" => {
drag_from_x = Some(parse_click_coordinate(
"--drag-from-x",
next_argument(&mut args, "--drag-from-x")?,
)?)
}
"--drag-from-y" => {
drag_from_y = Some(parse_click_coordinate(
"--drag-from-y",
next_argument(&mut args, "--drag-from-y")?,
)?)
}
"--drag-to-x" => {
drag_to_x = Some(parse_click_coordinate(
"--drag-to-x",
next_argument(&mut args, "--drag-to-x")?,
)?)
}
"--drag-to-y" => {
drag_to_y = Some(parse_click_coordinate(
"--drag-to-y",
next_argument(&mut args, "--drag-to-y")?,
)?)
}
"--touch-x" => {
touch_x = Some(parse_click_coordinate(
"--touch-x",
next_argument(&mut args, "--touch-x")?,
)?)
}
"--touch-y" => {
touch_y = Some(parse_click_coordinate(
"--touch-y",
next_argument(&mut args, "--touch-y")?,
)?)
}
"--type-text" => typed_text = Some(next_argument(&mut args, "--type-text")?),
"--site-permission" => site_permissions
.push(parse_site_permission(next_argument(&mut args, "--site-permission")?)?),
_ => return Err(SidecarArgsError::UnknownArgument { value: name }),
}
}
let click_point = match (click_x, click_y) {
(Some(x), Some(y)) => Some(ClickPoint { x, y }),
(None, None) => None,
_ => return Err(SidecarArgsError::IncompleteClickPoint),
};
let drag_points = match (drag_from_x, drag_from_y, drag_to_x, drag_to_y) {
(Some(from_x), Some(from_y), Some(to_x), Some(to_y)) => Some(DragPoints {
from: ClickPoint { x: from_x, y: from_y },
to: ClickPoint { x: to_x, y: to_y },
}),
(None, None, None, None) => None,
_ => return Err(SidecarArgsError::IncompleteDragPoints),
};
let touch_point = match (touch_x, touch_y) {
(Some(x), Some(y)) => Some(ClickPoint { x, y }),
(None, None) => None,
_ => return Err(SidecarArgsError::IncompleteTouchPoint),
};
Ok(SnapshotArgs {
url: url.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--url" })?,
profile_id: profile_id
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-id" })?,
profile_data_dir: profile_data_dir
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?,
rgba_out: rgba_out
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--rgba-out" })?,
width: width.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--width" })?,
height: height.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--height" })?,
scroll_x,
scroll_y,
page_zoom_percent,
click_point,
drag_points,
touch_point,
typed_text,
site_permissions,
})
}
#[derive(Deserialize)]
struct SitePermissionArg {
origin: String,
feature: String,
decision: String,
}
fn parse_site_permission(value: String) -> Result<SidecarSitePermission, SidecarArgsError> {
let parsed: SitePermissionArg = serde_json::from_str(&value)
.map_err(|source| SidecarArgsError::InvalidSitePermissionJson { value, source })?;
Ok(SidecarSitePermission {
origin: SiteOrigin::parse(parsed.origin)?,
feature: SitePermissionFeature::parse(parsed.feature.as_str())?,
decision: SitePermissionDecision::parse(parsed.decision.as_str())?,
})
}
fn next_argument(
args: &mut impl Iterator<Item = String>,
name: &'static str,
) -> Result<String, SidecarArgsError> {
args.next().ok_or(SidecarArgsError::MissingArgumentValue { name })
}
fn parse_dimension(name: &'static str, value: String) -> Result<u32, SidecarArgsError> {
let dimension = value.parse::<u32>().map_err(|source| SidecarArgsError::InvalidInteger {
name,
value,
source,
})?;
if dimension == 0 {
return Err(SidecarArgsError::ZeroDimension { name });
}
Ok(dimension)
}
fn parse_scroll_delta(name: &'static str, value: String) -> Result<i32, SidecarArgsError> {
value.parse::<i32>().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source })
}
fn parse_click_coordinate(name: &'static str, value: String) -> Result<u32, SidecarArgsError> {
value.parse::<u32>().map_err(|source| SidecarArgsError::InvalidInteger { name, value, source })
}
fn parse_zoom_percent(name: &'static str, value: String) -> Result<u16, SidecarArgsError> {
let percent = value.parse::<u16>().map_err(|source| SidecarArgsError::InvalidInteger {
name,
value,
source,
})?;
Ok(validate_zoom_percent(percent)?)
}
fn parse_path(name: &'static str, value: String) -> Result<PathBuf, SidecarArgsError> {
if value.trim().is_empty() {
return Err(SidecarArgsError::EmptyPath { name });
}
Ok(PathBuf::from(value))
}
#[cfg(test)]
#[path = "args_tests.rs"]
mod tests;
@@ -1,183 +0,0 @@
use std::{env, path::PathBuf};
use super::{SidecarArgsError, SidecarCommand, parse_command};
use ely_domain::{
DEFAULT_ZOOM_PERCENT, DomainError, ProfileId, SitePermissionDecision, SitePermissionFeature,
};
use ely_servo_host::RenderingContextKind;
#[test]
fn parses_snapshot_profile_identity() -> Result<(), SidecarArgsError> {
let profile_id = ProfileId::new();
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
let args = parse_snapshot_command(&profile_id, profile_data_dir.clone())?;
assert_eq!(args.profile_id, profile_id);
assert_eq!(args.profile_data_dir, profile_data_dir);
assert_eq!(args.page_zoom_percent, DEFAULT_ZOOM_PERCENT);
Ok(())
}
#[test]
fn rejects_invalid_snapshot_profile_id() {
let command = parse_command(
[
"ely_servo_sidecar",
"snapshot",
"--url",
"https://example.com",
"--profile-id",
"profile_invalid",
"--profile-data-dir",
"/tmp/profile",
"--rgba-out",
"/tmp/frame.rgba",
"--width",
"64",
"--height",
"64",
]
.into_iter()
.map(str::to_string),
);
assert!(matches!(command, Err(SidecarArgsError::Domain(DomainError::InvalidEntityId { .. }))));
}
#[test]
fn parses_snapshot_site_permissions() -> Result<(), SidecarArgsError> {
let profile_id = ProfileId::new();
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
command.push("--site-permission".to_string());
command.push(
r#"{"origin":"https://example.com","feature":"camera","decision":"allow-once"}"#
.to_string(),
);
let args = snapshot_args(parse_command(command)?);
assert_eq!(args.site_permissions.len(), 1);
let permission = &args.site_permissions[0];
assert_eq!(permission.origin.as_str(), "https://example.com");
assert_eq!(permission.feature, SitePermissionFeature::Camera);
assert_eq!(permission.decision, SitePermissionDecision::AllowOnce);
Ok(())
}
#[test]
fn parses_snapshot_page_zoom_percent() -> Result<(), SidecarArgsError> {
let profile_id = ProfileId::new();
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
command.push("--page-zoom-percent".to_string());
command.push("125".to_string());
let args = snapshot_args(parse_command(command)?);
assert_eq!(args.page_zoom_percent, 125);
Ok(())
}
#[test]
fn rejects_out_of_range_snapshot_page_zoom_percent() {
let profile_id = ProfileId::new();
let profile_data_dir = env::temp_dir().join(profile_id.as_str());
let mut command = snapshot_command_args(&profile_id, profile_data_dir);
command.push("--page-zoom-percent".to_string());
command.push("5".to_string());
assert!(matches!(
parse_command(command),
Err(SidecarArgsError::Domain(DomainError::InvalidZoomPercent { value: 5, .. }))
));
}
fn parse_snapshot_command(
profile_id: &ProfileId,
profile_data_dir: PathBuf,
) -> Result<super::SnapshotArgs, SidecarArgsError> {
Ok(snapshot_args(parse_command(snapshot_command_args(profile_id, profile_data_dir))?))
}
fn snapshot_args(command: SidecarCommand) -> super::SnapshotArgs {
match command {
SidecarCommand::Snapshot(args) => args,
SidecarCommand::Live(_) => unreachable!("expected snapshot command"),
}
}
fn snapshot_command_args(profile_id: &ProfileId, profile_data_dir: PathBuf) -> Vec<String> {
[
"ely_servo_sidecar".to_string(),
"snapshot".to_string(),
"--url".to_string(),
"https://example.com".to_string(),
"--profile-id".to_string(),
profile_id.as_str().to_string(),
"--profile-data-dir".to_string(),
profile_data_dir.display().to_string(),
"--rgba-out".to_string(),
"/tmp/frame.rgba".to_string(),
"--width".to_string(),
"64".to_string(),
"--height".to_string(),
"64".to_string(),
]
.into_iter()
.collect()
}
fn parse_live(extra_args: &[&str]) -> Result<super::LiveArgs, SidecarArgsError> {
let base = ["ely_servo_sidecar", "live", "--profile-data-dir", "/tmp/sidecar-live"];
let argv: Vec<String> =
base.iter().chain(extra_args.iter()).map(|s| (*s).to_string()).collect();
let SidecarCommand::Live(args) = parse_command(argv)? else {
return Err(SidecarArgsError::UnknownCommand {
value: "live-extracted-as-snapshot".into(),
});
};
Ok(args)
}
#[test]
fn live_defaults_rendering_context_to_software() -> Result<(), SidecarArgsError> {
let args = parse_live(&[])?;
assert_eq!(args.rendering_context_kind, RenderingContextKind::Software);
Ok(())
}
#[test]
fn live_accepts_explicit_software_rendering_context() -> Result<(), SidecarArgsError> {
let args = parse_live(&["--rendering-context", "software"])?;
assert_eq!(args.rendering_context_kind, RenderingContextKind::Software);
Ok(())
}
#[test]
fn live_accepts_explicit_hardware_rendering_context() -> Result<(), SidecarArgsError> {
let args = parse_live(&["--rendering-context", "hardware"])?;
assert_eq!(args.rendering_context_kind, RenderingContextKind::Hardware);
Ok(())
}
#[test]
fn live_accepts_iosurface_mach_service_name() -> Result<(), SidecarArgsError> {
let args = parse_live(&["--iosurface-mach-service", "com.ely.test.iosurface"])?;
assert_eq!(args.iosurface_mach_service.as_deref(), Some("com.ely.test.iosurface"));
Ok(())
}
#[test]
fn live_rejects_unknown_rendering_context_value() {
assert!(matches!(
parse_live(&["--rendering-context", "gpu"]),
Err(SidecarArgsError::InvalidRenderingContext { value }) if value == "gpu"
));
}
#[test]
fn live_requires_rendering_context_value() {
assert!(matches!(
parse_live(&["--rendering-context"]),
Err(SidecarArgsError::MissingArgumentValue { name: "--rendering-context" })
));
}
@@ -1,133 +0,0 @@
use std::{ffi::CString, mem, time::Duration};
use mach2::{
bootstrap::{bootstrap_look_up, bootstrap_port},
kern_return::KERN_SUCCESS,
mach_port::mach_port_deallocate,
message::{
MACH_MSG_SUCCESS, MACH_MSG_TYPE_COPY_SEND, MACH_MSG_TYPE_MOVE_SEND, MACH_MSGH_BITS,
MACH_MSGH_BITS_COMPLEX, MACH_SEND_MSG, MACH_SEND_TIMEOUT, mach_msg, mach_msg_body_t,
mach_msg_header_t, mach_msg_port_descriptor_t,
},
port::{MACH_PORT_NULL, mach_port_t},
traps::mach_task_self,
};
use thiserror::Error;
use super::live_protocol::{LiveOutcome, LiveSidecarError};
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
const SEND_TIMEOUT: Duration = Duration::from_secs(1);
pub(super) struct IOSurfaceMachSender {
send_port: mach_port_t,
}
#[derive(Debug, Error)]
pub(super) enum IOSurfaceMachError {
#[error("Mach service name contains an interior nul byte")]
InvalidServiceName,
#[error("bootstrap_look_up returned {code}")]
LookupService { code: i32 },
#[error("mach_msg send returned {code}")]
Send { code: i32 },
}
impl IOSurfaceMachSender {
pub(super) fn connect(service_name: &str) -> Result<Self, IOSurfaceMachError> {
let service_name =
CString::new(service_name).map_err(|_| IOSurfaceMachError::InvalidServiceName)?;
let mut send_port = MACH_PORT_NULL;
#[expect(unsafe_code)]
let result =
unsafe { bootstrap_look_up(bootstrap_port, service_name.as_ptr(), &mut send_port) };
if result != KERN_SUCCESS {
return Err(IOSurfaceMachError::LookupService { code: result });
}
Ok(Self { send_port })
}
pub(super) fn send_surface_port(
&mut self,
surface_id: u64,
mach_port: mach_port_t,
) -> Result<(), IOSurfaceMachError> {
let mut message = IOSurfacePortMessage {
header: mach_msg_header_t {
msgh_bits: MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0) | MACH_MSGH_BITS_COMPLEX,
msgh_size: mem::size_of::<IOSurfacePortMessage>() as u32,
msgh_remote_port: self.send_port,
msgh_local_port: MACH_PORT_NULL,
msgh_voucher_port: MACH_PORT_NULL,
msgh_id: IOSURFACE_PORT_MESSAGE_ID,
},
body: mach_msg_body_t { msgh_descriptor_count: 1 },
surface_port: mach_msg_port_descriptor_t::new(mach_port, MACH_MSG_TYPE_MOVE_SEND),
surface_id,
};
#[expect(unsafe_code)]
let result = unsafe {
mach_msg(
&mut message.header,
MACH_SEND_MSG | MACH_SEND_TIMEOUT,
message.header.msgh_size,
0,
MACH_PORT_NULL,
timeout_millis(SEND_TIMEOUT),
MACH_PORT_NULL,
)
};
if result != MACH_MSG_SUCCESS {
destroy_message(&mut message);
return Err(IOSurfaceMachError::Send { code: result });
}
Ok(())
}
}
impl Drop for IOSurfaceMachSender {
fn drop(&mut self) {
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
unsafe {
let _ = mach_port_deallocate(task, self.send_port);
}
}
}
pub(super) fn send_surface_port_if_needed(
sender: Option<&mut IOSurfaceMachSender>,
outcome: &mut Result<LiveOutcome, LiveSidecarError>,
) {
let (Some(sender), Ok(live_outcome)) = (sender, outcome.as_ref()) else {
return;
};
let Some(handle) = live_outcome.response.surface_handle else {
return;
};
if let Err(error) = sender.send_surface_port(handle.surface_id, handle.mach_port_name) {
*outcome = Err(LiveSidecarError::IOSurfaceMach(error));
}
}
#[repr(C)]
struct IOSurfacePortMessage {
header: mach_msg_header_t,
body: mach_msg_body_t,
surface_port: mach_msg_port_descriptor_t,
surface_id: u64,
}
fn timeout_millis(timeout: Duration) -> u32 {
u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(1)
}
fn destroy_message(message: &mut IOSurfacePortMessage) {
#[expect(unsafe_code)]
unsafe {
mach2::message::mach_msg_destroy(&mut message.header);
}
}
@@ -1,476 +0,0 @@
use std::{
collections::{HashMap, HashSet},
fs,
io::{self, BufRead},
time::Instant,
};
use ely_domain::{ProfileId, TabId, UrlText};
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
use ely_servo_host::ServoHostError;
use ely_servo_host::{
IOSurfaceIdentity, NavigationRequest, RenderingContextKind, ServoHost, ServoSurfaceSize,
SoftwareServoHost,
};
use super::args::LiveArgs;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
use super::iosurface_mach::{IOSurfaceMachSender, send_surface_port_if_needed};
use super::live_output::{populate_surface_fields, write_outcome};
pub(super) use super::live_protocol::LiveSidecarError;
use super::live_protocol::{LiveFrameReport, LiveOutcome, LiveRequest, PartialFrameTimings};
use super::live_session::{
LiveInput, LiveSession, apply_input, apply_layout, apply_permissions, ensure_session,
};
use super::perf::{FramePerfAggregator, FramePerfSummary, elapsed_ns};
pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> {
let LiveArgs { profile_data_dir, iosurface_mach_service, rendering_context_kind } = args;
let publish_readback_surface_fields = true;
let require_client_ready_surfaces = iosurface_mach_service.is_some();
fs::create_dir_all(&profile_data_dir)?;
let context_label = rendering_context_label(rendering_context_kind);
let mut host = SoftwareServoHost::new_with_config_dir_and_kind(
ServoSurfaceSize::new(1, 1),
Some(profile_data_dir),
rendering_context_kind,
)?;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let mut iosurface_mach_sender =
iosurface_mach_service.as_deref().map(IOSurfaceMachSender::connect).transpose()?;
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
let _ = iosurface_mach_service;
let mut sessions = HashMap::new();
let mut perf =
FramePerfAggregator::new(context_label, FramePerfAggregator::DEFAULT_WINDOW_SIZE);
let mut pending_summary: Option<FramePerfSummary> = None;
let mut published_surface_ids: HashMap<String, HashSet<IOSurfaceIdentity>> = HashMap::new();
let stdin = io::stdin();
let mut stdout = io::stdout().lock();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
// `frame_started_at` is the honest start of the end-to-end
// frame: a request just arrived and we're about to do
// everything required to put bytes back on the pipe. The
// matching stop is the `stdout.flush()` inside
// `write_outcome`.
let frame_started_at = Instant::now();
let outcome = match serde_json::from_str::<LiveRequest>(&line) {
Ok(request) => handle_request(
&mut host,
&mut sessions,
&mut published_surface_ids,
rendering_context_kind,
publish_readback_surface_fields,
require_client_ready_surfaces,
request,
),
Err(error) => Err(LiveSidecarError::Json(error)),
};
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let outcome = {
let mut outcome = outcome;
send_surface_port_if_needed(iosurface_mach_sender.as_mut(), &mut outcome);
outcome
};
write_outcome(&mut stdout, &mut perf, &mut pending_summary, outcome, frame_started_at)?;
}
Ok(())
}
const fn rendering_context_label(kind: RenderingContextKind) -> &'static str {
match kind {
RenderingContextKind::Software => "software",
RenderingContextKind::Hardware => "hardware",
}
}
fn handle_request(
host: &mut SoftwareServoHost,
sessions: &mut HashMap<String, LiveSession>,
published_surface_ids: &mut HashMap<String, HashSet<IOSurfaceIdentity>>,
rendering_context_kind: RenderingContextKind,
publish_readback_surface_fields: bool,
require_client_ready_surfaces: bool,
request: LiveRequest,
) -> Result<LiveOutcome, LiveSidecarError> {
match request {
LiveRequest::Ensure {
tab_id,
profile_id,
url,
width,
height,
page_zoom_percent,
device_pixel_ratio,
scroll_delta_x,
scroll_delta_y,
scroll_point_x,
scroll_point_y,
click_x,
click_y,
hover_x,
hover_y,
typed_text,
site_permissions,
ready_surface_ids,
} => {
let tab = TabId::parse(tab_id.clone())?;
let profile = ProfileId::parse(profile_id)?;
let url = UrlText::parse(url)?;
let session =
ensure_session(host, sessions, tab_id.clone(), &tab, &profile, width, height)?;
if apply_layout(host, session, width, height, page_zoom_percent, device_pixel_ratio)? {
session.awaiting_visible_frame = true;
}
apply_permissions(host, session, &profile, site_permissions)?;
if session.requested_url != url.as_str() {
host.navigate(NavigationRequest {
webview_id: session.webview_id.clone(),
tab_id: tab,
url: url.clone(),
})?;
session.requested_url = url.as_str().to_string();
session.scroll_x = 0;
session.scroll_y = 0;
session.awaiting_visible_frame = true;
// New URL: the previous tab's pixels are no longer
// valid evidence that "we have visible content"; let
// the gate skip blank loading frames again.
session.ever_visible_frame = false;
}
let input = LiveInput {
scroll_delta_x,
scroll_delta_y,
scroll_point_x,
scroll_point_y,
click_x,
click_y,
hover_x,
hover_y,
typed_text,
};
if apply_input(host, session, input)? {
// The app tick calls this sidecar synchronously from
// GPUI's update path. Mark that a fresh frame is
// desired, then let poll_frame take one event-loop
// step; a later 16 ms app tick will poll again if
// Servo has not painted yet.
session.awaiting_visible_frame = true;
}
let webview_id = session.webview_id.clone();
let mut outcome = poll_frame(
host,
session,
rendering_context_kind,
payloadless_readiness(
&tab_id,
published_surface_ids,
&ready_surface_ids,
require_client_ready_surfaces,
),
)?;
populate_surface_fields(
host,
&webview_id,
&tab_id,
published_surface_ids,
publish_readback_surface_fields,
&mut outcome,
);
Ok(outcome)
}
LiveRequest::Poll { tab_id, ready_surface_ids } => {
let Some(session) = sessions.get_mut(&tab_id) else {
return Ok(LiveOutcome::empty());
};
let webview_id = session.webview_id.clone();
let mut outcome = poll_frame(
host,
session,
rendering_context_kind,
payloadless_readiness(
&tab_id,
published_surface_ids,
&ready_surface_ids,
require_client_ready_surfaces,
),
)?;
populate_surface_fields(
host,
&webview_id,
&tab_id,
published_surface_ids,
publish_readback_surface_fields,
&mut outcome,
);
Ok(outcome)
}
LiveRequest::Close { tab_id } => {
if let Some(session) = sessions.remove(&tab_id) {
host.close_webview(&session.webview_id);
}
published_surface_ids.remove(&tab_id);
Ok(LiveOutcome::empty())
}
}
}
fn poll_frame(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
rendering_context_kind: RenderingContextKind,
readiness: PayloadlessReadiness<'_>,
) -> Result<LiveOutcome, LiveSidecarError> {
host.tick();
let snapshot = host.snapshot(&session.webview_id)?;
let has_pending_frame = snapshot.has_pending_frame();
if !should_paint_live_frame(has_pending_frame, session.awaiting_visible_frame) {
return Ok(LiveOutcome::empty());
}
let (outcome, has_visible_content) =
paint_pending_frame(host, session, rendering_context_kind, readiness, has_pending_frame)?;
if has_visible_content {
session.awaiting_visible_frame = false;
session.ever_visible_frame = true;
return Ok(outcome);
}
if !session.awaiting_visible_frame {
return Ok(outcome);
}
Ok(LiveOutcome::empty())
}
fn should_paint_live_frame(has_pending_frame: bool, awaiting_visible_frame: bool) -> bool {
has_pending_frame || awaiting_visible_frame
}
fn paint_pending_frame(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
rendering_context_kind: RenderingContextKind,
readiness: PayloadlessReadiness<'_>,
has_pending_frame: bool,
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
let _ = readiness;
match rendering_context_kind {
RenderingContextKind::Software => paint_readback_frame(host, session, !has_pending_frame),
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
RenderingContextKind::Hardware => {
paint_hardware_surface_frame(host, session, readiness, has_pending_frame)
}
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
RenderingContextKind::Hardware => paint_readback_frame(host, session, !has_pending_frame),
}
}
fn paint_readback_frame(
host: &mut SoftwareServoHost,
session: &LiveSession,
wait_for_completion: bool,
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
let paint_started_at = Instant::now();
host.paint_with_readback(&session.webview_id, wait_for_completion)?;
let snapshot = host.snapshot(&session.webview_id)?;
let frame = host.last_rendered_frame()?;
let paint_ns = elapsed_ns(paint_started_at);
let encode_started_at = Instant::now();
let has_visible_content = session.ever_visible_frame
|| (frame.non_white_pixel_count() > 0 && frame.content_pixel_count() > 0);
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio());
let encode_ns = elapsed_ns(encode_started_at);
let timings = PartialFrameTimings { paint_ns, encode_ns };
Ok((LiveOutcome::from_frame(report, frame, timings), has_visible_content))
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn paint_hardware_surface_frame(
host: &mut SoftwareServoHost,
session: &LiveSession,
readiness: PayloadlessReadiness<'_>,
has_pending_frame: bool,
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
if !session.ever_visible_frame {
return paint_initial_hardware_surface_frame(host, session, !has_pending_frame);
}
if !payloadless_surface_pool_ready(readiness, session.width, session.height) {
return paint_readback_frame(host, session, !has_pending_frame);
}
let (outcome, _) = paint_hardware_surface_report(host, session, !has_pending_frame)?;
Ok((outcome, true))
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn paint_initial_hardware_surface_frame(
host: &mut SoftwareServoHost,
session: &LiveSession,
wait_for_completion: bool,
) -> Result<(LiveOutcome, bool), LiveSidecarError> {
let paint_started_at = Instant::now();
host.paint_with_readback(&session.webview_id, wait_for_completion)?;
let snapshot = host.snapshot(&session.webview_id)?;
let frame = host.last_rendered_frame()?;
let paint_ns = elapsed_ns(paint_started_at);
let encode_started_at = Instant::now();
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio());
let has_visible_content = frame.non_white_pixel_count() > 0 && frame.content_pixel_count() > 0;
let encode_ns = elapsed_ns(encode_started_at);
let timings = PartialFrameTimings { paint_ns, encode_ns };
Ok((LiveOutcome::from_frame(report, frame, timings), has_visible_content))
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn paint_hardware_surface_report(
host: &mut SoftwareServoHost,
session: &LiveSession,
wait_for_completion: bool,
) -> Result<(LiveOutcome, IOSurfaceIdentity), LiveSidecarError> {
let paint_started_at = Instant::now();
host.paint_without_readback_with_completion(&session.webview_id, wait_for_completion)?;
let snapshot = host.snapshot(&session.webview_id)?;
let identity = host.peek_iosurface_identity(&session.webview_id)?.ok_or_else(|| {
ServoHostError::HardwareSurfaceUnavailable { id: session.webview_id.clone() }
})?;
let paint_ns = elapsed_ns(paint_started_at);
let encode_started_at = Instant::now();
let report = LiveFrameReport::from_surface(
&snapshot,
identity.width,
identity.height,
session.device_pixel_ratio(),
);
let encode_ns = elapsed_ns(encode_started_at);
let timings = PartialFrameTimings { paint_ns, encode_ns };
Ok((LiveOutcome::from_report(report, timings), identity))
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn payloadless_surface_pool_ready(
readiness: PayloadlessReadiness<'_>,
width: u32,
height: u32,
) -> bool {
let Some(published) = readiness.published_surface_ids.get(readiness.tab_id) else {
return false;
};
let matching = published
.iter()
.filter(|identity| identity.width == width && identity.height == height)
.copied()
.collect::<Vec<_>>();
if matching.is_empty() {
return false;
}
!readiness.require_client_ready_surfaces
|| matching
.iter()
.any(|identity| readiness.ready_surface_ids.contains(&identity.surface_id))
}
#[derive(Clone, Copy)]
struct PayloadlessReadiness<'a> {
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
tab_id: &'a str,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
published_surface_ids: &'a HashMap<String, HashSet<IOSurfaceIdentity>>,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
ready_surface_ids: &'a [u64],
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
require_client_ready_surfaces: bool,
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
_marker: std::marker::PhantomData<&'a ()>,
}
fn payloadless_readiness<'a>(
tab_id: &'a str,
published_surface_ids: &'a HashMap<String, HashSet<IOSurfaceIdentity>>,
ready_surface_ids: &'a [u64],
require_client_ready_surfaces: bool,
) -> PayloadlessReadiness<'a> {
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
{
PayloadlessReadiness {
tab_id,
published_surface_ids,
ready_surface_ids,
require_client_ready_surfaces,
}
}
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
{
let _ = (tab_id, published_surface_ids, ready_surface_ids, require_client_ready_surfaces);
PayloadlessReadiness { _marker: std::marker::PhantomData }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn awaiting_visible_frame_forces_paint_without_pending_flag() {
assert!(should_paint_live_frame(false, true));
}
#[test]
fn idle_poll_waits_for_pending_frame() {
assert!(!should_paint_live_frame(false, false));
assert!(should_paint_live_frame(true, false));
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[test]
fn payloadless_pool_accepts_one_client_ready_surface() {
let published = published_identities([identity(7, 800, 600), identity(8, 800, 600)]);
assert!(!payloadless_surface_pool_ready(readiness(&published, &[], true), 800, 600));
assert!(payloadless_surface_pool_ready(readiness(&published, &[7], true), 800, 600));
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[test]
fn payloadless_pool_uses_published_surfaces_for_no_mach_clients() {
let published = published_identities([identity(7, 800, 600), identity(8, 800, 600)]);
assert!(payloadless_surface_pool_ready(readiness(&published, &[], false), 800, 600));
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn readiness<'a>(
published_surface_ids: &'a HashMap<String, HashSet<IOSurfaceIdentity>>,
ready_surface_ids: &'a [u64],
require_client_ready_surfaces: bool,
) -> PayloadlessReadiness<'a> {
PayloadlessReadiness {
tab_id: "tab",
published_surface_ids,
ready_surface_ids,
require_client_ready_surfaces,
}
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn published_identities(
identities: [IOSurfaceIdentity; 2],
) -> HashMap<String, HashSet<IOSurfaceIdentity>> {
let mut published = HashMap::new();
published.insert("tab".to_string(), identities.into_iter().collect());
published
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn identity(surface_id: u64, width: u32, height: u32) -> IOSurfaceIdentity {
IOSurfaceIdentity { surface_id, width, height }
}
}
@@ -1,348 +0,0 @@
use std::{
collections::{HashMap, HashSet},
io::Write,
time::{Duration, Instant},
};
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
use ely_servo_host::IOSurfaceHandle;
use ely_servo_host::{IOSurfaceIdentity, SoftwareServoHost};
use super::live_protocol::{LiveOutcome, LiveSidecarError, PartialFrameTimings};
use super::perf::{FramePerfAggregator, FramePerfSummary, FrameStageTimings, elapsed_ns};
/// Populate the hardware surface protocol fields on `outcome`. Readback
/// warm-up frames publish IOSurface handles so the app can import them
/// on its dedicated importer thread before steady-state payloadless
/// frames select the rotating surface ids. Two pieces of state ride out
/// together:
///
/// * `current_surface_id` — set on every payload-bearing hardware
/// frame so the receiver knows which previously-imported
/// `MTLTexture` to sample THIS frame. surfman's attached swap
/// chain rotates front/back surfaces, so this alternates between
/// a small set of ids.
/// * `surface_handle` — populated only the first time the sidecar
/// sees a given `surface_id`; the receiver imports the IOSurface
/// once and caches the resulting Metal texture. Minting a fresh
/// mach port per frame would leak ports — `IOSurfaceCreateMachPort`
/// hands out a new send right each call and they don't free
/// automatically until the receiver `mach_port_deallocate`s.
pub(super) fn populate_surface_fields(
host: &SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
tab_id: &str,
published_surface_ids: &mut HashMap<String, HashSet<IOSurfaceIdentity>>,
publish_readback_surface_fields: bool,
outcome: &mut LiveOutcome,
) {
if outcome.response.frame.is_none() {
return;
}
if outcome.frame.is_some() && !publish_readback_surface_fields {
return;
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
{
let Ok(Some(identity)) = host.peek_iosurface_identity(webview_id) else {
return;
};
if let Err(message) = require_report_matches_surface_identity(outcome, identity) {
*outcome = LiveOutcome::error(message);
return;
}
let handle = if surface_has_been_published(published_surface_ids, tab_id, identity) {
None
} else {
host.current_iosurface_handle(webview_id).ok().flatten()
};
let publication = surface_publication_for(published_surface_ids, tab_id, identity, handle);
outcome.response.current_surface_id = publication.current_surface_id;
outcome.response.surface_handle = publication.surface_handle;
}
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
{
let _ = (host, webview_id, tab_id, published_surface_ids, publish_readback_surface_fields);
}
}
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
fn surface_has_been_published(
published_surface_ids: &HashMap<String, HashSet<IOSurfaceIdentity>>,
tab_id: &str,
identity: IOSurfaceIdentity,
) -> bool {
published_surface_ids.get(tab_id).is_some_and(|published| published.contains(&identity))
}
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
#[derive(Clone, Copy)]
struct SurfacePublication {
current_surface_id: Option<u64>,
surface_handle: Option<IOSurfaceHandle>,
}
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
fn surface_publication_for(
published_surface_ids: &mut HashMap<String, HashSet<IOSurfaceIdentity>>,
tab_id: &str,
identity: IOSurfaceIdentity,
handle: Option<IOSurfaceHandle>,
) -> SurfacePublication {
if surface_has_been_published(published_surface_ids, tab_id, identity) {
return SurfacePublication {
current_surface_id: Some(identity.surface_id),
surface_handle: None,
};
}
let Some(handle) = handle.filter(|handle| handle_matches_identity(*handle, identity)) else {
return SurfacePublication { current_surface_id: None, surface_handle: None };
};
published_surface_ids
.entry(tab_id.to_string())
.or_default()
.insert(IOSurfaceIdentity::from_handle(handle));
SurfacePublication { current_surface_id: Some(handle.surface_id), surface_handle: Some(handle) }
}
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
fn handle_matches_identity(handle: IOSurfaceHandle, identity: IOSurfaceIdentity) -> bool {
handle.surface_id == identity.surface_id
&& handle.width == identity.width
&& handle.height == identity.height
}
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
fn require_report_matches_surface_identity(
outcome: &LiveOutcome,
identity: IOSurfaceIdentity,
) -> Result<(), String> {
let Some(frame) = outcome.response.frame.as_ref() else {
return Ok(());
};
if frame.width == identity.width && frame.height == identity.height {
return Ok(());
}
Err(format!(
"servo hardware surface size {}x{} did not match frame report {}x{}",
identity.width, identity.height, frame.width, frame.height,
))
}
/// Serialise the response then stream the optional raw RGBA frame on
/// the same stdout pipe. The client reads the JSON line, takes
/// `rgba_byte_count` from the report, then reads that many bytes
/// from the same stream — no temp file round-trip.
///
/// After the bytes hit the pipe we fold paint/encode/write/total
/// timings into the aggregator. `total_ns` is the wall-clock span
/// from `frame_started_at` (request arrival) to the stdout flush
/// returning, so it captures every per-frame cost outside the three
/// measured stages. Any summary the aggregator emits is stashed on
/// `pending_summary` and rides out on the *next* response, because
/// the protocol is one-line-per-response and an unsolicited summary
/// line would desync the main process's read loop.
pub(super) fn write_outcome(
stdout: &mut impl Write,
perf: &mut FramePerfAggregator,
pending_summary: &mut Option<FramePerfSummary>,
outcome: Result<LiveOutcome, LiveSidecarError>,
frame_started_at: Instant,
) -> Result<(), LiveSidecarError> {
let mut outcome = outcome.unwrap_or_else(|error| LiveOutcome::error(error.to_string()));
let partial_timings = outcome.partial_timings.take();
let frame_present = outcome.response.frame.is_some();
if let Some(summary) = pending_summary.take() {
outcome.response.perf = Some(summary);
}
// Payloadless hardware frames carry only the IOSurface selector.
let drop_rgba_payload =
outcome.response.current_surface_id.is_some() && outcome.frame.is_none();
if drop_rgba_payload && let Some(report) = outcome.response.frame.as_mut() {
report.rgba_byte_count = 0;
}
let write_started_at = Instant::now();
serde_json::to_writer(&mut *stdout, &outcome.response)?;
stdout.write_all(b"\n")?;
if !drop_rgba_payload && let Some(frame) = outcome.frame.as_ref() {
stdout.write_all(frame.rgba_bytes())?;
}
stdout.flush()?;
if frame_present {
let write_ns = elapsed_ns(write_started_at);
let total_ns = elapsed_ns(frame_started_at);
let partial = partial_timings.unwrap_or(PartialFrameTimings { paint_ns: 0, encode_ns: 0 });
let timings = FrameStageTimings::from_durations(
Duration::from_nanos(partial.paint_ns),
Duration::from_nanos(partial.encode_ns),
Duration::from_nanos(write_ns),
Duration::from_nanos(total_ns),
);
if let Some(summary) = perf.record(timings) {
*pending_summary = Some(summary);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::{collections::HashMap, error::Error, time::Instant};
use ely_servo_host::{IOSurfaceHandle, IOSurfaceIdentity};
use super::super::{
live_protocol::{LiveFrameReport, LiveOutcome, PartialFrameTimings},
perf::FramePerfAggregator,
};
use super::{require_report_matches_surface_identity, surface_publication_for, write_outcome};
#[test]
fn unpublished_surface_without_handle_leaves_selector_empty() {
let mut published = HashMap::new();
let publication =
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), None);
assert_eq!(publication.current_surface_id, None);
assert!(publication.surface_handle.is_none());
assert!(published.is_empty());
}
#[test]
fn unpublished_surface_with_matching_handle_publishes_selector_and_handle() {
let mut published = HashMap::new();
let handle = handle(7, 800, 600);
let publication =
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(handle));
assert_eq!(publication.current_surface_id, Some(7));
assert_eq!(publication.surface_handle, Some(handle));
assert!(published.get("tab-1").is_some_and(|ids| ids.contains(&identity(7, 800, 600))));
}
#[test]
fn published_surface_reuses_selector_without_republishing_handle() {
let mut published = HashMap::new();
let handle = handle(7, 800, 600);
let _ =
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(handle));
let publication =
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), None);
assert_eq!(publication.current_surface_id, Some(7));
assert!(publication.surface_handle.is_none());
}
#[test]
fn same_surface_id_with_changed_dimensions_republishes_handle() {
let mut published = HashMap::new();
let initial = handle(7, 800, 600);
let resized = handle(7, 1024, 768);
let _ =
surface_publication_for(&mut published, "tab-1", identity(7, 800, 600), Some(initial));
let publication =
surface_publication_for(&mut published, "tab-1", identity(7, 1024, 768), Some(resized));
assert_eq!(publication.current_surface_id, Some(7));
assert_eq!(publication.surface_handle, Some(resized));
}
#[test]
fn hardware_report_mismatch_is_reported() -> Result<(), Box<dyn Error>> {
let outcome = LiveOutcome::from_report(
report_with_size(2180, 1586),
PartialFrameTimings { paint_ns: 1_000, encode_ns: 2_000 },
);
let error = match require_report_matches_surface_identity(&outcome, identity(7, 2168, 1566))
{
Ok(()) => return Err("mismatched IOSurface dimensions must be reported".into()),
Err(error) => error,
};
assert_eq!(
error,
"servo hardware surface size 2168x1566 did not match frame report 2180x1586",
);
Ok(())
}
#[test]
fn mismatched_handle_leaves_surface_unpublished() {
let mut published = HashMap::new();
let publication = surface_publication_for(
&mut published,
"tab-1",
identity(7, 800, 600),
Some(handle(8, 800, 600)),
);
assert_eq!(publication.current_surface_id, None);
assert!(publication.surface_handle.is_none());
assert!(published.is_empty());
}
#[test]
fn payloadless_surface_report_records_perf_and_writes_no_rgba() -> Result<(), Box<dyn Error>> {
let mut outcome = LiveOutcome::from_report(
report_with_byte_count(16),
PartialFrameTimings { paint_ns: 1_000, encode_ns: 2_000 },
);
outcome.response.current_surface_id = Some(7);
let mut stdout = Vec::new();
let mut perf = FramePerfAggregator::new("hardware", 1);
let mut pending_summary = None;
write_outcome(&mut stdout, &mut perf, &mut pending_summary, Ok(outcome), Instant::now())?;
let Some(newline_index) = stdout.iter().position(|byte| *byte == b'\n') else {
return Err("response newline missing".into());
};
let line = std::str::from_utf8(&stdout[..newline_index])?;
let response: serde_json::Value = serde_json::from_str(line)?;
let rgba_byte_count = response
.get("frame")
.and_then(|frame| frame.get("rgba_byte_count"))
.and_then(serde_json::Value::as_u64);
assert_eq!(rgba_byte_count, Some(0));
assert!(stdout[newline_index + 1..].is_empty());
assert!(pending_summary.is_some());
Ok(())
}
fn identity(surface_id: u64, width: u32, height: u32) -> IOSurfaceIdentity {
IOSurfaceIdentity { surface_id, width, height }
}
fn handle(surface_id: u64, width: u32, height: u32) -> IOSurfaceHandle {
IOSurfaceHandle { mach_port_name: 42, surface_id, width, height }
}
fn report_with_byte_count(rgba_byte_count: usize) -> LiveFrameReport {
let mut report = report_with_size(2, 2);
report.rgba_byte_count = rgba_byte_count;
report
}
fn report_with_size(width: u32, height: u32) -> LiveFrameReport {
LiveFrameReport {
loaded_url: Some("https://example.com/".to_string()),
title: Some("Example".to_string()),
state: "complete",
width,
height,
device_pixel_ratio: 1.0,
css_viewport_width: width,
css_viewport_height: height,
rgba_byte_count: 0,
non_white_pixel_count: 0,
content_pixel_count: 0,
sample_hash: 0,
}
}
}
@@ -1,300 +0,0 @@
//! Wire types for the sidecar live loop. Split out of `live.rs` to
//! keep the hot loop and protocol surface in separate files.
use std::io;
use ely_servo_host::{
IOSurfaceHandle, RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
use super::iosurface_mach::IOSurfaceMachError;
use super::perf::FramePerfSummary;
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(super) enum LiveRequest {
Ensure {
tab_id: String,
profile_id: String,
url: String,
width: u32,
height: u32,
page_zoom_percent: u16,
/// Display scale factor reported by the host's window
/// (1.0 standard, 2.0 Retina). The sidecar plumbs this into
/// Servo's `WebView::set_hidpi_scale_factor` so CSS layout
/// happens at logical-pixel dimensions instead of physical.
/// Defaults to 1.0 for backward compatibility if a client
/// (e.g. the live perf bench) omits the field.
#[serde(default = "default_device_pixel_ratio")]
device_pixel_ratio: f32,
scroll_delta_x: i32,
scroll_delta_y: i32,
scroll_point_x: Option<u32>,
scroll_point_y: Option<u32>,
click_x: Option<u32>,
click_y: Option<u32>,
#[serde(default)]
hover_x: Option<u32>,
#[serde(default)]
hover_y: Option<u32>,
typed_text: Option<String>,
site_permissions: Vec<LiveSitePermission>,
#[serde(default)]
ready_surface_ids: Vec<u64>,
},
Poll {
tab_id: String,
#[serde(default)]
ready_surface_ids: Vec<u64>,
},
Close {
tab_id: String,
},
}
fn default_device_pixel_ratio() -> f32 {
1.0
}
#[derive(Deserialize)]
pub(super) struct LiveSitePermission {
pub origin: String,
pub feature: String,
pub decision: String,
}
/// Partial stage timings captured inside `poll_frame` before the
/// write phase. Combined with the write-stage duration measured by
/// `write_outcome` to form a full set of frame timings.
#[derive(Clone, Copy, Debug)]
pub(super) struct PartialFrameTimings {
pub paint_ns: u64,
pub encode_ns: u64,
}
/// A response plus an optional software RGBA payload and partial stage
/// timings. Software frames carry `RenderedFrame` so the write step
/// can stream its existing rgba slice straight onto the pipe; hardware
/// surface frames carry only a `LiveFrameReport`.
pub(super) struct LiveOutcome {
pub response: LiveResponse,
pub frame: Option<RenderedFrame>,
pub partial_timings: Option<PartialFrameTimings>,
}
impl LiveOutcome {
pub fn empty() -> Self {
Self { response: LiveResponse::empty(), frame: None, partial_timings: None }
}
pub fn error(message: String) -> Self {
Self { response: LiveResponse::error(message), frame: None, partial_timings: None }
}
pub fn from_frame(
report: LiveFrameReport,
frame: RenderedFrame,
partial_timings: PartialFrameTimings,
) -> Self {
Self {
response: LiveResponse::frame(report),
frame: Some(frame),
partial_timings: Some(partial_timings),
}
}
#[cfg(any(test, all(feature = "hardware-render", target_os = "macos")))]
pub fn from_report(report: LiveFrameReport, partial_timings: PartialFrameTimings) -> Self {
Self {
response: LiveResponse::frame(report),
frame: None,
partial_timings: Some(partial_timings),
}
}
}
#[derive(Serialize)]
pub(super) struct LiveResponse {
pub error: Option<String>,
pub frame: Option<LiveFrameReport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub perf: Option<FramePerfSummary>,
/// Populated on the first frame the sidecar emits for a given
/// surface — initial paint, after a resize, or whenever surfman
/// rotates its swap chain to a surface we haven't seen yet. The
/// receiver imports the IOSurface (via
/// `IOSurfaceLookupFromMachPort`) once per `surface_id` and caches
/// the resulting Metal texture. Always `None` on the software
/// path.
#[serde(skip_serializing_if = "Option::is_none")]
pub surface_handle: Option<IOSurfaceHandle>,
/// Populated on every hardware paint frame. Tells the receiver
/// which previously-imported IOSurface to sample THIS frame. The
/// surfman attached swap chain rotates between front/back
/// surfaces, so this id alternates between the values the receiver
/// has already imported. Always `None` on the software path.
#[serde(skip_serializing_if = "Option::is_none")]
pub current_surface_id: Option<u64>,
}
impl LiveResponse {
fn empty() -> Self {
Self {
error: None,
frame: None,
perf: None,
surface_handle: None,
current_surface_id: None,
}
}
fn frame(frame: LiveFrameReport) -> Self {
Self {
error: None,
frame: Some(frame),
perf: None,
surface_handle: None,
current_surface_id: None,
}
}
fn error(message: String) -> Self {
Self {
error: Some(message),
frame: None,
perf: None,
surface_handle: None,
current_surface_id: None,
}
}
}
#[derive(Serialize)]
pub(super) struct LiveFrameReport {
pub loaded_url: Option<String>,
pub title: Option<String>,
pub state: &'static str,
pub width: u32,
pub height: u32,
pub device_pixel_ratio: f32,
pub css_viewport_width: u32,
pub css_viewport_height: u32,
pub rgba_byte_count: usize,
pub non_white_pixel_count: u64,
pub content_pixel_count: u64,
pub sample_hash: u64,
}
impl LiveFrameReport {
pub fn new(snapshot: &WebViewSnapshot, frame: &RenderedFrame, device_pixel_ratio: f32) -> Self {
let (css_viewport_width, css_viewport_height) =
css_viewport_size(frame.width(), frame.height(), device_pixel_ratio);
Self {
loaded_url: snapshot.url().map(str::to_string),
title: snapshot.title().map(str::to_string),
state: state_label(snapshot.state()),
width: frame.width(),
height: frame.height(),
device_pixel_ratio,
css_viewport_width,
css_viewport_height,
rgba_byte_count: frame.rgba_bytes().len(),
non_white_pixel_count: frame.non_white_pixel_count(),
content_pixel_count: frame.content_pixel_count(),
sample_hash: frame.sample_hash(),
}
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub fn from_surface(
snapshot: &WebViewSnapshot,
width: u32,
height: u32,
device_pixel_ratio: f32,
) -> Self {
let (css_viewport_width, css_viewport_height) =
css_viewport_size(width, height, device_pixel_ratio);
Self {
loaded_url: snapshot.url().map(str::to_string),
title: snapshot.title().map(str::to_string),
state: state_label(snapshot.state()),
width,
height,
device_pixel_ratio,
css_viewport_width,
css_viewport_height,
rgba_byte_count: 0,
non_white_pixel_count: 0,
content_pixel_count: 0,
sample_hash: 0,
}
}
}
fn css_viewport_size(width: u32, height: u32, device_pixel_ratio: f32) -> (u32, u32) {
let dpr = if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 {
device_pixel_ratio
} else {
1.0
};
(
((width as f32) / dpr).round().max(1.0) as u32,
((height as f32) / dpr).round().max(1.0) as u32,
)
}
fn state_label(state: &WebViewState) -> &'static str {
match state {
WebViewState::Created => "created",
WebViewState::Loading => "loading",
WebViewState::Complete => "complete",
WebViewState::Sleeping => "sleeping",
WebViewState::Crashed => "crashed",
}
}
#[derive(Debug, Error)]
pub(super) enum LiveSidecarError {
#[error("live session is unavailable after creation")]
SessionUnavailable,
#[error("scroll input requires both scroll_point_x and scroll_point_y")]
IncompleteScrollPoint,
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
#[error(transparent)]
Host(#[from] ServoHostError),
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[error(transparent)]
IOSurfaceMach(#[from] IOSurfaceMachError),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn close_request_deserializes_from_wire() -> Result<(), serde_json::Error> {
let request =
serde_json::from_str::<LiveRequest>(r#"{"type":"close","tab_id":"tab-live-close"}"#)?;
assert!(matches!(
request,
LiveRequest::Close { tab_id } if tab_id == "tab-live-close"
));
Ok(())
}
}
@@ -1,240 +0,0 @@
use std::collections::HashMap;
use ely_domain::{DEFAULT_ZOOM_PERCENT, ProfileId, TabId};
use ely_servo_host::{
KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, PageZoomRequest, PermissionDecision,
PermissionRequest, ResizeRequest, ScrollRequest, ServoHost, ServoSurfaceSize,
SoftwareServoHost,
};
use super::live_protocol::{LiveSidecarError, LiveSitePermission};
#[derive(Clone)]
pub(super) struct LiveSession {
pub(super) webview_id: ely_domain::WebViewId,
pub(super) requested_url: String,
pub(super) width: u32,
pub(super) height: u32,
page_zoom_percent: u16,
/// Last hidpi factor pushed to Servo, encoded as `(scale × 1000)`.
/// Stored as a u32 so equality is cheap and stable across the
/// f32 jitter that JSON parsing can introduce. Init to 0 so the
/// first apply_layout call always pushes a real value.
hidpi_scale_milli: u32,
pub(super) scroll_x: i32,
pub(super) scroll_y: i32,
pub(super) awaiting_visible_frame: bool,
/// Sticky for the lifetime of a single URL: flipped to `true`
/// the first time `poll_frame` sees a paint with real content
/// (non-white, non-empty) and reset to `false` on every navigate.
/// After it's `true`, the visible-content gate stops gating:
/// scroll/click/hover/type all return on the first
/// `has_pending_frame=true` (~3 ms) instead of waiting the full
/// `LIVE_FRAME_WAIT_TIMEOUT`. The gate stays armed for the
/// initial paint of each new URL so loading frames are still
/// skipped.
pub(super) ever_visible_frame: bool,
}
impl LiveSession {
fn new(webview_id: ely_domain::WebViewId, _width: u32, _height: u32) -> Self {
Self {
webview_id,
requested_url: String::new(),
width: 0,
height: 0,
page_zoom_percent: DEFAULT_ZOOM_PERCENT,
hidpi_scale_milli: 0,
scroll_x: 0,
scroll_y: 0,
awaiting_visible_frame: false,
ever_visible_frame: false,
}
}
pub(super) fn device_pixel_ratio(&self) -> f32 {
hidpi_scale_milli_to_f32(self.hidpi_scale_milli)
}
}
pub(super) fn ensure_session<'a>(
host: &mut SoftwareServoHost,
sessions: &'a mut HashMap<String, LiveSession>,
key: String,
tab_id: &TabId,
profile_id: &ProfileId,
width: u32,
height: u32,
) -> Result<&'a mut LiveSession, LiveSidecarError> {
if !sessions.contains_key(&key) {
let webview_id = host.create_webview_with_size(
tab_id.clone(),
profile_id.clone(),
ServoSurfaceSize::new(width, height),
)?;
sessions.insert(key.clone(), LiveSession::new(webview_id, width, height));
}
sessions.get_mut(&key).ok_or(LiveSidecarError::SessionUnavailable)
}
pub(super) fn apply_layout(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
width: u32,
height: u32,
page_zoom_percent: u16,
device_pixel_ratio: f32,
) -> Result<bool, LiveSidecarError> {
let mut changed = false;
// Push the device pixel ratio BEFORE resize. Servo's WebView
// defaults hidpi to 1.0; without this the first layout treats
// physical-pixel viewport widths as CSS-pixel widths and the page
// lays out half the size you'd expect on a Retina display.
let hidpi_scale_milli = encode_hidpi_scale_milli(device_pixel_ratio);
if session.hidpi_scale_milli != hidpi_scale_milli {
host.set_hidpi_scale(ely_servo_host::HidpiScaleRequest {
webview_id: session.webview_id.clone(),
scale_factor: hidpi_scale_milli_to_f32(hidpi_scale_milli),
})?;
session.hidpi_scale_milli = hidpi_scale_milli;
changed = true;
}
if session.width != width || session.height != height {
host.resize(ResizeRequest { webview_id: session.webview_id.clone(), width, height })?;
session.width = width;
session.height = height;
changed = true;
}
if session.page_zoom_percent != page_zoom_percent {
host.set_page_zoom(PageZoomRequest {
webview_id: session.webview_id.clone(),
zoom_factor: f32::from(page_zoom_percent) / 100.0,
})?;
session.page_zoom_percent = page_zoom_percent;
changed = true;
}
Ok(changed)
}
pub(super) fn apply_permissions(
host: &mut SoftwareServoHost,
session: &LiveSession,
profile_id: &ProfileId,
permissions: Vec<LiveSitePermission>,
) -> Result<(), LiveSidecarError> {
for permission in permissions {
host.set_permission(
PermissionRequest {
webview_id: session.webview_id.clone(),
profile_id: profile_id.clone(),
origin: ely_domain::SiteOrigin::parse(permission.origin)?,
feature: ely_domain::SitePermissionFeature::parse(permission.feature.as_str())?,
},
PermissionDecision::from(ely_domain::SitePermissionDecision::parse(
permission.decision.as_str(),
)?),
)?;
}
Ok(())
}
pub(super) fn apply_input(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
input: LiveInput,
) -> Result<bool, LiveSidecarError> {
let mut changed = false;
if input.scroll_delta_x != 0 || input.scroll_delta_y != 0 {
let (point_x, point_y) = input.scroll_point()?;
host.scroll(ScrollRequest {
webview_id: session.webview_id.clone(),
delta_x: input.scroll_delta_x,
delta_y: input.scroll_delta_y,
point_x,
point_y,
})?;
session.scroll_x = positive_scroll_component(session.scroll_x, input.scroll_delta_x);
session.scroll_y = positive_scroll_component(session.scroll_y, input.scroll_delta_y);
changed = true;
}
if let (Some(x), Some(y)) = (input.hover_x, input.hover_y) {
host.hover(MouseHoverRequest { webview_id: session.webview_id.clone(), x, y })?;
changed = true;
}
if let (Some(x), Some(y)) = (input.click_x, input.click_y) {
host.click(MouseClickRequest { webview_id: session.webview_id.clone(), x, y })?;
changed = true;
}
if let Some(text) = input.typed_text {
host.type_text(KeyboardTextRequest { webview_id: session.webview_id.clone(), text })?;
changed = true;
}
Ok(changed)
}
pub(super) struct LiveInput {
pub(super) scroll_delta_x: i32,
pub(super) scroll_delta_y: i32,
pub(super) scroll_point_x: Option<u32>,
pub(super) scroll_point_y: Option<u32>,
pub(super) click_x: Option<u32>,
pub(super) click_y: Option<u32>,
pub(super) hover_x: Option<u32>,
pub(super) hover_y: Option<u32>,
pub(super) typed_text: Option<String>,
}
impl LiveInput {
fn scroll_point(&self) -> Result<(u32, u32), LiveSidecarError> {
let point = match (self.scroll_point_x, self.scroll_point_y) {
(Some(x), Some(y)) => (x, y),
_ => return Err(LiveSidecarError::IncompleteScrollPoint),
};
Ok(point)
}
}
fn encode_hidpi_scale_milli(scale: f32) -> u32 {
if !scale.is_finite() || scale <= 0.0 {
return 1_000;
}
let scaled = (scale * 1_000.0).round();
scaled.clamp(500.0, 5_000.0) as u32
}
fn hidpi_scale_milli_to_f32(milli: u32) -> f32 {
milli as f32 / 1_000.0
}
fn positive_scroll_component(current: i32, delta: i32) -> i32 {
let value = i64::from(current) + i64::from(delta);
value.clamp(0, i64::from(i32::MAX)) as i32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_live_session_forces_first_resize_after_hidpi() {
let session = LiveSession::new(ely_domain::WebViewId::new(), 1280, 720);
assert_ne!(
session.width, 1280,
"first apply_layout must resize after hidpi has been pushed",
);
assert_ne!(
session.height, 720,
"first apply_layout must resize after hidpi has been pushed",
);
}
}
@@ -1,257 +0,0 @@
//! Per-frame paint→encode→write stage timings for the live sidecar
//! loop, plus exact fixed-window percentile summaries so the main
//! process can read out p50/p95/p99 latencies.
//!
//! Why this lives next to `live.rs`: the sidecar already owns the hot
//! loop. Sampling here costs one `Instant::now()` per stage boundary.
//! Each stage keeps one preallocated window of nanosecond samples and
//! sorts only at the 60-frame summary boundary, so steady-state record
//! cost stays a single push per stage while p95 remains exact enough
//! for 120 fps gates.
use std::time::{Duration, Instant};
/// Per-frame stage timings captured by the live loop.
///
/// `total_ns` is the real wall-clock span from request arrival to the
/// stdout flush returning, so it captures every byte of overhead
/// outside paint/encode/write (snapshot reads, JSON parse, scratch
/// allocations). It is measured at the loop boundary, not summed.
#[derive(Clone, Copy, Debug)]
pub(super) struct FrameStageTimings {
pub paint_ns: u64,
pub encode_ns: u64,
pub write_ns: u64,
pub total_ns: u64,
}
impl FrameStageTimings {
pub(super) fn from_durations(
paint: Duration,
encode: Duration,
write: Duration,
total: Duration,
) -> Self {
Self {
paint_ns: duration_to_ns(paint),
encode_ns: duration_to_ns(encode),
write_ns: duration_to_ns(write),
total_ns: duration_to_ns(total),
}
}
}
fn duration_to_ns(duration: Duration) -> u64 {
u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX)
}
/// Saturating elapsed-ns helper. `Instant::elapsed` is monotonic but
/// the cast can still overflow on the (impossible) hour-long frame.
pub(super) fn elapsed_ns(start: Instant) -> u64 {
duration_to_ns(start.elapsed())
}
#[derive(Debug)]
struct StageSamples {
values: Vec<u64>,
}
impl StageSamples {
fn new(window_size: usize) -> Self {
Self { values: Vec::with_capacity(window_size) }
}
fn record(&mut self, ns: u64) {
self.values.push(ns);
}
fn len(&self) -> usize {
self.values.len()
}
fn percentiles_us(&self) -> StagePercentiles {
let mut sorted = self.values.clone();
sorted.sort_unstable();
StagePercentiles {
p50: percentile_us(&sorted, 0.50),
p95: percentile_us(&sorted, 0.95),
p99: percentile_us(&sorted, 0.99),
}
}
fn reset(&mut self) {
self.values.clear();
}
}
#[derive(Clone, Copy)]
struct StagePercentiles {
p50: u64,
p95: u64,
p99: u64,
}
fn percentile_us(sorted_ns: &[u64], percentile: f64) -> u64 {
if sorted_ns.is_empty() {
return 0;
}
let target = ((sorted_ns.len() as f64) * percentile).ceil() as usize;
let index = target.max(1).min(sorted_ns.len()) - 1;
ns_to_us_ceil(sorted_ns[index])
}
fn ns_to_us_ceil(ns: u64) -> u64 {
ns.div_ceil(1_000)
}
/// Aggregates a rolling window of [`FrameStageTimings`] across N
/// frames, exposing one [`FramePerfSummary`] per window flush.
pub(super) struct FramePerfAggregator {
window_size: usize,
paint: StageSamples,
encode: StageSamples,
write: StageSamples,
total: StageSamples,
context_label: &'static str,
}
impl FramePerfAggregator {
pub(super) const DEFAULT_WINDOW_SIZE: u32 = 60;
pub(super) fn new(context_label: &'static str, window_size: u32) -> Self {
let window_size = usize::try_from(window_size.max(1)).unwrap_or(usize::MAX);
Self {
window_size,
paint: StageSamples::new(window_size),
encode: StageSamples::new(window_size),
write: StageSamples::new(window_size),
total: StageSamples::new(window_size),
context_label,
}
}
pub(super) fn record(&mut self, timings: FrameStageTimings) -> Option<FramePerfSummary> {
self.paint.record(timings.paint_ns);
self.encode.record(timings.encode_ns);
self.write.record(timings.write_ns);
self.total.record(timings.total_ns);
if self.paint.len() < self.window_size {
return None;
}
let paint = self.paint.percentiles_us();
let encode = self.encode.percentiles_us();
let write = self.write.percentiles_us();
let total = self.total.percentiles_us();
let summary = FramePerfSummary {
window: u32::try_from(self.paint.len()).unwrap_or(u32::MAX),
context: self.context_label,
paint_p50_us: paint.p50,
paint_p95_us: paint.p95,
paint_p99_us: paint.p99,
encode_p50_us: encode.p50,
encode_p95_us: encode.p95,
encode_p99_us: encode.p99,
write_p50_us: write.p50,
write_p95_us: write.p95,
write_p99_us: write.p99,
total_p50_us: total.p50,
total_p95_us: total.p95,
total_p99_us: total.p99,
};
self.paint.reset();
self.encode.reset();
self.write.reset();
self.total.reset();
Some(summary)
}
}
#[derive(Clone, Copy, Debug, serde::Serialize)]
pub(super) struct FramePerfSummary {
pub window: u32,
pub context: &'static str,
pub paint_p50_us: u64,
pub paint_p95_us: u64,
pub paint_p99_us: u64,
pub encode_p50_us: u64,
pub encode_p95_us: u64,
pub encode_p99_us: u64,
pub write_p50_us: u64,
pub write_p95_us: u64,
pub write_p99_us: u64,
pub total_p50_us: u64,
pub total_p95_us: u64,
pub total_p99_us: u64,
}
#[cfg(test)]
mod tests {
use super::{FramePerfAggregator, FrameStageTimings, percentile_us};
use std::time::Duration;
#[test]
fn percentile_us_uses_nearest_rank_and_ceils_microseconds() {
let sorted_ns = [1, 1_000, 1_001];
assert_eq!(percentile_us(&sorted_ns, 0.50), 1);
assert_eq!(percentile_us(&sorted_ns, 0.95), 2);
assert_eq!(percentile_us(&sorted_ns, 0.99), 2);
}
#[test]
fn aggregator_emits_summary_after_window_size_records() -> Result<(), &'static str> {
let mut aggregator =
FramePerfAggregator::new("software", FramePerfAggregator::DEFAULT_WINDOW_SIZE);
for index in 0..(FramePerfAggregator::DEFAULT_WINDOW_SIZE - 1) {
let result = aggregator.record(constant_timing());
assert!(result.is_none(), "should not flush at frame {index}");
}
let summary = aggregator
.record(constant_timing())
.ok_or("aggregator must flush at window boundary")?;
assert_eq!(summary.window, FramePerfAggregator::DEFAULT_WINDOW_SIZE);
assert_eq!(summary.context, "software");
Ok(())
}
#[test]
fn aggregator_resets_after_flush_so_next_window_starts_fresh() {
let mut aggregator = FramePerfAggregator::new("hardware", 2);
let _ = aggregator.record(constant_timing());
let summary = aggregator.record(constant_timing());
assert!(summary.is_some(), "expected first flush");
let after_flush = aggregator.record(constant_timing());
assert!(after_flush.is_none(), "aggregator must zero counters after flush");
}
#[test]
fn aggregator_percentiles_track_increasing_paint_durations() -> Result<(), &'static str> {
let mut aggregator = FramePerfAggregator::new("software", 4);
let paint_durations_us = [10u64, 100, 1_000, 10_000];
let mut summary = None;
for paint_us in paint_durations_us {
summary = aggregator.record(FrameStageTimings::from_durations(
Duration::from_micros(paint_us),
Duration::from_micros(1),
Duration::from_micros(1),
Duration::from_micros(paint_us + 2),
));
}
let summary = summary.ok_or("4-frame window must flush")?;
assert_eq!(summary.paint_p50_us, 100);
assert_eq!(summary.paint_p95_us, 10_000);
assert_eq!(summary.paint_p99_us, 10_000);
assert_eq!(summary.total_p50_us, 102);
assert_eq!(summary.total_p95_us, 10_002);
assert_eq!(summary.total_p99_us, 10_002);
Ok(())
}
fn constant_timing() -> FrameStageTimings {
FrameStageTimings::from_durations(
Duration::from_micros(2_000),
Duration::from_micros(500),
Duration::from_micros(100),
Duration::from_micros(2_600),
)
}
}
@@ -1,98 +0,0 @@
use ely_servo_host::{RenderedFrame, WebViewSnapshot, WebViewState};
use serde::Serialize;
use super::args::SnapshotArgs;
pub(super) struct SnapshotInputChanges {
pub(super) scroll: bool,
pub(super) click: bool,
pub(super) drag: bool,
pub(super) touch: bool,
pub(super) text: bool,
}
#[derive(Serialize)]
pub(super) struct SnapshotReport {
requested_url: String,
profile_id: String,
loaded_url: Option<String>,
title: Option<String>,
rgba_path: String,
state: &'static str,
width: u32,
height: u32,
rgba_byte_count: usize,
opaque_pixel_count: u64,
non_white_pixel_count: u64,
content_pixel_count: u64,
sample_hash: u64,
scroll_x: i32,
scroll_y: i32,
page_zoom_percent: u16,
scroll_changed_frame: bool,
click_x: Option<u32>,
click_y: Option<u32>,
click_changed_frame: bool,
drag_from_x: Option<u32>,
drag_from_y: Option<u32>,
drag_to_x: Option<u32>,
drag_to_y: Option<u32>,
drag_changed_frame: bool,
touch_x: Option<u32>,
touch_y: Option<u32>,
touch_changed_frame: bool,
typed_text_byte_count: usize,
text_changed_frame: bool,
}
impl SnapshotReport {
pub(super) fn new(
args: &SnapshotArgs,
snapshot: &WebViewSnapshot,
frame: &RenderedFrame,
changes: SnapshotInputChanges,
) -> Self {
Self {
requested_url: args.url.as_str().to_string(),
profile_id: snapshot.profile_id().as_str().to_string(),
loaded_url: snapshot.url().map(str::to_string),
title: snapshot.title().map(str::to_string),
rgba_path: args.rgba_out.display().to_string(),
state: state_label(snapshot.state()),
width: frame.width(),
height: frame.height(),
rgba_byte_count: frame.rgba_bytes().len(),
opaque_pixel_count: frame.opaque_pixel_count(),
non_white_pixel_count: frame.non_white_pixel_count(),
content_pixel_count: frame.content_pixel_count(),
sample_hash: frame.sample_hash(),
scroll_x: args.scroll_x,
scroll_y: args.scroll_y,
page_zoom_percent: args.page_zoom_percent,
scroll_changed_frame: changes.scroll,
click_x: args.click_point.map(|point| point.x),
click_y: args.click_point.map(|point| point.y),
click_changed_frame: changes.click,
drag_from_x: args.drag_points.map(|points| points.from.x),
drag_from_y: args.drag_points.map(|points| points.from.y),
drag_to_x: args.drag_points.map(|points| points.to.x),
drag_to_y: args.drag_points.map(|points| points.to.y),
drag_changed_frame: changes.drag,
touch_x: args.touch_point.map(|point| point.x),
touch_y: args.touch_point.map(|point| point.y),
touch_changed_frame: changes.touch,
typed_text_byte_count: args.typed_text.as_ref().map_or(0, String::len),
text_changed_frame: changes.text,
}
}
}
fn state_label(state: &WebViewState) -> &'static str {
match state {
WebViewState::Created => "created",
WebViewState::Loading => "loading",
WebViewState::Complete => "complete",
WebViewState::Sleeping => "sleeping",
WebViewState::Crashed => "crashed",
}
}
-15
View File
@@ -24,21 +24,6 @@ pub enum ServoHostError {
#[error("servo rendering context could not be made current")]
RenderingContextNotCurrent,
#[error(
"hardware rendering context requested but the `hardware-render` feature \
was not compiled in; rebuild with --features servo-engine,hardware-render"
)]
HardwareRenderUnavailable,
#[error("servo rendered frame is unavailable")]
RenderedFrameUnavailable,
#[error("servo hardware surface is unavailable for {id}")]
HardwareSurfaceUnavailable { id: WebViewId },
#[error("servo screenshot capture timed out for {id}")]
ScreenshotTimedOut { id: WebViewId },
#[error("servo screenshot capture failed: {reason}")]
ScreenshotUnavailable { reason: String },
}
@@ -1,398 +0,0 @@
//! Headless hardware [`RenderingContext`] for Servo, vendored from
//! `servo-paint-api`'s private `SurfmanRenderingContext` and reshaped
//! so it can be constructed without a `RawWindowHandle`.
//!
//! Why this file exists: `servo-paint-api 0.1` exposes three
//! constructors — `SoftwareRenderingContext` (CPU-only),
//! `WindowRenderingContext` (requires `DisplayHandle + WindowHandle`),
//! and `OffscreenRenderingContext` (must be a child of a
//! `WindowRenderingContext`). The sidecar process has no window, so
//! none of the three works for us when we want **hardware**
//! rasterising. The underlying `SurfmanRenderingContext` glue *can*
//! drive a hardware adapter against a `SurfaceType::Generic`
//! offscreen surface — that's exactly what we need — but its
//! constructor is `fn new` (private). Until Servo accepts an upstream
//! PR exposing a headless hardware constructor, this file vendors the
//! minimal slice of glue we need.
//!
//! Scope kept deliberately narrow:
//!
//! * `prepare_for_rendering`, `read_to_image`, `size`, `resize`,
//! `present`, `make_current`, `gleam_gl_api`, `glow_gl_api`, and
//! `connection` are vendored. `connection` is mandatory:
//! `servo-paint`'s painter calls `rendering_context.connection()
//! .expect("Failed to get connection")` while constructing its
//! painter, so a `None` default panics the compositor before the
//! first frame is ever painted.
//! * `create_texture`/`destroy_texture` still fall through to the
//! trait defaults — Servo only reaches for them when sharing
//! surfman surfaces with its compositor for WebGL/WebGPU, which
//! this readback path does not exercise.
//! * No `RefreshDriver`. The sidecar drives its own polling loop.
//! * The reading path inlines `read_framebuffer_to_image` from the
//! same upstream file so we don't take a dependency on a private
//! helper that may change shape.
//!
//! This is feature-gated on `hardware-render`. The default build path
//! (and every existing test in this repo) keeps using
//! `SoftwareRenderingContext`; the hardware constructor only exists
//! when the feature is enabled, which is also when the additional
//! surfman/gleam/glow deps are pulled in.
#![cfg(feature = "hardware-render")]
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;
use dpi::PhysicalSize;
use euclid::Size2D;
use gleam::gl::{self, Gl};
use image::RgbaImage;
use servo::{DeviceIntRect, RenderingContext};
use surfman::chains::{PreserveBuffer, SwapChain, SwapChainAPI};
#[cfg(target_os = "macos")]
use surfman::platform::macos::cgl::surface::NativeSurface;
use surfman::{
Connection, Context, ContextAttributeFlags, ContextAttributes, Device, Error as SurfmanError,
GLApi, NativeWidget, Surface, SurfaceAccess, SurfaceType,
};
/// A headless hardware-backed [`RenderingContext`].
///
/// Construct with [`HardwareOffscreenContext::new`]; drop normally to
/// release the surfman context, surface, and swap chain.
pub struct HardwareOffscreenContext {
size: Cell<PhysicalSize<u32>>,
inner: SurfmanInner,
swap_chain: SwapChain<Device>,
#[cfg(target_os = "macos")]
held_presented_surface: RefCell<Option<Surface>>,
#[cfg(target_os = "macos")]
last_presented_iosurface: RefCell<Option<PresentedIOSurface>>,
}
impl HardwareOffscreenContext {
/// Build a new hardware context with an offscreen
/// [`SurfaceType::Generic`] surface of the requested size.
///
/// Uses `Connection::new()` to pick the platform default
/// (CGL on macOS — which backs surfaces with `IOSurface`s —
/// EGL on Linux, WGL on Windows) and `create_adapter()` for the
/// real GPU adapter. Falls back nowhere: if the host can't give
/// us a hardware GL context, the returned `Err` carries the
/// surfman cause and the caller is expected to either retry with
/// the software path or surface the failure.
pub fn new(size: PhysicalSize<u32>) -> Result<Self, SurfmanError> {
let connection = Connection::new()?;
let adapter = connection.create_adapter()?;
let inner = SurfmanInner::new(&connection, &adapter)?;
let surfman_size = Size2D::new(size.width as i32, size.height as i32);
let surface = inner.create_surface(SurfaceType::Generic { size: surfman_size })?;
inner.bind_surface(surface)?;
inner.make_current()?;
let swap_chain = inner.create_attached_swap_chain()?;
Ok(Self {
size: Cell::new(size),
inner,
swap_chain,
#[cfg(target_os = "macos")]
held_presented_surface: RefCell::new(None),
#[cfg(target_os = "macos")]
last_presented_iosurface: RefCell::new(None),
})
}
}
impl Drop for HardwareOffscreenContext {
fn drop(&mut self) {
let device = &mut self.inner.device.borrow_mut();
let context = &mut self.inner.context.borrow_mut();
#[cfg(target_os = "macos")]
self.destroy_held_presented_surface(device, context);
let _ = self.swap_chain.destroy(device, context);
}
}
impl RenderingContext for HardwareOffscreenContext {
fn prepare_for_rendering(&self) {
self.inner.prepare_for_rendering();
}
fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option<RgbaImage> {
self.inner.read_to_image(source_rectangle)
}
fn size(&self) -> PhysicalSize<u32> {
self.size.get()
}
fn resize(&self, size: PhysicalSize<u32>) {
if self.size.get() == size {
return;
}
self.size.set(size);
let device = &mut self.inner.device.borrow_mut();
let context = &mut self.inner.context.borrow_mut();
#[cfg(target_os = "macos")]
self.destroy_held_presented_surface(device, context);
let size = Size2D::new(size.width as i32, size.height as i32);
let _ = self.swap_chain.resize(device, context, size);
}
fn present(&self) {
let device = &mut self.inner.device.borrow_mut();
let context = &mut self.inner.context.borrow_mut();
#[cfg(target_os = "macos")]
self.recycle_held_presented_surface();
let _ = self.swap_chain.swap_buffers(device, context, PreserveBuffer::No);
#[cfg(target_os = "macos")]
self.capture_presented_iosurface(device);
}
fn make_current(&self) -> Result<(), SurfmanError> {
self.inner.make_current()
}
fn gleam_gl_api(&self) -> Rc<dyn Gl> {
self.inner.gleam_gl.clone()
}
fn glow_gl_api(&self) -> Arc<glow::Context> {
self.inner.glow_gl.clone()
}
fn connection(&self) -> Option<Connection> {
Some(self.inner.device.borrow().connection())
}
}
#[cfg(target_os = "macos")]
use crate::iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity};
#[cfg(target_os = "macos")]
impl HardwareOffscreenContext {
/// Cheap, non-mutating identity probe of the IOSurface that was
/// just presented. Used by the sidecar to dedup mach port creation.
pub fn peek_iosurface_identity(&self) -> Result<Option<IOSurfaceIdentity>, SurfmanError> {
Ok(self.last_presented_iosurface.borrow().as_ref().map(|surface| surface.identity))
}
/// Snapshot the just-presented IOSurface and return its mach port
/// name plus dimensions and stable surface id. Increments the
/// IOSurface's mach-port use count; the
/// receiving process holds it via `IOSurfaceLookupFromMachPort` and
/// is responsible for `mach_port_deallocate` once the import is
/// finished.
pub fn current_iosurface_mach_port(&self) -> Result<IOSurfaceHandle, SurfmanError> {
let presented = self.last_presented_iosurface.borrow();
let presented = presented.as_ref().ok_or(SurfmanError::Failed)?;
let mach_port = presented.native.0.create_mach_port();
Ok(IOSurfaceHandle {
mach_port_name: mach_port,
surface_id: presented.identity.surface_id,
width: presented.identity.width,
height: presented.identity.height,
})
}
fn capture_presented_iosurface(&self, device: &mut Device) {
let Some(surface) = self.swap_chain.take_pending_surface() else {
self.last_presented_iosurface.borrow_mut().take();
return;
};
let info = device.surface_info(&surface);
let native = device.native_surface(&surface);
let identity = IOSurfaceIdentity {
surface_id: info.id.0 as u64,
width: u32::try_from(info.size.width).unwrap_or(0),
height: u32::try_from(info.size.height).unwrap_or(0),
};
self.held_presented_surface.replace(Some(surface));
self.last_presented_iosurface.replace(Some(PresentedIOSurface { identity, native }));
}
fn recycle_held_presented_surface(&self) {
if let Some(surface) = self.held_presented_surface.borrow_mut().take() {
self.swap_chain.recycle_surface(surface);
}
}
fn destroy_held_presented_surface(&self, device: &mut Device, context: &mut Context) {
self.last_presented_iosurface.borrow_mut().take();
if let Some(mut surface) = self.held_presented_surface.borrow_mut().take() {
let _ = device.destroy_surface(context, &mut surface);
}
}
}
#[cfg(target_os = "macos")]
struct PresentedIOSurface {
identity: IOSurfaceIdentity,
native: NativeSurface,
}
/// Trimmed mirror of `paint_api::rendering_context::SurfmanRenderingContext`.
///
/// Only the methods the public type above actually uses are kept; the
/// upstream original also wires up texture sharing, refresh drivers,
/// and several other knobs that Servo's compositor reaches into but
/// the embedder's headless readback path does not.
struct SurfmanInner {
gleam_gl: Rc<dyn Gl>,
glow_gl: Arc<glow::Context>,
device: RefCell<Device>,
context: RefCell<Context>,
}
impl Drop for SurfmanInner {
fn drop(&mut self) {
let device = &mut self.device.borrow_mut();
let context = &mut self.context.borrow_mut();
let _ = device.destroy_context(context);
}
}
impl SurfmanInner {
fn new(connection: &Connection, adapter: &surfman::Adapter) -> Result<Self, SurfmanError> {
let device = connection.create_device(adapter)?;
let flags = ContextAttributeFlags::ALPHA
| ContextAttributeFlags::DEPTH
| ContextAttributeFlags::STENCIL;
let gl_api = connection.gl_api();
let version = match &gl_api {
GLApi::GLES => surfman::GLVersion { major: 3, minor: 0 },
GLApi::GL => surfman::GLVersion { major: 3, minor: 2 },
};
let context_descriptor =
device.create_context_descriptor(&ContextAttributes { flags, version })?;
let context = device.create_context(&context_descriptor, None)?;
// Loading the GL function pointers requires unsafe ABI calls
// through surfman's `get_proc_address` — these are the same
// calls the upstream `SurfmanRenderingContext::new` makes,
// and they're sound for the same reason: surfman guarantees
// the returned function pointers match the requested API.
#[expect(unsafe_code)]
let gleam_gl = {
match gl_api {
GLApi::GL => unsafe {
gl::GlFns::load_with(|name| device.get_proc_address(&context, name))
},
GLApi::GLES => unsafe {
gl::GlesFns::load_with(|name| device.get_proc_address(&context, name))
},
}
};
#[expect(unsafe_code)]
let glow_gl = unsafe {
glow::Context::from_loader_function(|name| device.get_proc_address(&context, name))
};
Ok(Self {
gleam_gl,
glow_gl: Arc::new(glow_gl),
device: RefCell::new(device),
context: RefCell::new(context),
})
}
fn create_surface(
&self,
surface_type: SurfaceType<NativeWidget>,
) -> Result<Surface, SurfmanError> {
let device = &mut self.device.borrow_mut();
let context = &self.context.borrow();
device.create_surface(context, SurfaceAccess::GPUOnly, surface_type)
}
fn bind_surface(&self, surface: Surface) -> Result<(), SurfmanError> {
let device = &self.device.borrow();
let context = &mut self.context.borrow_mut();
device.bind_surface_to_context(context, surface).map_err(|(err, mut surface)| {
let _ = device.destroy_surface(context, &mut surface);
err
})?;
Ok(())
}
fn create_attached_swap_chain(&self) -> Result<SwapChain<Device>, SurfmanError> {
let device = &mut self.device.borrow_mut();
let context = &mut self.context.borrow_mut();
SwapChain::create_attached(device, context, SurfaceAccess::GPUOnly)
}
fn make_current(&self) -> Result<(), SurfmanError> {
let device = &self.device.borrow();
let context = &self.context.borrow();
device.make_context_current(context)
}
fn framebuffer_id(&self) -> u32 {
let device = &self.device.borrow();
let context = &self.context.borrow();
device
.context_surface_info(context)
.unwrap_or(None)
.and_then(|info| info.framebuffer_object)
.map_or(0, |framebuffer| framebuffer.0.into())
}
fn prepare_for_rendering(&self) {
let framebuffer_id = self.framebuffer_id();
self.gleam_gl.bind_framebuffer(gleam::gl::FRAMEBUFFER, framebuffer_id);
}
/// Inlined copy of `Framebuffer::read_framebuffer_to_image` from
/// `paint-api`. Reads the bound framebuffer into a `Vec<u8>`,
/// flips it vertically (GL's origin is bottom-left, the rest of
/// the embedder expects top-left), and returns it as an
/// [`RgbaImage`]. Returns `None` if `RgbaImage::from_raw` rejects
/// the buffer (size mismatch); GL errors are logged but don't
/// abort the read — the caller can decide whether a corrupt
/// frame is recoverable.
fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option<RgbaImage> {
let framebuffer_id = self.framebuffer_id();
self.gleam_gl.bind_framebuffer(gl::FRAMEBUFFER, framebuffer_id);
// Working around an OSMesa headless bug carried forward from
// the upstream implementation, see servo/servo#18606.
self.gleam_gl.bind_vertex_array(0);
let mut pixels = self.gleam_gl.read_pixels(
source_rectangle.min.x,
source_rectangle.min.y,
source_rectangle.width(),
source_rectangle.height(),
gl::RGBA,
gl::UNSIGNED_BYTE,
);
let gl_error = self.gleam_gl.get_error();
if gl_error != gl::NO_ERROR {
log::warn!("GL error 0x{gl_error:x} after read_pixels in hardware offscreen context");
}
let source_rectangle = source_rectangle.to_usize();
let stride = source_rectangle.width().checked_mul(4)?;
let mirror = pixels.clone();
for y in 0..source_rectangle.height() {
let dst_start = y.checked_mul(stride)?;
let src_start = (source_rectangle.height().checked_sub(y + 1)?).checked_mul(stride)?;
let dst_end = dst_start.checked_add(stride)?;
let src_end = src_start.checked_add(stride)?;
if dst_end > pixels.len() || src_end > mirror.len() {
return None;
}
pixels[dst_start..dst_end].clone_from_slice(&mirror[src_start..src_end]);
}
RgbaImage::from_raw(
source_rectangle.width() as u32,
source_rectangle.height() as u32,
pixels,
)
}
}
-10
View File
@@ -284,11 +284,6 @@ pub struct KeyboardTextRequest {
pub text: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ScreenshotRequest {
pub webview_id: WebViewId,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PermissionRequest {
pub webview_id: WebViewId,
@@ -341,11 +336,6 @@ pub trait ServoHost {
fn type_text(&mut self, request: KeyboardTextRequest) -> Result<(), ServoHostError>;
fn capture_screenshot(
&mut self,
request: ScreenshotRequest,
) -> Result<RenderedFrame, ServoHostError>;
fn set_permission(
&mut self,
request: PermissionRequest,
@@ -1,48 +0,0 @@
//! Cross-process IOSurface descriptor types.
//!
//! These wire types live outside `hardware_rendering_context` (which
//! is hardware-render + macOS gated) so the sidecar's JSON protocol
//! can carry an `Option<IOSurfaceHandle>` regardless of feature
//! flags. The receiver always knows how to parse the field; if no
//! sender ever populates it (software-only build), it's just `None`
//! on every frame.
//!
//! Minting an [`IOSurfaceHandle`] requires a hardware surfman context
//! and a macOS host. That part lives in
//! [`crate::hardware_rendering_context`].
/// Cross-process handle to a hardware surface: the receiving process
/// rebuilds an `IOSurfaceRef` from `mach_port_name` and imports it as
/// a Metal texture without copying pixels.
///
/// `surface_id` is the stable surfman `SurfaceID` (a pointer-shaped
/// `usize` widened to `u64` for the wire). Together with `width` and
/// `height` it lets the receiver dedup imported IOSurfaces. The pixel
/// dimensions are part of the identity because a resize can reuse the
/// same surfman id for a newly-sized IOSurface. `width` and `height`
/// are reported in surface pixels (post-DPR).
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[cfg_attr(feature = "servo-engine", derive(serde::Serialize, serde::Deserialize))]
pub struct IOSurfaceHandle {
pub mach_port_name: u32,
pub surface_id: u64,
pub width: u32,
pub height: u32,
}
/// Identity-only peek of the currently bound IOSurface. Distinguishes
/// "same surface as last frame" from "resize/swap rotated to a new
/// surface" without minting a fresh mach port (mach ports are a scarce
/// kernel resource and `IOSurfaceCreateMachPort` is not cheap).
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct IOSurfaceIdentity {
pub surface_id: u64,
pub width: u32,
pub height: u32,
}
impl IOSurfaceIdentity {
pub fn from_handle(handle: IOSurfaceHandle) -> Self {
Self { surface_id: handle.surface_id, width: handle.width, height: handle.height }
}
}
+2 -8
View File
@@ -1,8 +1,5 @@
mod error;
#[cfg(feature = "hardware-render")]
mod hardware_rendering_context;
mod host;
mod iosurface_handle;
#[cfg(feature = "servo-engine")]
mod keyboard;
#[cfg(feature = "servo-engine")]
@@ -17,14 +14,11 @@ mod runtime_waker;
mod runtime_webview;
pub use error::ServoHostError;
#[cfg(feature = "hardware-render")]
pub use hardware_rendering_context::HardwareOffscreenContext;
pub use host::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest,
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame,
RenderedFrameSummary, ResizeRequest, ScreenshotRequest, ScrollRequest, ServoHost,
TouchTapRequest, WebViewSnapshot, WebViewState,
RenderedFrameSummary, ResizeRequest, ScrollRequest, ServoHost, TouchTapRequest,
WebViewSnapshot, WebViewState,
};
pub use iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity};
#[cfg(feature = "servo-engine")]
pub use runtime::{RenderingContextKind, ServoSurfaceSize, SoftwareServoHost};
+44 -92
View File
@@ -4,18 +4,17 @@ use std::{
path::PathBuf,
rc::Rc,
sync::{
Arc,
Arc, Once,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant},
};
use dpi::PhysicalSize;
use ely_domain::{ProfileId, TabId, WebViewId};
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use servo::{
DevicePoint, DeviceVector2D, Opts, Scroll, Servo, ServoBuilder, WebViewBuilder, WebViewPoint,
WebViewVector,
DevicePoint, DeviceVector2D, Opts, Preferences, Scroll, Servo, ServoBuilder, WebViewBuilder,
WebViewPoint, WebViewVector,
};
#[path = "runtime_context.rs"]
@@ -28,8 +27,8 @@ use url::Url;
use crate::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest,
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame,
ResizeRequest, ScreenshotRequest, ScrollRequest, ServoHost, ServoHostError, TouchTapRequest,
WebViewSnapshot, WebViewState,
ResizeRequest, ScrollRequest, ServoHost, ServoHostError, TouchTapRequest, WebViewSnapshot,
WebViewState,
runtime_input::{
send_keyboard_text, send_mouse_click, send_mouse_drag, send_mouse_hover, send_touch_tap,
},
@@ -39,8 +38,7 @@ use crate::{
};
static SERVO_RUNTIME_STARTED: AtomicBool = AtomicBool::new(false);
const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(20);
const SCREENSHOT_POLL_INTERVAL: Duration = Duration::from_millis(2);
static RUSTLS_PROVIDER: Once = Once::new();
pub struct SoftwareServoHost {
servo: Servo,
@@ -70,12 +68,6 @@ impl SoftwareServoHost {
config_dir: Option<PathBuf>,
rendering_context_kind: RenderingContextKind,
) -> Result<Self, ServoHostError> {
if rendering_context_kind == RenderingContextKind::Hardware
&& !cfg!(feature = "hardware-render")
{
return Err(ServoHostError::HardwareRenderUnavailable);
}
if SERVO_RUNTIME_STARTED
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
@@ -96,7 +88,22 @@ impl SoftwareServoHost {
profile_id: ProfileId,
size: ServoSurfaceSize,
) -> Result<WebViewId, ServoHostError> {
self.create_webview_in_context(tab_id, profile_id, size)
let handles = self.new_rendering_context(size)?;
self.create_webview_in_context(tab_id, profile_id, handles)
}
pub fn create_webview_with_native_surface<S>(
&mut self,
tab_id: TabId,
profile_id: ProfileId,
size: ServoSurfaceSize,
native_surface: &S,
) -> Result<WebViewId, ServoHostError>
where
S: HasDisplayHandle + HasWindowHandle + ?Sized,
{
let handles = self.new_rendering_context_for_native_surface(size, native_surface)?;
self.create_webview_in_context(tab_id, profile_id, handles)
}
/// Paint and present the current surface without RGBA readback.
@@ -121,8 +128,10 @@ impl SoftwareServoHost {
config_dir: Option<PathBuf>,
rendering_context_kind: RenderingContextKind,
) -> Result<Self, ServoHostError> {
install_rustls_provider();
let wake_requested = Arc::new(AtomicBool::new(false));
let mut builder = ServoBuilder::default()
.preferences(ely_servo_preferences())
.event_loop_waker(Box::new(ServoWakeFlag::new(wake_requested.clone())));
if let Some(config_dir) = config_dir {
builder = builder.opts(Opts { config_dir: Some(config_dir), ..Opts::default() });
@@ -183,13 +192,24 @@ impl SoftwareServoHost {
}
}
fn install_rustls_provider() {
RUSTLS_PROVIDER.call_once(|| {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
});
}
fn ely_servo_preferences() -> Preferences {
Preferences { dom_intersection_observer_enabled: true, ..Preferences::default() }
}
impl ServoHost for SoftwareServoHost {
fn create_webview(
&mut self,
tab_id: TabId,
profile_id: ProfileId,
) -> Result<WebViewId, ServoHostError> {
self.create_webview_in_context(tab_id, profile_id, self.default_surface_size)
let handles = self.new_rendering_context(self.default_surface_size)?;
self.create_webview_in_context(tab_id, profile_id, handles)
}
fn navigate(&mut self, request: NavigationRequest) -> Result<(), ServoHostError> {
@@ -313,41 +333,6 @@ impl ServoHost for SoftwareServoHost {
Ok(())
}
fn capture_screenshot(
&mut self,
request: ScreenshotRequest,
) -> Result<RenderedFrame, ServoHostError> {
let webview = self.webview(&request.webview_id)?.webview.clone();
let captured_image = Rc::new(RefCell::new(None));
let callback_image = captured_image.clone();
webview.take_screenshot(None, move |result| {
callback_image.replace(Some(result));
});
let started_at = Instant::now();
while captured_image.borrow().is_none() {
if started_at.elapsed() >= SCREENSHOT_TIMEOUT {
return Err(ServoHostError::ScreenshotTimedOut { id: request.webview_id.clone() });
}
self.tick();
if self.snapshot(&request.webview_id)?.has_pending_frame() {
self.paint(&request.webview_id)?;
}
thread::sleep(SCREENSHOT_POLL_INTERVAL);
}
let Some(result) = captured_image.borrow_mut().take() else {
return Err(ServoHostError::RenderedFrameUnavailable);
};
let image = result.map_err(|error| ServoHostError::ScreenshotUnavailable {
reason: format!("{error:?}"),
})?;
let frame = RenderedFrame::from_rgba_bytes(image.width(), image.height(), image.into_raw());
self.last_rendered_frame = Some(frame.clone());
Ok(frame)
}
fn set_permission(
&mut self,
request: PermissionRequest,
@@ -392,15 +377,20 @@ impl ServoHost for SoftwareServoHost {
}
}
impl Drop for SoftwareServoHost {
fn drop(&mut self) {
SERVO_RUNTIME_STARTED.store(false, Ordering::Release);
}
}
impl SoftwareServoHost {
fn create_webview_in_context(
&mut self,
tab_id: TabId,
profile_id: ProfileId,
size: ServoSurfaceSize,
handles: runtime_context::RenderingContextHandles,
) -> Result<WebViewId, ServoHostError> {
let webview_id = WebViewId::new();
let handles = self.new_rendering_context(size)?;
let delegate =
Rc::new(HostWebViewDelegate::new(profile_id.clone(), self.permissions.clone()));
let webview = WebViewBuilder::new(&self.servo, handles.rendering_context.clone())
@@ -420,8 +410,6 @@ impl SoftwareServoHost {
tab_id,
profile_id,
rendering_context: handles.rendering_context,
#[cfg(feature = "hardware-render")]
hardware_context: handles.hardware_context,
webview,
delegate,
requested_url: None,
@@ -431,42 +419,6 @@ impl SoftwareServoHost {
Ok(webview_id)
}
/// Cheap peek at the IOSurface identity bound to this webview's
/// hardware context. Returns `None` for software webviews and on
/// non-macOS hosts; otherwise the surfman `SurfaceID`-derived
/// identity plus dimensions. Used by the sidecar's live loop to
/// dedup mach port creation.
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub fn peek_iosurface_identity(
&self,
webview_id: &WebViewId,
) -> Result<Option<crate::IOSurfaceIdentity>, ServoHostError> {
let webview = self.webview(webview_id)?;
let Some(hardware) = webview.hardware_context.as_ref() else {
return Ok(None);
};
hardware.peek_iosurface_identity().map_err(|_| ServoHostError::RenderingContextUnavailable)
}
/// Mint a fresh mach port for the IOSurface bound to this
/// webview's hardware context. The caller is responsible for
/// transferring the port to the receiving process; if no transfer
/// happens, the port leaks. Software webviews return `None`.
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub fn current_iosurface_handle(
&self,
webview_id: &WebViewId,
) -> Result<Option<crate::IOSurfaceHandle>, ServoHostError> {
let webview = self.webview(webview_id)?;
let Some(hardware) = webview.hardware_context.as_ref() else {
return Ok(None);
};
hardware
.current_iosurface_mach_port()
.map(Some)
.map_err(|_| ServoHostError::RenderingContextUnavailable)
}
fn webview(&self, webview_id: &WebViewId) -> Result<&HostWebView, ServoHostError> {
self.webviews
.get(webview_id)
+24 -34
View File
@@ -8,6 +8,7 @@ use std::{
use dpi::PhysicalSize;
use euclid::Scale;
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use servo::{
DeviceIndependentPixel, DeviceIntPoint, DeviceIntRect, DeviceIntSize, DevicePixel,
RenderingContext,
@@ -62,31 +63,16 @@ impl ServoSurfaceSize {
}
/// Selects the `RenderingContext` implementation each webview gets.
///
/// `Software` uses Servo's built-in `SoftwareRenderingContext`, which
/// rasterises on the CPU. `Hardware` uses the vendored
/// [`HardwareOffscreenContext`](crate::HardwareOffscreenContext),
/// which rasterises through the real GPU adapter against a
/// `SurfaceType::Generic` offscreen surface. The `Hardware` variant
/// is only available when the `hardware-render` feature is enabled;
/// requesting it without the feature is a configuration error
/// surfaced via `ServoHostError::HardwareRenderUnavailable`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RenderingContextKind {
#[default]
Software,
Hardware,
}
/// Pair of rendering-context handles produced by
/// [`SoftwareServoHost::new_rendering_context`]. The trait-object
/// handle drives Servo's compositor; the concrete hardware handle is
/// kept on the side so the host can call macOS-specific methods
/// (IOSurface mach port extraction) without downcasting.
/// [`SoftwareServoHost::new_rendering_context`].
pub(super) struct RenderingContextHandles {
pub(super) rendering_context: Rc<dyn RenderingContext>,
#[cfg(feature = "hardware-render")]
pub(super) hardware_context: Option<Rc<crate::HardwareOffscreenContext>>,
}
impl SoftwareServoHost {
@@ -103,27 +89,31 @@ impl SoftwareServoHost {
rendering_context
.make_current()
.map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
Ok(RenderingContextHandles {
rendering_context,
#[cfg(feature = "hardware-render")]
hardware_context: None,
})
Ok(RenderingContextHandles { rendering_context })
}
#[cfg(feature = "hardware-render")]
RenderingContextKind::Hardware => {
let hardware = Rc::new(
crate::HardwareOffscreenContext::new(size.physical())
}
}
pub(super) fn new_rendering_context_for_native_surface<S>(
&self,
size: ServoSurfaceSize,
native_surface: &S,
) -> Result<RenderingContextHandles, ServoHostError>
where
S: HasDisplayHandle + HasWindowHandle + ?Sized,
{
let display_handle = native_surface
.display_handle()
.map_err(|_| ServoHostError::RenderingContextUnavailable)?;
let window_handle = native_surface
.window_handle()
.map_err(|_| ServoHostError::RenderingContextUnavailable)?;
let rendering_context = Rc::new(
servo::WindowRenderingContext::new(display_handle, window_handle, size.physical())
.map_err(|_| ServoHostError::RenderingContextUnavailable)?,
);
hardware.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
Ok(RenderingContextHandles {
rendering_context: hardware.clone(),
hardware_context: Some(hardware),
})
}
#[cfg(not(feature = "hardware-render"))]
RenderingContextKind::Hardware => Err(ServoHostError::HardwareRenderUnavailable),
}
rendering_context.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
Ok(RenderingContextHandles { rendering_context })
}
/// Spin Servo's event loop until the webview's delegate observes a
@@ -13,12 +13,6 @@ pub(super) struct HostWebView {
pub(super) tab_id: TabId,
pub(super) profile_id: ProfileId,
pub(super) rendering_context: Rc<dyn RenderingContext>,
/// Parallel concrete handle when the rendering context is the
/// vendored hardware path. `None` for software webviews. Lets the
/// host call macOS-specific methods (IOSurface mach port
/// extraction) without downcasting `dyn RenderingContext`.
#[cfg(feature = "hardware-render")]
pub(super) hardware_context: Option<Rc<crate::HardwareOffscreenContext>>,
pub(super) webview: WebView,
pub(super) delegate: Rc<HostWebViewDelegate>,
pub(super) requested_url: Option<String>,
@@ -1,78 +0,0 @@
//! Smoke test for the vendored hardware [`RenderingContext`].
//!
//! Runs only when the `hardware-render` feature is enabled. The test
//! degrades gracefully when the host machine lacks a hardware GL
//! adapter (CI sandboxes, no-GPU containers): construction returns
//! `Err`, the test logs the cause, and reports `ok` — proving the
//! vendored constructor is wired up correctly without falsely
//! marking the suite green when a GPU is actually expected and
//! missing. Inverting that check (turning a GPU-missing host into a
//! hard failure) is left for downstream CI configuration once the
//! hardware path is wired into the sidecar binary.
#![cfg(feature = "hardware-render")]
use dpi::PhysicalSize;
use ely_servo_host::HardwareOffscreenContext;
use servo::RenderingContext;
#[test]
fn constructs_or_explains_why_not() {
let size = PhysicalSize::new(640, 480);
match HardwareOffscreenContext::new(size) {
Ok(context) => {
// We don't drive Servo here — just confirm the vendored
// glue produced a live context. Construction is the
// failure mode this smoke test guards against; once a
// context exists the real Servo paint path exercises the
// rest of the surface.
drop(context);
}
Err(error) => {
eprintln!(
"hardware GL adapter not available on this host \
(acceptable in headless / no-GPU environments): {error:?}"
);
}
}
}
#[cfg(target_os = "macos")]
#[test]
fn extracts_iosurface_mach_port_from_current_surface() -> Result<(), String> {
let width = 256;
let height = 192;
let context = match HardwareOffscreenContext::new(PhysicalSize::new(width, height)) {
Ok(context) => context,
Err(error) => {
eprintln!(
"hardware GL adapter not available on this host \
(acceptable in headless / no-GPU environments): {error:?}"
);
return Ok(());
}
};
context.prepare_for_rendering();
context.present();
let first = context
.current_iosurface_mach_port()
.map_err(|error| format!("first IOSurface mach port extraction failed: {error:?}"))?;
assert!(
first.mach_port_name != 0,
"IOSurfaceCreateMachPort must return a non-null mach_port_t (got 0)"
);
assert_eq!(first.width, width, "reported width must match surface width");
assert_eq!(first.height, height, "reported height must match surface height");
// The unbind/rebind cycle must leave the context usable: a second
// call should still produce a valid mach port without panicking on
// a stale `Framebuffer::None`.
let second = context
.current_iosurface_mach_port()
.map_err(|error| format!("repeated mach port extraction failed: {error:?}"))?;
assert!(second.mach_port_name != 0);
assert_eq!(second.width, width);
assert_eq!(second.height, height);
Ok(())
}
@@ -1,494 +0,0 @@
//! Manual sidecar perf bench; ignored by normal CI.
#![cfg(feature = "servo-engine")]
#[path = "live_perf_bench/pixels.rs"]
mod pixels;
use std::{
env,
error::Error,
fs,
io::{BufRead, BufReader, Read, Write},
path::PathBuf,
process::{Child, ChildStdin, ChildStdout, Command, Stdio},
thread,
time::{Duration, Instant},
};
use ely_domain::{ProfileId, TabId};
use serde::Deserialize;
const DEFAULT_FRAMES: u32 = 240;
const VIEWPORT_WIDTH: u32 = 1024;
const VIEWPORT_HEIGHT: u32 = 768;
const SCROLL_STEP_PX: i32 = 4;
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(20);
const SCROLL_PAGE_DATA_URL: &str = "data:text/html,\
<!doctype html><meta charset=utf-8><title>perf</title>\
<style>html,body{margin:0;padding:0}\
body{height:8000px;background:linear-gradient(180deg,red,teal,navy,white,crimson)}\
div.row{height:80px;border-bottom:2px solid rgba(0,0,0,.5);color:white;font:24px/80px sans-serif;padding-left:24px}\
</style>\
<script>for(let i=0;i<100;i++){let d=document.createElement('div');d.className='row';d.textContent='row '+i;document.body.appendChild(d)}</script>";
#[derive(Deserialize, Debug)]
struct LiveResponse {
error: Option<String>,
frame: Option<LiveFrameReport>,
#[serde(default)]
perf: Option<FramePerfSummary>,
#[serde(default)]
surface_handle: Option<BenchSurfaceHandle>,
#[serde(default)]
current_surface_id: Option<u64>,
}
#[derive(Deserialize, Debug, Clone, Copy)]
struct BenchSurfaceHandle {
mach_port_name: u32,
surface_id: u64,
width: u32,
height: u32,
}
#[derive(Deserialize, Debug)]
struct LiveFrameReport {
rgba_byte_count: usize,
#[serde(default)]
width: u32,
#[serde(default)]
height: u32,
#[serde(default)]
device_pixel_ratio: f32,
#[serde(default)]
css_viewport_width: u32,
#[serde(default)]
css_viewport_height: u32,
}
#[derive(Deserialize, Debug, Clone)]
struct FramePerfSummary {
window: u32,
context: String,
paint_p50_us: u64,
paint_p95_us: u64,
paint_p99_us: u64,
encode_p50_us: u64,
encode_p95_us: u64,
encode_p99_us: u64,
write_p50_us: u64,
write_p95_us: u64,
write_p99_us: u64,
total_p50_us: u64,
total_p95_us: u64,
total_p99_us: u64,
}
#[test]
#[ignore = "manual bench: spawns sidecar, scrolls a data: URL for N frames"]
fn run_live_bench() -> Result<(), Box<dyn Error>> {
let kind = env::var("ELY_PERF_KIND").unwrap_or_else(|_| "software".to_string());
let frames: u32 = env::var("ELY_PERF_FRAMES")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_FRAMES);
let url = env::var("ELY_PERF_URL").unwrap_or_else(|_| SCROLL_PAGE_DATA_URL.to_string());
let profile_id = ProfileId::new();
let tab = TabId::new();
let profile_data_dir = env::temp_dir().join(format!(
"ely-perf-bench-{}-{}-{}",
std::process::id(),
kind,
profile_id.as_str()
));
fs::create_dir_all(&profile_data_dir)?;
let mut child = spawn_sidecar(&kind, &profile_data_dir)?;
let mut stdin = child.stdin.take().ok_or("sidecar stdin missing")?;
let stdout = child.stdout.take().ok_or("sidecar stdout missing")?;
let mut reader = BufReader::new(stdout);
let outcome = match drive_bench(&mut stdin, &mut reader, &kind, &tab, &profile_id, &url, frames)
{
Ok(outcome) => outcome,
Err(error) => {
drop(stdin);
let _ = child.kill();
cleanup(&profile_data_dir)?;
return Err(error);
}
};
drop(stdin);
let _ = child.wait();
cleanup(&profile_data_dir)?;
print_summaries(&kind, frames, &outcome.summaries);
print_surface_handles(&kind, &outcome.surface_handles);
print_current_surface_summary(&kind, &outcome.current_surface_ids);
eprintln!(
"\n=== ELY_PERF_KIND={kind} readback_rgba_bytes={} surface_rgba_bytes={} ===",
outcome.readback_rgba_bytes, outcome.surface_rgba_bytes,
);
assert!(
!outcome.summaries.is_empty(),
"expected at least one FramePerfSummary across {frames} frames"
);
if kind == "hardware" {
assert!(
!outcome.surface_handles.is_empty(),
"hardware live path must publish IOSurface handles"
);
assert!(
!outcome.current_surface_ids.is_empty(),
"hardware live path must report current_surface_id selectors"
);
} else {
assert!(
outcome.surface_handles.is_empty(),
"software path must never publish an IOSurface handle"
);
assert!(
outcome.current_surface_ids.is_empty(),
"software path must never report current_surface_id"
);
}
let viewport_bytes = (1024u64) * (768u64) * 4;
let total_rgba_bytes = outcome.readback_rgba_bytes + outcome.surface_rgba_bytes;
assert!(
total_rgba_bytes >= viewport_bytes,
"{kind} path delivered only {total_rgba_bytes} bytes — expected at least one full frame ({})",
viewport_bytes,
);
if kind == "hardware" {
let full_readback_budget = viewport_bytes * u64::from(frames);
assert_eq!(
outcome.readback_rgba_bytes, viewport_bytes,
"hardware path should read back only the initial visible frame"
);
assert!(
total_rgba_bytes < full_readback_budget,
"hardware path stayed on full readback: {total_rgba_bytes} >= {full_readback_budget}"
);
}
Ok(())
}
struct BenchOutcome {
summaries: Vec<FramePerfSummary>,
surface_handles: Vec<BenchSurfaceHandle>,
current_surface_ids: Vec<u64>,
readback_rgba_bytes: u64,
surface_rgba_bytes: u64,
}
fn spawn_sidecar(kind: &str, profile_data_dir: &PathBuf) -> Result<Child, Box<dyn Error>> {
let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"));
command
.arg("live")
.arg("--profile-data-dir")
.arg(profile_data_dir)
.arg("--rendering-context")
.arg(kind)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
Ok(command.spawn()?)
}
fn drive_bench(
stdin: &mut ChildStdin,
reader: &mut BufReader<ChildStdout>,
kind: &str,
tab: &TabId,
profile_id: &ProfileId,
url: &str,
frames: u32,
) -> Result<BenchOutcome, Box<dyn Error>> {
let mut summaries = Vec::new();
let mut surface_handles = Vec::new();
let mut current_surface_ids = Vec::new();
let mut readback_rgba_bytes: u64 = 0;
let mut surface_rgba_bytes: u64 = 0;
let navigate = build_ensure(tab, profile_id, url, 0, 0, false);
write_request(stdin, &navigate)?;
let response = read_response(reader, RESPONSE_TIMEOUT)?;
assert_frame_viewport_report(&response);
record_summary(&response, kind, &mut summaries);
record_surface_handle(&response, kind, &mut surface_handles);
record_current_surface_id(&response, &mut current_surface_ids);
record_rgba_bytes(&response, &mut readback_rgba_bytes, &mut surface_rgba_bytes);
let mut accumulated_scroll = 0;
let mut painted_frames = 0;
let max_attempts = frames.saturating_mul(4).max(frames + 10);
for attempt in 0..max_attempts {
if painted_frames >= frames {
break;
}
let scroll_delta_y =
if painted_frames % 80 == 79 { -SCROLL_STEP_PX * 60 } else { SCROLL_STEP_PX };
accumulated_scroll += scroll_delta_y;
let request = build_ensure(tab, profile_id, url, 0, scroll_delta_y, true);
write_request(stdin, &request)?;
let response = read_response(reader, RESPONSE_TIMEOUT)?;
if let Some(error) = response.error.as_ref() {
return Err(format!("sidecar error at attempt {attempt}: {error}").into());
}
if response.frame.is_some() {
painted_frames += 1;
}
assert_frame_viewport_report(&response);
record_summary(&response, kind, &mut summaries);
record_surface_handle(&response, kind, &mut surface_handles);
record_current_surface_id(&response, &mut current_surface_ids);
record_rgba_bytes(&response, &mut readback_rgba_bytes, &mut surface_rgba_bytes);
}
assert_eq!(painted_frames, frames, "bench did not receive the requested painted frame count");
let _ = accumulated_scroll;
for _ in 0..5 {
if !summaries.is_empty() {
break;
}
let poll = build_poll(tab);
write_request(stdin, &poll)?;
let response = read_response(reader, RESPONSE_TIMEOUT)?;
record_summary(&response, kind, &mut summaries);
}
Ok(BenchOutcome {
summaries,
surface_handles,
current_surface_ids,
readback_rgba_bytes,
surface_rgba_bytes,
})
}
fn record_rgba_bytes(
response: &LiveResponse,
readback_rgba_bytes: &mut u64,
surface_rgba_bytes: &mut u64,
) {
let rgba_byte_count = response.frame.as_ref().map_or(0, |frame| frame.rgba_byte_count as u64);
if rgba_byte_count > 0 {
*readback_rgba_bytes += rgba_byte_count;
} else if response.current_surface_id.is_some() {
*surface_rgba_bytes += rgba_byte_count;
}
}
fn assert_frame_viewport_report(response: &LiveResponse) {
let Some(frame) = response.frame.as_ref() else {
return;
};
let dpr = if frame.device_pixel_ratio.is_finite() && frame.device_pixel_ratio > 0.0 {
frame.device_pixel_ratio
} else {
1.0
};
let expected_width = ((frame.width as f32) / dpr).round().max(1.0) as u32;
let expected_height = ((frame.height as f32) / dpr).round().max(1.0) as u32;
assert_eq!(
frame.css_viewport_width, expected_width,
"CSS viewport width must match physical width divided by DPR",
);
assert_eq!(
frame.css_viewport_height, expected_height,
"CSS viewport height must match physical height divided by DPR",
);
}
fn record_surface_handle(
response: &LiveResponse,
kind: &str,
surface_handles: &mut Vec<BenchSurfaceHandle>,
) {
if let Some(handle) = response.surface_handle {
eprintln!(
"[iosurface {kind}] new surface_id=0x{:x} mach_port=0x{:x} {}x{}",
handle.surface_id, handle.mach_port_name, handle.width, handle.height,
);
surface_handles.push(handle);
}
}
fn record_current_surface_id(response: &LiveResponse, current_surface_ids: &mut Vec<u64>) {
if let Some(surface_id) = response.current_surface_id {
current_surface_ids.push(surface_id);
}
}
fn print_surface_handles(kind: &str, surface_handles: &[BenchSurfaceHandle]) {
eprintln!(
"\n=== ELY_PERF_KIND={kind} iosurface_imports={} (one per unique surface) ===",
surface_handles.len()
);
for (index, handle) in surface_handles.iter().enumerate() {
eprintln!(
"{:<4} surface_id=0x{:x} mach_port=0x{:x} {}x{}",
index, handle.surface_id, handle.mach_port_name, handle.width, handle.height,
);
}
}
fn print_current_surface_summary(kind: &str, current_surface_ids: &[u64]) {
use std::collections::BTreeMap;
let mut counts: BTreeMap<u64, u32> = BTreeMap::new();
for id in current_surface_ids {
*counts.entry(*id).or_default() += 1;
}
eprintln!("\n=== ELY_PERF_KIND={kind} current_surface_id histogram (per-frame selector) ===",);
for (surface_id, count) in counts.iter() {
eprintln!("surface_id=0x{:x} frames={}", surface_id, count);
}
}
fn build_ensure(
tab: &TabId,
profile_id: &ProfileId,
url: &str,
scroll_dx: i32,
scroll_dy: i32,
include_hover: bool,
) -> String {
let hover_x = if include_hover { Some(256u32) } else { None };
let hover_y = if include_hover { Some(256u32) } else { None };
let scroll_point = if scroll_dx != 0 || scroll_dy != 0 { Some((256u32, 256u32)) } else { None };
let hover_x_json = match hover_x {
Some(value) => format!("{value}"),
None => "null".to_string(),
};
let hover_y_json = match hover_y {
Some(value) => format!("{value}"),
None => "null".to_string(),
};
let scroll_point_x_json = match scroll_point {
Some((x, _)) => format!("{x}"),
None => "null".to_string(),
};
let scroll_point_y_json = match scroll_point {
Some((_, y)) => format!("{y}"),
None => "null".to_string(),
};
format!(
r#"{{"type":"ensure","tab_id":"{tab}","profile_id":"{profile}","url":{url},"width":{w},"height":{h},"page_zoom_percent":100,"scroll_delta_x":{dx},"scroll_delta_y":{dy},"scroll_point_x":{sx},"scroll_point_y":{sy},"click_x":null,"click_y":null,"hover_x":{hx},"hover_y":{hy},"typed_text":null,"site_permissions":[]}}"#,
tab = tab.as_str(),
profile = profile_id.as_str(),
url = serde_json::to_string(url).unwrap_or_else(|_| "\"\"".to_string()),
w = VIEWPORT_WIDTH,
h = VIEWPORT_HEIGHT,
dx = scroll_dx,
dy = scroll_dy,
sx = scroll_point_x_json,
sy = scroll_point_y_json,
hx = hover_x_json,
hy = hover_y_json,
)
}
fn build_poll(tab: &TabId) -> String {
format!(r#"{{"type":"poll","tab_id":"{}"}}"#, tab.as_str())
}
fn write_request(stdin: &mut ChildStdin, request: &str) -> Result<(), Box<dyn Error>> {
stdin.write_all(request.as_bytes())?;
stdin.write_all(b"\n")?;
stdin.flush()?;
Ok(())
}
fn read_response(
reader: &mut BufReader<ChildStdout>,
timeout: Duration,
) -> Result<LiveResponse, Box<dyn Error>> {
Ok(read_response_with_bytes(reader, timeout)?.0)
}
fn read_response_with_bytes(
reader: &mut BufReader<ChildStdout>,
timeout: Duration,
) -> Result<(LiveResponse, Vec<u8>), Box<dyn Error>> {
let started_at = Instant::now();
let mut json_line = String::new();
loop {
json_line.clear();
let read_bytes = reader.read_line(&mut json_line)?;
if read_bytes == 0 {
return Err("sidecar closed stdout".into());
}
if json_line.trim().is_empty() {
if started_at.elapsed() >= timeout {
return Err("sidecar response timeout".into());
}
thread::sleep(Duration::from_millis(2));
continue;
}
break;
}
let response: LiveResponse = serde_json::from_str(json_line.trim_end())?;
let mut rgba = Vec::new();
if let Some(frame) = response.frame.as_ref()
&& frame.rgba_byte_count > 0
{
rgba.resize(frame.rgba_byte_count, 0);
reader.read_exact(&mut rgba)?;
}
Ok((response, rgba))
}
fn record_summary(response: &LiveResponse, kind: &str, summaries: &mut Vec<FramePerfSummary>) {
if let Some(perf) = response.perf.as_ref() {
assert_eq!(perf.context, kind, "sidecar context label must match requested kind");
eprintln!(
"[perf {kind}] window={} paint p50/p95/p99={}/{}/{} encode {}/{}/{} write {}/{}/{} total {}/{}/{} (µs)",
perf.window,
perf.paint_p50_us,
perf.paint_p95_us,
perf.paint_p99_us,
perf.encode_p50_us,
perf.encode_p95_us,
perf.encode_p99_us,
perf.write_p50_us,
perf.write_p95_us,
perf.write_p99_us,
perf.total_p50_us,
perf.total_p95_us,
perf.total_p99_us,
);
summaries.push(perf.clone());
}
}
fn print_summaries(kind: &str, frames: u32, summaries: &[FramePerfSummary]) {
eprintln!("\n=== ELY_PERF_KIND={kind} frames={frames} windows={} ===", summaries.len());
for summary in summaries {
eprintln!(
"win={} paint={}/{}/{} encode={}/{}/{} write={}/{}/{} total={}/{}/{}",
summary.window,
summary.paint_p50_us,
summary.paint_p95_us,
summary.paint_p99_us,
summary.encode_p50_us,
summary.encode_p95_us,
summary.encode_p99_us,
summary.write_p50_us,
summary.write_p95_us,
summary.write_p99_us,
summary.total_p50_us,
summary.total_p95_us,
summary.total_p99_us,
);
}
}
fn cleanup(profile_data_dir: &PathBuf) -> Result<(), Box<dyn Error>> {
match fs::remove_dir_all(profile_data_dir) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
@@ -1,163 +0,0 @@
use std::{
env,
error::Error,
fs,
io::BufReader,
process::{ChildStdin, ChildStdout},
};
use ely_domain::{ProfileId, TabId};
use super::{
RESPONSE_TIMEOUT, build_ensure, cleanup, read_response_with_bytes, spawn_sidecar, write_request,
};
const SOLID_RED_DATA_URL: &str =
"data:text/html,<body style=\"margin:0;background:%23ff0000;height:4000px\">";
const SOLID_BLUE_DATA_URL: &str =
"data:text/html,<body style=\"margin:0;background:%230000ff;height:4000px\">";
#[test]
#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"]
fn red_data_url_yields_red_rgba() -> Result<(), Box<dyn Error>> {
assert_solid_color_renders("software", SOLID_RED_DATA_URL, ColorTarget::Red)
}
#[test]
#[ignore = "drives a real sidecar via stdin/stdout; takes a few seconds"]
fn blue_data_url_yields_blue_rgba() -> Result<(), Box<dyn Error>> {
assert_solid_color_renders("software", SOLID_BLUE_DATA_URL, ColorTarget::Blue)
}
#[derive(Clone, Copy)]
enum ColorTarget {
Red,
Blue,
}
impl ColorTarget {
fn label(self) -> &'static str {
match self {
ColorTarget::Red => "red",
ColorTarget::Blue => "blue",
}
}
}
fn assert_solid_color_renders(
kind: &str,
url: &str,
target: ColorTarget,
) -> Result<(), Box<dyn Error>> {
let profile_id = ProfileId::new();
let tab = TabId::new();
let profile_data_dir = env::temp_dir().join(format!(
"ely-pixel-{}-{}-{}",
std::process::id(),
target.label(),
profile_id.as_str(),
));
fs::create_dir_all(&profile_data_dir)?;
let mut child = spawn_sidecar(kind, &profile_data_dir)?;
let mut stdin = child.stdin.take().ok_or("sidecar stdin missing")?;
let stdout = child.stdout.take().ok_or("sidecar stdout missing")?;
let mut reader = BufReader::new(stdout);
let outcome = drive_solid_color_render(&mut stdin, &mut reader, &tab, &profile_id, url, target);
drop(stdin);
let _ = child.wait();
cleanup(&profile_data_dir)?;
outcome
}
fn drive_solid_color_render(
stdin: &mut ChildStdin,
reader: &mut BufReader<ChildStdout>,
tab: &TabId,
profile_id: &ProfileId,
url: &str,
target: ColorTarget,
) -> Result<(), Box<dyn Error>> {
let mut bytes = Vec::new();
let mut report = None;
for iteration in 0..30 {
let scroll_y = if iteration == 0 {
0
} else if iteration % 2 == 1 {
1
} else {
-1
};
let request = build_ensure(tab, profile_id, url, 0, scroll_y, false);
write_request(stdin, &request)?;
let (response, response_bytes) = read_response_with_bytes(reader, RESPONSE_TIMEOUT)?;
if let Some(error) = response.error.as_ref() {
return Err(format!("sidecar error: {error}").into());
}
if let Some(frame_report) = response.frame {
if !response_bytes.is_empty()
&& sample_matches_target(
&response_bytes,
frame_report.width,
frame_report.height,
target,
)
{
report = Some(frame_report);
bytes = response_bytes;
break;
}
if !response_bytes.is_empty() {
bytes = response_bytes;
report = Some(frame_report);
}
}
}
let report = report.ok_or("never received a frame with bytes")?;
let width = report.width as usize;
let height = report.height as usize;
assert_eq!(bytes.len(), width * height * 4, "rgba byte count must match width * height * 4",);
let mut samples = Vec::new();
for fy in [1, 2, 3] {
for fx in [1, 2, 3] {
let x = width * fx / 4;
let y = height * fy / 4;
let idx = (y * width + x) * 4;
samples.push((x, y, bytes[idx], bytes[idx + 1], bytes[idx + 2], bytes[idx + 3]));
}
}
eprintln!("[pixel sample {}] {:?}", target.label(), samples);
let hits =
samples.iter().filter(|(_x, _y, r, g, b, _a)| matches_color(*r, *g, *b, target)).count();
assert!(
hits >= 5,
"expected at least 5/9 center-quadrant pixels to be {} after rendering {}; got samples {:?}",
target.label(),
url,
samples,
);
Ok(())
}
fn sample_matches_target(bytes: &[u8], width: u32, height: u32, target: ColorTarget) -> bool {
let w = width as usize;
let h = height as usize;
if bytes.len() < w * h * 4 || w == 0 || h == 0 {
return false;
}
let cx = w / 2;
let cy = h / 2;
let idx = (cy * w + cx) * 4;
matches_color(bytes[idx], bytes[idx + 1], bytes[idx + 2], target)
}
fn matches_color(r: u8, g: u8, b: u8, target: ColorTarget) -> bool {
match target {
ColorTarget::Red => r >= 200 && g <= 60 && b <= 60,
ColorTarget::Blue => r <= 60 && g <= 60 && b >= 200,
}
}
-215
View File
@@ -1,215 +0,0 @@
#![cfg(feature = "servo-engine")]
use std::{collections::BTreeSet, error::Error, fs, path::PathBuf, process::Command};
use ely_domain::ProfileId;
#[path = "sidecar/site_cases.rs"]
mod site_cases;
#[path = "sidecar/support.rs"]
mod support;
use support::*;
#[test]
fn sidecar_prd_reference_cases_cover_prd_urls() -> Result<(), Box<dyn Error>> {
let prd = fs::read_to_string(prd_path())?;
let prd_urls = prd_reference_urls(&prd);
let covered_urls = PRD_REFERENCE_SITE_COMPATIBILITY_CASES
.iter()
.map(|case| normalized_url(case.url))
.collect::<BTreeSet<_>>();
let missing_urls = prd_urls
.iter()
.filter(|url| !covered_urls.contains(url.as_str()))
.cloned()
.collect::<Vec<_>>();
assert!(missing_urls.is_empty(), "missing PRD sidecar smoke cases: {missing_urls:?}");
assert_eq!(prd_urls.len(), PRD_REFERENCE_SITE_COMPATIBILITY_CASES.len());
Ok(())
}
#[test]
fn sidecar_opens_and_renders_prd_sites_to_rgba_files() -> Result<(), Box<dyn Error>> {
for case in PRD_SITE_COMPATIBILITY_CASES {
for size in PRD_SITE_COMPATIBILITY_SIZES {
snapshot_prd_site(case, *size, ScrollOffset::ZERO)?;
}
}
Ok(())
}
#[test]
fn sidecar_report_uses_requested_profile_id() -> Result<(), Box<dyn Error>> {
let profile_id = ProfileId::new();
let profile_data_dir = std::env::temp_dir().join(format!(
"ely-servo-sidecar-profile-test-{}-{}",
std::process::id(),
profile_id.as_str()
));
let rgba_path = std::env::temp_dir().join(format!(
"ely-servo-sidecar-profile-test-{}-{}.rgba",
std::process::id(),
profile_id.as_str()
));
let output = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"))
.arg("snapshot")
.arg("--url")
.arg("data:text/html,%3Ctitle%3EProfile%20Probe%3C%2Ftitle%3EProfile%20Probe")
.arg("--profile-id")
.arg(profile_id.as_str())
.arg("--profile-data-dir")
.arg(&profile_data_dir)
.arg("--rgba-out")
.arg(&rgba_path)
.arg("--width")
.arg("64")
.arg("--height")
.arg("64")
.output()?;
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&output.stdout)?;
assert_eq!(
report.get("profile_id").and_then(serde_json::Value::as_str),
Some(profile_id.as_str())
);
remove_file_if_present(rgba_path)?;
remove_dir_if_present(profile_data_dir)?;
Ok(())
}
fn prd_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..").join("PRD.md")
}
fn remove_file_if_present(path: PathBuf) -> Result<(), Box<dyn Error>> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
fn remove_dir_if_present(path: PathBuf) -> Result<(), Box<dyn Error>> {
match fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
fn prd_reference_urls(prd: &str) -> Vec<String> {
prd.lines()
.filter(|line| line.starts_with("[R"))
.filter_map(|line| {
let start = line.find("https://")?;
let url = line[start..].split_whitespace().next()?;
Some(normalized_url(url))
})
.collect()
}
fn normalized_url(url: &str) -> String {
url.trim().trim_end_matches('/').to_string()
}
#[test]
fn sidecar_opens_and_renders_prd_reference_sites_to_rgba_files() -> Result<(), Box<dyn Error>> {
for case in PRD_REFERENCE_SITE_COMPATIBILITY_CASES {
snapshot_prd_site(case, PRD_REFERENCE_SITE_SIZE, ScrollOffset::ZERO)?;
}
Ok(())
}
#[test]
fn sidecar_scrolls_prd_site_with_servo_input() -> Result<(), Box<dyn Error>> {
let scrolled_report =
snapshot_prd_site(&SERVO_SCROLL_SITE, SERVO_SCROLL_SIZE, SERVO_SCROLL_OFFSET)?;
assert_eq!(report_field_as_i64(&scrolled_report, "scroll_x")?, SERVO_SCROLL_OFFSET.x);
assert_eq!(report_field_as_i64(&scrolled_report, "scroll_y")?, SERVO_SCROLL_OFFSET.y);
assert_eq!(report_field_as_u64(&scrolled_report, "width")?, SERVO_SCROLL_SIZE.width);
assert!(report_field_as_bool(&scrolled_report, "scroll_changed_frame")?);
Ok(())
}
#[test]
fn sidecar_clicks_page_with_servo_mouse_input() -> Result<(), Box<dyn Error>> {
let initial_report = snapshot_click_probe(None)?;
let clicked_report = snapshot_click_probe(Some(SERVO_CLICK_POINT))?;
assert_eq!(report_field_as_u64(&clicked_report, "click_x")?, SERVO_CLICK_POINT.x);
assert_eq!(report_field_as_u64(&clicked_report, "click_y")?, SERVO_CLICK_POINT.y);
assert!(report_field_as_bool(&clicked_report, "click_changed_frame")?);
assert_ne!(
report_field_as_u64(&initial_report, "sample_hash")?,
report_field_as_u64(&clicked_report, "sample_hash")?
);
Ok(())
}
#[test]
fn sidecar_drags_page_with_servo_mouse_input() -> Result<(), Box<dyn Error>> {
let initial_report = snapshot_drag_probe(None)?;
let drag_points = DragPoints { from: SERVO_DRAG_FROM, to: SERVO_DRAG_TO };
let dragged_report = snapshot_drag_probe(Some(drag_points))?;
assert_eq!(report_field_as_u64(&dragged_report, "drag_from_x")?, SERVO_DRAG_FROM.x);
assert_eq!(report_field_as_u64(&dragged_report, "drag_from_y")?, SERVO_DRAG_FROM.y);
assert_eq!(report_field_as_u64(&dragged_report, "drag_to_x")?, SERVO_DRAG_TO.x);
assert_eq!(report_field_as_u64(&dragged_report, "drag_to_y")?, SERVO_DRAG_TO.y);
assert!(report_field_as_bool(&dragged_report, "drag_changed_frame")?);
assert_ne!(
report_field_as_u64(&initial_report, "sample_hash")?,
report_field_as_u64(&dragged_report, "sample_hash")?
);
Ok(())
}
#[test]
fn sidecar_touches_page_with_servo_touch_input() -> Result<(), Box<dyn Error>> {
let initial_report = snapshot_touch_probe(None)?;
let touched_report = snapshot_touch_probe(Some(SERVO_TOUCH_POINT))?;
assert_eq!(report_field_as_u64(&touched_report, "touch_x")?, SERVO_TOUCH_POINT.x);
assert_eq!(report_field_as_u64(&touched_report, "touch_y")?, SERVO_TOUCH_POINT.y);
assert!(report_field_as_bool(&touched_report, "touch_changed_frame")?);
assert_ne!(
report_field_as_u64(&initial_report, "sample_hash")?,
report_field_as_u64(&touched_report, "sample_hash")?
);
Ok(())
}
#[test]
fn sidecar_types_text_with_servo_keyboard_input() -> Result<(), Box<dyn Error>> {
let initial_report = snapshot_text_probe(None)?;
let typed_report = snapshot_text_probe(Some(SERVO_TEXT_VALUE))?;
assert_eq!(
report_field_as_u64(&typed_report, "typed_text_byte_count")?,
SERVO_TEXT_VALUE.len() as u64
);
assert!(report_field_as_bool(&typed_report, "text_changed_frame")?);
assert_ne!(
report_field_as_u64(&initial_report, "sample_hash")?,
report_field_as_u64(&typed_report, "sample_hash")?
);
Ok(())
}
@@ -1,141 +0,0 @@
pub(super) const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[
PrdSiteCompatibilityCase { url: "https://github.com", title_fragment: "GitHub" },
PrdSiteCompatibilityCase { url: "https://example.com", title_fragment: "Example Domain" },
PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" },
];
pub(super) const PRD_REFERENCE_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[
PrdSiteCompatibilityCase {
url: "https://blog.google/products-and-platforms/products/chrome/new-chrome-productivity-features/",
title_fragment: "Chrome",
},
PrdSiteCompatibilityCase {
url: "https://www.microsoft.com/en-us/edge/features/vertical-tabs",
title_fragment: "Microsoft Edge",
},
PrdSiteCompatibilityCase {
url: "https://resources.arc.net/hc/en-us/articles/19230755904151-Favorites-Top-Tabs-Across-Every-Space",
title_fragment: "Favorites",
},
PrdSiteCompatibilityCase {
url: "https://resources.arc.net/hc/en-us/articles/19228855311127-Auto-Archive-Clean-as-you-go",
title_fragment: "Auto Archive",
},
PrdSiteCompatibilityCase {
url: "https://vivaldi.com/features/workspaces/",
title_fragment: "Workspaces",
},
PrdSiteCompatibilityCase {
url: "https://help.vivaldi.com/desktop/tabs/tab-tiling/",
title_fragment: "Tab Tiling",
},
PrdSiteCompatibilityCase { url: "https://www.gpui.rs/", title_fragment: "gpui" },
PrdSiteCompatibilityCase { url: "https://docs.rs/gpui", title_fragment: "gpui" },
PrdSiteCompatibilityCase {
url: "https://zed.dev/blog/videogame",
title_fragment: "Leveraging Rust",
},
PrdSiteCompatibilityCase {
url: "https://github.com/longbridge/gpui-component/",
title_fragment: "gpui-component",
},
PrdSiteCompatibilityCase {
url: "https://github.com/zed-industries/awesome-gpui/",
title_fragment: "awesome-gpui",
},
PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" },
PrdSiteCompatibilityCase {
url: "https://servo.org/blog/2026/04/13/servo-0.1.0-release/",
title_fragment: "Servo",
},
PrdSiteCompatibilityCase {
url: "https://developers.cloudflare.com/d1/",
title_fragment: "Cloudflare",
},
PrdSiteCompatibilityCase {
url: "https://developers.cloudflare.com/workers/platform/storage-options/",
title_fragment: "Cloudflare",
},
PrdSiteCompatibilityCase {
url: "https://developers.cloudflare.com/kv/concepts/how-kv-works/",
title_fragment: "Cloudflare",
},
PrdSiteCompatibilityCase {
url: "https://better-auth.com/blog/1-5",
title_fragment: "Better Auth",
},
PrdSiteCompatibilityCase {
url: "https://developers.cloudflare.com/d1/platform/limits/",
title_fragment: "Cloudflare",
},
PrdSiteCompatibilityCase {
url: "https://component-model.bytecodealliance.org/",
title_fragment: "WebAssembly Component Model",
},
PrdSiteCompatibilityCase {
url: "https://docs.wasmtime.dev/api/wasmtime/component/index.html",
title_fragment: "wasmtime",
},
PrdSiteCompatibilityCase {
url: "https://docs.wasmtime.dev/security.html",
title_fragment: "Wasmtime",
},
];
pub(super) const PRD_SITE_COMPATIBILITY_SIZES: &[FrameSize] = &[
FrameSize { width: 640, height: 480 },
FrameSize { width: 934, height: 657 },
FrameSize { width: 1614, height: 980 },
];
pub(super) const PRD_REFERENCE_SITE_SIZE: FrameSize = FrameSize { width: 934, height: 657 };
pub(super) const SERVO_SCROLL_SITE: PrdSiteCompatibilityCase =
PrdSiteCompatibilityCase { url: "https://servo.org/", title_fragment: "Servo" };
pub(super) const SERVO_SCROLL_SIZE: FrameSize = FrameSize { width: 934, height: 657 };
pub(super) const SERVO_SCROLL_OFFSET: ScrollOffset = ScrollOffset { x: 0, y: 480 };
pub(super) const SERVO_CLICK_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EClick%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Clicked%27%3Bthis.textContent%3D%27Clicked%27%3B%22%3ETap%3C%2Fbutton%3E";
pub(super) const SERVO_CLICK_SIZE: FrameSize = FrameSize { width: 640, height: 480 };
pub(super) const SERVO_CLICK_POINT: ClickPoint = ClickPoint { x: 160, y: 120 };
pub(super) const SERVO_DRAG_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3EDrag%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20id%3Dbox%3EDrag%3C%2Fbutton%3E%3Cscript%3Elet%20dragging%3Dfalse%3Bconst%20box%3Ddocument.getElementById%28%27box%27%29%3BaddEventListener%28%27mousedown%27%2Cevent%3D%3E%7Bif%28event.target%3D%3D%3Dbox%29%7Bdragging%3Dtrue%3B%7D%7D%29%3BaddEventListener%28%27mousemove%27%2Cevent%3D%3E%7Bif%28dragging%26%26event.clientX%3E280%29%7Bdocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Dragged%27%3Bbox.textContent%3D%27Dragged%27%3B%7D%7D%29%3BaddEventListener%28%27mouseup%27%2C%28%29%3D%3E%7Bdragging%3Dfalse%3B%7D%29%3B%3C%2Fscript%3E";
pub(super) const SERVO_DRAG_SIZE: FrameSize = FrameSize { width: 640, height: 480 };
pub(super) const SERVO_DRAG_FROM: ClickPoint = ClickPoint { x: 160, y: 120 };
pub(super) const SERVO_DRAG_TO: ClickPoint = ClickPoint { x: 320, y: 120 };
pub(super) const SERVO_TOUCH_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3ETouch%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3Btouch-action%3Amanipulation%3B%7D%3C%2Fstyle%3E%3Cbutton%20ontouchstart%3D%22document.body.dataset.touch%3D%27start%27%3B%22%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E";
pub(super) const SERVO_TOUCH_SIZE: FrameSize = FrameSize { width: 640, height: 480 };
pub(super) const SERVO_TOUCH_POINT: ClickPoint = ClickPoint { x: 160, y: 120 };
pub(super) const SERVO_TEXT_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EText%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3Bfont%3A28px%20sans-serif%3B%7Dinput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A260px%3Bheight%3A70px%3Bfont%3A28px%20sans-serif%3B%7Doutput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A180px%3Bfont%3A32px%20sans-serif%3B%7D%3C%2Fstyle%3E%3Cinput%20id%3Dq%20autofocus%20oninput%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.getElementById%28%27out%27%29.textContent%3Dthis.value%3B%22%3E%3Coutput%20id%3Dout%3Eempty%3C%2Foutput%3E";
pub(super) const SERVO_TEXT_SIZE: FrameSize = FrameSize { width: 640, height: 480 };
pub(super) const SERVO_TEXT_POINT: ClickPoint = ClickPoint { x: 160, y: 120 };
pub(super) const SERVO_TEXT_VALUE: &str = "ely42";
pub(super) struct PrdSiteCompatibilityCase {
pub(super) url: &'static str,
pub(super) title_fragment: &'static str,
}
#[derive(Clone, Copy)]
pub(super) struct FrameSize {
pub(super) width: u64,
pub(super) height: u64,
}
#[derive(Clone, Copy)]
pub(super) struct ScrollOffset {
pub(super) x: i64,
pub(super) y: i64,
}
impl ScrollOffset {
pub(super) const ZERO: Self = Self { x: 0, y: 0 };
}
#[derive(Clone, Copy)]
pub(super) struct ClickPoint {
pub(super) x: u64,
pub(super) y: u64,
}
#[derive(Clone, Copy)]
pub(super) struct DragPoints {
pub(super) from: ClickPoint,
pub(super) to: ClickPoint,
}
@@ -1,443 +0,0 @@
use std::{
error::Error,
io,
path::{Path, PathBuf},
process::{Child, Command, Output, Stdio},
sync::Mutex,
thread,
time::{Duration, Instant},
};
use ely_domain::ProfileId;
pub(super) use super::site_cases::{
ClickPoint, DragPoints, FrameSize, PRD_REFERENCE_SITE_COMPATIBILITY_CASES,
PRD_REFERENCE_SITE_SIZE, PRD_SITE_COMPATIBILITY_CASES, PRD_SITE_COMPATIBILITY_SIZES,
PrdSiteCompatibilityCase, SERVO_CLICK_POINT, SERVO_DRAG_FROM, SERVO_DRAG_TO,
SERVO_SCROLL_OFFSET, SERVO_SCROLL_SITE, SERVO_SCROLL_SIZE, SERVO_TEXT_VALUE, SERVO_TOUCH_POINT,
ScrollOffset,
};
use super::site_cases::{
SERVO_CLICK_SIZE, SERVO_CLICK_URL, SERVO_DRAG_SIZE, SERVO_DRAG_URL, SERVO_TEXT_POINT,
SERVO_TEXT_SIZE, SERVO_TEXT_URL, SERVO_TOUCH_SIZE, SERVO_TOUCH_URL,
};
pub(super) const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
const SIDECAR_TIMEOUT: Duration = Duration::from_secs(45);
const SIDECAR_POLL_INTERVAL: Duration = Duration::from_millis(20);
const SIDECAR_COMMAND_COOLDOWN: Duration = Duration::from_millis(750);
const SIDECAR_RETRY_INTERVAL: Duration = Duration::from_millis(250);
const SIDECAR_MAX_ATTEMPTS: usize = 3;
static SIDECAR_COMMAND_LOCK: Mutex<()> = Mutex::new(());
#[derive(Clone, Copy, Default)]
struct SnapshotInput<'a> {
click_point: Option<ClickPoint>,
drag_points: Option<DragPoints>,
touch_point: Option<ClickPoint>,
typed_text: Option<&'a str>,
}
pub(super) fn snapshot_prd_site(
case: &PrdSiteCompatibilityCase,
size: FrameSize,
scroll_offset: ScrollOffset,
) -> Result<serde_json::Value, Box<dyn Error>> {
let site_name = case
.url
.chars()
.map(|character| if character.is_ascii_alphanumeric() { character } else { '-' })
.collect::<String>();
let output_path = std::env::temp_dir().join(format!(
"ely-servo-sidecar-{}-{site_name}-{}x{}-{}-{}.rgba",
std::process::id(),
size.width,
size.height,
scroll_offset.x,
scroll_offset.y
));
snapshot_prd_site_with_retry(case, &output_path, size, scroll_offset)
}
fn snapshot_prd_site_with_retry(
case: &PrdSiteCompatibilityCase,
output_path: &Path,
size: FrameSize,
scroll_offset: ScrollOffset,
) -> Result<serde_json::Value, Box<dyn Error>> {
for attempt in 0..SIDECAR_MAX_ATTEMPTS {
match snapshot_prd_site_once(case, output_path, size, scroll_offset) {
Ok(report) => return Ok(report),
Err(error) if attempt + 1 == SIDECAR_MAX_ATTEMPTS => return Err(error),
Err(_) => remove_file_if_present(output_path)?,
}
thread::sleep(SIDECAR_RETRY_INTERVAL);
}
Err("sidecar PRD snapshot retry did not produce output".into())
}
fn snapshot_prd_site_once(
case: &PrdSiteCompatibilityCase,
output_path: &Path,
size: FrameSize,
scroll_offset: ScrollOffset,
) -> Result<serde_json::Value, Box<dyn Error>> {
let output = run_sidecar_snapshot_with_retry(
case.url,
output_path,
size,
scroll_offset,
SnapshotInput::default(),
)?;
assert!(
output.status.success(),
"{} {}x{}\nstatus: {:?}\nstdout: {}\nstderr: {}",
case.url,
size.width,
size.height,
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&output.stdout)?;
assert_report_state_is_renderable(&report)?;
assert_eq!(report_field_as_u64(&report, "width")?, size.width, "{}", case.url);
assert_eq!(report_field_as_u64(&report, "height")?, size.height, "{}", case.url);
assert_eq!(
report_field_as_u64(&report, "rgba_byte_count")?,
size.width * size.height * 4,
"{}",
case.url
);
assert_report_text_contains(&report, "loaded_url", case.url)?;
assert_report_text_equals(&report, "requested_url", case.url)?;
assert_report_text_contains(&report, "title", case.title_fragment)?;
assert!(report_field_as_u64(&report, "non_white_pixel_count")? > 0, "{}", case.url);
assert!(
report_field_as_u64(&report, "content_pixel_count")? >= MINIMUM_CONTENT_PIXELS,
"{}",
case.url
);
assert!(report_field_as_u64(&report, "sample_hash")? > 0, "{}", case.url);
assert_eq!(std::fs::metadata(output_path)?.len(), size.width * size.height * 4);
log_prd_report(&report, case, size)?;
std::fs::remove_file(output_path)?;
Ok(report)
}
fn remove_file_if_present(path: &Path) -> Result<(), Box<dyn Error>> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
pub(super) fn snapshot_click_probe(
click_point: Option<ClickPoint>,
) -> Result<serde_json::Value, Box<dyn Error>> {
snapshot_probe(
SERVO_CLICK_URL,
SERVO_CLICK_SIZE,
"click",
SnapshotInput { click_point, ..SnapshotInput::default() },
)
}
pub(super) fn snapshot_drag_probe(
drag_points: Option<DragPoints>,
) -> Result<serde_json::Value, Box<dyn Error>> {
snapshot_probe(
SERVO_DRAG_URL,
SERVO_DRAG_SIZE,
"drag",
SnapshotInput { drag_points, ..SnapshotInput::default() },
)
}
pub(super) fn snapshot_touch_probe(
touch_point: Option<ClickPoint>,
) -> Result<serde_json::Value, Box<dyn Error>> {
snapshot_probe(
SERVO_TOUCH_URL,
SERVO_TOUCH_SIZE,
"touch",
SnapshotInput { touch_point, ..SnapshotInput::default() },
)
}
pub(super) fn snapshot_text_probe(
typed_text: Option<&str>,
) -> Result<serde_json::Value, Box<dyn Error>> {
snapshot_probe(
SERVO_TEXT_URL,
SERVO_TEXT_SIZE,
"text",
SnapshotInput {
click_point: typed_text.map(|_| SERVO_TEXT_POINT),
typed_text,
..SnapshotInput::default()
},
)
}
fn snapshot_probe(
url: &str,
size: FrameSize,
label: &'static str,
input: SnapshotInput<'_>,
) -> Result<serde_json::Value, Box<dyn Error>> {
let output_path = std::env::temp_dir().join(format!(
"ely-servo-sidecar-{}-{label}-{}x{}.rgba",
std::process::id(),
size.width,
size.height
));
if output_path.exists() {
std::fs::remove_file(&output_path)?;
}
let output =
run_sidecar_snapshot_with_retry(url, &output_path, size, ScrollOffset::ZERO, input)?;
assert!(
output.status.success(),
"{label} probe\nstatus: {:?}\nstdout: {}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&output.stdout)?;
assert_eq!(report_field_as_u64(&report, "width")?, size.width);
assert_eq!(report_field_as_u64(&report, "height")?, size.height);
assert!(report_field_as_u64(&report, "content_pixel_count")? > 0);
assert_eq!(std::fs::metadata(&output_path)?.len(), size.width * size.height * 4);
std::fs::remove_file(&output_path)?;
Ok(report)
}
fn run_sidecar_snapshot(
site_url: &str,
output_path: &Path,
size: FrameSize,
scroll_offset: ScrollOffset,
input: SnapshotInput<'_>,
) -> Result<Output, Box<dyn Error>> {
let _guard = SIDECAR_COMMAND_LOCK
.lock()
.map_err(|_| io::Error::other("sidecar command lock poisoned"))?;
let profile_id = ProfileId::new();
let profile_data_dir = temporary_profile_data_dir(&profile_id);
let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"));
command
.arg("snapshot")
.arg("--url")
.arg(site_url)
.arg("--profile-id")
.arg(profile_id.as_str())
.arg("--profile-data-dir")
.arg(&profile_data_dir)
.arg("--rgba-out")
.arg(output_path)
.arg("--width")
.arg(size.width.to_string())
.arg("--height")
.arg(size.height.to_string());
if scroll_offset.x != 0 {
command.arg("--scroll-x").arg(scroll_offset.x.to_string());
}
if scroll_offset.y != 0 {
command.arg("--scroll-y").arg(scroll_offset.y.to_string());
}
if let Some(click_point) = input.click_point {
command.arg("--click-x").arg(click_point.x.to_string());
command.arg("--click-y").arg(click_point.y.to_string());
}
if let Some(drag_points) = input.drag_points {
command.arg("--drag-from-x").arg(drag_points.from.x.to_string());
command.arg("--drag-from-y").arg(drag_points.from.y.to_string());
command.arg("--drag-to-x").arg(drag_points.to.x.to_string());
command.arg("--drag-to-y").arg(drag_points.to.y.to_string());
}
if let Some(touch_point) = input.touch_point {
command.arg("--touch-x").arg(touch_point.x.to_string());
command.arg("--touch-y").arg(touch_point.y.to_string());
}
if let Some(typed_text) = input.typed_text {
command.arg("--type-text").arg(typed_text);
}
let mut child = command.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
let started_at = Instant::now();
loop {
if child.try_wait()?.is_some() {
let output = child.wait_with_output()?;
remove_temporary_dir(&profile_data_dir)?;
thread::sleep(SIDECAR_COMMAND_COOLDOWN);
return Ok(output);
}
if started_at.elapsed() >= SIDECAR_TIMEOUT {
terminate_child(child)?;
remove_temporary_dir(&profile_data_dir)?;
thread::sleep(SIDECAR_COMMAND_COOLDOWN);
return Err(format!(
"timed out rendering {site_url} at {}x{}",
size.width, size.height
)
.into());
}
thread::sleep(SIDECAR_POLL_INTERVAL);
}
}
fn temporary_profile_data_dir(profile_id: &ProfileId) -> PathBuf {
std::env::temp_dir().join(format!(
"ely-servo-sidecar-profile-{}-{}",
std::process::id(),
profile_id.as_str()
))
}
fn remove_temporary_dir(path: &Path) -> Result<(), Box<dyn Error>> {
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
fn run_sidecar_snapshot_with_retry(
site_url: &str,
output_path: &std::path::Path,
size: FrameSize,
scroll_offset: ScrollOffset,
input: SnapshotInput<'_>,
) -> Result<Output, Box<dyn Error>> {
for attempt in 0..SIDECAR_MAX_ATTEMPTS {
if output_path.exists() {
std::fs::remove_file(output_path)?;
}
match run_sidecar_snapshot(site_url, output_path, size, scroll_offset, input) {
Ok(output) if output.status.success() => return Ok(output),
Ok(output) if attempt + 1 == SIDECAR_MAX_ATTEMPTS => return Ok(output),
Ok(_output) => {}
Err(error) if attempt + 1 == SIDECAR_MAX_ATTEMPTS => return Err(error),
Err(_error) => {}
}
thread::sleep(SIDECAR_RETRY_INTERVAL);
}
Err("sidecar snapshot retry did not produce output".into())
}
fn terminate_child(mut child: Child) -> Result<(), Box<dyn Error>> {
match child.kill() {
Ok(()) => {
let _output = child.wait_with_output()?;
Ok(())
}
Err(error) if error.kind() == io::ErrorKind::InvalidInput => Ok(()),
Err(error) => Err(error.into()),
}
}
fn assert_report_text_equals(
report: &serde_json::Value,
field: &'static str,
expected: &str,
) -> Result<(), Box<dyn Error>> {
let value = report_field_as_text(report, field)?;
if value == expected { Ok(()) } else { Err(format!("{field}: {value}").into()) }
}
fn assert_report_text_contains(
report: &serde_json::Value,
field: &'static str,
fragment: &str,
) -> Result<(), Box<dyn Error>> {
let value = report_field_as_text(report, field)?;
if value.contains(fragment) { Ok(()) } else { Err(format!("{field}: {value}").into()) }
}
fn assert_report_state_is_renderable(report: &serde_json::Value) -> Result<(), Box<dyn Error>> {
let state = report_field_as_text(report, "state")?;
if matches!(state, "complete" | "loading") {
Ok(())
} else {
Err(format!("state: {state}").into())
}
}
fn log_prd_report(
report: &serde_json::Value,
case: &PrdSiteCompatibilityCase,
size: FrameSize,
) -> Result<(), Box<dyn Error>> {
eprintln!(
"prd-live-site servo-sidecar url={} loaded={} title={} state={} size={}x{} content_pixels={} non_white_pixels={} sample_hash={}",
case.url,
report_field_as_text(report, "loaded_url")?,
report_field_as_text(report, "title")?,
report_field_as_text(report, "state")?,
size.width,
size.height,
report_field_as_u64(report, "content_pixel_count")?,
report_field_as_u64(report, "non_white_pixel_count")?,
report_field_as_u64(report, "sample_hash")?
);
Ok(())
}
fn report_field_as_text<'a>(
report: &'a serde_json::Value,
field: &'static str,
) -> Result<&'a str, Box<dyn Error>> {
report
.get(field)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("missing text report field: {field}").into())
}
pub(super) fn report_field_as_bool(
report: &serde_json::Value,
field: &'static str,
) -> Result<bool, Box<dyn Error>> {
report
.get(field)
.and_then(serde_json::Value::as_bool)
.ok_or_else(|| format!("missing boolean report field: {field}").into())
}
pub(super) fn report_field_as_i64(
report: &serde_json::Value,
field: &'static str,
) -> Result<i64, Box<dyn Error>> {
report
.get(field)
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| format!("missing signed report field: {field}").into())
}
pub(super) fn report_field_as_u64(
report: &serde_json::Value,
field: &'static str,
) -> Result<u64, Box<dyn Error>> {
report
.get(field)
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| format!("missing numeric report field: {field}").into())
}
+2 -14
View File
@@ -11,9 +11,8 @@ use std::{
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature, TabId, UrlText};
use ely_servo_host::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, NavigationRequest,
PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest, ScreenshotRequest,
ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest,
WebViewState,
PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest, ScrollRequest,
ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, TouchTapRequest, WebViewState,
};
const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
@@ -336,17 +335,6 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
site.url
);
assert_rendered_frame_has_content(&host, site.url, MINIMUM_CONTENT_PIXELS)?;
if site.url == "https://example.com" {
let screenshot =
host.capture_screenshot(ScreenshotRequest { webview_id: webview_id.clone() })?;
assert_frame_has_dimensions_and_content(
&screenshot,
"https://example.com screenshot",
INITIAL_WIDTH,
INITIAL_HEIGHT,
MINIMUM_CONTENT_PIXELS,
);
}
previous_frame_hash = Some(host.last_rendered_frame()?.sample_hash());
}
+47 -44
View File
@@ -2,46 +2,52 @@
## Decision
ELY is a Servo-based browser. The page renderer is Servo itself, embedded in the application process and attached to a real platform rendering surface. The browser chrome can stay GPUI, while web content must follow Servo's embedder model:
ELY is a Servo-based browser. The page renderer lives in the application process and follows Servo's embedder model:
```text
┌──────────────────────────── ELY App Process ────────────────────────────┐
│ │
│ GPUI chrome │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Sidebar Toolbar Tabs Settings │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ Servo content host │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Servo + WebView + WindowRenderingContext │ │
│ │ notify_new_frame_ready -> window repaint -> paint -> present │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Servo + WebView + RenderingContext │ │
│ │ notify_new_frame_ready -> repaint -> paint -> present │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
└─────────────────────────────────────────────────────────────────────────
```
The normal page-display path excludes external rendering sidecars, stdout frame transport, RGBA frame payloads, cross-process IOSurface handoff, and GPUI `RenderImage` uploads for live web content.
The app process owns WebView lifecycle, navigation, input, permissions, frame readiness, and rendering context presentation.
## Root Cause
The current ELY page path is a remote-frame architecture:
## Current Code Boundary
```text
GPUI shell
-> WebSurfaceStore
WebSurfaceStore
-> GPUI NativeSurface
-> LiveRuntimeWorker
-> ServoLiveClient
-> ely_servo_sidecar stdin/stdout JSON
-> SoftwareServoHost
-> Servo WebView paint
-> RGBA payload or IOSurface handle
-> GPUI surface/image element
-> servo::Servo + servo::WebView + servo::WindowRenderingContext
```
This makes page interaction depend on worker scheduling, IPC, polling cadence, surface import, frame object churn, and GPUI scene refresh. Hardware IOSurface reduces byte volume, while the architecture still behaves like a remote compositor.
`ServoLiveClient` is now an in-process adapter over `ely_servo_host::SoftwareServoHost`. It shares one Servo runtime across profile scopes, creates multiple WebViews inside that runtime, and routes scroll, hover, click, keyboard, resize, zoom, navigation, and permissions through the `ServoHost` API.
Servo's own embedder route is direct:
Normal page display now enters GPUI through `native_surface(...)`. GPUI creates a platform child surface for the content bounds, passes that raw handle into Servo's `WindowRenderingContext`, and presents with `paint_without_readback_with_completion`. The RGBA readback path remains inside `ely_servo_host` for low-level tests.
Current platform child surfaces:
- macOS: child `NSView` with an AppKit raw window handle.
- Windows: child `HWND` with a Win32 raw window handle.
- Linux/X11: child XCB window with XCB display/window handles.
- Linux/Wayland: child `wl_surface` attached as a `wl_subsurface` with Wayland display/surface handles.
## Upstream Servo Route
Servo's own shell route is the model for the final ELY rendering path:
```text
Window event
@@ -57,7 +63,7 @@ Relevant upstream evidence from Servo `7c48af7`:
- `ports/servoshell/window.rs` creates `WebViewBuilder::new(state.servo(), platform_window.rendering_context())`.
- `ports/servoshell/window.rs` repaints with `webview.paint()` and `rendering_context().present()`.
- `ports/servoshell/running_app_state.rs` handles `notify_new_frame_ready` by marking the owning window for repaint.
- `components/paint/paint.rs` owns one WebRender painter per `RenderingContext` and explicitly avoids blocking paint on the constellation.
- `components/paint/paint.rs` owns one WebRender painter per `RenderingContext` and keeps paint coordination inside Servo's rendering pipeline.
## Target Boundaries
@@ -73,11 +79,11 @@ crates/ely_app/src/servo_embed
WebView lifecycle, repaint dispatch, and web input routing.
crates/ely_servo_host
Transitional compatibility surface for explicit screenshots and isolated
compatibility tools. Normal live page display leaves this crate.
Owns the current in-process compatibility adapter while the native
platform rendering surface lands.
```
Each production source file in the new embedding path stays below 500 lines. Large responsibilities split by ownership:
Each production source file in the final embedding path stays below 500 lines. Large responsibilities split by ownership:
- `runtime.rs`: `Servo`, wake handling, webview registry.
- `platform_view.rs`: platform content surface attachment.
@@ -88,36 +94,33 @@ Each production source file in the new embedding path stays below 500 lines. Lar
## Migration Slices
1. Create `servo_embed` as an in-process module behind the existing tab/domain model.
2. Add a macOS platform content view using the GPUI window's raw AppKit handle.
3. Build Servo `WindowRenderingContext` or child context against the platform content view.
4. Move one active tab to in-process `Servo + WebView + RenderingContext`.
5. Route scroll, mouse, keyboard, resize, zoom, and navigation directly into the active `WebView`.
6. Replace `WebSurfaceStore` for normal web pages with the in-process host.
7. Delete sidecar spawning from normal page display.
8. Keep explicit page screenshot capture as a user-command path only.
1. App process owns the Servo runtime and WebView registry.
2. App process routes web input and navigation directly into Servo.
3. GPUI exposes one `NativeSurfaceHandle` element contract for platform child surfaces.
4. macOS creates a child `NSView` and hands the AppKit raw handle to Servo.
5. Windows creates a child `HWND` and hands the Win32 raw handle to Servo.
6. Linux/X11 creates a child XCB window and hands the XCB raw handle to Servo.
7. Linux/Wayland creates a child `wl_surface`/subsurface and hands the Wayland raw handle to Servo.
8. GPUI web page display uses native content surface presentation for every platform target.
## Acceptance Gates
- `cargo run` opens a normal web page without starting `ely_servo_sidecar`.
- `cargo run` opens a normal web page with Servo inside the ELY app process.
- Scrolling a live web page uses Servo input events and Servo repaint callbacks.
- The page display path contains no RGBA frame payload transport.
- The page display path contains no stdout JSON frame loop.
- The page display path contains no GPUI `RenderImage` upload for live web content.
- Address/search, tabs, spaces, profiles, settings, permissions, sync, and explicit screenshots continue to compile and behave through existing domain APIs.
- Page display presents through a Servo rendering context.
- macOS, Windows, Linux/X11, and Linux/Wayland each have a platform child surface implementation under the same `NativeSurfaceHandle` contract.
- Address/search, tabs, spaces, profiles, settings, permissions, and sync compile through existing domain APIs.
- Every new source file stays below 500 lines.
- No user-facing frontend status, logs, debug panels, or explanatory clutter are added.
- User-facing chrome stays clean.
## First Implementation Target
The first code slice is macOS content-view attachment:
## First Platform Surface Target
```text
GPUI Window
-> raw AppKit NSView
-> ELY child NSView for web content bounds
-> GPUI NativeSurface element
-> platform child surface for web content bounds
-> Servo WindowRenderingContext
-> Servo WebView
```
This slice creates the real platform surface required by Servo's direct rendering model. Once the content view exists, Servo can paint into a native surface in the ELY app process, and the remote-frame path can be removed tab by tab.
This slice gives Servo a native surface in the ELY app process. The compatibility adapter remains a narrow bridge for tests and non-normal display probes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+80
View File
@@ -0,0 +1,80 @@
{
"images": [
{
"size": "20x20",
"idiom": "iphone",
"filename": "icon-40.png",
"scale": "2x"
},
{
"size": "20x20",
"idiom": "iphone",
"filename": "icon-60.png",
"scale": "3x"
},
{
"size": "29x29",
"idiom": "iphone",
"filename": "icon-58.png",
"scale": "2x"
},
{
"size": "29x29",
"idiom": "iphone",
"filename": "icon-87.png",
"scale": "3x"
},
{
"size": "40x40",
"idiom": "iphone",
"filename": "icon-80.png",
"scale": "2x"
},
{
"size": "40x40",
"idiom": "iphone",
"filename": "icon-120.png",
"scale": "3x"
},
{
"size": "60x60",
"idiom": "iphone",
"filename": "icon-120.png",
"scale": "2x"
},
{
"size": "60x60",
"idiom": "iphone",
"filename": "icon-180.png",
"scale": "3x"
},
{
"size": "76x76",
"idiom": "ipad",
"filename": "icon-76.png",
"scale": "1x"
},
{
"size": "76x76",
"idiom": "ipad",
"filename": "icon-152.png",
"scale": "2x"
},
{
"size": "83.5x83.5",
"idiom": "ipad",
"filename": "icon-167.png",
"scale": "2x"
},
{
"size": "1024x1024",
"idiom": "ios-marketing",
"filename": "icon-1024.png",
"scale": "1x"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

-4
View File
@@ -3,21 +3,17 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
binary_path="${repo_root}/target/debug/ely_app"
sidecar_path="${repo_root}/target/debug/ely_servo_sidecar"
bundle_root="${repo_root}/target/macos/ELY Browser.app"
contents_dir="${bundle_root}/Contents"
macos_dir="${contents_dir}/MacOS"
resources_dir="${contents_dir}/Resources"
cargo build -p ely_app
cargo build -p ely_servo_host --features servo-engine,hardware-render --bin ely_servo_sidecar
rm -rf "${bundle_root}"
mkdir -p "${macos_dir}" "${resources_dir}"
cp "${repo_root}/packaging/macos/Info.plist" "${contents_dir}/Info.plist"
cp "${binary_path}" "${macos_dir}/ely_app"
cp "${sidecar_path}" "${macos_dir}/ely_servo_sidecar"
chmod 755 "${macos_dir}/ely_app"
chmod 755 "${macos_dir}/ely_servo_sidecar"
echo "${bundle_root}"
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Generate ELY Browser app icon — source PNG + all platform sizes."""
from PIL import Image, ImageDraw, ImageFilter
import math
import os
import json
RENDER = 3072
FINAL = 1024
GRAD_SMALL = 512 # larger base for smoother gradient
BASE = os.path.dirname(os.path.dirname(__file__))
OUT_DIR = os.path.join(BASE, "generated-icons")
os.makedirs(OUT_DIR, exist_ok=True)
def create_gradient(size):
img = Image.new("RGB", (size, size))
for y in range(size):
for x in range(size):
nx, ny = x / size, y / size
r, g, b = 236.0, 224.0, 243.0
dx = (nx - 0.80) * 1.7
dy = (ny - 0.14) * 2.1
d = min(1.0, math.sqrt(dx * dx + dy * dy))
w = max(0.0, 1.0 - d) ** 1.7
r += (255 - r) * w; g += (195 - g) * w; b += (212 - b) * w
dx = (nx - 0.17) * 1.8
dy = (ny - 0.87) * 1.7
d = min(1.0, math.sqrt(dx * dx + dy * dy))
w = max(0.0, 1.0 - d) ** 1.7
r += (170 - r) * w; g += (190 - g) * w; b += (255 - b) * w
dx = (nx - 0.22) * 2.0
dy = (ny - 0.22) * 2.0
d = min(1.0, math.sqrt(dx * dx + dy * dy))
w = max(0.0, 1.0 - d) ** 2.2 * 0.35
r += (255 - r) * w; g += (222 - g) * w; b += (208 - b) * w
img.putpixel((x, y), (min(255, int(r)), min(255, int(g)), min(255, int(b))))
return img
def draw_e_rounded(draw, cx, cy, sz):
"""Draw geometric E with slightly rounded bar ends."""
e_h = int(sz * 0.40)
e_w = int(sz * 0.30)
mid_w = int(e_w * 0.70)
bar = int(sz * 0.064)
cr = int(bar * 0.28) # corner radius for exposed ends
left = cx - e_w // 2
top = cy - e_h // 2
right = left + e_w
bottom = top + e_h
mid_y = cy
white = (255, 255, 255, 255)
# Vertical bar — full height, rounded top-left and bottom-left corners
draw.rounded_rectangle([left, top, left + bar, bottom], radius=cr, fill=white)
# Top bar — overlaps vertical bar at left end
draw.rounded_rectangle([left, top, right, top + bar], radius=cr, fill=white)
# Middle bar (shorter)
draw.rounded_rectangle(
[left, mid_y - bar // 2, left + mid_w, mid_y + bar // 2],
radius=cr, fill=white,
)
# Bottom bar
draw.rounded_rectangle([left, bottom - bar, right, bottom], radius=cr, fill=white)
def main():
print("Creating gradient (512x512)...")
grad_small = create_gradient(GRAD_SMALL)
grad = grad_small.resize((RENDER, RENDER), Image.BICUBIC)
# Slight blur to eliminate any banding
grad = grad.filter(ImageFilter.GaussianBlur(radius=2))
print("Building icon at 3072...")
corner_r = int(RENDER * 0.223)
mask = Image.new("L", (RENDER, RENDER), 0)
ImageDraw.Draw(mask).rounded_rectangle(
[0, 0, RENDER - 1, RENDER - 1], radius=corner_r, fill=255
)
icon = Image.new("RGBA", (RENDER, RENDER), (0, 0, 0, 0))
icon.paste(grad.convert("RGBA"), mask=mask)
cx, cy = RENDER // 2, RENDER // 2
# Subtle drop shadow for the E
shadow = Image.new("RGBA", (RENDER, RENDER), (0, 0, 0, 0))
draw_e_rounded(ImageDraw.Draw(shadow), cx, cy + int(RENDER * 0.005), RENDER)
# Convert white shadow pixels to dark translucent
sd = shadow.load()
for sy in range(RENDER):
for sx in range(RENDER):
_, _, _, a = sd[sx, sy]
if a > 0:
sd[sx, sy] = (25, 18, 12, int(a * 0.15))
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=int(RENDER * 0.01)))
icon = Image.alpha_composite(icon, shadow)
# White E glyph
glyph = Image.new("RGBA", (RENDER, RENDER), (0, 0, 0, 0))
draw_e_rounded(ImageDraw.Draw(glyph), cx, cy, RENDER)
icon = Image.alpha_composite(icon, glyph)
# Downscale to 1024
source = icon.resize((FINAL, FINAL), Image.LANCZOS)
src_path = os.path.join(OUT_DIR, "icon-source-1024.png")
source.save(src_path, "PNG")
print(f" Source: {src_path}")
# ---- Generate all platform sizes from source ----
print("Generating platform sizes...")
ios_dir = os.path.join(OUT_DIR, "ios")
android_dir = os.path.join(OUT_DIR, "android")
web_dir = os.path.join(OUT_DIR, "web")
macos_dir = os.path.join(OUT_DIR, "macos")
for d in [ios_dir, android_dir, web_dir, macos_dir]:
os.makedirs(d, exist_ok=True)
# iOS
ios_sizes = [40, 60, 58, 87, 80, 120, 180, 76, 152, 167, 1024]
for s in ios_sizes:
resized = source.resize((s, s), Image.LANCZOS)
resized.save(os.path.join(ios_dir, f"icon-{s}.png"), "PNG")
print(f" iOS: {len(ios_sizes)} sizes")
# Write Contents.json for Xcode
contents = {
"images": [
{"size": "20x20", "idiom": "iphone", "filename": "icon-40.png", "scale": "2x"},
{"size": "20x20", "idiom": "iphone", "filename": "icon-60.png", "scale": "3x"},
{"size": "29x29", "idiom": "iphone", "filename": "icon-58.png", "scale": "2x"},
{"size": "29x29", "idiom": "iphone", "filename": "icon-87.png", "scale": "3x"},
{"size": "40x40", "idiom": "iphone", "filename": "icon-80.png", "scale": "2x"},
{"size": "40x40", "idiom": "iphone", "filename": "icon-120.png", "scale": "3x"},
{"size": "60x60", "idiom": "iphone", "filename": "icon-120.png", "scale": "2x"},
{"size": "60x60", "idiom": "iphone", "filename": "icon-180.png", "scale": "3x"},
{"size": "76x76", "idiom": "ipad", "filename": "icon-76.png", "scale": "1x"},
{"size": "76x76", "idiom": "ipad", "filename": "icon-152.png", "scale": "2x"},
{"size": "83.5x83.5", "idiom": "ipad", "filename": "icon-167.png", "scale": "2x"},
{"size": "1024x1024", "idiom": "ios-marketing", "filename": "icon-1024.png", "scale": "1x"},
],
"info": {"version": 1, "author": "xcode"},
}
with open(os.path.join(ios_dir, "Contents.json"), "w") as f:
json.dump(contents, f, indent=2)
# Android
android_sizes = {"mdpi": 48, "hdpi": 72, "xhdpi": 96, "xxhdpi": 144, "xxxhdpi": 192}
for density, s in android_sizes.items():
resized = source.resize((s, s), Image.LANCZOS)
resized.save(os.path.join(android_dir, f"ic_launcher_{density}.png"), "PNG")
source.resize((512, 512), Image.LANCZOS).save(
os.path.join(android_dir, "playstore-512.png"), "PNG"
)
print(f" Android: {len(android_sizes) + 1} sizes")
# Web + PWA
web_sizes = [16, 32, 48, 72, 96, 128, 144, 152, 192, 384, 512]
for s in web_sizes:
resized = source.resize((s, s), Image.LANCZOS)
resized.save(os.path.join(web_dir, f"icon-{s}.png"), "PNG")
# Apple touch icon
source.resize((180, 180), Image.LANCZOS).save(
os.path.join(web_dir, "apple-touch-icon.png"), "PNG"
)
# Favicon .ico (multi-size)
ico_16 = source.resize((16, 16), Image.LANCZOS)
ico_32 = source.resize((32, 32), Image.LANCZOS)
ico_48 = source.resize((48, 48), Image.LANCZOS)
ico_16.save(os.path.join(web_dir, "favicon.ico"), format="ICO", sizes=[(16, 16), (32, 32), (48, 48)])
print(f" Web/PWA: {len(web_sizes) + 2} assets")
# macOS .iconset (for iconutil → .icns)
iconset_dir = os.path.join(macos_dir, "AppIcon.iconset")
os.makedirs(iconset_dir, exist_ok=True)
macos_sizes = {
"icon_16x16.png": 16,
"icon_16x16@2x.png": 32,
"icon_32x32.png": 32,
"icon_32x32@2x.png": 64,
"icon_128x128.png": 128,
"icon_128x128@2x.png": 256,
"icon_256x256.png": 256,
"icon_256x256@2x.png": 512,
"icon_512x512.png": 512,
"icon_512x512@2x.png": 1024,
}
for name, s in macos_sizes.items():
resized = source.resize((s, s), Image.LANCZOS)
resized.save(os.path.join(iconset_dir, name), "PNG")
print(f" macOS: {len(macos_sizes)} sizes in AppIcon.iconset")
print("\nDone! All icons in: generated-icons/")
print("To build macOS .icns: iconutil -c icns generated-icons/macos/AppIcon.iconset")
if __name__ == "__main__":
main()
-3
View File
@@ -3,6 +3,3 @@ set -euo pipefail
echo "Verifying PRD live sites through the GPUI web surface adapter"
cargo test -p ely_app --features live-site-smoke --all-targets -- --test-threads=1 --nocapture
echo "Verifying PRD live sites through Servo sidecar RGBA snapshots"
cargo test -p ely_servo_host --features servo-engine --test sidecar -- --test-threads=1 --nocapture
-8
View File
@@ -167,10 +167,6 @@ version = "0.2"
[dependencies.log]
version = "0.4.16"
features = [
"kv_unstable_serde",
"serde",
]
[dependencies.lyon]
version = "1.0"
@@ -499,10 +495,6 @@ version = "0.5"
[target.'cfg(target_os = "macos")'.dependencies.log]
version = "0.4.16"
features = [
"kv_unstable_serde",
"serde",
]
[target.'cfg(target_os = "macos")'.dependencies.media]
version = "0.2.2"
+2
View File
@@ -6,6 +6,7 @@ mod div;
mod image_cache;
mod img;
mod list;
mod native_surface;
mod surface;
mod svg;
mod text;
@@ -19,6 +20,7 @@ pub use div::*;
pub use image_cache::*;
pub use img::*;
pub use list::*;
pub use native_surface::*;
pub use surface::*;
pub use svg::*;
pub use text::*;
+109
View File
@@ -0,0 +1,109 @@
use crate::{
App, Bounds, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, LayoutId,
NativeSurfaceHandle, Pixels, Style, StyleRefinement, Styled, Window,
};
use refineable::Refineable;
/// A native platform surface hosted inside a GPUI layout box.
pub struct NativeSurface {
id: ElementId,
on_surface: Option<Box<dyn FnMut(NativeSurfaceHandle, Bounds<Pixels>, &mut Window, &mut App)>>,
style: StyleRefinement,
}
/// Create a native platform surface element.
pub fn native_surface(
id: impl Into<ElementId>,
on_surface: impl FnMut(NativeSurfaceHandle, Bounds<Pixels>, &mut Window, &mut App) + 'static,
) -> NativeSurface {
NativeSurface { id: id.into(), on_surface: Some(Box::new(on_surface)), style: Default::default() }
}
impl Element for NativeSurface {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_global_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.refine(&self.style);
let layout_id = window.request_layout(style, [], cx);
(layout_id, ())
}
fn prepaint(
&mut self,
_global_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
_bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
}
fn paint(
&mut self,
global_id: Option<&GlobalElementId>,
_inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
let Some(global_id) = global_id else {
return;
};
let Some(on_surface) = self.on_surface.as_mut() else {
return;
};
let Some(surface) = window.with_element_state::<NativeSurfaceState, _>(
global_id,
|state, window| {
let surface = state
.and_then(|state| state.surface)
.or_else(|| window.create_native_surface());
if let Some(surface) = surface {
window.sync_native_surface(&surface, bounds);
return (Some(surface.clone()), NativeSurfaceState { surface: Some(surface) });
}
(None, NativeSurfaceState { surface: None })
},
) else {
return;
};
on_surface(surface, bounds, window, cx);
}
}
impl IntoElement for NativeSurface {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Styled for NativeSurface {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
struct NativeSurfaceState {
surface: Option<NativeSurfaceHandle>,
}

Some files were not shown because too many files have changed in this diff Show More