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
+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;
+265 -251
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,
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,
})
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> {
#[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 })
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> {
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 {
let Some(session) = self.sessions.remove(&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",
);
}
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,
});
}
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,
native_surface_id,
},
);
Ok(webview_id)
}
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(());
};
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;
}
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(())
}
}
impl Drop for ServoLiveClient {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
fn apply_input(
&mut self,
request: &ServoLiveEnsureRequest,
webview_id: &WebViewId,
) -> Result<(), ServoLiveError> {
if request.scroll_delta_x != 0 || request.scroll_delta_y != 0 {
let point_x = request.scroll_point_x.ok_or(ServoLiveError::MissingScrollPoint)?;
let point_y = request.scroll_point_y.ok_or(ServoLiveError::MissingScrollPoint)?;
self.host.scroll(ScrollRequest {
webview_id: webview_id.clone(),
delta_x: request.scroll_delta_x,
delta_y: request.scroll_delta_y,
point_x,
point_y,
})?;
}
if let (Some(x), Some(y)) = (request.hover_x, request.hover_y) {
self.host.hover(MouseHoverRequest { webview_id: webview_id.clone(), x, y })?;
}
if let (Some(x), Some(y)) = (request.click_x, request.click_y) {
self.host.click(MouseClickRequest { webview_id: webview_id.clone(), x, y })?;
}
if let Some(text) = request.typed_text.as_ref() {
self.host.type_text(KeyboardTextRequest {
webview_id: webview_id.clone(),
text: text.clone(),
})?;
}
Ok(())
}
fn 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,
+19 -195
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;
swap_red_blue_in_place(&mut bytes);
let bytes_hash = rgba_hash(&bytes);
Some(resolve_render_image(parts.width, parts.height, bytes, bytes_hash)?)
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,14 +231,7 @@ 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
}
false
}
}
@@ -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,
+240 -89
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);
self.ensure_worker(scope.clone())?;
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 {
return Err("Servo worker was created but is no longer registered".to_string());
};
scoped.worker.submit_ensure(request);
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,68 +169,19 @@ 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();
for response in responses {
match response {
WorkerResponse::Frame { tab_id, frame } => {
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
continue;
};
let session = match self.sessions.get_mut(&tab_id_obj) {
Some(session) => session,
None => continue,
};
let requested_url = session.requested_url.clone();
let scroll_offset = session.scroll_offset;
let zoom_percent = session.zoom_percent;
session.cadence.note_frame(frame.render_state(), now);
match WebSurfaceFrame::from_live_frame(
requested_url.clone(),
scroll_offset,
zoom_percent,
frame,
) {
Ok(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),
url_change,
});
}
Err(error) => frames.push(WebSurfaceRuntimeFrame::Failed {
tab_id: tab_id_obj,
message: error.to_string(),
}),
}
}
WorkerResponse::Failed { tab_id, message } => {
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
continue;
};
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
}
WorkerResponse::SidecarExited => dead_scopes.push(scope.clone()),
}
}
}
for scope in dead_scopes {
self.workers.remove(&scope);
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;
@@ -183,11 +189,22 @@ impl WebSurfaceRuntime {
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);
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
@@ -201,38 +218,156 @@ impl WebSurfaceRuntime {
visible_tab_ids
.iter()
.filter_map(|tab_id| self.sessions.get(tab_id))
.filter(|session| self.workers.contains_key(&session.scope))
.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) {
let Some(session) = self.sessions.remove(tab_id) else {
if self.sessions.remove(tab_id).is_none() {
return;
};
if let Some(scoped) = self.workers.get(&session.scope) {
}
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.workers.contains_key(&scope) {
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)?;
let worker = LiveRuntimeWorker::new(client)?;
self.workers.insert(scope, ScopedWorker { worker, transient_profile_data_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 } => {
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
continue;
};
let session = match self.sessions.get_mut(&tab_id_obj) {
Some(session) => session,
None => continue,
};
let requested_url = session.requested_url.clone();
let scroll_offset = session.scroll_offset;
let zoom_percent = session.zoom_percent;
session.cadence.note_frame(frame.render_state(), now);
match WebSurfaceFrame::from_live_frame(
requested_url.clone(),
scroll_offset,
zoom_percent,
frame,
) {
Ok(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),
url_change,
});
}
Err(error) => frames.push(WebSurfaceRuntimeFrame::Failed {
tab_id: tab_id_obj,
message: error.to_string(),
}),
}
}
WorkerResponse::Failed { tab_id, message } => {
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
continue;
};
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
}
WorkerResponse::RuntimeUnavailable => {
runtime_unavailable = true;
}
}
}
runtime_unavailable
}
fn lookup_session_tab_id(&self, tab_id: &str) -> Option<TabId> {
self.sessions.keys().find(|key| key.as_str() == tab_id).cloned()
}
#[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