feat(servo): isolate profiles with hardware sidecars
This commit is contained in:
@@ -15,7 +15,6 @@ ed25519-dalek.workspace = true
|
||||
ely_browser_core = { path = "../ely_browser_core" }
|
||||
ely_design_system = { path = "../ely_design_system" }
|
||||
ely_domain = { path = "../ely_domain" }
|
||||
ely_servo_host = { path = "../ely_servo_host", features = ["servo-engine"] }
|
||||
ely_sync_client = { path = "../ely_sync_client" }
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
@@ -24,13 +23,24 @@ image.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
ureq.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
core-video = "0.4"
|
||||
io-surface = "0.16"
|
||||
mach2 = "0.6"
|
||||
objc2-core-foundation = { version = "0.3.2", features = ["CFBase", "CFDictionary", "CFNumber", "CFString"] }
|
||||
objc2-io-surface = "0.3.2"
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ely_servo_host = { path = "../ely_servo_host" }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
+79
-31
@@ -11,24 +11,19 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
|
||||
let workspace_root = workspace_root(&manifest_dir)?;
|
||||
let workspace_manifest_path = workspace_root.join("Cargo.toml");
|
||||
let git_head_path = workspace_root.join(".git/HEAD");
|
||||
let workspace_manifest = read_workspace_manifest(&workspace_manifest_path)?;
|
||||
let workspace = table(&workspace_manifest, "workspace")?;
|
||||
let package = table(workspace, "package")?;
|
||||
let dependencies = table(workspace, "dependencies")?;
|
||||
|
||||
emit_cargo_directive(format!("cargo:rerun-if-changed={}", workspace_manifest_path.display()))?;
|
||||
emit_cargo_directive(format!("cargo:rerun-if-changed={}", git_head_path.display()))?;
|
||||
if let Some(ref_path) = git_head_ref(&git_head_path)? {
|
||||
emit_cargo_directive(format!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
workspace_root.join(".git").join(ref_path).display()
|
||||
))?;
|
||||
}
|
||||
emit_cargo_directive("cargo:rerun-if-env-changed=ELY_BUILD_REVISION")?;
|
||||
|
||||
emit_env("ELY_BUILD_REVISION", &git_revision(workspace_root)?)?;
|
||||
emit_env("ELY_BUILD_REVISION", &build_revision(workspace_root)?)?;
|
||||
emit_env("ELY_WORKSPACE_LICENSE", string_value(package, "license")?)?;
|
||||
emit_env("ELY_WORKSPACE_MANIFEST", path_value(&workspace_manifest_path)?)?;
|
||||
if env::var("PROFILE").as_deref() == Ok("debug") {
|
||||
emit_env("ELY_WORKSPACE_MANIFEST", path_value(&workspace_manifest_path)?)?;
|
||||
}
|
||||
emit_env("ELY_GPUI_VERSION", dependency_version(dependencies, "gpui")?)?;
|
||||
emit_env("ELY_GPUI_COMPONENT_VERSION", dependency_version(dependencies, "gpui-component")?)?;
|
||||
emit_env("ELY_SERVO_VERSION", dependency_version(dependencies, "servo")?)?;
|
||||
@@ -90,39 +85,92 @@ fn dependency_version<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
fn git_revision(workspace_root: &Path) -> Result<String, Box<dyn Error>> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--short=12", "HEAD"])
|
||||
.current_dir(workspace_root)
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
io::Error::new(io::ErrorKind::InvalidData, "git revision is unavailable").into()
|
||||
);
|
||||
fn build_revision(workspace_root: &Path) -> Result<String, Box<dyn Error>> {
|
||||
match env::var("ELY_BUILD_REVISION") {
|
||||
Ok(revision) => return validated_revision(revision),
|
||||
Err(env::VarError::NotUnicode(_)) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"ELY_BUILD_REVISION is not UTF-8",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Err(env::VarError::NotPresent) => {}
|
||||
}
|
||||
let revision = String::from_utf8(output.stdout)?.trim().to_string();
|
||||
if revision.is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "git revision is empty").into());
|
||||
|
||||
match fs::symlink_metadata(workspace_root.join(".git")) {
|
||||
Ok(_) => git_revision(workspace_root),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
validated_revision(format!("source-{}", env::var("CARGO_PKG_VERSION")?))
|
||||
}
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
fn git_head_ref(git_head_path: &Path) -> Result<Option<String>, Box<dyn Error>> {
|
||||
let head = fs::read_to_string(git_head_path)?;
|
||||
let Some(ref_path) = head.trim().strip_prefix("ref: ") else {
|
||||
fn git_revision(workspace_root: &Path) -> Result<String, Box<dyn Error>> {
|
||||
emit_git_watch(workspace_root, "HEAD")?;
|
||||
emit_git_watch(workspace_root, "packed-refs")?;
|
||||
if let Some(reference) = git_optional_output(workspace_root, &["symbolic-ref", "-q", "HEAD"])? {
|
||||
emit_git_watch(workspace_root, &reference)?;
|
||||
}
|
||||
let revision = git_output(workspace_root, &["rev-parse", "--short=12", "HEAD"])?;
|
||||
validated_revision(revision)
|
||||
}
|
||||
|
||||
fn emit_git_watch(workspace_root: &Path, git_path: &str) -> Result<(), Box<dyn Error>> {
|
||||
let path = PathBuf::from(git_output(workspace_root, &["rev-parse", "--git-path", git_path])?);
|
||||
let path = if path.is_absolute() { path } else { workspace_root.join(path) };
|
||||
emit_cargo_directive(format!("cargo:rerun-if-changed={}", path.display()))
|
||||
}
|
||||
|
||||
fn git_output(workspace_root: &Path, arguments: &[&str]) -> Result<String, Box<dyn Error>> {
|
||||
let output = Command::new("git").args(arguments).current_dir(workspace_root).output()?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("git {} failed", arguments.join(" ")),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
String::from_utf8(output.stdout).map(|value| value.trim().to_string()).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn git_optional_output(
|
||||
workspace_root: &Path,
|
||||
arguments: &[&str],
|
||||
) -> Result<Option<String>, Box<dyn Error>> {
|
||||
let output = Command::new("git").args(arguments).current_dir(workspace_root).output()?;
|
||||
if output.status.success() {
|
||||
return String::from_utf8(output.stdout)
|
||||
.map(|value| Some(value.trim().to_string()))
|
||||
.map_err(Into::into);
|
||||
}
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(ref_path.to_string()))
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::InvalidData, format!("git {} failed", arguments.join(" ")))
|
||||
.into())
|
||||
}
|
||||
|
||||
fn validated_revision(revision: String) -> Result<String, Box<dyn Error>> {
|
||||
let revision = revision.trim();
|
||||
validate_env_value("ELY_BUILD_REVISION", revision)?;
|
||||
Ok(revision.to_string())
|
||||
}
|
||||
|
||||
fn emit_env(key: &str, value: &str) -> Result<(), Box<dyn Error>> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("{key} is empty")).into());
|
||||
}
|
||||
validate_env_value(key, value)?;
|
||||
emit_cargo_directive(format!("cargo:rustc-env={key}={value}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_env_value(key: &str, value: &str) -> Result<(), Box<dyn Error>> {
|
||||
if value.trim().is_empty() || value.contains('\r') || value.contains('\n') {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("invalid {key}")).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_cargo_directive(directive: impl AsRef<str>) -> Result<(), Box<dyn Error>> {
|
||||
writeln!(io::stdout(), "{}", directive.as_ref())?;
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
ffi::CString,
|
||||
mem,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use mach2::{
|
||||
bootstrap::{bootstrap_port, bootstrap_register},
|
||||
kern_return::KERN_SUCCESS,
|
||||
mach_port::{
|
||||
mach_port_allocate, mach_port_deallocate, mach_port_destroy, mach_port_insert_right,
|
||||
},
|
||||
message::{
|
||||
MACH_MSG_PORT_DESCRIPTOR, MACH_MSG_SUCCESS, MACH_MSG_TIMEOUT_NONE, MACH_MSG_TYPE_MAKE_SEND,
|
||||
MACH_MSG_TYPE_MOVE_SEND, MACH_MSGH_BITS_COMPLEX, MACH_RCV_MSG, MACH_RCV_TIMED_OUT,
|
||||
MACH_RCV_TIMEOUT, mach_msg, mach_msg_body_t, mach_msg_header_t, mach_msg_port_descriptor_t,
|
||||
mach_msg_trailer_t,
|
||||
},
|
||||
port::{MACH_PORT_NULL, MACH_PORT_RIGHT_RECEIVE, mach_port_t},
|
||||
traps::mach_task_self,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
|
||||
const SERVICE_PREFIX: &str = "com.ely.browser.iosurface";
|
||||
const MAX_PENDING_SURFACE_PORTS: usize = 16;
|
||||
|
||||
pub(crate) struct IOSurfaceMachReceiver {
|
||||
service_name: String,
|
||||
receive_port: mach_port_t,
|
||||
pending_ports: BTreeMap<u64, mach_port_t>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum IOSurfaceMachError {
|
||||
#[error("Mach service name contains an interior nul byte")]
|
||||
InvalidServiceName,
|
||||
#[error("mach_port_allocate returned {code}")]
|
||||
AllocatePort { code: i32 },
|
||||
#[error("mach_port_insert_right returned {code}")]
|
||||
InsertSendRight { code: i32 },
|
||||
#[error("bootstrap_register returned {code}")]
|
||||
RegisterService { code: i32 },
|
||||
#[error("mach_msg receive timed out for IOSurface surface {surface_id:#x}")]
|
||||
ReceiveTimedOut { surface_id: u64 },
|
||||
#[error("mach_msg receive returned {code}")]
|
||||
Receive { code: i32 },
|
||||
#[error("received unexpected Mach message id {message_id}")]
|
||||
UnexpectedMessage { message_id: i32 },
|
||||
#[error("received invalid IOSurface Mach message")]
|
||||
InvalidMessage,
|
||||
}
|
||||
|
||||
impl IOSurfaceMachReceiver {
|
||||
pub(crate) fn new() -> Result<Self, IOSurfaceMachError> {
|
||||
let service_name = unique_service_name();
|
||||
let service_name_c = CString::new(service_name.as_str())
|
||||
.map_err(|_| IOSurfaceMachError::InvalidServiceName)?;
|
||||
let mut receive_port = MACH_PORT_NULL;
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
let task = unsafe { mach_task_self() };
|
||||
#[expect(unsafe_code)]
|
||||
let allocate =
|
||||
unsafe { mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &mut receive_port) };
|
||||
if allocate != KERN_SUCCESS {
|
||||
return Err(IOSurfaceMachError::AllocatePort { code: allocate });
|
||||
}
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
let insert = unsafe {
|
||||
mach_port_insert_right(task, receive_port, receive_port, MACH_MSG_TYPE_MAKE_SEND)
|
||||
};
|
||||
if insert != KERN_SUCCESS {
|
||||
destroy_port(receive_port);
|
||||
return Err(IOSurfaceMachError::InsertSendRight { code: insert });
|
||||
}
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
#[allow(deprecated)]
|
||||
let register = unsafe {
|
||||
bootstrap_register(bootstrap_port, service_name_c.as_ptr() as *mut _, receive_port)
|
||||
};
|
||||
if register != KERN_SUCCESS {
|
||||
destroy_port(receive_port);
|
||||
return Err(IOSurfaceMachError::RegisterService { code: register });
|
||||
}
|
||||
|
||||
Ok(Self { service_name, receive_port, pending_ports: BTreeMap::new() })
|
||||
}
|
||||
|
||||
pub(crate) fn service_name(&self) -> &str {
|
||||
self.service_name.as_str()
|
||||
}
|
||||
|
||||
pub(crate) fn receive_port_for_surface(
|
||||
&mut self,
|
||||
surface_id: u64,
|
||||
timeout: Duration,
|
||||
) -> Result<mach_port_t, IOSurfaceMachError> {
|
||||
if let Some(port) = self.pending_ports.remove(&surface_id) {
|
||||
return Ok(port);
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id });
|
||||
}
|
||||
let Some(received) = self.receive_one(remaining)? else {
|
||||
return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id });
|
||||
};
|
||||
if received.surface_id == surface_id {
|
||||
return Ok(received.mach_port);
|
||||
}
|
||||
if let Some(discarded) = insert_pending_port(
|
||||
&mut self.pending_ports,
|
||||
received.surface_id,
|
||||
received.mach_port,
|
||||
) {
|
||||
deallocate_port(discarded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn receive_one(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<ReceivedSurfacePort>, IOSurfaceMachError> {
|
||||
#[expect(unsafe_code)]
|
||||
let mut received_message: ReceivedIOSurfacePortMessage = unsafe { mem::zeroed() };
|
||||
#[expect(unsafe_code)]
|
||||
let result = unsafe {
|
||||
mach_msg(
|
||||
&mut received_message.message.header,
|
||||
MACH_RCV_MSG | MACH_RCV_TIMEOUT,
|
||||
0,
|
||||
mem::size_of::<ReceivedIOSurfacePortMessage>() as u32,
|
||||
self.receive_port,
|
||||
timeout_millis(timeout),
|
||||
MACH_PORT_NULL,
|
||||
)
|
||||
};
|
||||
|
||||
if result == MACH_RCV_TIMED_OUT {
|
||||
return Ok(None);
|
||||
}
|
||||
if result != MACH_MSG_SUCCESS {
|
||||
return Err(IOSurfaceMachError::Receive { code: result });
|
||||
}
|
||||
let message = &mut received_message.message;
|
||||
if message.header.msgh_id != IOSURFACE_PORT_MESSAGE_ID {
|
||||
let message_id = message.header.msgh_id;
|
||||
destroy_message(message);
|
||||
return Err(IOSurfaceMachError::UnexpectedMessage { message_id });
|
||||
}
|
||||
if !is_valid_surface_port_message(message) {
|
||||
destroy_message(message);
|
||||
return Err(IOSurfaceMachError::InvalidMessage);
|
||||
}
|
||||
|
||||
Ok(Some(ReceivedSurfacePort {
|
||||
surface_id: message.surface_id,
|
||||
mach_port: message.surface_port.name,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_pending_port(
|
||||
pending_ports: &mut BTreeMap<u64, mach_port_t>,
|
||||
surface_id: u64,
|
||||
mach_port: mach_port_t,
|
||||
) -> Option<mach_port_t> {
|
||||
if let Some(replaced) = pending_ports.insert(surface_id, mach_port) {
|
||||
return Some(replaced);
|
||||
}
|
||||
if pending_ports.len() > MAX_PENDING_SURFACE_PORTS
|
||||
&& let Some(evicted_id) = pending_ports.keys().next().copied()
|
||||
{
|
||||
return pending_ports.remove(&evicted_id);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl Drop for IOSurfaceMachReceiver {
|
||||
fn drop(&mut self) {
|
||||
for port in std::mem::take(&mut self.pending_ports).into_values() {
|
||||
deallocate_port(port);
|
||||
}
|
||||
destroy_port(self.receive_port);
|
||||
}
|
||||
}
|
||||
|
||||
struct ReceivedSurfacePort {
|
||||
surface_id: u64,
|
||||
mach_port: mach_port_t,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct IOSurfacePortMessage {
|
||||
header: mach_msg_header_t,
|
||||
body: mach_msg_body_t,
|
||||
surface_port: mach_msg_port_descriptor_t,
|
||||
surface_id: u64,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct ReceivedIOSurfacePortMessage {
|
||||
message: IOSurfacePortMessage,
|
||||
_trailer: mach_msg_trailer_t,
|
||||
}
|
||||
|
||||
fn is_valid_surface_port_message(message: &IOSurfacePortMessage) -> bool {
|
||||
message.header.msgh_bits & MACH_MSGH_BITS_COMPLEX != 0
|
||||
&& message.header.msgh_size == mem::size_of::<IOSurfacePortMessage>() as u32
|
||||
&& message.body.msgh_descriptor_count == 1
|
||||
&& message.surface_port.type_ == MACH_MSG_PORT_DESCRIPTOR as u8
|
||||
&& message.surface_port.disposition == MACH_MSG_TYPE_MOVE_SEND as u8
|
||||
&& message.surface_port.name != MACH_PORT_NULL
|
||||
}
|
||||
|
||||
fn unique_service_name() -> String {
|
||||
format!("{SERVICE_PREFIX}.{}", Uuid::now_v7().as_simple())
|
||||
}
|
||||
|
||||
fn timeout_millis(timeout: Duration) -> u32 {
|
||||
u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(MACH_MSG_TIMEOUT_NONE + 1)
|
||||
}
|
||||
|
||||
fn destroy_message(message: &mut IOSurfacePortMessage) {
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
mach2::message::mach_msg_destroy(&mut message.header);
|
||||
}
|
||||
}
|
||||
|
||||
fn destroy_port(port: mach_port_t) {
|
||||
#[expect(unsafe_code)]
|
||||
let task = unsafe { mach_task_self() };
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
let _ = mach_port_destroy(task, port);
|
||||
}
|
||||
}
|
||||
|
||||
fn deallocate_port(port: mach_port_t) {
|
||||
#[expect(unsafe_code)]
|
||||
let task = unsafe { mach_task_self() };
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
let _ = mach_port_deallocate(task, port);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn surface_port_message() -> IOSurfacePortMessage {
|
||||
IOSurfacePortMessage {
|
||||
header: mach_msg_header_t {
|
||||
msgh_bits: MACH_MSGH_BITS_COMPLEX,
|
||||
msgh_size: mem::size_of::<IOSurfacePortMessage>() as u32,
|
||||
msgh_id: IOSURFACE_PORT_MESSAGE_ID,
|
||||
..mach_msg_header_t::default()
|
||||
},
|
||||
body: mach_msg_body_t { msgh_descriptor_count: 1 },
|
||||
surface_port: mach_msg_port_descriptor_t::new(42, MACH_MSG_TYPE_MOVE_SEND),
|
||||
surface_id: 7,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_port_message_requires_kernel_translated_complex_descriptor() {
|
||||
let mut message = surface_port_message();
|
||||
assert!(is_valid_surface_port_message(&message));
|
||||
|
||||
message.header.msgh_bits &= !MACH_MSGH_BITS_COMPLEX;
|
||||
assert!(!is_valid_surface_port_message(&message));
|
||||
|
||||
message = surface_port_message();
|
||||
message.header.msgh_size -= 1;
|
||||
assert!(!is_valid_surface_port_message(&message));
|
||||
|
||||
message = surface_port_message();
|
||||
message.surface_port.disposition = mach2::message::MACH_MSG_TYPE_COPY_SEND as u8;
|
||||
assert!(!is_valid_surface_port_message(&message));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_rejects_simple_message_with_forged_port_descriptor() -> Result<(), IOSurfaceMachError>
|
||||
{
|
||||
let receiver = IOSurfaceMachReceiver::new()?;
|
||||
let mut message = surface_port_message();
|
||||
message.header.msgh_bits =
|
||||
mach2::message::MACH_MSGH_BITS(mach2::message::MACH_MSG_TYPE_COPY_SEND, 0);
|
||||
message.header.msgh_remote_port = receiver.receive_port;
|
||||
message.surface_port.name = receiver.receive_port;
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
let send = unsafe {
|
||||
mach_msg(
|
||||
&mut message.header,
|
||||
mach2::message::MACH_SEND_MSG,
|
||||
message.header.msgh_size,
|
||||
0,
|
||||
MACH_PORT_NULL,
|
||||
MACH_MSG_TIMEOUT_NONE,
|
||||
MACH_PORT_NULL,
|
||||
)
|
||||
};
|
||||
assert_eq!(send, MACH_MSG_SUCCESS);
|
||||
assert!(matches!(
|
||||
receiver.receive_one(Duration::from_secs(1)),
|
||||
Err(IOSurfaceMachError::InvalidMessage)
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_port_map_evicts_lowest_surface_at_capacity() {
|
||||
let mut ports = BTreeMap::new();
|
||||
for surface_id in 0..=MAX_PENDING_SURFACE_PORTS as u64 {
|
||||
let discarded = insert_pending_port(&mut ports, surface_id, surface_id as mach_port_t);
|
||||
if surface_id < MAX_PENDING_SURFACE_PORTS as u64 {
|
||||
assert_eq!(discarded, None);
|
||||
} else {
|
||||
assert_eq!(discarded, Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(ports.len(), MAX_PENDING_SURFACE_PORTS);
|
||||
assert!(!ports.contains_key(&0));
|
||||
assert!(ports.contains_key(&(MAX_PENDING_SURFACE_PORTS as u64)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, VecDeque},
|
||||
sync::{Arc, Weak},
|
||||
};
|
||||
|
||||
use core_foundation::base::TCFType as _;
|
||||
use core_video::pixel_buffer::CVPixelBuffer;
|
||||
#[allow(deprecated)]
|
||||
use io_surface::IOSurface;
|
||||
use mach2::{mach_port::mach_port_deallocate, traps::mach_task_self};
|
||||
use objc2_core_foundation::CFRetained;
|
||||
use objc2_io_surface::IOSurfaceRef;
|
||||
use thiserror::Error;
|
||||
|
||||
pub(crate) struct IOSurfaceCache {
|
||||
surfaces: BTreeMap<u64, CachedSurface>,
|
||||
insertion_order: VecDeque<u64>,
|
||||
}
|
||||
|
||||
const MAX_CACHED_SURFACES: usize = 16;
|
||||
|
||||
struct CachedSurface {
|
||||
iosurface: CFRetained<IOSurfaceRef>,
|
||||
pending_backing: Option<Arc<HardwareSurfaceBacking>>,
|
||||
active_backing: Weak<HardwareSurfaceBacking>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum SurfaceImportError {
|
||||
#[error("IOSurfaceLookupFromMachPort returned null for port 0x{port:x}")]
|
||||
LookupFailed { port: u32 },
|
||||
#[error("CVPixelBufferCreateWithIOSurface returned status {status}")]
|
||||
PixelBufferBuildFailed { status: i32 },
|
||||
}
|
||||
|
||||
pub(crate) struct ImportedPixelBuffer {
|
||||
pub(crate) system_surface_id: u64,
|
||||
pub(crate) iosurface: CFRetained<IOSurfaceRef>,
|
||||
pub(crate) backing: Arc<HardwareSurfaceBacking>,
|
||||
}
|
||||
|
||||
pub(crate) struct HardwareSurfaceBacking {
|
||||
pixel_buffer: CVPixelBuffer,
|
||||
}
|
||||
|
||||
// SAFETY: `CVPixelBuffer` is a retained, read-only CoreVideo frame here.
|
||||
// CoreFoundation retain/release is thread-safe, and both the runtime and GPU
|
||||
// paths only clone the handle or submit the immutable IOSurface-backed image.
|
||||
#[expect(unsafe_code)]
|
||||
unsafe impl Send for HardwareSurfaceBacking {}
|
||||
// SAFETY: The backing exposes immutable pixel-buffer metadata and clones.
|
||||
// IOSurface lifetime tracking remains owned by CoreVideo's retained buffer.
|
||||
#[expect(unsafe_code)]
|
||||
unsafe impl Sync for HardwareSurfaceBacking {}
|
||||
|
||||
impl HardwareSurfaceBacking {
|
||||
pub(crate) fn new(pixel_buffer: CVPixelBuffer) -> Arc<Self> {
|
||||
Arc::new(Self { pixel_buffer })
|
||||
}
|
||||
|
||||
pub(crate) fn pixel_buffer(&self) -> &CVPixelBuffer {
|
||||
&self.pixel_buffer
|
||||
}
|
||||
}
|
||||
|
||||
impl IOSurfaceCache {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self { surfaces: BTreeMap::new(), insertion_order: VecDeque::new() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn import(&mut self, mach_port_name: u32, surface_id: u64) -> Result<(), SurfaceImportError> {
|
||||
let imported = import_pixel_buffer_from_mach_port(mach_port_name)?;
|
||||
self.insert_imported(surface_id, imported);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn insert_imported(&mut self, surface_id: u64, imported: ImportedPixelBuffer) {
|
||||
let width = imported.backing.pixel_buffer().get_width() as u32;
|
||||
let height = imported.backing.pixel_buffer().get_height() as u32;
|
||||
if self
|
||||
.surfaces
|
||||
.get(&surface_id)
|
||||
.is_some_and(|cached| cached.width == width && cached.height == height)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.surfaces.contains_key(&surface_id) {
|
||||
while self.surfaces.len() >= MAX_CACHED_SURFACES {
|
||||
if !self.evict_preferred_surface() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.insertion_order.push_back(surface_id);
|
||||
}
|
||||
self.surfaces.insert(
|
||||
surface_id,
|
||||
CachedSurface {
|
||||
iosurface: imported.iosurface,
|
||||
pending_backing: Some(imported.backing),
|
||||
active_backing: Weak::new(),
|
||||
width,
|
||||
height,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn hardware_surface_for(
|
||||
&mut self,
|
||||
surface_id: u64,
|
||||
) -> Result<Option<Arc<HardwareSurfaceBacking>>, SurfaceImportError> {
|
||||
let Some(cached) = self.surfaces.get_mut(&surface_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let backing = cached
|
||||
.active_backing
|
||||
.upgrade()
|
||||
.or_else(|| cached.pending_backing.take())
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| build_hardware_surface_backing(&cached.iosurface))?;
|
||||
cached.active_backing = Arc::downgrade(&backing);
|
||||
self.trim_to_capacity();
|
||||
Ok(Some(backing))
|
||||
}
|
||||
|
||||
pub(crate) fn surface_ids(&self) -> Vec<u64> {
|
||||
self.surfaces.keys().copied().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn cached_surface_count(&self) -> usize {
|
||||
self.surfaces.len()
|
||||
}
|
||||
|
||||
fn trim_to_capacity(&mut self) {
|
||||
while self.surfaces.len() > MAX_CACHED_SURFACES {
|
||||
if !self.evict_preferred_surface() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn evict_preferred_surface(&mut self) -> bool {
|
||||
let inactive_index = self.insertion_order.iter().position(|surface_id| {
|
||||
self.surfaces
|
||||
.get(surface_id)
|
||||
.is_none_or(|cached| cached.active_backing.upgrade().is_none())
|
||||
});
|
||||
let surface_id = match inactive_index {
|
||||
Some(index) => self.insertion_order.remove(index),
|
||||
None => self.insertion_order.pop_front(),
|
||||
};
|
||||
let Some(surface_id) = surface_id else {
|
||||
return false;
|
||||
};
|
||||
self.surfaces.remove(&surface_id);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn import_pixel_buffer_from_mach_port(
|
||||
mach_port_name: u32,
|
||||
) -> Result<ImportedPixelBuffer, SurfaceImportError> {
|
||||
let result = build_pixel_buffer_from_mach_port(mach_port_name);
|
||||
deallocate_mach_port(mach_port_name);
|
||||
result
|
||||
}
|
||||
|
||||
fn build_pixel_buffer_from_mach_port(
|
||||
mach_port_name: u32,
|
||||
) -> Result<ImportedPixelBuffer, SurfaceImportError> {
|
||||
let Some(iosurface) = objc2_io_surface::IOSurfaceRef::lookup_from_mach_port(mach_port_name)
|
||||
else {
|
||||
return Err(SurfaceImportError::LookupFailed { port: mach_port_name });
|
||||
};
|
||||
let backing = build_hardware_surface_backing(&iosurface)?;
|
||||
Ok(ImportedPixelBuffer { system_surface_id: u64::from(iosurface.id()), iosurface, backing })
|
||||
}
|
||||
|
||||
fn build_hardware_surface_backing(
|
||||
iosurface: &CFRetained<IOSurfaceRef>,
|
||||
) -> Result<Arc<HardwareSurfaceBacking>, SurfaceImportError> {
|
||||
let raw_ptr: *const std::ffi::c_void =
|
||||
(&**iosurface) as *const objc2_io_surface::IOSurfaceRef as *const std::ffi::c_void;
|
||||
#[allow(deprecated)]
|
||||
let io_surface_view: IOSurface = {
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
IOSurface::wrap_under_get_rule(raw_ptr as io_surface::IOSurfaceRef)
|
||||
}
|
||||
};
|
||||
|
||||
let pixel_buffer = CVPixelBuffer::from_io_surface(&io_surface_view, None)
|
||||
.map_err(|status| SurfaceImportError::PixelBufferBuildFailed { status })?;
|
||||
Ok(HardwareSurfaceBacking::new(pixel_buffer))
|
||||
}
|
||||
|
||||
fn deallocate_mach_port(port: u32) {
|
||||
#[expect(unsafe_code)]
|
||||
let task = unsafe { mach_task_self() };
|
||||
#[expect(unsafe_code)]
|
||||
let _ = unsafe { mach_port_deallocate(task, port) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::os::raw::c_void;
|
||||
use std::sync::Arc;
|
||||
|
||||
use objc2_core_foundation::{
|
||||
CFDictionary, CFIndex, CFNumber, CFString, kCFAllocatorDefault,
|
||||
kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks,
|
||||
};
|
||||
use objc2_io_surface::{
|
||||
IOSurfaceRef, kIOSurfaceBytesPerElement, kIOSurfaceBytesPerRow, kIOSurfaceHeight,
|
||||
kIOSurfacePixelFormat, kIOSurfaceWidth,
|
||||
};
|
||||
|
||||
use super::IOSurfaceCache;
|
||||
|
||||
fn build_local_iosurface(
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<objc2_core_foundation::CFRetained<IOSurfaceRef>, String> {
|
||||
let pixel_format = i32::from_be_bytes(*b"BGRA");
|
||||
let bytes_per_element = 4_i32;
|
||||
let bytes_per_row = (width as i32) * bytes_per_element;
|
||||
let width_num = CFNumber::new_i32(width as i32);
|
||||
let height_num = CFNumber::new_i32(height as i32);
|
||||
let bpe_num = CFNumber::new_i32(bytes_per_element);
|
||||
let bpr_num = CFNumber::new_i32(bytes_per_row);
|
||||
let pf_num = CFNumber::new_i32(pixel_format);
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
let keys: [&CFString; 5] = [
|
||||
kIOSurfaceWidth,
|
||||
kIOSurfaceHeight,
|
||||
kIOSurfaceBytesPerElement,
|
||||
kIOSurfaceBytesPerRow,
|
||||
kIOSurfacePixelFormat,
|
||||
];
|
||||
let values: [&CFNumber; 5] = [&width_num, &height_num, &bpe_num, &bpr_num, &pf_num];
|
||||
let properties = CFDictionary::new(
|
||||
kCFAllocatorDefault,
|
||||
keys.as_ptr() as *mut *const c_void,
|
||||
values.as_ptr() as *mut *const c_void,
|
||||
keys.len() as CFIndex,
|
||||
&kCFTypeDictionaryKeyCallBacks,
|
||||
&kCFTypeDictionaryValueCallBacks,
|
||||
)
|
||||
.ok_or_else(|| "CFDictionaryCreate returned null".to_string())?;
|
||||
IOSurfaceRef::new(&properties)
|
||||
.ok_or_else(|| "IOSurfaceCreate returned null".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_iosurface_into_pixel_buffer_cache() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let iosurface = build_local_iosurface(64, 48)?;
|
||||
let surface_id = u64::from(iosurface.id());
|
||||
|
||||
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
|
||||
.map_err(|error| error.to_string())?;
|
||||
assert_eq!(imported.system_surface_id, surface_id);
|
||||
cache.insert_imported(surface_id, imported);
|
||||
|
||||
let surface = cache
|
||||
.hardware_surface_for(surface_id)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "imported pixel buffer was missing".to_string())?;
|
||||
assert_eq!(surface.pixel_buffer().get_width(), 64);
|
||||
assert_eq!(surface.pixel_buffer().get_height(), 48);
|
||||
assert_eq!(cache.cached_surface_count(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_surface_id_reuses_cache_entry() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let iosurface = build_local_iosurface(64, 48)?;
|
||||
let surface_id = u64::from(iosurface.id());
|
||||
|
||||
cache
|
||||
.import(iosurface.create_mach_port(), surface_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
cache
|
||||
.import(iosurface.create_mach_port(), surface_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
assert_eq!(cache.cached_surface_count(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_surface_backing_reuses_and_releases_use_count() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let iosurface = build_local_iosurface(64, 48)?;
|
||||
let surface_id = u64::from(iosurface.id());
|
||||
let baseline_use_count = iosurface.use_count();
|
||||
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
|
||||
.map_err(|error| error.to_string())?;
|
||||
assert_eq!(iosurface.use_count(), baseline_use_count + 1);
|
||||
cache.insert_imported(surface_id, imported);
|
||||
|
||||
let first = cache
|
||||
.hardware_surface_for(surface_id)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "first hardware surface was missing".to_string())?;
|
||||
let second = cache
|
||||
.hardware_surface_for(surface_id)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "second hardware surface was missing".to_string())?;
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
assert_eq!(iosurface.use_count(), baseline_use_count + 1);
|
||||
|
||||
drop(first);
|
||||
drop(second);
|
||||
assert_eq!(iosurface.use_count(), baseline_use_count);
|
||||
|
||||
let rebuilt = cache
|
||||
.hardware_surface_for(surface_id)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "rebuilt hardware surface was missing".to_string())?;
|
||||
assert_eq!(iosurface.use_count(), baseline_use_count + 1);
|
||||
drop(rebuilt);
|
||||
assert_eq!(iosurface.use_count(), baseline_use_count);
|
||||
drop(cache);
|
||||
assert_eq!(iosurface.use_count(), baseline_use_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_evicts_oldest_surface_at_capacity() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let mut surface_ids = Vec::new();
|
||||
for _ in 0..=super::MAX_CACHED_SURFACES {
|
||||
let iosurface = build_local_iosurface(8, 8)?;
|
||||
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
|
||||
.map_err(|error| error.to_string())?;
|
||||
surface_ids.push(imported.system_surface_id);
|
||||
cache.insert_imported(imported.system_surface_id, imported);
|
||||
}
|
||||
|
||||
assert_eq!(cache.cached_surface_count(), super::MAX_CACHED_SURFACES);
|
||||
assert!(
|
||||
cache
|
||||
.hardware_surface_for(surface_ids[0])
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
cache
|
||||
.hardware_surface_for(surface_ids[super::MAX_CACHED_SURFACES])
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_some()
|
||||
);
|
||||
assert!(!cache.surface_ids().contains(&surface_ids[0]));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_stays_bounded_while_gpu_backings_are_active() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let mut surface_ids = Vec::new();
|
||||
let mut native_surfaces = Vec::new();
|
||||
let mut active_backings = Vec::new();
|
||||
for _ in 0..super::MAX_CACHED_SURFACES {
|
||||
let iosurface = build_local_iosurface(8, 8)?;
|
||||
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
|
||||
.map_err(|error| error.to_string())?;
|
||||
surface_ids.push(imported.system_surface_id);
|
||||
native_surfaces.push(iosurface);
|
||||
cache.insert_imported(imported.system_surface_id, imported);
|
||||
active_backings.push(
|
||||
cache
|
||||
.hardware_surface_for(*surface_ids.last().ok_or("surface id was missing")?)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or("active surface backing was missing")?,
|
||||
);
|
||||
}
|
||||
let overflow = build_local_iosurface(8, 8)?;
|
||||
let overflow = super::import_pixel_buffer_from_mach_port(overflow.create_mach_port())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let overflow_id = overflow.system_surface_id;
|
||||
cache.insert_imported(overflow_id, overflow);
|
||||
|
||||
assert_eq!(cache.cached_surface_count(), super::MAX_CACHED_SURFACES);
|
||||
assert!(!cache.surface_ids().contains(&surface_ids[0]));
|
||||
assert_eq!(active_backings[0].pixel_buffer().get_width(), 8);
|
||||
assert!(
|
||||
cache
|
||||
.hardware_surface_for(surface_ids[0])
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
cache.hardware_surface_for(overflow_id).map_err(|error| error.to_string())?.is_some()
|
||||
);
|
||||
|
||||
cache
|
||||
.import(native_surfaces[0].create_mach_port(), surface_ids[0])
|
||||
.map_err(|error| error.to_string())?;
|
||||
let reimported = cache
|
||||
.hardware_surface_for(surface_ids[0])
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or("reimported surface backing was missing")?;
|
||||
|
||||
assert_eq!(cache.cached_surface_count(), super::MAX_CACHED_SURFACES);
|
||||
assert!(cache.surface_ids().contains(&surface_ids[0]));
|
||||
assert!(!Arc::ptr_eq(&active_backings[0], &reimported));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
pub mod download_checksums;
|
||||
pub mod download_files;
|
||||
pub mod http_downloads;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) mod iosurface_mach;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) mod iosurface_metal;
|
||||
pub mod plugin_package_store;
|
||||
pub mod plugin_packages;
|
||||
pub mod plugin_signatures;
|
||||
pub(crate) mod profile_identity;
|
||||
pub mod servo_live;
|
||||
pub(crate) mod servo_profile_data;
|
||||
mod servo_sidecar_command;
|
||||
|
||||
pub(crate) use servo_profile_data::ProfileDataMode;
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use ely_domain::ProfileId;
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(not(test))]
|
||||
use super::servo_profile_data::{create_profile_data_dir, default_profile_data_root};
|
||||
|
||||
#[cfg(not(test))]
|
||||
const DEFAULT_PROFILE_ID_FILE: &str = "profile-id";
|
||||
#[cfg(not(test))]
|
||||
const DEFAULT_PROFILE_DIRECTORY: &str = "default";
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn default_standard_profile_id() -> Result<ProfileId, ProfileIdentityError> {
|
||||
static TEST_PROFILE_ID: std::sync::OnceLock<ProfileId> = std::sync::OnceLock::new();
|
||||
Ok(TEST_PROFILE_ID.get_or_init(ProfileId::new).clone())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn default_standard_profile_id() -> Result<ProfileId, ProfileIdentityError> {
|
||||
let root = default_profile_data_root().ok_or(ProfileIdentityError::DataRootUnavailable)?;
|
||||
let profile_id = load_or_create_profile_id(
|
||||
&root.join(DEFAULT_PROFILE_DIRECTORY).join(DEFAULT_PROFILE_ID_FILE),
|
||||
)?;
|
||||
create_profile_data_dir(&root, &profile_id)?;
|
||||
Ok(profile_id)
|
||||
}
|
||||
|
||||
fn load_or_create_profile_id(path: &Path) -> Result<ProfileId, ProfileIdentityError> {
|
||||
match read_profile_id(path) {
|
||||
Ok(profile_id) => return Ok(profile_id),
|
||||
Err(ProfileIdentityError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| ProfileIdentityError::ParentUnavailable { path: path.to_path_buf() })?;
|
||||
create_private_directory(parent)?;
|
||||
let profile_id = ProfileId::new();
|
||||
let mut file = tempfile::NamedTempFile::new_in(parent)?;
|
||||
file.write_all(profile_id.as_str().as_bytes())?;
|
||||
file.write_all(b"\n")?;
|
||||
file.as_file().sync_all()?;
|
||||
match file.persist_noclobber(path) {
|
||||
Ok(_) => {
|
||||
sync_parent_directory(parent)?;
|
||||
Ok(profile_id)
|
||||
}
|
||||
Err(error) if error.error.kind() == io::ErrorKind::AlreadyExists => read_profile_id(path),
|
||||
Err(error) => Err(error.error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_private_directory(path: &Path) -> Result<(), io::Error> {
|
||||
fs::create_dir_all(path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sync_parent_directory(path: &Path) -> Result<(), io::Error> {
|
||||
fs::File::open(path)?.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn sync_parent_directory(_path: &Path) -> Result<(), io::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_profile_id(path: &Path) -> Result<ProfileId, ProfileIdentityError> {
|
||||
let raw = fs::read_to_string(path)?;
|
||||
ProfileId::parse(raw.trim()).map_err(ProfileIdentityError::Domain)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum ProfileIdentityError {
|
||||
#[cfg(not(test))]
|
||||
#[error("profile data root is unavailable")]
|
||||
DataRootUnavailable,
|
||||
|
||||
#[error("profile identity parent directory is unavailable for {path}")]
|
||||
ParentUnavailable { path: PathBuf },
|
||||
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Domain(#[from] ely_domain::DomainError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::load_or_create_profile_id;
|
||||
use crate::services::servo_profile_data::profile_data_dir;
|
||||
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
||||
|
||||
#[test]
|
||||
fn default_profile_identity_survives_restart() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let root = directory.path().join("profiles");
|
||||
let path = root.join("default").join("profile-id");
|
||||
|
||||
let first_start = load_or_create_profile_id(&path)?;
|
||||
let second_start = load_or_create_profile_id(&path)?;
|
||||
let mut first_config = InitialBrowserConfig::ely_defaults()?;
|
||||
first_config.profile_id = Some(first_start.clone());
|
||||
let first_core = BrowserCore::new(first_config)?;
|
||||
let mut second_config = InitialBrowserConfig::ely_defaults()?;
|
||||
second_config.profile_id = Some(second_start.clone());
|
||||
let second_core = BrowserCore::new(second_config)?;
|
||||
|
||||
assert_eq!(first_start, second_start);
|
||||
assert_eq!(std::fs::read_to_string(&path)?.trim(), first_start.as_str());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(path.parent().ok_or("missing marker parent")?)?
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
}
|
||||
assert_eq!(first_core.active_tab()?.profile_id(), second_core.active_tab()?.profile_id());
|
||||
assert_eq!(
|
||||
profile_data_dir(&root, first_core.active_tab()?.profile_id()),
|
||||
profile_data_dir(&root, second_core.active_tab()?.profile_id()),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_starts_converge_on_one_profile_identity() -> Result<(), Box<dyn std::error::Error>>
|
||||
{
|
||||
let directory = tempfile::tempdir()?;
|
||||
let path = std::sync::Arc::new(directory.path().join("default").join("profile-id"));
|
||||
let threads = (0..4)
|
||||
.map(|_| {
|
||||
let path = path.clone();
|
||||
std::thread::spawn(move || load_or_create_profile_id(&path))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut identities = Vec::new();
|
||||
for thread in threads {
|
||||
identities.push(
|
||||
thread
|
||||
.join()
|
||||
.map_err(|_| std::io::Error::other("profile identity thread panicked"))??,
|
||||
);
|
||||
}
|
||||
|
||||
assert!(identities.windows(2).all(|pair| pair[0] == pair[1]));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,498 +1,465 @@
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
use ely_domain::{
|
||||
ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, TabId, UrlText, WebViewId,
|
||||
};
|
||||
use ely_servo_host::{
|
||||
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest,
|
||||
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest,
|
||||
ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost,
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
process::{Child, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[path = "servo_live_iosurface_importer.rs"]
|
||||
mod iosurface_importer;
|
||||
#[path = "servo_live_ipc.rs"]
|
||||
mod ipc;
|
||||
#[path = "servo_live_types.rs"]
|
||||
mod types;
|
||||
#[path = "servo_live_wire.rs"]
|
||||
mod wire;
|
||||
|
||||
pub(crate) use types::{
|
||||
ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveSitePermission,
|
||||
};
|
||||
|
||||
/// Servo's `WebViewBuilder` defaults the hidpi (device → CSS) scale to
|
||||
/// 1.0 — see the contract documented on
|
||||
/// [`ely_servo_host::HidpiScaleRequest`]. Mirroring the post-build state
|
||||
/// in [`DirectWebViewSession`] makes the diff inside `apply_viewport`
|
||||
/// the source of truth for whether `set_hidpi_scale` has to run.
|
||||
const SERVO_DEFAULT_DEVICE_PIXEL_RATIO: f32 = 1.0;
|
||||
use self::{
|
||||
ipc::{IpcReply, ServoLiveIpc, validate_frame_layout},
|
||||
wire::{LIVE_PROTOCOL_VERSION, LiveRequest, LiveSurfaceHandle},
|
||||
};
|
||||
use super::servo_sidecar_command::{
|
||||
SidecarRenderingContext, default_sidecar_command, rendering_context_from_env,
|
||||
};
|
||||
|
||||
/// Servo's `WebViewBuilder` likewise defaults page zoom to 1.0 (100%).
|
||||
const SERVO_DEFAULT_PAGE_ZOOM_PERCENT: u16 = 100;
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::iosurface_metal::IOSurfaceCache;
|
||||
#[cfg(target_os = "macos")]
|
||||
use iosurface_importer::{IOSurfaceImportResult, IOSurfaceImportWorker};
|
||||
|
||||
const SIDECAR_TIMEOUTS: SidecarTimeouts = SidecarTimeouts {
|
||||
request: Duration::from_secs(10),
|
||||
shutdown: Duration::from_secs(2),
|
||||
exit: Duration::from_secs(2),
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct SidecarTimeouts {
|
||||
request: Duration,
|
||||
shutdown: Duration,
|
||||
exit: Duration,
|
||||
}
|
||||
|
||||
pub(crate) struct ServoLiveClient {
|
||||
host: SoftwareServoHost,
|
||||
sessions: BTreeMap<String, DirectWebViewSession>,
|
||||
child: Child,
|
||||
ipc: ServoLiveIpc,
|
||||
timeouts: SidecarTimeouts,
|
||||
active: bool,
|
||||
#[cfg(target_os = "macos")]
|
||||
iosurface_cache: IOSurfaceCache,
|
||||
#[cfg(target_os = "macos")]
|
||||
iosurface_importer: Option<IOSurfaceImportWorker>,
|
||||
#[cfg(target_os = "macos")]
|
||||
pending_surface_ids: BTreeSet<u64>,
|
||||
}
|
||||
|
||||
impl ServoLiveClient {
|
||||
pub fn new(profile_data_dir: PathBuf) -> Result<Self, ServoLiveError> {
|
||||
let host = SoftwareServoHost::new_with_config_dir(
|
||||
ServoSurfaceSize::new(1, 1),
|
||||
Some(profile_data_dir),
|
||||
)?;
|
||||
Ok(Self { host, sessions: BTreeMap::new() })
|
||||
let rendering_context = rendering_context_from_env();
|
||||
let command_target = default_sidecar_command()?;
|
||||
if let Some(path) = command_target.missing_binary_path() {
|
||||
return Err(ServoLiveError::SidecarBinaryUnavailable { path: path.to_path_buf() });
|
||||
}
|
||||
|
||||
let mut command = command_target.command();
|
||||
command.arg("live").arg("--profile-data-dir").arg(profile_data_dir);
|
||||
command.arg("--rendering-context").arg(rendering_context.cli_arg());
|
||||
#[cfg(target_os = "macos")]
|
||||
let iosurface_importer = if rendering_context == SidecarRenderingContext::Hardware {
|
||||
let receiver = super::iosurface_mach::IOSurfaceMachReceiver::new()?;
|
||||
command.arg("--iosurface-mach-service").arg(receiver.service_name());
|
||||
Some(
|
||||
IOSurfaceImportWorker::new(receiver)
|
||||
.map_err(ServoLiveError::IOSurfaceImportWorker)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(ServoLiveError::Command)?;
|
||||
let mut client = Self::from_spawned_child(child, SIDECAR_TIMEOUTS)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
client.iosurface_importer = iosurface_importer;
|
||||
}
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn from_spawned_child(
|
||||
mut child: Child,
|
||||
timeouts: SidecarTimeouts,
|
||||
) -> Result<Self, ServoLiveError> {
|
||||
let Some(stdin) = child.stdin.take() else {
|
||||
terminate_child(&mut child, timeouts.exit);
|
||||
return Err(ServoLiveError::PipeUnavailable { name: "stdin" });
|
||||
};
|
||||
let Some(stdout) = child.stdout.take() else {
|
||||
drop(stdin);
|
||||
terminate_child(&mut child, timeouts.exit);
|
||||
return Err(ServoLiveError::PipeUnavailable { name: "stdout" });
|
||||
};
|
||||
let mut client = Self {
|
||||
child,
|
||||
ipc: ServoLiveIpc::spawn(stdin, stdout),
|
||||
timeouts,
|
||||
active: true,
|
||||
#[cfg(target_os = "macos")]
|
||||
iosurface_cache: IOSurfaceCache::new(),
|
||||
#[cfg(target_os = "macos")]
|
||||
iosurface_importer: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
pending_surface_ids: BTreeSet::new(),
|
||||
};
|
||||
if let Err(error) = client.handshake() {
|
||||
client.terminate();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
pub fn ensure(
|
||||
&mut self,
|
||||
request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
||||
let tab_id = TabId::parse(request.tab_id.clone())?;
|
||||
let profile_id = ProfileId::parse(request.profile_id.clone())?;
|
||||
let requested_url = UrlText::parse(request.url.clone())?;
|
||||
let webview_id = self.ensure_webview(&request, &tab_id, &profile_id)?;
|
||||
|
||||
self.apply_viewport(&request, &webview_id)?;
|
||||
self.apply_permissions(&request, &webview_id, &profile_id)?;
|
||||
self.apply_navigation(&request, &webview_id, tab_id, requested_url)?;
|
||||
self.apply_input(&request, &webview_id)?;
|
||||
// Match Servo's `examples/winit_minimal.rs`: spin the event loop on
|
||||
// the embedder-side hot path, never paint. Servo's public
|
||||
// rendering contract says `notify_new_frame_ready` is the signal
|
||||
// for `WebView::paint`; URL/title/load-status callbacks are
|
||||
// metadata updates and travel through snapshots. Painting here
|
||||
// would present before Servo has composited the navigated page,
|
||||
// which produced per-redirect white flashes.
|
||||
self.host.tick();
|
||||
if self.session_uses_native_surface(&request.tab_id) {
|
||||
return self.presented_frame_from_session(&request.tab_id, &webview_id).map(Some);
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
#[cfg(target_os = "macos")]
|
||||
self.drain_iosurface_imports()?;
|
||||
validate_frame_layout(request.width, request.height)?;
|
||||
let ready_surface_ids = self.ready_surface_ids();
|
||||
let pending_surface_ids = self.pending_surface_ids();
|
||||
self.request(LiveRequest::Ensure {
|
||||
tab_id: request.tab_id,
|
||||
profile_id: request.profile_id,
|
||||
url: request.url,
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
page_zoom_percent: request.page_zoom_percent,
|
||||
device_pixel_ratio: request.device_pixel_ratio,
|
||||
scroll_delta_x: request.scroll_delta_x,
|
||||
scroll_delta_y: request.scroll_delta_y,
|
||||
scroll_point_x: request.scroll_point_x,
|
||||
scroll_point_y: request.scroll_point_y,
|
||||
click_x: request.click_x,
|
||||
click_y: request.click_y,
|
||||
hover_x: request.hover_x,
|
||||
hover_y: request.hover_y,
|
||||
typed_text: request.typed_text,
|
||||
site_permissions: request.site_permissions,
|
||||
ready_surface_ids,
|
||||
pending_surface_ids,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn poll(&mut self, tab_id: String) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
||||
let Some(session) = self.sessions.get(&tab_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let webview_id = session.webview_id.clone();
|
||||
let uses_native_surface = session.native_surface_id.is_some();
|
||||
|
||||
self.host.tick();
|
||||
let snapshot = self.host.snapshot(&webview_id)?;
|
||||
if !snapshot.has_pending_frame() && !snapshot.has_pending_metadata() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if uses_native_surface {
|
||||
if snapshot.has_pending_frame() {
|
||||
self.host.paint_without_readback_with_completion(&webview_id, false)?;
|
||||
}
|
||||
return self.presented_frame_from_session(&tab_id, &webview_id).map(Some);
|
||||
}
|
||||
|
||||
if snapshot.has_pending_frame() {
|
||||
self.host.paint(&webview_id)?;
|
||||
}
|
||||
self.rendered_frame_from_session(&tab_id, &webview_id)
|
||||
#[cfg(target_os = "macos")]
|
||||
self.drain_iosurface_imports()?;
|
||||
self.request(LiveRequest::Poll {
|
||||
tab_id,
|
||||
ready_surface_ids: self.ready_surface_ids(),
|
||||
pending_surface_ids: self.pending_surface_ids(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn close(&mut self, tab_id: String) -> Result<(), ServoLiveError> {
|
||||
let Some(session) = self.sessions.remove(&tab_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
self.host.close_webview(&session.webview_id);
|
||||
Ok(())
|
||||
self.request(LiveRequest::Close { tab_id }).map(|_| ())
|
||||
}
|
||||
|
||||
fn ensure_webview(
|
||||
&mut self,
|
||||
request: &ServoLiveEnsureRequest,
|
||||
tab_id: &TabId,
|
||||
profile_id: &ProfileId,
|
||||
) -> Result<WebViewId, ServoLiveError> {
|
||||
if self
|
||||
.sessions
|
||||
.get(&request.tab_id)
|
||||
.is_some_and(|session| session.profile_id != *profile_id)
|
||||
&& let Some(session) = self.sessions.remove(&request.tab_id)
|
||||
fn handshake(&mut self) -> Result<(), ServoLiveError> {
|
||||
let reply = self.exchange(
|
||||
LiveRequest::Handshake { protocol_version: LIVE_PROTOCOL_VERSION },
|
||||
self.timeouts.request,
|
||||
"handshake",
|
||||
)?;
|
||||
if reply.frame.is_some()
|
||||
|| reply.surface_handle.is_some()
|
||||
|| reply.current_surface_id.is_some()
|
||||
{
|
||||
self.host.close_webview(&session.webview_id);
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "handshake response contains a frame",
|
||||
});
|
||||
}
|
||||
|
||||
let native_surface_id =
|
||||
request.native_surface.as_ref().map(gpui::NativeSurfaceHandle::identity);
|
||||
if self
|
||||
.sessions
|
||||
.get(&request.tab_id)
|
||||
.is_some_and(|session| session.native_surface_id != native_surface_id)
|
||||
&& let Some(session) = self.sessions.remove(&request.tab_id)
|
||||
{
|
||||
self.host.close_webview(&session.webview_id);
|
||||
match reply.error {
|
||||
Some(message) => Err(ServoLiveError::SidecarFailed { message }),
|
||||
None => Ok(()),
|
||||
}
|
||||
|
||||
if let Some(session) = self.sessions.get(&request.tab_id) {
|
||||
return Ok(session.webview_id.clone());
|
||||
}
|
||||
|
||||
let surface_size = ServoSurfaceSize::new(request.width, request.height);
|
||||
let webview_id = match request.native_surface.as_ref() {
|
||||
Some(native_surface) => self.host.create_webview_with_native_surface(
|
||||
tab_id.clone(),
|
||||
profile_id.clone(),
|
||||
surface_size,
|
||||
native_surface,
|
||||
)?,
|
||||
None => self.host.create_webview_with_size(
|
||||
tab_id.clone(),
|
||||
profile_id.clone(),
|
||||
surface_size,
|
||||
)?,
|
||||
};
|
||||
self.sessions.insert(
|
||||
request.tab_id.clone(),
|
||||
DirectWebViewSession {
|
||||
webview_id: webview_id.clone(),
|
||||
profile_id: profile_id.clone(),
|
||||
requested_url: None,
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
// Servo's WebViewBuilder defaults page zoom to 1.0 (100%) and
|
||||
// hidpi scale to 1.0; record both as Servo's actual post-build
|
||||
// state so `apply_viewport` pushes the embedder-requested
|
||||
// values on the first ensure. Without this, a request whose
|
||||
// zoom/DPR happens to equal the cached request value would
|
||||
// bypass `set_page_zoom` / `set_hidpi_scale` and leave the
|
||||
// WebView at Servo's defaults — on Retina that collapses CSS
|
||||
// pixels onto device pixels and renders pages at half size.
|
||||
page_zoom_percent: SERVO_DEFAULT_PAGE_ZOOM_PERCENT,
|
||||
device_pixel_ratio: SERVO_DEFAULT_DEVICE_PIXEL_RATIO,
|
||||
native_surface_id,
|
||||
},
|
||||
);
|
||||
Ok(webview_id)
|
||||
}
|
||||
|
||||
fn apply_viewport(
|
||||
fn request(&mut self, request: LiveRequest) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
||||
let reply = self.exchange(request, self.timeouts.request, "request")?;
|
||||
if let Some(message) = reply.error {
|
||||
return Err(ServoLiveError::SidecarFailed { message });
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(handle) = reply.surface_handle {
|
||||
self.queue_iosurface_handle(handle)?;
|
||||
self.drain_iosurface_imports()?;
|
||||
}
|
||||
|
||||
let mut frame = reply.frame;
|
||||
#[cfg(target_os = "macos")]
|
||||
if let (Some(frame), Some(surface_id)) = (frame.as_mut(), reply.current_surface_id) {
|
||||
let hardware_surface =
|
||||
self.iosurface_cache.hardware_surface_for(surface_id).map_err(|error| {
|
||||
ServoLiveError::IOSurfaceBackingFailed {
|
||||
surface_id,
|
||||
message: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
if hardware_surface.is_none() && !frame.has_software_payload() {
|
||||
return Ok(None);
|
||||
}
|
||||
frame.set_hardware_surface(surface_id, hardware_surface);
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
|
||||
fn exchange(
|
||||
&mut self,
|
||||
request: &ServoLiveEnsureRequest,
|
||||
webview_id: &WebViewId,
|
||||
) -> Result<(), ServoLiveError> {
|
||||
let Some(session) = self.sessions.get_mut(&request.tab_id) else {
|
||||
request: LiveRequest,
|
||||
timeout: Duration,
|
||||
operation: &'static str,
|
||||
) -> Result<IpcReply, ServoLiveError> {
|
||||
if !self.active {
|
||||
return Err(ServoLiveError::SidecarExited);
|
||||
}
|
||||
let reply = match self.ipc.exchange(request, timeout, operation) {
|
||||
Ok(reply) => reply,
|
||||
Err(error) => {
|
||||
self.terminate();
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if reply.protocol_version != Some(LIVE_PROTOCOL_VERSION) {
|
||||
let error = ServoLiveError::ProtocolVersionMismatch {
|
||||
expected: LIVE_PROTOCOL_VERSION,
|
||||
actual: reply.protocol_version,
|
||||
};
|
||||
self.terminate();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
fn shutdown(&mut self) {
|
||||
if !self.active {
|
||||
return;
|
||||
}
|
||||
let acknowledged = self
|
||||
.ipc
|
||||
.exchange(LiveRequest::Shutdown, self.timeouts.shutdown, "shutdown")
|
||||
.ok()
|
||||
.is_some_and(|reply| {
|
||||
reply.protocol_version == Some(LIVE_PROTOCOL_VERSION)
|
||||
&& reply.error.is_none()
|
||||
&& reply.frame.is_none()
|
||||
&& reply.surface_handle.is_none()
|
||||
&& reply.current_surface_id.is_none()
|
||||
});
|
||||
if acknowledged && wait_for_exit(&mut self.child, self.timeouts.exit) {
|
||||
self.active = false;
|
||||
self.ipc.close_and_join(self.timeouts.exit);
|
||||
return;
|
||||
}
|
||||
self.terminate();
|
||||
}
|
||||
|
||||
fn terminate(&mut self) {
|
||||
if self.active {
|
||||
terminate_child(&mut self.child, self.timeouts.exit);
|
||||
self.active = false;
|
||||
}
|
||||
self.ipc.close_and_join(self.timeouts.exit);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
impl ServoLiveClient {
|
||||
fn ready_surface_ids(&self) -> Vec<u64> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn pending_surface_ids(&self) -> Vec<u64> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
impl ServoLiveClient {
|
||||
fn ready_surface_ids(&self) -> Vec<u64> {
|
||||
self.iosurface_cache.surface_ids()
|
||||
}
|
||||
|
||||
fn pending_surface_ids(&self) -> Vec<u64> {
|
||||
self.pending_surface_ids.iter().copied().collect()
|
||||
}
|
||||
|
||||
fn queue_iosurface_handle(&mut self, handle: LiveSurfaceHandle) -> Result<(), ServoLiveError> {
|
||||
let Some(importer) = self.iosurface_importer.as_ref() else {
|
||||
return Err(ServoLiveError::IOSurfaceImportFailed {
|
||||
surface_id: handle.surface_id,
|
||||
mach_port_name: handle.mach_port_name,
|
||||
message: "IOSurface import worker is unavailable".to_string(),
|
||||
});
|
||||
};
|
||||
let surface_id = handle.surface_id;
|
||||
importer.submit(handle).map_err(|failure| ServoLiveError::IOSurfaceImportFailed {
|
||||
surface_id: failure.surface_id,
|
||||
mach_port_name: failure.mach_port_name,
|
||||
message: failure.message,
|
||||
})?;
|
||||
self.pending_surface_ids.insert(surface_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drain_iosurface_imports(&mut self) -> Result<(), ServoLiveError> {
|
||||
let Some(importer) = self.iosurface_importer.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let change = ViewportChange::between(request, session);
|
||||
|
||||
if let Some((width, height)) = change.resize {
|
||||
self.host.resize(ResizeRequest { webview_id: webview_id.clone(), width, height })?;
|
||||
session.width = width;
|
||||
session.height = height;
|
||||
}
|
||||
|
||||
if let Some(scale_factor) = change.set_hidpi {
|
||||
self.host.set_hidpi_scale(HidpiScaleRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
scale_factor,
|
||||
})?;
|
||||
session.device_pixel_ratio = scale_factor;
|
||||
}
|
||||
|
||||
if let Some(page_zoom_percent) = change.set_page_zoom {
|
||||
self.host.set_page_zoom(PageZoomRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
zoom_factor: f32::from(page_zoom_percent) / 100.0,
|
||||
})?;
|
||||
session.page_zoom_percent = page_zoom_percent;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
apply_iosurface_import_results(
|
||||
&mut self.iosurface_cache,
|
||||
&mut self.pending_surface_ids,
|
||||
importer.drain(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_permissions(
|
||||
&mut self,
|
||||
request: &ServoLiveEnsureRequest,
|
||||
webview_id: &WebViewId,
|
||||
profile_id: &ProfileId,
|
||||
) -> Result<(), ServoLiveError> {
|
||||
for permission in &request.site_permissions {
|
||||
let origin = SiteOrigin::parse(permission.origin.clone())?;
|
||||
let feature = SitePermissionFeature::parse(permission.feature.as_str())?;
|
||||
let decision = SitePermissionDecision::parse(permission.decision.as_str())?;
|
||||
self.host.set_permission(
|
||||
PermissionRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
profile_id: profile_id.clone(),
|
||||
origin,
|
||||
feature,
|
||||
},
|
||||
PermissionDecision::from(decision),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_navigation(
|
||||
&mut self,
|
||||
request: &ServoLiveEnsureRequest,
|
||||
webview_id: &WebViewId,
|
||||
tab_id: TabId,
|
||||
requested_url: UrlText,
|
||||
) -> Result<(), ServoLiveError> {
|
||||
// Servo's `set_history` fires `notify_url_changed` on *every*
|
||||
// history mutation — full navigations, redirects, in-page
|
||||
// links, and JS-driven `history.pushState` / `replaceState`.
|
||||
// Our embedder fans that back through `WebSurfaceUrlChange`
|
||||
// into `tab.url`, which makes the next `ensure_surface` see a
|
||||
// "different" URL and call back into this method. Without a
|
||||
// guard we then send Servo `WebView::load(new_url)` for a URL
|
||||
// Servo just informed us it is *already* at — and `load` is a
|
||||
// hard navigation that aborts the live document, clears the
|
||||
// surface, and refetches.
|
||||
//
|
||||
// The google.com homepage `replaceState`s a `?zx=<timestamp>`
|
||||
// every second; under the unguarded path that turned into a
|
||||
// load-clear-refetch cycle per second, i.e. the continuous
|
||||
// white flash. Servo's own `webview.url()` (driven by
|
||||
// `set_history`) is the source of truth — if it already
|
||||
// matches the requested URL, this URL change came *from*
|
||||
// Servo and only needs an embedder-side bookkeeping sync.
|
||||
let servo_current_url = self.host.snapshot(webview_id)?.url().map(str::to_string);
|
||||
if servo_current_url.as_deref() == Some(requested_url.as_str()) {
|
||||
if let Some(session) = self.sessions.get_mut(&request.tab_id) {
|
||||
session.requested_url = Some(requested_url.as_str().to_string());
|
||||
#[cfg(target_os = "macos")]
|
||||
fn apply_iosurface_import_results(
|
||||
cache: &mut IOSurfaceCache,
|
||||
pending_surface_ids: &mut BTreeSet<u64>,
|
||||
results: Vec<IOSurfaceImportResult>,
|
||||
) -> Result<(), ServoLiveError> {
|
||||
let mut first_failure = None;
|
||||
for result in results {
|
||||
let surface_id = match &result {
|
||||
IOSurfaceImportResult::Imported(imported) => imported.surface_id,
|
||||
IOSurfaceImportResult::Failed(failure) => failure.surface_id,
|
||||
};
|
||||
pending_surface_ids.remove(&surface_id);
|
||||
match result {
|
||||
IOSurfaceImportResult::Imported(imported) => {
|
||||
cache.insert_imported(imported.surface_id, imported.imported);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let should_navigate = self
|
||||
.sessions
|
||||
.get(&request.tab_id)
|
||||
.and_then(|session| session.requested_url.as_deref())
|
||||
.is_none_or(|current| current != requested_url.as_str());
|
||||
if should_navigate {
|
||||
self.host.navigate(NavigationRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
tab_id,
|
||||
url: requested_url.clone(),
|
||||
})?;
|
||||
if let Some(session) = self.sessions.get_mut(&request.tab_id) {
|
||||
session.requested_url = Some(requested_url.as_str().to_string());
|
||||
IOSurfaceImportResult::Failed(failure) if first_failure.is_none() => {
|
||||
first_failure = Some(ServoLiveError::IOSurfaceImportFailed {
|
||||
surface_id: failure.surface_id,
|
||||
mach_port_name: failure.mach_port_name,
|
||||
message: failure.message,
|
||||
});
|
||||
}
|
||||
IOSurfaceImportResult::Failed(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
first_failure.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
fn apply_input(
|
||||
&mut self,
|
||||
request: &ServoLiveEnsureRequest,
|
||||
webview_id: &WebViewId,
|
||||
) -> Result<(), ServoLiveError> {
|
||||
if request.scroll_delta_x != 0 || request.scroll_delta_y != 0 {
|
||||
let point_x = request.scroll_point_x.ok_or(ServoLiveError::MissingScrollPoint)?;
|
||||
let point_y = request.scroll_point_y.ok_or(ServoLiveError::MissingScrollPoint)?;
|
||||
self.host.scroll(ScrollRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
delta_x: request.scroll_delta_x,
|
||||
delta_y: request.scroll_delta_y,
|
||||
point_x,
|
||||
point_y,
|
||||
})?;
|
||||
}
|
||||
|
||||
if let (Some(x), Some(y)) = (request.hover_x, request.hover_y) {
|
||||
self.host.hover(MouseHoverRequest { webview_id: webview_id.clone(), x, y })?;
|
||||
}
|
||||
|
||||
if let (Some(x), Some(y)) = (request.click_x, request.click_y) {
|
||||
self.host.click(MouseClickRequest { webview_id: webview_id.clone(), x, y })?;
|
||||
}
|
||||
|
||||
if let Some(text) = request.typed_text.as_ref() {
|
||||
self.host.type_text(KeyboardTextRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
text: text.clone(),
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn presented_frame_from_session(
|
||||
&self,
|
||||
tab_id: &str,
|
||||
webview_id: &WebViewId,
|
||||
) -> Result<ServoLiveFrame, ServoLiveError> {
|
||||
let Some(session) = self.sessions.get(tab_id) else {
|
||||
return Err(ServoLiveError::Host(ely_servo_host::ServoHostError::WebViewNotFound {
|
||||
id: webview_id.clone(),
|
||||
}));
|
||||
};
|
||||
if session.native_surface_id.is_some() {
|
||||
let snapshot = self.host.snapshot_and_mark_metadata_observed(webview_id)?;
|
||||
return Ok(ServoLiveFrame::from_presented(
|
||||
snapshot,
|
||||
session.width,
|
||||
session.height,
|
||||
session.device_pixel_ratio,
|
||||
));
|
||||
}
|
||||
Err(ServoLiveError::NativeSurfaceUnavailable)
|
||||
}
|
||||
|
||||
fn rendered_frame_from_session(
|
||||
&self,
|
||||
tab_id: &str,
|
||||
webview_id: &WebViewId,
|
||||
) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
||||
let Some(session) = self.sessions.get(tab_id) else {
|
||||
return Err(ServoLiveError::Host(ServoHostError::WebViewNotFound {
|
||||
id: webview_id.clone(),
|
||||
}));
|
||||
};
|
||||
let rendered_frame = match self.host.last_rendered_frame() {
|
||||
Ok(frame) => frame,
|
||||
Err(ServoHostError::RenderedFrameUnavailable) => return Ok(None),
|
||||
Err(error) => return Err(ServoLiveError::Host(error)),
|
||||
};
|
||||
let snapshot = self.host.snapshot_and_mark_metadata_observed(webview_id)?;
|
||||
Ok(Some(ServoLiveFrame::from_rendered(
|
||||
snapshot,
|
||||
rendered_frame,
|
||||
session.device_pixel_ratio,
|
||||
)))
|
||||
}
|
||||
|
||||
fn session_uses_native_surface(&self, tab_id: &str) -> bool {
|
||||
self.sessions.get(tab_id).is_some_and(|session| session.native_surface_id.is_some())
|
||||
impl Drop for ServoLiveClient {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectWebViewSession {
|
||||
webview_id: WebViewId,
|
||||
profile_id: ProfileId,
|
||||
requested_url: Option<String>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
page_zoom_percent: u16,
|
||||
device_pixel_ratio: f32,
|
||||
native_surface_id: Option<usize>,
|
||||
}
|
||||
|
||||
/// Calls that `apply_viewport` needs to make to bring the underlying
|
||||
/// Servo WebView state in line with the embedder's request. Extracted
|
||||
/// as a pure value so the diff decision is testable without a live
|
||||
/// `SoftwareServoHost` — see `tests` below.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct ViewportChange {
|
||||
resize: Option<(u32, u32)>,
|
||||
set_hidpi: Option<f32>,
|
||||
set_page_zoom: Option<u16>,
|
||||
}
|
||||
|
||||
impl ViewportChange {
|
||||
fn between(request: &ServoLiveEnsureRequest, session: &DirectWebViewSession) -> Self {
|
||||
Self {
|
||||
resize: (session.width != request.width || session.height != request.height)
|
||||
.then_some((request.width, request.height)),
|
||||
set_hidpi: (session.device_pixel_ratio != request.device_pixel_ratio)
|
||||
.then_some(request.device_pixel_ratio),
|
||||
set_page_zoom: (session.page_zoom_percent != request.page_zoom_percent)
|
||||
.then_some(request.page_zoom_percent),
|
||||
fn wait_for_exit(child: &mut Child, timeout: Duration) -> bool {
|
||||
let started_at = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => return true,
|
||||
Ok(None) => {}
|
||||
Err(_) => return false,
|
||||
}
|
||||
if started_at.elapsed() >= timeout {
|
||||
return false;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn terminate_child(child: &mut Child, timeout: Duration) {
|
||||
if child.try_wait().ok().flatten().is_none() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
let _ = wait_for_exit(child, timeout);
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use std::{error::Error, process::Command};
|
||||
|
||||
use super::*;
|
||||
use ely_domain::WebViewId;
|
||||
|
||||
fn session(width: u32, height: u32, dpr: f32, zoom: u16) -> DirectWebViewSession {
|
||||
DirectWebViewSession {
|
||||
webview_id: WebViewId::new(),
|
||||
profile_id: ProfileId::new(),
|
||||
requested_url: None,
|
||||
width,
|
||||
height,
|
||||
page_zoom_percent: zoom,
|
||||
device_pixel_ratio: dpr,
|
||||
native_surface_id: Some(0xC0FFEE),
|
||||
}
|
||||
}
|
||||
const TEST_TIMEOUTS: SidecarTimeouts = SidecarTimeouts {
|
||||
request: Duration::from_millis(50),
|
||||
shutdown: Duration::from_millis(50),
|
||||
exit: Duration::from_millis(250),
|
||||
};
|
||||
|
||||
fn request(width: u32, height: u32, dpr: f32, zoom: u16) -> ServoLiveEnsureRequest {
|
||||
ServoLiveEnsureRequest {
|
||||
tab_id: String::from("tab"),
|
||||
profile_id: String::from("profile"),
|
||||
url: String::from("https://example.com/"),
|
||||
width,
|
||||
height,
|
||||
page_zoom_percent: zoom,
|
||||
device_pixel_ratio: dpr,
|
||||
native_surface: None,
|
||||
scroll_delta_x: 0,
|
||||
scroll_delta_y: 0,
|
||||
scroll_point_x: None,
|
||||
scroll_point_y: None,
|
||||
click_x: None,
|
||||
click_y: None,
|
||||
hover_x: None,
|
||||
hover_y: None,
|
||||
typed_text: None,
|
||||
site_permissions: Vec::new(),
|
||||
}
|
||||
#[test]
|
||||
fn startup_rejects_a_sidecar_without_the_current_protocol() -> Result<(), Box<dyn Error>> {
|
||||
let child = spawn_script(
|
||||
"IFS= read -r request; printf '%s\\n' '{\"error\":\"unknown request\",\"frame\":null}'",
|
||||
)?;
|
||||
|
||||
let result = ServoLiveClient::from_spawned_child(child, TEST_TIMEOUTS);
|
||||
|
||||
assert!(matches!(result, Err(ServoLiveError::ProtocolVersionMismatch { .. })));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_retina_session_pushes_hidpi_only() {
|
||||
// A 1440×900 Retina viewport arrives as physical 2880×1800 with
|
||||
// DPR=2.0. `ensure_webview` stamps the session with Servo's
|
||||
// post-build defaults (1.0 DPR, 100 % zoom) and the request's
|
||||
// physical dimensions, so only the hidpi push should fire.
|
||||
let change = ViewportChange::between(
|
||||
&request(2880, 1800, 2.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
|
||||
&session(2880, 1800, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
|
||||
);
|
||||
assert_eq!(
|
||||
change,
|
||||
ViewportChange { resize: None, set_hidpi: Some(2.0), set_page_zoom: None },
|
||||
);
|
||||
fn request_timeout_kills_and_reaps_a_hung_sidecar() -> Result<(), Box<dyn Error>> {
|
||||
let mut client = hung_sidecar_client()?;
|
||||
let started_at = Instant::now();
|
||||
|
||||
let result = client.poll("tab-hung".to_string());
|
||||
|
||||
assert!(matches!(result, Err(ServoLiveError::RequestTimedOut { .. })));
|
||||
assert!(started_at.elapsed() < Duration::from_secs(1));
|
||||
assert!(client.child.try_wait()?.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_standard_dpi_session_pushes_nothing() {
|
||||
// On a 1.0-DPR display the fresh session already matches the
|
||||
// request — Servo's defaults are exactly what we asked for.
|
||||
let change = ViewportChange::between(
|
||||
&request(1280, 720, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
|
||||
&session(1280, 720, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
|
||||
);
|
||||
assert_eq!(change, ViewportChange::default());
|
||||
fn shutdown_timeout_kills_and_reaps_a_hung_sidecar() -> Result<(), Box<dyn Error>> {
|
||||
let mut client = hung_sidecar_client()?;
|
||||
let started_at = Instant::now();
|
||||
|
||||
client.shutdown();
|
||||
|
||||
assert!(started_at.elapsed() < Duration::from_secs(1));
|
||||
assert!(client.child.try_wait()?.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_zoom_change_pushes_only_zoom() {
|
||||
// User toggled zoom to 150 % on a session that's already at the
|
||||
// right size and DPR — only `set_page_zoom` should fire.
|
||||
let change = ViewportChange::between(
|
||||
&request(2880, 1800, 2.0, 150),
|
||||
&session(2880, 1800, 2.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
|
||||
);
|
||||
assert_eq!(
|
||||
change,
|
||||
ViewportChange { resize: None, set_hidpi: None, set_page_zoom: Some(150) },
|
||||
fn hung_sidecar_client() -> Result<ServoLiveClient, ServoLiveError> {
|
||||
let script = format!(
|
||||
"IFS= read -r request; printf '%s\\n' '{{\"protocol_version\":{LIVE_PROTOCOL_VERSION},\"error\":null,\"frame\":null}}'; while IFS= read -r request; do :; done"
|
||||
);
|
||||
let child = spawn_script(&script).map_err(ServoLiveError::Command)?;
|
||||
ServoLiveClient::from_spawned_child(child, TEST_TIMEOUTS)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_monitor_dpr_change_pushes_only_hidpi() {
|
||||
// A window dragged from a 2.0-DPR monitor to a 1.0-DPR one keeps
|
||||
// the same physical surface (GPUI re-emits the viewport at the
|
||||
// new scale) — only the DPR push should fire.
|
||||
let change = ViewportChange::between(
|
||||
&request(2880, 1800, 1.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
|
||||
&session(2880, 1800, 2.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
|
||||
);
|
||||
assert_eq!(
|
||||
change,
|
||||
ViewportChange { resize: None, set_hidpi: Some(1.0), set_page_zoom: None },
|
||||
);
|
||||
fn spawn_script(script: &str) -> Result<Child, std::io::Error> {
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
#[path = "servo_live_iosurface_tests.rs"]
|
||||
mod iosurface_tests;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::{
|
||||
io,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::services::{
|
||||
iosurface_mach::IOSurfaceMachReceiver,
|
||||
iosurface_metal::{ImportedPixelBuffer, import_pixel_buffer_from_mach_port},
|
||||
};
|
||||
|
||||
use super::wire::LiveSurfaceHandle;
|
||||
|
||||
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const IMPORT_QUEUE_CAPACITY: usize = 16;
|
||||
|
||||
pub(super) struct IOSurfaceImportWorker {
|
||||
request_tx: Option<mpsc::SyncSender<LiveSurfaceHandle>>,
|
||||
result_rx: mpsc::Receiver<IOSurfaceImportResult>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl IOSurfaceImportWorker {
|
||||
pub(super) fn new(receiver: IOSurfaceMachReceiver) -> Result<Self, io::Error> {
|
||||
let (request_tx, request_rx) = mpsc::sync_channel(IMPORT_QUEUE_CAPACITY);
|
||||
let (result_tx, result_rx) = mpsc::channel();
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let worker_shutdown = shutdown.clone();
|
||||
let thread = thread::Builder::new()
|
||||
.name("ely-iosurface-import".to_string())
|
||||
.spawn(move || run_import_worker(receiver, request_rx, result_tx, worker_shutdown))?;
|
||||
Ok(Self { request_tx: Some(request_tx), result_rx, shutdown, thread: Some(thread) })
|
||||
}
|
||||
|
||||
pub(super) fn submit(&self, handle: LiveSurfaceHandle) -> Result<(), IOSurfaceImportFailure> {
|
||||
let Some(request_tx) = self.request_tx.as_ref() else {
|
||||
return Err(IOSurfaceImportFailure::worker_stopped(handle));
|
||||
};
|
||||
request_tx.send(handle).map_err(|error| IOSurfaceImportFailure::worker_stopped(error.0))
|
||||
}
|
||||
|
||||
pub(super) fn drain(&self) -> Vec<IOSurfaceImportResult> {
|
||||
let mut results = Vec::new();
|
||||
while let Ok(result) = self.result_rx.try_recv() {
|
||||
results.push(result);
|
||||
}
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IOSurfaceImportWorker {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown.store(true, Ordering::Release);
|
||||
self.request_tx.take();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum IOSurfaceImportResult {
|
||||
Imported(ImportedIOSurface),
|
||||
Failed(IOSurfaceImportFailure),
|
||||
}
|
||||
|
||||
pub(super) struct ImportedIOSurface {
|
||||
pub(super) surface_id: u64,
|
||||
pub(super) imported: ImportedPixelBuffer,
|
||||
}
|
||||
|
||||
// SAFETY: `ImportedPixelBuffer` owns retained IOSurface and CVPixelBuffer
|
||||
// handles plus a use-count guard. CoreFoundation retain/release and IOSurface
|
||||
// use-count operations are thread-safe while this value crosses the channel.
|
||||
#[expect(unsafe_code)]
|
||||
unsafe impl Send for ImportedIOSurface {}
|
||||
|
||||
pub(super) struct IOSurfaceImportFailure {
|
||||
pub(super) surface_id: u64,
|
||||
pub(super) mach_port_name: u32,
|
||||
pub(super) message: String,
|
||||
}
|
||||
|
||||
impl IOSurfaceImportFailure {
|
||||
fn worker_stopped(handle: LiveSurfaceHandle) -> Self {
|
||||
Self::queue_rejected(handle, "IOSurface import worker stopped")
|
||||
}
|
||||
|
||||
fn queue_rejected(handle: LiveSurfaceHandle, message: &str) -> Self {
|
||||
Self {
|
||||
surface_id: handle.surface_id,
|
||||
mach_port_name: handle.mach_port_name,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_import_worker(
|
||||
mut receiver: IOSurfaceMachReceiver,
|
||||
request_rx: mpsc::Receiver<LiveSurfaceHandle>,
|
||||
result_tx: mpsc::Sender<IOSurfaceImportResult>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
) {
|
||||
run_import_requests(request_rx, result_tx, shutdown, |handle| {
|
||||
import_surface_handle(&mut receiver, handle)
|
||||
});
|
||||
}
|
||||
|
||||
fn run_import_requests(
|
||||
request_rx: mpsc::Receiver<LiveSurfaceHandle>,
|
||||
result_tx: mpsc::Sender<IOSurfaceImportResult>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
mut import: impl FnMut(LiveSurfaceHandle) -> IOSurfaceImportResult,
|
||||
) {
|
||||
while let Ok(handle) = request_rx.recv() {
|
||||
if shutdown.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let result = import(handle);
|
||||
if shutdown.load(Ordering::Acquire) || result_tx.send(result).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn import_surface_handle(
|
||||
receiver: &mut IOSurfaceMachReceiver,
|
||||
handle: LiveSurfaceHandle,
|
||||
) -> IOSurfaceImportResult {
|
||||
let mach_port_name = match receiver.receive_port_for_surface(handle.surface_id, RECEIVE_TIMEOUT)
|
||||
{
|
||||
Ok(mach_port_name) => mach_port_name,
|
||||
Err(error) => {
|
||||
return IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
|
||||
surface_id: handle.surface_id,
|
||||
mach_port_name: handle.mach_port_name,
|
||||
message: error.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
match import_pixel_buffer_from_mach_port(mach_port_name) {
|
||||
Ok(imported)
|
||||
if imported_surface_matches(
|
||||
&handle,
|
||||
imported.system_surface_id,
|
||||
imported.backing.pixel_buffer().get_width(),
|
||||
imported.backing.pixel_buffer().get_height(),
|
||||
) =>
|
||||
{
|
||||
IOSurfaceImportResult::Imported(ImportedIOSurface {
|
||||
surface_id: handle.surface_id,
|
||||
imported,
|
||||
})
|
||||
}
|
||||
Ok(imported) => IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
|
||||
surface_id: handle.surface_id,
|
||||
mach_port_name,
|
||||
message: format!(
|
||||
"IOSurface id/size {:#x} {}x{} did not match handle {:#x} {}x{}",
|
||||
imported.system_surface_id,
|
||||
imported.backing.pixel_buffer().get_width(),
|
||||
imported.backing.pixel_buffer().get_height(),
|
||||
handle.surface_id,
|
||||
handle.width,
|
||||
handle.height,
|
||||
),
|
||||
}),
|
||||
Err(error) => IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
|
||||
surface_id: handle.surface_id,
|
||||
mach_port_name,
|
||||
message: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn imported_surface_matches(
|
||||
handle: &LiveSurfaceHandle,
|
||||
system_surface_id: u64,
|
||||
width: usize,
|
||||
height: usize,
|
||||
) -> bool {
|
||||
system_surface_id == handle.surface_id
|
||||
&& width == handle.width as usize
|
||||
&& height == handle.height as usize
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{cell::Cell, sync::mpsc};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shutdown_stops_after_current_import() {
|
||||
let (request_tx, request_rx) = mpsc::channel();
|
||||
let (result_tx, result_rx) = mpsc::channel();
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
for surface_id in 1..=3 {
|
||||
assert!(request_tx.send(handle(surface_id)).is_ok());
|
||||
}
|
||||
drop(request_tx);
|
||||
let processed = Cell::new(0);
|
||||
let import_shutdown = shutdown.clone();
|
||||
|
||||
run_import_requests(request_rx, result_tx, shutdown, |handle| {
|
||||
processed.set(processed.get() + 1);
|
||||
import_shutdown.store(true, Ordering::Release);
|
||||
IOSurfaceImportResult::Failed(IOSurfaceImportFailure::worker_stopped(handle))
|
||||
});
|
||||
|
||||
assert_eq!(processed.get(), 1);
|
||||
assert!(result_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_surface_requires_matching_system_id_and_dimensions() {
|
||||
assert!(imported_surface_matches(&handle(7), 7, 64, 48));
|
||||
assert!(!imported_surface_matches(&handle(8), 7, 64, 48));
|
||||
let mut wrong_size = handle(7);
|
||||
wrong_size.width = 96;
|
||||
assert!(!imported_surface_matches(&wrong_size, 7, 64, 48));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_backpressures_surface_burst_at_queue_capacity() {
|
||||
let (request_tx, request_rx) = mpsc::sync_channel(IMPORT_QUEUE_CAPACITY);
|
||||
let (_result_tx, result_rx) = mpsc::channel();
|
||||
let worker = IOSurfaceImportWorker {
|
||||
request_tx: Some(request_tx),
|
||||
result_rx,
|
||||
shutdown: Arc::new(AtomicBool::new(false)),
|
||||
thread: None,
|
||||
};
|
||||
for surface_id in 0..IMPORT_QUEUE_CAPACITY as u64 {
|
||||
assert!(worker.submit(handle(surface_id)).is_ok());
|
||||
}
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
let consumer = std::thread::spawn(move || {
|
||||
let received = request_rx.recv();
|
||||
let _ = release_rx.recv();
|
||||
received
|
||||
});
|
||||
|
||||
assert!(worker.submit(handle(99)).is_ok());
|
||||
assert!(release_tx.send(()).is_ok());
|
||||
assert!(consumer.join().is_ok());
|
||||
}
|
||||
|
||||
fn handle(surface_id: u64) -> LiveSurfaceHandle {
|
||||
LiveSurfaceHandle { mach_port_name: surface_id as u32, surface_id, width: 64, height: 48 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::{
|
||||
ServoLiveError, apply_iosurface_import_results,
|
||||
iosurface_importer::{IOSurfaceImportFailure, IOSurfaceImportResult},
|
||||
};
|
||||
use crate::services::iosurface_metal::IOSurfaceCache;
|
||||
|
||||
#[test]
|
||||
fn import_failure_clears_every_completed_pending_surface() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let mut pending = BTreeSet::from([1, 2, 3]);
|
||||
let results = vec![failed_import(1), failed_import(2), failed_import(3)];
|
||||
|
||||
let Err(error) = apply_iosurface_import_results(&mut cache, &mut pending, results) else {
|
||||
return Err("the failed import batch succeeded".to_string());
|
||||
};
|
||||
|
||||
assert!(matches!(error, ServoLiveError::IOSurfaceImportFailed { surface_id: 1, .. }));
|
||||
assert!(pending.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn failed_import(surface_id: u64) -> IOSurfaceImportResult {
|
||||
IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
|
||||
surface_id,
|
||||
mach_port_name: surface_id as u32,
|
||||
message: format!("surface {surface_id} failed"),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
process::{ChildStdin, ChildStdout},
|
||||
sync::mpsc::{self, Receiver, Sender, SyncSender},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use super::{
|
||||
ServoLiveError, ServoLiveFrame,
|
||||
wire::{
|
||||
LiveRequest, LiveResponse, LiveSurfaceHandle, MAX_FRAME_BYTE_COUNT, MAX_FRAME_DIMENSION,
|
||||
},
|
||||
};
|
||||
|
||||
const MAX_RESPONSE_HEADER_BYTES: usize = 256 * 1024;
|
||||
|
||||
pub(super) struct ServoLiveIpc {
|
||||
requests: Option<Sender<IpcRequest>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
pub(super) struct IpcReply {
|
||||
pub(super) protocol_version: Option<u32>,
|
||||
pub(super) error: Option<String>,
|
||||
pub(super) frame: Option<ServoLiveFrame>,
|
||||
pub(super) surface_handle: Option<LiveSurfaceHandle>,
|
||||
pub(super) current_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
struct IpcRequest {
|
||||
request: LiveRequest,
|
||||
response: SyncSender<Result<IpcReply, ServoLiveError>>,
|
||||
}
|
||||
|
||||
impl ServoLiveIpc {
|
||||
pub(super) fn spawn(stdin: ChildStdin, stdout: ChildStdout) -> Self {
|
||||
let (requests, receiver) = mpsc::channel();
|
||||
let thread = thread::spawn(move || run_io(stdin, BufReader::new(stdout), receiver));
|
||||
Self { requests: Some(requests), thread: Some(thread) }
|
||||
}
|
||||
|
||||
pub(super) fn exchange(
|
||||
&self,
|
||||
request: LiveRequest,
|
||||
timeout: Duration,
|
||||
operation: &'static str,
|
||||
) -> Result<IpcReply, ServoLiveError> {
|
||||
let requests = self.requests.as_ref().ok_or(ServoLiveError::SidecarExited)?;
|
||||
let (response, receiver) = mpsc::sync_channel(1);
|
||||
requests
|
||||
.send(IpcRequest { request, response })
|
||||
.map_err(|_| ServoLiveError::SidecarExited)?;
|
||||
receiver.recv_timeout(timeout).map_err(|error| match error {
|
||||
mpsc::RecvTimeoutError::Timeout => {
|
||||
ServoLiveError::RequestTimedOut { operation, timeout_millis: timeout.as_millis() }
|
||||
}
|
||||
mpsc::RecvTimeoutError::Disconnected => ServoLiveError::SidecarExited,
|
||||
})?
|
||||
}
|
||||
|
||||
pub(super) fn close_and_join(&mut self, timeout: Duration) {
|
||||
self.requests.take();
|
||||
let Some(thread) = self.thread.take() else {
|
||||
return;
|
||||
};
|
||||
let started_at = Instant::now();
|
||||
while !thread.is_finished() && started_at.elapsed() < timeout {
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
if thread.is_finished() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_io(
|
||||
mut stdin: ChildStdin,
|
||||
mut stdout: BufReader<ChildStdout>,
|
||||
requests: Receiver<IpcRequest>,
|
||||
) {
|
||||
while let Ok(message) = requests.recv() {
|
||||
let should_shutdown = matches!(message.request, LiveRequest::Shutdown);
|
||||
let response = exchange_pipes(&mut stdin, &mut stdout, &message.request);
|
||||
let should_stop = should_shutdown || response.is_err();
|
||||
let _ = message.response.send(response);
|
||||
if should_stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exchange_pipes(
|
||||
stdin: &mut ChildStdin,
|
||||
stdout: &mut BufReader<ChildStdout>,
|
||||
request: &LiveRequest,
|
||||
) -> Result<IpcReply, ServoLiveError> {
|
||||
serde_json::to_writer(&mut *stdin, request)?;
|
||||
stdin.write_all(b"\n").map_err(ServoLiveError::Command)?;
|
||||
stdin.flush().map_err(ServoLiveError::Command)?;
|
||||
read_reply(stdout)
|
||||
}
|
||||
|
||||
fn read_reply(stdout: &mut impl BufRead) -> Result<IpcReply, ServoLiveError> {
|
||||
let mut line = String::new();
|
||||
let bytes = Read::by_ref(stdout)
|
||||
.take((MAX_RESPONSE_HEADER_BYTES + 1) as u64)
|
||||
.read_line(&mut line)
|
||||
.map_err(ServoLiveError::Command)?;
|
||||
if bytes == 0 {
|
||||
return Err(ServoLiveError::SidecarExited);
|
||||
}
|
||||
if bytes > MAX_RESPONSE_HEADER_BYTES {
|
||||
return Err(ServoLiveError::ResponseHeaderTooLarge { limit: MAX_RESPONSE_HEADER_BYTES });
|
||||
}
|
||||
if !line.ends_with('\n') {
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "response header is missing its newline delimiter",
|
||||
});
|
||||
}
|
||||
|
||||
let response: LiveResponse = serde_json::from_str(&line)?;
|
||||
if response.error.is_some()
|
||||
&& (response.frame.is_some()
|
||||
|| response.surface_handle.is_some()
|
||||
|| response.current_surface_id.is_some())
|
||||
{
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "error response contains a frame or hardware surface",
|
||||
});
|
||||
}
|
||||
if response.frame.is_none()
|
||||
&& (response.surface_handle.is_some() || response.current_surface_id.is_some())
|
||||
{
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "hardware surface response is missing its frame report",
|
||||
});
|
||||
}
|
||||
if let Some(handle) = response.surface_handle.as_ref() {
|
||||
if response.current_surface_id != Some(handle.surface_id) {
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "surface_handle does not match current_surface_id",
|
||||
});
|
||||
}
|
||||
if response
|
||||
.frame
|
||||
.as_ref()
|
||||
.is_none_or(|frame| frame.width != handle.width || frame.height != handle.height)
|
||||
{
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "surface_handle dimensions do not match frame report",
|
||||
});
|
||||
}
|
||||
}
|
||||
let frame = response.frame.map(|report| {
|
||||
let rgba_byte_count = validate_frame_layout(report.width, report.height)?;
|
||||
let hardware_frame = report.rgba_byte_count == 0;
|
||||
if hardware_frame && response.current_surface_id.is_none() {
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "hardware frame is missing current_surface_id",
|
||||
});
|
||||
}
|
||||
if !hardware_frame
|
||||
&& (response.surface_handle.is_some() || response.current_surface_id.is_some())
|
||||
{
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "software frame contains hardware surface metadata",
|
||||
});
|
||||
}
|
||||
if !hardware_frame && report.rgba_byte_count != rgba_byte_count {
|
||||
return Err(ServoLiveError::InvalidFrameByteCount {
|
||||
advertised: report.rgba_byte_count,
|
||||
expected: rgba_byte_count as u64,
|
||||
width: report.width,
|
||||
height: report.height,
|
||||
});
|
||||
}
|
||||
let rgba_bytes = if hardware_frame {
|
||||
None
|
||||
} else {
|
||||
let mut bytes = Vec::new();
|
||||
bytes.try_reserve_exact(rgba_byte_count).map_err(|source| {
|
||||
ServoLiveError::FrameAllocation { bytes: rgba_byte_count, source }
|
||||
})?;
|
||||
bytes.resize(rgba_byte_count, 0);
|
||||
stdout.read_exact(&mut bytes).map_err(ServoLiveError::FrameRead)?;
|
||||
Some(bytes)
|
||||
};
|
||||
Ok(ServoLiveFrame::from_parts(report, rgba_bytes))
|
||||
});
|
||||
Ok(IpcReply {
|
||||
protocol_version: response.protocol_version,
|
||||
error: response.error,
|
||||
frame: frame.transpose()?,
|
||||
surface_handle: response.surface_handle,
|
||||
current_surface_id: response.current_surface_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn validate_frame_layout(width: u32, height: u32) -> Result<usize, ServoLiveError> {
|
||||
if width == 0 || height == 0 || width > MAX_FRAME_DIMENSION || height > MAX_FRAME_DIMENSION {
|
||||
return Err(ServoLiveError::InvalidFrameDimensions {
|
||||
width,
|
||||
height,
|
||||
max_dimension: MAX_FRAME_DIMENSION,
|
||||
});
|
||||
}
|
||||
let bytes = u64::from(width)
|
||||
.checked_mul(u64::from(height))
|
||||
.and_then(|pixels| pixels.checked_mul(4))
|
||||
.ok_or(ServoLiveError::InvalidFrameDimensions {
|
||||
width,
|
||||
height,
|
||||
max_dimension: MAX_FRAME_DIMENSION,
|
||||
})?;
|
||||
if bytes > MAX_FRAME_BYTE_COUNT as u64 {
|
||||
return Err(ServoLiveError::FrameByteLimitExceeded { bytes, limit: MAX_FRAME_BYTE_COUNT });
|
||||
}
|
||||
Ok(bytes as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Cursor;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_layout_rejects_oversized_frames() {
|
||||
assert!(matches!(
|
||||
validate_frame_layout(MAX_FRAME_DIMENSION, MAX_FRAME_DIMENSION),
|
||||
Err(ServoLiveError::FrameByteLimitExceeded { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
validate_frame_layout(MAX_FRAME_DIMENSION + 1, 1),
|
||||
Err(ServoLiveError::InvalidFrameDimensions { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_rejects_oversized_frame_before_readback_allocation() {
|
||||
let header = format!(
|
||||
"{{\"protocol_version\":2,\"error\":null,\"frame\":{{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",\"width\":{0},\"height\":{0},\"device_pixel_ratio\":1.0,\"css_viewport_width\":{0},\"css_viewport_height\":{0},\"rgba_byte_count\":1073741824,\"pixels_changed\":true}}}}\n",
|
||||
MAX_FRAME_DIMENSION
|
||||
);
|
||||
let mut input = Cursor::new(header.into_bytes());
|
||||
|
||||
assert!(matches!(
|
||||
read_reply(&mut input),
|
||||
Err(ServoLiveError::FrameByteLimitExceeded { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn hardware_reply_uses_surface_without_rgba_allocation() -> Result<(), ServoLiveError> {
|
||||
let header = concat!(
|
||||
"{\"protocol_version\":2,\"error\":null,",
|
||||
"\"surface_handle\":{\"mach_port_name\":91,\"surface_id\":7,\"width\":64,\"height\":48},",
|
||||
"\"current_surface_id\":7,",
|
||||
"\"frame\":{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",",
|
||||
"\"width\":64,\"height\":48,\"device_pixel_ratio\":1.0,",
|
||||
"\"css_viewport_width\":64,\"css_viewport_height\":48,",
|
||||
"\"rgba_byte_count\":0,\"pixels_changed\":true}}\n"
|
||||
);
|
||||
let mut input = Cursor::new(header.as_bytes());
|
||||
|
||||
let reply = read_reply(&mut input)?;
|
||||
let Some(frame) = reply.frame else {
|
||||
return Err(ServoLiveError::InvalidResponse { message: "test frame was missing" });
|
||||
};
|
||||
|
||||
assert_eq!(reply.current_surface_id, Some(7));
|
||||
assert_eq!(reply.surface_handle.map(|handle| handle.surface_id), Some(7));
|
||||
assert!(frame.into_rgba_bytes().is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn hardware_reply_rejects_mismatched_surface_identity() {
|
||||
let header = hardware_header(8, 64, 48);
|
||||
let mut input = Cursor::new(header.into_bytes());
|
||||
|
||||
assert!(matches!(
|
||||
read_reply(&mut input),
|
||||
Err(ServoLiveError::InvalidResponse {
|
||||
message: "surface_handle does not match current_surface_id"
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn hardware_reply_rejects_mismatched_surface_dimensions() {
|
||||
let header = hardware_header(7, 96, 72);
|
||||
let mut input = Cursor::new(header.into_bytes());
|
||||
|
||||
assert!(matches!(
|
||||
read_reply(&mut input),
|
||||
Err(ServoLiveError::InvalidResponse {
|
||||
message: "surface_handle dimensions do not match frame report"
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn hardware_header(current_surface_id: u64, handle_width: u32, handle_height: u32) -> String {
|
||||
format!(
|
||||
"{{\"protocol_version\":2,\"error\":null,\"surface_handle\":{{\"mach_port_name\":91,\"surface_id\":7,\"width\":{handle_width},\"height\":{handle_height}}},\"current_surface_id\":{current_surface_id},\"frame\":{{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",\"width\":64,\"height\":48,\"device_pixel_ratio\":1.0,\"css_viewport_width\":64,\"css_viewport_height\":48,\"rgba_byte_count\":0,\"pixels_changed\":true}}}}\n"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::sync::Arc;
|
||||
use std::{collections::TryReserveError, io, path::PathBuf};
|
||||
|
||||
use ely_domain::SitePermissionDecision;
|
||||
use ely_servo_host::{RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState};
|
||||
use gpui::NativeSurfaceHandle;
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::wire::LiveFrameReport;
|
||||
use crate::services::servo_sidecar_command::SidecarCommandError;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::services::iosurface_mach::IOSurfaceMachError;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::services::iosurface_metal::HardwareSurfaceBacking;
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
use core_video::pixel_buffer::CVPixelBuffer;
|
||||
|
||||
pub(crate) struct ServoLiveEnsureRequest {
|
||||
pub(crate) tab_id: String,
|
||||
pub(crate) profile_id: String,
|
||||
@@ -11,10 +23,8 @@ pub(crate) struct ServoLiveEnsureRequest {
|
||||
pub(crate) width: u32,
|
||||
pub(crate) height: u32,
|
||||
pub(crate) page_zoom_percent: u16,
|
||||
/// Display scale factor (1.0 standard, 2.0 Retina). Servo's
|
||||
/// WebView lays out CSS pixels = device pixels / hidpi factor.
|
||||
/// Display scale factor used to derive Servo's CSS viewport.
|
||||
pub(crate) device_pixel_ratio: f32,
|
||||
pub(crate) native_surface: Option<NativeSurfaceHandle>,
|
||||
pub(crate) scroll_delta_x: i32,
|
||||
pub(crate) scroll_delta_y: i32,
|
||||
pub(crate) scroll_point_x: Option<u32>,
|
||||
@@ -53,6 +63,7 @@ pub(crate) struct ServoLiveFrame {
|
||||
device_pixel_ratio: f32,
|
||||
css_viewport_width: u32,
|
||||
css_viewport_height: u32,
|
||||
pixels_changed: bool,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
@@ -60,62 +71,63 @@ pub(crate) struct ServoLiveFrame {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: u64,
|
||||
rgba_bytes: Option<Vec<u8>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface: Option<Arc<HardwareSurfaceBacking>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl ServoLiveFrame {
|
||||
pub(super) fn from_presented(
|
||||
snapshot: WebViewSnapshot,
|
||||
width: u32,
|
||||
height: u32,
|
||||
device_pixel_ratio: f32,
|
||||
) -> Self {
|
||||
let (css_viewport_width, css_viewport_height) =
|
||||
css_viewport_size(width, height, device_pixel_ratio);
|
||||
pub(super) fn from_parts(report: LiveFrameReport, rgba_bytes: Option<Vec<u8>>) -> Self {
|
||||
let (css_viewport_width, css_viewport_height) = css_viewport_size_from_report(&report);
|
||||
Self {
|
||||
loaded_url: snapshot.url().map(str::to_string),
|
||||
title: snapshot.title().map(str::to_string),
|
||||
render_state: render_state_label(snapshot.state()).to_string(),
|
||||
width,
|
||||
height,
|
||||
device_pixel_ratio,
|
||||
loaded_url: report.loaded_url,
|
||||
title: report.title,
|
||||
render_state: report.state,
|
||||
width: report.width,
|
||||
height: report.height,
|
||||
device_pixel_ratio: report.device_pixel_ratio,
|
||||
css_viewport_width,
|
||||
css_viewport_height,
|
||||
pixels_changed: report.pixels_changed,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: 1,
|
||||
non_white_pixel_count: report.non_white_pixel_count,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
content_pixel_count: 1,
|
||||
content_pixel_count: report.content_pixel_count,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: 0,
|
||||
rgba_bytes: None,
|
||||
sample_hash: report.sample_hash,
|
||||
rgba_bytes,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn from_rendered(
|
||||
snapshot: WebViewSnapshot,
|
||||
rendered_frame: RenderedFrame,
|
||||
device_pixel_ratio: f32,
|
||||
) -> Self {
|
||||
let width = rendered_frame.width();
|
||||
let height = rendered_frame.height();
|
||||
let (css_viewport_width, css_viewport_height) =
|
||||
css_viewport_size(width, height, device_pixel_ratio);
|
||||
Self {
|
||||
loaded_url: snapshot.url().map(str::to_string),
|
||||
title: snapshot.title().map(str::to_string),
|
||||
render_state: render_state_label(snapshot.state()).to_string(),
|
||||
width,
|
||||
height,
|
||||
device_pixel_ratio,
|
||||
css_viewport_width,
|
||||
css_viewport_height,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: rendered_frame.non_white_pixel_count(),
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
content_pixel_count: rendered_frame.content_pixel_count(),
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: rendered_frame.sample_hash(),
|
||||
rgba_bytes: Some(rendered_frame.rgba_bytes().to_vec()),
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn set_hardware_surface(
|
||||
&mut self,
|
||||
surface_id: u64,
|
||||
surface: Option<Arc<HardwareSurfaceBacking>>,
|
||||
) {
|
||||
self.hardware_surface_id = surface.as_ref().map(|_| surface_id);
|
||||
self.hardware_surface = surface;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[must_use]
|
||||
pub fn hardware_surface_id(&self) -> Option<u64> {
|
||||
self.hardware_surface_id
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[must_use]
|
||||
pub(crate) fn hardware_surface(&self) -> Option<&Arc<HardwareSurfaceBacking>> {
|
||||
self.hardware_surface.as_ref()
|
||||
}
|
||||
|
||||
pub(super) fn has_software_payload(&self) -> bool {
|
||||
self.rgba_bytes.is_some()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -158,6 +170,11 @@ impl ServoLiveFrame {
|
||||
self.css_viewport_height
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn pixels_changed(&self) -> bool {
|
||||
self.pixels_changed
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[must_use]
|
||||
pub fn non_white_pixel_count(&self) -> u64 {
|
||||
@@ -183,18 +200,29 @@ impl ServoLiveFrame {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_test(width: u32, height: u32, rgba_bytes: Vec<u8>) -> Self {
|
||||
Self::for_test_with_render_state(width, height, rgba_bytes, "complete")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_test_with_render_state(
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba_bytes: Vec<u8>,
|
||||
render_state: &str,
|
||||
) -> Self {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
let summary =
|
||||
ely_servo_host::RenderedFrameSummary::from_rgba_bytes(width, height, &rgba_bytes);
|
||||
Self {
|
||||
loaded_url: Some("https://example.com/".to_string()),
|
||||
title: Some("Example".to_string()),
|
||||
render_state: "complete".to_string(),
|
||||
render_state: render_state.to_string(),
|
||||
width,
|
||||
height,
|
||||
device_pixel_ratio: 1.0,
|
||||
css_viewport_width: width,
|
||||
css_viewport_height: height,
|
||||
pixels_changed: true,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: summary.non_white_pixel_count(),
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
@@ -202,49 +230,197 @@ impl ServoLiveFrame {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: summary.sample_hash(),
|
||||
rgba_bytes: Some(rgba_bytes),
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_state_label(state: &WebViewState) -> &'static str {
|
||||
match state {
|
||||
WebViewState::Created => "created",
|
||||
WebViewState::Loading => "loading",
|
||||
WebViewState::Complete => "complete",
|
||||
WebViewState::Sleeping => "sleeping",
|
||||
WebViewState::Crashed => "crashed",
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_test_with_title(
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba_bytes: Vec<u8>,
|
||||
render_state: &str,
|
||||
title: &str,
|
||||
) -> Self {
|
||||
let mut frame = Self::for_test_with_render_state(width, height, rgba_bytes, render_state);
|
||||
frame.title = Some(title.to_string());
|
||||
frame
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
pub(crate) fn for_test_with_pixel_buffer(
|
||||
width: u32,
|
||||
height: u32,
|
||||
surface_id: u64,
|
||||
pixel_buffer: CVPixelBuffer,
|
||||
) -> Self {
|
||||
Self::for_test_with_hardware_change(width, height, surface_id, pixel_buffer, true)
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
pub(crate) fn for_test_with_hardware_change(
|
||||
width: u32,
|
||||
height: u32,
|
||||
surface_id: u64,
|
||||
pixel_buffer: CVPixelBuffer,
|
||||
pixels_changed: bool,
|
||||
) -> Self {
|
||||
let mut frame = Self::for_test(width, height, Vec::new());
|
||||
frame.rgba_bytes = None;
|
||||
frame.hardware_surface = Some(HardwareSurfaceBacking::new(pixel_buffer));
|
||||
frame.hardware_surface_id = Some(surface_id);
|
||||
frame.pixels_changed = pixels_changed;
|
||||
frame
|
||||
}
|
||||
}
|
||||
|
||||
fn css_viewport_size(width: u32, height: u32, device_pixel_ratio: f32) -> (u32, u32) {
|
||||
let scale = if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 {
|
||||
device_pixel_ratio
|
||||
fn css_viewport_size_from_report(report: &LiveFrameReport) -> (u32, u32) {
|
||||
let scale = if report.device_pixel_ratio.is_finite() && report.device_pixel_ratio > 0.0 {
|
||||
report.device_pixel_ratio
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let fallback_width = ((report.width as f32) / scale).round().max(1.0) as u32;
|
||||
let fallback_height = ((report.height as f32) / scale).round().max(1.0) as u32;
|
||||
(
|
||||
((width as f32) / scale).round().max(1.0) as u32,
|
||||
((height as f32) / scale).round().max(1.0) as u32,
|
||||
if report.css_viewport_width > 0 { report.css_viewport_width } else { fallback_width },
|
||||
if report.css_viewport_height > 0 { report.css_viewport_height } else { fallback_height },
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum ServoLiveError {
|
||||
#[error("servo native surface is unavailable")]
|
||||
NativeSurfaceUnavailable,
|
||||
#[error("servo sidecar binary is unavailable at {path}")]
|
||||
SidecarBinaryUnavailable { path: PathBuf },
|
||||
|
||||
#[error("servo scroll input is missing a viewport point")]
|
||||
MissingScrollPoint,
|
||||
#[error("failed to run servo live sidecar: {0}")]
|
||||
Command(#[source] io::Error),
|
||||
|
||||
#[error("servo live sidecar pipe is unavailable: {name}")]
|
||||
PipeUnavailable { name: &'static str },
|
||||
|
||||
#[error("servo live sidecar exited")]
|
||||
SidecarExited,
|
||||
|
||||
#[error("servo live sidecar protocol mismatch: expected {expected}, received {actual:?}")]
|
||||
ProtocolVersionMismatch { expected: u32, actual: Option<u32> },
|
||||
|
||||
#[error("servo live sidecar {operation} timed out after {timeout_millis} ms")]
|
||||
RequestTimedOut { operation: &'static str, timeout_millis: u128 },
|
||||
|
||||
#[error("servo live sidecar failed: {message}")]
|
||||
SidecarFailed { message: String },
|
||||
|
||||
#[error("servo live sidecar response header exceeded {limit} bytes")]
|
||||
ResponseHeaderTooLarge { limit: usize },
|
||||
|
||||
#[error("failed to read servo live frame bytes: {0}")]
|
||||
FrameRead(#[source] io::Error),
|
||||
|
||||
#[error(
|
||||
"servo live frame dimensions {width}x{height} exceed the {max_dimension}px dimension limit"
|
||||
)]
|
||||
InvalidFrameDimensions { width: u32, height: u32, max_dimension: u32 },
|
||||
|
||||
#[error("servo live frame requires {bytes} bytes; the limit is {limit}")]
|
||||
FrameByteLimitExceeded { bytes: u64, limit: usize },
|
||||
|
||||
#[error(
|
||||
"servo live sidecar advertised {advertised} frame bytes for {width}x{height}; expected {expected}"
|
||||
)]
|
||||
InvalidFrameByteCount { advertised: usize, expected: u64, width: u32, height: u32 },
|
||||
|
||||
#[error("failed to reserve {bytes} bytes for a servo live frame: {source}")]
|
||||
FrameAllocation {
|
||||
bytes: usize,
|
||||
#[source]
|
||||
source: TryReserveError,
|
||||
},
|
||||
|
||||
#[error("invalid servo live sidecar response: {message}")]
|
||||
InvalidResponse { message: &'static str },
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error(
|
||||
"servo live IOSurface import failed for surface {surface_id:#x} mach port 0x{mach_port_name:x}: {message}"
|
||||
)]
|
||||
IOSurfaceImportFailed { surface_id: u64, mach_port_name: u32, message: String },
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error("servo live IOSurface backing failed for surface {surface_id:#x}: {message}")]
|
||||
IOSurfaceBackingFailed { surface_id: u64, message: String },
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error("failed to spawn servo live IOSurface importer: {0}")]
|
||||
IOSurfaceImportWorker(#[source] io::Error),
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error(transparent)]
|
||||
IOSurfaceMach(#[from] IOSurfaceMachError),
|
||||
|
||||
#[error(transparent)]
|
||||
Domain(#[from] ely_domain::DomainError),
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Host(#[from] ServoHostError),
|
||||
SidecarCommand(#[from] SidecarCommandError),
|
||||
}
|
||||
|
||||
impl ServoLiveError {
|
||||
pub(crate) fn is_runtime_unavailable(&self) -> bool {
|
||||
matches!(self, Self::Host(ServoHostError::RuntimeAlreadyStarted))
|
||||
match self {
|
||||
Self::SidecarExited
|
||||
| Self::ProtocolVersionMismatch { .. }
|
||||
| Self::RequestTimedOut { .. }
|
||||
| Self::ResponseHeaderTooLarge { .. }
|
||||
| Self::InvalidFrameDimensions { .. }
|
||||
| Self::FrameByteLimitExceeded { .. }
|
||||
| Self::InvalidFrameByteCount { .. }
|
||||
| Self::FrameAllocation { .. }
|
||||
| Self::InvalidResponse { .. }
|
||||
| Self::Json(_) => true,
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::IOSurfaceImportFailed { .. }
|
||||
| Self::IOSurfaceBackingFailed { .. }
|
||||
| Self::IOSurfaceImportWorker(_)
|
||||
| Self::IOSurfaceMach(_) => true,
|
||||
Self::Command(error) | Self::FrameRead(error) => matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::BrokenPipe
|
||||
| io::ErrorKind::ConnectionAborted
|
||||
| io::ErrorKind::ConnectionReset
|
||||
| io::ErrorKind::UnexpectedEof
|
||||
),
|
||||
Self::SidecarBinaryUnavailable { .. }
|
||||
| Self::PipeUnavailable { .. }
|
||||
| Self::SidecarFailed { .. }
|
||||
| Self::SidecarCommand(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn iosurface_transport_failures_invalidate_runtime() {
|
||||
let errors = [
|
||||
ServoLiveError::IOSurfaceImportFailed {
|
||||
surface_id: 7,
|
||||
mach_port_name: 11,
|
||||
message: "injected".to_string(),
|
||||
},
|
||||
ServoLiveError::IOSurfaceImportWorker(io::Error::other("injected")),
|
||||
ServoLiveError::IOSurfaceMach(IOSurfaceMachError::InvalidMessage),
|
||||
ServoLiveError::IOSurfaceBackingFailed {
|
||||
surface_id: 7,
|
||||
message: "injected".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
assert!(errors.iter().all(ServoLiveError::is_runtime_unavailable));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ServoLiveSitePermission;
|
||||
|
||||
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 2;
|
||||
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
|
||||
pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(super) enum LiveRequest {
|
||||
Handshake {
|
||||
protocol_version: u32,
|
||||
},
|
||||
Ensure {
|
||||
tab_id: String,
|
||||
profile_id: String,
|
||||
url: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
page_zoom_percent: u16,
|
||||
device_pixel_ratio: f32,
|
||||
scroll_delta_x: i32,
|
||||
scroll_delta_y: i32,
|
||||
scroll_point_x: Option<u32>,
|
||||
scroll_point_y: Option<u32>,
|
||||
click_x: Option<u32>,
|
||||
click_y: Option<u32>,
|
||||
hover_x: Option<u32>,
|
||||
hover_y: Option<u32>,
|
||||
typed_text: Option<String>,
|
||||
site_permissions: Vec<ServoLiveSitePermission>,
|
||||
ready_surface_ids: Vec<u64>,
|
||||
pending_surface_ids: Vec<u64>,
|
||||
},
|
||||
Poll {
|
||||
tab_id: String,
|
||||
ready_surface_ids: Vec<u64>,
|
||||
pending_surface_ids: Vec<u64>,
|
||||
},
|
||||
Close {
|
||||
tab_id: String,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LiveResponse {
|
||||
#[serde(default)]
|
||||
pub(super) protocol_version: Option<u32>,
|
||||
pub(super) error: Option<String>,
|
||||
pub(super) frame: Option<LiveFrameReport>,
|
||||
#[serde(default)]
|
||||
pub(super) surface_handle: Option<LiveSurfaceHandle>,
|
||||
#[serde(default)]
|
||||
pub(super) current_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
pub(super) struct LiveSurfaceHandle {
|
||||
pub(super) mach_port_name: u32,
|
||||
pub(super) surface_id: u64,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LiveFrameReport {
|
||||
pub(super) loaded_url: Option<String>,
|
||||
pub(super) title: Option<String>,
|
||||
pub(super) state: String,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
#[serde(default = "default_device_pixel_ratio")]
|
||||
pub(super) device_pixel_ratio: f32,
|
||||
#[serde(default)]
|
||||
pub(super) css_viewport_width: u32,
|
||||
#[serde(default)]
|
||||
pub(super) css_viewport_height: u32,
|
||||
pub(super) rgba_byte_count: usize,
|
||||
pub(super) pixels_changed: bool,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[serde(default)]
|
||||
pub(super) non_white_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[serde(default)]
|
||||
pub(super) content_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[serde(default)]
|
||||
pub(super) sample_hash: u64,
|
||||
}
|
||||
|
||||
fn default_device_pixel_ratio() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{LIVE_PROTOCOL_VERSION, LiveRequest};
|
||||
|
||||
#[test]
|
||||
fn handshake_request_serializes_protocol_version() -> Result<(), serde_json::Error> {
|
||||
let value = serde_json::to_value(LiveRequest::Handshake {
|
||||
protocol_version: LIVE_PROTOCOL_VERSION,
|
||||
})?;
|
||||
|
||||
assert_eq!(value, json!({"type": "handshake", "protocol_version": LIVE_PROTOCOL_VERSION}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_request_serializes_to_wire() -> Result<(), serde_json::Error> {
|
||||
let value =
|
||||
serde_json::to_value(LiveRequest::Close { tab_id: "tab-live-close".to_string() })?;
|
||||
|
||||
assert_eq!(value, json!({"type": "close", "tab_id": "tab-live-close"}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_serializes_hardware_surface_states() -> Result<(), serde_json::Error> {
|
||||
let value = serde_json::to_value(LiveRequest::Poll {
|
||||
tab_id: "tab-live".to_string(),
|
||||
ready_surface_ids: vec![7, 11],
|
||||
pending_surface_ids: vec![13],
|
||||
})?;
|
||||
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"type": "poll",
|
||||
"tab_id": "tab-live",
|
||||
"ready_surface_ids": [7, 11],
|
||||
"pending_surface_ids": [13]
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,66 @@
|
||||
use std::{
|
||||
env,
|
||||
fs::{File, OpenOptions},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, SystemTimeError, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use ely_domain::{ProfileId, ProfileKind};
|
||||
use ely_domain::ProfileId;
|
||||
|
||||
const ELY_QUALIFIER: &str = "com";
|
||||
const ELY_ORGANIZATION: &str = "elydora";
|
||||
const ELY_APPLICATION: &str = "ELY Browser";
|
||||
const DEFAULT_STANDARD_PROFILE_DIR: &str = "default";
|
||||
const TRANSIENT_PROFILE_ROOT: &str = "ely-browser-servo-profiles";
|
||||
const TRANSIENT_PROFILE_PREFIX: &str = "profile-";
|
||||
const TRANSIENT_PROFILE_LEASE: &str = ".lease";
|
||||
const TRANSIENT_ROOT_LOCK: &str = ".lock";
|
||||
|
||||
pub(crate) struct TransientProfileDataDir {
|
||||
directory: Option<tempfile::TempDir>,
|
||||
lease: Option<File>,
|
||||
root: PathBuf,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TransientProfileDataDir {
|
||||
pub(crate) fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub(crate) fn close(mut self) -> Result<(), io::Error> {
|
||||
self.remove()
|
||||
}
|
||||
|
||||
fn remove(&mut self) -> Result<(), io::Error> {
|
||||
let Some(directory) = self.directory.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let path = directory.keep();
|
||||
let root_lock = match open_lock_file(&self.root.join(TRANSIENT_ROOT_LOCK)) {
|
||||
Ok(root_lock) => root_lock,
|
||||
Err(error) => {
|
||||
self.lease.take();
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Err(error) = lock_file(&root_lock) {
|
||||
self.lease.take();
|
||||
return Err(error);
|
||||
}
|
||||
self.lease.take();
|
||||
match std::fs::remove_dir_all(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TransientProfileDataDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.remove();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn default_profile_data_root() -> Option<PathBuf> {
|
||||
ProjectDirs::from(ELY_QUALIFIER, ELY_ORGANIZATION, ELY_APPLICATION)
|
||||
@@ -21,27 +71,147 @@ pub(crate) fn profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId)
|
||||
profile_data_root.join(profile_id.as_str()).join("servo")
|
||||
}
|
||||
|
||||
pub(crate) fn sync_profile_data_dir(
|
||||
pub(crate) fn sync_profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId) -> PathBuf {
|
||||
profile_data_dir(profile_data_root, profile_id)
|
||||
}
|
||||
|
||||
pub(crate) fn create_profile_data_dir(
|
||||
profile_data_root: &Path,
|
||||
profile_id: &ProfileId,
|
||||
profile_name: &str,
|
||||
profile_kind: &ProfileKind,
|
||||
) -> PathBuf {
|
||||
if profile_name == "Default" && matches!(profile_kind, ProfileKind::Standard) {
|
||||
return profile_data_root.join(DEFAULT_STANDARD_PROFILE_DIR).join("servo");
|
||||
) -> Result<PathBuf, io::Error> {
|
||||
let profile_dir = profile_data_root.join(profile_id.as_str());
|
||||
let servo_dir = profile_dir.join("servo");
|
||||
std::fs::create_dir_all(&servo_dir)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&profile_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
std::fs::set_permissions(&servo_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
profile_data_dir(profile_data_root, profile_id)
|
||||
Ok(servo_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn cleanup_stale_transient_profile_data_dirs() -> Result<(), io::Error> {
|
||||
cleanup_stale_transient_profile_data_dirs_at(&transient_profile_data_root()?)
|
||||
}
|
||||
|
||||
pub(crate) fn transient_profile_data_dir(
|
||||
profile_id: &ProfileId,
|
||||
) -> Result<PathBuf, SystemTimeError> {
|
||||
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
|
||||
Ok(env::temp_dir().join("ely-browser-servo-profiles").join(format!(
|
||||
"{}-{}-{timestamp}",
|
||||
std::process::id(),
|
||||
profile_id.as_str()
|
||||
)))
|
||||
) -> Result<TransientProfileDataDir, io::Error> {
|
||||
let root = transient_profile_data_root()?;
|
||||
create_private_directory(&root)?;
|
||||
let root_lock = open_lock_file(&root.join(TRANSIENT_ROOT_LOCK))?;
|
||||
lock_file(&root_lock)?;
|
||||
cleanup_stale_transient_profile_data_dirs_locked(&root, |path| std::fs::remove_dir_all(path))?;
|
||||
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix(&format!("{TRANSIENT_PROFILE_PREFIX}{}-", profile_id.as_str()))
|
||||
.tempdir_in(&root)?;
|
||||
create_private_directory(directory.path())?;
|
||||
let lease = open_lock_file(&directory.path().join(TRANSIENT_PROFILE_LEASE))?;
|
||||
lock_file(&lease)?;
|
||||
let path = directory.path().to_path_buf();
|
||||
drop(root_lock);
|
||||
Ok(TransientProfileDataDir { directory: Some(directory), lease: Some(lease), root, path })
|
||||
}
|
||||
|
||||
fn transient_profile_data_root() -> Result<PathBuf, io::Error> {
|
||||
let project_dirs = ProjectDirs::from(ELY_QUALIFIER, ELY_ORGANIZATION, ELY_APPLICATION)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "ELY project directory unavailable")
|
||||
})?;
|
||||
let base = project_dirs.runtime_dir().unwrap_or_else(|| project_dirs.cache_dir());
|
||||
Ok(base.join(TRANSIENT_PROFILE_ROOT))
|
||||
}
|
||||
|
||||
fn cleanup_stale_transient_profile_data_dirs_at(root: &Path) -> Result<(), io::Error> {
|
||||
create_private_directory(root)?;
|
||||
let root_lock = open_lock_file(&root.join(TRANSIENT_ROOT_LOCK))?;
|
||||
lock_file(&root_lock)?;
|
||||
cleanup_stale_transient_profile_data_dirs_locked(root, |path| std::fs::remove_dir_all(path))
|
||||
}
|
||||
|
||||
fn cleanup_stale_transient_profile_data_dirs_locked(
|
||||
root: &Path,
|
||||
remove_dir: impl Fn(&Path) -> Result<(), io::Error>,
|
||||
) -> Result<(), io::Error> {
|
||||
for entry in std::fs::read_dir(root)? {
|
||||
let entry = entry?;
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(file_type) => file_type,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if !file_type.is_dir()
|
||||
|| !entry.file_name().to_string_lossy().starts_with(TRANSIENT_PROFILE_PREFIX)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let lease = match open_lock_file(&entry.path().join(TRANSIENT_PROFILE_LEASE)) {
|
||||
Ok(lease) => lease,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
remove_directory_if_present(&remove_dir, &entry.path())?;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if try_lock_file(&lease)? {
|
||||
remove_directory_if_present(&remove_dir, &entry.path())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_directory_if_present(
|
||||
remove_dir: &impl Fn(&Path) -> Result<(), io::Error>,
|
||||
path: &Path,
|
||||
) -> Result<(), io::Error> {
|
||||
match remove_dir(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_lock_file(path: &Path) -> Result<File, io::Error> {
|
||||
OpenOptions::new().create(true).read(true).write(true).truncate(false).open(path)
|
||||
}
|
||||
|
||||
fn lock_file(file: &File) -> Result<(), io::Error> {
|
||||
file.lock()
|
||||
}
|
||||
|
||||
fn try_lock_file(file: &File) -> Result<bool, io::Error> {
|
||||
match file.try_lock() {
|
||||
Ok(()) => Ok(true),
|
||||
Err(std::fs::TryLockError::WouldBlock) => Ok(false),
|
||||
Err(std::fs::TryLockError::Error(error)) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_private_directory(path: &Path) -> Result<(), io::Error> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
match std::fs::create_dir(path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||
let metadata = std::fs::symlink_metadata(path)?;
|
||||
if !metadata.file_type().is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("private directory path is not a directory: {}", path.display()),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
@@ -52,19 +222,46 @@ pub(crate) enum ProfileDataMode {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{profile_data_dir, sync_profile_data_dir};
|
||||
use ely_domain::{ProfileId, ProfileKind};
|
||||
use std::{cell::Cell, io};
|
||||
|
||||
use super::{
|
||||
TRANSIENT_PROFILE_LEASE, TRANSIENT_PROFILE_PREFIX,
|
||||
cleanup_stale_transient_profile_data_dirs_at,
|
||||
cleanup_stale_transient_profile_data_dirs_locked, create_profile_data_dir, open_lock_file,
|
||||
profile_data_dir, sync_profile_data_dir, transient_profile_data_dir,
|
||||
transient_profile_data_root,
|
||||
};
|
||||
use ely_domain::ProfileId;
|
||||
|
||||
#[test]
|
||||
fn default_standard_sync_profile_dir_is_stable() {
|
||||
fn default_standard_sync_profile_dir_keeps_profile_identity() {
|
||||
let root = std::path::Path::new("/profiles");
|
||||
let first_id = ProfileId::new();
|
||||
let second_id = ProfileId::new();
|
||||
|
||||
assert_eq!(
|
||||
sync_profile_data_dir(root, &first_id, "Default", &ProfileKind::Standard),
|
||||
sync_profile_data_dir(root, &second_id, "Default", &ProfileKind::Standard)
|
||||
);
|
||||
assert_eq!(sync_profile_data_dir(root, &first_id), profile_data_dir(root, &first_id));
|
||||
assert_eq!(sync_profile_data_dir(root, &second_id), profile_data_dir(root, &second_id));
|
||||
assert_ne!(profile_data_dir(root, &first_id), profile_data_dir(root, &second_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_profile_directory_is_private() -> Result<(), std::io::Error> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let servo_dir = create_profile_data_dir(directory.path(), &profile_id)?;
|
||||
|
||||
assert_eq!(servo_dir, profile_data_dir(directory.path(), &profile_id));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(directory.path().join(profile_id.as_str()))?.permissions().mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(std::fs::metadata(servo_dir)?.permissions().mode() & 0o777, 0o700);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -72,9 +269,124 @@ mod tests {
|
||||
let root = std::path::Path::new("/profiles");
|
||||
let profile_id = ProfileId::new();
|
||||
|
||||
assert_eq!(sync_profile_data_dir(root, &profile_id), profile_data_dir(root, &profile_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_profile_directory_is_private_and_removed_on_close() -> Result<(), io::Error> {
|
||||
let directory = transient_profile_data_dir(&ProfileId::new())?;
|
||||
let path = directory.path().to_path_buf();
|
||||
assert!(path.is_dir());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(std::fs::metadata(&path)?.permissions().mode() & 0o777, 0o700);
|
||||
}
|
||||
|
||||
directory.close()?;
|
||||
|
||||
assert!(!path.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_profile_root_uses_the_user_project_directory() -> Result<(), io::Error> {
|
||||
let project_dirs = directories::ProjectDirs::from(
|
||||
super::ELY_QUALIFIER,
|
||||
super::ELY_ORGANIZATION,
|
||||
super::ELY_APPLICATION,
|
||||
)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "project directory unavailable"))?;
|
||||
let expected_base = project_dirs.runtime_dir().unwrap_or_else(|| project_dirs.cache_dir());
|
||||
|
||||
assert_eq!(
|
||||
sync_profile_data_dir(root, &profile_id, "Personal", &ProfileKind::Standard),
|
||||
profile_data_dir(root, &profile_id)
|
||||
transient_profile_data_root()?,
|
||||
expected_base.join(super::TRANSIENT_PROFILE_ROOT)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_cleanup_preserves_leased_profile_and_removes_stale_profile() -> Result<(), io::Error>
|
||||
{
|
||||
let root = tempfile::tempdir()?;
|
||||
let active = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}active"));
|
||||
let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale"));
|
||||
std::fs::create_dir_all(&active)?;
|
||||
std::fs::create_dir_all(&stale)?;
|
||||
let active_lease = open_lock_file(&active.join(TRANSIENT_PROFILE_LEASE))?;
|
||||
super::lock_file(&active_lease)?;
|
||||
open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?;
|
||||
|
||||
cleanup_stale_transient_profile_data_dirs_at(root.path())?;
|
||||
|
||||
assert!(active.is_dir());
|
||||
assert!(!stale.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_failure_keeps_stale_profile_for_retry() -> Result<(), io::Error> {
|
||||
let root = tempfile::tempdir()?;
|
||||
let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale"));
|
||||
std::fs::create_dir_all(&stale)?;
|
||||
open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?;
|
||||
let attempts = Cell::new(0_u8);
|
||||
|
||||
let result = cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| {
|
||||
attempts.set(attempts.get() + 1);
|
||||
if attempts.get() == 1 {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected failure"));
|
||||
}
|
||||
std::fs::remove_dir_all(path)
|
||||
});
|
||||
let Err(error) = result else {
|
||||
return Err(io::Error::other("the first cleanup attempt succeeded"));
|
||||
};
|
||||
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert!(stale.is_dir());
|
||||
cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| {
|
||||
std::fs::remove_dir_all(path)
|
||||
})?;
|
||||
assert!(!stale.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_accepts_a_profile_removed_concurrently() -> Result<(), io::Error> {
|
||||
let root = tempfile::tempdir()?;
|
||||
let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale"));
|
||||
std::fs::create_dir(&stale)?;
|
||||
open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?;
|
||||
|
||||
cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| {
|
||||
std::fs::remove_dir_all(path)?;
|
||||
Err(io::Error::new(io::ErrorKind::NotFound, "removed by cleanup worker"))
|
||||
})?;
|
||||
|
||||
assert!(!stale.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn cleanup_rejects_a_symlinked_transient_root() -> Result<(), io::Error> {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let parent = tempfile::tempdir()?;
|
||||
let victim = tempfile::tempdir()?;
|
||||
let stale = victim.path().join(format!("{TRANSIENT_PROFILE_PREFIX}victim"));
|
||||
std::fs::create_dir(&stale)?;
|
||||
let root = parent.path().join("transient-root");
|
||||
symlink(victim.path(), &root)?;
|
||||
|
||||
let Err(error) = cleanup_stale_transient_profile_data_dirs_at(&root) else {
|
||||
return Err(io::Error::other("symlinked transient root was accepted"));
|
||||
};
|
||||
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
||||
assert!(stale.is_dir());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
use std::{
|
||||
env, io,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
const SIDECAR_PATH_ENV: &str = "ELY_SERVO_SIDECAR";
|
||||
const RENDERING_CONTEXT_ENV: &str = "ELY_SERVO_RENDERING_CONTEXT";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct SidecarCommandTarget(PathBuf);
|
||||
|
||||
impl SidecarCommandTarget {
|
||||
pub(super) fn command(&self) -> Command {
|
||||
Command::new(&self.0)
|
||||
}
|
||||
|
||||
pub(super) fn missing_binary_path(&self) -> Option<&Path> {
|
||||
(!self.0.is_file()).then_some(self.0.as_path())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum SidecarRenderingContext {
|
||||
Software,
|
||||
Hardware,
|
||||
}
|
||||
|
||||
impl SidecarRenderingContext {
|
||||
pub(super) fn cli_arg(self) -> &'static str {
|
||||
match self {
|
||||
Self::Software => "software",
|
||||
Self::Hardware => "hardware",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum SidecarCommandError {
|
||||
#[error("current executable path is unavailable: {0}")]
|
||||
CurrentExecutable(#[source] io::Error),
|
||||
|
||||
#[error("current executable directory is unavailable for {path}")]
|
||||
CurrentExecutableDirectoryUnavailable { path: PathBuf },
|
||||
}
|
||||
|
||||
pub(super) fn default_sidecar_command() -> Result<SidecarCommandTarget, SidecarCommandError> {
|
||||
if let Some(path) = env::var_os(SIDECAR_PATH_ENV) {
|
||||
return Ok(SidecarCommandTarget(PathBuf::from(path)));
|
||||
}
|
||||
|
||||
let current_exe = env::current_exe().map_err(SidecarCommandError::CurrentExecutable)?;
|
||||
let exe_dir = current_exe.parent().ok_or_else(|| {
|
||||
SidecarCommandError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() }
|
||||
})?;
|
||||
let adjacent_sidecar = exe_dir.join(sidecar_binary_name());
|
||||
let workspace_manifest = workspace_manifest_path();
|
||||
if adjacent_sidecar.is_file() || is_macos_app_bundle_exe_dir(exe_dir) {
|
||||
return Ok(SidecarCommandTarget(adjacent_sidecar));
|
||||
}
|
||||
|
||||
if let Some(target_sidecar) =
|
||||
workspace_manifest.as_deref().and_then(workspace_target_sidecar_path)
|
||||
{
|
||||
return Ok(SidecarCommandTarget(target_sidecar));
|
||||
}
|
||||
|
||||
Ok(SidecarCommandTarget(adjacent_sidecar))
|
||||
}
|
||||
|
||||
pub(super) fn rendering_context_from_env() -> SidecarRenderingContext {
|
||||
let raw = env::var(RENDERING_CONTEXT_ENV).ok();
|
||||
rendering_context_selection(raw.as_deref())
|
||||
}
|
||||
|
||||
fn rendering_context_selection(raw: Option<&str>) -> SidecarRenderingContext {
|
||||
match raw.map(str::to_lowercase).as_deref() {
|
||||
Some("software") => SidecarRenderingContext::Software,
|
||||
Some("hardware") => SidecarRenderingContext::Hardware,
|
||||
_ if cfg!(target_os = "macos") => SidecarRenderingContext::Hardware,
|
||||
_ => SidecarRenderingContext::Software,
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_manifest_path() -> Option<PathBuf> {
|
||||
option_env!("ELY_WORKSPACE_MANIFEST").filter(|path| !path.is_empty()).map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn workspace_target_sidecar_path(manifest_path: &Path) -> Option<PathBuf> {
|
||||
let profile = if cfg!(debug_assertions) { "debug" } else { "release" };
|
||||
Some(manifest_path.parent()?.join("target").join(profile).join(sidecar_binary_name()))
|
||||
}
|
||||
|
||||
fn sidecar_binary_name() -> String {
|
||||
format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX)
|
||||
}
|
||||
|
||||
fn is_macos_app_bundle_exe_dir(path: &Path) -> bool {
|
||||
path.file_name().is_some_and(|name| name == "MacOS")
|
||||
&& path
|
||||
.parent()
|
||||
.is_some_and(|contents| contents.file_name().is_some_and(|name| name == "Contents"))
|
||||
&& path
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.is_some_and(|bundle| bundle.extension().is_some_and(|extension| extension == "app"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
SidecarRenderingContext, is_macos_app_bundle_exe_dir, rendering_context_selection,
|
||||
sidecar_binary_name, workspace_target_sidecar_path,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn workspace_sidecar_path_uses_current_build_profile() {
|
||||
let manifest = std::path::Path::new("/workspace/Cargo.toml");
|
||||
let profile = if cfg!(debug_assertions) { "debug" } else { "release" };
|
||||
|
||||
assert_eq!(
|
||||
workspace_target_sidecar_path(manifest),
|
||||
Some(
|
||||
std::path::Path::new("/workspace/target").join(profile).join(sidecar_binary_name())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_context_follows_explicit_environment_selection() {
|
||||
let hardware = rendering_context_selection(Some("HARDWARE"));
|
||||
let software = rendering_context_selection(Some("software"));
|
||||
|
||||
assert_eq!(hardware, SidecarRenderingContext::Hardware);
|
||||
assert_eq!(software, SidecarRenderingContext::Software);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_macos_app_bundle_executable_directory() {
|
||||
assert!(is_macos_app_bundle_exe_dir(std::path::Path::new(
|
||||
"/tmp/ELY Browser.app/Contents/MacOS"
|
||||
)));
|
||||
assert!(!is_macos_app_bundle_exe_dir(std::path::Path::new("/tmp/target/debug")));
|
||||
}
|
||||
}
|
||||
@@ -104,12 +104,7 @@ impl ElyShell {
|
||||
};
|
||||
return;
|
||||
};
|
||||
let profile_dir = sync_profile_data_dir(
|
||||
&profile_root,
|
||||
&active_profile.id,
|
||||
&active_profile.name,
|
||||
&active_profile.kind,
|
||||
);
|
||||
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile.id);
|
||||
self.auth_flow_phase = AuthFlowPhase::Verifying { email: email.clone() };
|
||||
let tx = self.sync_inbox_tx.clone();
|
||||
spawn_verify_otp(email, normalized_otp, profile_dir, tx);
|
||||
@@ -127,12 +122,7 @@ impl ElyShell {
|
||||
let Some(profile_root) = default_profile_data_root() else {
|
||||
return;
|
||||
};
|
||||
let profile_dir = sync_profile_data_dir(
|
||||
&profile_root,
|
||||
&active_profile.id,
|
||||
&active_profile.name,
|
||||
&active_profile.kind,
|
||||
);
|
||||
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile.id);
|
||||
match SyncEngine::for_profile_dir(&profile_dir, "ELY", sync_platform_label()) {
|
||||
Ok(mut engine) => {
|
||||
let _ = engine.install_bearer("");
|
||||
|
||||
@@ -271,6 +271,6 @@ fn identical_live_frames_share_render_image_arc() -> Result<(), String> {
|
||||
Arc::as_ptr(first_image),
|
||||
Arc::as_ptr(second_image),
|
||||
);
|
||||
assert!(first.has_same_software_render_as(&second));
|
||||
assert!(first.has_same_render_as(&second));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ fn render_about_rows(snapshot: &BrowserSnapshot) -> AnyElement {
|
||||
format!("scheme: {DEEP_LINK_SCHEME}; auth: {AUTH_CALLBACK_URL}"),
|
||||
))
|
||||
.child(about_row(IconName::Info, "Version", APP_VERSION, "Cargo package version"))
|
||||
.child(about_row(IconName::GitHub, "Build", BUILD_REVISION, "Git revision"))
|
||||
.child(about_row(IconName::GitHub, "Build", BUILD_REVISION, "Build source"))
|
||||
.child(about_row(
|
||||
IconName::Frame,
|
||||
"GPUI",
|
||||
|
||||
@@ -257,7 +257,7 @@ fn render_compatibility_rows(
|
||||
format!("gpui {GPUI_VERSION}"),
|
||||
"Native shell renderer",
|
||||
))
|
||||
.child(compatibility_row(IconName::GitHub, "Build", BUILD_REVISION, "Git revision"))
|
||||
.child(compatibility_row(IconName::GitHub, "Build", BUILD_REVISION, "Build source"))
|
||||
.child(compatibility_row(
|
||||
IconName::CircleCheck,
|
||||
"Site Permissions",
|
||||
|
||||
@@ -121,15 +121,28 @@ pub struct ElyShell {
|
||||
|
||||
impl ElyShell {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self::new_with_config(InitialBrowserConfig::ely_defaults(), window, cx)
|
||||
let config = InitialBrowserConfig::ely_defaults()
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|mut config| {
|
||||
config.profile_id = Some(
|
||||
crate::services::profile_identity::default_standard_profile_id()
|
||||
.map_err(|error| error.to_string())?,
|
||||
);
|
||||
Ok(config)
|
||||
});
|
||||
Self::new_with_config(config, window, cx)
|
||||
}
|
||||
|
||||
pub fn new_private(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self::new_with_config(InitialBrowserConfig::private_window(), window, cx)
|
||||
Self::new_with_config(
|
||||
InitialBrowserConfig::private_window().map_err(|error| error.to_string()),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
fn new_with_config(
|
||||
config: Result<InitialBrowserConfig, ely_domain::DomainError>,
|
||||
config: Result<InitialBrowserConfig, String>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -192,14 +205,11 @@ impl ElyShell {
|
||||
},
|
||||
);
|
||||
|
||||
let state = match config.and_then(|config| {
|
||||
BrowserCore::new(config).map_err(|error| match error {
|
||||
ely_browser_core::CoreError::Domain(source) => source,
|
||||
_ => ely_domain::DomainError::InvalidCommand,
|
||||
})
|
||||
}) {
|
||||
let state = match config
|
||||
.and_then(|config| BrowserCore::new(config).map_err(|error| error.to_string()))
|
||||
{
|
||||
Ok(core) => ShellState::Ready(Box::new(core)),
|
||||
Err(error) => ShellState::StartupError(error.to_string()),
|
||||
Err(error) => ShellState::StartupError(error),
|
||||
};
|
||||
|
||||
let (sync_inbox_tx, sync_inbox_rx) = std::sync::mpsc::channel();
|
||||
|
||||
@@ -269,19 +269,12 @@ impl ElyShell {
|
||||
return;
|
||||
};
|
||||
let active_profile_id = snapshot.active_profile_id.clone();
|
||||
let active_profile_name = snapshot.active_profile_name.clone();
|
||||
let active_profile_kind = snapshot.active_profile_kind.clone();
|
||||
let device_name = format!("ELY · {}", snapshot.active_profile_name);
|
||||
let Some(profile_root) = default_profile_data_root() else {
|
||||
tracing::warn!(target: "ely::sync", "profile data root is unavailable");
|
||||
return;
|
||||
};
|
||||
let profile_dir = sync_profile_data_dir(
|
||||
&profile_root,
|
||||
&active_profile_id,
|
||||
&active_profile_name,
|
||||
&active_profile_kind,
|
||||
);
|
||||
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile_id);
|
||||
let bytes = match core.build_sync_snapshot_bytes() {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
|
||||
@@ -116,8 +116,6 @@ impl ElyShell {
|
||||
let profile_dir = crate::services::servo_profile_data::sync_profile_data_dir(
|
||||
&profile_root,
|
||||
&snapshot.active_profile_id,
|
||||
&snapshot.active_profile_name,
|
||||
&snapshot.active_profile_kind,
|
||||
);
|
||||
if snapshot.active_profile_name == "Default"
|
||||
&& matches!(snapshot.active_profile_kind, ProfileKind::Standard)
|
||||
@@ -237,27 +235,18 @@ fn migrate_legacy_default_sync_dir(profile_root: &Path, stable_profile_dir: &Pat
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(profile_root) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let candidate = entry.path().join("servo").join("sync");
|
||||
if candidate == stable_sync_dir {
|
||||
continue;
|
||||
}
|
||||
if !bearer_token_file_present(&candidate.join("bearer.token")) {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = copy_dir_recursive(&candidate, &stable_sync_dir) {
|
||||
tracing::warn!(
|
||||
target: "ely::sync",
|
||||
error = %error,
|
||||
source = %candidate.display(),
|
||||
"legacy sync profile migration failed",
|
||||
);
|
||||
}
|
||||
let candidate = profile_root.join("default").join("servo").join("sync");
|
||||
if !bearer_token_file_present(&candidate.join("bearer.token")) {
|
||||
return;
|
||||
}
|
||||
if let Err(error) = copy_dir_recursive(&candidate, &stable_sync_dir) {
|
||||
tracing::warn!(
|
||||
target: "ely::sync",
|
||||
error = %error,
|
||||
source = %candidate.display(),
|
||||
"legacy sync profile migration failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()> {
|
||||
@@ -277,7 +266,7 @@ fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()>
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::bearer_token_file_present;
|
||||
use super::{bearer_token_file_present, migrate_legacy_default_sync_dir};
|
||||
|
||||
#[test]
|
||||
fn bearer_token_file_presence_requires_bytes() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -297,4 +286,33 @@ mod tests {
|
||||
std::fs::remove_dir_all(dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_default_sync_directory_is_migrated() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let legacy = directory.path().join("default/servo/sync");
|
||||
let stable = directory.path().join("profile_stable/servo");
|
||||
std::fs::create_dir_all(&legacy)?;
|
||||
std::fs::write(legacy.join("bearer.token"), "default-token")?;
|
||||
|
||||
migrate_legacy_default_sync_dir(directory.path(), &stable);
|
||||
|
||||
assert_eq!(std::fs::read_to_string(stable.join("sync/bearer.token"))?, "default-token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_profile_bearer_is_ignored_during_default_migration()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let custom = directory.path().join("profile_custom/servo/sync");
|
||||
let stable = directory.path().join("profile_stable/servo");
|
||||
std::fs::create_dir_all(&custom)?;
|
||||
std::fs::write(custom.join("bearer.token"), "custom-token")?;
|
||||
|
||||
migrate_legacy_default_sync_dir(directory.path(), &stable);
|
||||
|
||||
assert!(!stable.join("sync/bearer.token").exists());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,23 @@ impl WebSurfaceStore {
|
||||
Self { runtime, surfaces: BTreeMap::new(), keyboard_focus: None }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn state(&self, tab_id: &TabId) -> Option<&WebSurfaceState> {
|
||||
self.surfaces.get(tab_id).and_then(|surface| surface.state.as_ref())
|
||||
}
|
||||
|
||||
pub(super) fn state_for_scope(
|
||||
&self,
|
||||
tab_id: &TabId,
|
||||
profile_id: &ely_domain::ProfileId,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
) -> Option<&WebSurfaceState> {
|
||||
self.surfaces
|
||||
.get(tab_id)
|
||||
.filter(|surface| surface.has_scope(profile_id, profile_data_mode))
|
||||
.and_then(|surface| surface.state.as_ref())
|
||||
}
|
||||
|
||||
pub(super) fn ensure_surface(
|
||||
&mut self,
|
||||
tab: &BrowserTab,
|
||||
@@ -53,23 +66,28 @@ impl WebSurfaceStore {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let native_surface =
|
||||
self.surfaces.get(tab.id()).and_then(|surface| surface.native_surface.clone());
|
||||
#[cfg(not(test))]
|
||||
let Some(native_surface) = native_surface else {
|
||||
return false;
|
||||
};
|
||||
let ensure_key = WebSurfaceEnsureKey::new(
|
||||
requested_url.clone(),
|
||||
size,
|
||||
#[cfg(test)]
|
||||
native_surface.as_ref(),
|
||||
#[cfg(not(test))]
|
||||
Some(&native_surface),
|
||||
tab.profile_id().clone(),
|
||||
profile_data_mode,
|
||||
tab.zoom_percent(),
|
||||
permissions,
|
||||
);
|
||||
if self.surfaces.get(tab.id()).is_some_and(|surface| !surface.should_ensure(&ensure_key)) {
|
||||
let scope_changed = self.surface_mut(tab.id()).reset_for_scope_change(&ensure_key);
|
||||
if scope_changed
|
||||
&& self.keyboard_focus.as_ref().is_some_and(|focus| focus.tab_id == *tab.id())
|
||||
{
|
||||
self.keyboard_focus = None;
|
||||
}
|
||||
let runtime_has_session =
|
||||
self.runtime.has_session(tab.id(), tab.profile_id(), profile_data_mode);
|
||||
if runtime_has_session
|
||||
&& self
|
||||
.surfaces
|
||||
.get(tab.id())
|
||||
.is_some_and(|surface| !surface.should_ensure(&ensure_key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Once the page has ensured at least once, defer further ensures
|
||||
@@ -89,31 +107,23 @@ impl WebSurfaceStore {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let input = self.take_pending_input(tab.id(), requested_url.as_str());
|
||||
let previous_frame =
|
||||
self.previous_ready_frame(tab.id(), requested_url.as_str(), tab.zoom_percent());
|
||||
if let Err(message) =
|
||||
self.runtime.prepare_tab_scope(tab.id(), tab.profile_id(), profile_data_mode)
|
||||
{
|
||||
return self.record_ensure_failure(
|
||||
tab.id(),
|
||||
ensure_key,
|
||||
requested_url,
|
||||
previous_frame,
|
||||
message,
|
||||
);
|
||||
}
|
||||
let input = self.take_pending_input(tab.id(), requested_url.as_str());
|
||||
|
||||
#[cfg(test)]
|
||||
let ensure_result = match native_surface {
|
||||
Some(native_surface) => self.runtime.ensure_tab_with_native_surface(
|
||||
tab,
|
||||
size,
|
||||
native_surface,
|
||||
profile_data_mode,
|
||||
permissions,
|
||||
input,
|
||||
),
|
||||
None => self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input),
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
let ensure_result = self.runtime.ensure_tab_with_native_surface(
|
||||
tab,
|
||||
size,
|
||||
native_surface,
|
||||
profile_data_mode,
|
||||
permissions,
|
||||
input,
|
||||
);
|
||||
let ensure_result =
|
||||
self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input);
|
||||
|
||||
match ensure_result {
|
||||
Ok(result) => {
|
||||
@@ -127,9 +137,35 @@ impl WebSurfaceStore {
|
||||
}
|
||||
false
|
||||
}
|
||||
Err(message) => {
|
||||
self.surface_mut(tab.id()).mark_ensured(ensure_key);
|
||||
self.surface_mut(tab.id()).state = Some(WebSurfaceState::Failed { message });
|
||||
Err(message) => self.record_ensure_failure(
|
||||
tab.id(),
|
||||
ensure_key,
|
||||
requested_url,
|
||||
previous_frame,
|
||||
message,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_ensure_failure(
|
||||
&mut self,
|
||||
tab_id: &TabId,
|
||||
ensure_key: WebSurfaceEnsureKey,
|
||||
requested_url: String,
|
||||
previous_frame: Option<WebSurfaceFrame>,
|
||||
message: String,
|
||||
) -> bool {
|
||||
self.surface_mut(tab_id).mark_ensured(ensure_key);
|
||||
match previous_frame {
|
||||
Some(previous_frame) => {
|
||||
self.surface_mut(tab_id).state = Some(WebSurfaceState::Loading {
|
||||
requested_url,
|
||||
previous_frame: Some(previous_frame),
|
||||
});
|
||||
false
|
||||
}
|
||||
None => {
|
||||
self.surface_mut(tab_id).state = Some(WebSurfaceState::Failed { message });
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -137,6 +173,13 @@ impl WebSurfaceStore {
|
||||
|
||||
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> WebSurfaceTickResult {
|
||||
let frames = self.runtime.tick(visible_tab_ids);
|
||||
self.apply_runtime_frames(frames)
|
||||
}
|
||||
|
||||
fn apply_runtime_frames(
|
||||
&mut self,
|
||||
frames: Vec<WebSurfaceRuntimeFrame>,
|
||||
) -> WebSurfaceTickResult {
|
||||
let mut result = WebSurfaceTickResult::default();
|
||||
|
||||
for frame in frames {
|
||||
@@ -146,29 +189,12 @@ impl WebSurfaceStore {
|
||||
self.surfaces.get(&tab_id).and_then(|surface| surface.state.as_ref()),
|
||||
Some(WebSurfaceState::Ready(_))
|
||||
);
|
||||
match self.initial_display_gate_message(&tab_id, &frame, had_ready) {
|
||||
Ok(()) => {}
|
||||
Err(message) => {
|
||||
if !had_ready {
|
||||
self.surface_mut(&tab_id).state =
|
||||
Some(WebSurfaceState::Failed { message });
|
||||
result.changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if self.should_hold_initial_frame(&tab_id, &frame, had_ready) {
|
||||
if !had_ready {
|
||||
self.surface_mut(&tab_id).state = Some(WebSurfaceState::Loading {
|
||||
requested_url: frame.requested_url.clone(),
|
||||
previous_frame: None,
|
||||
});
|
||||
result.changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let metadata = self.surface_mut(&tab_id).changed_page_metadata(&tab_id, &frame);
|
||||
result.page_metadata.extend(metadata);
|
||||
result.url_changes.extend(url_change);
|
||||
if self.should_hold_initial_frame(&tab_id, &frame, had_ready) {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.surfaces
|
||||
.get(&tab_id)
|
||||
@@ -177,7 +203,6 @@ impl WebSurfaceStore {
|
||||
self.surface_mut(&tab_id).state = Some(WebSurfaceState::Ready(*frame));
|
||||
result.changed = true;
|
||||
}
|
||||
result.url_changes.extend(url_change);
|
||||
}
|
||||
WebSurfaceRuntimeFrame::Failed { tab_id, message } => {
|
||||
let had_ready = matches!(
|
||||
@@ -303,32 +328,11 @@ impl WebSurfaceStore {
|
||||
frame: &WebSurfaceFrame,
|
||||
has_previous_frame: bool,
|
||||
) -> bool {
|
||||
!has_previous_frame
|
||||
frame.render_state() != "complete"
|
||||
&& !has_previous_frame
|
||||
&& self
|
||||
.previous_ready_frame(tab_id, frame.requested_url.as_str(), frame.zoom_percent())
|
||||
.is_none()
|
||||
&& matches!(frame.has_visible_content_for_initial_display(), Ok(false))
|
||||
}
|
||||
|
||||
fn initial_display_gate_message(
|
||||
&self,
|
||||
tab_id: &TabId,
|
||||
frame: &WebSurfaceFrame,
|
||||
has_previous_frame: bool,
|
||||
) -> Result<(), String> {
|
||||
if has_previous_frame
|
||||
|| self
|
||||
.previous_ready_frame(tab_id, frame.requested_url.as_str(), frame.zoom_percent())
|
||||
.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
frame.has_visible_content_for_initial_display().map(|_| ()).map_err(|error| {
|
||||
format!(
|
||||
"Servo hardware surface initial content check failed for {}: {error}",
|
||||
frame.requested_url
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn surface_mut(&mut self, tab_id: &TabId) -> &mut PerTabSurface {
|
||||
@@ -349,14 +353,7 @@ impl WebSurfaceStore {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn clear_viewport_resize_debounce_for_test(&mut self, tab_id: &TabId) {
|
||||
if let Some(surface) = self.surfaces.get_mut(tab_id) {
|
||||
surface.clear_viewport_resize_debounce_for_test();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn flush_runtime_for_test(&self) {
|
||||
pub(super) fn flush_runtime_for_test(&mut self) {
|
||||
self.runtime.flush_for_test();
|
||||
}
|
||||
}
|
||||
@@ -372,3 +369,15 @@ mod tests;
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[path = "web_surface_live_site_tests.rs"]
|
||||
mod web_surface_live_site_tests;
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[path = "web_surface_profile_isolation_tests.rs"]
|
||||
mod web_surface_profile_isolation_tests;
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
||||
#[path = "web_surface_hardware_import_tests.rs"]
|
||||
mod web_surface_hardware_import_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_scope_tests.rs"]
|
||||
mod web_surface_scope_tests;
|
||||
|
||||
@@ -41,7 +41,10 @@ impl WebSurfacePollCadence {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn note_frame(&mut self, render_state: &str, now: Instant) {
|
||||
pub(super) fn note_frame(&mut self, render_state: &str, pixels_changed: bool, now: Instant) {
|
||||
if pixels_changed {
|
||||
extend_deadline(&mut self.settle_active_until, now + FRAME_SETTLE_WINDOW);
|
||||
}
|
||||
let phase = WebSurfaceRenderPhase::from_render_state(render_state);
|
||||
match phase {
|
||||
WebSurfaceRenderPhase::Created | WebSurfaceRenderPhase::Loading
|
||||
@@ -173,7 +176,7 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_frame("loading", start);
|
||||
cadence.note_frame("loading", true, start);
|
||||
cadence.note_poll_submitted(start);
|
||||
|
||||
assert!(cadence.next_poll_delay(start) <= FRAME_BUDGET_120HZ);
|
||||
@@ -209,7 +212,7 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_frame("complete", start);
|
||||
cadence.note_frame("complete", true, start);
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(300));
|
||||
|
||||
assert!(!cadence.should_poll(start + Duration::from_millis(379)));
|
||||
@@ -222,7 +225,7 @@ mod tests {
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_ensure(WebSurfaceInputKind::Idle, true, start);
|
||||
cadence.note_frame("complete", start + Duration::from_secs(1));
|
||||
cadence.note_frame("complete", true, start + Duration::from_secs(1));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(1_300));
|
||||
|
||||
assert_eq!(
|
||||
@@ -237,7 +240,7 @@ mod tests {
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_ensure(WebSurfaceInputKind::Idle, true, start);
|
||||
cadence.note_frame("loading", start + Duration::from_secs(1));
|
||||
cadence.note_frame("loading", true, start + Duration::from_secs(1));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(1_300));
|
||||
|
||||
assert_eq!(
|
||||
@@ -252,7 +255,7 @@ mod tests {
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_ensure(WebSurfaceInputKind::Scroll, false, start);
|
||||
cadence.note_frame("complete", start + Duration::from_millis(100));
|
||||
cadence.note_frame("complete", true, start + Duration::from_millis(100));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(500));
|
||||
|
||||
assert_eq!(
|
||||
@@ -267,7 +270,7 @@ mod tests {
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_ensure(WebSurfaceInputKind::Scroll, false, start);
|
||||
cadence.note_frame("loading", start + Duration::from_millis(100));
|
||||
cadence.note_frame("loading", true, start + Duration::from_millis(100));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(500));
|
||||
|
||||
assert_eq!(
|
||||
@@ -281,8 +284,8 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_frame("complete", start);
|
||||
cadence.note_frame("complete", start + Duration::from_millis(200));
|
||||
cadence.note_frame("complete", false, start);
|
||||
cadence.note_frame("complete", false, start + Duration::from_millis(200));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(260));
|
||||
|
||||
assert!(!cadence.should_poll(start + Duration::from_millis(339)));
|
||||
@@ -294,8 +297,8 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_frame("loading", start);
|
||||
cadence.note_frame("loading", start + Duration::from_millis(200));
|
||||
cadence.note_frame("loading", false, start);
|
||||
cadence.note_frame("loading", false, start + Duration::from_millis(200));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(5_010));
|
||||
|
||||
assert_eq!(
|
||||
@@ -309,10 +312,25 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_frame("sleeping", start);
|
||||
cadence.note_frame("sleeping", start + Duration::from_millis(200));
|
||||
cadence.note_frame("sleeping", false, start);
|
||||
cadence.note_frame("sleeping", false, start + Duration::from_millis(200));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(610));
|
||||
|
||||
assert_eq!(cadence.next_poll_delay(start + Duration::from_millis(610)), IDLE_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_pixel_frames_keep_animation_cadence_active() {
|
||||
let start = Instant::now();
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_frame("complete", true, start);
|
||||
cadence.note_frame("complete", true, start + Duration::from_millis(240));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(300));
|
||||
|
||||
assert_eq!(
|
||||
cadence.next_poll_delay(start + Duration::from_millis(300)),
|
||||
ACTIVE_POLL_INTERVAL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use ely_browser_core::{BrowserCore, BrowserSnapshot};
|
||||
use ely_domain::{BrowserTab, ProfileKind, TabId, UrlText};
|
||||
use gpui::{AnyElement, Bounds, Context, NativeSurfaceHandle, Pixels, Point};
|
||||
use gpui::{AnyElement, Bounds, Context, Pixels, Point};
|
||||
|
||||
use crate::services::ProfileDataMode;
|
||||
|
||||
@@ -26,11 +26,11 @@ impl ElyShell {
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let state_entity = cx.entity().clone();
|
||||
if profile_data_mode_for(tab, snapshot).is_none() {
|
||||
let Some(profile_data_mode) = profile_data_mode_for(tab, snapshot) else {
|
||||
return render_failed_web_surface(tab, "Profile context is unavailable.", state_entity);
|
||||
}
|
||||
};
|
||||
|
||||
match self.web_surfaces.state(tab.id()) {
|
||||
match self.web_surfaces.state_for_scope(tab.id(), tab.profile_id(), profile_data_mode) {
|
||||
Some(WebSurfaceState::Ready(frame)) => {
|
||||
render_ready_web_surface(frame, tab, state_entity, bottom_corner_radius)
|
||||
}
|
||||
@@ -102,25 +102,6 @@ impl ElyShell {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record_external_web_surface(
|
||||
&mut self,
|
||||
tab_id: TabId,
|
||||
bounds: Bounds<Pixels>,
|
||||
scale_factor: f32,
|
||||
native_surface: NativeSurfaceHandle,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let viewport_changed =
|
||||
self.web_surfaces.record_viewport_size(&tab_id, bounds, scale_factor)
|
||||
== WebSurfaceInputOutcome::Applied;
|
||||
let surface_changed = self.web_surfaces.record_native_surface(&tab_id, native_surface)
|
||||
== WebSurfaceInputOutcome::Applied;
|
||||
|
||||
if viewport_changed || surface_changed {
|
||||
self.flush_external_web_surface_tick(cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn scroll_external_web_viewport(
|
||||
&mut self,
|
||||
tab_id: TabId,
|
||||
|
||||
@@ -3,20 +3,31 @@ use std::hash::Hasher;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ahash::AHasher;
|
||||
#[cfg(target_os = "macos")]
|
||||
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
|
||||
use gpui::RenderImage;
|
||||
use image::{ImageBuffer, Rgba};
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::services::iosurface_metal::HardwareSurfaceBacking;
|
||||
use crate::services::servo_live::ServoLiveFrame;
|
||||
|
||||
thread_local! {
|
||||
/// Single-slot cache for byte-identical software frames.
|
||||
/// AHash keeps the 1080p hash pass below the frame budget while
|
||||
/// avoiding repeated GPUI texture allocation for idle pages.
|
||||
static LAST_FRAME_IMAGE: RefCell<Option<(u64, Arc<RenderImage>)>> =
|
||||
static LAST_FRAME_IMAGE: RefCell<Option<CachedRenderImage>> =
|
||||
const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct CachedRenderImage {
|
||||
width: u32,
|
||||
height: u32,
|
||||
bytes_hash: u64,
|
||||
image: Arc<RenderImage>,
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
use super::web_surface_geometry::WebSurfaceSize;
|
||||
use super::web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset};
|
||||
@@ -37,6 +48,7 @@ pub(super) struct WebSurfaceFrame {
|
||||
zoom_percent: u16,
|
||||
click_point: Option<WebSurfaceClickPoint>,
|
||||
typed_text: Option<String>,
|
||||
pixels_changed: bool,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
@@ -44,6 +56,10 @@ pub(super) struct WebSurfaceFrame {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: u64,
|
||||
pub(super) image: Option<Arc<RenderImage>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) hardware_surface: Option<Arc<HardwareSurfaceBacking>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl WebSurfaceFrame {
|
||||
@@ -53,6 +69,10 @@ impl WebSurfaceFrame {
|
||||
zoom_percent: u16,
|
||||
frame: ServoLiveFrame,
|
||||
) -> Result<Self, WebSurfaceError> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let hardware_surface = frame.hardware_surface().cloned();
|
||||
#[cfg(target_os = "macos")]
|
||||
let hardware_surface_id = frame.hardware_surface_id();
|
||||
Self::from_parts(WebSurfaceFrameParts {
|
||||
requested_url,
|
||||
loaded_url: frame.loaded_url().map(str::to_string),
|
||||
@@ -67,6 +87,7 @@ impl WebSurfaceFrame {
|
||||
zoom_percent,
|
||||
click_point: None,
|
||||
typed_text: None,
|
||||
pixels_changed: frame.pixels_changed(),
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: frame.non_white_pixel_count(),
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
@@ -74,10 +95,26 @@ impl WebSurfaceFrame {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: frame.sample_hash(),
|
||||
rgba_bytes: frame.into_rgba_bytes(),
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn from_parts(parts: WebSurfaceFrameParts) -> Result<Self, WebSurfaceError> {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(surface) = parts.hardware_surface.as_ref() {
|
||||
validate_hardware_pixel_buffer(surface.pixel_buffer(), parts.width, parts.height)?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
let has_hardware_surface = parts.hardware_surface.is_some();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let has_hardware_surface = false;
|
||||
if parts.rgba_bytes.is_none() && !has_hardware_surface {
|
||||
return Err(WebSurfaceError::MissingRenderablePayload);
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
let pixel_sample = pixel_sample_for_parts(&parts)?;
|
||||
|
||||
@@ -108,6 +145,7 @@ impl WebSurfaceFrame {
|
||||
zoom_percent: parts.zoom_percent,
|
||||
click_point: parts.click_point,
|
||||
typed_text: parts.typed_text,
|
||||
pixels_changed: parts.pixels_changed,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: pixel_sample.non_white_pixel_count,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
@@ -115,6 +153,10 @@ impl WebSurfaceFrame {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: pixel_sample.sample_hash,
|
||||
image,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface: parts.hardware_surface,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id: parts.hardware_surface_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -158,7 +200,6 @@ impl WebSurfaceFrame {
|
||||
(self.css_viewport_width, self.css_viewport_height)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn render_state(&self) -> &str {
|
||||
self.render_state.as_str()
|
||||
}
|
||||
@@ -180,15 +221,26 @@ impl WebSurfaceFrame {
|
||||
self.title.as_deref()
|
||||
}
|
||||
|
||||
pub(super) fn has_same_software_render_as(&self, other: &Self) -> bool {
|
||||
pub(super) fn has_same_render_as(&self, other: &Self) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.hardware_surface.is_some() || other.hardware_surface.is_some() {
|
||||
return self.hardware_surface.is_some()
|
||||
&& other.hardware_surface.is_some()
|
||||
&& self.hardware_surface_id == other.hardware_surface_id
|
||||
&& !other.pixels_changed
|
||||
&& self.metadata_matches(other);
|
||||
}
|
||||
let image_matches = match (self.image.as_ref(), other.image.as_ref()) {
|
||||
(Some(image), Some(other_image)) => Arc::ptr_eq(image, other_image),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
image_matches
|
||||
&& self.requested_url == other.requested_url
|
||||
image_matches && self.metadata_matches(other)
|
||||
}
|
||||
|
||||
fn metadata_matches(&self, other: &Self) -> bool {
|
||||
self.requested_url == other.requested_url
|
||||
&& self.loaded_url == other.loaded_url
|
||||
&& self.title == other.title
|
||||
&& self.render_state == other.render_state
|
||||
@@ -203,17 +255,6 @@ impl WebSurfaceFrame {
|
||||
&& self.typed_text == other.typed_text
|
||||
}
|
||||
|
||||
pub(super) fn has_visible_content_for_initial_display(&self) -> Result<bool, WebSurfaceError> {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
{
|
||||
Ok(self.non_white_pixel_count > 0 && self.content_pixel_count > 0)
|
||||
}
|
||||
#[cfg(not(all(test, feature = "live-site-smoke")))]
|
||||
{
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn non_white_pixel_count(&self) -> u64 {
|
||||
self.non_white_pixel_count
|
||||
@@ -229,9 +270,21 @@ impl WebSurfaceFrame {
|
||||
self.sample_hash
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[cfg(test)]
|
||||
pub(super) fn has_hardware_surface(&self) -> bool {
|
||||
false
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.hardware_surface.is_some()
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
||||
pub(super) fn hardware_surface_id_for_test(&self) -> Option<u64> {
|
||||
self.hardware_surface_id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +302,7 @@ struct WebSurfaceFrameParts {
|
||||
zoom_percent: u16,
|
||||
click_point: Option<WebSurfaceClickPoint>,
|
||||
typed_text: Option<String>,
|
||||
pixels_changed: bool,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
@@ -256,6 +310,10 @@ struct WebSurfaceFrameParts {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: u64,
|
||||
rgba_bytes: Option<Vec<u8>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface: Option<Arc<HardwareSurfaceBacking>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -264,6 +322,42 @@ pub(super) enum WebSurfaceError {
|
||||
InvalidFrameBuffer { width: u32, height: u32 },
|
||||
#[error("servo live frame did not include renderable pixels")]
|
||||
MissingRenderablePayload,
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error(
|
||||
"servo hardware surface size {actual_width}x{actual_height} did not match frame report {expected_width}x{expected_height}"
|
||||
)]
|
||||
HardwareSurfaceSizeMismatch {
|
||||
expected_width: u32,
|
||||
expected_height: u32,
|
||||
actual_width: usize,
|
||||
actual_height: usize,
|
||||
},
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error("servo hardware surface pixel format 0x{actual:x} is unsupported; expected 32BGRA")]
|
||||
UnsupportedHardwareSurfaceFormat { actual: u32 },
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn validate_hardware_pixel_buffer(
|
||||
pixel_buffer: &CVPixelBuffer,
|
||||
expected_width: u32,
|
||||
expected_height: u32,
|
||||
) -> Result<(), WebSurfaceError> {
|
||||
let actual_width = pixel_buffer.get_width();
|
||||
let actual_height = pixel_buffer.get_height();
|
||||
if actual_width != expected_width as usize || actual_height != expected_height as usize {
|
||||
return Err(WebSurfaceError::HardwareSurfaceSizeMismatch {
|
||||
expected_width,
|
||||
expected_height,
|
||||
actual_width,
|
||||
actual_height,
|
||||
});
|
||||
}
|
||||
let actual_format = pixel_buffer.get_pixel_format();
|
||||
if actual_format != kCVPixelFormatType_32BGRA {
|
||||
return Err(WebSurfaceError::UnsupportedHardwareSurfaceFormat { actual: actual_format });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Swap byte 0 and byte 2 of every 4-byte pixel, converting Servo's
|
||||
@@ -307,16 +401,27 @@ fn resolve_render_image(
|
||||
) -> Result<Arc<RenderImage>, WebSurfaceError> {
|
||||
LAST_FRAME_IMAGE.with(|cache| -> Result<Arc<RenderImage>, WebSurfaceError> {
|
||||
let mut cache = cache.borrow_mut();
|
||||
if let Some((cached_hash, cached_image)) = cache.as_ref()
|
||||
&& *cached_hash == bytes_hash
|
||||
if let Some(cached) = cache.as_ref()
|
||||
&& cached.width == width
|
||||
&& cached.height == height
|
||||
&& cached.bytes_hash == bytes_hash
|
||||
&& cached.image.as_bytes(0) == Some(rgba_bytes.as_slice())
|
||||
{
|
||||
return Ok(cached_image.clone());
|
||||
return Ok(cached.image.clone());
|
||||
}
|
||||
|
||||
let image_buffer = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, rgba_bytes)
|
||||
.ok_or(WebSurfaceError::InvalidFrameBuffer { width, height })?;
|
||||
let new_image = Arc::new(RenderImage::new([image::Frame::new(image_buffer)]));
|
||||
*cache = Some((bytes_hash, new_image.clone()));
|
||||
*cache = Some(CachedRenderImage { width, height, bytes_hash, image: new_image.clone() });
|
||||
Ok(new_image)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_frame_cache_tests.rs"]
|
||||
mod cache_tests;
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
#[path = "web_surface_frame_hardware_tests.rs"]
|
||||
mod hardware_tests;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::resolve_render_image;
|
||||
|
||||
#[test]
|
||||
fn cache_key_includes_frame_dimensions() -> Result<(), super::WebSurfaceError> {
|
||||
let bytes = vec![0, 0, 0, 255, 255, 255, 255, 255];
|
||||
let first = resolve_render_image(1, 2, bytes.clone(), 41)?;
|
||||
let reshaped = resolve_render_image(2, 1, bytes, 41)?;
|
||||
|
||||
assert!(!Arc::ptr_eq(&first, &reshaped));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_verifies_bytes_after_hash_match() -> Result<(), super::WebSurfaceError> {
|
||||
let first = resolve_render_image(1, 1, vec![1, 2, 3, 255], 99)?;
|
||||
let collision = resolve_render_image(1, 1, vec![3, 2, 1, 255], 99)?;
|
||||
|
||||
assert!(!Arc::ptr_eq(&first, &collision));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_frame_reuses_render_image() -> Result<(), super::WebSurfaceError> {
|
||||
let bytes = vec![1, 2, 3, 255];
|
||||
let first = resolve_render_image(1, 1, bytes.clone(), 7)?;
|
||||
let repeated = resolve_render_image(1, 1, bytes, 7)?;
|
||||
|
||||
assert!(Arc::ptr_eq(&first, &repeated));
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
|
||||
|
||||
use crate::services::servo_live::ServoLiveFrame;
|
||||
|
||||
use super::{WebSurfaceError, WebSurfaceFrame};
|
||||
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
||||
|
||||
#[test]
|
||||
fn hardware_frame_keeps_pixel_buffer_without_software_image() -> Result<(), WebSurfaceError> {
|
||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
|
||||
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
|
||||
let live_frame = ServoLiveFrame::for_test_with_pixel_buffer(64, 48, 7, pixel_buffer);
|
||||
|
||||
let frame = WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
live_frame,
|
||||
)?;
|
||||
|
||||
assert!(frame.has_hardware_surface());
|
||||
assert!(frame.hardware_surface.is_some());
|
||||
assert!(frame.image.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardware_frame_identity_participates_in_equality() -> Result<(), WebSurfaceError> {
|
||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
|
||||
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
|
||||
let first = WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_pixel_buffer(64, 48, 7, pixel_buffer.clone()),
|
||||
)?;
|
||||
let second = WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_pixel_buffer(64, 48, 8, pixel_buffer),
|
||||
)?;
|
||||
|
||||
assert!(!first.has_same_render_as(&second));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_hardware_surface_with_new_pixels_triggers_repaint() -> Result<(), WebSurfaceError> {
|
||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
|
||||
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
|
||||
let first = hardware_frame(7, pixel_buffer.clone(), false)?;
|
||||
let changed = hardware_frame(7, pixel_buffer, true)?;
|
||||
|
||||
assert!(!first.has_same_render_as(&changed));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_only_hardware_frame_reuses_same_surface() -> Result<(), WebSurfaceError> {
|
||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
|
||||
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
|
||||
let first = hardware_frame(7, pixel_buffer.clone(), true)?;
|
||||
let metadata_only = hardware_frame(7, pixel_buffer, false)?;
|
||||
|
||||
assert!(first.has_same_render_as(&metadata_only));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardware_frame_validates_reported_dimensions() -> Result<(), String> {
|
||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
|
||||
.map_err(|status| format!("test pixel buffer creation failed: {status}"))?;
|
||||
let live_frame = ServoLiveFrame::for_test_with_pixel_buffer(96, 72, 7, pixel_buffer);
|
||||
|
||||
assert!(matches!(
|
||||
WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
live_frame,
|
||||
),
|
||||
Err(WebSurfaceError::HardwareSurfaceSizeMismatch { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hardware_frame(
|
||||
surface_id: u64,
|
||||
pixel_buffer: CVPixelBuffer,
|
||||
pixels_changed: bool,
|
||||
) -> Result<WebSurfaceFrame, WebSurfaceError> {
|
||||
WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_hardware_change(
|
||||
64,
|
||||
48,
|
||||
surface_id,
|
||||
pixel_buffer,
|
||||
pixels_changed,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use std::{
|
||||
env,
|
||||
error::Error,
|
||||
ffi::OsString,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use core_video::pixel_buffer::kCVPixelFormatType_32BGRA;
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
use gpui::{Bounds, SurfaceLease, point, px, size, submit_surface_to_metal_for_test};
|
||||
use objc2_io_surface::IOSurfaceRef;
|
||||
|
||||
use crate::{
|
||||
services::ProfileDataMode,
|
||||
shell::web_surface_frame::WebSurfaceFrame,
|
||||
shell::web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState},
|
||||
};
|
||||
|
||||
use super::{WebSurfaceStore, web_surface_profile_isolation_tests::ProfileProbeServer};
|
||||
|
||||
const CHILD_ENV: &str = "ELY_APP_HARDWARE_IOSURFACE_CHILD";
|
||||
const TEST_NAME: &str = concat!(
|
||||
"shell::web_surface::web_surface_hardware_import_tests::",
|
||||
"web_surface_imports_hardware_iosurface",
|
||||
);
|
||||
const PROBE_TITLE: &str = "request=empty|document=hardware|storage=hardware|cache=cache-1";
|
||||
const PROBE_WIDTH: u32 = 640;
|
||||
const PROBE_HEIGHT: u32 = 480;
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[test]
|
||||
fn web_surface_imports_hardware_iosurface() -> Result<(), Box<dyn Error>> {
|
||||
if env::var_os(CHILD_ENV).is_some() {
|
||||
return run_hardware_iosurface_probe();
|
||||
}
|
||||
|
||||
let sidecar = build_hardware_sidecar()?;
|
||||
let output = Command::new(env::current_exe()?)
|
||||
.arg(TEST_NAME)
|
||||
.arg("--exact")
|
||||
.arg("--test-threads=1")
|
||||
.env(CHILD_ENV, "1")
|
||||
.env("ELY_SERVO_RENDERING_CONTEXT", "hardware")
|
||||
.env("ELY_SERVO_SIDECAR", sidecar)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
if output.status.success() && stdout.contains("running 1 test") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"isolated hardware IOSurface test failed\nstatus: {}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
stdout,
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
fn build_hardware_sidecar() -> Result<PathBuf, Box<dyn Error>> {
|
||||
let workspace_manifest = PathBuf::from(env!("ELY_WORKSPACE_MANIFEST"));
|
||||
let workspace_root = workspace_manifest.parent().ok_or("missing workspace root")?;
|
||||
let cargo = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"));
|
||||
let output = Command::new(cargo)
|
||||
.args([
|
||||
"build",
|
||||
"--locked",
|
||||
"-p",
|
||||
"ely_servo_host",
|
||||
"--features",
|
||||
"servo-engine,hardware-render",
|
||||
"--bin",
|
||||
"ely_servo_sidecar",
|
||||
])
|
||||
.current_dir(workspace_root)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"hardware sidecar build failed\nstatus: {}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let sidecar = sidecar_binary_path(workspace_root);
|
||||
if !sidecar.is_file() {
|
||||
return Err(format!("hardware sidecar is missing at {}", sidecar.display()).into());
|
||||
}
|
||||
Ok(sidecar)
|
||||
}
|
||||
|
||||
fn sidecar_binary_path(workspace_root: &Path) -> PathBuf {
|
||||
let mut target_dir = env::var_os("CARGO_TARGET_DIR").map_or_else(
|
||||
|| workspace_root.join("target"),
|
||||
|path| {
|
||||
let path = PathBuf::from(path);
|
||||
if path.is_absolute() { path } else { workspace_root.join(path) }
|
||||
},
|
||||
);
|
||||
if let Some(target) = env::var_os("CARGO_BUILD_TARGET") {
|
||||
target_dir.push(target);
|
||||
}
|
||||
target_dir.join("debug").join(format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX))
|
||||
}
|
||||
|
||||
fn run_hardware_iosurface_probe() -> Result<(), Box<dyn Error>> {
|
||||
let mut server = ProfileProbeServer::start()?;
|
||||
let mut store = WebSurfaceStore::new();
|
||||
let url = format!("{}/probe?value=hardware", server.origin());
|
||||
let tab = BrowserTab::new(
|
||||
TabId::new(),
|
||||
SpaceId::new(),
|
||||
ProfileId::new(),
|
||||
"Hardware IOSurface probe",
|
||||
UrlText::parse(&url)?,
|
||||
);
|
||||
assert_eq!(
|
||||
store.record_viewport_size(tab.id(), probe_bounds(), 1.0),
|
||||
WebSurfaceInputOutcome::Applied,
|
||||
);
|
||||
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
|
||||
|
||||
let frame = wait_for_hardware_surface(&mut store, &tab)?;
|
||||
store.close_surface(tab.id());
|
||||
store.flush_runtime_for_test();
|
||||
assert_hardware_lease_lifecycle(frame)?;
|
||||
drop(store);
|
||||
server.finish()
|
||||
}
|
||||
|
||||
fn wait_for_hardware_surface(
|
||||
store: &mut WebSurfaceStore,
|
||||
tab: &BrowserTab,
|
||||
) -> Result<WebSurfaceFrame, Box<dyn Error>> {
|
||||
let started_at = Instant::now();
|
||||
let mut last_frame = None;
|
||||
loop {
|
||||
store.tick(std::slice::from_ref(tab.id()));
|
||||
match store.state(tab.id()) {
|
||||
Some(WebSurfaceState::Ready(frame)) => {
|
||||
last_frame = Some(format!(
|
||||
"title={:?}, state={}, hardware={}",
|
||||
frame.title(),
|
||||
frame.render_state(),
|
||||
frame.has_hardware_surface(),
|
||||
));
|
||||
if frame.title() == Some(PROBE_TITLE) && frame.has_hardware_surface() {
|
||||
return Ok(frame.clone());
|
||||
}
|
||||
}
|
||||
Some(WebSurfaceState::Failed { message }) => {
|
||||
return Err(format!("hardware IOSurface probe failed: {message}").into());
|
||||
}
|
||||
Some(WebSurfaceState::Loading { .. }) | None => {}
|
||||
}
|
||||
if started_at.elapsed() >= PROBE_TIMEOUT {
|
||||
return Err(format!(
|
||||
"timed out waiting for imported hardware IOSurface; last frame: {last_frame:?}",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_hardware_lease_lifecycle(frame: WebSurfaceFrame) -> Result<(), Box<dyn Error>> {
|
||||
let hardware_surface = frame.hardware_surface.clone().ok_or("hardware backing is missing")?;
|
||||
let pixel_buffer = hardware_surface.pixel_buffer();
|
||||
assert_eq!(pixel_buffer.get_pixel_format(), kCVPixelFormatType_32BGRA);
|
||||
|
||||
let surface_id = frame
|
||||
.hardware_surface_id_for_test()
|
||||
.and_then(|surface_id| u32::try_from(surface_id).ok())
|
||||
.ok_or("hardware IOSurface ID is invalid")?;
|
||||
let iosurface = IOSurfaceRef::lookup(surface_id).ok_or("hardware IOSurface lookup failed")?;
|
||||
let active_use_count = iosurface.use_count();
|
||||
let released_use_count = active_use_count
|
||||
.checked_sub(1)
|
||||
.ok_or("hardware IOSurface use count was not incremented")?;
|
||||
assert_eq!(Arc::strong_count(&hardware_surface), 2);
|
||||
|
||||
let weak_backing = Arc::downgrade(&hardware_surface);
|
||||
let submission = submit_surface_to_metal_for_test(
|
||||
pixel_buffer.clone(),
|
||||
SurfaceLease::from_arc(hardware_surface.clone()),
|
||||
)?;
|
||||
drop(frame);
|
||||
drop(hardware_surface);
|
||||
|
||||
assert_eq!(weak_backing.strong_count(), 1);
|
||||
assert_eq!(iosurface.use_count(), active_use_count);
|
||||
submission.finish()?;
|
||||
|
||||
assert_eq!(weak_backing.strong_count(), 0);
|
||||
assert_eq!(iosurface.use_count(), released_use_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn probe_bounds() -> Bounds<gpui::Pixels> {
|
||||
Bounds::new(point(px(0.0), px(0.0)), size(px(PROBE_WIDTH as f32), px(PROBE_HEIGHT as f32)))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use ely_domain::TabId;
|
||||
use gpui::{Bounds, NativeSurfaceHandle, Pixels, Point};
|
||||
use gpui::{Bounds, Pixels, Point};
|
||||
|
||||
use super::{
|
||||
web_surface::WebSurfaceStore,
|
||||
@@ -104,24 +104,6 @@ impl WebSurfaceStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record_native_surface(
|
||||
&mut self,
|
||||
tab_id: &TabId,
|
||||
native_surface: NativeSurfaceHandle,
|
||||
) -> WebSurfaceInputOutcome {
|
||||
let surface = self.surface_mut(tab_id);
|
||||
if surface
|
||||
.native_surface
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.identity() == native_surface.identity())
|
||||
{
|
||||
return WebSurfaceInputOutcome::NoChange;
|
||||
}
|
||||
surface.native_surface = Some(native_surface);
|
||||
surface.last_ensure_key = None;
|
||||
WebSurfaceInputOutcome::Applied
|
||||
}
|
||||
|
||||
pub(super) fn record_hover_point(
|
||||
&mut self,
|
||||
tab_id: &TabId,
|
||||
|
||||
@@ -92,6 +92,7 @@ fn run_isolated_live_site_test(
|
||||
let output = Command::new(env::current_exe()?)
|
||||
.arg(test_name)
|
||||
.env(LIVE_SITE_CHILD_ENV, "1")
|
||||
.env("ELY_SERVO_RENDERING_CONTEXT", "software")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()?;
|
||||
|
||||
@@ -124,6 +124,7 @@ mod tests {
|
||||
profile_name: "Default".to_string(),
|
||||
profile_color_hex: 0x26251e,
|
||||
profile_kind: ely_domain::ProfileKind::Standard,
|
||||
profile_id: None,
|
||||
new_tab_destination: Default::default(),
|
||||
})?;
|
||||
core.open_tab(UrlText::parse(url)?);
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
use std::{
|
||||
error::Error,
|
||||
io::{self, Read, Write},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
use gpui::{Bounds, point, px, size};
|
||||
|
||||
use crate::{
|
||||
services::ProfileDataMode,
|
||||
shell::web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState},
|
||||
};
|
||||
|
||||
use super::WebSurfaceStore;
|
||||
|
||||
const PROBE_WIDTH: u32 = 640;
|
||||
const PROBE_HEIGHT: u32 = 480;
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[test]
|
||||
fn web_surface_keeps_profile_site_data_isolated() -> Result<(), Box<dyn Error>> {
|
||||
let mut server = ProfileProbeServer::start()?;
|
||||
let mut store = WebSurfaceStore::new();
|
||||
let profile_a = ProfileId::new();
|
||||
let profile_b = ProfileId::new();
|
||||
|
||||
let tab_a_seed = render_probe(
|
||||
&mut store,
|
||||
&profile_a,
|
||||
&format!("{}/probe?value=alpha", server.origin()),
|
||||
"request=empty|document=alpha|storage=alpha|cache=cache-1",
|
||||
)?;
|
||||
let tab_b_seed = render_probe(
|
||||
&mut store,
|
||||
&profile_b,
|
||||
&format!("{}/probe?value=beta", server.origin()),
|
||||
"request=empty|document=beta|storage=beta|cache=cache-2",
|
||||
)?;
|
||||
let tab_a_inspect = render_probe(
|
||||
&mut store,
|
||||
&profile_a,
|
||||
&format!("{}/probe?inspect=alpha", server.origin()),
|
||||
"request=alpha|document=alpha|storage=alpha|cache=cache-1",
|
||||
)?;
|
||||
let tab_b_inspect = render_probe(
|
||||
&mut store,
|
||||
&profile_b,
|
||||
&format!("{}/probe?inspect=beta", server.origin()),
|
||||
"request=beta|document=beta|storage=beta|cache=cache-2",
|
||||
)?;
|
||||
|
||||
assert_eq!(server.cache_request_count(), 2);
|
||||
store.close_surface(&tab_a_seed);
|
||||
store.close_surface(&tab_a_inspect);
|
||||
store.flush_runtime_for_test();
|
||||
|
||||
let tab_a_reopened = render_probe(
|
||||
&mut store,
|
||||
&profile_a,
|
||||
&format!("{}/probe?value=gamma", server.origin()),
|
||||
"request=empty|document=gamma|storage=gamma|cache=cache-3",
|
||||
)?;
|
||||
assert_eq!(server.cache_request_count(), 3);
|
||||
|
||||
for tab_id in [tab_b_seed, tab_b_inspect, tab_a_reopened] {
|
||||
store.close_surface(&tab_id);
|
||||
}
|
||||
store.flush_runtime_for_test();
|
||||
drop(store);
|
||||
server.finish()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_probe(
|
||||
store: &mut WebSurfaceStore,
|
||||
profile_id: &ProfileId,
|
||||
url: &str,
|
||||
expected_title: &str,
|
||||
) -> Result<TabId, Box<dyn Error>> {
|
||||
let tab = BrowserTab::new(
|
||||
TabId::new(),
|
||||
SpaceId::new(),
|
||||
profile_id.clone(),
|
||||
"Profile probe",
|
||||
UrlText::parse(url)?,
|
||||
);
|
||||
assert_eq!(
|
||||
store.record_viewport_size(tab.id(), probe_bounds(), 1.0),
|
||||
WebSurfaceInputOutcome::Applied,
|
||||
);
|
||||
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
|
||||
|
||||
let started_at = Instant::now();
|
||||
let mut last_title = None;
|
||||
loop {
|
||||
store.tick(std::slice::from_ref(tab.id()));
|
||||
match store.state(tab.id()) {
|
||||
Some(WebSurfaceState::Ready(frame)) => {
|
||||
last_title = frame.title().map(str::to_string);
|
||||
if frame.title() == Some(expected_title) {
|
||||
return Ok(tab.id().clone());
|
||||
}
|
||||
}
|
||||
Some(WebSurfaceState::Failed { message }) => {
|
||||
return Err(format!("profile probe failed for {url}: {message}").into());
|
||||
}
|
||||
Some(WebSurfaceState::Loading { .. }) | None => {}
|
||||
}
|
||||
|
||||
if started_at.elapsed() >= PROBE_TIMEOUT {
|
||||
return Err(format!(
|
||||
"timed out waiting for profile probe title `{expected_title}` at {url}; last title: {last_title:?}",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_bounds() -> Bounds<gpui::Pixels> {
|
||||
Bounds::new(point(px(0.0), px(0.0)), size(px(PROBE_WIDTH as f32), px(PROBE_HEIGHT as f32)))
|
||||
}
|
||||
|
||||
pub(super) struct ProfileProbeServer {
|
||||
origin: String,
|
||||
cache_requests: Arc<AtomicUsize>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ProfileProbeServer {
|
||||
pub(super) fn start() -> Result<Self, Box<dyn Error>> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
listener.set_nonblocking(true)?;
|
||||
let address = listener.local_addr()?;
|
||||
let cache_requests = Arc::new(AtomicUsize::new(0));
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let error = Arc::new(Mutex::new(None));
|
||||
let thread_cache_requests = cache_requests.clone();
|
||||
let thread_shutdown = shutdown.clone();
|
||||
let thread_error = error.clone();
|
||||
let thread = thread::Builder::new().name("ely-profile-probe-server".to_string()).spawn(
|
||||
move || {
|
||||
serve_profile_probes(
|
||||
listener,
|
||||
thread_cache_requests,
|
||||
thread_shutdown,
|
||||
thread_error,
|
||||
);
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
origin: format!("http://{address}"),
|
||||
cache_requests,
|
||||
shutdown,
|
||||
error,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn origin(&self) -> &str {
|
||||
self.origin.as_str()
|
||||
}
|
||||
|
||||
fn cache_request_count(&self) -> usize {
|
||||
self.cache_requests.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub(super) fn finish(&mut self) -> Result<(), Box<dyn Error>> {
|
||||
self.shutdown.store(true, Ordering::SeqCst);
|
||||
if let Some(thread) = self.thread.take()
|
||||
&& thread.join().is_err()
|
||||
{
|
||||
return Err("profile probe server thread panicked".into());
|
||||
}
|
||||
let error = self.error.lock().map_err(|_| "profile probe error lock was poisoned")?.take();
|
||||
match error {
|
||||
Some(message) => Err(message.into()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProfileProbeServer {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown.store(true, Ordering::SeqCst);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_profile_probes(
|
||||
listener: TcpListener,
|
||||
cache_requests: Arc<AtomicUsize>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
) {
|
||||
while !shutdown.load(Ordering::SeqCst) {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
let connection_cache_requests = cache_requests.clone();
|
||||
let connection_error = error.clone();
|
||||
_ = thread::spawn(move || {
|
||||
if let Err(server_error) = serve_connection(stream, &connection_cache_requests)
|
||||
{
|
||||
record_server_error(&connection_error, server_error.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(server_error) if server_error.kind() == io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
Err(server_error) => {
|
||||
record_server_error(&error, server_error.to_string());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_server_error(error: &Arc<Mutex<Option<String>>>, message: String) {
|
||||
if let Ok(mut slot) = error.lock() {
|
||||
*slot = Some(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_connection(mut stream: TcpStream, cache_requests: &AtomicUsize) -> Result<(), io::Error> {
|
||||
stream.set_nonblocking(false)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||
let request = read_http_request(&mut stream)?;
|
||||
let request_text = String::from_utf8_lossy(&request);
|
||||
let path = request_text
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_ascii_whitespace().nth(1))
|
||||
.unwrap_or("/");
|
||||
|
||||
if path.starts_with("/cache-token") {
|
||||
let request_number = cache_requests.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
return write_http_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
"text/plain; charset=utf-8",
|
||||
"public, max-age=3600, immutable",
|
||||
None,
|
||||
format!("cache-{request_number}").as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
if path.starts_with("/probe") {
|
||||
let request_cookie = cookie_value(&request_text, "ely_profile").unwrap_or("empty");
|
||||
let seed = query_value(path, "value");
|
||||
let set_cookie =
|
||||
seed.map(|value| format!("ely_profile={value}; Path=/; Max-Age=3600; SameSite=Lax"));
|
||||
let body = profile_probe_html(request_cookie);
|
||||
return write_http_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
"text/html; charset=utf-8",
|
||||
"no-store",
|
||||
set_cookie.as_deref(),
|
||||
body.as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
write_http_response(
|
||||
&mut stream,
|
||||
"404 Not Found",
|
||||
"text/plain; charset=utf-8",
|
||||
"no-store",
|
||||
None,
|
||||
b"missing",
|
||||
)
|
||||
}
|
||||
|
||||
fn read_http_request(stream: &mut TcpStream) -> Result<Vec<u8>, io::Error> {
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
loop {
|
||||
let read = stream.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
if request.len() > 64 * 1024 {
|
||||
return Err(io::Error::other("profile probe request exceeded 64 KiB"));
|
||||
}
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn write_http_response(
|
||||
stream: &mut TcpStream,
|
||||
status: &str,
|
||||
content_type: &str,
|
||||
cache_control: &str,
|
||||
set_cookie: Option<&str>,
|
||||
body: &[u8],
|
||||
) -> Result<(), io::Error> {
|
||||
let cookie_header =
|
||||
set_cookie.map(|cookie| format!("Set-Cookie: {cookie}\r\n")).unwrap_or_default();
|
||||
let headers = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nCache-Control: {cache_control}\r\n{cookie_header}Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len(),
|
||||
);
|
||||
stream.write_all(headers.as_bytes())?;
|
||||
stream.write_all(body)?;
|
||||
stream.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cookie_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
|
||||
request.lines().find_map(|line| {
|
||||
let (header, value) = line.split_once(':')?;
|
||||
if !header.eq_ignore_ascii_case("cookie") {
|
||||
return None;
|
||||
}
|
||||
value.split(';').find_map(|pair| {
|
||||
let (cookie_name, cookie_value) = pair.trim().split_once('=')?;
|
||||
(cookie_name == name).then_some(cookie_value)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn query_value<'a>(path: &'a str, name: &str) -> Option<&'a str> {
|
||||
let (_, query) = path.split_once('?')?;
|
||||
query.split('&').find_map(|pair| {
|
||||
let (key, value) = pair.split_once('=')?;
|
||||
(key == name).then_some(value)
|
||||
})
|
||||
}
|
||||
|
||||
fn profile_probe_html(request_cookie: &str) -> String {
|
||||
format!(
|
||||
"<!doctype html><title>loading</title><pre id=state>loading</pre><script>\
|
||||
const seed = new URLSearchParams(location.search).get('value');\
|
||||
if (seed) localStorage.setItem('ely_profile', seed);\
|
||||
const cookie = () => (document.cookie.match(/(?:^|; )ely_profile=([^;]*)/) || [,'empty'])[1];\
|
||||
fetch('/cache-token', {{cache:'force-cache'}}).then(response => response.text()).then(cache => {{\
|
||||
const title = 'request={request_cookie}|document=' + cookie() + '|storage=' + (localStorage.getItem('ely_profile') || 'empty') + '|cache=' + cache;\
|
||||
document.title = title; document.getElementById('state').textContent = title;\
|
||||
}});</script>",
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::PathBuf,
|
||||
thread::JoinHandle,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_domain::{BrowserTab, TabId};
|
||||
use gpui::NativeSurfaceHandle;
|
||||
|
||||
use crate::services::{
|
||||
ProfileDataMode,
|
||||
servo_live::{ServoLiveClient, ServoLiveEnsureRequest, ServoLiveSitePermission},
|
||||
servo_profile_data::cleanup_stale_transient_profile_data_dirs,
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -25,44 +25,54 @@ use super::{
|
||||
scroll_wire_fields,
|
||||
},
|
||||
web_surface_state::WebSurfacePendingInput,
|
||||
web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, WorkerResponse},
|
||||
web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, RequestGeneration, WorkerResponse},
|
||||
};
|
||||
use cleanup::{ScopedWorker, shutdown_scoped_worker};
|
||||
|
||||
pub(super) use super::web_surface_runtime_session::{
|
||||
WebSurfaceEnsureResult, WebSurfaceRuntimeFrame, WebSurfaceUrlChange, WebSurfaceUrlChangeKind,
|
||||
};
|
||||
|
||||
pub(super) struct WebSurfaceRuntime {
|
||||
worker: Option<ScopedWorker>,
|
||||
direct_client: Option<ScopedDirectClient>,
|
||||
pending_direct_responses: Vec<WorkerResponse>,
|
||||
workers: BTreeMap<WebSurfaceRuntimeScope, ScopedWorker>,
|
||||
sessions: BTreeMap<TabId, WebSurfaceSession>,
|
||||
retry_state: BTreeMap<WebSurfaceRuntimeScope, ScopeRetryState>,
|
||||
retired_workers: Vec<JoinHandle<Result<(), String>>>,
|
||||
transient_cleanup_error: Option<String>,
|
||||
client_factory: LiveRuntimeClientFactory,
|
||||
last_generation: u64,
|
||||
}
|
||||
|
||||
const SIDECAR_RESTART_BASE_DELAY: Duration = Duration::from_millis(250);
|
||||
const SIDECAR_RESTART_MAX_DELAY: Duration = Duration::from_secs(5);
|
||||
impl WebSurfaceRuntime {
|
||||
pub(super) fn new() -> Self {
|
||||
let transient_cleanup_error =
|
||||
cleanup_stale_transient_profile_data_dirs().err().map(|error| error.to_string());
|
||||
Self {
|
||||
worker: None,
|
||||
direct_client: None,
|
||||
pending_direct_responses: Vec::new(),
|
||||
workers: BTreeMap::new(),
|
||||
sessions: BTreeMap::new(),
|
||||
retry_state: BTreeMap::new(),
|
||||
retired_workers: Vec::new(),
|
||||
transient_cleanup_error,
|
||||
client_factory: new_servo_live_client,
|
||||
last_generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self {
|
||||
Self {
|
||||
worker: None,
|
||||
direct_client: None,
|
||||
pending_direct_responses: Vec::new(),
|
||||
workers: BTreeMap::new(),
|
||||
sessions: BTreeMap::new(),
|
||||
retry_state: BTreeMap::new(),
|
||||
retired_workers: Vec::new(),
|
||||
transient_cleanup_error: None,
|
||||
client_factory,
|
||||
last_generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn ensure_tab(
|
||||
&mut self,
|
||||
tab: &BrowserTab,
|
||||
@@ -70,48 +80,11 @@ impl WebSurfaceRuntime {
|
||||
profile_data_mode: ProfileDataMode,
|
||||
permissions: &[WebSurfaceSitePermission],
|
||||
input: WebSurfacePendingInput,
|
||||
) -> Result<WebSurfaceEnsureResult, String> {
|
||||
self.ensure_tab_inner(tab, size, None, profile_data_mode, permissions, input)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_tab_with_native_surface(
|
||||
&mut self,
|
||||
tab: &BrowserTab,
|
||||
size: WebSurfaceSize,
|
||||
native_surface: NativeSurfaceHandle,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
permissions: &[WebSurfaceSitePermission],
|
||||
input: WebSurfacePendingInput,
|
||||
) -> Result<WebSurfaceEnsureResult, String> {
|
||||
self.ensure_tab_inner(
|
||||
tab,
|
||||
size,
|
||||
Some(native_surface),
|
||||
profile_data_mode,
|
||||
permissions,
|
||||
input,
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_tab_inner(
|
||||
&mut self,
|
||||
tab: &BrowserTab,
|
||||
size: WebSurfaceSize,
|
||||
native_surface: Option<NativeSurfaceHandle>,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
permissions: &[WebSurfaceSitePermission],
|
||||
input: WebSurfacePendingInput,
|
||||
) -> Result<WebSurfaceEnsureResult, String> {
|
||||
let scope = WebSurfaceRuntimeScope::new(tab.profile_id().clone(), profile_data_mode);
|
||||
let use_direct_client = native_surface.is_some();
|
||||
if use_direct_client {
|
||||
self.ensure_direct_client(scope.clone())?;
|
||||
} else {
|
||||
self.ensure_worker(scope.clone())?;
|
||||
}
|
||||
self.prepare_tab_scope(tab.id(), tab.profile_id(), profile_data_mode)?;
|
||||
|
||||
let requested_url = tab.url().as_str().to_string();
|
||||
let tab_id_string = tab.id().as_str().to_string();
|
||||
let zoom_percent = tab.zoom_percent();
|
||||
let enqueued_at = input.enqueued_at;
|
||||
let input_kind = pending_input_kind(&input);
|
||||
@@ -119,6 +92,7 @@ impl WebSurfaceRuntime {
|
||||
scroll_wire_fields(input.scroll_delta, input.scroll_point)?;
|
||||
let user_navigation_input = input_requests_history_navigation(&input);
|
||||
let next_scroll_offset = input.scroll_offset;
|
||||
let generation = self.next_request_generation();
|
||||
|
||||
let submitted_at = Instant::now();
|
||||
let started_loading = {
|
||||
@@ -134,6 +108,7 @@ impl WebSurfaceRuntime {
|
||||
session.size = size;
|
||||
session.zoom_percent = zoom_percent;
|
||||
session.scroll_offset = next_scroll_offset;
|
||||
session.generation = Some(generation);
|
||||
session.cadence.note_ensure(input_kind, started_loading, submitted_at);
|
||||
started_loading
|
||||
};
|
||||
@@ -146,7 +121,6 @@ impl WebSurfaceRuntime {
|
||||
height: size.height,
|
||||
page_zoom_percent: zoom_percent,
|
||||
device_pixel_ratio: size.device_pixel_ratio_f32(),
|
||||
native_surface,
|
||||
scroll_delta_x,
|
||||
scroll_delta_y,
|
||||
scroll_point_x,
|
||||
@@ -159,15 +133,10 @@ impl WebSurfaceRuntime {
|
||||
site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(),
|
||||
};
|
||||
|
||||
if use_direct_client {
|
||||
let response = self.ensure_direct(request, tab_id_string.clone())?;
|
||||
self.pending_direct_responses.extend(response);
|
||||
} else {
|
||||
let Some(scoped) = self.worker.as_ref() else {
|
||||
return Err("Servo worker was created but is no longer registered".to_string());
|
||||
};
|
||||
scoped.worker.submit_ensure(request);
|
||||
}
|
||||
let Some(scoped) = self.workers.get(&scope) else {
|
||||
return Err("Servo sidecar worker was created but is no longer registered".to_string());
|
||||
};
|
||||
scoped.worker.submit_ensure(generation, request);
|
||||
if let Some(session) = self.sessions.get_mut(tab.id()) {
|
||||
session.cadence.note_poll_submitted(submitted_at);
|
||||
}
|
||||
@@ -177,42 +146,45 @@ impl WebSurfaceRuntime {
|
||||
}
|
||||
|
||||
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec<WebSurfaceRuntimeFrame> {
|
||||
self.reap_retired_workers(false);
|
||||
let mut frames = Vec::new();
|
||||
let now = Instant::now();
|
||||
let mut responses = std::mem::take(&mut self.pending_direct_responses);
|
||||
responses.extend(
|
||||
self.worker.as_ref().map(|scoped| scoped.worker.drain_responses()).unwrap_or_default(),
|
||||
);
|
||||
let runtime_unavailable = self.collect_responses(responses, now, &mut frames);
|
||||
if runtime_unavailable {
|
||||
self.remove_worker();
|
||||
self.remove_direct_client();
|
||||
let scopes = self.workers.keys().cloned().collect::<Vec<_>>();
|
||||
let mut unavailable_scopes = Vec::new();
|
||||
|
||||
for scope in scopes {
|
||||
let responses = self
|
||||
.workers
|
||||
.get(&scope)
|
||||
.map(|scoped| scoped.worker.drain_responses())
|
||||
.unwrap_or_default();
|
||||
if self.collect_responses(&scope, responses, now, &mut frames) {
|
||||
unavailable_scopes.push(scope);
|
||||
}
|
||||
}
|
||||
for scope in unavailable_scopes {
|
||||
self.invalidate_scope(&scope, now);
|
||||
}
|
||||
|
||||
let poll_now = Instant::now();
|
||||
let mut direct_polls = Vec::new();
|
||||
for tab_id in visible_tab_ids {
|
||||
let Some(session) = self.sessions.get_mut(tab_id) else {
|
||||
let Some(scope) = self.sessions.get(tab_id).and_then(|session| {
|
||||
session.cadence.should_poll(poll_now).then(|| session.scope.clone())
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
if !session.cadence.should_poll(poll_now) {
|
||||
if !self.workers.contains_key(&scope) {
|
||||
continue;
|
||||
}
|
||||
if let Some(scoped) = self.worker.as_ref() {
|
||||
let _ = scoped.worker.submit_poll(tab_id.as_str().to_string());
|
||||
let generation = self.next_request_generation();
|
||||
let submitted = self.workers.get(&scope).is_some_and(|scoped| {
|
||||
scoped.worker.submit_poll(generation, tab_id.as_str().to_string())
|
||||
});
|
||||
if let Some(session) = self.sessions.get_mut(tab_id) {
|
||||
if submitted {
|
||||
session.generation = Some(generation);
|
||||
}
|
||||
session.cadence.note_poll_submitted(poll_now);
|
||||
} else if self.direct_client.is_some() {
|
||||
direct_polls.push(tab_id.as_str().to_string());
|
||||
session.cadence.note_poll_submitted(poll_now);
|
||||
}
|
||||
}
|
||||
|
||||
if !direct_polls.is_empty() {
|
||||
let (responses, runtime_unavailable) = self.poll_direct(direct_polls);
|
||||
let unavailable_from_responses =
|
||||
self.collect_responses(responses, Instant::now(), &mut frames);
|
||||
if runtime_unavailable || unavailable_from_responses {
|
||||
self.remove_direct_client();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,95 +199,104 @@ impl WebSurfaceRuntime {
|
||||
visible_tab_ids
|
||||
.iter()
|
||||
.filter_map(|tab_id| self.sessions.get(tab_id))
|
||||
.filter(|_| self.worker.is_some() || self.direct_client.is_some())
|
||||
.filter(|session| self.workers.contains_key(&session.scope))
|
||||
.map(|session| session.cadence.next_poll_delay(now))
|
||||
.min()
|
||||
}
|
||||
|
||||
pub(super) fn close_tab(&mut self, tab_id: &TabId) {
|
||||
if self.sessions.remove(tab_id).is_none() {
|
||||
let Some(session) = self.sessions.remove(tab_id) else {
|
||||
return;
|
||||
}
|
||||
let direct_result = self
|
||||
.direct_client
|
||||
.as_mut()
|
||||
.map(|scoped| scoped.client.close(tab_id.as_str().to_string()));
|
||||
if direct_result.as_ref().is_some_and(|result| {
|
||||
result.as_ref().is_err_and(|error| error.is_runtime_unavailable())
|
||||
}) {
|
||||
self.remove_direct_client();
|
||||
}
|
||||
if let Some(scoped) = self.worker.as_ref() {
|
||||
};
|
||||
let has_remaining_session =
|
||||
self.sessions.values().any(|candidate| candidate.scope == session.scope);
|
||||
if session.scope.is_transient() && !has_remaining_session {
|
||||
self.remove_worker(&session.scope);
|
||||
} else if let Some(scoped) = self.workers.get(&session.scope) {
|
||||
scoped.worker.submit_close(tab_id.as_str().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn has_session(
|
||||
&self,
|
||||
tab_id: &TabId,
|
||||
profile_id: &ely_domain::ProfileId,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
) -> bool {
|
||||
self.sessions.get(tab_id).is_some_and(|session| {
|
||||
session.scope == WebSurfaceRuntimeScope::new(profile_id.clone(), profile_data_mode)
|
||||
&& self.workers.contains_key(&session.scope)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn prepare_tab_scope(
|
||||
&mut self,
|
||||
tab_id: &TabId,
|
||||
profile_id: &ely_domain::ProfileId,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
) -> Result<(), String> {
|
||||
let scope = WebSurfaceRuntimeScope::new(profile_id.clone(), profile_data_mode);
|
||||
self.detach_tab_from_previous_scope(tab_id, &scope);
|
||||
self.ensure_worker(scope.clone())?;
|
||||
session_for_scope(&mut self.sessions, tab_id, scope);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_worker(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
|
||||
if self.worker.is_some() {
|
||||
self.reap_retired_workers(false);
|
||||
if self.workers.contains_key(&scope) {
|
||||
return Ok(());
|
||||
}
|
||||
if scope.is_transient() && self.transient_cleanup_error.is_some() {
|
||||
match cleanup_stale_transient_profile_data_dirs() {
|
||||
Ok(()) => self.transient_cleanup_error = None,
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
self.transient_cleanup_error = Some(message.clone());
|
||||
return Err(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
let now = Instant::now();
|
||||
if let Some(retry) = self.retry_state.get(&scope)
|
||||
&& now < retry.retry_after
|
||||
{
|
||||
return Err("Servo sidecar restart is cooling down".to_string());
|
||||
}
|
||||
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
|
||||
let client_factory = self.client_factory;
|
||||
let worker = LiveRuntimeWorker::new(move || client_factory(config_dir))?;
|
||||
self.worker = Some(ScopedWorker { worker, transient_profile_data_dir });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_direct_client(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
|
||||
if self.direct_client.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
|
||||
let client = (self.client_factory)(config_dir)?;
|
||||
self.direct_client = Some(ScopedDirectClient { client, transient_profile_data_dir });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_direct(
|
||||
&mut self,
|
||||
request: ServoLiveEnsureRequest,
|
||||
tab_id: String,
|
||||
) -> Result<Option<WorkerResponse>, String> {
|
||||
let Some(scoped) = self.direct_client.as_mut() else {
|
||||
return Err("Servo client was created but is no longer registered".to_string());
|
||||
};
|
||||
match scoped.client.ensure(request) {
|
||||
Ok(Some(frame)) => Ok(Some(WorkerResponse::Frame { tab_id, frame })),
|
||||
Ok(None) => Ok(None),
|
||||
let worker = match LiveRuntimeWorker::new(move || client_factory(config_dir)) {
|
||||
Ok(worker) => worker,
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
if error.is_runtime_unavailable() {
|
||||
self.remove_direct_client();
|
||||
}
|
||||
Err(message)
|
||||
self.note_scope_failure(&scope, now);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
self.workers.insert(scope, ScopedWorker { worker, transient_profile_data_dir });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll_direct(&mut self, tab_ids: Vec<String>) -> (Vec<WorkerResponse>, bool) {
|
||||
let Some(scoped) = self.direct_client.as_mut() else {
|
||||
return (Vec::new(), false);
|
||||
fn detach_tab_from_previous_scope(&mut self, tab_id: &TabId, scope: &WebSurfaceRuntimeScope) {
|
||||
let Some(previous_scope) = self.sessions.get(tab_id).map(|session| session.scope.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut responses = Vec::new();
|
||||
let mut runtime_unavailable = false;
|
||||
for tab_id in tab_ids {
|
||||
match scoped.client.poll(tab_id.clone()) {
|
||||
Ok(Some(frame)) => responses.push(WorkerResponse::Frame { tab_id, frame }),
|
||||
Ok(None) => {}
|
||||
Err(error) if error.is_runtime_unavailable() => {
|
||||
runtime_unavailable = true;
|
||||
responses.push(WorkerResponse::RuntimeUnavailable);
|
||||
}
|
||||
Err(error) => {
|
||||
responses.push(WorkerResponse::Failed { tab_id, message: error.to_string() })
|
||||
}
|
||||
}
|
||||
if &previous_scope == scope {
|
||||
return;
|
||||
}
|
||||
self.sessions.remove(tab_id);
|
||||
let has_remaining_session =
|
||||
self.sessions.values().any(|candidate| candidate.scope == previous_scope);
|
||||
if previous_scope.is_transient() && !has_remaining_session {
|
||||
self.remove_worker(&previous_scope);
|
||||
} else if let Some(scoped) = self.workers.get(&previous_scope) {
|
||||
scoped.worker.submit_close(tab_id.as_str().to_string());
|
||||
}
|
||||
(responses, runtime_unavailable)
|
||||
}
|
||||
|
||||
fn collect_responses(
|
||||
&mut self,
|
||||
scope: &WebSurfaceRuntimeScope,
|
||||
responses: Vec<WorkerResponse>,
|
||||
now: Instant,
|
||||
frames: &mut Vec<WebSurfaceRuntimeFrame>,
|
||||
@@ -323,18 +304,23 @@ impl WebSurfaceRuntime {
|
||||
let mut runtime_unavailable = false;
|
||||
for response in responses {
|
||||
match response {
|
||||
WorkerResponse::Frame { tab_id, frame } => {
|
||||
WorkerResponse::Frame { generation, tab_id, frame } => {
|
||||
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
|
||||
continue;
|
||||
};
|
||||
let session = match self.sessions.get_mut(&tab_id_obj) {
|
||||
Some(session) => session,
|
||||
None => continue,
|
||||
if !self.sessions.get(&tab_id_obj).is_some_and(|session| {
|
||||
&session.scope == scope && session.generation == Some(generation)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
self.note_scope_success(scope);
|
||||
let Some(session) = self.sessions.get_mut(&tab_id_obj) else {
|
||||
continue;
|
||||
};
|
||||
let requested_url = session.requested_url.clone();
|
||||
let scroll_offset = session.scroll_offset;
|
||||
let zoom_percent = session.zoom_percent;
|
||||
session.cadence.note_frame(frame.render_state(), now);
|
||||
session.cadence.note_frame(frame.render_state(), frame.pixels_changed(), now);
|
||||
match WebSurfaceFrame::from_live_frame(
|
||||
requested_url.clone(),
|
||||
scroll_offset,
|
||||
@@ -356,15 +342,17 @@ impl WebSurfaceRuntime {
|
||||
}),
|
||||
}
|
||||
}
|
||||
WorkerResponse::Failed { tab_id, message } => {
|
||||
WorkerResponse::Failed { generation, tab_id, message } => {
|
||||
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
|
||||
continue;
|
||||
};
|
||||
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
|
||||
}
|
||||
WorkerResponse::RuntimeUnavailable => {
|
||||
runtime_unavailable = true;
|
||||
if self.sessions.get(&tab_id_obj).is_some_and(|session| {
|
||||
&session.scope == scope && session.generation == Some(generation)
|
||||
}) {
|
||||
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
|
||||
}
|
||||
}
|
||||
WorkerResponse::RuntimeUnavailable => runtime_unavailable = true,
|
||||
}
|
||||
}
|
||||
runtime_unavailable
|
||||
@@ -374,9 +362,15 @@ impl WebSurfaceRuntime {
|
||||
self.sessions.keys().find(|key| key.as_str() == tab_id).cloned()
|
||||
}
|
||||
|
||||
fn next_request_generation(&mut self) -> RequestGeneration {
|
||||
assert!(self.last_generation < u64::MAX, "web surface request generation exhausted");
|
||||
self.last_generation += 1;
|
||||
RequestGeneration::new(self.last_generation)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn client_count_for_test(&self) -> usize {
|
||||
usize::from(self.worker.is_some()) + usize::from(self.direct_client.is_some())
|
||||
self.workers.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -385,39 +379,72 @@ impl WebSurfaceRuntime {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn flush_for_test(&self) {
|
||||
if let Some(scoped) = self.worker.as_ref() {
|
||||
pub(super) fn flush_for_test(&mut self) {
|
||||
for scoped in self.workers.values() {
|
||||
scoped.worker.wait_until_idle();
|
||||
}
|
||||
self.reap_retired_workers(true);
|
||||
}
|
||||
|
||||
fn remove_worker(&mut self) {
|
||||
let Some(scoped) = self.worker.take() else {
|
||||
fn remove_worker(&mut self, scope: &WebSurfaceRuntimeScope) {
|
||||
let Some(scoped) = self.workers.remove(scope) else {
|
||||
return;
|
||||
};
|
||||
let ScopedWorker { worker, transient_profile_data_dir } = scoped;
|
||||
drop(worker);
|
||||
if let Some(path) = transient_profile_data_dir {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
if scoped.transient_profile_data_dir.is_some() {
|
||||
match std::thread::Builder::new()
|
||||
.name("ely-servo-profile-cleanup".to_string())
|
||||
.spawn(move || shutdown_scoped_worker(scoped))
|
||||
{
|
||||
Ok(handle) => self.retired_workers.push(handle),
|
||||
Err(error) => self.transient_cleanup_error = Some(error.to_string()),
|
||||
}
|
||||
} else {
|
||||
let _ = shutdown_scoped_worker(scoped);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_direct_client(&mut self) {
|
||||
let Some(scoped) = self.direct_client.take() else {
|
||||
return;
|
||||
};
|
||||
let ScopedDirectClient { client, transient_profile_data_dir } = scoped;
|
||||
drop(client);
|
||||
if let Some(path) = transient_profile_data_dir {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
fn reap_retired_workers(&mut self, wait_for_all: bool) {
|
||||
let mut pending = Vec::new();
|
||||
for handle in self.retired_workers.drain(..) {
|
||||
if !wait_for_all && !handle.is_finished() {
|
||||
pending.push(handle);
|
||||
continue;
|
||||
}
|
||||
match handle.join() {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => self.transient_cleanup_error = Some(error),
|
||||
Err(_) => {
|
||||
self.transient_cleanup_error =
|
||||
Some("Servo profile cleanup thread panicked".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
self.retired_workers = pending;
|
||||
}
|
||||
|
||||
fn invalidate_scope(&mut self, scope: &WebSurfaceRuntimeScope, now: Instant) {
|
||||
self.remove_worker(scope);
|
||||
self.sessions.retain(|_, session| &session.scope != scope);
|
||||
self.note_scope_failure(scope, now);
|
||||
}
|
||||
|
||||
fn note_scope_failure(&mut self, scope: &WebSurfaceRuntimeScope, now: Instant) {
|
||||
let retry = ScopeRetryState::after_failure(self.retry_state.get(scope), now);
|
||||
self.retry_state.insert(scope.clone(), retry);
|
||||
}
|
||||
|
||||
fn note_scope_success(&mut self, scope: &WebSurfaceRuntimeScope) {
|
||||
self.retry_state.remove(scope);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WebSurfaceRuntime {
|
||||
fn drop(&mut self) {
|
||||
self.remove_worker();
|
||||
self.remove_direct_client();
|
||||
let scopes = self.workers.keys().cloned().collect::<Vec<_>>();
|
||||
for scope in scopes {
|
||||
self.remove_worker(&scope);
|
||||
}
|
||||
self.reap_retired_workers(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,16 +457,38 @@ fn new_servo_live_client(config_dir: PathBuf) -> Result<Box<dyn LiveRuntimeClien
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
struct ScopedWorker {
|
||||
worker: LiveRuntimeWorker,
|
||||
transient_profile_data_dir: Option<PathBuf>,
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ScopeRetryState {
|
||||
failure_count: u32,
|
||||
retry_after: Instant,
|
||||
}
|
||||
|
||||
struct ScopedDirectClient {
|
||||
client: Box<dyn LiveRuntimeClient>,
|
||||
transient_profile_data_dir: Option<PathBuf>,
|
||||
impl ScopeRetryState {
|
||||
fn after_failure(previous: Option<&Self>, now: Instant) -> Self {
|
||||
let failure_count = previous.map_or(1, |state| state.failure_count.saturating_add(1));
|
||||
let shift = failure_count.saturating_sub(1).min(31);
|
||||
let multiplier = 1_u32 << shift;
|
||||
let delay =
|
||||
SIDECAR_RESTART_BASE_DELAY.saturating_mul(multiplier).min(SIDECAR_RESTART_MAX_DELAY);
|
||||
Self { failure_count, retry_after: now + delay }
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "web_surface_runtime_cleanup.rs"]
|
||||
mod cleanup;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_runtime_backpressure_tests.rs"]
|
||||
mod backpressure_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_runtime_cleanup_tests.rs"]
|
||||
mod cleanup_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_runtime_generation_tests.rs"]
|
||||
mod generation_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_runtime_retry_tests.rs"]
|
||||
mod retry_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_runtime_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Mutex, mpsc},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
|
||||
use crate::services::{
|
||||
ProfileDataMode,
|
||||
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
|
||||
};
|
||||
|
||||
use super::{
|
||||
super::{
|
||||
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
|
||||
web_surface_state::WebSurfacePendingInput,
|
||||
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
|
||||
},
|
||||
WebSurfaceRuntime,
|
||||
};
|
||||
|
||||
struct SlowPollClient;
|
||||
|
||||
struct BlockingEnsureClient {
|
||||
ensure_started_tx: mpsc::Sender<()>,
|
||||
release_ensure_rx: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
struct BlockingEnsureSetup {
|
||||
ensure_started_tx: mpsc::Sender<()>,
|
||||
release_ensure_rx: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
static BLOCKING_ENSURE_SETUP: Mutex<Option<BlockingEnsureSetup>> = Mutex::new(None);
|
||||
|
||||
impl LiveRuntimeClient for SlowPollClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveRuntimeClient for BlockingEnsureClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
self.ensure_started_tx.send(()).map_err(|error| error.to_string())?;
|
||||
self.release_ensure_rx.recv().map_err(|error| error.to_string())?;
|
||||
Ok(Some(ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])))
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Err(LiveRuntimeClientError::Message("unexpected poll".to_string()))
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn slow_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
Ok(Box::new(SlowPollClient))
|
||||
}
|
||||
|
||||
fn blocking_ensure_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
let setup = BLOCKING_ENSURE_SETUP
|
||||
.lock()
|
||||
.map_err(|_| "blocking ensure setup lock was poisoned".to_string())?
|
||||
.take()
|
||||
.ok_or_else(|| "blocking ensure setup was missing".to_string())?;
|
||||
Ok(Box::new(BlockingEnsureClient {
|
||||
ensure_started_tx: setup.ensure_started_tx,
|
||||
release_ensure_rx: setup.release_ensure_rx,
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_tick_poll_preserves_in_flight_ensure_generation() -> Result<(), String> {
|
||||
let (ensure_started_tx, ensure_started_rx) = mpsc::channel();
|
||||
let (release_ensure_tx, release_ensure_rx) = mpsc::channel();
|
||||
*BLOCKING_ENSURE_SETUP
|
||||
.lock()
|
||||
.map_err(|_| "blocking ensure setup lock was poisoned".to_string())? =
|
||||
Some(BlockingEnsureSetup { ensure_started_tx, release_ensure_rx });
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(blocking_ensure_client_factory);
|
||||
let tab = web_tab("Blocked ensure")?;
|
||||
|
||||
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
ensure_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
assert!(runtime.tick(std::slice::from_ref(tab.id())).is_empty());
|
||||
|
||||
release_ensure_tx.send(()).map_err(|error| error.to_string())?;
|
||||
runtime.flush_for_test();
|
||||
let frames = runtime.tick(std::slice::from_ref(tab.id()));
|
||||
|
||||
assert!(matches!(
|
||||
frames.as_slice(),
|
||||
[super::WebSurfaceRuntimeFrame::Ready { tab_id, .. }] if tab_id == tab.id()
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_poll_advances_deadline_under_worker_backpressure() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(slow_client_factory);
|
||||
let tab = web_tab("Backpressure")?;
|
||||
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
runtime.flush_for_test();
|
||||
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
runtime.tick(std::slice::from_ref(tab.id()));
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
runtime.tick(std::slice::from_ref(tab.id()));
|
||||
|
||||
let delay = runtime
|
||||
.next_poll_delay(std::slice::from_ref(tab.id()), std::time::Instant::now())
|
||||
.ok_or_else(|| "visible tab lost its poll deadline".to_string())?;
|
||||
assert!(delay > Duration::ZERO, "backpressured poll must not schedule a zero-delay timer");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn web_tab(title: &str) -> Result<BrowserTab, String> {
|
||||
Ok(BrowserTab::new(
|
||||
TabId::new(),
|
||||
SpaceId::new(),
|
||||
ProfileId::new(),
|
||||
title,
|
||||
UrlText::parse("https://example.com/backpressure").map_err(|error| error.to_string())?,
|
||||
))
|
||||
}
|
||||
|
||||
fn surface_size() -> WebSurfaceSize {
|
||||
WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 }
|
||||
}
|
||||
|
||||
fn pending_input() -> WebSurfacePendingInput {
|
||||
WebSurfacePendingInput {
|
||||
enqueued_at: None,
|
||||
scroll_offset: WebSurfaceScrollOffset::default(),
|
||||
scroll_delta: None,
|
||||
scroll_point: None,
|
||||
click_point: None,
|
||||
hover_point: None,
|
||||
typed_text: None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use crate::services::servo_profile_data::TransientProfileDataDir;
|
||||
|
||||
use super::LiveRuntimeWorker;
|
||||
|
||||
pub(super) struct ScopedWorker {
|
||||
pub(super) worker: LiveRuntimeWorker,
|
||||
pub(super) transient_profile_data_dir: Option<TransientProfileDataDir>,
|
||||
}
|
||||
|
||||
pub(super) fn shutdown_scoped_worker(scoped: ScopedWorker) -> Result<(), String> {
|
||||
let ScopedWorker { worker, transient_profile_data_dir } = scoped;
|
||||
drop(worker);
|
||||
let Some(directory) = transient_profile_data_dir else {
|
||||
return Ok(());
|
||||
};
|
||||
directory
|
||||
.close()
|
||||
.map_err(|error| format!("failed to remove transient Servo profile data: {error}"))
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
|
||||
use crate::{
|
||||
services::{
|
||||
ProfileDataMode,
|
||||
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
|
||||
},
|
||||
shell::{
|
||||
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
|
||||
web_surface_state::WebSurfacePendingInput,
|
||||
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
|
||||
},
|
||||
};
|
||||
|
||||
use super::WebSurfaceRuntime;
|
||||
|
||||
static FACTORY_CALLS: AtomicUsize = AtomicUsize::new(0);
|
||||
static DROP_SETUP: Mutex<Option<DropSetup>> = Mutex::new(None);
|
||||
static FACTORY_PATHS: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
|
||||
|
||||
struct DropSetup {
|
||||
started_tx: mpsc::Sender<()>,
|
||||
release_rx: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
struct BlockingDropClient {
|
||||
started_tx: mpsc::Sender<()>,
|
||||
release_rx: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
impl LiveRuntimeClient for BlockingDropClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BlockingDropClient {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.started_tx.send(());
|
||||
let _ = self.release_rx.recv();
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyClient;
|
||||
|
||||
impl LiveRuntimeClient for EmptyClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_worker_creation_uses_fresh_storage_during_previous_cleanup() -> Result<(), String> {
|
||||
FACTORY_CALLS.store(0, Ordering::SeqCst);
|
||||
FACTORY_PATHS.lock().map_err(|_| "factory paths lock was poisoned")?.clear();
|
||||
let (started_tx, started_rx) = mpsc::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
*DROP_SETUP.lock().map_err(|_| "drop setup lock was poisoned")? =
|
||||
Some(DropSetup { started_tx, release_rx });
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(cleanup_client_factory);
|
||||
let first = web_tab(ProfileId::new())?;
|
||||
let second = web_tab(ProfileId::new())?;
|
||||
|
||||
runtime.ensure_tab(&first, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
runtime.flush_for_test();
|
||||
runtime.close_tab(first.id());
|
||||
started_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.map_err(|error| format!("cleanup did not start: {error}"))?;
|
||||
|
||||
runtime.ensure_tab(
|
||||
&second,
|
||||
surface_size(),
|
||||
ProfileDataMode::Transient,
|
||||
&[],
|
||||
pending_input(),
|
||||
)?;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
while FACTORY_CALLS.load(Ordering::SeqCst) < 2 && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
assert_eq!(FACTORY_CALLS.load(Ordering::SeqCst), 2);
|
||||
let paths = FACTORY_PATHS.lock().map_err(|_| "factory paths lock was poisoned")?.clone();
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert_ne!(paths[0], paths[1]);
|
||||
assert!(paths.iter().all(|path| path.is_dir()));
|
||||
|
||||
release_tx.send(()).map_err(|error| error.to_string())?;
|
||||
runtime.flush_for_test();
|
||||
assert!(!paths[0].exists());
|
||||
assert!(paths[1].is_dir());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_client_factory(
|
||||
config_dir: std::path::PathBuf,
|
||||
) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
FACTORY_PATHS
|
||||
.lock()
|
||||
.map_err(|_| "factory paths lock was poisoned".to_string())?
|
||||
.push(config_dir);
|
||||
if FACTORY_CALLS.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
let setup = DROP_SETUP
|
||||
.lock()
|
||||
.map_err(|_| "drop setup lock was poisoned".to_string())?
|
||||
.take()
|
||||
.ok_or_else(|| "drop setup was missing".to_string())?;
|
||||
Ok(Box::new(BlockingDropClient {
|
||||
started_tx: setup.started_tx,
|
||||
release_rx: setup.release_rx,
|
||||
}))
|
||||
} else {
|
||||
Ok(Box::new(EmptyClient))
|
||||
}
|
||||
}
|
||||
|
||||
fn web_tab(profile_id: ProfileId) -> Result<BrowserTab, String> {
|
||||
Ok(BrowserTab::new(
|
||||
TabId::new(),
|
||||
SpaceId::new(),
|
||||
profile_id,
|
||||
"Private",
|
||||
UrlText::parse("https://example.com/private").map_err(|error| error.to_string())?,
|
||||
))
|
||||
}
|
||||
|
||||
fn surface_size() -> WebSurfaceSize {
|
||||
WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 }
|
||||
}
|
||||
|
||||
fn pending_input() -> WebSurfacePendingInput {
|
||||
WebSurfacePendingInput {
|
||||
enqueued_at: None,
|
||||
scroll_offset: WebSurfaceScrollOffset::default(),
|
||||
scroll_delta: None,
|
||||
scroll_point: None,
|
||||
click_point: None,
|
||||
hover_point: None,
|
||||
typed_text: None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
|
||||
use crate::services::{
|
||||
ProfileDataMode,
|
||||
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
|
||||
};
|
||||
|
||||
use super::{
|
||||
super::{
|
||||
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
|
||||
web_surface_state::WebSurfacePendingInput,
|
||||
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError, WorkerResponse},
|
||||
},
|
||||
WebSurfaceRuntime, WebSurfaceRuntimeFrame, WebSurfaceRuntimeScope,
|
||||
};
|
||||
|
||||
struct EmptyClient;
|
||||
|
||||
static FAILING_FACTORY_CALLS: AtomicUsize = AtomicUsize::new(0);
|
||||
static FAILING_FACTORY_SHUTDOWNS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
impl LiveRuntimeClient for EmptyClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct CloseRecordingClient;
|
||||
|
||||
impl LiveRuntimeClient for CloseRecordingClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CloseRecordingClient {
|
||||
fn drop(&mut self) {
|
||||
FAILING_FACTORY_SHUTDOWNS.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_frame_from_a_is_discarded_after_a_to_b() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
|
||||
let tab_id = TabId::new();
|
||||
let profile_a = ProfileId::new();
|
||||
let profile_b = ProfileId::new();
|
||||
let scope_a = scope(&profile_a);
|
||||
let tab_a = web_tab(tab_id.clone(), profile_a, "https://example.com/a")?;
|
||||
let tab_b = web_tab(tab_id.clone(), profile_b, "https://example.com/b")?;
|
||||
|
||||
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
let generation_a = runtime
|
||||
.sessions
|
||||
.get(&tab_id)
|
||||
.and_then(|session| session.generation)
|
||||
.ok_or_else(|| "profile A ensure generation was missing".to_string())?;
|
||||
runtime.ensure_tab(&tab_b, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
let generation_b = runtime
|
||||
.sessions
|
||||
.get(&tab_id)
|
||||
.and_then(|session| session.generation)
|
||||
.ok_or_else(|| "profile B ensure generation was missing".to_string())?;
|
||||
|
||||
let mut frames = Vec::new();
|
||||
runtime.collect_responses(
|
||||
&scope_a,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation: generation_a,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: live_frame(),
|
||||
}],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
|
||||
assert!(generation_a < generation_b);
|
||||
assert!(frames.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_blocks_aba_frames_and_failures() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
|
||||
let tab_id = TabId::new();
|
||||
let profile_a = ProfileId::new();
|
||||
let profile_b = ProfileId::new();
|
||||
let scope_a = scope(&profile_a);
|
||||
let tab_a = web_tab(tab_id.clone(), profile_a, "https://example.com/a")?;
|
||||
let tab_b = web_tab(tab_id.clone(), profile_b, "https://example.com/b")?;
|
||||
|
||||
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
let first_a = current_generation(&runtime, &tab_id)?;
|
||||
runtime.ensure_tab(&tab_b, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
let generation_b = current_generation(&runtime, &tab_id)?;
|
||||
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
let second_a = current_generation(&runtime, &tab_id)?;
|
||||
|
||||
let mut frames = Vec::new();
|
||||
runtime.collect_responses(
|
||||
&scope_a,
|
||||
vec![
|
||||
WorkerResponse::Frame {
|
||||
generation: first_a,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: live_frame(),
|
||||
},
|
||||
WorkerResponse::Failed {
|
||||
generation: first_a,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
message: "stale failure".to_string(),
|
||||
},
|
||||
],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
assert!(frames.is_empty());
|
||||
|
||||
runtime.collect_responses(
|
||||
&scope_a,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation: second_a,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: live_frame(),
|
||||
}],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
|
||||
assert!(first_a < generation_b && generation_b < second_a);
|
||||
assert!(matches!(
|
||||
frames.as_slice(),
|
||||
[WebSurfaceRuntimeFrame::Ready { tab_id: ready_tab_id, .. }] if ready_tab_id == &tab_id
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_creation_failure_invalidates_previous_session_generation() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
|
||||
let tab_id = TabId::new();
|
||||
let profile_a = ProfileId::new();
|
||||
let profile_b = ProfileId::new();
|
||||
let scope_a = scope(&profile_a);
|
||||
let scope_b = scope(&profile_b);
|
||||
let tab_a = web_tab(tab_id.clone(), profile_a, "https://example.com/a")?;
|
||||
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
let generation_a = current_generation(&runtime, &tab_id)?;
|
||||
runtime.retry_state.insert(
|
||||
scope_b,
|
||||
super::ScopeRetryState {
|
||||
failure_count: 1,
|
||||
retry_after: Instant::now() + std::time::Duration::from_secs(1),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(runtime.prepare_tab_scope(&tab_id, &profile_b, ProfileDataMode::Transient).is_err());
|
||||
assert!(!runtime.sessions.contains_key(&tab_id));
|
||||
|
||||
let mut frames = Vec::new();
|
||||
runtime.collect_responses(
|
||||
&scope_a,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation: generation_a,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: live_frame(),
|
||||
}],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
assert!(frames.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_frame_preserves_scope_backoff_until_current_frame() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
|
||||
let tab_id = TabId::new();
|
||||
let profile_id = ProfileId::new();
|
||||
let scope = scope(&profile_id);
|
||||
let tab = web_tab(tab_id.clone(), profile_id, "https://example.com/current")?;
|
||||
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
runtime.flush_for_test();
|
||||
let current = current_generation(&runtime, &tab_id)?;
|
||||
runtime.note_scope_failure(&scope, Instant::now());
|
||||
|
||||
let mut frames = Vec::new();
|
||||
runtime.collect_responses(
|
||||
&scope,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation: super::super::web_surface_worker::RequestGeneration::new(0),
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: live_frame(),
|
||||
}],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
assert!(frames.is_empty());
|
||||
assert!(runtime.retry_state.contains_key(&scope));
|
||||
|
||||
runtime.collect_responses(
|
||||
&scope,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation: current,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: live_frame(),
|
||||
}],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
assert!(matches!(frames.as_slice(), [WebSurfaceRuntimeFrame::Ready { .. }]));
|
||||
assert!(!runtime.retry_state.contains_key(&scope));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_scope_shuts_down_when_new_worker_factory_fails_async() -> Result<(), String> {
|
||||
FAILING_FACTORY_CALLS.store(0, Ordering::SeqCst);
|
||||
FAILING_FACTORY_SHUTDOWNS.store(0, Ordering::SeqCst);
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fail_second_client_factory);
|
||||
let tab_id = TabId::new();
|
||||
let tab_a = web_tab(tab_id.clone(), ProfileId::new(), "https://example.com/a")?;
|
||||
let tab_b = web_tab(tab_id, ProfileId::new(), "https://example.com/b")?;
|
||||
|
||||
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
runtime.flush_for_test();
|
||||
runtime.ensure_tab(&tab_b, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
runtime.flush_for_test();
|
||||
let frames = runtime.tick(std::slice::from_ref(tab_b.id()));
|
||||
|
||||
assert_eq!(FAILING_FACTORY_SHUTDOWNS.load(Ordering::SeqCst), 1);
|
||||
assert!(matches!(
|
||||
frames.as_slice(),
|
||||
[WebSurfaceRuntimeFrame::Failed { message, .. }]
|
||||
if message == "injected client creation failure"
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_generation(
|
||||
runtime: &WebSurfaceRuntime,
|
||||
tab_id: &TabId,
|
||||
) -> Result<super::super::web_surface_worker::RequestGeneration, String> {
|
||||
runtime
|
||||
.sessions
|
||||
.get(tab_id)
|
||||
.and_then(|session| session.generation)
|
||||
.ok_or_else(|| "session generation was missing".to_string())
|
||||
}
|
||||
|
||||
fn empty_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
Ok(Box::new(EmptyClient))
|
||||
}
|
||||
|
||||
fn fail_second_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
if FAILING_FACTORY_CALLS.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
Ok(Box::new(CloseRecordingClient))
|
||||
} else {
|
||||
Err("injected client creation failure".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn scope(profile_id: &ProfileId) -> WebSurfaceRuntimeScope {
|
||||
WebSurfaceRuntimeScope::new(profile_id.clone(), ProfileDataMode::Transient)
|
||||
}
|
||||
|
||||
fn web_tab(tab_id: TabId, profile_id: ProfileId, url: &str) -> Result<BrowserTab, String> {
|
||||
Ok(BrowserTab::new(
|
||||
tab_id,
|
||||
SpaceId::new(),
|
||||
profile_id,
|
||||
"Web",
|
||||
UrlText::parse(url).map_err(|error| error.to_string())?,
|
||||
))
|
||||
}
|
||||
|
||||
fn surface_size() -> WebSurfaceSize {
|
||||
WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 }
|
||||
}
|
||||
|
||||
fn pending_input() -> WebSurfacePendingInput {
|
||||
WebSurfacePendingInput {
|
||||
enqueued_at: None,
|
||||
scroll_offset: WebSurfaceScrollOffset::default(),
|
||||
scroll_delta: None,
|
||||
scroll_point: None,
|
||||
click_point: None,
|
||||
hover_point: None,
|
||||
typed_text: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn live_frame() -> ServoLiveFrame {
|
||||
ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ely_domain::{ProfileId, TabId};
|
||||
|
||||
use crate::services::{ProfileDataMode, servo_live::ServoLiveFrame};
|
||||
|
||||
use super::{
|
||||
super::{
|
||||
web_surface_runtime_session::session_for_scope,
|
||||
web_surface_worker::{RequestGeneration, WorkerResponse},
|
||||
},
|
||||
SIDECAR_RESTART_BASE_DELAY, SIDECAR_RESTART_MAX_DELAY, ScopeRetryState, WebSurfaceRuntime,
|
||||
WebSurfaceRuntimeScope,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn retry_delay_grows_exponentially_and_caps() {
|
||||
let now = Instant::now();
|
||||
let expected = [
|
||||
Duration::from_millis(250),
|
||||
Duration::from_millis(500),
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(2),
|
||||
Duration::from_secs(4),
|
||||
SIDECAR_RESTART_MAX_DELAY,
|
||||
SIDECAR_RESTART_MAX_DELAY,
|
||||
];
|
||||
let mut state = None;
|
||||
|
||||
for delay in expected {
|
||||
let next = ScopeRetryState::after_failure(state.as_ref(), now);
|
||||
assert_eq!(next.retry_after.duration_since(now), delay);
|
||||
state = Some(next);
|
||||
}
|
||||
assert_eq!(SIDECAR_RESTART_BASE_DELAY, Duration::from_millis(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_state_is_scoped() {
|
||||
let mut runtime = WebSurfaceRuntime::new();
|
||||
let scope_a = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
|
||||
let scope_b = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
|
||||
let now = Instant::now();
|
||||
|
||||
runtime.note_scope_failure(&scope_a, now);
|
||||
runtime.note_scope_failure(&scope_a, now);
|
||||
runtime.note_scope_failure(&scope_b, now);
|
||||
|
||||
assert_eq!(runtime.retry_state.get(&scope_a).map(|state| state.failure_count), Some(2));
|
||||
assert_eq!(runtime.retry_state.get(&scope_b).map(|state| state.failure_count), Some(1));
|
||||
assert_eq!(runtime.retry_state.get(&scope_b).map(|state| state.failure_count), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_frame_resets_scope_retry_state() {
|
||||
let mut runtime = WebSurfaceRuntime::new();
|
||||
let scope = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
|
||||
let tab_id = TabId::new();
|
||||
let generation = RequestGeneration::new(1);
|
||||
session_for_scope(&mut runtime.sessions, &tab_id, scope.clone()).generation = Some(generation);
|
||||
runtime.note_scope_failure(&scope, Instant::now());
|
||||
let mut frames = Vec::new();
|
||||
|
||||
runtime.collect_responses(
|
||||
&scope,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255]),
|
||||
}],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
|
||||
assert!(!runtime.retry_state.contains_key(&scope));
|
||||
assert_eq!(frames.len(), 1);
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
use std::{collections::BTreeMap, fs, path::PathBuf};
|
||||
|
||||
use ely_domain::{ProfileId, TabId};
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
use crate::services::{
|
||||
ProfileDataMode,
|
||||
servo_profile_data::{default_profile_data_root, profile_data_dir, transient_profile_data_dir},
|
||||
servo_profile_data::{
|
||||
TransientProfileDataDir, create_profile_data_dir, default_profile_data_root,
|
||||
transient_profile_data_dir,
|
||||
},
|
||||
};
|
||||
use ely_domain::{ProfileId, TabId};
|
||||
|
||||
use super::{
|
||||
web_surface_cadence::WebSurfacePollCadence,
|
||||
web_surface_frame::WebSurfaceFrame,
|
||||
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
|
||||
web_surface_worker::RequestGeneration,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
@@ -23,6 +26,10 @@ impl WebSurfaceRuntimeScope {
|
||||
pub(super) fn new(profile_id: ProfileId, profile_data_mode: ProfileDataMode) -> Self {
|
||||
Self { profile_id, profile_data_mode }
|
||||
}
|
||||
|
||||
pub(super) fn is_transient(&self) -> bool {
|
||||
self.profile_data_mode == ProfileDataMode::Transient
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -33,6 +40,7 @@ pub(super) struct WebSurfaceSession {
|
||||
pub(super) zoom_percent: u16,
|
||||
pub(super) scroll_offset: WebSurfaceScrollOffset,
|
||||
pub(super) pending_user_navigation: bool,
|
||||
pub(super) generation: Option<RequestGeneration>,
|
||||
pub(super) cadence: WebSurfacePollCadence,
|
||||
}
|
||||
|
||||
@@ -45,6 +53,7 @@ impl WebSurfaceSession {
|
||||
zoom_percent: 0,
|
||||
scroll_offset: WebSurfaceScrollOffset::default(),
|
||||
pending_user_navigation: false,
|
||||
generation: None,
|
||||
cadence: WebSurfacePollCadence::default(),
|
||||
}
|
||||
}
|
||||
@@ -110,20 +119,20 @@ pub(super) enum WebSurfaceUrlChangeKind {
|
||||
|
||||
pub(super) fn config_dir_for_scope(
|
||||
scope: &WebSurfaceRuntimeScope,
|
||||
) -> Result<(PathBuf, Option<PathBuf>), String> {
|
||||
) -> Result<(PathBuf, Option<TransientProfileDataDir>), String> {
|
||||
match scope.profile_data_mode {
|
||||
ProfileDataMode::Persistent => {
|
||||
let root = default_profile_data_root()
|
||||
.ok_or_else(|| "Profile data root is unavailable".to_string())?;
|
||||
let config_dir = profile_data_dir(&root, &scope.profile_id);
|
||||
fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?;
|
||||
let config_dir = create_profile_data_dir(&root, &scope.profile_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok((config_dir, None))
|
||||
}
|
||||
ProfileDataMode::Transient => {
|
||||
let config_dir =
|
||||
let transient_dir =
|
||||
transient_profile_data_dir(&scope.profile_id).map_err(|error| error.to_string())?;
|
||||
fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?;
|
||||
Ok((config_dir.clone(), Some(config_dir)))
|
||||
let config_dir = transient_dir.path().to_path_buf();
|
||||
Ok((config_dir, Some(transient_dir)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
sync::{
|
||||
Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
@@ -21,15 +24,28 @@ use super::*;
|
||||
|
||||
use crate::services::servo_live::{ServoLiveEnsureRequest, ServoLiveFrame};
|
||||
|
||||
static FAKE_CLOSE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static FAKE_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static IDLE_SKIP_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static RECOVERY_FACTORY_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static FAILING_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static REPEATED_FRAME_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static CLOSE_CONFIG_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
|
||||
|
||||
#[test]
|
||||
fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), String> {
|
||||
fn transient_scope_owns_removable_servo_storage() -> Result<(), String> {
|
||||
let scope = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
|
||||
let (config_dir, guard) = config_dir_for_scope(&scope)?;
|
||||
let Some(guard) = guard else {
|
||||
return Err("transient scope did not return a storage guard".to_string());
|
||||
};
|
||||
assert_eq!(guard.path(), config_dir);
|
||||
guard.close().map_err(|error| error.to_string())?;
|
||||
assert!(!config_dir.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_routes_profile_scopes_to_distinct_clients() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fake_client_factory);
|
||||
let first_profile = ProfileId::new();
|
||||
let second_profile = ProfileId::new();
|
||||
@@ -60,7 +76,7 @@ fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), Stri
|
||||
|
||||
runtime.flush_for_test();
|
||||
|
||||
assert_eq!(runtime.client_count_for_test(), 1);
|
||||
assert_eq!(runtime.client_count_for_test(), 2);
|
||||
assert_eq!(
|
||||
runtime.session_scope_for_test(first_tab.id()),
|
||||
Some(&WebSurfaceRuntimeScope::new(first_profile, ProfileDataMode::Transient)),
|
||||
@@ -73,22 +89,64 @@ fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), Stri
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_tab_removes_session_and_closes_client() -> Result<(), String> {
|
||||
let before = FAKE_CLOSE_COUNT.load(Ordering::SeqCst);
|
||||
fn delayed_frame_from_previous_profile_scope_is_ignored() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fake_client_factory);
|
||||
let tab = web_tab(TabId::new(), ProfileId::new(), "https://example.com/close")?;
|
||||
let tab_id = TabId::new();
|
||||
let first_profile = ProfileId::new();
|
||||
let second_profile = ProfileId::new();
|
||||
let first_scope =
|
||||
WebSurfaceRuntimeScope::new(first_profile.clone(), ProfileDataMode::Transient);
|
||||
let second_scope = WebSurfaceRuntimeScope::new(second_profile, ProfileDataMode::Transient);
|
||||
let first_tab = web_tab(tab_id.clone(), first_profile, "https://example.com/shared")?;
|
||||
|
||||
runtime.ensure_tab(
|
||||
&first_tab,
|
||||
surface_size(),
|
||||
ProfileDataMode::Transient,
|
||||
&[],
|
||||
pending_input(),
|
||||
)?;
|
||||
runtime.flush_for_test();
|
||||
session_for_scope(&mut runtime.sessions, &tab_id, second_scope);
|
||||
|
||||
let mut frames = Vec::new();
|
||||
let unavailable = runtime.collect_responses(
|
||||
&first_scope,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation: RequestGeneration::new(1),
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: repeated_live_frame(),
|
||||
}],
|
||||
std::time::Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
|
||||
assert!(!unavailable);
|
||||
assert!(frames.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_last_transient_tab_removes_session_worker_and_profile_data() -> Result<(), String> {
|
||||
*CLOSE_CONFIG_DIR.lock().map_err(|_| "close config lock was poisoned")? = None;
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(close_client_factory);
|
||||
let tab = web_tab(TabId::new(), ProfileId::new(), "https://example.com/close")?;
|
||||
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
runtime.flush_for_test();
|
||||
let config_dir = CLOSE_CONFIG_DIR
|
||||
.lock()
|
||||
.map_err(|_| "close config lock was poisoned")?
|
||||
.clone()
|
||||
.ok_or_else(|| "close client config directory was missing".to_string())?;
|
||||
assert!(config_dir.is_dir());
|
||||
runtime.close_tab(tab.id());
|
||||
runtime.flush_for_test();
|
||||
|
||||
assert_eq!(runtime.session_scope_for_test(tab.id()), None);
|
||||
assert_eq!(FAKE_CLOSE_COUNT.load(Ordering::SeqCst), before + 1);
|
||||
|
||||
assert_eq!(runtime.client_count_for_test(), 0);
|
||||
assert!(!config_dir.exists());
|
||||
runtime.close_tab(tab.id());
|
||||
runtime.flush_for_test();
|
||||
assert_eq!(FAKE_CLOSE_COUNT.load(Ordering::SeqCst), before + 1);
|
||||
assert_eq!(runtime.client_count_for_test(), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -207,6 +265,7 @@ fn runtime_unavailable_removes_dead_runtime_client() -> Result<(), String> {
|
||||
assert_eq!(runtime.client_count_for_test(), 0);
|
||||
|
||||
let next_tab = web_tab(TabId::new(), profile, "https://example.com/next")?;
|
||||
std::thread::sleep(super::SIDECAR_RESTART_BASE_DELAY + Duration::from_millis(10));
|
||||
runtime.ensure_tab(
|
||||
&next_tab,
|
||||
surface_size(),
|
||||
@@ -222,7 +281,7 @@ fn runtime_unavailable_removes_dead_runtime_client() -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_surface_ensure_waits_for_a_new_key_before_retrying() -> Result<(), String> {
|
||||
fn failed_surface_ensure_retries_same_key_after_cooldown() -> Result<(), String> {
|
||||
FAILING_ENSURE_COUNT.store(0, Ordering::SeqCst);
|
||||
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
|
||||
failing_client_factory,
|
||||
@@ -239,21 +298,7 @@ fn failed_surface_ensure_waits_for_a_new_key_before_retrying() -> Result<(), Str
|
||||
assert!(tick.changed, "the failing client must surface a state change via tick");
|
||||
assert_eq!(FAILING_ENSURE_COUNT.load(Ordering::SeqCst), 1);
|
||||
|
||||
assert!(!store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
|
||||
store.flush_runtime_for_test();
|
||||
let _ = store.tick(&[tab.id().clone()]);
|
||||
assert_eq!(FAILING_ENSURE_COUNT.load(Ordering::SeqCst), 1);
|
||||
|
||||
assert_eq!(
|
||||
store.record_viewport_size(tab.id(), resized_viewport_bounds(), 1.0),
|
||||
WebSurfaceInputOutcome::Applied,
|
||||
);
|
||||
// `record_viewport_size` stamps the viewport-resize debounce on a
|
||||
// genuine size transition; in production the poll cadence retries
|
||||
// `ensure_surface` once the gesture settles, but a synchronous
|
||||
// unit test can't advance the clock — clear the timestamp so we
|
||||
// exercise the retry-on-new-key business rule in isolation.
|
||||
store.clear_viewport_resize_debounce_for_test(tab.id());
|
||||
std::thread::sleep(super::SIDECAR_RESTART_BASE_DELAY + Duration::from_millis(10));
|
||||
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
|
||||
store.flush_runtime_for_test();
|
||||
let _ = store.tick(&[tab.id().clone()]);
|
||||
@@ -305,7 +350,6 @@ impl LiveRuntimeClient for FakeLiveRuntimeClient {
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
FAKE_CLOSE_COUNT.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -387,6 +431,14 @@ fn fake_client_factory(
|
||||
Ok(Box::new(FakeLiveRuntimeClient))
|
||||
}
|
||||
|
||||
fn close_client_factory(
|
||||
config_dir: std::path::PathBuf,
|
||||
) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
*CLOSE_CONFIG_DIR.lock().map_err(|_| "close config lock was poisoned".to_string())? =
|
||||
Some(config_dir);
|
||||
Ok(Box::new(FakeLiveRuntimeClient))
|
||||
}
|
||||
|
||||
fn idle_skip_client_factory(
|
||||
_config_dir: std::path::PathBuf,
|
||||
) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
@@ -435,13 +487,6 @@ fn viewport_bounds() -> gpui::Bounds<gpui::Pixels> {
|
||||
)
|
||||
}
|
||||
|
||||
fn resized_viewport_bounds() -> gpui::Bounds<gpui::Pixels> {
|
||||
gpui::Bounds::new(
|
||||
gpui::point(gpui::px(0.0), gpui::px(0.0)),
|
||||
gpui::size(gpui::px(720.0), gpui::px(480.0)),
|
||||
)
|
||||
}
|
||||
|
||||
fn pending_input() -> WebSurfacePendingInput {
|
||||
WebSurfacePendingInput {
|
||||
enqueued_at: None,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
use std::{error::Error, sync::Mutex};
|
||||
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
use gpui::{Bounds, point, px, size};
|
||||
|
||||
use crate::services::{
|
||||
ProfileDataMode,
|
||||
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
web_surface_frame::WebSurfaceFrame,
|
||||
web_surface_geometry::WebSurfaceScrollOffset,
|
||||
web_surface_runtime::{
|
||||
WebSurfaceRuntime, WebSurfaceRuntimeFrame, WebSurfaceUrlChange, WebSurfaceUrlChangeKind,
|
||||
},
|
||||
web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState},
|
||||
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
|
||||
};
|
||||
use super::WebSurfaceStore;
|
||||
|
||||
static ENSURES: Mutex<Vec<RecordedEnsure>> = Mutex::new(Vec::new());
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RecordedEnsure {
|
||||
profile_id: String,
|
||||
had_input: bool,
|
||||
}
|
||||
|
||||
struct RecordingClient;
|
||||
|
||||
impl LiveRuntimeClient for RecordingClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
ENSURES.lock().map_err(|_| "ensure recorder lock was poisoned".to_string())?.push(
|
||||
RecordedEnsure {
|
||||
profile_id: request.profile_id,
|
||||
had_input: request.scroll_delta_x != 0
|
||||
|| request.scroll_delta_y != 0
|
||||
|| request.click_x.is_some()
|
||||
|| request.click_y.is_some()
|
||||
|| request.hover_x.is_some()
|
||||
|| request.hover_y.is_some()
|
||||
|| request.typed_text.is_some(),
|
||||
},
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn recording_client_factory(
|
||||
_config_dir: std::path::PathBuf,
|
||||
) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
Ok(Box::new(RecordingClient))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_scope_change_clears_pixels_input_and_focus_before_ensure() -> Result<(), Box<dyn Error>>
|
||||
{
|
||||
ENSURES.lock().map_err(|_| "ensure recorder lock was poisoned")?.clear();
|
||||
let runtime = WebSurfaceRuntime::new_with_client_factory(recording_client_factory);
|
||||
let mut store = WebSurfaceStore::new_with_runtime(runtime);
|
||||
let tab_id = TabId::new();
|
||||
let url = UrlText::parse("https://example.com/shared")?;
|
||||
let profile_a = ProfileId::new();
|
||||
let profile_b = ProfileId::new();
|
||||
let tab_a =
|
||||
BrowserTab::new(tab_id.clone(), SpaceId::new(), profile_a.clone(), "Shared", url.clone());
|
||||
let tab_b = BrowserTab::new(tab_id, SpaceId::new(), profile_b.clone(), "Shared", url);
|
||||
|
||||
assert_eq!(
|
||||
store.record_viewport_size(tab_a.id(), viewport_bounds(), 1.0),
|
||||
WebSurfaceInputOutcome::Applied,
|
||||
);
|
||||
assert!(store.ensure_surface(&tab_a, ProfileDataMode::Transient, &[]));
|
||||
store.flush_runtime_for_test();
|
||||
let frame = WebSurfaceFrame::from_live_frame(
|
||||
tab_a.url().as_str().to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test(1, 1, vec![0, 0, 0, 255]),
|
||||
)?;
|
||||
store.surface_mut(tab_a.id()).state = Some(WebSurfaceState::Ready(frame));
|
||||
assert!(
|
||||
store.state_for_scope(tab_a.id(), &profile_b, ProfileDataMode::Transient).is_none(),
|
||||
"a model scope change must hide old pixels before the next ensure",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.record_click_point(tab_a.id(), tab_a.url().as_str(), point(px(120.0), px(80.0)), 1.0,),
|
||||
WebSurfaceInputOutcome::Applied,
|
||||
);
|
||||
assert_eq!(
|
||||
store.record_typed_text(tab_a.id(), tab_a.url().as_str(), "secret"),
|
||||
WebSurfaceInputOutcome::Applied,
|
||||
);
|
||||
assert!(store.ensure_surface(&tab_b, ProfileDataMode::Transient, &[]));
|
||||
store.flush_runtime_for_test();
|
||||
|
||||
assert!(store.keyboard_focus.is_none());
|
||||
assert!(matches!(
|
||||
store.state(tab_b.id()),
|
||||
Some(WebSurfaceState::Loading { previous_frame: None, .. })
|
||||
));
|
||||
let ensures = ENSURES.lock().map_err(|_| "ensure recorder lock was poisoned")?;
|
||||
assert_eq!(ensures.len(), 2);
|
||||
assert_eq!(ensures[0].profile_id, profile_a.as_str());
|
||||
assert_eq!(ensures[1].profile_id, profile_b.as_str());
|
||||
assert!(!ensures[1].had_input);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn viewport_bounds() -> Bounds<gpui::Pixels> {
|
||||
Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_holds_loading_white_frame_and_accepts_complete_white_page()
|
||||
-> Result<(), Box<dyn Error>> {
|
||||
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
|
||||
recording_client_factory,
|
||||
));
|
||||
let tab_id = TabId::new();
|
||||
let requested_url = "https://example.com/white".to_string();
|
||||
let previous = WebSurfaceFrame::from_live_frame(
|
||||
requested_url.clone(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test(1, 1, vec![0, 0, 0, 255]),
|
||||
)?;
|
||||
store.surface_mut(&tab_id).state = Some(WebSurfaceState::Loading {
|
||||
requested_url: requested_url.clone(),
|
||||
previous_frame: Some(previous),
|
||||
});
|
||||
let loading_white = WebSurfaceFrame::from_live_frame(
|
||||
requested_url.clone(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_render_state(1, 1, vec![255; 4], "loading"),
|
||||
)?;
|
||||
let complete_white = WebSurfaceFrame::from_live_frame(
|
||||
requested_url,
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_render_state(1, 1, vec![255; 4], "complete"),
|
||||
)?;
|
||||
|
||||
assert!(store.should_hold_initial_frame(&tab_id, &loading_white, false));
|
||||
assert!(!store.should_hold_initial_frame(&tab_id, &complete_white, false));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_loading_frame_replaces_an_earlier_ready_frame() -> Result<(), Box<dyn Error>> {
|
||||
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
|
||||
recording_client_factory,
|
||||
));
|
||||
let tab_id = TabId::new();
|
||||
let requested_url = "https://example.com/progressive".to_string();
|
||||
let first_loading = WebSurfaceFrame::from_live_frame(
|
||||
requested_url.clone(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_render_state(1, 1, vec![16, 32, 64, 255], "loading"),
|
||||
)?;
|
||||
store.surface_mut(&tab_id).state = Some(WebSurfaceState::Ready(first_loading));
|
||||
let next_loading = WebSurfaceFrame::from_live_frame(
|
||||
requested_url,
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_render_state(1, 1, vec![64, 32, 16, 255], "loading"),
|
||||
)?;
|
||||
|
||||
assert!(!store.should_hold_initial_frame(&tab_id, &next_loading, true));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_loading_frame_preserves_previous_pixels_and_delivers_metadata() -> Result<(), Box<dyn Error>>
|
||||
{
|
||||
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
|
||||
recording_client_factory,
|
||||
));
|
||||
let tab_id = TabId::new();
|
||||
let requested_url = "https://example.com/held".to_string();
|
||||
let previous = WebSurfaceFrame::from_live_frame(
|
||||
requested_url.clone(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_title(1, 1, vec![0, 0, 0, 255], "complete", "Previous"),
|
||||
)?;
|
||||
store.surface_mut(&tab_id).state = Some(WebSurfaceState::Loading {
|
||||
requested_url: requested_url.clone(),
|
||||
previous_frame: Some(previous),
|
||||
});
|
||||
let held = WebSurfaceFrame::from_live_frame(
|
||||
requested_url,
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_title(1, 1, vec![255; 4], "loading", "Updated"),
|
||||
)?;
|
||||
let result = store.apply_runtime_frames(vec![WebSurfaceRuntimeFrame::Ready {
|
||||
tab_id: tab_id.clone(),
|
||||
frame: Box::new(held),
|
||||
url_change: Some(WebSurfaceUrlChange {
|
||||
tab_id: tab_id.clone(),
|
||||
loaded_url: "https://example.com/redirected".to_string(),
|
||||
kind: WebSurfaceUrlChangeKind::Observed,
|
||||
}),
|
||||
}]);
|
||||
|
||||
assert!(matches!(
|
||||
store.state(&tab_id),
|
||||
Some(WebSurfaceState::Loading { previous_frame: Some(frame), .. })
|
||||
if frame.title() == Some("Previous")
|
||||
));
|
||||
assert!(matches!(
|
||||
result.page_metadata.as_slice(),
|
||||
[metadata] if metadata.title.as_deref() == Some("Updated")
|
||||
));
|
||||
assert_eq!(result.url_changes.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ely_domain::TabId;
|
||||
use gpui::{Bounds, NativeSurfaceHandle, Pixels};
|
||||
use ely_domain::{ProfileId, TabId};
|
||||
use gpui::{Bounds, Pixels};
|
||||
|
||||
use crate::services::ProfileDataMode;
|
||||
|
||||
use super::{
|
||||
web_surface_cadence::ACTIVE_POLL_INTERVAL,
|
||||
@@ -133,7 +135,6 @@ pub(super) struct PerTabSurface {
|
||||
/// every GPUI paint does not translate into a Servo framebuffer
|
||||
/// destroy/recreate every frame (the "page flashing" symptom).
|
||||
viewport_size_changed_at: Option<Instant>,
|
||||
pub(super) native_surface: Option<NativeSurfaceHandle>,
|
||||
pub(super) last_ensure_key: Option<WebSurfaceEnsureKey>,
|
||||
pub(super) hover_point: Option<WebSurfaceClickPoint>,
|
||||
last_hover_enqueued_at: Option<Instant>,
|
||||
@@ -154,7 +155,6 @@ impl PerTabSurface {
|
||||
viewport_bounds: None,
|
||||
viewport_size: None,
|
||||
viewport_size_changed_at: None,
|
||||
native_surface: None,
|
||||
last_ensure_key: None,
|
||||
hover_point: None,
|
||||
last_hover_enqueued_at: None,
|
||||
@@ -196,6 +196,39 @@ impl PerTabSurface {
|
||||
self.last_ensure_key.as_ref() != Some(key) || self.has_pending_input()
|
||||
}
|
||||
|
||||
pub(super) fn has_scope(
|
||||
&self,
|
||||
profile_id: &ProfileId,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
) -> bool {
|
||||
self.last_ensure_key.as_ref().is_some_and(|key| {
|
||||
key.profile_id == *profile_id && key.profile_data_mode == profile_data_mode
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn reset_for_scope_change(&mut self, key: &WebSurfaceEnsureKey) -> bool {
|
||||
let Some(previous_key) = self.last_ensure_key.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if previous_key.has_same_scope(key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.last_ensure_key = None;
|
||||
self.hover_point = None;
|
||||
self.last_hover_enqueued_at = None;
|
||||
self.click_point = None;
|
||||
self.pending_scroll_delta = None;
|
||||
self.pending_scroll_point = None;
|
||||
self.pending_input_started_at = None;
|
||||
self.scroll_offset = None;
|
||||
self.typed_text = None;
|
||||
self.state = None;
|
||||
self.last_input_flushed_at = None;
|
||||
self.metadata_tracker = WebSurfaceMetadataTracker::default();
|
||||
true
|
||||
}
|
||||
|
||||
/// True when the viewport bounds have changed within the last
|
||||
/// [`VIEWPORT_RESIZE_DEBOUNCE`] window. The renderer treats this
|
||||
/// as "the user is mid-animation" and holds off telling Servo to
|
||||
@@ -217,23 +250,13 @@ impl PerTabSurface {
|
||||
self.viewport_size_changed_at = Some(now);
|
||||
}
|
||||
|
||||
/// Test-only escape hatch: simulate the trailing edge of a resize
|
||||
/// animation by clearing the debounce timestamp, so a synchronous
|
||||
/// `record + ensure` sequence in tests behaves as if the gesture
|
||||
/// has fully settled. Production code drives this via the poll
|
||||
/// cadence and time elapsing — tests can't advance an `Instant`.
|
||||
#[cfg(test)]
|
||||
pub(super) fn clear_viewport_resize_debounce_for_test(&mut self) {
|
||||
self.viewport_size_changed_at = None;
|
||||
}
|
||||
|
||||
pub(super) fn mark_ensured(&mut self, key: WebSurfaceEnsureKey) {
|
||||
self.last_ensure_key = Some(key);
|
||||
}
|
||||
|
||||
pub(super) fn matches_ready(&self, frame: &WebSurfaceFrame) -> bool {
|
||||
match self.state.as_ref() {
|
||||
Some(WebSurfaceState::Ready(current)) => current.has_same_software_render_as(frame),
|
||||
Some(WebSurfaceState::Ready(current)) => current.has_same_render_as(frame),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -275,7 +298,8 @@ const VIEWPORT_RESIZE_DEBOUNCE: Duration = Duration::from_millis(80);
|
||||
pub(super) struct WebSurfaceEnsureKey {
|
||||
requested_url: String,
|
||||
size: WebSurfaceSize,
|
||||
native_surface_id: Option<usize>,
|
||||
profile_id: ProfileId,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
zoom_percent: u16,
|
||||
permissions: Vec<WebSurfaceSitePermission>,
|
||||
}
|
||||
@@ -284,18 +308,24 @@ impl WebSurfaceEnsureKey {
|
||||
pub(super) fn new(
|
||||
requested_url: String,
|
||||
size: WebSurfaceSize,
|
||||
native_surface: Option<&NativeSurfaceHandle>,
|
||||
profile_id: ProfileId,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
zoom_percent: u16,
|
||||
permissions: &[WebSurfaceSitePermission],
|
||||
) -> Self {
|
||||
Self {
|
||||
requested_url,
|
||||
size,
|
||||
native_surface_id: native_surface.map(NativeSurfaceHandle::identity),
|
||||
profile_id,
|
||||
profile_data_mode,
|
||||
zoom_percent,
|
||||
permissions: permissions.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_same_scope(&self, other: &Self) -> bool {
|
||||
self.profile_id == other.profile_id && self.profile_data_mode == other.profile_data_mode
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -306,7 +336,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unchanged_surface_without_input_skips_ensure() {
|
||||
let key = ensure_key("https://example.com/", 800, 600);
|
||||
let key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
|
||||
let mut surface = PerTabSurface::new();
|
||||
|
||||
assert!(surface.should_ensure(&key));
|
||||
@@ -318,7 +348,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pending_input_forces_ensure_even_when_key_matches() {
|
||||
let key = ensure_key("https://example.com/", 800, 600);
|
||||
let key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
|
||||
let mut surface = PerTabSurface::new();
|
||||
surface.mark_ensured(key.clone());
|
||||
surface.pending_scroll_delta =
|
||||
@@ -329,8 +359,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn viewport_change_forces_ensure() {
|
||||
let old_key = ensure_key("https://example.com/", 800, 600);
|
||||
let new_key = ensure_key("https://example.com/", 1024, 768);
|
||||
let profile_id = ProfileId::new();
|
||||
let old_key = ensure_key("https://example.com/", 800, 600, &profile_id);
|
||||
let new_key = ensure_key("https://example.com/", 1024, 768, &profile_id);
|
||||
let mut surface = PerTabSurface::new();
|
||||
surface.mark_ensured(old_key);
|
||||
|
||||
assert!(surface.should_ensure(&new_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_change_forces_ensure() {
|
||||
let old_key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
|
||||
let new_key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
|
||||
let mut surface = PerTabSurface::new();
|
||||
surface.mark_ensured(old_key);
|
||||
|
||||
assert!(surface.should_ensure(&new_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_data_mode_change_forces_ensure() {
|
||||
let profile_id = ProfileId::new();
|
||||
let old_key = ensure_key("https://example.com/", 800, 600, &profile_id);
|
||||
let mut new_key = old_key.clone();
|
||||
new_key.profile_data_mode = ProfileDataMode::Transient;
|
||||
let mut surface = PerTabSurface::new();
|
||||
surface.mark_ensured(old_key);
|
||||
|
||||
@@ -348,11 +401,17 @@ mod tests {
|
||||
assert!(!surface.input_flush_is_throttled(start + Duration::from_millis(8)));
|
||||
}
|
||||
|
||||
fn ensure_key(url: &str, width: u32, height: u32) -> WebSurfaceEnsureKey {
|
||||
fn ensure_key(
|
||||
url: &str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
profile_id: &ProfileId,
|
||||
) -> WebSurfaceEnsureKey {
|
||||
WebSurfaceEnsureKey::new(
|
||||
url.to_string(),
|
||||
WebSurfaceSize { width, height, device_pixel_ratio_percent: 100 },
|
||||
None,
|
||||
profile_id.clone(),
|
||||
ProfileDataMode::Persistent,
|
||||
100,
|
||||
&[],
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use ely_domain::{BrowserTab, TabId};
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, Pixels, Styled, Window, canvas, div, native_surface, px, rgb,
|
||||
AnyElement, App, Entity, ImageSource, InteractiveElement, IntoElement, MouseButton, ObjectFit,
|
||||
ParentElement, Pixels, Styled, StyledImage, Window, canvas, div, img, px, rgb,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
use gpui::{Corners, SurfaceLease, surface};
|
||||
|
||||
use super::{
|
||||
ElyShell, web_surface_frame::WebSurfaceFrame,
|
||||
@@ -11,15 +13,44 @@ use super::{
|
||||
use ely_design_system::colors;
|
||||
|
||||
pub(super) fn render_ready_web_surface(
|
||||
_frame: &WebSurfaceFrame,
|
||||
frame: &WebSurfaceFrame,
|
||||
tab: &BrowserTab,
|
||||
state_entity: Entity<ElyShell>,
|
||||
bottom_corner_radius: Pixels,
|
||||
) -> AnyElement {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(hardware_surface) = frame.hardware_surface.as_ref() {
|
||||
return render_web_surface(
|
||||
tab,
|
||||
state_entity,
|
||||
surface(hardware_surface.pixel_buffer().clone())
|
||||
.lease(SurfaceLease::from_arc(hardware_surface.clone()))
|
||||
.size_full()
|
||||
.corner_radii(Corners {
|
||||
top_left: px(0.0),
|
||||
top_right: px(0.0),
|
||||
bottom_left: bottom_corner_radius,
|
||||
bottom_right: bottom_corner_radius,
|
||||
})
|
||||
.object_fit(ObjectFit::Fill),
|
||||
);
|
||||
}
|
||||
let Some(image) = frame.image.as_ref() else {
|
||||
return render_web_surface(
|
||||
tab,
|
||||
state_entity,
|
||||
error_page("Web surface frame did not include renderable pixels."),
|
||||
);
|
||||
};
|
||||
render_web_surface(
|
||||
tab,
|
||||
state_entity.clone(),
|
||||
render_native_web_surface(tab, state_entity, bottom_corner_radius),
|
||||
state_entity,
|
||||
div()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.rounded_bl(bottom_corner_radius)
|
||||
.rounded_br(bottom_corner_radius)
|
||||
.child(img(ImageSource::Render(image.clone())).size_full().object_fit(ObjectFit::Fill)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,8 +61,12 @@ pub(super) fn render_loading_web_surface(
|
||||
) -> AnyElement {
|
||||
render_web_surface(
|
||||
tab,
|
||||
state_entity.clone(),
|
||||
render_native_web_surface(tab, state_entity, bottom_corner_radius),
|
||||
state_entity,
|
||||
div()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.rounded_bl(bottom_corner_radius)
|
||||
.rounded_br(bottom_corner_radius),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,29 +120,6 @@ fn render_web_surface(
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_native_web_surface(
|
||||
tab: &BrowserTab,
|
||||
state_entity: Entity<ElyShell>,
|
||||
bottom_corner_radius: Pixels,
|
||||
) -> impl IntoElement {
|
||||
let tab_id = tab.id().clone();
|
||||
let element_id = ElementId::Name(format!("web-surface-{}", tab_id.as_str()).into());
|
||||
// `bottom_corner_radius` is wired through the GPUI `native_surface`
|
||||
// patch to the AppKit overlay's `CALayer.cornerRadius`, so the
|
||||
// canvas follows the same rounded edge as its containing panel
|
||||
// instead of painting past it. The top corners stay flat because
|
||||
// the topbar / pane header sits flush above the canvas.
|
||||
native_surface(element_id, move |surface, bounds, window: &mut Window, cx: &mut App| {
|
||||
let scale_factor = window.scale_factor();
|
||||
state_entity.update(cx, |shell, cx| {
|
||||
shell.record_external_web_surface(tab_id.clone(), bounds, scale_factor, surface, cx);
|
||||
});
|
||||
})
|
||||
.size_full()
|
||||
.rounded_bl(bottom_corner_radius)
|
||||
.rounded_br(bottom_corner_radius)
|
||||
}
|
||||
|
||||
fn render_input_overlay(
|
||||
tab_id: TabId,
|
||||
url: String,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
collections::{BTreeMap, VecDeque},
|
||||
io,
|
||||
sync::{Arc, Condvar, Mutex, mpsc},
|
||||
thread::JoinHandle,
|
||||
@@ -9,11 +9,10 @@ use crate::services::servo_live::{
|
||||
ServoLiveClient, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame,
|
||||
};
|
||||
|
||||
/// Blocking surface for the embedded Servo runtime.
|
||||
/// Blocking transport for one profile-scoped Servo sidecar.
|
||||
///
|
||||
/// Production wraps [`ServoLiveClient`] directly; tests substitute a
|
||||
/// fake. The contract: every call is blocking and may run for tens of
|
||||
/// milliseconds. Implementations live on the worker thread.
|
||||
/// Production wraps [`ServoLiveClient`]; tests substitute a fake. Every
|
||||
/// call may block on sidecar IPC, so implementations live on a worker.
|
||||
pub(super) trait LiveRuntimeClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
@@ -57,7 +56,7 @@ impl LiveRuntimeClientError {
|
||||
impl std::fmt::Display for LiveRuntimeClientError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::RuntimeUnavailable => formatter.write_str("servo live runtime is unavailable"),
|
||||
Self::RuntimeUnavailable => formatter.write_str("servo sidecar runtime is unavailable"),
|
||||
Self::Message(message) => formatter.write_str(message),
|
||||
}
|
||||
}
|
||||
@@ -86,41 +85,57 @@ impl From<io::Error> for LiveRuntimeClientError {
|
||||
|
||||
/// Output of a worker request.
|
||||
pub(super) enum WorkerResponse {
|
||||
Frame { tab_id: String, frame: ServoLiveFrame },
|
||||
Failed { tab_id: String, message: String },
|
||||
Frame { generation: RequestGeneration, tab_id: String, frame: ServoLiveFrame },
|
||||
Failed { generation: RequestGeneration, tab_id: String, message: String },
|
||||
RuntimeUnavailable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub(super) struct RequestGeneration(u64);
|
||||
|
||||
impl RequestGeneration {
|
||||
pub(super) const fn new(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
enum WorkerRequest {
|
||||
Ensure(ServoLiveEnsureRequest),
|
||||
Poll { tab_id: String },
|
||||
Ensure { generation: RequestGeneration, request: ServoLiveEnsureRequest },
|
||||
Poll { generation: RequestGeneration, tab_id: String },
|
||||
}
|
||||
|
||||
impl WorkerRequest {
|
||||
fn tab_id(&self) -> &str {
|
||||
match self {
|
||||
Self::Ensure { request, .. } => request.tab_id.as_str(),
|
||||
Self::Poll { tab_id, .. } => tab_id.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure_parts(self) -> (RequestGeneration, String) {
|
||||
match self {
|
||||
Self::Ensure { generation, request } => (generation, request.tab_id),
|
||||
Self::Poll { generation, tab_id } => (generation, tab_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkerQueue {
|
||||
/// Latest request per tab. A new submission for a tab replaces any
|
||||
/// earlier in-flight-but-not-yet-started request, so a flurry of
|
||||
/// scrolls never piles up — the worker always processes the most
|
||||
/// recent frame's worth of inputs.
|
||||
pending: BTreeMap<String, WorkerRequest>,
|
||||
/// Close orders. Sent after pending is cleared for that tab so the
|
||||
/// worker never closes a tab that still has live frames in flight.
|
||||
closes: Vec<String>,
|
||||
/// True while the worker is processing a request. `wait_until_idle`
|
||||
/// uses this alongside the queue emptiness to know when all
|
||||
/// previously-submitted work has actually run.
|
||||
/// Ordered inputs queue; idle and hover updates coalesce at the tail.
|
||||
pending: BTreeMap<String, VecDeque<WorkerRequest>>,
|
||||
/// Round-robin tab order, with each pending tab represented once.
|
||||
ready_tabs: VecDeque<String>,
|
||||
closes: VecDeque<String>,
|
||||
in_flight: bool,
|
||||
in_flight_tab: Option<String>,
|
||||
initialization_failure: Option<String>,
|
||||
shutdown: bool,
|
||||
}
|
||||
|
||||
/// Owns a [`LiveRuntimeClient`] on a dedicated OS thread and exposes
|
||||
/// a non-blocking API: submit ensure/poll/close, then drain responses.
|
||||
///
|
||||
/// The UI thread never blocks on Servo. Submissions push into a
|
||||
/// coalescing queue (latest request per tab wins). The worker thread
|
||||
/// drains the queue, runs the blocking calls, and emits responses on a
|
||||
/// `std::sync::mpsc` channel that the UI thread reads with `try_recv`.
|
||||
/// Runs one blocking profile client behind a non-blocking fair queue.
|
||||
pub(super) struct LiveRuntimeWorker {
|
||||
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
|
||||
response_tx: mpsc::Sender<WorkerResponse>,
|
||||
response_rx: mpsc::Receiver<WorkerResponse>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
@@ -132,46 +147,35 @@ impl LiveRuntimeWorker {
|
||||
let queue = Arc::new((
|
||||
Mutex::new(WorkerQueue {
|
||||
pending: BTreeMap::new(),
|
||||
closes: Vec::new(),
|
||||
ready_tabs: VecDeque::new(),
|
||||
closes: VecDeque::new(),
|
||||
in_flight: false,
|
||||
in_flight_tab: None,
|
||||
initialization_failure: None,
|
||||
shutdown: false,
|
||||
}),
|
||||
Condvar::new(),
|
||||
));
|
||||
let (response_tx, response_rx) = mpsc::channel();
|
||||
let (init_tx, init_rx) = mpsc::channel();
|
||||
let queue_for_thread = queue.clone();
|
||||
let response_for_thread = response_tx.clone();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("ely-servo-runtime".to_string())
|
||||
.spawn(move || {
|
||||
let client = match client_factory() {
|
||||
Ok(client) => {
|
||||
let _ = init_tx.send(Ok(()));
|
||||
client
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = init_tx.send(Err(error));
|
||||
return;
|
||||
}
|
||||
};
|
||||
run_worker(client, queue_for_thread, response_tx);
|
||||
.spawn(move || match client_factory() {
|
||||
Ok(client) => run_worker(client, queue_for_thread, response_for_thread),
|
||||
Err(error) => {
|
||||
fail_worker_initialization(queue_for_thread, &response_for_thread, error);
|
||||
}
|
||||
})
|
||||
.map_err(|error| format!("failed to spawn servo live worker thread: {error}"))?;
|
||||
match init_rx.recv() {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => {
|
||||
let _ = thread.join();
|
||||
return Err(error);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = thread.join();
|
||||
return Err(format!("servo live worker initialization failed: {error}"));
|
||||
}
|
||||
}
|
||||
Ok(Self { queue, response_rx, thread: Some(thread) })
|
||||
Ok(Self { queue, response_tx, response_rx, thread: Some(thread) })
|
||||
}
|
||||
|
||||
pub(super) fn submit_ensure(&self, request: ServoLiveEnsureRequest) {
|
||||
pub(super) fn submit_ensure(
|
||||
&self,
|
||||
generation: RequestGeneration,
|
||||
request: ServoLiveEnsureRequest,
|
||||
) {
|
||||
let tab_id = request.tab_id.clone();
|
||||
let (lock, cvar) = &*self.queue;
|
||||
let mut q = match lock.lock() {
|
||||
@@ -181,11 +185,31 @@ impl LiveRuntimeWorker {
|
||||
if q.shutdown {
|
||||
return;
|
||||
}
|
||||
q.pending.insert(tab_id, WorkerRequest::Ensure(request));
|
||||
if let Some(message) = q.initialization_failure.clone() {
|
||||
drop(q);
|
||||
let _ = self.response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
|
||||
return;
|
||||
}
|
||||
let mut request = WorkerRequest::Ensure { generation, request };
|
||||
if let Some(pending) = q.pending.get_mut(&tab_id) {
|
||||
let replace_tail = pending.back().is_some_and(|tail| {
|
||||
matches!(tail, WorkerRequest::Poll { .. })
|
||||
|| (!request_has_ordered_input(&request) && !request_has_ordered_input(tail))
|
||||
});
|
||||
if replace_tail && let Some(tail) = pending.back_mut() {
|
||||
preserve_latest_hover(&mut request, tail);
|
||||
*tail = request;
|
||||
} else {
|
||||
pending.push_back(request);
|
||||
}
|
||||
} else {
|
||||
q.pending.insert(tab_id.clone(), VecDeque::from([request]));
|
||||
q.ready_tabs.push_back(tab_id);
|
||||
}
|
||||
cvar.notify_one();
|
||||
}
|
||||
|
||||
pub(super) fn submit_poll(&self, tab_id: String) -> bool {
|
||||
pub(super) fn submit_poll(&self, generation: RequestGeneration, tab_id: String) -> bool {
|
||||
let (lock, cvar) = &*self.queue;
|
||||
let mut q = match lock.lock() {
|
||||
Ok(guard) => guard,
|
||||
@@ -194,15 +218,25 @@ impl LiveRuntimeWorker {
|
||||
if q.shutdown {
|
||||
return false;
|
||||
}
|
||||
if let Some(message) = q.initialization_failure.clone() {
|
||||
drop(q);
|
||||
let _ = self.response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
|
||||
return true;
|
||||
}
|
||||
// A pending Ensure already produces the latest frame after its
|
||||
// run; don't downgrade it to a Poll. Only insert if nothing is
|
||||
// queued.
|
||||
let inserted = match q.pending.entry(tab_id.clone()) {
|
||||
std::collections::btree_map::Entry::Vacant(entry) => {
|
||||
entry.insert(WorkerRequest::Poll { tab_id });
|
||||
true
|
||||
}
|
||||
std::collections::btree_map::Entry::Occupied(_) => false,
|
||||
let inserted = if q.pending.contains_key(&tab_id)
|
||||
|| q.in_flight_tab.as_deref() == Some(tab_id.as_str())
|
||||
{
|
||||
false
|
||||
} else {
|
||||
q.pending.insert(
|
||||
tab_id.clone(),
|
||||
VecDeque::from([WorkerRequest::Poll { generation, tab_id: tab_id.clone() }]),
|
||||
);
|
||||
q.ready_tabs.push_back(tab_id);
|
||||
true
|
||||
};
|
||||
cvar.notify_one();
|
||||
inserted
|
||||
@@ -217,8 +251,12 @@ impl LiveRuntimeWorker {
|
||||
if q.shutdown {
|
||||
return;
|
||||
}
|
||||
if q.initialization_failure.is_some() {
|
||||
return;
|
||||
}
|
||||
q.pending.remove(&tab_id);
|
||||
q.closes.push(tab_id);
|
||||
q.ready_tabs.retain(|ready_tab_id| ready_tab_id != &tab_id);
|
||||
q.closes.push_back(tab_id);
|
||||
cvar.notify_one();
|
||||
}
|
||||
|
||||
@@ -230,10 +268,7 @@ impl LiveRuntimeWorker {
|
||||
out
|
||||
}
|
||||
|
||||
/// Test-only barrier. Blocks the caller until the worker has
|
||||
/// drained everything currently submitted. Production code never
|
||||
/// waits — the whole point of the worker is that the UI thread
|
||||
/// progresses without IPC latency.
|
||||
/// Test-only barrier for all submitted work.
|
||||
#[cfg(test)]
|
||||
pub(super) fn wait_until_idle(&self) {
|
||||
let (lock, cvar) = &*self.queue;
|
||||
@@ -254,10 +289,12 @@ impl Drop for LiveRuntimeWorker {
|
||||
fn drop(&mut self) {
|
||||
{
|
||||
let (lock, cvar) = &*self.queue;
|
||||
if let Ok(mut q) = lock.lock() {
|
||||
q.shutdown = true;
|
||||
cvar.notify_all();
|
||||
}
|
||||
let mut q = match lock.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
q.shutdown = true;
|
||||
cvar.notify_all();
|
||||
}
|
||||
if let Some(handle) = self.thread.take() {
|
||||
let _ = handle.join();
|
||||
@@ -265,12 +302,41 @@ impl Drop for LiveRuntimeWorker {
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_worker_initialization(
|
||||
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
|
||||
response_tx: &mpsc::Sender<WorkerResponse>,
|
||||
message: String,
|
||||
) {
|
||||
let (lock, cvar) = &*queue;
|
||||
let mut q = match lock.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
q.initialization_failure = Some(message.clone());
|
||||
q.ready_tabs.clear();
|
||||
q.closes.clear();
|
||||
q.in_flight = false;
|
||||
q.in_flight_tab = None;
|
||||
let pending = std::mem::take(&mut q.pending);
|
||||
for request in pending.into_values().flatten() {
|
||||
let (generation, tab_id) = request.failure_parts();
|
||||
let _ = response_tx.send(WorkerResponse::Failed {
|
||||
generation,
|
||||
tab_id,
|
||||
message: message.clone(),
|
||||
});
|
||||
}
|
||||
let _ = response_tx.send(WorkerResponse::RuntimeUnavailable);
|
||||
cvar.notify_all();
|
||||
}
|
||||
|
||||
fn run_worker(
|
||||
mut client: Box<dyn LiveRuntimeClient>,
|
||||
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
|
||||
response_tx: mpsc::Sender<WorkerResponse>,
|
||||
) {
|
||||
let (lock, cvar) = &*queue;
|
||||
let mut last_dispatched_tab = None;
|
||||
loop {
|
||||
let work = {
|
||||
let mut q = match lock.lock() {
|
||||
@@ -278,6 +344,7 @@ fn run_worker(
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
q.in_flight = false;
|
||||
q.in_flight_tab = None;
|
||||
cvar.notify_all();
|
||||
while q.pending.is_empty() && q.closes.is_empty() && !q.shutdown {
|
||||
q = match cvar.wait(q) {
|
||||
@@ -288,19 +355,39 @@ fn run_worker(
|
||||
if q.shutdown {
|
||||
return;
|
||||
}
|
||||
let next = if let Some(close_id) = q.closes.pop() {
|
||||
let next = if let Some(close_id) = q.closes.pop_front() {
|
||||
Work::Close(close_id)
|
||||
} else {
|
||||
let key = match q.pending.keys().next().cloned() {
|
||||
Some(key) => key,
|
||||
if q.ready_tabs.len() > 1
|
||||
&& q.ready_tabs.front() == last_dispatched_tab.as_ref()
|
||||
&& let Some(last_tab) = q.ready_tabs.pop_front()
|
||||
{
|
||||
q.ready_tabs.push_back(last_tab);
|
||||
}
|
||||
let tab_id = match q.ready_tabs.pop_front() {
|
||||
Some(tab_id) => tab_id,
|
||||
None => continue,
|
||||
};
|
||||
let Some(request) = q.pending.remove(&key) else {
|
||||
continue;
|
||||
let (request, has_more) = match q.pending.get_mut(&tab_id) {
|
||||
Some(pending) => match pending.pop_front() {
|
||||
Some(request) => (request, !pending.is_empty()),
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
if has_more {
|
||||
q.ready_tabs.push_back(tab_id.clone());
|
||||
} else {
|
||||
q.pending.remove(&tab_id);
|
||||
}
|
||||
last_dispatched_tab = Some(tab_id);
|
||||
Work::Request(request)
|
||||
};
|
||||
q.in_flight = true;
|
||||
q.in_flight_tab = match &next {
|
||||
Work::Close(_) => None,
|
||||
Work::Request(request) => Some(request.tab_id().to_string()),
|
||||
};
|
||||
next
|
||||
};
|
||||
|
||||
@@ -309,25 +396,23 @@ fn run_worker(
|
||||
let _ = client.close(tab_id);
|
||||
false
|
||||
}
|
||||
Work::Request(WorkerRequest::Ensure(request)) => {
|
||||
Work::Request(WorkerRequest::Ensure { generation, request }) => {
|
||||
let tab_id = request.tab_id.clone();
|
||||
dispatch_result(&response_tx, tab_id, client.ensure(request))
|
||||
dispatch_result(&response_tx, generation, tab_id, client.ensure(request))
|
||||
}
|
||||
Work::Request(WorkerRequest::Poll { tab_id }) => {
|
||||
Work::Request(WorkerRequest::Poll { generation, tab_id }) => {
|
||||
let request_tab_id = tab_id.clone();
|
||||
dispatch_result(&response_tx, request_tab_id, client.poll(tab_id))
|
||||
dispatch_result(&response_tx, generation, request_tab_id, client.poll(tab_id))
|
||||
}
|
||||
};
|
||||
|
||||
if exit_after_dispatch {
|
||||
// Release the in-flight flag and wake any flush waiter
|
||||
// before exiting so wait_until_idle doesn't block forever
|
||||
// on a thread that has already returned.
|
||||
let mut q = match lock.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
q.in_flight = false;
|
||||
q.in_flight_tab = None;
|
||||
cvar.notify_all();
|
||||
return;
|
||||
}
|
||||
@@ -343,19 +428,20 @@ enum Work {
|
||||
/// `true` when the worker should exit.
|
||||
fn dispatch_result(
|
||||
response_tx: &mpsc::Sender<WorkerResponse>,
|
||||
generation: RequestGeneration,
|
||||
tab_id: String,
|
||||
result: Result<Option<ServoLiveFrame>, LiveRuntimeClientError>,
|
||||
) -> bool {
|
||||
match result {
|
||||
Ok(Some(frame)) => {
|
||||
let _ = response_tx.send(WorkerResponse::Frame { tab_id, frame });
|
||||
let _ = response_tx.send(WorkerResponse::Frame { generation, tab_id, frame });
|
||||
false
|
||||
}
|
||||
Ok(None) => false,
|
||||
Err(error) => {
|
||||
let unavailable = error.is_runtime_unavailable();
|
||||
let message = error.to_string();
|
||||
let _ = response_tx.send(WorkerResponse::Failed { tab_id, message });
|
||||
let _ = response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
|
||||
if unavailable {
|
||||
let _ = response_tx.send(WorkerResponse::RuntimeUnavailable);
|
||||
return true;
|
||||
@@ -364,3 +450,34 @@ fn dispatch_result(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_has_ordered_input(request: &WorkerRequest) -> bool {
|
||||
let WorkerRequest::Ensure { request, .. } = request else {
|
||||
return false;
|
||||
};
|
||||
request.scroll_delta_x != 0
|
||||
|| request.scroll_delta_y != 0
|
||||
|| request.scroll_point_x.is_some()
|
||||
|| request.scroll_point_y.is_some()
|
||||
|| request.click_x.is_some()
|
||||
|| request.click_y.is_some()
|
||||
|| request.typed_text.is_some()
|
||||
}
|
||||
|
||||
fn preserve_latest_hover(latest: &mut WorkerRequest, previous: &WorkerRequest) {
|
||||
let (
|
||||
WorkerRequest::Ensure { request: latest, .. },
|
||||
WorkerRequest::Ensure { request: previous, .. },
|
||||
) = (latest, previous)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if latest.hover_x.is_none() && latest.hover_y.is_none() {
|
||||
latest.hover_x = previous.hover_x;
|
||||
latest.hover_y = previous.hover_y;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_worker_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
use std::{
|
||||
sync::{Arc, Mutex, mpsc},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::services::servo_live::{ServoLiveEnsureRequest, ServoLiveFrame};
|
||||
|
||||
use super::{
|
||||
LiveRuntimeClient, LiveRuntimeClientError, LiveRuntimeWorker, RequestGeneration, WorkerResponse,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
enum RecordedInput {
|
||||
Idle(String),
|
||||
Scroll(String),
|
||||
Click(String),
|
||||
Text(String),
|
||||
Hover(String),
|
||||
}
|
||||
|
||||
struct SlowRecordingClient {
|
||||
calls: Arc<Mutex<Vec<RecordedInput>>>,
|
||||
first_started_tx: Option<mpsc::Sender<()>>,
|
||||
release_first_rx: mpsc::Receiver<()>,
|
||||
return_frame: bool,
|
||||
}
|
||||
|
||||
struct GenerationClient;
|
||||
|
||||
impl LiveRuntimeClient for GenerationClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(Some(ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])))
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Err(LiveRuntimeClientError::Message("poll failed".to_string()))
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveRuntimeClient for SlowRecordingClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
let input = recorded_input(&request);
|
||||
self.calls.lock().map_err(|_| "call recorder lock was poisoned".to_string())?.push(input);
|
||||
if let Some(first_started_tx) = self.first_started_tx.take() {
|
||||
first_started_tx.send(()).map_err(|error| error.to_string())?;
|
||||
self.release_first_rx.recv().map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(self.return_frame.then(|| ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])))
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_edge_inputs_for_one_tab_are_preserved() -> Result<(), String> {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let (first_started_tx, first_started_rx) = mpsc::channel();
|
||||
let (release_first_tx, release_first_rx) = mpsc::channel();
|
||||
let client_calls = calls.clone();
|
||||
let worker = LiveRuntimeWorker::new(move || {
|
||||
Ok(Box::new(SlowRecordingClient {
|
||||
calls: client_calls,
|
||||
first_started_tx: Some(first_started_tx),
|
||||
release_first_rx,
|
||||
return_frame: false,
|
||||
}))
|
||||
})?;
|
||||
|
||||
worker.submit_ensure(
|
||||
RequestGeneration::new(1),
|
||||
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
|
||||
);
|
||||
first_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
|
||||
for (generation, input) in [
|
||||
RecordedInput::Scroll("tab-a".to_string()),
|
||||
RecordedInput::Click("tab-a".to_string()),
|
||||
RecordedInput::Text("tab-a".to_string()),
|
||||
RecordedInput::Hover("tab-a".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
worker.submit_ensure(
|
||||
RequestGeneration::new(generation as u64 + 2),
|
||||
ensure_request("tab-a", input),
|
||||
);
|
||||
}
|
||||
release_first_tx.send(()).map_err(|error| error.to_string())?;
|
||||
worker.wait_until_idle();
|
||||
|
||||
assert_eq!(
|
||||
*calls.lock().map_err(|_| "call recorder lock was poisoned".to_string())?,
|
||||
vec![
|
||||
RecordedInput::Idle("tab-a".to_string()),
|
||||
RecordedInput::Scroll("tab-a".to_string()),
|
||||
RecordedInput::Click("tab-a".to_string()),
|
||||
RecordedInput::Text("tab-a".to_string()),
|
||||
RecordedInput::Hover("tab-a".to_string()),
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_tabs_are_dispatched_round_robin() -> Result<(), String> {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let (first_started_tx, first_started_rx) = mpsc::channel();
|
||||
let (release_first_tx, release_first_rx) = mpsc::channel();
|
||||
let client_calls = calls.clone();
|
||||
let worker = LiveRuntimeWorker::new(move || {
|
||||
Ok(Box::new(SlowRecordingClient {
|
||||
calls: client_calls,
|
||||
first_started_tx: Some(first_started_tx),
|
||||
release_first_rx,
|
||||
return_frame: false,
|
||||
}))
|
||||
})?;
|
||||
|
||||
worker.submit_ensure(
|
||||
RequestGeneration::new(1),
|
||||
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
|
||||
);
|
||||
first_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
|
||||
worker.submit_ensure(
|
||||
RequestGeneration::new(2),
|
||||
ensure_request("tab-a", RecordedInput::Click("tab-a".to_string())),
|
||||
);
|
||||
worker.submit_ensure(
|
||||
RequestGeneration::new(3),
|
||||
ensure_request("tab-b", RecordedInput::Click("tab-b".to_string())),
|
||||
);
|
||||
worker.submit_ensure(
|
||||
RequestGeneration::new(4),
|
||||
ensure_request("tab-a", RecordedInput::Text("tab-a".to_string())),
|
||||
);
|
||||
release_first_tx.send(()).map_err(|error| error.to_string())?;
|
||||
worker.wait_until_idle();
|
||||
|
||||
assert_eq!(
|
||||
*calls.lock().map_err(|_| "call recorder lock was poisoned".to_string())?,
|
||||
vec![
|
||||
RecordedInput::Idle("tab-a".to_string()),
|
||||
RecordedInput::Click("tab-b".to_string()),
|
||||
RecordedInput::Click("tab-a".to_string()),
|
||||
RecordedInput::Text("tab-a".to_string()),
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_keep_their_request_generation() -> Result<(), String> {
|
||||
let worker = LiveRuntimeWorker::new(|| Ok(Box::new(GenerationClient)))?;
|
||||
let ensure_generation = RequestGeneration::new(41);
|
||||
let poll_generation = RequestGeneration::new(42);
|
||||
|
||||
worker.submit_ensure(
|
||||
ensure_generation,
|
||||
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
|
||||
);
|
||||
worker.wait_until_idle();
|
||||
assert!(matches!(
|
||||
worker.drain_responses().as_slice(),
|
||||
[WorkerResponse::Frame { generation, tab_id, .. }]
|
||||
if *generation == ensure_generation && tab_id == "tab-a"
|
||||
));
|
||||
|
||||
assert!(worker.submit_poll(poll_generation, "tab-a".to_string()));
|
||||
worker.wait_until_idle();
|
||||
assert!(matches!(
|
||||
worker.drain_responses().as_slice(),
|
||||
[WorkerResponse::Failed { generation, tab_id, message }]
|
||||
if *generation == poll_generation && tab_id == "tab-a" && message == "poll failed"
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_updates_coalesce_to_latest_state() -> Result<(), String> {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let (first_started_tx, first_started_rx) = mpsc::channel();
|
||||
let (release_first_tx, release_first_rx) = mpsc::channel();
|
||||
let client_calls = calls.clone();
|
||||
let worker = LiveRuntimeWorker::new(move || {
|
||||
Ok(Box::new(SlowRecordingClient {
|
||||
calls: client_calls,
|
||||
first_started_tx: Some(first_started_tx),
|
||||
release_first_rx,
|
||||
return_frame: false,
|
||||
}))
|
||||
})?;
|
||||
|
||||
worker.submit_ensure(
|
||||
RequestGeneration::new(1),
|
||||
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
|
||||
);
|
||||
first_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
|
||||
for generation in 2..=101 {
|
||||
worker.submit_ensure(
|
||||
RequestGeneration::new(generation),
|
||||
ensure_request("tab-a", RecordedInput::Hover("tab-a".to_string())),
|
||||
);
|
||||
}
|
||||
release_first_tx.send(()).map_err(|error| error.to_string())?;
|
||||
worker.wait_until_idle();
|
||||
|
||||
assert_eq!(
|
||||
*calls.lock().map_err(|_| "call recorder lock was poisoned".to_string())?,
|
||||
vec![RecordedInput::Idle("tab-a".to_string()), RecordedInput::Hover("tab-a".to_string()),]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_is_rejected_while_same_tab_ensure_is_in_flight() -> Result<(), String> {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let (first_started_tx, first_started_rx) = mpsc::channel();
|
||||
let (release_first_tx, release_first_rx) = mpsc::channel();
|
||||
let worker = LiveRuntimeWorker::new(move || {
|
||||
Ok(Box::new(SlowRecordingClient {
|
||||
calls,
|
||||
first_started_tx: Some(first_started_tx),
|
||||
release_first_rx,
|
||||
return_frame: true,
|
||||
}))
|
||||
})?;
|
||||
let ensure_generation = RequestGeneration::new(1);
|
||||
|
||||
worker.submit_ensure(
|
||||
ensure_generation,
|
||||
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
|
||||
);
|
||||
first_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
|
||||
assert!(!worker.submit_poll(RequestGeneration::new(2), "tab-a".to_string()));
|
||||
release_first_tx.send(()).map_err(|error| error.to_string())?;
|
||||
worker.wait_until_idle();
|
||||
|
||||
assert!(matches!(
|
||||
worker.drain_responses().as_slice(),
|
||||
[WorkerResponse::Frame { generation, .. }] if *generation == ensure_generation
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_creation_does_not_wait_for_client_factory() -> Result<(), String> {
|
||||
let (factory_started_tx, factory_started_rx) = mpsc::channel();
|
||||
let (release_factory_tx, release_factory_rx) = mpsc::channel();
|
||||
let worker = LiveRuntimeWorker::new(move || {
|
||||
factory_started_tx.send(()).map_err(|error| error.to_string())?;
|
||||
release_factory_rx.recv().map_err(|error| error.to_string())?;
|
||||
Ok(Box::new(GenerationClient))
|
||||
})?;
|
||||
|
||||
worker.submit_ensure(
|
||||
RequestGeneration::new(1),
|
||||
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
|
||||
);
|
||||
factory_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
|
||||
release_factory_tx.send(()).map_err(|error| error.to_string())?;
|
||||
worker.wait_until_idle();
|
||||
assert!(matches!(worker.drain_responses().as_slice(), [WorkerResponse::Frame { .. }]));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_failure_rejects_queued_request() -> Result<(), String> {
|
||||
let worker = LiveRuntimeWorker::new(|| -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
Err("injected factory failure".to_string())
|
||||
})?;
|
||||
worker.submit_ensure(
|
||||
RequestGeneration::new(1),
|
||||
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
|
||||
);
|
||||
worker.wait_until_idle();
|
||||
let responses = worker.drain_responses();
|
||||
|
||||
assert!(responses.iter().any(|response| matches!(response, WorkerResponse::Failed { message, .. } if message == "injected factory failure")));
|
||||
assert!(
|
||||
responses.iter().any(|response| matches!(response, WorkerResponse::RuntimeUnavailable))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
reason = "this test deliberately poisons the worker queue mutex"
|
||||
)]
|
||||
fn poisoned_queue_still_shuts_down_worker() -> Result<(), String> {
|
||||
let worker = LiveRuntimeWorker::new(|| Ok(Box::new(GenerationClient)))?;
|
||||
let queue = worker.queue.clone();
|
||||
let poison_result = std::thread::spawn(move || {
|
||||
let (lock, _) = &*queue;
|
||||
let _guard = lock.lock().expect("queue lock should begin healthy");
|
||||
panic!("injected queue poison");
|
||||
})
|
||||
.join();
|
||||
|
||||
assert!(poison_result.is_err());
|
||||
drop(worker);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recorded_input(request: &ServoLiveEnsureRequest) -> RecordedInput {
|
||||
let tab_id = request.tab_id.clone();
|
||||
if request.scroll_delta_x != 0 || request.scroll_delta_y != 0 {
|
||||
RecordedInput::Scroll(tab_id)
|
||||
} else if request.click_x.is_some() {
|
||||
RecordedInput::Click(tab_id)
|
||||
} else if request.typed_text.is_some() {
|
||||
RecordedInput::Text(tab_id)
|
||||
} else if request.hover_x.is_some() {
|
||||
RecordedInput::Hover(tab_id)
|
||||
} else {
|
||||
RecordedInput::Idle(tab_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_request(tab_id: &str, input: RecordedInput) -> ServoLiveEnsureRequest {
|
||||
let scroll = matches!(input, RecordedInput::Scroll(_));
|
||||
let click = matches!(input, RecordedInput::Click(_));
|
||||
let text = matches!(input, RecordedInput::Text(_));
|
||||
let hover = matches!(input, RecordedInput::Hover(_));
|
||||
ServoLiveEnsureRequest {
|
||||
tab_id: tab_id.to_string(),
|
||||
profile_id: "profile".to_string(),
|
||||
url: "https://example.com/".to_string(),
|
||||
width: 640,
|
||||
height: 480,
|
||||
page_zoom_percent: 100,
|
||||
device_pixel_ratio: 1.0,
|
||||
scroll_delta_x: i32::from(scroll),
|
||||
scroll_delta_y: i32::from(scroll),
|
||||
scroll_point_x: scroll.then_some(1),
|
||||
scroll_point_y: scroll.then_some(1),
|
||||
click_x: click.then_some(1),
|
||||
click_y: click.then_some(1),
|
||||
hover_x: hover.then_some(1),
|
||||
hover_y: hover.then_some(1),
|
||||
typed_text: text.then(|| "text".to_string()),
|
||||
site_permissions: Vec::new(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user