feat(servo): isolate profiles with hardware sidecars

This commit is contained in:
2026-07-09 20:27:22 -04:00
parent 78dc86b18e
commit c28ec2bee8
92 changed files with 10306 additions and 1485 deletions
+97 -9
View File
@@ -1,6 +1,9 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
@@ -18,27 +21,32 @@ jobs:
run: rustup show
- name: Check formatting
run: cargo fmt --all --check
run: cargo --locked fmt --all --check
- name: Check workspace
run: cargo check --workspace --all-targets
run: cargo --locked check --workspace --all-targets
- name: Lint workspace
run: cargo clippy --workspace --all-targets -- -D warnings
run: cargo --locked clippy --workspace --all-targets -- -D warnings
- name: Test workspace
run: cargo test --workspace --all-targets
run: cargo --locked test --workspace --all-targets
- name: Check Servo engine host
run: cargo check -p ely_servo_host --features servo-engine --all-targets
run: cargo --locked check -p ely_servo_host --features servo-engine,hardware-render --all-targets
- name: Lint Servo engine host
run: cargo clippy -p ely_servo_host --features servo-engine --all-targets -- -D warnings
run: cargo --locked clippy -p ely_servo_host --features servo-engine,hardware-render --all-targets -- -D warnings
- name: Test Servo engine host
run: cargo test -p ely_servo_host --features servo-engine --test software_host
- name: Test Servo engine host all targets
run: cargo --locked test -p ely_servo_host --features servo-engine,hardware-render --all-targets -- --test-threads=1
- name: Test embedded PRD site compatibility
- name: Test app hardware IOSurface integration
env:
ELY_SERVO_RENDERING_CONTEXT: hardware
run: cargo --locked test -p ely_app --features live-site-smoke --all-targets web_surface_imports_hardware_iosurface -- --test-threads=1
- name: Test profile-scoped PRD site compatibility
run: scripts/verify_prd_site_rendering.sh
- name: Audit source file size
@@ -59,6 +67,86 @@ jobs:
- name: Verify built macOS app bundle metadata
run: scripts/verify_macos_bundle_metadata.sh "target/macos/ELY Browser.app/Contents/Info.plist"
- name: Verify macOS bundle executable placement
run: |
test -x "target/macos/ELY Browser.app/Contents/MacOS/ely_app"
test -x "target/macos/ELY Browser.app/Contents/MacOS/ely_servo_sidecar"
test -f "target/macos/ELY Browser.app/Contents/Resources/AppIcon.icns"
test ! -e "target/macos/ELY Browser.app/Contents/Resources/ely_servo_sidecar"
codesign --verify --deep --strict "target/macos/ELY Browser.app"
- name: Build adjacent-binary native distribution
run: scripts/create_native_distribution.sh
- name: Verify native distribution sidecar contract
run: |
test -x target/distribution/ely-browser/ely_app
test -x target/distribution/ely-browser/ely_servo_sidecar
portable:
name: Portable app (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use pinned Rust toolchain
run: rustup show
- name: Install Linux native dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libasound2-dev \
libfontconfig1-dev \
libssl-dev \
libwayland-dev \
libx11-dev \
libx11-xcb-dev \
libxkbcommon-x11-dev \
libxrandr-dev
- name: Check portable app targets
run: cargo --locked check -p ely_app --all-targets
- name: Check shell scripts
shell: bash
run: >-
bash -n
scripts/run_dev.sh
scripts/create_macos_app_bundle.sh
scripts/create_native_distribution.sh
scripts/release_build_env.sh
scripts/verify_release_artifacts.sh
- name: Build native distribution
shell: bash
run: scripts/create_native_distribution.sh
- name: Verify native distribution
shell: bash
run: |
suffix=""
if [[ "${RUNNER_OS}" == "Windows" ]]; then
suffix=".exe"
fi
test -f "target/distribution/ely-browser/ely_app${suffix}"
test -f "target/distribution/ely-browser/ely_servo_sidecar${suffix}"
if [[ "${RUNNER_OS}" == "Linux" ]]; then
test -f target/distribution/ely-browser/com.elydora.ely-browser.desktop
fi
if [[ "${RUNNER_OS}" == "Windows" ]]; then
test -f target/distribution/ely-browser/ely_app.exe.manifest
fi
cloudflare:
name: Cloudflare worker
runs-on: ubuntu-latest
Generated
+15
View File
@@ -2434,6 +2434,8 @@ name = "ely_app"
version = "0.1.0"
dependencies = [
"ahash",
"core-foundation 0.10.0",
"core-video",
"directories",
"ed25519-dalek 2.2.0",
"ely_browser_core",
@@ -2445,15 +2447,21 @@ dependencies = [
"gpui-component",
"gpui-component-assets",
"image",
"io-surface",
"mach2",
"objc2-core-foundation",
"objc2-io-surface",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"tracing",
"tracing-subscriber",
"ureq",
"url",
"uuid",
]
[[package]]
@@ -2492,12 +2500,18 @@ dependencies = [
"dpi",
"ely_domain",
"euclid",
"gleam",
"glow 0.17.0",
"image",
"mach2",
"naga 26.0.0",
"objc2-io-surface",
"raw-window-handle",
"rustls",
"serde",
"serde_json",
"servo",
"surfman",
"thiserror 2.0.18",
"url",
]
@@ -6264,6 +6278,7 @@ dependencies = [
"libc",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
+1
View File
@@ -28,6 +28,7 @@ sha2 = "0.10.9"
semver = "1.0.28"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
tempfile = "3.27.0"
thiserror = "2.0.12"
toml = "1.1.2"
tracing = "0.1"
+20 -8
View File
@@ -10,18 +10,30 @@ from version control.
## Local Commands
```bash
cargo fmt --all --check
cargo check --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace --all-targets
cargo check -p ely_servo_host --features servo-engine --all-targets
cargo clippy -p ely_servo_host --features servo-engine --all-targets -- -D warnings
cargo test -p ely_servo_host --features servo-engine --test software_host
cargo --locked fmt --all --check
cargo check --locked --workspace --all-targets
cargo clippy --locked --workspace --all-targets -- -D warnings
cargo test --locked --workspace --all-targets
cargo check --locked -p ely_servo_host --features servo-engine,hardware-render --all-targets
cargo clippy --locked -p ely_servo_host --features servo-engine,hardware-render --all-targets -- -D warnings
cargo test --locked -p ely_servo_host --features servo-engine,hardware-render --all-targets -- --test-threads=1
scripts/verify_prd_site_rendering.sh
scripts/verify_windows_app_manifest.sh
cargo run -p ely_app
scripts/create_macos_app_bundle.sh
scripts/create_native_distribution.sh
scripts/run_dev.sh
```
The macOS hardware tests cover the CGL rendering context, IOSurface export, sidecar Mach descriptor
transfer, GPUI surface lease, and IOSurface use-count release. `scripts/verify_prd_site_rendering.sh`
keeps the software RGBA assertions.
The local macOS QA bundle places both release executables in `Contents/MacOS`, installs
`AppIcon.icns`, and verifies its code signature. It uses an ad-hoc signature by default;
`ELY_CODESIGN_IDENTITY` selects an installed signing identity. Native distribution inputs place
`ely_app` and `ely_servo_sidecar` beside each other at the distribution root. The packaging scripts
verify those paths and executable permissions.
## Cloudflare Auth Configuration
`/api/auth/*` is served by Better Auth in the Cloudflare Worker. Local `wrangler dev`
+11 -1
View File
@@ -15,7 +15,6 @@ ed25519-dalek.workspace = true
ely_browser_core = { path = "../ely_browser_core" }
ely_design_system = { path = "../ely_design_system" }
ely_domain = { path = "../ely_domain" }
ely_servo_host = { path = "../ely_servo_host", features = ["servo-engine"] }
ely_sync_client = { path = "../ely_sync_client" }
gpui.workspace = true
gpui-component.workspace = true
@@ -24,13 +23,24 @@ image.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
tempfile.workspace = true
thiserror.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
ureq.workspace = true
url.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
core-video = "0.4"
io-surface = "0.16"
mach2 = "0.6"
objc2-core-foundation = { version = "0.3.2", features = ["CFBase", "CFDictionary", "CFNumber", "CFString"] }
objc2-io-surface = "0.3.2"
uuid.workspace = true
[dev-dependencies]
ely_servo_host = { path = "../ely_servo_host" }
gpui = { workspace = true, features = ["test-support"] }
[build-dependencies]
+79 -31
View File
@@ -11,24 +11,19 @@ fn main() -> Result<(), Box<dyn Error>> {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
let workspace_root = workspace_root(&manifest_dir)?;
let workspace_manifest_path = workspace_root.join("Cargo.toml");
let git_head_path = workspace_root.join(".git/HEAD");
let workspace_manifest = read_workspace_manifest(&workspace_manifest_path)?;
let workspace = table(&workspace_manifest, "workspace")?;
let package = table(workspace, "package")?;
let dependencies = table(workspace, "dependencies")?;
emit_cargo_directive(format!("cargo:rerun-if-changed={}", workspace_manifest_path.display()))?;
emit_cargo_directive(format!("cargo:rerun-if-changed={}", git_head_path.display()))?;
if let Some(ref_path) = git_head_ref(&git_head_path)? {
emit_cargo_directive(format!(
"cargo:rerun-if-changed={}",
workspace_root.join(".git").join(ref_path).display()
))?;
}
emit_cargo_directive("cargo:rerun-if-env-changed=ELY_BUILD_REVISION")?;
emit_env("ELY_BUILD_REVISION", &git_revision(workspace_root)?)?;
emit_env("ELY_BUILD_REVISION", &build_revision(workspace_root)?)?;
emit_env("ELY_WORKSPACE_LICENSE", string_value(package, "license")?)?;
emit_env("ELY_WORKSPACE_MANIFEST", path_value(&workspace_manifest_path)?)?;
if env::var("PROFILE").as_deref() == Ok("debug") {
emit_env("ELY_WORKSPACE_MANIFEST", path_value(&workspace_manifest_path)?)?;
}
emit_env("ELY_GPUI_VERSION", dependency_version(dependencies, "gpui")?)?;
emit_env("ELY_GPUI_COMPONENT_VERSION", dependency_version(dependencies, "gpui-component")?)?;
emit_env("ELY_SERVO_VERSION", dependency_version(dependencies, "servo")?)?;
@@ -90,39 +85,92 @@ fn dependency_version<'a>(
}
}
fn git_revision(workspace_root: &Path) -> Result<String, Box<dyn Error>> {
let output = Command::new("git")
.args(["rev-parse", "--short=12", "HEAD"])
.current_dir(workspace_root)
.output()?;
if !output.status.success() {
return Err(
io::Error::new(io::ErrorKind::InvalidData, "git revision is unavailable").into()
);
fn build_revision(workspace_root: &Path) -> Result<String, Box<dyn Error>> {
match env::var("ELY_BUILD_REVISION") {
Ok(revision) => return validated_revision(revision),
Err(env::VarError::NotUnicode(_)) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"ELY_BUILD_REVISION is not UTF-8",
)
.into());
}
Err(env::VarError::NotPresent) => {}
}
let revision = String::from_utf8(output.stdout)?.trim().to_string();
if revision.is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, "git revision is empty").into());
match fs::symlink_metadata(workspace_root.join(".git")) {
Ok(_) => git_revision(workspace_root),
Err(error) if error.kind() == io::ErrorKind::NotFound => {
validated_revision(format!("source-{}", env::var("CARGO_PKG_VERSION")?))
}
Err(error) => Err(error.into()),
}
Ok(revision)
}
fn git_head_ref(git_head_path: &Path) -> Result<Option<String>, Box<dyn Error>> {
let head = fs::read_to_string(git_head_path)?;
let Some(ref_path) = head.trim().strip_prefix("ref: ") else {
fn git_revision(workspace_root: &Path) -> Result<String, Box<dyn Error>> {
emit_git_watch(workspace_root, "HEAD")?;
emit_git_watch(workspace_root, "packed-refs")?;
if let Some(reference) = git_optional_output(workspace_root, &["symbolic-ref", "-q", "HEAD"])? {
emit_git_watch(workspace_root, &reference)?;
}
let revision = git_output(workspace_root, &["rev-parse", "--short=12", "HEAD"])?;
validated_revision(revision)
}
fn emit_git_watch(workspace_root: &Path, git_path: &str) -> Result<(), Box<dyn Error>> {
let path = PathBuf::from(git_output(workspace_root, &["rev-parse", "--git-path", git_path])?);
let path = if path.is_absolute() { path } else { workspace_root.join(path) };
emit_cargo_directive(format!("cargo:rerun-if-changed={}", path.display()))
}
fn git_output(workspace_root: &Path, arguments: &[&str]) -> Result<String, Box<dyn Error>> {
let output = Command::new("git").args(arguments).current_dir(workspace_root).output()?;
if !output.status.success() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("git {} failed", arguments.join(" ")),
)
.into());
}
String::from_utf8(output.stdout).map(|value| value.trim().to_string()).map_err(Into::into)
}
fn git_optional_output(
workspace_root: &Path,
arguments: &[&str],
) -> Result<Option<String>, Box<dyn Error>> {
let output = Command::new("git").args(arguments).current_dir(workspace_root).output()?;
if output.status.success() {
return String::from_utf8(output.stdout)
.map(|value| Some(value.trim().to_string()))
.map_err(Into::into);
}
if output.status.code() == Some(1) {
return Ok(None);
};
Ok(Some(ref_path.to_string()))
}
Err(io::Error::new(io::ErrorKind::InvalidData, format!("git {} failed", arguments.join(" ")))
.into())
}
fn validated_revision(revision: String) -> Result<String, Box<dyn Error>> {
let revision = revision.trim();
validate_env_value("ELY_BUILD_REVISION", revision)?;
Ok(revision.to_string())
}
fn emit_env(key: &str, value: &str) -> Result<(), Box<dyn Error>> {
if value.trim().is_empty() {
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("{key} is empty")).into());
}
validate_env_value(key, value)?;
emit_cargo_directive(format!("cargo:rustc-env={key}={value}"))?;
Ok(())
}
fn validate_env_value(key: &str, value: &str) -> Result<(), Box<dyn Error>> {
if value.trim().is_empty() || value.contains('\r') || value.contains('\n') {
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("invalid {key}")).into());
}
Ok(())
}
fn emit_cargo_directive(directive: impl AsRef<str>) -> Result<(), Box<dyn Error>> {
writeln!(io::stdout(), "{}", directive.as_ref())?;
Ok(())
@@ -0,0 +1,341 @@
#![cfg(target_os = "macos")]
use std::{
collections::BTreeMap,
ffi::CString,
mem,
time::{Duration, Instant},
};
use mach2::{
bootstrap::{bootstrap_port, bootstrap_register},
kern_return::KERN_SUCCESS,
mach_port::{
mach_port_allocate, mach_port_deallocate, mach_port_destroy, mach_port_insert_right,
},
message::{
MACH_MSG_PORT_DESCRIPTOR, MACH_MSG_SUCCESS, MACH_MSG_TIMEOUT_NONE, MACH_MSG_TYPE_MAKE_SEND,
MACH_MSG_TYPE_MOVE_SEND, MACH_MSGH_BITS_COMPLEX, MACH_RCV_MSG, MACH_RCV_TIMED_OUT,
MACH_RCV_TIMEOUT, mach_msg, mach_msg_body_t, mach_msg_header_t, mach_msg_port_descriptor_t,
mach_msg_trailer_t,
},
port::{MACH_PORT_NULL, MACH_PORT_RIGHT_RECEIVE, mach_port_t},
traps::mach_task_self,
};
use thiserror::Error;
use uuid::Uuid;
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
const SERVICE_PREFIX: &str = "com.ely.browser.iosurface";
const MAX_PENDING_SURFACE_PORTS: usize = 16;
pub(crate) struct IOSurfaceMachReceiver {
service_name: String,
receive_port: mach_port_t,
pending_ports: BTreeMap<u64, mach_port_t>,
}
#[derive(Debug, Error)]
pub(crate) enum IOSurfaceMachError {
#[error("Mach service name contains an interior nul byte")]
InvalidServiceName,
#[error("mach_port_allocate returned {code}")]
AllocatePort { code: i32 },
#[error("mach_port_insert_right returned {code}")]
InsertSendRight { code: i32 },
#[error("bootstrap_register returned {code}")]
RegisterService { code: i32 },
#[error("mach_msg receive timed out for IOSurface surface {surface_id:#x}")]
ReceiveTimedOut { surface_id: u64 },
#[error("mach_msg receive returned {code}")]
Receive { code: i32 },
#[error("received unexpected Mach message id {message_id}")]
UnexpectedMessage { message_id: i32 },
#[error("received invalid IOSurface Mach message")]
InvalidMessage,
}
impl IOSurfaceMachReceiver {
pub(crate) fn new() -> Result<Self, IOSurfaceMachError> {
let service_name = unique_service_name();
let service_name_c = CString::new(service_name.as_str())
.map_err(|_| IOSurfaceMachError::InvalidServiceName)?;
let mut receive_port = MACH_PORT_NULL;
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
let allocate =
unsafe { mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &mut receive_port) };
if allocate != KERN_SUCCESS {
return Err(IOSurfaceMachError::AllocatePort { code: allocate });
}
#[expect(unsafe_code)]
let insert = unsafe {
mach_port_insert_right(task, receive_port, receive_port, MACH_MSG_TYPE_MAKE_SEND)
};
if insert != KERN_SUCCESS {
destroy_port(receive_port);
return Err(IOSurfaceMachError::InsertSendRight { code: insert });
}
#[expect(unsafe_code)]
#[allow(deprecated)]
let register = unsafe {
bootstrap_register(bootstrap_port, service_name_c.as_ptr() as *mut _, receive_port)
};
if register != KERN_SUCCESS {
destroy_port(receive_port);
return Err(IOSurfaceMachError::RegisterService { code: register });
}
Ok(Self { service_name, receive_port, pending_ports: BTreeMap::new() })
}
pub(crate) fn service_name(&self) -> &str {
self.service_name.as_str()
}
pub(crate) fn receive_port_for_surface(
&mut self,
surface_id: u64,
timeout: Duration,
) -> Result<mach_port_t, IOSurfaceMachError> {
if let Some(port) = self.pending_ports.remove(&surface_id) {
return Ok(port);
}
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id });
}
let Some(received) = self.receive_one(remaining)? else {
return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id });
};
if received.surface_id == surface_id {
return Ok(received.mach_port);
}
if let Some(discarded) = insert_pending_port(
&mut self.pending_ports,
received.surface_id,
received.mach_port,
) {
deallocate_port(discarded);
}
}
}
fn receive_one(
&self,
timeout: Duration,
) -> Result<Option<ReceivedSurfacePort>, IOSurfaceMachError> {
#[expect(unsafe_code)]
let mut received_message: ReceivedIOSurfacePortMessage = unsafe { mem::zeroed() };
#[expect(unsafe_code)]
let result = unsafe {
mach_msg(
&mut received_message.message.header,
MACH_RCV_MSG | MACH_RCV_TIMEOUT,
0,
mem::size_of::<ReceivedIOSurfacePortMessage>() as u32,
self.receive_port,
timeout_millis(timeout),
MACH_PORT_NULL,
)
};
if result == MACH_RCV_TIMED_OUT {
return Ok(None);
}
if result != MACH_MSG_SUCCESS {
return Err(IOSurfaceMachError::Receive { code: result });
}
let message = &mut received_message.message;
if message.header.msgh_id != IOSURFACE_PORT_MESSAGE_ID {
let message_id = message.header.msgh_id;
destroy_message(message);
return Err(IOSurfaceMachError::UnexpectedMessage { message_id });
}
if !is_valid_surface_port_message(message) {
destroy_message(message);
return Err(IOSurfaceMachError::InvalidMessage);
}
Ok(Some(ReceivedSurfacePort {
surface_id: message.surface_id,
mach_port: message.surface_port.name,
}))
}
}
fn insert_pending_port(
pending_ports: &mut BTreeMap<u64, mach_port_t>,
surface_id: u64,
mach_port: mach_port_t,
) -> Option<mach_port_t> {
if let Some(replaced) = pending_ports.insert(surface_id, mach_port) {
return Some(replaced);
}
if pending_ports.len() > MAX_PENDING_SURFACE_PORTS
&& let Some(evicted_id) = pending_ports.keys().next().copied()
{
return pending_ports.remove(&evicted_id);
}
None
}
impl Drop for IOSurfaceMachReceiver {
fn drop(&mut self) {
for port in std::mem::take(&mut self.pending_ports).into_values() {
deallocate_port(port);
}
destroy_port(self.receive_port);
}
}
struct ReceivedSurfacePort {
surface_id: u64,
mach_port: mach_port_t,
}
#[repr(C)]
struct IOSurfacePortMessage {
header: mach_msg_header_t,
body: mach_msg_body_t,
surface_port: mach_msg_port_descriptor_t,
surface_id: u64,
}
#[repr(C)]
struct ReceivedIOSurfacePortMessage {
message: IOSurfacePortMessage,
_trailer: mach_msg_trailer_t,
}
fn is_valid_surface_port_message(message: &IOSurfacePortMessage) -> bool {
message.header.msgh_bits & MACH_MSGH_BITS_COMPLEX != 0
&& message.header.msgh_size == mem::size_of::<IOSurfacePortMessage>() as u32
&& message.body.msgh_descriptor_count == 1
&& message.surface_port.type_ == MACH_MSG_PORT_DESCRIPTOR as u8
&& message.surface_port.disposition == MACH_MSG_TYPE_MOVE_SEND as u8
&& message.surface_port.name != MACH_PORT_NULL
}
fn unique_service_name() -> String {
format!("{SERVICE_PREFIX}.{}", Uuid::now_v7().as_simple())
}
fn timeout_millis(timeout: Duration) -> u32 {
u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(MACH_MSG_TIMEOUT_NONE + 1)
}
fn destroy_message(message: &mut IOSurfacePortMessage) {
#[expect(unsafe_code)]
unsafe {
mach2::message::mach_msg_destroy(&mut message.header);
}
}
fn destroy_port(port: mach_port_t) {
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
unsafe {
let _ = mach_port_destroy(task, port);
}
}
fn deallocate_port(port: mach_port_t) {
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
unsafe {
let _ = mach_port_deallocate(task, port);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn surface_port_message() -> IOSurfacePortMessage {
IOSurfacePortMessage {
header: mach_msg_header_t {
msgh_bits: MACH_MSGH_BITS_COMPLEX,
msgh_size: mem::size_of::<IOSurfacePortMessage>() as u32,
msgh_id: IOSURFACE_PORT_MESSAGE_ID,
..mach_msg_header_t::default()
},
body: mach_msg_body_t { msgh_descriptor_count: 1 },
surface_port: mach_msg_port_descriptor_t::new(42, MACH_MSG_TYPE_MOVE_SEND),
surface_id: 7,
}
}
#[test]
fn surface_port_message_requires_kernel_translated_complex_descriptor() {
let mut message = surface_port_message();
assert!(is_valid_surface_port_message(&message));
message.header.msgh_bits &= !MACH_MSGH_BITS_COMPLEX;
assert!(!is_valid_surface_port_message(&message));
message = surface_port_message();
message.header.msgh_size -= 1;
assert!(!is_valid_surface_port_message(&message));
message = surface_port_message();
message.surface_port.disposition = mach2::message::MACH_MSG_TYPE_COPY_SEND as u8;
assert!(!is_valid_surface_port_message(&message));
}
#[test]
fn receive_rejects_simple_message_with_forged_port_descriptor() -> Result<(), IOSurfaceMachError>
{
let receiver = IOSurfaceMachReceiver::new()?;
let mut message = surface_port_message();
message.header.msgh_bits =
mach2::message::MACH_MSGH_BITS(mach2::message::MACH_MSG_TYPE_COPY_SEND, 0);
message.header.msgh_remote_port = receiver.receive_port;
message.surface_port.name = receiver.receive_port;
#[expect(unsafe_code)]
let send = unsafe {
mach_msg(
&mut message.header,
mach2::message::MACH_SEND_MSG,
message.header.msgh_size,
0,
MACH_PORT_NULL,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL,
)
};
assert_eq!(send, MACH_MSG_SUCCESS);
assert!(matches!(
receiver.receive_one(Duration::from_secs(1)),
Err(IOSurfaceMachError::InvalidMessage)
));
Ok(())
}
#[test]
fn pending_port_map_evicts_lowest_surface_at_capacity() {
let mut ports = BTreeMap::new();
for surface_id in 0..=MAX_PENDING_SURFACE_PORTS as u64 {
let discarded = insert_pending_port(&mut ports, surface_id, surface_id as mach_port_t);
if surface_id < MAX_PENDING_SURFACE_PORTS as u64 {
assert_eq!(discarded, None);
} else {
assert_eq!(discarded, Some(0));
}
}
assert_eq!(ports.len(), MAX_PENDING_SURFACE_PORTS);
assert!(!ports.contains_key(&0));
assert!(ports.contains_key(&(MAX_PENDING_SURFACE_PORTS as u64)));
}
}
@@ -0,0 +1,419 @@
#![cfg(target_os = "macos")]
use std::{
collections::{BTreeMap, VecDeque},
sync::{Arc, Weak},
};
use core_foundation::base::TCFType as _;
use core_video::pixel_buffer::CVPixelBuffer;
#[allow(deprecated)]
use io_surface::IOSurface;
use mach2::{mach_port::mach_port_deallocate, traps::mach_task_self};
use objc2_core_foundation::CFRetained;
use objc2_io_surface::IOSurfaceRef;
use thiserror::Error;
pub(crate) struct IOSurfaceCache {
surfaces: BTreeMap<u64, CachedSurface>,
insertion_order: VecDeque<u64>,
}
const MAX_CACHED_SURFACES: usize = 16;
struct CachedSurface {
iosurface: CFRetained<IOSurfaceRef>,
pending_backing: Option<Arc<HardwareSurfaceBacking>>,
active_backing: Weak<HardwareSurfaceBacking>,
width: u32,
height: u32,
}
#[derive(Debug, Error)]
pub(crate) enum SurfaceImportError {
#[error("IOSurfaceLookupFromMachPort returned null for port 0x{port:x}")]
LookupFailed { port: u32 },
#[error("CVPixelBufferCreateWithIOSurface returned status {status}")]
PixelBufferBuildFailed { status: i32 },
}
pub(crate) struct ImportedPixelBuffer {
pub(crate) system_surface_id: u64,
pub(crate) iosurface: CFRetained<IOSurfaceRef>,
pub(crate) backing: Arc<HardwareSurfaceBacking>,
}
pub(crate) struct HardwareSurfaceBacking {
pixel_buffer: CVPixelBuffer,
}
// SAFETY: `CVPixelBuffer` is a retained, read-only CoreVideo frame here.
// CoreFoundation retain/release is thread-safe, and both the runtime and GPU
// paths only clone the handle or submit the immutable IOSurface-backed image.
#[expect(unsafe_code)]
unsafe impl Send for HardwareSurfaceBacking {}
// SAFETY: The backing exposes immutable pixel-buffer metadata and clones.
// IOSurface lifetime tracking remains owned by CoreVideo's retained buffer.
#[expect(unsafe_code)]
unsafe impl Sync for HardwareSurfaceBacking {}
impl HardwareSurfaceBacking {
pub(crate) fn new(pixel_buffer: CVPixelBuffer) -> Arc<Self> {
Arc::new(Self { pixel_buffer })
}
pub(crate) fn pixel_buffer(&self) -> &CVPixelBuffer {
&self.pixel_buffer
}
}
impl IOSurfaceCache {
pub(crate) fn new() -> Self {
Self { surfaces: BTreeMap::new(), insertion_order: VecDeque::new() }
}
#[cfg(test)]
fn import(&mut self, mach_port_name: u32, surface_id: u64) -> Result<(), SurfaceImportError> {
let imported = import_pixel_buffer_from_mach_port(mach_port_name)?;
self.insert_imported(surface_id, imported);
Ok(())
}
pub(crate) fn insert_imported(&mut self, surface_id: u64, imported: ImportedPixelBuffer) {
let width = imported.backing.pixel_buffer().get_width() as u32;
let height = imported.backing.pixel_buffer().get_height() as u32;
if self
.surfaces
.get(&surface_id)
.is_some_and(|cached| cached.width == width && cached.height == height)
{
return;
}
if !self.surfaces.contains_key(&surface_id) {
while self.surfaces.len() >= MAX_CACHED_SURFACES {
if !self.evict_preferred_surface() {
break;
}
}
self.insertion_order.push_back(surface_id);
}
self.surfaces.insert(
surface_id,
CachedSurface {
iosurface: imported.iosurface,
pending_backing: Some(imported.backing),
active_backing: Weak::new(),
width,
height,
},
);
}
pub(crate) fn hardware_surface_for(
&mut self,
surface_id: u64,
) -> Result<Option<Arc<HardwareSurfaceBacking>>, SurfaceImportError> {
let Some(cached) = self.surfaces.get_mut(&surface_id) else {
return Ok(None);
};
let backing = cached
.active_backing
.upgrade()
.or_else(|| cached.pending_backing.take())
.map(Ok)
.unwrap_or_else(|| build_hardware_surface_backing(&cached.iosurface))?;
cached.active_backing = Arc::downgrade(&backing);
self.trim_to_capacity();
Ok(Some(backing))
}
pub(crate) fn surface_ids(&self) -> Vec<u64> {
self.surfaces.keys().copied().collect()
}
#[cfg(test)]
fn cached_surface_count(&self) -> usize {
self.surfaces.len()
}
fn trim_to_capacity(&mut self) {
while self.surfaces.len() > MAX_CACHED_SURFACES {
if !self.evict_preferred_surface() {
break;
}
}
}
fn evict_preferred_surface(&mut self) -> bool {
let inactive_index = self.insertion_order.iter().position(|surface_id| {
self.surfaces
.get(surface_id)
.is_none_or(|cached| cached.active_backing.upgrade().is_none())
});
let surface_id = match inactive_index {
Some(index) => self.insertion_order.remove(index),
None => self.insertion_order.pop_front(),
};
let Some(surface_id) = surface_id else {
return false;
};
self.surfaces.remove(&surface_id);
true
}
}
pub(crate) fn import_pixel_buffer_from_mach_port(
mach_port_name: u32,
) -> Result<ImportedPixelBuffer, SurfaceImportError> {
let result = build_pixel_buffer_from_mach_port(mach_port_name);
deallocate_mach_port(mach_port_name);
result
}
fn build_pixel_buffer_from_mach_port(
mach_port_name: u32,
) -> Result<ImportedPixelBuffer, SurfaceImportError> {
let Some(iosurface) = objc2_io_surface::IOSurfaceRef::lookup_from_mach_port(mach_port_name)
else {
return Err(SurfaceImportError::LookupFailed { port: mach_port_name });
};
let backing = build_hardware_surface_backing(&iosurface)?;
Ok(ImportedPixelBuffer { system_surface_id: u64::from(iosurface.id()), iosurface, backing })
}
fn build_hardware_surface_backing(
iosurface: &CFRetained<IOSurfaceRef>,
) -> Result<Arc<HardwareSurfaceBacking>, SurfaceImportError> {
let raw_ptr: *const std::ffi::c_void =
(&**iosurface) as *const objc2_io_surface::IOSurfaceRef as *const std::ffi::c_void;
#[allow(deprecated)]
let io_surface_view: IOSurface = {
#[expect(unsafe_code)]
unsafe {
IOSurface::wrap_under_get_rule(raw_ptr as io_surface::IOSurfaceRef)
}
};
let pixel_buffer = CVPixelBuffer::from_io_surface(&io_surface_view, None)
.map_err(|status| SurfaceImportError::PixelBufferBuildFailed { status })?;
Ok(HardwareSurfaceBacking::new(pixel_buffer))
}
fn deallocate_mach_port(port: u32) {
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
let _ = unsafe { mach_port_deallocate(task, port) };
}
#[cfg(test)]
mod tests {
use std::os::raw::c_void;
use std::sync::Arc;
use objc2_core_foundation::{
CFDictionary, CFIndex, CFNumber, CFString, kCFAllocatorDefault,
kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks,
};
use objc2_io_surface::{
IOSurfaceRef, kIOSurfaceBytesPerElement, kIOSurfaceBytesPerRow, kIOSurfaceHeight,
kIOSurfacePixelFormat, kIOSurfaceWidth,
};
use super::IOSurfaceCache;
fn build_local_iosurface(
width: u32,
height: u32,
) -> Result<objc2_core_foundation::CFRetained<IOSurfaceRef>, String> {
let pixel_format = i32::from_be_bytes(*b"BGRA");
let bytes_per_element = 4_i32;
let bytes_per_row = (width as i32) * bytes_per_element;
let width_num = CFNumber::new_i32(width as i32);
let height_num = CFNumber::new_i32(height as i32);
let bpe_num = CFNumber::new_i32(bytes_per_element);
let bpr_num = CFNumber::new_i32(bytes_per_row);
let pf_num = CFNumber::new_i32(pixel_format);
#[expect(unsafe_code)]
unsafe {
let keys: [&CFString; 5] = [
kIOSurfaceWidth,
kIOSurfaceHeight,
kIOSurfaceBytesPerElement,
kIOSurfaceBytesPerRow,
kIOSurfacePixelFormat,
];
let values: [&CFNumber; 5] = [&width_num, &height_num, &bpe_num, &bpr_num, &pf_num];
let properties = CFDictionary::new(
kCFAllocatorDefault,
keys.as_ptr() as *mut *const c_void,
values.as_ptr() as *mut *const c_void,
keys.len() as CFIndex,
&kCFTypeDictionaryKeyCallBacks,
&kCFTypeDictionaryValueCallBacks,
)
.ok_or_else(|| "CFDictionaryCreate returned null".to_string())?;
IOSurfaceRef::new(&properties)
.ok_or_else(|| "IOSurfaceCreate returned null".to_string())
}
}
#[test]
fn imports_iosurface_into_pixel_buffer_cache() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let iosurface = build_local_iosurface(64, 48)?;
let surface_id = u64::from(iosurface.id());
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
.map_err(|error| error.to_string())?;
assert_eq!(imported.system_surface_id, surface_id);
cache.insert_imported(surface_id, imported);
let surface = cache
.hardware_surface_for(surface_id)
.map_err(|error| error.to_string())?
.ok_or_else(|| "imported pixel buffer was missing".to_string())?;
assert_eq!(surface.pixel_buffer().get_width(), 64);
assert_eq!(surface.pixel_buffer().get_height(), 48);
assert_eq!(cache.cached_surface_count(), 1);
Ok(())
}
#[test]
fn repeated_surface_id_reuses_cache_entry() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let iosurface = build_local_iosurface(64, 48)?;
let surface_id = u64::from(iosurface.id());
cache
.import(iosurface.create_mach_port(), surface_id)
.map_err(|error| error.to_string())?;
cache
.import(iosurface.create_mach_port(), surface_id)
.map_err(|error| error.to_string())?;
assert_eq!(cache.cached_surface_count(), 1);
Ok(())
}
#[test]
fn active_surface_backing_reuses_and_releases_use_count() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let iosurface = build_local_iosurface(64, 48)?;
let surface_id = u64::from(iosurface.id());
let baseline_use_count = iosurface.use_count();
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
.map_err(|error| error.to_string())?;
assert_eq!(iosurface.use_count(), baseline_use_count + 1);
cache.insert_imported(surface_id, imported);
let first = cache
.hardware_surface_for(surface_id)
.map_err(|error| error.to_string())?
.ok_or_else(|| "first hardware surface was missing".to_string())?;
let second = cache
.hardware_surface_for(surface_id)
.map_err(|error| error.to_string())?
.ok_or_else(|| "second hardware surface was missing".to_string())?;
assert!(Arc::ptr_eq(&first, &second));
assert_eq!(iosurface.use_count(), baseline_use_count + 1);
drop(first);
drop(second);
assert_eq!(iosurface.use_count(), baseline_use_count);
let rebuilt = cache
.hardware_surface_for(surface_id)
.map_err(|error| error.to_string())?
.ok_or_else(|| "rebuilt hardware surface was missing".to_string())?;
assert_eq!(iosurface.use_count(), baseline_use_count + 1);
drop(rebuilt);
assert_eq!(iosurface.use_count(), baseline_use_count);
drop(cache);
assert_eq!(iosurface.use_count(), baseline_use_count);
Ok(())
}
#[test]
fn cache_evicts_oldest_surface_at_capacity() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let mut surface_ids = Vec::new();
for _ in 0..=super::MAX_CACHED_SURFACES {
let iosurface = build_local_iosurface(8, 8)?;
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
.map_err(|error| error.to_string())?;
surface_ids.push(imported.system_surface_id);
cache.insert_imported(imported.system_surface_id, imported);
}
assert_eq!(cache.cached_surface_count(), super::MAX_CACHED_SURFACES);
assert!(
cache
.hardware_surface_for(surface_ids[0])
.map_err(|error| error.to_string())?
.is_none()
);
assert!(
cache
.hardware_surface_for(surface_ids[super::MAX_CACHED_SURFACES])
.map_err(|error| error.to_string())?
.is_some()
);
assert!(!cache.surface_ids().contains(&surface_ids[0]));
Ok(())
}
#[test]
fn cache_stays_bounded_while_gpu_backings_are_active() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let mut surface_ids = Vec::new();
let mut native_surfaces = Vec::new();
let mut active_backings = Vec::new();
for _ in 0..super::MAX_CACHED_SURFACES {
let iosurface = build_local_iosurface(8, 8)?;
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
.map_err(|error| error.to_string())?;
surface_ids.push(imported.system_surface_id);
native_surfaces.push(iosurface);
cache.insert_imported(imported.system_surface_id, imported);
active_backings.push(
cache
.hardware_surface_for(*surface_ids.last().ok_or("surface id was missing")?)
.map_err(|error| error.to_string())?
.ok_or("active surface backing was missing")?,
);
}
let overflow = build_local_iosurface(8, 8)?;
let overflow = super::import_pixel_buffer_from_mach_port(overflow.create_mach_port())
.map_err(|error| error.to_string())?;
let overflow_id = overflow.system_surface_id;
cache.insert_imported(overflow_id, overflow);
assert_eq!(cache.cached_surface_count(), super::MAX_CACHED_SURFACES);
assert!(!cache.surface_ids().contains(&surface_ids[0]));
assert_eq!(active_backings[0].pixel_buffer().get_width(), 8);
assert!(
cache
.hardware_surface_for(surface_ids[0])
.map_err(|error| error.to_string())?
.is_none()
);
assert!(
cache.hardware_surface_for(overflow_id).map_err(|error| error.to_string())?.is_some()
);
cache
.import(native_surfaces[0].create_mach_port(), surface_ids[0])
.map_err(|error| error.to_string())?;
let reimported = cache
.hardware_surface_for(surface_ids[0])
.map_err(|error| error.to_string())?
.ok_or("reimported surface backing was missing")?;
assert_eq!(cache.cached_surface_count(), super::MAX_CACHED_SURFACES);
assert!(cache.surface_ids().contains(&surface_ids[0]));
assert!(!Arc::ptr_eq(&active_backings[0], &reimported));
Ok(())
}
}
+6
View File
@@ -1,11 +1,17 @@
pub mod download_checksums;
pub mod download_files;
pub mod http_downloads;
#[cfg(target_os = "macos")]
pub(crate) mod iosurface_mach;
#[cfg(target_os = "macos")]
pub(crate) mod iosurface_metal;
pub mod plugin_package_store;
pub mod plugin_packages;
pub mod plugin_signatures;
pub(crate) mod profile_identity;
pub mod servo_live;
pub(crate) mod servo_profile_data;
mod servo_sidecar_command;
pub(crate) use servo_profile_data::ProfileDataMode;
@@ -0,0 +1,166 @@
use std::{
fs,
io::{self, Write},
path::{Path, PathBuf},
};
use ely_domain::ProfileId;
use thiserror::Error;
#[cfg(not(test))]
use super::servo_profile_data::{create_profile_data_dir, default_profile_data_root};
#[cfg(not(test))]
const DEFAULT_PROFILE_ID_FILE: &str = "profile-id";
#[cfg(not(test))]
const DEFAULT_PROFILE_DIRECTORY: &str = "default";
#[cfg(test)]
pub(crate) fn default_standard_profile_id() -> Result<ProfileId, ProfileIdentityError> {
static TEST_PROFILE_ID: std::sync::OnceLock<ProfileId> = std::sync::OnceLock::new();
Ok(TEST_PROFILE_ID.get_or_init(ProfileId::new).clone())
}
#[cfg(not(test))]
pub(crate) fn default_standard_profile_id() -> Result<ProfileId, ProfileIdentityError> {
let root = default_profile_data_root().ok_or(ProfileIdentityError::DataRootUnavailable)?;
let profile_id = load_or_create_profile_id(
&root.join(DEFAULT_PROFILE_DIRECTORY).join(DEFAULT_PROFILE_ID_FILE),
)?;
create_profile_data_dir(&root, &profile_id)?;
Ok(profile_id)
}
fn load_or_create_profile_id(path: &Path) -> Result<ProfileId, ProfileIdentityError> {
match read_profile_id(path) {
Ok(profile_id) => return Ok(profile_id),
Err(ProfileIdentityError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
let parent = path
.parent()
.ok_or_else(|| ProfileIdentityError::ParentUnavailable { path: path.to_path_buf() })?;
create_private_directory(parent)?;
let profile_id = ProfileId::new();
let mut file = tempfile::NamedTempFile::new_in(parent)?;
file.write_all(profile_id.as_str().as_bytes())?;
file.write_all(b"\n")?;
file.as_file().sync_all()?;
match file.persist_noclobber(path) {
Ok(_) => {
sync_parent_directory(parent)?;
Ok(profile_id)
}
Err(error) if error.error.kind() == io::ErrorKind::AlreadyExists => read_profile_id(path),
Err(error) => Err(error.error.into()),
}
}
fn create_private_directory(path: &Path) -> Result<(), io::Error> {
fs::create_dir_all(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
#[cfg(unix)]
fn sync_parent_directory(path: &Path) -> Result<(), io::Error> {
fs::File::open(path)?.sync_all()
}
#[cfg(not(unix))]
fn sync_parent_directory(_path: &Path) -> Result<(), io::Error> {
Ok(())
}
fn read_profile_id(path: &Path) -> Result<ProfileId, ProfileIdentityError> {
let raw = fs::read_to_string(path)?;
ProfileId::parse(raw.trim()).map_err(ProfileIdentityError::Domain)
}
#[derive(Debug, Error)]
pub(crate) enum ProfileIdentityError {
#[cfg(not(test))]
#[error("profile data root is unavailable")]
DataRootUnavailable,
#[error("profile identity parent directory is unavailable for {path}")]
ParentUnavailable { path: PathBuf },
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
}
#[cfg(test)]
mod tests {
use super::load_or_create_profile_id;
use crate::services::servo_profile_data::profile_data_dir;
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
#[test]
fn default_profile_identity_survives_restart() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let root = directory.path().join("profiles");
let path = root.join("default").join("profile-id");
let first_start = load_or_create_profile_id(&path)?;
let second_start = load_or_create_profile_id(&path)?;
let mut first_config = InitialBrowserConfig::ely_defaults()?;
first_config.profile_id = Some(first_start.clone());
let first_core = BrowserCore::new(first_config)?;
let mut second_config = InitialBrowserConfig::ely_defaults()?;
second_config.profile_id = Some(second_start.clone());
let second_core = BrowserCore::new(second_config)?;
assert_eq!(first_start, second_start);
assert_eq!(std::fs::read_to_string(&path)?.trim(), first_start.as_str());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(path.parent().ok_or("missing marker parent")?)?
.permissions()
.mode()
& 0o777,
0o700
);
}
assert_eq!(first_core.active_tab()?.profile_id(), second_core.active_tab()?.profile_id());
assert_eq!(
profile_data_dir(&root, first_core.active_tab()?.profile_id()),
profile_data_dir(&root, second_core.active_tab()?.profile_id()),
);
Ok(())
}
#[test]
fn concurrent_starts_converge_on_one_profile_identity() -> Result<(), Box<dyn std::error::Error>>
{
let directory = tempfile::tempdir()?;
let path = std::sync::Arc::new(directory.path().join("default").join("profile-id"));
let threads = (0..4)
.map(|_| {
let path = path.clone();
std::thread::spawn(move || load_or_create_profile_id(&path))
})
.collect::<Vec<_>>();
let mut identities = Vec::new();
for thread in threads {
identities.push(
thread
.join()
.map_err(|_| std::io::Error::other("profile identity thread panicked"))??,
);
}
assert!(identities.windows(2).all(|pair| pair[0] == pair[1]));
Ok(())
}
}
+396 -429
View File
@@ -1,498 +1,465 @@
use std::{collections::BTreeMap, path::PathBuf};
use ely_domain::{
ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, TabId, UrlText, WebViewId,
};
use ely_servo_host::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest,
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest,
ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost,
use std::{
path::PathBuf,
process::{Child, Stdio},
thread,
time::{Duration, Instant},
};
#[cfg(target_os = "macos")]
use std::collections::BTreeSet;
#[cfg(target_os = "macos")]
#[path = "servo_live_iosurface_importer.rs"]
mod iosurface_importer;
#[path = "servo_live_ipc.rs"]
mod ipc;
#[path = "servo_live_types.rs"]
mod types;
#[path = "servo_live_wire.rs"]
mod wire;
pub(crate) use types::{
ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveSitePermission,
};
/// Servo's `WebViewBuilder` defaults the hidpi (device → CSS) scale to
/// 1.0 — see the contract documented on
/// [`ely_servo_host::HidpiScaleRequest`]. Mirroring the post-build state
/// in [`DirectWebViewSession`] makes the diff inside `apply_viewport`
/// the source of truth for whether `set_hidpi_scale` has to run.
const SERVO_DEFAULT_DEVICE_PIXEL_RATIO: f32 = 1.0;
use self::{
ipc::{IpcReply, ServoLiveIpc, validate_frame_layout},
wire::{LIVE_PROTOCOL_VERSION, LiveRequest, LiveSurfaceHandle},
};
use super::servo_sidecar_command::{
SidecarRenderingContext, default_sidecar_command, rendering_context_from_env,
};
/// Servo's `WebViewBuilder` likewise defaults page zoom to 1.0 (100%).
const SERVO_DEFAULT_PAGE_ZOOM_PERCENT: u16 = 100;
#[cfg(target_os = "macos")]
use super::iosurface_metal::IOSurfaceCache;
#[cfg(target_os = "macos")]
use iosurface_importer::{IOSurfaceImportResult, IOSurfaceImportWorker};
const SIDECAR_TIMEOUTS: SidecarTimeouts = SidecarTimeouts {
request: Duration::from_secs(10),
shutdown: Duration::from_secs(2),
exit: Duration::from_secs(2),
};
#[derive(Clone, Copy)]
struct SidecarTimeouts {
request: Duration,
shutdown: Duration,
exit: Duration,
}
pub(crate) struct ServoLiveClient {
host: SoftwareServoHost,
sessions: BTreeMap<String, DirectWebViewSession>,
child: Child,
ipc: ServoLiveIpc,
timeouts: SidecarTimeouts,
active: bool,
#[cfg(target_os = "macos")]
iosurface_cache: IOSurfaceCache,
#[cfg(target_os = "macos")]
iosurface_importer: Option<IOSurfaceImportWorker>,
#[cfg(target_os = "macos")]
pending_surface_ids: BTreeSet<u64>,
}
impl ServoLiveClient {
pub fn new(profile_data_dir: PathBuf) -> Result<Self, ServoLiveError> {
let host = SoftwareServoHost::new_with_config_dir(
ServoSurfaceSize::new(1, 1),
Some(profile_data_dir),
)?;
Ok(Self { host, sessions: BTreeMap::new() })
let rendering_context = rendering_context_from_env();
let command_target = default_sidecar_command()?;
if let Some(path) = command_target.missing_binary_path() {
return Err(ServoLiveError::SidecarBinaryUnavailable { path: path.to_path_buf() });
}
let mut command = command_target.command();
command.arg("live").arg("--profile-data-dir").arg(profile_data_dir);
command.arg("--rendering-context").arg(rendering_context.cli_arg());
#[cfg(target_os = "macos")]
let iosurface_importer = if rendering_context == SidecarRenderingContext::Hardware {
let receiver = super::iosurface_mach::IOSurfaceMachReceiver::new()?;
command.arg("--iosurface-mach-service").arg(receiver.service_name());
Some(
IOSurfaceImportWorker::new(receiver)
.map_err(ServoLiveError::IOSurfaceImportWorker)?,
)
} else {
None
};
let child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(ServoLiveError::Command)?;
let mut client = Self::from_spawned_child(child, SIDECAR_TIMEOUTS)?;
#[cfg(target_os = "macos")]
{
client.iosurface_importer = iosurface_importer;
}
Ok(client)
}
fn from_spawned_child(
mut child: Child,
timeouts: SidecarTimeouts,
) -> Result<Self, ServoLiveError> {
let Some(stdin) = child.stdin.take() else {
terminate_child(&mut child, timeouts.exit);
return Err(ServoLiveError::PipeUnavailable { name: "stdin" });
};
let Some(stdout) = child.stdout.take() else {
drop(stdin);
terminate_child(&mut child, timeouts.exit);
return Err(ServoLiveError::PipeUnavailable { name: "stdout" });
};
let mut client = Self {
child,
ipc: ServoLiveIpc::spawn(stdin, stdout),
timeouts,
active: true,
#[cfg(target_os = "macos")]
iosurface_cache: IOSurfaceCache::new(),
#[cfg(target_os = "macos")]
iosurface_importer: None,
#[cfg(target_os = "macos")]
pending_surface_ids: BTreeSet::new(),
};
if let Err(error) = client.handshake() {
client.terminate();
return Err(error);
}
Ok(client)
}
pub fn ensure(
&mut self,
request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
let tab_id = TabId::parse(request.tab_id.clone())?;
let profile_id = ProfileId::parse(request.profile_id.clone())?;
let requested_url = UrlText::parse(request.url.clone())?;
let webview_id = self.ensure_webview(&request, &tab_id, &profile_id)?;
self.apply_viewport(&request, &webview_id)?;
self.apply_permissions(&request, &webview_id, &profile_id)?;
self.apply_navigation(&request, &webview_id, tab_id, requested_url)?;
self.apply_input(&request, &webview_id)?;
// Match Servo's `examples/winit_minimal.rs`: spin the event loop on
// the embedder-side hot path, never paint. Servo's public
// rendering contract says `notify_new_frame_ready` is the signal
// for `WebView::paint`; URL/title/load-status callbacks are
// metadata updates and travel through snapshots. Painting here
// would present before Servo has composited the navigated page,
// which produced per-redirect white flashes.
self.host.tick();
if self.session_uses_native_surface(&request.tab_id) {
return self.presented_frame_from_session(&request.tab_id, &webview_id).map(Some);
}
Ok(None)
#[cfg(target_os = "macos")]
self.drain_iosurface_imports()?;
validate_frame_layout(request.width, request.height)?;
let ready_surface_ids = self.ready_surface_ids();
let pending_surface_ids = self.pending_surface_ids();
self.request(LiveRequest::Ensure {
tab_id: request.tab_id,
profile_id: request.profile_id,
url: request.url,
width: request.width,
height: request.height,
page_zoom_percent: request.page_zoom_percent,
device_pixel_ratio: request.device_pixel_ratio,
scroll_delta_x: request.scroll_delta_x,
scroll_delta_y: request.scroll_delta_y,
scroll_point_x: request.scroll_point_x,
scroll_point_y: request.scroll_point_y,
click_x: request.click_x,
click_y: request.click_y,
hover_x: request.hover_x,
hover_y: request.hover_y,
typed_text: request.typed_text,
site_permissions: request.site_permissions,
ready_surface_ids,
pending_surface_ids,
})
}
pub fn poll(&mut self, tab_id: String) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
let Some(session) = self.sessions.get(&tab_id) else {
return Ok(None);
};
let webview_id = session.webview_id.clone();
let uses_native_surface = session.native_surface_id.is_some();
self.host.tick();
let snapshot = self.host.snapshot(&webview_id)?;
if !snapshot.has_pending_frame() && !snapshot.has_pending_metadata() {
return Ok(None);
}
if uses_native_surface {
if snapshot.has_pending_frame() {
self.host.paint_without_readback_with_completion(&webview_id, false)?;
}
return self.presented_frame_from_session(&tab_id, &webview_id).map(Some);
}
if snapshot.has_pending_frame() {
self.host.paint(&webview_id)?;
}
self.rendered_frame_from_session(&tab_id, &webview_id)
#[cfg(target_os = "macos")]
self.drain_iosurface_imports()?;
self.request(LiveRequest::Poll {
tab_id,
ready_surface_ids: self.ready_surface_ids(),
pending_surface_ids: self.pending_surface_ids(),
})
}
pub fn close(&mut self, tab_id: String) -> Result<(), ServoLiveError> {
let Some(session) = self.sessions.remove(&tab_id) else {
return Ok(());
};
self.host.close_webview(&session.webview_id);
Ok(())
self.request(LiveRequest::Close { tab_id }).map(|_| ())
}
fn ensure_webview(
&mut self,
request: &ServoLiveEnsureRequest,
tab_id: &TabId,
profile_id: &ProfileId,
) -> Result<WebViewId, ServoLiveError> {
if self
.sessions
.get(&request.tab_id)
.is_some_and(|session| session.profile_id != *profile_id)
&& let Some(session) = self.sessions.remove(&request.tab_id)
fn handshake(&mut self) -> Result<(), ServoLiveError> {
let reply = self.exchange(
LiveRequest::Handshake { protocol_version: LIVE_PROTOCOL_VERSION },
self.timeouts.request,
"handshake",
)?;
if reply.frame.is_some()
|| reply.surface_handle.is_some()
|| reply.current_surface_id.is_some()
{
self.host.close_webview(&session.webview_id);
return Err(ServoLiveError::InvalidResponse {
message: "handshake response contains a frame",
});
}
let native_surface_id =
request.native_surface.as_ref().map(gpui::NativeSurfaceHandle::identity);
if self
.sessions
.get(&request.tab_id)
.is_some_and(|session| session.native_surface_id != native_surface_id)
&& let Some(session) = self.sessions.remove(&request.tab_id)
{
self.host.close_webview(&session.webview_id);
match reply.error {
Some(message) => Err(ServoLiveError::SidecarFailed { message }),
None => Ok(()),
}
if let Some(session) = self.sessions.get(&request.tab_id) {
return Ok(session.webview_id.clone());
}
let surface_size = ServoSurfaceSize::new(request.width, request.height);
let webview_id = match request.native_surface.as_ref() {
Some(native_surface) => self.host.create_webview_with_native_surface(
tab_id.clone(),
profile_id.clone(),
surface_size,
native_surface,
)?,
None => self.host.create_webview_with_size(
tab_id.clone(),
profile_id.clone(),
surface_size,
)?,
};
self.sessions.insert(
request.tab_id.clone(),
DirectWebViewSession {
webview_id: webview_id.clone(),
profile_id: profile_id.clone(),
requested_url: None,
width: request.width,
height: request.height,
// Servo's WebViewBuilder defaults page zoom to 1.0 (100%) and
// hidpi scale to 1.0; record both as Servo's actual post-build
// state so `apply_viewport` pushes the embedder-requested
// values on the first ensure. Without this, a request whose
// zoom/DPR happens to equal the cached request value would
// bypass `set_page_zoom` / `set_hidpi_scale` and leave the
// WebView at Servo's defaults — on Retina that collapses CSS
// pixels onto device pixels and renders pages at half size.
page_zoom_percent: SERVO_DEFAULT_PAGE_ZOOM_PERCENT,
device_pixel_ratio: SERVO_DEFAULT_DEVICE_PIXEL_RATIO,
native_surface_id,
},
);
Ok(webview_id)
}
fn apply_viewport(
fn request(&mut self, request: LiveRequest) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
let reply = self.exchange(request, self.timeouts.request, "request")?;
if let Some(message) = reply.error {
return Err(ServoLiveError::SidecarFailed { message });
}
#[cfg(target_os = "macos")]
if let Some(handle) = reply.surface_handle {
self.queue_iosurface_handle(handle)?;
self.drain_iosurface_imports()?;
}
let mut frame = reply.frame;
#[cfg(target_os = "macos")]
if let (Some(frame), Some(surface_id)) = (frame.as_mut(), reply.current_surface_id) {
let hardware_surface =
self.iosurface_cache.hardware_surface_for(surface_id).map_err(|error| {
ServoLiveError::IOSurfaceBackingFailed {
surface_id,
message: error.to_string(),
}
})?;
if hardware_surface.is_none() && !frame.has_software_payload() {
return Ok(None);
}
frame.set_hardware_surface(surface_id, hardware_surface);
}
Ok(frame)
}
fn exchange(
&mut self,
request: &ServoLiveEnsureRequest,
webview_id: &WebViewId,
) -> Result<(), ServoLiveError> {
let Some(session) = self.sessions.get_mut(&request.tab_id) else {
request: LiveRequest,
timeout: Duration,
operation: &'static str,
) -> Result<IpcReply, ServoLiveError> {
if !self.active {
return Err(ServoLiveError::SidecarExited);
}
let reply = match self.ipc.exchange(request, timeout, operation) {
Ok(reply) => reply,
Err(error) => {
self.terminate();
return Err(error);
}
};
if reply.protocol_version != Some(LIVE_PROTOCOL_VERSION) {
let error = ServoLiveError::ProtocolVersionMismatch {
expected: LIVE_PROTOCOL_VERSION,
actual: reply.protocol_version,
};
self.terminate();
return Err(error);
}
Ok(reply)
}
fn shutdown(&mut self) {
if !self.active {
return;
}
let acknowledged = self
.ipc
.exchange(LiveRequest::Shutdown, self.timeouts.shutdown, "shutdown")
.ok()
.is_some_and(|reply| {
reply.protocol_version == Some(LIVE_PROTOCOL_VERSION)
&& reply.error.is_none()
&& reply.frame.is_none()
&& reply.surface_handle.is_none()
&& reply.current_surface_id.is_none()
});
if acknowledged && wait_for_exit(&mut self.child, self.timeouts.exit) {
self.active = false;
self.ipc.close_and_join(self.timeouts.exit);
return;
}
self.terminate();
}
fn terminate(&mut self) {
if self.active {
terminate_child(&mut self.child, self.timeouts.exit);
self.active = false;
}
self.ipc.close_and_join(self.timeouts.exit);
}
}
#[cfg(not(target_os = "macos"))]
impl ServoLiveClient {
fn ready_surface_ids(&self) -> Vec<u64> {
Vec::new()
}
fn pending_surface_ids(&self) -> Vec<u64> {
Vec::new()
}
}
#[cfg(target_os = "macos")]
impl ServoLiveClient {
fn ready_surface_ids(&self) -> Vec<u64> {
self.iosurface_cache.surface_ids()
}
fn pending_surface_ids(&self) -> Vec<u64> {
self.pending_surface_ids.iter().copied().collect()
}
fn queue_iosurface_handle(&mut self, handle: LiveSurfaceHandle) -> Result<(), ServoLiveError> {
let Some(importer) = self.iosurface_importer.as_ref() else {
return Err(ServoLiveError::IOSurfaceImportFailed {
surface_id: handle.surface_id,
mach_port_name: handle.mach_port_name,
message: "IOSurface import worker is unavailable".to_string(),
});
};
let surface_id = handle.surface_id;
importer.submit(handle).map_err(|failure| ServoLiveError::IOSurfaceImportFailed {
surface_id: failure.surface_id,
mach_port_name: failure.mach_port_name,
message: failure.message,
})?;
self.pending_surface_ids.insert(surface_id);
Ok(())
}
fn drain_iosurface_imports(&mut self) -> Result<(), ServoLiveError> {
let Some(importer) = self.iosurface_importer.as_ref() else {
return Ok(());
};
let change = ViewportChange::between(request, session);
if let Some((width, height)) = change.resize {
self.host.resize(ResizeRequest { webview_id: webview_id.clone(), width, height })?;
session.width = width;
session.height = height;
}
if let Some(scale_factor) = change.set_hidpi {
self.host.set_hidpi_scale(HidpiScaleRequest {
webview_id: webview_id.clone(),
scale_factor,
})?;
session.device_pixel_ratio = scale_factor;
}
if let Some(page_zoom_percent) = change.set_page_zoom {
self.host.set_page_zoom(PageZoomRequest {
webview_id: webview_id.clone(),
zoom_factor: f32::from(page_zoom_percent) / 100.0,
})?;
session.page_zoom_percent = page_zoom_percent;
}
Ok(())
apply_iosurface_import_results(
&mut self.iosurface_cache,
&mut self.pending_surface_ids,
importer.drain(),
)
}
}
fn apply_permissions(
&mut self,
request: &ServoLiveEnsureRequest,
webview_id: &WebViewId,
profile_id: &ProfileId,
) -> Result<(), ServoLiveError> {
for permission in &request.site_permissions {
let origin = SiteOrigin::parse(permission.origin.clone())?;
let feature = SitePermissionFeature::parse(permission.feature.as_str())?;
let decision = SitePermissionDecision::parse(permission.decision.as_str())?;
self.host.set_permission(
PermissionRequest {
webview_id: webview_id.clone(),
profile_id: profile_id.clone(),
origin,
feature,
},
PermissionDecision::from(decision),
)?;
}
Ok(())
}
fn apply_navigation(
&mut self,
request: &ServoLiveEnsureRequest,
webview_id: &WebViewId,
tab_id: TabId,
requested_url: UrlText,
) -> Result<(), ServoLiveError> {
// Servo's `set_history` fires `notify_url_changed` on *every*
// history mutation — full navigations, redirects, in-page
// links, and JS-driven `history.pushState` / `replaceState`.
// Our embedder fans that back through `WebSurfaceUrlChange`
// into `tab.url`, which makes the next `ensure_surface` see a
// "different" URL and call back into this method. Without a
// guard we then send Servo `WebView::load(new_url)` for a URL
// Servo just informed us it is *already* at — and `load` is a
// hard navigation that aborts the live document, clears the
// surface, and refetches.
//
// The google.com homepage `replaceState`s a `?zx=<timestamp>`
// every second; under the unguarded path that turned into a
// load-clear-refetch cycle per second, i.e. the continuous
// white flash. Servo's own `webview.url()` (driven by
// `set_history`) is the source of truth — if it already
// matches the requested URL, this URL change came *from*
// Servo and only needs an embedder-side bookkeeping sync.
let servo_current_url = self.host.snapshot(webview_id)?.url().map(str::to_string);
if servo_current_url.as_deref() == Some(requested_url.as_str()) {
if let Some(session) = self.sessions.get_mut(&request.tab_id) {
session.requested_url = Some(requested_url.as_str().to_string());
#[cfg(target_os = "macos")]
fn apply_iosurface_import_results(
cache: &mut IOSurfaceCache,
pending_surface_ids: &mut BTreeSet<u64>,
results: Vec<IOSurfaceImportResult>,
) -> Result<(), ServoLiveError> {
let mut first_failure = None;
for result in results {
let surface_id = match &result {
IOSurfaceImportResult::Imported(imported) => imported.surface_id,
IOSurfaceImportResult::Failed(failure) => failure.surface_id,
};
pending_surface_ids.remove(&surface_id);
match result {
IOSurfaceImportResult::Imported(imported) => {
cache.insert_imported(imported.surface_id, imported.imported);
}
return Ok(());
}
let should_navigate = self
.sessions
.get(&request.tab_id)
.and_then(|session| session.requested_url.as_deref())
.is_none_or(|current| current != requested_url.as_str());
if should_navigate {
self.host.navigate(NavigationRequest {
webview_id: webview_id.clone(),
tab_id,
url: requested_url.clone(),
})?;
if let Some(session) = self.sessions.get_mut(&request.tab_id) {
session.requested_url = Some(requested_url.as_str().to_string());
IOSurfaceImportResult::Failed(failure) if first_failure.is_none() => {
first_failure = Some(ServoLiveError::IOSurfaceImportFailed {
surface_id: failure.surface_id,
mach_port_name: failure.mach_port_name,
message: failure.message,
});
}
IOSurfaceImportResult::Failed(_) => {}
}
Ok(())
}
first_failure.map_or(Ok(()), Err)
}
fn apply_input(
&mut self,
request: &ServoLiveEnsureRequest,
webview_id: &WebViewId,
) -> Result<(), ServoLiveError> {
if request.scroll_delta_x != 0 || request.scroll_delta_y != 0 {
let point_x = request.scroll_point_x.ok_or(ServoLiveError::MissingScrollPoint)?;
let point_y = request.scroll_point_y.ok_or(ServoLiveError::MissingScrollPoint)?;
self.host.scroll(ScrollRequest {
webview_id: webview_id.clone(),
delta_x: request.scroll_delta_x,
delta_y: request.scroll_delta_y,
point_x,
point_y,
})?;
}
if let (Some(x), Some(y)) = (request.hover_x, request.hover_y) {
self.host.hover(MouseHoverRequest { webview_id: webview_id.clone(), x, y })?;
}
if let (Some(x), Some(y)) = (request.click_x, request.click_y) {
self.host.click(MouseClickRequest { webview_id: webview_id.clone(), x, y })?;
}
if let Some(text) = request.typed_text.as_ref() {
self.host.type_text(KeyboardTextRequest {
webview_id: webview_id.clone(),
text: text.clone(),
})?;
}
Ok(())
}
fn presented_frame_from_session(
&self,
tab_id: &str,
webview_id: &WebViewId,
) -> Result<ServoLiveFrame, ServoLiveError> {
let Some(session) = self.sessions.get(tab_id) else {
return Err(ServoLiveError::Host(ely_servo_host::ServoHostError::WebViewNotFound {
id: webview_id.clone(),
}));
};
if session.native_surface_id.is_some() {
let snapshot = self.host.snapshot_and_mark_metadata_observed(webview_id)?;
return Ok(ServoLiveFrame::from_presented(
snapshot,
session.width,
session.height,
session.device_pixel_ratio,
));
}
Err(ServoLiveError::NativeSurfaceUnavailable)
}
fn rendered_frame_from_session(
&self,
tab_id: &str,
webview_id: &WebViewId,
) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
let Some(session) = self.sessions.get(tab_id) else {
return Err(ServoLiveError::Host(ServoHostError::WebViewNotFound {
id: webview_id.clone(),
}));
};
let rendered_frame = match self.host.last_rendered_frame() {
Ok(frame) => frame,
Err(ServoHostError::RenderedFrameUnavailable) => return Ok(None),
Err(error) => return Err(ServoLiveError::Host(error)),
};
let snapshot = self.host.snapshot_and_mark_metadata_observed(webview_id)?;
Ok(Some(ServoLiveFrame::from_rendered(
snapshot,
rendered_frame,
session.device_pixel_ratio,
)))
}
fn session_uses_native_surface(&self, tab_id: &str) -> bool {
self.sessions.get(tab_id).is_some_and(|session| session.native_surface_id.is_some())
impl Drop for ServoLiveClient {
fn drop(&mut self) {
self.shutdown();
}
}
struct DirectWebViewSession {
webview_id: WebViewId,
profile_id: ProfileId,
requested_url: Option<String>,
width: u32,
height: u32,
page_zoom_percent: u16,
device_pixel_ratio: f32,
native_surface_id: Option<usize>,
}
/// Calls that `apply_viewport` needs to make to bring the underlying
/// Servo WebView state in line with the embedder's request. Extracted
/// as a pure value so the diff decision is testable without a live
/// `SoftwareServoHost` — see `tests` below.
#[derive(Debug, Default, PartialEq)]
struct ViewportChange {
resize: Option<(u32, u32)>,
set_hidpi: Option<f32>,
set_page_zoom: Option<u16>,
}
impl ViewportChange {
fn between(request: &ServoLiveEnsureRequest, session: &DirectWebViewSession) -> Self {
Self {
resize: (session.width != request.width || session.height != request.height)
.then_some((request.width, request.height)),
set_hidpi: (session.device_pixel_ratio != request.device_pixel_ratio)
.then_some(request.device_pixel_ratio),
set_page_zoom: (session.page_zoom_percent != request.page_zoom_percent)
.then_some(request.page_zoom_percent),
fn wait_for_exit(child: &mut Child, timeout: Duration) -> bool {
let started_at = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => return true,
Ok(None) => {}
Err(_) => return false,
}
if started_at.elapsed() >= timeout {
return false;
}
thread::sleep(Duration::from_millis(5));
}
}
#[cfg(test)]
fn terminate_child(child: &mut Child, timeout: Duration) {
if child.try_wait().ok().flatten().is_none() {
let _ = child.kill();
}
let _ = wait_for_exit(child, timeout);
}
#[cfg(all(test, unix))]
mod tests {
use std::{error::Error, process::Command};
use super::*;
use ely_domain::WebViewId;
fn session(width: u32, height: u32, dpr: f32, zoom: u16) -> DirectWebViewSession {
DirectWebViewSession {
webview_id: WebViewId::new(),
profile_id: ProfileId::new(),
requested_url: None,
width,
height,
page_zoom_percent: zoom,
device_pixel_ratio: dpr,
native_surface_id: Some(0xC0FFEE),
}
}
const TEST_TIMEOUTS: SidecarTimeouts = SidecarTimeouts {
request: Duration::from_millis(50),
shutdown: Duration::from_millis(50),
exit: Duration::from_millis(250),
};
fn request(width: u32, height: u32, dpr: f32, zoom: u16) -> ServoLiveEnsureRequest {
ServoLiveEnsureRequest {
tab_id: String::from("tab"),
profile_id: String::from("profile"),
url: String::from("https://example.com/"),
width,
height,
page_zoom_percent: zoom,
device_pixel_ratio: dpr,
native_surface: None,
scroll_delta_x: 0,
scroll_delta_y: 0,
scroll_point_x: None,
scroll_point_y: None,
click_x: None,
click_y: None,
hover_x: None,
hover_y: None,
typed_text: None,
site_permissions: Vec::new(),
}
#[test]
fn startup_rejects_a_sidecar_without_the_current_protocol() -> Result<(), Box<dyn Error>> {
let child = spawn_script(
"IFS= read -r request; printf '%s\\n' '{\"error\":\"unknown request\",\"frame\":null}'",
)?;
let result = ServoLiveClient::from_spawned_child(child, TEST_TIMEOUTS);
assert!(matches!(result, Err(ServoLiveError::ProtocolVersionMismatch { .. })));
Ok(())
}
#[test]
fn fresh_retina_session_pushes_hidpi_only() {
// A 1440×900 Retina viewport arrives as physical 2880×1800 with
// DPR=2.0. `ensure_webview` stamps the session with Servo's
// post-build defaults (1.0 DPR, 100 % zoom) and the request's
// physical dimensions, so only the hidpi push should fire.
let change = ViewportChange::between(
&request(2880, 1800, 2.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
&session(2880, 1800, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
);
assert_eq!(
change,
ViewportChange { resize: None, set_hidpi: Some(2.0), set_page_zoom: None },
);
fn request_timeout_kills_and_reaps_a_hung_sidecar() -> Result<(), Box<dyn Error>> {
let mut client = hung_sidecar_client()?;
let started_at = Instant::now();
let result = client.poll("tab-hung".to_string());
assert!(matches!(result, Err(ServoLiveError::RequestTimedOut { .. })));
assert!(started_at.elapsed() < Duration::from_secs(1));
assert!(client.child.try_wait()?.is_some());
Ok(())
}
#[test]
fn fresh_standard_dpi_session_pushes_nothing() {
// On a 1.0-DPR display the fresh session already matches the
// request — Servo's defaults are exactly what we asked for.
let change = ViewportChange::between(
&request(1280, 720, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
&session(1280, 720, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
);
assert_eq!(change, ViewportChange::default());
fn shutdown_timeout_kills_and_reaps_a_hung_sidecar() -> Result<(), Box<dyn Error>> {
let mut client = hung_sidecar_client()?;
let started_at = Instant::now();
client.shutdown();
assert!(started_at.elapsed() < Duration::from_secs(1));
assert!(client.child.try_wait()?.is_some());
Ok(())
}
#[test]
fn page_zoom_change_pushes_only_zoom() {
// User toggled zoom to 150 % on a session that's already at the
// right size and DPR — only `set_page_zoom` should fire.
let change = ViewportChange::between(
&request(2880, 1800, 2.0, 150),
&session(2880, 1800, 2.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
);
assert_eq!(
change,
ViewportChange { resize: None, set_hidpi: None, set_page_zoom: Some(150) },
fn hung_sidecar_client() -> Result<ServoLiveClient, ServoLiveError> {
let script = format!(
"IFS= read -r request; printf '%s\\n' '{{\"protocol_version\":{LIVE_PROTOCOL_VERSION},\"error\":null,\"frame\":null}}'; while IFS= read -r request; do :; done"
);
let child = spawn_script(&script).map_err(ServoLiveError::Command)?;
ServoLiveClient::from_spawned_child(child, TEST_TIMEOUTS)
}
#[test]
fn cross_monitor_dpr_change_pushes_only_hidpi() {
// A window dragged from a 2.0-DPR monitor to a 1.0-DPR one keeps
// the same physical surface (GPUI re-emits the viewport at the
// new scale) — only the DPR push should fire.
let change = ViewportChange::between(
&request(2880, 1800, 1.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
&session(2880, 1800, 2.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT),
);
assert_eq!(
change,
ViewportChange { resize: None, set_hidpi: Some(1.0), set_page_zoom: None },
);
fn spawn_script(script: &str) -> Result<Child, std::io::Error> {
Command::new("sh")
.arg("-c")
.arg(script)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
}
}
#[cfg(all(test, target_os = "macos"))]
#[path = "servo_live_iosurface_tests.rs"]
mod iosurface_tests;
@@ -0,0 +1,260 @@
#![cfg(target_os = "macos")]
use std::{
io,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread::{self, JoinHandle},
time::Duration,
};
use crate::services::{
iosurface_mach::IOSurfaceMachReceiver,
iosurface_metal::{ImportedPixelBuffer, import_pixel_buffer_from_mach_port},
};
use super::wire::LiveSurfaceHandle;
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(1);
const IMPORT_QUEUE_CAPACITY: usize = 16;
pub(super) struct IOSurfaceImportWorker {
request_tx: Option<mpsc::SyncSender<LiveSurfaceHandle>>,
result_rx: mpsc::Receiver<IOSurfaceImportResult>,
shutdown: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl IOSurfaceImportWorker {
pub(super) fn new(receiver: IOSurfaceMachReceiver) -> Result<Self, io::Error> {
let (request_tx, request_rx) = mpsc::sync_channel(IMPORT_QUEUE_CAPACITY);
let (result_tx, result_rx) = mpsc::channel();
let shutdown = Arc::new(AtomicBool::new(false));
let worker_shutdown = shutdown.clone();
let thread = thread::Builder::new()
.name("ely-iosurface-import".to_string())
.spawn(move || run_import_worker(receiver, request_rx, result_tx, worker_shutdown))?;
Ok(Self { request_tx: Some(request_tx), result_rx, shutdown, thread: Some(thread) })
}
pub(super) fn submit(&self, handle: LiveSurfaceHandle) -> Result<(), IOSurfaceImportFailure> {
let Some(request_tx) = self.request_tx.as_ref() else {
return Err(IOSurfaceImportFailure::worker_stopped(handle));
};
request_tx.send(handle).map_err(|error| IOSurfaceImportFailure::worker_stopped(error.0))
}
pub(super) fn drain(&self) -> Vec<IOSurfaceImportResult> {
let mut results = Vec::new();
while let Ok(result) = self.result_rx.try_recv() {
results.push(result);
}
results
}
}
impl Drop for IOSurfaceImportWorker {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Release);
self.request_tx.take();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
pub(super) enum IOSurfaceImportResult {
Imported(ImportedIOSurface),
Failed(IOSurfaceImportFailure),
}
pub(super) struct ImportedIOSurface {
pub(super) surface_id: u64,
pub(super) imported: ImportedPixelBuffer,
}
// SAFETY: `ImportedPixelBuffer` owns retained IOSurface and CVPixelBuffer
// handles plus a use-count guard. CoreFoundation retain/release and IOSurface
// use-count operations are thread-safe while this value crosses the channel.
#[expect(unsafe_code)]
unsafe impl Send for ImportedIOSurface {}
pub(super) struct IOSurfaceImportFailure {
pub(super) surface_id: u64,
pub(super) mach_port_name: u32,
pub(super) message: String,
}
impl IOSurfaceImportFailure {
fn worker_stopped(handle: LiveSurfaceHandle) -> Self {
Self::queue_rejected(handle, "IOSurface import worker stopped")
}
fn queue_rejected(handle: LiveSurfaceHandle, message: &str) -> Self {
Self {
surface_id: handle.surface_id,
mach_port_name: handle.mach_port_name,
message: message.to_string(),
}
}
}
fn run_import_worker(
mut receiver: IOSurfaceMachReceiver,
request_rx: mpsc::Receiver<LiveSurfaceHandle>,
result_tx: mpsc::Sender<IOSurfaceImportResult>,
shutdown: Arc<AtomicBool>,
) {
run_import_requests(request_rx, result_tx, shutdown, |handle| {
import_surface_handle(&mut receiver, handle)
});
}
fn run_import_requests(
request_rx: mpsc::Receiver<LiveSurfaceHandle>,
result_tx: mpsc::Sender<IOSurfaceImportResult>,
shutdown: Arc<AtomicBool>,
mut import: impl FnMut(LiveSurfaceHandle) -> IOSurfaceImportResult,
) {
while let Ok(handle) = request_rx.recv() {
if shutdown.load(Ordering::Acquire) {
return;
}
let result = import(handle);
if shutdown.load(Ordering::Acquire) || result_tx.send(result).is_err() {
return;
}
}
}
fn import_surface_handle(
receiver: &mut IOSurfaceMachReceiver,
handle: LiveSurfaceHandle,
) -> IOSurfaceImportResult {
let mach_port_name = match receiver.receive_port_for_surface(handle.surface_id, RECEIVE_TIMEOUT)
{
Ok(mach_port_name) => mach_port_name,
Err(error) => {
return IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
surface_id: handle.surface_id,
mach_port_name: handle.mach_port_name,
message: error.to_string(),
});
}
};
match import_pixel_buffer_from_mach_port(mach_port_name) {
Ok(imported)
if imported_surface_matches(
&handle,
imported.system_surface_id,
imported.backing.pixel_buffer().get_width(),
imported.backing.pixel_buffer().get_height(),
) =>
{
IOSurfaceImportResult::Imported(ImportedIOSurface {
surface_id: handle.surface_id,
imported,
})
}
Ok(imported) => IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
surface_id: handle.surface_id,
mach_port_name,
message: format!(
"IOSurface id/size {:#x} {}x{} did not match handle {:#x} {}x{}",
imported.system_surface_id,
imported.backing.pixel_buffer().get_width(),
imported.backing.pixel_buffer().get_height(),
handle.surface_id,
handle.width,
handle.height,
),
}),
Err(error) => IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
surface_id: handle.surface_id,
mach_port_name,
message: error.to_string(),
}),
}
}
fn imported_surface_matches(
handle: &LiveSurfaceHandle,
system_surface_id: u64,
width: usize,
height: usize,
) -> bool {
system_surface_id == handle.surface_id
&& width == handle.width as usize
&& height == handle.height as usize
}
#[cfg(test)]
mod tests {
use std::{cell::Cell, sync::mpsc};
use super::*;
#[test]
fn shutdown_stops_after_current_import() {
let (request_tx, request_rx) = mpsc::channel();
let (result_tx, result_rx) = mpsc::channel();
let shutdown = Arc::new(AtomicBool::new(false));
for surface_id in 1..=3 {
assert!(request_tx.send(handle(surface_id)).is_ok());
}
drop(request_tx);
let processed = Cell::new(0);
let import_shutdown = shutdown.clone();
run_import_requests(request_rx, result_tx, shutdown, |handle| {
processed.set(processed.get() + 1);
import_shutdown.store(true, Ordering::Release);
IOSurfaceImportResult::Failed(IOSurfaceImportFailure::worker_stopped(handle))
});
assert_eq!(processed.get(), 1);
assert!(result_rx.try_recv().is_err());
}
#[test]
fn imported_surface_requires_matching_system_id_and_dimensions() {
assert!(imported_surface_matches(&handle(7), 7, 64, 48));
assert!(!imported_surface_matches(&handle(8), 7, 64, 48));
let mut wrong_size = handle(7);
wrong_size.width = 96;
assert!(!imported_surface_matches(&wrong_size, 7, 64, 48));
}
#[test]
fn submit_backpressures_surface_burst_at_queue_capacity() {
let (request_tx, request_rx) = mpsc::sync_channel(IMPORT_QUEUE_CAPACITY);
let (_result_tx, result_rx) = mpsc::channel();
let worker = IOSurfaceImportWorker {
request_tx: Some(request_tx),
result_rx,
shutdown: Arc::new(AtomicBool::new(false)),
thread: None,
};
for surface_id in 0..IMPORT_QUEUE_CAPACITY as u64 {
assert!(worker.submit(handle(surface_id)).is_ok());
}
let (release_tx, release_rx) = mpsc::channel();
let consumer = std::thread::spawn(move || {
let received = request_rx.recv();
let _ = release_rx.recv();
received
});
assert!(worker.submit(handle(99)).is_ok());
assert!(release_tx.send(()).is_ok());
assert!(consumer.join().is_ok());
}
fn handle(surface_id: u64) -> LiveSurfaceHandle {
LiveSurfaceHandle { mach_port_name: surface_id as u32, surface_id, width: 64, height: 48 }
}
}
@@ -0,0 +1,30 @@
use std::collections::BTreeSet;
use super::{
ServoLiveError, apply_iosurface_import_results,
iosurface_importer::{IOSurfaceImportFailure, IOSurfaceImportResult},
};
use crate::services::iosurface_metal::IOSurfaceCache;
#[test]
fn import_failure_clears_every_completed_pending_surface() -> Result<(), String> {
let mut cache = IOSurfaceCache::new();
let mut pending = BTreeSet::from([1, 2, 3]);
let results = vec![failed_import(1), failed_import(2), failed_import(3)];
let Err(error) = apply_iosurface_import_results(&mut cache, &mut pending, results) else {
return Err("the failed import batch succeeded".to_string());
};
assert!(matches!(error, ServoLiveError::IOSurfaceImportFailed { surface_id: 1, .. }));
assert!(pending.is_empty());
Ok(())
}
fn failed_import(surface_id: u64) -> IOSurfaceImportResult {
IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
surface_id,
mach_port_name: surface_id as u32,
message: format!("surface {surface_id} failed"),
})
}
@@ -0,0 +1,313 @@
use std::{
io::{BufRead, BufReader, Read, Write},
process::{ChildStdin, ChildStdout},
sync::mpsc::{self, Receiver, Sender, SyncSender},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
use super::{
ServoLiveError, ServoLiveFrame,
wire::{
LiveRequest, LiveResponse, LiveSurfaceHandle, MAX_FRAME_BYTE_COUNT, MAX_FRAME_DIMENSION,
},
};
const MAX_RESPONSE_HEADER_BYTES: usize = 256 * 1024;
pub(super) struct ServoLiveIpc {
requests: Option<Sender<IpcRequest>>,
thread: Option<JoinHandle<()>>,
}
pub(super) struct IpcReply {
pub(super) protocol_version: Option<u32>,
pub(super) error: Option<String>,
pub(super) frame: Option<ServoLiveFrame>,
pub(super) surface_handle: Option<LiveSurfaceHandle>,
pub(super) current_surface_id: Option<u64>,
}
struct IpcRequest {
request: LiveRequest,
response: SyncSender<Result<IpcReply, ServoLiveError>>,
}
impl ServoLiveIpc {
pub(super) fn spawn(stdin: ChildStdin, stdout: ChildStdout) -> Self {
let (requests, receiver) = mpsc::channel();
let thread = thread::spawn(move || run_io(stdin, BufReader::new(stdout), receiver));
Self { requests: Some(requests), thread: Some(thread) }
}
pub(super) fn exchange(
&self,
request: LiveRequest,
timeout: Duration,
operation: &'static str,
) -> Result<IpcReply, ServoLiveError> {
let requests = self.requests.as_ref().ok_or(ServoLiveError::SidecarExited)?;
let (response, receiver) = mpsc::sync_channel(1);
requests
.send(IpcRequest { request, response })
.map_err(|_| ServoLiveError::SidecarExited)?;
receiver.recv_timeout(timeout).map_err(|error| match error {
mpsc::RecvTimeoutError::Timeout => {
ServoLiveError::RequestTimedOut { operation, timeout_millis: timeout.as_millis() }
}
mpsc::RecvTimeoutError::Disconnected => ServoLiveError::SidecarExited,
})?
}
pub(super) fn close_and_join(&mut self, timeout: Duration) {
self.requests.take();
let Some(thread) = self.thread.take() else {
return;
};
let started_at = Instant::now();
while !thread.is_finished() && started_at.elapsed() < timeout {
thread::sleep(Duration::from_millis(5));
}
if thread.is_finished() {
let _ = thread.join();
}
}
}
fn run_io(
mut stdin: ChildStdin,
mut stdout: BufReader<ChildStdout>,
requests: Receiver<IpcRequest>,
) {
while let Ok(message) = requests.recv() {
let should_shutdown = matches!(message.request, LiveRequest::Shutdown);
let response = exchange_pipes(&mut stdin, &mut stdout, &message.request);
let should_stop = should_shutdown || response.is_err();
let _ = message.response.send(response);
if should_stop {
break;
}
}
}
fn exchange_pipes(
stdin: &mut ChildStdin,
stdout: &mut BufReader<ChildStdout>,
request: &LiveRequest,
) -> Result<IpcReply, ServoLiveError> {
serde_json::to_writer(&mut *stdin, request)?;
stdin.write_all(b"\n").map_err(ServoLiveError::Command)?;
stdin.flush().map_err(ServoLiveError::Command)?;
read_reply(stdout)
}
fn read_reply(stdout: &mut impl BufRead) -> Result<IpcReply, ServoLiveError> {
let mut line = String::new();
let bytes = Read::by_ref(stdout)
.take((MAX_RESPONSE_HEADER_BYTES + 1) as u64)
.read_line(&mut line)
.map_err(ServoLiveError::Command)?;
if bytes == 0 {
return Err(ServoLiveError::SidecarExited);
}
if bytes > MAX_RESPONSE_HEADER_BYTES {
return Err(ServoLiveError::ResponseHeaderTooLarge { limit: MAX_RESPONSE_HEADER_BYTES });
}
if !line.ends_with('\n') {
return Err(ServoLiveError::InvalidResponse {
message: "response header is missing its newline delimiter",
});
}
let response: LiveResponse = serde_json::from_str(&line)?;
if response.error.is_some()
&& (response.frame.is_some()
|| response.surface_handle.is_some()
|| response.current_surface_id.is_some())
{
return Err(ServoLiveError::InvalidResponse {
message: "error response contains a frame or hardware surface",
});
}
if response.frame.is_none()
&& (response.surface_handle.is_some() || response.current_surface_id.is_some())
{
return Err(ServoLiveError::InvalidResponse {
message: "hardware surface response is missing its frame report",
});
}
if let Some(handle) = response.surface_handle.as_ref() {
if response.current_surface_id != Some(handle.surface_id) {
return Err(ServoLiveError::InvalidResponse {
message: "surface_handle does not match current_surface_id",
});
}
if response
.frame
.as_ref()
.is_none_or(|frame| frame.width != handle.width || frame.height != handle.height)
{
return Err(ServoLiveError::InvalidResponse {
message: "surface_handle dimensions do not match frame report",
});
}
}
let frame = response.frame.map(|report| {
let rgba_byte_count = validate_frame_layout(report.width, report.height)?;
let hardware_frame = report.rgba_byte_count == 0;
if hardware_frame && response.current_surface_id.is_none() {
return Err(ServoLiveError::InvalidResponse {
message: "hardware frame is missing current_surface_id",
});
}
if !hardware_frame
&& (response.surface_handle.is_some() || response.current_surface_id.is_some())
{
return Err(ServoLiveError::InvalidResponse {
message: "software frame contains hardware surface metadata",
});
}
if !hardware_frame && report.rgba_byte_count != rgba_byte_count {
return Err(ServoLiveError::InvalidFrameByteCount {
advertised: report.rgba_byte_count,
expected: rgba_byte_count as u64,
width: report.width,
height: report.height,
});
}
let rgba_bytes = if hardware_frame {
None
} else {
let mut bytes = Vec::new();
bytes.try_reserve_exact(rgba_byte_count).map_err(|source| {
ServoLiveError::FrameAllocation { bytes: rgba_byte_count, source }
})?;
bytes.resize(rgba_byte_count, 0);
stdout.read_exact(&mut bytes).map_err(ServoLiveError::FrameRead)?;
Some(bytes)
};
Ok(ServoLiveFrame::from_parts(report, rgba_bytes))
});
Ok(IpcReply {
protocol_version: response.protocol_version,
error: response.error,
frame: frame.transpose()?,
surface_handle: response.surface_handle,
current_surface_id: response.current_surface_id,
})
}
pub(super) fn validate_frame_layout(width: u32, height: u32) -> Result<usize, ServoLiveError> {
if width == 0 || height == 0 || width > MAX_FRAME_DIMENSION || height > MAX_FRAME_DIMENSION {
return Err(ServoLiveError::InvalidFrameDimensions {
width,
height,
max_dimension: MAX_FRAME_DIMENSION,
});
}
let bytes = u64::from(width)
.checked_mul(u64::from(height))
.and_then(|pixels| pixels.checked_mul(4))
.ok_or(ServoLiveError::InvalidFrameDimensions {
width,
height,
max_dimension: MAX_FRAME_DIMENSION,
})?;
if bytes > MAX_FRAME_BYTE_COUNT as u64 {
return Err(ServoLiveError::FrameByteLimitExceeded { bytes, limit: MAX_FRAME_BYTE_COUNT });
}
Ok(bytes as usize)
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use super::*;
#[test]
fn frame_layout_rejects_oversized_frames() {
assert!(matches!(
validate_frame_layout(MAX_FRAME_DIMENSION, MAX_FRAME_DIMENSION),
Err(ServoLiveError::FrameByteLimitExceeded { .. })
));
assert!(matches!(
validate_frame_layout(MAX_FRAME_DIMENSION + 1, 1),
Err(ServoLiveError::InvalidFrameDimensions { .. })
));
}
#[test]
fn reply_rejects_oversized_frame_before_readback_allocation() {
let header = format!(
"{{\"protocol_version\":2,\"error\":null,\"frame\":{{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",\"width\":{0},\"height\":{0},\"device_pixel_ratio\":1.0,\"css_viewport_width\":{0},\"css_viewport_height\":{0},\"rgba_byte_count\":1073741824,\"pixels_changed\":true}}}}\n",
MAX_FRAME_DIMENSION
);
let mut input = Cursor::new(header.into_bytes());
assert!(matches!(
read_reply(&mut input),
Err(ServoLiveError::FrameByteLimitExceeded { .. })
));
}
#[cfg(target_os = "macos")]
#[test]
fn hardware_reply_uses_surface_without_rgba_allocation() -> Result<(), ServoLiveError> {
let header = concat!(
"{\"protocol_version\":2,\"error\":null,",
"\"surface_handle\":{\"mach_port_name\":91,\"surface_id\":7,\"width\":64,\"height\":48},",
"\"current_surface_id\":7,",
"\"frame\":{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",",
"\"width\":64,\"height\":48,\"device_pixel_ratio\":1.0,",
"\"css_viewport_width\":64,\"css_viewport_height\":48,",
"\"rgba_byte_count\":0,\"pixels_changed\":true}}\n"
);
let mut input = Cursor::new(header.as_bytes());
let reply = read_reply(&mut input)?;
let Some(frame) = reply.frame else {
return Err(ServoLiveError::InvalidResponse { message: "test frame was missing" });
};
assert_eq!(reply.current_surface_id, Some(7));
assert_eq!(reply.surface_handle.map(|handle| handle.surface_id), Some(7));
assert!(frame.into_rgba_bytes().is_none());
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn hardware_reply_rejects_mismatched_surface_identity() {
let header = hardware_header(8, 64, 48);
let mut input = Cursor::new(header.into_bytes());
assert!(matches!(
read_reply(&mut input),
Err(ServoLiveError::InvalidResponse {
message: "surface_handle does not match current_surface_id"
})
));
}
#[cfg(target_os = "macos")]
#[test]
fn hardware_reply_rejects_mismatched_surface_dimensions() {
let header = hardware_header(7, 96, 72);
let mut input = Cursor::new(header.into_bytes());
assert!(matches!(
read_reply(&mut input),
Err(ServoLiveError::InvalidResponse {
message: "surface_handle dimensions do not match frame report"
})
));
}
#[cfg(target_os = "macos")]
fn hardware_header(current_surface_id: u64, handle_width: u32, handle_height: u32) -> String {
format!(
"{{\"protocol_version\":2,\"error\":null,\"surface_handle\":{{\"mach_port_name\":91,\"surface_id\":7,\"width\":{handle_width},\"height\":{handle_height}}},\"current_surface_id\":{current_surface_id},\"frame\":{{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",\"width\":64,\"height\":48,\"device_pixel_ratio\":1.0,\"css_viewport_width\":64,\"css_viewport_height\":48,\"rgba_byte_count\":0,\"pixels_changed\":true}}}}\n"
)
}
}
+246 -70
View File
@@ -1,9 +1,21 @@
#[cfg(target_os = "macos")]
use std::sync::Arc;
use std::{collections::TryReserveError, io, path::PathBuf};
use ely_domain::SitePermissionDecision;
use ely_servo_host::{RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState};
use gpui::NativeSurfaceHandle;
use serde::Serialize;
use thiserror::Error;
use super::wire::LiveFrameReport;
use crate::services::servo_sidecar_command::SidecarCommandError;
#[cfg(target_os = "macos")]
use crate::services::iosurface_mach::IOSurfaceMachError;
#[cfg(target_os = "macos")]
use crate::services::iosurface_metal::HardwareSurfaceBacking;
#[cfg(all(test, target_os = "macos"))]
use core_video::pixel_buffer::CVPixelBuffer;
pub(crate) struct ServoLiveEnsureRequest {
pub(crate) tab_id: String,
pub(crate) profile_id: String,
@@ -11,10 +23,8 @@ pub(crate) struct ServoLiveEnsureRequest {
pub(crate) width: u32,
pub(crate) height: u32,
pub(crate) page_zoom_percent: u16,
/// Display scale factor (1.0 standard, 2.0 Retina). Servo's
/// WebView lays out CSS pixels = device pixels / hidpi factor.
/// Display scale factor used to derive Servo's CSS viewport.
pub(crate) device_pixel_ratio: f32,
pub(crate) native_surface: Option<NativeSurfaceHandle>,
pub(crate) scroll_delta_x: i32,
pub(crate) scroll_delta_y: i32,
pub(crate) scroll_point_x: Option<u32>,
@@ -53,6 +63,7 @@ pub(crate) struct ServoLiveFrame {
device_pixel_ratio: f32,
css_viewport_width: u32,
css_viewport_height: u32,
pixels_changed: bool,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -60,62 +71,63 @@ pub(crate) struct ServoLiveFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: u64,
rgba_bytes: Option<Vec<u8>>,
#[cfg(target_os = "macos")]
hardware_surface: Option<Arc<HardwareSurfaceBacking>>,
#[cfg(target_os = "macos")]
hardware_surface_id: Option<u64>,
}
impl ServoLiveFrame {
pub(super) fn from_presented(
snapshot: WebViewSnapshot,
width: u32,
height: u32,
device_pixel_ratio: f32,
) -> Self {
let (css_viewport_width, css_viewport_height) =
css_viewport_size(width, height, device_pixel_ratio);
pub(super) fn from_parts(report: LiveFrameReport, rgba_bytes: Option<Vec<u8>>) -> Self {
let (css_viewport_width, css_viewport_height) = css_viewport_size_from_report(&report);
Self {
loaded_url: snapshot.url().map(str::to_string),
title: snapshot.title().map(str::to_string),
render_state: render_state_label(snapshot.state()).to_string(),
width,
height,
device_pixel_ratio,
loaded_url: report.loaded_url,
title: report.title,
render_state: report.state,
width: report.width,
height: report.height,
device_pixel_ratio: report.device_pixel_ratio,
css_viewport_width,
css_viewport_height,
pixels_changed: report.pixels_changed,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: 1,
non_white_pixel_count: report.non_white_pixel_count,
#[cfg(all(test, feature = "live-site-smoke"))]
content_pixel_count: 1,
content_pixel_count: report.content_pixel_count,
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: 0,
rgba_bytes: None,
sample_hash: report.sample_hash,
rgba_bytes,
#[cfg(target_os = "macos")]
hardware_surface: None,
#[cfg(target_os = "macos")]
hardware_surface_id: None,
}
}
pub(super) fn from_rendered(
snapshot: WebViewSnapshot,
rendered_frame: RenderedFrame,
device_pixel_ratio: f32,
) -> Self {
let width = rendered_frame.width();
let height = rendered_frame.height();
let (css_viewport_width, css_viewport_height) =
css_viewport_size(width, height, device_pixel_ratio);
Self {
loaded_url: snapshot.url().map(str::to_string),
title: snapshot.title().map(str::to_string),
render_state: render_state_label(snapshot.state()).to_string(),
width,
height,
device_pixel_ratio,
css_viewport_width,
css_viewport_height,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: rendered_frame.non_white_pixel_count(),
#[cfg(all(test, feature = "live-site-smoke"))]
content_pixel_count: rendered_frame.content_pixel_count(),
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: rendered_frame.sample_hash(),
rgba_bytes: Some(rendered_frame.rgba_bytes().to_vec()),
}
#[cfg(target_os = "macos")]
pub(super) fn set_hardware_surface(
&mut self,
surface_id: u64,
surface: Option<Arc<HardwareSurfaceBacking>>,
) {
self.hardware_surface_id = surface.as_ref().map(|_| surface_id);
self.hardware_surface = surface;
}
#[cfg(target_os = "macos")]
#[must_use]
pub fn hardware_surface_id(&self) -> Option<u64> {
self.hardware_surface_id
}
#[cfg(target_os = "macos")]
#[must_use]
pub(crate) fn hardware_surface(&self) -> Option<&Arc<HardwareSurfaceBacking>> {
self.hardware_surface.as_ref()
}
pub(super) fn has_software_payload(&self) -> bool {
self.rgba_bytes.is_some()
}
#[must_use]
@@ -158,6 +170,11 @@ impl ServoLiveFrame {
self.css_viewport_height
}
#[must_use]
pub fn pixels_changed(&self) -> bool {
self.pixels_changed
}
#[cfg(all(test, feature = "live-site-smoke"))]
#[must_use]
pub fn non_white_pixel_count(&self) -> u64 {
@@ -183,18 +200,29 @@ impl ServoLiveFrame {
#[cfg(test)]
pub(crate) fn for_test(width: u32, height: u32, rgba_bytes: Vec<u8>) -> Self {
Self::for_test_with_render_state(width, height, rgba_bytes, "complete")
}
#[cfg(test)]
pub(crate) fn for_test_with_render_state(
width: u32,
height: u32,
rgba_bytes: Vec<u8>,
render_state: &str,
) -> Self {
#[cfg(all(test, feature = "live-site-smoke"))]
let summary =
ely_servo_host::RenderedFrameSummary::from_rgba_bytes(width, height, &rgba_bytes);
Self {
loaded_url: Some("https://example.com/".to_string()),
title: Some("Example".to_string()),
render_state: "complete".to_string(),
render_state: render_state.to_string(),
width,
height,
device_pixel_ratio: 1.0,
css_viewport_width: width,
css_viewport_height: height,
pixels_changed: true,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: summary.non_white_pixel_count(),
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -202,49 +230,197 @@ impl ServoLiveFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: summary.sample_hash(),
rgba_bytes: Some(rgba_bytes),
#[cfg(target_os = "macos")]
hardware_surface: None,
#[cfg(target_os = "macos")]
hardware_surface_id: None,
}
}
}
fn render_state_label(state: &WebViewState) -> &'static str {
match state {
WebViewState::Created => "created",
WebViewState::Loading => "loading",
WebViewState::Complete => "complete",
WebViewState::Sleeping => "sleeping",
WebViewState::Crashed => "crashed",
#[cfg(test)]
pub(crate) fn for_test_with_title(
width: u32,
height: u32,
rgba_bytes: Vec<u8>,
render_state: &str,
title: &str,
) -> Self {
let mut frame = Self::for_test_with_render_state(width, height, rgba_bytes, render_state);
frame.title = Some(title.to_string());
frame
}
#[cfg(all(test, target_os = "macos"))]
pub(crate) fn for_test_with_pixel_buffer(
width: u32,
height: u32,
surface_id: u64,
pixel_buffer: CVPixelBuffer,
) -> Self {
Self::for_test_with_hardware_change(width, height, surface_id, pixel_buffer, true)
}
#[cfg(all(test, target_os = "macos"))]
pub(crate) fn for_test_with_hardware_change(
width: u32,
height: u32,
surface_id: u64,
pixel_buffer: CVPixelBuffer,
pixels_changed: bool,
) -> Self {
let mut frame = Self::for_test(width, height, Vec::new());
frame.rgba_bytes = None;
frame.hardware_surface = Some(HardwareSurfaceBacking::new(pixel_buffer));
frame.hardware_surface_id = Some(surface_id);
frame.pixels_changed = pixels_changed;
frame
}
}
fn css_viewport_size(width: u32, height: u32, device_pixel_ratio: f32) -> (u32, u32) {
let scale = if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 {
device_pixel_ratio
fn css_viewport_size_from_report(report: &LiveFrameReport) -> (u32, u32) {
let scale = if report.device_pixel_ratio.is_finite() && report.device_pixel_ratio > 0.0 {
report.device_pixel_ratio
} else {
1.0
};
let fallback_width = ((report.width as f32) / scale).round().max(1.0) as u32;
let fallback_height = ((report.height as f32) / scale).round().max(1.0) as u32;
(
((width as f32) / scale).round().max(1.0) as u32,
((height as f32) / scale).round().max(1.0) as u32,
if report.css_viewport_width > 0 { report.css_viewport_width } else { fallback_width },
if report.css_viewport_height > 0 { report.css_viewport_height } else { fallback_height },
)
}
#[derive(Debug, Error)]
pub(crate) enum ServoLiveError {
#[error("servo native surface is unavailable")]
NativeSurfaceUnavailable,
#[error("servo sidecar binary is unavailable at {path}")]
SidecarBinaryUnavailable { path: PathBuf },
#[error("servo scroll input is missing a viewport point")]
MissingScrollPoint,
#[error("failed to run servo live sidecar: {0}")]
Command(#[source] io::Error),
#[error("servo live sidecar pipe is unavailable: {name}")]
PipeUnavailable { name: &'static str },
#[error("servo live sidecar exited")]
SidecarExited,
#[error("servo live sidecar protocol mismatch: expected {expected}, received {actual:?}")]
ProtocolVersionMismatch { expected: u32, actual: Option<u32> },
#[error("servo live sidecar {operation} timed out after {timeout_millis} ms")]
RequestTimedOut { operation: &'static str, timeout_millis: u128 },
#[error("servo live sidecar failed: {message}")]
SidecarFailed { message: String },
#[error("servo live sidecar response header exceeded {limit} bytes")]
ResponseHeaderTooLarge { limit: usize },
#[error("failed to read servo live frame bytes: {0}")]
FrameRead(#[source] io::Error),
#[error(
"servo live frame dimensions {width}x{height} exceed the {max_dimension}px dimension limit"
)]
InvalidFrameDimensions { width: u32, height: u32, max_dimension: u32 },
#[error("servo live frame requires {bytes} bytes; the limit is {limit}")]
FrameByteLimitExceeded { bytes: u64, limit: usize },
#[error(
"servo live sidecar advertised {advertised} frame bytes for {width}x{height}; expected {expected}"
)]
InvalidFrameByteCount { advertised: usize, expected: u64, width: u32, height: u32 },
#[error("failed to reserve {bytes} bytes for a servo live frame: {source}")]
FrameAllocation {
bytes: usize,
#[source]
source: TryReserveError,
},
#[error("invalid servo live sidecar response: {message}")]
InvalidResponse { message: &'static str },
#[cfg(target_os = "macos")]
#[error(
"servo live IOSurface import failed for surface {surface_id:#x} mach port 0x{mach_port_name:x}: {message}"
)]
IOSurfaceImportFailed { surface_id: u64, mach_port_name: u32, message: String },
#[cfg(target_os = "macos")]
#[error("servo live IOSurface backing failed for surface {surface_id:#x}: {message}")]
IOSurfaceBackingFailed { surface_id: u64, message: String },
#[cfg(target_os = "macos")]
#[error("failed to spawn servo live IOSurface importer: {0}")]
IOSurfaceImportWorker(#[source] io::Error),
#[cfg(target_os = "macos")]
#[error(transparent)]
IOSurfaceMach(#[from] IOSurfaceMachError),
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
Json(#[from] serde_json::Error),
#[error(transparent)]
Host(#[from] ServoHostError),
SidecarCommand(#[from] SidecarCommandError),
}
impl ServoLiveError {
pub(crate) fn is_runtime_unavailable(&self) -> bool {
matches!(self, Self::Host(ServoHostError::RuntimeAlreadyStarted))
match self {
Self::SidecarExited
| Self::ProtocolVersionMismatch { .. }
| Self::RequestTimedOut { .. }
| Self::ResponseHeaderTooLarge { .. }
| Self::InvalidFrameDimensions { .. }
| Self::FrameByteLimitExceeded { .. }
| Self::InvalidFrameByteCount { .. }
| Self::FrameAllocation { .. }
| Self::InvalidResponse { .. }
| Self::Json(_) => true,
#[cfg(target_os = "macos")]
Self::IOSurfaceImportFailed { .. }
| Self::IOSurfaceBackingFailed { .. }
| Self::IOSurfaceImportWorker(_)
| Self::IOSurfaceMach(_) => true,
Self::Command(error) | Self::FrameRead(error) => matches!(
error.kind(),
io::ErrorKind::BrokenPipe
| io::ErrorKind::ConnectionAborted
| io::ErrorKind::ConnectionReset
| io::ErrorKind::UnexpectedEof
),
Self::SidecarBinaryUnavailable { .. }
| Self::PipeUnavailable { .. }
| Self::SidecarFailed { .. }
| Self::SidecarCommand(_) => false,
}
}
}
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
#[test]
fn iosurface_transport_failures_invalidate_runtime() {
let errors = [
ServoLiveError::IOSurfaceImportFailed {
surface_id: 7,
mach_port_name: 11,
message: "injected".to_string(),
},
ServoLiveError::IOSurfaceImportWorker(io::Error::other("injected")),
ServoLiveError::IOSurfaceMach(IOSurfaceMachError::InvalidMessage),
ServoLiveError::IOSurfaceBackingFailed {
surface_id: 7,
message: "injected".to_string(),
},
];
assert!(errors.iter().all(ServoLiveError::is_runtime_unavailable));
}
}
@@ -0,0 +1,141 @@
use serde::{Deserialize, Serialize};
use super::ServoLiveSitePermission;
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 2;
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(super) enum LiveRequest {
Handshake {
protocol_version: u32,
},
Ensure {
tab_id: String,
profile_id: String,
url: String,
width: u32,
height: u32,
page_zoom_percent: u16,
device_pixel_ratio: f32,
scroll_delta_x: i32,
scroll_delta_y: i32,
scroll_point_x: Option<u32>,
scroll_point_y: Option<u32>,
click_x: Option<u32>,
click_y: Option<u32>,
hover_x: Option<u32>,
hover_y: Option<u32>,
typed_text: Option<String>,
site_permissions: Vec<ServoLiveSitePermission>,
ready_surface_ids: Vec<u64>,
pending_surface_ids: Vec<u64>,
},
Poll {
tab_id: String,
ready_surface_ids: Vec<u64>,
pending_surface_ids: Vec<u64>,
},
Close {
tab_id: String,
},
Shutdown,
}
#[derive(Deserialize)]
pub(super) struct LiveResponse {
#[serde(default)]
pub(super) protocol_version: Option<u32>,
pub(super) error: Option<String>,
pub(super) frame: Option<LiveFrameReport>,
#[serde(default)]
pub(super) surface_handle: Option<LiveSurfaceHandle>,
#[serde(default)]
pub(super) current_surface_id: Option<u64>,
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub(super) struct LiveSurfaceHandle {
pub(super) mach_port_name: u32,
pub(super) surface_id: u64,
pub(super) width: u32,
pub(super) height: u32,
}
#[derive(Deserialize)]
pub(super) struct LiveFrameReport {
pub(super) loaded_url: Option<String>,
pub(super) title: Option<String>,
pub(super) state: String,
pub(super) width: u32,
pub(super) height: u32,
#[serde(default = "default_device_pixel_ratio")]
pub(super) device_pixel_ratio: f32,
#[serde(default)]
pub(super) css_viewport_width: u32,
#[serde(default)]
pub(super) css_viewport_height: u32,
pub(super) rgba_byte_count: usize,
pub(super) pixels_changed: bool,
#[cfg(all(test, feature = "live-site-smoke"))]
#[serde(default)]
pub(super) non_white_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
#[serde(default)]
pub(super) content_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
#[serde(default)]
pub(super) sample_hash: u64,
}
fn default_device_pixel_ratio() -> f32 {
1.0
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{LIVE_PROTOCOL_VERSION, LiveRequest};
#[test]
fn handshake_request_serializes_protocol_version() -> Result<(), serde_json::Error> {
let value = serde_json::to_value(LiveRequest::Handshake {
protocol_version: LIVE_PROTOCOL_VERSION,
})?;
assert_eq!(value, json!({"type": "handshake", "protocol_version": LIVE_PROTOCOL_VERSION}));
Ok(())
}
#[test]
fn close_request_serializes_to_wire() -> Result<(), serde_json::Error> {
let value =
serde_json::to_value(LiveRequest::Close { tab_id: "tab-live-close".to_string() })?;
assert_eq!(value, json!({"type": "close", "tab_id": "tab-live-close"}));
Ok(())
}
#[test]
fn poll_serializes_hardware_surface_states() -> Result<(), serde_json::Error> {
let value = serde_json::to_value(LiveRequest::Poll {
tab_id: "tab-live".to_string(),
ready_surface_ids: vec![7, 11],
pending_surface_ids: vec![13],
})?;
assert_eq!(
value,
json!({
"type": "poll",
"tab_id": "tab-live",
"ready_surface_ids": [7, 11],
"pending_surface_ids": [13]
})
);
Ok(())
}
}
+339 -27
View File
@@ -1,16 +1,66 @@
use std::{
env,
fs::{File, OpenOptions},
io,
path::{Path, PathBuf},
time::{SystemTime, SystemTimeError, UNIX_EPOCH},
};
use directories::ProjectDirs;
use ely_domain::{ProfileId, ProfileKind};
use ely_domain::ProfileId;
const ELY_QUALIFIER: &str = "com";
const ELY_ORGANIZATION: &str = "elydora";
const ELY_APPLICATION: &str = "ELY Browser";
const DEFAULT_STANDARD_PROFILE_DIR: &str = "default";
const TRANSIENT_PROFILE_ROOT: &str = "ely-browser-servo-profiles";
const TRANSIENT_PROFILE_PREFIX: &str = "profile-";
const TRANSIENT_PROFILE_LEASE: &str = ".lease";
const TRANSIENT_ROOT_LOCK: &str = ".lock";
pub(crate) struct TransientProfileDataDir {
directory: Option<tempfile::TempDir>,
lease: Option<File>,
root: PathBuf,
path: PathBuf,
}
impl TransientProfileDataDir {
pub(crate) fn path(&self) -> &Path {
&self.path
}
pub(crate) fn close(mut self) -> Result<(), io::Error> {
self.remove()
}
fn remove(&mut self) -> Result<(), io::Error> {
let Some(directory) = self.directory.take() else {
return Ok(());
};
let path = directory.keep();
let root_lock = match open_lock_file(&self.root.join(TRANSIENT_ROOT_LOCK)) {
Ok(root_lock) => root_lock,
Err(error) => {
self.lease.take();
return Err(error);
}
};
if let Err(error) = lock_file(&root_lock) {
self.lease.take();
return Err(error);
}
self.lease.take();
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
}
impl Drop for TransientProfileDataDir {
fn drop(&mut self) {
let _ = self.remove();
}
}
pub(crate) fn default_profile_data_root() -> Option<PathBuf> {
ProjectDirs::from(ELY_QUALIFIER, ELY_ORGANIZATION, ELY_APPLICATION)
@@ -21,27 +71,147 @@ pub(crate) fn profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId)
profile_data_root.join(profile_id.as_str()).join("servo")
}
pub(crate) fn sync_profile_data_dir(
pub(crate) fn sync_profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId) -> PathBuf {
profile_data_dir(profile_data_root, profile_id)
}
pub(crate) fn create_profile_data_dir(
profile_data_root: &Path,
profile_id: &ProfileId,
profile_name: &str,
profile_kind: &ProfileKind,
) -> PathBuf {
if profile_name == "Default" && matches!(profile_kind, ProfileKind::Standard) {
return profile_data_root.join(DEFAULT_STANDARD_PROFILE_DIR).join("servo");
) -> Result<PathBuf, io::Error> {
let profile_dir = profile_data_root.join(profile_id.as_str());
let servo_dir = profile_dir.join("servo");
std::fs::create_dir_all(&servo_dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&profile_dir, std::fs::Permissions::from_mode(0o700))?;
std::fs::set_permissions(&servo_dir, std::fs::Permissions::from_mode(0o700))?;
}
profile_data_dir(profile_data_root, profile_id)
Ok(servo_dir)
}
pub(crate) fn cleanup_stale_transient_profile_data_dirs() -> Result<(), io::Error> {
cleanup_stale_transient_profile_data_dirs_at(&transient_profile_data_root()?)
}
pub(crate) fn transient_profile_data_dir(
profile_id: &ProfileId,
) -> Result<PathBuf, SystemTimeError> {
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
Ok(env::temp_dir().join("ely-browser-servo-profiles").join(format!(
"{}-{}-{timestamp}",
std::process::id(),
profile_id.as_str()
)))
) -> Result<TransientProfileDataDir, io::Error> {
let root = transient_profile_data_root()?;
create_private_directory(&root)?;
let root_lock = open_lock_file(&root.join(TRANSIENT_ROOT_LOCK))?;
lock_file(&root_lock)?;
cleanup_stale_transient_profile_data_dirs_locked(&root, |path| std::fs::remove_dir_all(path))?;
let directory = tempfile::Builder::new()
.prefix(&format!("{TRANSIENT_PROFILE_PREFIX}{}-", profile_id.as_str()))
.tempdir_in(&root)?;
create_private_directory(directory.path())?;
let lease = open_lock_file(&directory.path().join(TRANSIENT_PROFILE_LEASE))?;
lock_file(&lease)?;
let path = directory.path().to_path_buf();
drop(root_lock);
Ok(TransientProfileDataDir { directory: Some(directory), lease: Some(lease), root, path })
}
fn transient_profile_data_root() -> Result<PathBuf, io::Error> {
let project_dirs = ProjectDirs::from(ELY_QUALIFIER, ELY_ORGANIZATION, ELY_APPLICATION)
.ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "ELY project directory unavailable")
})?;
let base = project_dirs.runtime_dir().unwrap_or_else(|| project_dirs.cache_dir());
Ok(base.join(TRANSIENT_PROFILE_ROOT))
}
fn cleanup_stale_transient_profile_data_dirs_at(root: &Path) -> Result<(), io::Error> {
create_private_directory(root)?;
let root_lock = open_lock_file(&root.join(TRANSIENT_ROOT_LOCK))?;
lock_file(&root_lock)?;
cleanup_stale_transient_profile_data_dirs_locked(root, |path| std::fs::remove_dir_all(path))
}
fn cleanup_stale_transient_profile_data_dirs_locked(
root: &Path,
remove_dir: impl Fn(&Path) -> Result<(), io::Error>,
) -> Result<(), io::Error> {
for entry in std::fs::read_dir(root)? {
let entry = entry?;
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
Err(error) => return Err(error),
};
if !file_type.is_dir()
|| !entry.file_name().to_string_lossy().starts_with(TRANSIENT_PROFILE_PREFIX)
{
continue;
}
let lease = match open_lock_file(&entry.path().join(TRANSIENT_PROFILE_LEASE)) {
Ok(lease) => lease,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
remove_directory_if_present(&remove_dir, &entry.path())?;
continue;
}
Err(error) => return Err(error),
};
if try_lock_file(&lease)? {
remove_directory_if_present(&remove_dir, &entry.path())?;
}
}
Ok(())
}
fn remove_directory_if_present(
remove_dir: &impl Fn(&Path) -> Result<(), io::Error>,
path: &Path,
) -> Result<(), io::Error> {
match remove_dir(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
fn open_lock_file(path: &Path) -> Result<File, io::Error> {
OpenOptions::new().create(true).read(true).write(true).truncate(false).open(path)
}
fn lock_file(file: &File) -> Result<(), io::Error> {
file.lock()
}
fn try_lock_file(file: &File) -> Result<bool, io::Error> {
match file.try_lock() {
Ok(()) => Ok(true),
Err(std::fs::TryLockError::WouldBlock) => Ok(false),
Err(std::fs::TryLockError::Error(error)) => Err(error),
}
}
fn create_private_directory(path: &Path) -> Result<(), io::Error> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
match std::fs::create_dir(path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
let metadata = std::fs::symlink_metadata(path)?;
if !metadata.file_type().is_dir() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("private directory path is not a directory: {}", path.display()),
));
}
}
Err(error) => return Err(error),
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
@@ -52,19 +222,46 @@ pub(crate) enum ProfileDataMode {
#[cfg(test)]
mod tests {
use super::{profile_data_dir, sync_profile_data_dir};
use ely_domain::{ProfileId, ProfileKind};
use std::{cell::Cell, io};
use super::{
TRANSIENT_PROFILE_LEASE, TRANSIENT_PROFILE_PREFIX,
cleanup_stale_transient_profile_data_dirs_at,
cleanup_stale_transient_profile_data_dirs_locked, create_profile_data_dir, open_lock_file,
profile_data_dir, sync_profile_data_dir, transient_profile_data_dir,
transient_profile_data_root,
};
use ely_domain::ProfileId;
#[test]
fn default_standard_sync_profile_dir_is_stable() {
fn default_standard_sync_profile_dir_keeps_profile_identity() {
let root = std::path::Path::new("/profiles");
let first_id = ProfileId::new();
let second_id = ProfileId::new();
assert_eq!(
sync_profile_data_dir(root, &first_id, "Default", &ProfileKind::Standard),
sync_profile_data_dir(root, &second_id, "Default", &ProfileKind::Standard)
);
assert_eq!(sync_profile_data_dir(root, &first_id), profile_data_dir(root, &first_id));
assert_eq!(sync_profile_data_dir(root, &second_id), profile_data_dir(root, &second_id));
assert_ne!(profile_data_dir(root, &first_id), profile_data_dir(root, &second_id));
}
#[test]
fn persistent_profile_directory_is_private() -> Result<(), std::io::Error> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let servo_dir = create_profile_data_dir(directory.path(), &profile_id)?;
assert_eq!(servo_dir, profile_data_dir(directory.path(), &profile_id));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(directory.path().join(profile_id.as_str()))?.permissions().mode()
& 0o777,
0o700
);
assert_eq!(std::fs::metadata(servo_dir)?.permissions().mode() & 0o777, 0o700);
}
Ok(())
}
#[test]
@@ -72,9 +269,124 @@ mod tests {
let root = std::path::Path::new("/profiles");
let profile_id = ProfileId::new();
assert_eq!(sync_profile_data_dir(root, &profile_id), profile_data_dir(root, &profile_id));
}
#[test]
fn transient_profile_directory_is_private_and_removed_on_close() -> Result<(), io::Error> {
let directory = transient_profile_data_dir(&ProfileId::new())?;
let path = directory.path().to_path_buf();
assert!(path.is_dir());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&path)?.permissions().mode() & 0o777, 0o700);
}
directory.close()?;
assert!(!path.exists());
Ok(())
}
#[test]
fn transient_profile_root_uses_the_user_project_directory() -> Result<(), io::Error> {
let project_dirs = directories::ProjectDirs::from(
super::ELY_QUALIFIER,
super::ELY_ORGANIZATION,
super::ELY_APPLICATION,
)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "project directory unavailable"))?;
let expected_base = project_dirs.runtime_dir().unwrap_or_else(|| project_dirs.cache_dir());
assert_eq!(
sync_profile_data_dir(root, &profile_id, "Personal", &ProfileKind::Standard),
profile_data_dir(root, &profile_id)
transient_profile_data_root()?,
expected_base.join(super::TRANSIENT_PROFILE_ROOT)
);
Ok(())
}
#[test]
fn startup_cleanup_preserves_leased_profile_and_removes_stale_profile() -> Result<(), io::Error>
{
let root = tempfile::tempdir()?;
let active = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}active"));
let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale"));
std::fs::create_dir_all(&active)?;
std::fs::create_dir_all(&stale)?;
let active_lease = open_lock_file(&active.join(TRANSIENT_PROFILE_LEASE))?;
super::lock_file(&active_lease)?;
open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?;
cleanup_stale_transient_profile_data_dirs_at(root.path())?;
assert!(active.is_dir());
assert!(!stale.exists());
Ok(())
}
#[test]
fn cleanup_failure_keeps_stale_profile_for_retry() -> Result<(), io::Error> {
let root = tempfile::tempdir()?;
let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale"));
std::fs::create_dir_all(&stale)?;
open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?;
let attempts = Cell::new(0_u8);
let result = cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| {
attempts.set(attempts.get() + 1);
if attempts.get() == 1 {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected failure"));
}
std::fs::remove_dir_all(path)
});
let Err(error) = result else {
return Err(io::Error::other("the first cleanup attempt succeeded"));
};
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
assert!(stale.is_dir());
cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| {
std::fs::remove_dir_all(path)
})?;
assert!(!stale.exists());
Ok(())
}
#[test]
fn cleanup_accepts_a_profile_removed_concurrently() -> Result<(), io::Error> {
let root = tempfile::tempdir()?;
let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale"));
std::fs::create_dir(&stale)?;
open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?;
cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| {
std::fs::remove_dir_all(path)?;
Err(io::Error::new(io::ErrorKind::NotFound, "removed by cleanup worker"))
})?;
assert!(!stale.exists());
Ok(())
}
#[cfg(unix)]
#[test]
fn cleanup_rejects_a_symlinked_transient_root() -> Result<(), io::Error> {
use std::os::unix::fs::symlink;
let parent = tempfile::tempdir()?;
let victim = tempfile::tempdir()?;
let stale = victim.path().join(format!("{TRANSIENT_PROFILE_PREFIX}victim"));
std::fs::create_dir(&stale)?;
let root = parent.path().join("transient-root");
symlink(victim.path(), &root)?;
let Err(error) = cleanup_stale_transient_profile_data_dirs_at(&root) else {
return Err(io::Error::other("symlinked transient root was accepted"));
};
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
assert!(stale.is_dir());
Ok(())
}
}
@@ -0,0 +1,147 @@
use std::{
env, io,
path::{Path, PathBuf},
process::Command,
};
use thiserror::Error;
const SIDECAR_PATH_ENV: &str = "ELY_SERVO_SIDECAR";
const RENDERING_CONTEXT_ENV: &str = "ELY_SERVO_RENDERING_CONTEXT";
#[derive(Clone, Debug)]
pub(super) struct SidecarCommandTarget(PathBuf);
impl SidecarCommandTarget {
pub(super) fn command(&self) -> Command {
Command::new(&self.0)
}
pub(super) fn missing_binary_path(&self) -> Option<&Path> {
(!self.0.is_file()).then_some(self.0.as_path())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum SidecarRenderingContext {
Software,
Hardware,
}
impl SidecarRenderingContext {
pub(super) fn cli_arg(self) -> &'static str {
match self {
Self::Software => "software",
Self::Hardware => "hardware",
}
}
}
#[derive(Debug, Error)]
pub(crate) enum SidecarCommandError {
#[error("current executable path is unavailable: {0}")]
CurrentExecutable(#[source] io::Error),
#[error("current executable directory is unavailable for {path}")]
CurrentExecutableDirectoryUnavailable { path: PathBuf },
}
pub(super) fn default_sidecar_command() -> Result<SidecarCommandTarget, SidecarCommandError> {
if let Some(path) = env::var_os(SIDECAR_PATH_ENV) {
return Ok(SidecarCommandTarget(PathBuf::from(path)));
}
let current_exe = env::current_exe().map_err(SidecarCommandError::CurrentExecutable)?;
let exe_dir = current_exe.parent().ok_or_else(|| {
SidecarCommandError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() }
})?;
let adjacent_sidecar = exe_dir.join(sidecar_binary_name());
let workspace_manifest = workspace_manifest_path();
if adjacent_sidecar.is_file() || is_macos_app_bundle_exe_dir(exe_dir) {
return Ok(SidecarCommandTarget(adjacent_sidecar));
}
if let Some(target_sidecar) =
workspace_manifest.as_deref().and_then(workspace_target_sidecar_path)
{
return Ok(SidecarCommandTarget(target_sidecar));
}
Ok(SidecarCommandTarget(adjacent_sidecar))
}
pub(super) fn rendering_context_from_env() -> SidecarRenderingContext {
let raw = env::var(RENDERING_CONTEXT_ENV).ok();
rendering_context_selection(raw.as_deref())
}
fn rendering_context_selection(raw: Option<&str>) -> SidecarRenderingContext {
match raw.map(str::to_lowercase).as_deref() {
Some("software") => SidecarRenderingContext::Software,
Some("hardware") => SidecarRenderingContext::Hardware,
_ if cfg!(target_os = "macos") => SidecarRenderingContext::Hardware,
_ => SidecarRenderingContext::Software,
}
}
fn workspace_manifest_path() -> Option<PathBuf> {
option_env!("ELY_WORKSPACE_MANIFEST").filter(|path| !path.is_empty()).map(PathBuf::from)
}
fn workspace_target_sidecar_path(manifest_path: &Path) -> Option<PathBuf> {
let profile = if cfg!(debug_assertions) { "debug" } else { "release" };
Some(manifest_path.parent()?.join("target").join(profile).join(sidecar_binary_name()))
}
fn sidecar_binary_name() -> String {
format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX)
}
fn is_macos_app_bundle_exe_dir(path: &Path) -> bool {
path.file_name().is_some_and(|name| name == "MacOS")
&& path
.parent()
.is_some_and(|contents| contents.file_name().is_some_and(|name| name == "Contents"))
&& path
.parent()
.and_then(Path::parent)
.is_some_and(|bundle| bundle.extension().is_some_and(|extension| extension == "app"))
}
#[cfg(test)]
mod tests {
use super::{
SidecarRenderingContext, is_macos_app_bundle_exe_dir, rendering_context_selection,
sidecar_binary_name, workspace_target_sidecar_path,
};
#[test]
fn workspace_sidecar_path_uses_current_build_profile() {
let manifest = std::path::Path::new("/workspace/Cargo.toml");
let profile = if cfg!(debug_assertions) { "debug" } else { "release" };
assert_eq!(
workspace_target_sidecar_path(manifest),
Some(
std::path::Path::new("/workspace/target").join(profile).join(sidecar_binary_name())
)
);
}
#[test]
fn rendering_context_follows_explicit_environment_selection() {
let hardware = rendering_context_selection(Some("HARDWARE"));
let software = rendering_context_selection(Some("software"));
assert_eq!(hardware, SidecarRenderingContext::Hardware);
assert_eq!(software, SidecarRenderingContext::Software);
}
#[test]
fn recognizes_macos_app_bundle_executable_directory() {
assert!(is_macos_app_bundle_exe_dir(std::path::Path::new(
"/tmp/ELY Browser.app/Contents/MacOS"
)));
assert!(!is_macos_app_bundle_exe_dir(std::path::Path::new("/tmp/target/debug")));
}
}
+2 -12
View File
@@ -104,12 +104,7 @@ impl ElyShell {
};
return;
};
let profile_dir = sync_profile_data_dir(
&profile_root,
&active_profile.id,
&active_profile.name,
&active_profile.kind,
);
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile.id);
self.auth_flow_phase = AuthFlowPhase::Verifying { email: email.clone() };
let tx = self.sync_inbox_tx.clone();
spawn_verify_otp(email, normalized_otp, profile_dir, tx);
@@ -127,12 +122,7 @@ impl ElyShell {
let Some(profile_root) = default_profile_data_root() else {
return;
};
let profile_dir = sync_profile_data_dir(
&profile_root,
&active_profile.id,
&active_profile.name,
&active_profile.kind,
);
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile.id);
match SyncEngine::for_profile_dir(&profile_dir, "ELY", sync_platform_label()) {
Ok(mut engine) => {
let _ = engine.install_bearer("");
@@ -271,6 +271,6 @@ fn identical_live_frames_share_render_image_arc() -> Result<(), String> {
Arc::as_ptr(first_image),
Arc::as_ptr(second_image),
);
assert!(first.has_same_software_render_as(&second));
assert!(first.has_same_render_as(&second));
Ok(())
}
@@ -89,7 +89,7 @@ fn render_about_rows(snapshot: &BrowserSnapshot) -> AnyElement {
format!("scheme: {DEEP_LINK_SCHEME}; auth: {AUTH_CALLBACK_URL}"),
))
.child(about_row(IconName::Info, "Version", APP_VERSION, "Cargo package version"))
.child(about_row(IconName::GitHub, "Build", BUILD_REVISION, "Git revision"))
.child(about_row(IconName::GitHub, "Build", BUILD_REVISION, "Build source"))
.child(about_row(
IconName::Frame,
"GPUI",
@@ -257,7 +257,7 @@ fn render_compatibility_rows(
format!("gpui {GPUI_VERSION}"),
"Native shell renderer",
))
.child(compatibility_row(IconName::GitHub, "Build", BUILD_REVISION, "Git revision"))
.child(compatibility_row(IconName::GitHub, "Build", BUILD_REVISION, "Build source"))
.child(compatibility_row(
IconName::CircleCheck,
"Site Permissions",
+20 -10
View File
@@ -121,15 +121,28 @@ pub struct ElyShell {
impl ElyShell {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::new_with_config(InitialBrowserConfig::ely_defaults(), window, cx)
let config = InitialBrowserConfig::ely_defaults()
.map_err(|error| error.to_string())
.and_then(|mut config| {
config.profile_id = Some(
crate::services::profile_identity::default_standard_profile_id()
.map_err(|error| error.to_string())?,
);
Ok(config)
});
Self::new_with_config(config, window, cx)
}
pub fn new_private(window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::new_with_config(InitialBrowserConfig::private_window(), window, cx)
Self::new_with_config(
InitialBrowserConfig::private_window().map_err(|error| error.to_string()),
window,
cx,
)
}
fn new_with_config(
config: Result<InitialBrowserConfig, ely_domain::DomainError>,
config: Result<InitialBrowserConfig, String>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -192,14 +205,11 @@ impl ElyShell {
},
);
let state = match config.and_then(|config| {
BrowserCore::new(config).map_err(|error| match error {
ely_browser_core::CoreError::Domain(source) => source,
_ => ely_domain::DomainError::InvalidCommand,
})
}) {
let state = match config
.and_then(|config| BrowserCore::new(config).map_err(|error| error.to_string()))
{
Ok(core) => ShellState::Ready(Box::new(core)),
Err(error) => ShellState::StartupError(error.to_string()),
Err(error) => ShellState::StartupError(error),
};
let (sync_inbox_tx, sync_inbox_rx) = std::sync::mpsc::channel();
+1 -8
View File
@@ -269,19 +269,12 @@ impl ElyShell {
return;
};
let active_profile_id = snapshot.active_profile_id.clone();
let active_profile_name = snapshot.active_profile_name.clone();
let active_profile_kind = snapshot.active_profile_kind.clone();
let device_name = format!("ELY · {}", snapshot.active_profile_name);
let Some(profile_root) = default_profile_data_root() else {
tracing::warn!(target: "ely::sync", "profile data root is unavailable");
return;
};
let profile_dir = sync_profile_data_dir(
&profile_root,
&active_profile_id,
&active_profile_name,
&active_profile_kind,
);
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile_id);
let bytes = match core.build_sync_snapshot_bytes() {
Ok(bytes) => bytes,
Err(error) => {
+40 -22
View File
@@ -116,8 +116,6 @@ impl ElyShell {
let profile_dir = crate::services::servo_profile_data::sync_profile_data_dir(
&profile_root,
&snapshot.active_profile_id,
&snapshot.active_profile_name,
&snapshot.active_profile_kind,
);
if snapshot.active_profile_name == "Default"
&& matches!(snapshot.active_profile_kind, ProfileKind::Standard)
@@ -237,27 +235,18 @@ fn migrate_legacy_default_sync_dir(profile_root: &Path, stable_profile_dir: &Pat
return;
}
let Ok(entries) = std::fs::read_dir(profile_root) else {
return;
};
for entry in entries.flatten() {
let candidate = entry.path().join("servo").join("sync");
if candidate == stable_sync_dir {
continue;
}
if !bearer_token_file_present(&candidate.join("bearer.token")) {
continue;
}
if let Err(error) = copy_dir_recursive(&candidate, &stable_sync_dir) {
tracing::warn!(
target: "ely::sync",
error = %error,
source = %candidate.display(),
"legacy sync profile migration failed",
);
}
let candidate = profile_root.join("default").join("servo").join("sync");
if !bearer_token_file_present(&candidate.join("bearer.token")) {
return;
}
if let Err(error) = copy_dir_recursive(&candidate, &stable_sync_dir) {
tracing::warn!(
target: "ely::sync",
error = %error,
source = %candidate.display(),
"legacy sync profile migration failed",
);
}
}
fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()> {
@@ -277,7 +266,7 @@ fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()>
#[cfg(test)]
mod tests {
use super::bearer_token_file_present;
use super::{bearer_token_file_present, migrate_legacy_default_sync_dir};
#[test]
fn bearer_token_file_presence_requires_bytes() -> Result<(), Box<dyn std::error::Error>> {
@@ -297,4 +286,33 @@ mod tests {
std::fs::remove_dir_all(dir)?;
Ok(())
}
#[test]
fn known_default_sync_directory_is_migrated() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let legacy = directory.path().join("default/servo/sync");
let stable = directory.path().join("profile_stable/servo");
std::fs::create_dir_all(&legacy)?;
std::fs::write(legacy.join("bearer.token"), "default-token")?;
migrate_legacy_default_sync_dir(directory.path(), &stable);
assert_eq!(std::fs::read_to_string(stable.join("sync/bearer.token"))?, "default-token");
Ok(())
}
#[test]
fn custom_profile_bearer_is_ignored_during_default_migration()
-> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let custom = directory.path().join("profile_custom/servo/sync");
let stable = directory.path().join("profile_stable/servo");
std::fs::create_dir_all(&custom)?;
std::fs::write(custom.join("bearer.token"), "custom-token")?;
migrate_legacy_default_sync_dir(directory.path(), &stable);
assert!(!stable.join("sync/bearer.token").exists());
Ok(())
}
}
+98 -89
View File
@@ -35,10 +35,23 @@ impl WebSurfaceStore {
Self { runtime, surfaces: BTreeMap::new(), keyboard_focus: None }
}
#[cfg(test)]
pub(super) fn state(&self, tab_id: &TabId) -> Option<&WebSurfaceState> {
self.surfaces.get(tab_id).and_then(|surface| surface.state.as_ref())
}
pub(super) fn state_for_scope(
&self,
tab_id: &TabId,
profile_id: &ely_domain::ProfileId,
profile_data_mode: ProfileDataMode,
) -> Option<&WebSurfaceState> {
self.surfaces
.get(tab_id)
.filter(|surface| surface.has_scope(profile_id, profile_data_mode))
.and_then(|surface| surface.state.as_ref())
}
pub(super) fn ensure_surface(
&mut self,
tab: &BrowserTab,
@@ -53,23 +66,28 @@ impl WebSurfaceStore {
else {
return false;
};
let native_surface =
self.surfaces.get(tab.id()).and_then(|surface| surface.native_surface.clone());
#[cfg(not(test))]
let Some(native_surface) = native_surface else {
return false;
};
let ensure_key = WebSurfaceEnsureKey::new(
requested_url.clone(),
size,
#[cfg(test)]
native_surface.as_ref(),
#[cfg(not(test))]
Some(&native_surface),
tab.profile_id().clone(),
profile_data_mode,
tab.zoom_percent(),
permissions,
);
if self.surfaces.get(tab.id()).is_some_and(|surface| !surface.should_ensure(&ensure_key)) {
let scope_changed = self.surface_mut(tab.id()).reset_for_scope_change(&ensure_key);
if scope_changed
&& self.keyboard_focus.as_ref().is_some_and(|focus| focus.tab_id == *tab.id())
{
self.keyboard_focus = None;
}
let runtime_has_session =
self.runtime.has_session(tab.id(), tab.profile_id(), profile_data_mode);
if runtime_has_session
&& self
.surfaces
.get(tab.id())
.is_some_and(|surface| !surface.should_ensure(&ensure_key))
{
return false;
}
// Once the page has ensured at least once, defer further ensures
@@ -89,31 +107,23 @@ impl WebSurfaceStore {
{
return false;
}
let input = self.take_pending_input(tab.id(), requested_url.as_str());
let previous_frame =
self.previous_ready_frame(tab.id(), requested_url.as_str(), tab.zoom_percent());
if let Err(message) =
self.runtime.prepare_tab_scope(tab.id(), tab.profile_id(), profile_data_mode)
{
return self.record_ensure_failure(
tab.id(),
ensure_key,
requested_url,
previous_frame,
message,
);
}
let input = self.take_pending_input(tab.id(), requested_url.as_str());
#[cfg(test)]
let ensure_result = match native_surface {
Some(native_surface) => self.runtime.ensure_tab_with_native_surface(
tab,
size,
native_surface,
profile_data_mode,
permissions,
input,
),
None => self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input),
};
#[cfg(not(test))]
let ensure_result = self.runtime.ensure_tab_with_native_surface(
tab,
size,
native_surface,
profile_data_mode,
permissions,
input,
);
let ensure_result =
self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input);
match ensure_result {
Ok(result) => {
@@ -127,9 +137,35 @@ impl WebSurfaceStore {
}
false
}
Err(message) => {
self.surface_mut(tab.id()).mark_ensured(ensure_key);
self.surface_mut(tab.id()).state = Some(WebSurfaceState::Failed { message });
Err(message) => self.record_ensure_failure(
tab.id(),
ensure_key,
requested_url,
previous_frame,
message,
),
}
}
fn record_ensure_failure(
&mut self,
tab_id: &TabId,
ensure_key: WebSurfaceEnsureKey,
requested_url: String,
previous_frame: Option<WebSurfaceFrame>,
message: String,
) -> bool {
self.surface_mut(tab_id).mark_ensured(ensure_key);
match previous_frame {
Some(previous_frame) => {
self.surface_mut(tab_id).state = Some(WebSurfaceState::Loading {
requested_url,
previous_frame: Some(previous_frame),
});
false
}
None => {
self.surface_mut(tab_id).state = Some(WebSurfaceState::Failed { message });
true
}
}
@@ -137,6 +173,13 @@ impl WebSurfaceStore {
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> WebSurfaceTickResult {
let frames = self.runtime.tick(visible_tab_ids);
self.apply_runtime_frames(frames)
}
fn apply_runtime_frames(
&mut self,
frames: Vec<WebSurfaceRuntimeFrame>,
) -> WebSurfaceTickResult {
let mut result = WebSurfaceTickResult::default();
for frame in frames {
@@ -146,29 +189,12 @@ impl WebSurfaceStore {
self.surfaces.get(&tab_id).and_then(|surface| surface.state.as_ref()),
Some(WebSurfaceState::Ready(_))
);
match self.initial_display_gate_message(&tab_id, &frame, had_ready) {
Ok(()) => {}
Err(message) => {
if !had_ready {
self.surface_mut(&tab_id).state =
Some(WebSurfaceState::Failed { message });
result.changed = true;
}
continue;
}
}
if self.should_hold_initial_frame(&tab_id, &frame, had_ready) {
if !had_ready {
self.surface_mut(&tab_id).state = Some(WebSurfaceState::Loading {
requested_url: frame.requested_url.clone(),
previous_frame: None,
});
result.changed = true;
}
continue;
}
let metadata = self.surface_mut(&tab_id).changed_page_metadata(&tab_id, &frame);
result.page_metadata.extend(metadata);
result.url_changes.extend(url_change);
if self.should_hold_initial_frame(&tab_id, &frame, had_ready) {
continue;
}
if self
.surfaces
.get(&tab_id)
@@ -177,7 +203,6 @@ impl WebSurfaceStore {
self.surface_mut(&tab_id).state = Some(WebSurfaceState::Ready(*frame));
result.changed = true;
}
result.url_changes.extend(url_change);
}
WebSurfaceRuntimeFrame::Failed { tab_id, message } => {
let had_ready = matches!(
@@ -303,32 +328,11 @@ impl WebSurfaceStore {
frame: &WebSurfaceFrame,
has_previous_frame: bool,
) -> bool {
!has_previous_frame
frame.render_state() != "complete"
&& !has_previous_frame
&& self
.previous_ready_frame(tab_id, frame.requested_url.as_str(), frame.zoom_percent())
.is_none()
&& matches!(frame.has_visible_content_for_initial_display(), Ok(false))
}
fn initial_display_gate_message(
&self,
tab_id: &TabId,
frame: &WebSurfaceFrame,
has_previous_frame: bool,
) -> Result<(), String> {
if has_previous_frame
|| self
.previous_ready_frame(tab_id, frame.requested_url.as_str(), frame.zoom_percent())
.is_some()
{
return Ok(());
}
frame.has_visible_content_for_initial_display().map(|_| ()).map_err(|error| {
format!(
"Servo hardware surface initial content check failed for {}: {error}",
frame.requested_url
)
})
}
pub(super) fn surface_mut(&mut self, tab_id: &TabId) -> &mut PerTabSurface {
@@ -349,14 +353,7 @@ impl WebSurfaceStore {
}
#[cfg(test)]
pub(super) fn clear_viewport_resize_debounce_for_test(&mut self, tab_id: &TabId) {
if let Some(surface) = self.surfaces.get_mut(tab_id) {
surface.clear_viewport_resize_debounce_for_test();
}
}
#[cfg(test)]
pub(super) fn flush_runtime_for_test(&self) {
pub(super) fn flush_runtime_for_test(&mut self) {
self.runtime.flush_for_test();
}
}
@@ -372,3 +369,15 @@ mod tests;
#[cfg(all(test, feature = "live-site-smoke"))]
#[path = "web_surface_live_site_tests.rs"]
mod web_surface_live_site_tests;
#[cfg(all(test, feature = "live-site-smoke"))]
#[path = "web_surface_profile_isolation_tests.rs"]
mod web_surface_profile_isolation_tests;
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
#[path = "web_surface_hardware_import_tests.rs"]
mod web_surface_hardware_import_tests;
#[cfg(test)]
#[path = "web_surface_scope_tests.rs"]
mod web_surface_scope_tests;
+31 -13
View File
@@ -41,7 +41,10 @@ impl WebSurfacePollCadence {
}
}
pub(super) fn note_frame(&mut self, render_state: &str, now: Instant) {
pub(super) fn note_frame(&mut self, render_state: &str, pixels_changed: bool, now: Instant) {
if pixels_changed {
extend_deadline(&mut self.settle_active_until, now + FRAME_SETTLE_WINDOW);
}
let phase = WebSurfaceRenderPhase::from_render_state(render_state);
match phase {
WebSurfaceRenderPhase::Created | WebSurfaceRenderPhase::Loading
@@ -173,7 +176,7 @@ mod tests {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("loading", start);
cadence.note_frame("loading", true, start);
cadence.note_poll_submitted(start);
assert!(cadence.next_poll_delay(start) <= FRAME_BUDGET_120HZ);
@@ -209,7 +212,7 @@ mod tests {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("complete", start);
cadence.note_frame("complete", true, start);
cadence.note_poll_submitted(start + Duration::from_millis(300));
assert!(!cadence.should_poll(start + Duration::from_millis(379)));
@@ -222,7 +225,7 @@ mod tests {
let mut cadence = WebSurfacePollCadence::default();
cadence.note_ensure(WebSurfaceInputKind::Idle, true, start);
cadence.note_frame("complete", start + Duration::from_secs(1));
cadence.note_frame("complete", true, start + Duration::from_secs(1));
cadence.note_poll_submitted(start + Duration::from_millis(1_300));
assert_eq!(
@@ -237,7 +240,7 @@ mod tests {
let mut cadence = WebSurfacePollCadence::default();
cadence.note_ensure(WebSurfaceInputKind::Idle, true, start);
cadence.note_frame("loading", start + Duration::from_secs(1));
cadence.note_frame("loading", true, start + Duration::from_secs(1));
cadence.note_poll_submitted(start + Duration::from_millis(1_300));
assert_eq!(
@@ -252,7 +255,7 @@ mod tests {
let mut cadence = WebSurfacePollCadence::default();
cadence.note_ensure(WebSurfaceInputKind::Scroll, false, start);
cadence.note_frame("complete", start + Duration::from_millis(100));
cadence.note_frame("complete", true, start + Duration::from_millis(100));
cadence.note_poll_submitted(start + Duration::from_millis(500));
assert_eq!(
@@ -267,7 +270,7 @@ mod tests {
let mut cadence = WebSurfacePollCadence::default();
cadence.note_ensure(WebSurfaceInputKind::Scroll, false, start);
cadence.note_frame("loading", start + Duration::from_millis(100));
cadence.note_frame("loading", true, start + Duration::from_millis(100));
cadence.note_poll_submitted(start + Duration::from_millis(500));
assert_eq!(
@@ -281,8 +284,8 @@ mod tests {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("complete", start);
cadence.note_frame("complete", start + Duration::from_millis(200));
cadence.note_frame("complete", false, start);
cadence.note_frame("complete", false, start + Duration::from_millis(200));
cadence.note_poll_submitted(start + Duration::from_millis(260));
assert!(!cadence.should_poll(start + Duration::from_millis(339)));
@@ -294,8 +297,8 @@ mod tests {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("loading", start);
cadence.note_frame("loading", start + Duration::from_millis(200));
cadence.note_frame("loading", false, start);
cadence.note_frame("loading", false, start + Duration::from_millis(200));
cadence.note_poll_submitted(start + Duration::from_millis(5_010));
assert_eq!(
@@ -309,10 +312,25 @@ mod tests {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("sleeping", start);
cadence.note_frame("sleeping", start + Duration::from_millis(200));
cadence.note_frame("sleeping", false, start);
cadence.note_frame("sleeping", false, start + Duration::from_millis(200));
cadence.note_poll_submitted(start + Duration::from_millis(610));
assert_eq!(cadence.next_poll_delay(start + Duration::from_millis(610)), IDLE_POLL_INTERVAL);
}
#[test]
fn repeated_pixel_frames_keep_animation_cadence_active() {
let start = Instant::now();
let mut cadence = WebSurfacePollCadence::default();
cadence.note_frame("complete", true, start);
cadence.note_frame("complete", true, start + Duration::from_millis(240));
cadence.note_poll_submitted(start + Duration::from_millis(300));
assert_eq!(
cadence.next_poll_delay(start + Duration::from_millis(300)),
ACTIVE_POLL_INTERVAL
);
}
}
@@ -1,6 +1,6 @@
use ely_browser_core::{BrowserCore, BrowserSnapshot};
use ely_domain::{BrowserTab, ProfileKind, TabId, UrlText};
use gpui::{AnyElement, Bounds, Context, NativeSurfaceHandle, Pixels, Point};
use gpui::{AnyElement, Bounds, Context, Pixels, Point};
use crate::services::ProfileDataMode;
@@ -26,11 +26,11 @@ impl ElyShell {
cx: &mut Context<Self>,
) -> AnyElement {
let state_entity = cx.entity().clone();
if profile_data_mode_for(tab, snapshot).is_none() {
let Some(profile_data_mode) = profile_data_mode_for(tab, snapshot) else {
return render_failed_web_surface(tab, "Profile context is unavailable.", state_entity);
}
};
match self.web_surfaces.state(tab.id()) {
match self.web_surfaces.state_for_scope(tab.id(), tab.profile_id(), profile_data_mode) {
Some(WebSurfaceState::Ready(frame)) => {
render_ready_web_surface(frame, tab, state_entity, bottom_corner_radius)
}
@@ -102,25 +102,6 @@ impl ElyShell {
}
}
pub(super) fn record_external_web_surface(
&mut self,
tab_id: TabId,
bounds: Bounds<Pixels>,
scale_factor: f32,
native_surface: NativeSurfaceHandle,
cx: &mut Context<Self>,
) {
let viewport_changed =
self.web_surfaces.record_viewport_size(&tab_id, bounds, scale_factor)
== WebSurfaceInputOutcome::Applied;
let surface_changed = self.web_surfaces.record_native_surface(&tab_id, native_surface)
== WebSurfaceInputOutcome::Applied;
if viewport_changed || surface_changed {
self.flush_external_web_surface_tick(cx);
}
}
pub(super) fn scroll_external_web_viewport(
&mut self,
tab_id: TabId,
+127 -22
View File
@@ -3,20 +3,31 @@ use std::hash::Hasher;
use std::sync::Arc;
use ahash::AHasher;
#[cfg(target_os = "macos")]
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
use gpui::RenderImage;
use image::{ImageBuffer, Rgba};
use thiserror::Error;
#[cfg(target_os = "macos")]
use crate::services::iosurface_metal::HardwareSurfaceBacking;
use crate::services::servo_live::ServoLiveFrame;
thread_local! {
/// Single-slot cache for byte-identical software frames.
/// AHash keeps the 1080p hash pass below the frame budget while
/// avoiding repeated GPUI texture allocation for idle pages.
static LAST_FRAME_IMAGE: RefCell<Option<(u64, Arc<RenderImage>)>> =
static LAST_FRAME_IMAGE: RefCell<Option<CachedRenderImage>> =
const { RefCell::new(None) };
}
struct CachedRenderImage {
width: u32,
height: u32,
bytes_hash: u64,
image: Arc<RenderImage>,
}
#[cfg(all(test, feature = "live-site-smoke"))]
use super::web_surface_geometry::WebSurfaceSize;
use super::web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset};
@@ -37,6 +48,7 @@ pub(super) struct WebSurfaceFrame {
zoom_percent: u16,
click_point: Option<WebSurfaceClickPoint>,
typed_text: Option<String>,
pixels_changed: bool,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -44,6 +56,10 @@ pub(super) struct WebSurfaceFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: u64,
pub(super) image: Option<Arc<RenderImage>>,
#[cfg(target_os = "macos")]
pub(super) hardware_surface: Option<Arc<HardwareSurfaceBacking>>,
#[cfg(target_os = "macos")]
hardware_surface_id: Option<u64>,
}
impl WebSurfaceFrame {
@@ -53,6 +69,10 @@ impl WebSurfaceFrame {
zoom_percent: u16,
frame: ServoLiveFrame,
) -> Result<Self, WebSurfaceError> {
#[cfg(target_os = "macos")]
let hardware_surface = frame.hardware_surface().cloned();
#[cfg(target_os = "macos")]
let hardware_surface_id = frame.hardware_surface_id();
Self::from_parts(WebSurfaceFrameParts {
requested_url,
loaded_url: frame.loaded_url().map(str::to_string),
@@ -67,6 +87,7 @@ impl WebSurfaceFrame {
zoom_percent,
click_point: None,
typed_text: None,
pixels_changed: frame.pixels_changed(),
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: frame.non_white_pixel_count(),
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -74,10 +95,26 @@ impl WebSurfaceFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: frame.sample_hash(),
rgba_bytes: frame.into_rgba_bytes(),
#[cfg(target_os = "macos")]
hardware_surface,
#[cfg(target_os = "macos")]
hardware_surface_id,
})
}
fn from_parts(parts: WebSurfaceFrameParts) -> Result<Self, WebSurfaceError> {
#[cfg(target_os = "macos")]
if let Some(surface) = parts.hardware_surface.as_ref() {
validate_hardware_pixel_buffer(surface.pixel_buffer(), parts.width, parts.height)?;
}
#[cfg(target_os = "macos")]
let has_hardware_surface = parts.hardware_surface.is_some();
#[cfg(not(target_os = "macos"))]
let has_hardware_surface = false;
if parts.rgba_bytes.is_none() && !has_hardware_surface {
return Err(WebSurfaceError::MissingRenderablePayload);
}
#[cfg(all(test, feature = "live-site-smoke"))]
let pixel_sample = pixel_sample_for_parts(&parts)?;
@@ -108,6 +145,7 @@ impl WebSurfaceFrame {
zoom_percent: parts.zoom_percent,
click_point: parts.click_point,
typed_text: parts.typed_text,
pixels_changed: parts.pixels_changed,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: pixel_sample.non_white_pixel_count,
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -115,6 +153,10 @@ impl WebSurfaceFrame {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: pixel_sample.sample_hash,
image,
#[cfg(target_os = "macos")]
hardware_surface: parts.hardware_surface,
#[cfg(target_os = "macos")]
hardware_surface_id: parts.hardware_surface_id,
})
}
@@ -158,7 +200,6 @@ impl WebSurfaceFrame {
(self.css_viewport_width, self.css_viewport_height)
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn render_state(&self) -> &str {
self.render_state.as_str()
}
@@ -180,15 +221,26 @@ impl WebSurfaceFrame {
self.title.as_deref()
}
pub(super) fn has_same_software_render_as(&self, other: &Self) -> bool {
pub(super) fn has_same_render_as(&self, other: &Self) -> bool {
#[cfg(target_os = "macos")]
if self.hardware_surface.is_some() || other.hardware_surface.is_some() {
return self.hardware_surface.is_some()
&& other.hardware_surface.is_some()
&& self.hardware_surface_id == other.hardware_surface_id
&& !other.pixels_changed
&& self.metadata_matches(other);
}
let image_matches = match (self.image.as_ref(), other.image.as_ref()) {
(Some(image), Some(other_image)) => Arc::ptr_eq(image, other_image),
(None, None) => true,
_ => false,
};
image_matches
&& self.requested_url == other.requested_url
image_matches && self.metadata_matches(other)
}
fn metadata_matches(&self, other: &Self) -> bool {
self.requested_url == other.requested_url
&& self.loaded_url == other.loaded_url
&& self.title == other.title
&& self.render_state == other.render_state
@@ -203,17 +255,6 @@ impl WebSurfaceFrame {
&& self.typed_text == other.typed_text
}
pub(super) fn has_visible_content_for_initial_display(&self) -> Result<bool, WebSurfaceError> {
#[cfg(all(test, feature = "live-site-smoke"))]
{
Ok(self.non_white_pixel_count > 0 && self.content_pixel_count > 0)
}
#[cfg(not(all(test, feature = "live-site-smoke")))]
{
Ok(true)
}
}
#[cfg(all(test, feature = "live-site-smoke"))]
pub(super) fn non_white_pixel_count(&self) -> u64 {
self.non_white_pixel_count
@@ -229,9 +270,21 @@ impl WebSurfaceFrame {
self.sample_hash
}
#[cfg(all(test, feature = "live-site-smoke"))]
#[cfg(test)]
pub(super) fn has_hardware_surface(&self) -> bool {
false
#[cfg(target_os = "macos")]
{
self.hardware_surface.is_some()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
pub(super) fn hardware_surface_id_for_test(&self) -> Option<u64> {
self.hardware_surface_id
}
}
@@ -249,6 +302,7 @@ struct WebSurfaceFrameParts {
zoom_percent: u16,
click_point: Option<WebSurfaceClickPoint>,
typed_text: Option<String>,
pixels_changed: bool,
#[cfg(all(test, feature = "live-site-smoke"))]
non_white_pixel_count: u64,
#[cfg(all(test, feature = "live-site-smoke"))]
@@ -256,6 +310,10 @@ struct WebSurfaceFrameParts {
#[cfg(all(test, feature = "live-site-smoke"))]
sample_hash: u64,
rgba_bytes: Option<Vec<u8>>,
#[cfg(target_os = "macos")]
hardware_surface: Option<Arc<HardwareSurfaceBacking>>,
#[cfg(target_os = "macos")]
hardware_surface_id: Option<u64>,
}
#[derive(Debug, Error)]
@@ -264,6 +322,42 @@ pub(super) enum WebSurfaceError {
InvalidFrameBuffer { width: u32, height: u32 },
#[error("servo live frame did not include renderable pixels")]
MissingRenderablePayload,
#[cfg(target_os = "macos")]
#[error(
"servo hardware surface size {actual_width}x{actual_height} did not match frame report {expected_width}x{expected_height}"
)]
HardwareSurfaceSizeMismatch {
expected_width: u32,
expected_height: u32,
actual_width: usize,
actual_height: usize,
},
#[cfg(target_os = "macos")]
#[error("servo hardware surface pixel format 0x{actual:x} is unsupported; expected 32BGRA")]
UnsupportedHardwareSurfaceFormat { actual: u32 },
}
#[cfg(target_os = "macos")]
fn validate_hardware_pixel_buffer(
pixel_buffer: &CVPixelBuffer,
expected_width: u32,
expected_height: u32,
) -> Result<(), WebSurfaceError> {
let actual_width = pixel_buffer.get_width();
let actual_height = pixel_buffer.get_height();
if actual_width != expected_width as usize || actual_height != expected_height as usize {
return Err(WebSurfaceError::HardwareSurfaceSizeMismatch {
expected_width,
expected_height,
actual_width,
actual_height,
});
}
let actual_format = pixel_buffer.get_pixel_format();
if actual_format != kCVPixelFormatType_32BGRA {
return Err(WebSurfaceError::UnsupportedHardwareSurfaceFormat { actual: actual_format });
}
Ok(())
}
/// Swap byte 0 and byte 2 of every 4-byte pixel, converting Servo's
@@ -307,16 +401,27 @@ fn resolve_render_image(
) -> Result<Arc<RenderImage>, WebSurfaceError> {
LAST_FRAME_IMAGE.with(|cache| -> Result<Arc<RenderImage>, WebSurfaceError> {
let mut cache = cache.borrow_mut();
if let Some((cached_hash, cached_image)) = cache.as_ref()
&& *cached_hash == bytes_hash
if let Some(cached) = cache.as_ref()
&& cached.width == width
&& cached.height == height
&& cached.bytes_hash == bytes_hash
&& cached.image.as_bytes(0) == Some(rgba_bytes.as_slice())
{
return Ok(cached_image.clone());
return Ok(cached.image.clone());
}
let image_buffer = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, rgba_bytes)
.ok_or(WebSurfaceError::InvalidFrameBuffer { width, height })?;
let new_image = Arc::new(RenderImage::new([image::Frame::new(image_buffer)]));
*cache = Some((bytes_hash, new_image.clone()));
*cache = Some(CachedRenderImage { width, height, bytes_hash, image: new_image.clone() });
Ok(new_image)
})
}
#[cfg(test)]
#[path = "web_surface_frame_cache_tests.rs"]
mod cache_tests;
#[cfg(all(test, target_os = "macos"))]
#[path = "web_surface_frame_hardware_tests.rs"]
mod hardware_tests;
@@ -0,0 +1,32 @@
use std::sync::Arc;
use super::resolve_render_image;
#[test]
fn cache_key_includes_frame_dimensions() -> Result<(), super::WebSurfaceError> {
let bytes = vec![0, 0, 0, 255, 255, 255, 255, 255];
let first = resolve_render_image(1, 2, bytes.clone(), 41)?;
let reshaped = resolve_render_image(2, 1, bytes, 41)?;
assert!(!Arc::ptr_eq(&first, &reshaped));
Ok(())
}
#[test]
fn cache_verifies_bytes_after_hash_match() -> Result<(), super::WebSurfaceError> {
let first = resolve_render_image(1, 1, vec![1, 2, 3, 255], 99)?;
let collision = resolve_render_image(1, 1, vec![3, 2, 1, 255], 99)?;
assert!(!Arc::ptr_eq(&first, &collision));
Ok(())
}
#[test]
fn identical_frame_reuses_render_image() -> Result<(), super::WebSurfaceError> {
let bytes = vec![1, 2, 3, 255];
let first = resolve_render_image(1, 1, bytes.clone(), 7)?;
let repeated = resolve_render_image(1, 1, bytes, 7)?;
assert!(Arc::ptr_eq(&first, &repeated));
Ok(())
}
@@ -0,0 +1,105 @@
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
use crate::services::servo_live::ServoLiveFrame;
use super::{WebSurfaceError, WebSurfaceFrame};
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
#[test]
fn hardware_frame_keeps_pixel_buffer_without_software_image() -> Result<(), WebSurfaceError> {
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
let live_frame = ServoLiveFrame::for_test_with_pixel_buffer(64, 48, 7, pixel_buffer);
let frame = WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
live_frame,
)?;
assert!(frame.has_hardware_surface());
assert!(frame.hardware_surface.is_some());
assert!(frame.image.is_none());
Ok(())
}
#[test]
fn hardware_frame_identity_participates_in_equality() -> Result<(), WebSurfaceError> {
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
let first = WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_pixel_buffer(64, 48, 7, pixel_buffer.clone()),
)?;
let second = WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_pixel_buffer(64, 48, 8, pixel_buffer),
)?;
assert!(!first.has_same_render_as(&second));
Ok(())
}
#[test]
fn same_hardware_surface_with_new_pixels_triggers_repaint() -> Result<(), WebSurfaceError> {
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
let first = hardware_frame(7, pixel_buffer.clone(), false)?;
let changed = hardware_frame(7, pixel_buffer, true)?;
assert!(!first.has_same_render_as(&changed));
Ok(())
}
#[test]
fn metadata_only_hardware_frame_reuses_same_surface() -> Result<(), WebSurfaceError> {
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
let first = hardware_frame(7, pixel_buffer.clone(), true)?;
let metadata_only = hardware_frame(7, pixel_buffer, false)?;
assert!(first.has_same_render_as(&metadata_only));
Ok(())
}
#[test]
fn hardware_frame_validates_reported_dimensions() -> Result<(), String> {
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
.map_err(|status| format!("test pixel buffer creation failed: {status}"))?;
let live_frame = ServoLiveFrame::for_test_with_pixel_buffer(96, 72, 7, pixel_buffer);
assert!(matches!(
WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
live_frame,
),
Err(WebSurfaceError::HardwareSurfaceSizeMismatch { .. })
));
Ok(())
}
fn hardware_frame(
surface_id: u64,
pixel_buffer: CVPixelBuffer,
pixels_changed: bool,
) -> Result<WebSurfaceFrame, WebSurfaceError> {
WebSurfaceFrame::from_live_frame(
"https://example.com/".to_string(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_hardware_change(
64,
48,
surface_id,
pixel_buffer,
pixels_changed,
),
)
}
@@ -0,0 +1,212 @@
use std::{
env,
error::Error,
ffi::OsString,
path::{Path, PathBuf},
process::{Command, Stdio},
sync::Arc,
thread,
time::{Duration, Instant},
};
use core_video::pixel_buffer::kCVPixelFormatType_32BGRA;
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use gpui::{Bounds, SurfaceLease, point, px, size, submit_surface_to_metal_for_test};
use objc2_io_surface::IOSurfaceRef;
use crate::{
services::ProfileDataMode,
shell::web_surface_frame::WebSurfaceFrame,
shell::web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState},
};
use super::{WebSurfaceStore, web_surface_profile_isolation_tests::ProfileProbeServer};
const CHILD_ENV: &str = "ELY_APP_HARDWARE_IOSURFACE_CHILD";
const TEST_NAME: &str = concat!(
"shell::web_surface::web_surface_hardware_import_tests::",
"web_surface_imports_hardware_iosurface",
);
const PROBE_TITLE: &str = "request=empty|document=hardware|storage=hardware|cache=cache-1";
const PROBE_WIDTH: u32 = 640;
const PROBE_HEIGHT: u32 = 480;
const PROBE_TIMEOUT: Duration = Duration::from_secs(20);
#[test]
fn web_surface_imports_hardware_iosurface() -> Result<(), Box<dyn Error>> {
if env::var_os(CHILD_ENV).is_some() {
return run_hardware_iosurface_probe();
}
let sidecar = build_hardware_sidecar()?;
let output = Command::new(env::current_exe()?)
.arg(TEST_NAME)
.arg("--exact")
.arg("--test-threads=1")
.env(CHILD_ENV, "1")
.env("ELY_SERVO_RENDERING_CONTEXT", "hardware")
.env("ELY_SERVO_SIDECAR", sidecar)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
if output.status.success() && stdout.contains("running 1 test") {
return Ok(());
}
Err(format!(
"isolated hardware IOSurface test failed\nstatus: {}\nstdout: {}\nstderr: {}",
output.status,
stdout,
String::from_utf8_lossy(&output.stderr),
)
.into())
}
fn build_hardware_sidecar() -> Result<PathBuf, Box<dyn Error>> {
let workspace_manifest = PathBuf::from(env!("ELY_WORKSPACE_MANIFEST"));
let workspace_root = workspace_manifest.parent().ok_or("missing workspace root")?;
let cargo = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"));
let output = Command::new(cargo)
.args([
"build",
"--locked",
"-p",
"ely_servo_host",
"--features",
"servo-engine,hardware-render",
"--bin",
"ely_servo_sidecar",
])
.current_dir(workspace_root)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
if !output.status.success() {
return Err(format!(
"hardware sidecar build failed\nstatus: {}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
)
.into());
}
let sidecar = sidecar_binary_path(workspace_root);
if !sidecar.is_file() {
return Err(format!("hardware sidecar is missing at {}", sidecar.display()).into());
}
Ok(sidecar)
}
fn sidecar_binary_path(workspace_root: &Path) -> PathBuf {
let mut target_dir = env::var_os("CARGO_TARGET_DIR").map_or_else(
|| workspace_root.join("target"),
|path| {
let path = PathBuf::from(path);
if path.is_absolute() { path } else { workspace_root.join(path) }
},
);
if let Some(target) = env::var_os("CARGO_BUILD_TARGET") {
target_dir.push(target);
}
target_dir.join("debug").join(format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX))
}
fn run_hardware_iosurface_probe() -> Result<(), Box<dyn Error>> {
let mut server = ProfileProbeServer::start()?;
let mut store = WebSurfaceStore::new();
let url = format!("{}/probe?value=hardware", server.origin());
let tab = BrowserTab::new(
TabId::new(),
SpaceId::new(),
ProfileId::new(),
"Hardware IOSurface probe",
UrlText::parse(&url)?,
);
assert_eq!(
store.record_viewport_size(tab.id(), probe_bounds(), 1.0),
WebSurfaceInputOutcome::Applied,
);
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
let frame = wait_for_hardware_surface(&mut store, &tab)?;
store.close_surface(tab.id());
store.flush_runtime_for_test();
assert_hardware_lease_lifecycle(frame)?;
drop(store);
server.finish()
}
fn wait_for_hardware_surface(
store: &mut WebSurfaceStore,
tab: &BrowserTab,
) -> Result<WebSurfaceFrame, Box<dyn Error>> {
let started_at = Instant::now();
let mut last_frame = None;
loop {
store.tick(std::slice::from_ref(tab.id()));
match store.state(tab.id()) {
Some(WebSurfaceState::Ready(frame)) => {
last_frame = Some(format!(
"title={:?}, state={}, hardware={}",
frame.title(),
frame.render_state(),
frame.has_hardware_surface(),
));
if frame.title() == Some(PROBE_TITLE) && frame.has_hardware_surface() {
return Ok(frame.clone());
}
}
Some(WebSurfaceState::Failed { message }) => {
return Err(format!("hardware IOSurface probe failed: {message}").into());
}
Some(WebSurfaceState::Loading { .. }) | None => {}
}
if started_at.elapsed() >= PROBE_TIMEOUT {
return Err(format!(
"timed out waiting for imported hardware IOSurface; last frame: {last_frame:?}",
)
.into());
}
thread::sleep(Duration::from_millis(2));
}
}
fn assert_hardware_lease_lifecycle(frame: WebSurfaceFrame) -> Result<(), Box<dyn Error>> {
let hardware_surface = frame.hardware_surface.clone().ok_or("hardware backing is missing")?;
let pixel_buffer = hardware_surface.pixel_buffer();
assert_eq!(pixel_buffer.get_pixel_format(), kCVPixelFormatType_32BGRA);
let surface_id = frame
.hardware_surface_id_for_test()
.and_then(|surface_id| u32::try_from(surface_id).ok())
.ok_or("hardware IOSurface ID is invalid")?;
let iosurface = IOSurfaceRef::lookup(surface_id).ok_or("hardware IOSurface lookup failed")?;
let active_use_count = iosurface.use_count();
let released_use_count = active_use_count
.checked_sub(1)
.ok_or("hardware IOSurface use count was not incremented")?;
assert_eq!(Arc::strong_count(&hardware_surface), 2);
let weak_backing = Arc::downgrade(&hardware_surface);
let submission = submit_surface_to_metal_for_test(
pixel_buffer.clone(),
SurfaceLease::from_arc(hardware_surface.clone()),
)?;
drop(frame);
drop(hardware_surface);
assert_eq!(weak_backing.strong_count(), 1);
assert_eq!(iosurface.use_count(), active_use_count);
submission.finish()?;
assert_eq!(weak_backing.strong_count(), 0);
assert_eq!(iosurface.use_count(), released_use_count);
Ok(())
}
fn probe_bounds() -> Bounds<gpui::Pixels> {
Bounds::new(point(px(0.0), px(0.0)), size(px(PROBE_WIDTH as f32), px(PROBE_HEIGHT as f32)))
}
+1 -19
View File
@@ -1,7 +1,7 @@
use std::time::Instant;
use ely_domain::TabId;
use gpui::{Bounds, NativeSurfaceHandle, Pixels, Point};
use gpui::{Bounds, Pixels, Point};
use super::{
web_surface::WebSurfaceStore,
@@ -104,24 +104,6 @@ impl WebSurfaceStore {
}
}
pub(super) fn record_native_surface(
&mut self,
tab_id: &TabId,
native_surface: NativeSurfaceHandle,
) -> WebSurfaceInputOutcome {
let surface = self.surface_mut(tab_id);
if surface
.native_surface
.as_ref()
.is_some_and(|current| current.identity() == native_surface.identity())
{
return WebSurfaceInputOutcome::NoChange;
}
surface.native_surface = Some(native_surface);
surface.last_ensure_key = None;
WebSurfaceInputOutcome::Applied
}
pub(super) fn record_hover_point(
&mut self,
tab_id: &TabId,
@@ -92,6 +92,7 @@ fn run_isolated_live_site_test(
let output = Command::new(env::current_exe()?)
.arg(test_name)
.env(LIVE_SITE_CHILD_ENV, "1")
.env("ELY_SERVO_RENDERING_CONTEXT", "software")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
@@ -124,6 +124,7 @@ mod tests {
profile_name: "Default".to_string(),
profile_color_hex: 0x26251e,
profile_kind: ely_domain::ProfileKind::Standard,
profile_id: None,
new_tab_destination: Default::default(),
})?;
core.open_tab(UrlText::parse(url)?);
@@ -0,0 +1,357 @@
use std::{
error::Error,
io::{self, Read, Write},
net::{TcpListener, TcpStream},
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use gpui::{Bounds, point, px, size};
use crate::{
services::ProfileDataMode,
shell::web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState},
};
use super::WebSurfaceStore;
const PROBE_WIDTH: u32 = 640;
const PROBE_HEIGHT: u32 = 480;
const PROBE_TIMEOUT: Duration = Duration::from_secs(20);
#[test]
fn web_surface_keeps_profile_site_data_isolated() -> Result<(), Box<dyn Error>> {
let mut server = ProfileProbeServer::start()?;
let mut store = WebSurfaceStore::new();
let profile_a = ProfileId::new();
let profile_b = ProfileId::new();
let tab_a_seed = render_probe(
&mut store,
&profile_a,
&format!("{}/probe?value=alpha", server.origin()),
"request=empty|document=alpha|storage=alpha|cache=cache-1",
)?;
let tab_b_seed = render_probe(
&mut store,
&profile_b,
&format!("{}/probe?value=beta", server.origin()),
"request=empty|document=beta|storage=beta|cache=cache-2",
)?;
let tab_a_inspect = render_probe(
&mut store,
&profile_a,
&format!("{}/probe?inspect=alpha", server.origin()),
"request=alpha|document=alpha|storage=alpha|cache=cache-1",
)?;
let tab_b_inspect = render_probe(
&mut store,
&profile_b,
&format!("{}/probe?inspect=beta", server.origin()),
"request=beta|document=beta|storage=beta|cache=cache-2",
)?;
assert_eq!(server.cache_request_count(), 2);
store.close_surface(&tab_a_seed);
store.close_surface(&tab_a_inspect);
store.flush_runtime_for_test();
let tab_a_reopened = render_probe(
&mut store,
&profile_a,
&format!("{}/probe?value=gamma", server.origin()),
"request=empty|document=gamma|storage=gamma|cache=cache-3",
)?;
assert_eq!(server.cache_request_count(), 3);
for tab_id in [tab_b_seed, tab_b_inspect, tab_a_reopened] {
store.close_surface(&tab_id);
}
store.flush_runtime_for_test();
drop(store);
server.finish()?;
Ok(())
}
fn render_probe(
store: &mut WebSurfaceStore,
profile_id: &ProfileId,
url: &str,
expected_title: &str,
) -> Result<TabId, Box<dyn Error>> {
let tab = BrowserTab::new(
TabId::new(),
SpaceId::new(),
profile_id.clone(),
"Profile probe",
UrlText::parse(url)?,
);
assert_eq!(
store.record_viewport_size(tab.id(), probe_bounds(), 1.0),
WebSurfaceInputOutcome::Applied,
);
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
let started_at = Instant::now();
let mut last_title = None;
loop {
store.tick(std::slice::from_ref(tab.id()));
match store.state(tab.id()) {
Some(WebSurfaceState::Ready(frame)) => {
last_title = frame.title().map(str::to_string);
if frame.title() == Some(expected_title) {
return Ok(tab.id().clone());
}
}
Some(WebSurfaceState::Failed { message }) => {
return Err(format!("profile probe failed for {url}: {message}").into());
}
Some(WebSurfaceState::Loading { .. }) | None => {}
}
if started_at.elapsed() >= PROBE_TIMEOUT {
return Err(format!(
"timed out waiting for profile probe title `{expected_title}` at {url}; last title: {last_title:?}",
)
.into());
}
thread::sleep(Duration::from_millis(2));
}
}
fn probe_bounds() -> Bounds<gpui::Pixels> {
Bounds::new(point(px(0.0), px(0.0)), size(px(PROBE_WIDTH as f32), px(PROBE_HEIGHT as f32)))
}
pub(super) struct ProfileProbeServer {
origin: String,
cache_requests: Arc<AtomicUsize>,
shutdown: Arc<AtomicBool>,
error: Arc<Mutex<Option<String>>>,
thread: Option<JoinHandle<()>>,
}
impl ProfileProbeServer {
pub(super) fn start() -> Result<Self, Box<dyn Error>> {
let listener = TcpListener::bind("127.0.0.1:0")?;
listener.set_nonblocking(true)?;
let address = listener.local_addr()?;
let cache_requests = Arc::new(AtomicUsize::new(0));
let shutdown = Arc::new(AtomicBool::new(false));
let error = Arc::new(Mutex::new(None));
let thread_cache_requests = cache_requests.clone();
let thread_shutdown = shutdown.clone();
let thread_error = error.clone();
let thread = thread::Builder::new().name("ely-profile-probe-server".to_string()).spawn(
move || {
serve_profile_probes(
listener,
thread_cache_requests,
thread_shutdown,
thread_error,
);
},
)?;
Ok(Self {
origin: format!("http://{address}"),
cache_requests,
shutdown,
error,
thread: Some(thread),
})
}
pub(super) fn origin(&self) -> &str {
self.origin.as_str()
}
fn cache_request_count(&self) -> usize {
self.cache_requests.load(Ordering::SeqCst)
}
pub(super) fn finish(&mut self) -> Result<(), Box<dyn Error>> {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(thread) = self.thread.take()
&& thread.join().is_err()
{
return Err("profile probe server thread panicked".into());
}
let error = self.error.lock().map_err(|_| "profile probe error lock was poisoned")?.take();
match error {
Some(message) => Err(message.into()),
None => Ok(()),
}
}
}
impl Drop for ProfileProbeServer {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::SeqCst);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn serve_profile_probes(
listener: TcpListener,
cache_requests: Arc<AtomicUsize>,
shutdown: Arc<AtomicBool>,
error: Arc<Mutex<Option<String>>>,
) {
while !shutdown.load(Ordering::SeqCst) {
match listener.accept() {
Ok((stream, _)) => {
let connection_cache_requests = cache_requests.clone();
let connection_error = error.clone();
_ = thread::spawn(move || {
if let Err(server_error) = serve_connection(stream, &connection_cache_requests)
{
record_server_error(&connection_error, server_error.to_string());
}
});
}
Err(server_error) if server_error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(2));
}
Err(server_error) => {
record_server_error(&error, server_error.to_string());
return;
}
}
}
}
fn record_server_error(error: &Arc<Mutex<Option<String>>>, message: String) {
if let Ok(mut slot) = error.lock() {
*slot = Some(message);
}
}
fn serve_connection(mut stream: TcpStream, cache_requests: &AtomicUsize) -> Result<(), io::Error> {
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
let request = read_http_request(&mut stream)?;
let request_text = String::from_utf8_lossy(&request);
let path = request_text
.lines()
.next()
.and_then(|line| line.split_ascii_whitespace().nth(1))
.unwrap_or("/");
if path.starts_with("/cache-token") {
let request_number = cache_requests.fetch_add(1, Ordering::SeqCst) + 1;
return write_http_response(
&mut stream,
"200 OK",
"text/plain; charset=utf-8",
"public, max-age=3600, immutable",
None,
format!("cache-{request_number}").as_bytes(),
);
}
if path.starts_with("/probe") {
let request_cookie = cookie_value(&request_text, "ely_profile").unwrap_or("empty");
let seed = query_value(path, "value");
let set_cookie =
seed.map(|value| format!("ely_profile={value}; Path=/; Max-Age=3600; SameSite=Lax"));
let body = profile_probe_html(request_cookie);
return write_http_response(
&mut stream,
"200 OK",
"text/html; charset=utf-8",
"no-store",
set_cookie.as_deref(),
body.as_bytes(),
);
}
write_http_response(
&mut stream,
"404 Not Found",
"text/plain; charset=utf-8",
"no-store",
None,
b"missing",
)
}
fn read_http_request(stream: &mut TcpStream) -> Result<Vec<u8>, io::Error> {
let mut request = Vec::new();
let mut buffer = [0_u8; 4096];
loop {
let read = stream.read(&mut buffer)?;
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
if request.len() > 64 * 1024 {
return Err(io::Error::other("profile probe request exceeded 64 KiB"));
}
}
Ok(request)
}
fn write_http_response(
stream: &mut TcpStream,
status: &str,
content_type: &str,
cache_control: &str,
set_cookie: Option<&str>,
body: &[u8],
) -> Result<(), io::Error> {
let cookie_header =
set_cookie.map(|cookie| format!("Set-Cookie: {cookie}\r\n")).unwrap_or_default();
let headers = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nCache-Control: {cache_control}\r\n{cookie_header}Content-Length: {}\r\nConnection: close\r\n\r\n",
body.len(),
);
stream.write_all(headers.as_bytes())?;
stream.write_all(body)?;
stream.flush()?;
Ok(())
}
fn cookie_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
request.lines().find_map(|line| {
let (header, value) = line.split_once(':')?;
if !header.eq_ignore_ascii_case("cookie") {
return None;
}
value.split(';').find_map(|pair| {
let (cookie_name, cookie_value) = pair.trim().split_once('=')?;
(cookie_name == name).then_some(cookie_value)
})
})
}
fn query_value<'a>(path: &'a str, name: &str) -> Option<&'a str> {
let (_, query) = path.split_once('?')?;
query.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == name).then_some(value)
})
}
fn profile_probe_html(request_cookie: &str) -> String {
format!(
"<!doctype html><title>loading</title><pre id=state>loading</pre><script>\
const seed = new URLSearchParams(location.search).get('value');\
if (seed) localStorage.setItem('ely_profile', seed);\
const cookie = () => (document.cookie.match(/(?:^|; )ely_profile=([^;]*)/) || [,'empty'])[1];\
fetch('/cache-token', {{cache:'force-cache'}}).then(response => response.text()).then(cache => {{\
const title = 'request={request_cookie}|document=' + cookie() + '|storage=' + (localStorage.getItem('ely_profile') || 'empty') + '|cache=' + cache;\
document.title = title; document.getElementById('state').textContent = title;\
}});</script>",
)
}
+234 -185
View File
@@ -1,16 +1,16 @@
use std::{
collections::BTreeMap,
fs,
path::PathBuf,
thread::JoinHandle,
time::{Duration, Instant},
};
use ely_domain::{BrowserTab, TabId};
use gpui::NativeSurfaceHandle;
use crate::services::{
ProfileDataMode,
servo_live::{ServoLiveClient, ServoLiveEnsureRequest, ServoLiveSitePermission},
servo_profile_data::cleanup_stale_transient_profile_data_dirs,
};
use super::{
@@ -25,44 +25,54 @@ use super::{
scroll_wire_fields,
},
web_surface_state::WebSurfacePendingInput,
web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, WorkerResponse},
web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, RequestGeneration, WorkerResponse},
};
use cleanup::{ScopedWorker, shutdown_scoped_worker};
pub(super) use super::web_surface_runtime_session::{
WebSurfaceEnsureResult, WebSurfaceRuntimeFrame, WebSurfaceUrlChange, WebSurfaceUrlChangeKind,
};
pub(super) struct WebSurfaceRuntime {
worker: Option<ScopedWorker>,
direct_client: Option<ScopedDirectClient>,
pending_direct_responses: Vec<WorkerResponse>,
workers: BTreeMap<WebSurfaceRuntimeScope, ScopedWorker>,
sessions: BTreeMap<TabId, WebSurfaceSession>,
retry_state: BTreeMap<WebSurfaceRuntimeScope, ScopeRetryState>,
retired_workers: Vec<JoinHandle<Result<(), String>>>,
transient_cleanup_error: Option<String>,
client_factory: LiveRuntimeClientFactory,
last_generation: u64,
}
const SIDECAR_RESTART_BASE_DELAY: Duration = Duration::from_millis(250);
const SIDECAR_RESTART_MAX_DELAY: Duration = Duration::from_secs(5);
impl WebSurfaceRuntime {
pub(super) fn new() -> Self {
let transient_cleanup_error =
cleanup_stale_transient_profile_data_dirs().err().map(|error| error.to_string());
Self {
worker: None,
direct_client: None,
pending_direct_responses: Vec::new(),
workers: BTreeMap::new(),
sessions: BTreeMap::new(),
retry_state: BTreeMap::new(),
retired_workers: Vec::new(),
transient_cleanup_error,
client_factory: new_servo_live_client,
last_generation: 0,
}
}
#[cfg(test)]
pub(super) fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self {
Self {
worker: None,
direct_client: None,
pending_direct_responses: Vec::new(),
workers: BTreeMap::new(),
sessions: BTreeMap::new(),
retry_state: BTreeMap::new(),
retired_workers: Vec::new(),
transient_cleanup_error: None,
client_factory,
last_generation: 0,
}
}
#[cfg(test)]
pub(super) fn ensure_tab(
&mut self,
tab: &BrowserTab,
@@ -70,48 +80,11 @@ impl WebSurfaceRuntime {
profile_data_mode: ProfileDataMode,
permissions: &[WebSurfaceSitePermission],
input: WebSurfacePendingInput,
) -> Result<WebSurfaceEnsureResult, String> {
self.ensure_tab_inner(tab, size, None, profile_data_mode, permissions, input)
}
pub(super) fn ensure_tab_with_native_surface(
&mut self,
tab: &BrowserTab,
size: WebSurfaceSize,
native_surface: NativeSurfaceHandle,
profile_data_mode: ProfileDataMode,
permissions: &[WebSurfaceSitePermission],
input: WebSurfacePendingInput,
) -> Result<WebSurfaceEnsureResult, String> {
self.ensure_tab_inner(
tab,
size,
Some(native_surface),
profile_data_mode,
permissions,
input,
)
}
fn ensure_tab_inner(
&mut self,
tab: &BrowserTab,
size: WebSurfaceSize,
native_surface: Option<NativeSurfaceHandle>,
profile_data_mode: ProfileDataMode,
permissions: &[WebSurfaceSitePermission],
input: WebSurfacePendingInput,
) -> Result<WebSurfaceEnsureResult, String> {
let scope = WebSurfaceRuntimeScope::new(tab.profile_id().clone(), profile_data_mode);
let use_direct_client = native_surface.is_some();
if use_direct_client {
self.ensure_direct_client(scope.clone())?;
} else {
self.ensure_worker(scope.clone())?;
}
self.prepare_tab_scope(tab.id(), tab.profile_id(), profile_data_mode)?;
let requested_url = tab.url().as_str().to_string();
let tab_id_string = tab.id().as_str().to_string();
let zoom_percent = tab.zoom_percent();
let enqueued_at = input.enqueued_at;
let input_kind = pending_input_kind(&input);
@@ -119,6 +92,7 @@ impl WebSurfaceRuntime {
scroll_wire_fields(input.scroll_delta, input.scroll_point)?;
let user_navigation_input = input_requests_history_navigation(&input);
let next_scroll_offset = input.scroll_offset;
let generation = self.next_request_generation();
let submitted_at = Instant::now();
let started_loading = {
@@ -134,6 +108,7 @@ impl WebSurfaceRuntime {
session.size = size;
session.zoom_percent = zoom_percent;
session.scroll_offset = next_scroll_offset;
session.generation = Some(generation);
session.cadence.note_ensure(input_kind, started_loading, submitted_at);
started_loading
};
@@ -146,7 +121,6 @@ impl WebSurfaceRuntime {
height: size.height,
page_zoom_percent: zoom_percent,
device_pixel_ratio: size.device_pixel_ratio_f32(),
native_surface,
scroll_delta_x,
scroll_delta_y,
scroll_point_x,
@@ -159,15 +133,10 @@ impl WebSurfaceRuntime {
site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(),
};
if use_direct_client {
let response = self.ensure_direct(request, tab_id_string.clone())?;
self.pending_direct_responses.extend(response);
} else {
let Some(scoped) = self.worker.as_ref() else {
return Err("Servo worker was created but is no longer registered".to_string());
};
scoped.worker.submit_ensure(request);
}
let Some(scoped) = self.workers.get(&scope) else {
return Err("Servo sidecar worker was created but is no longer registered".to_string());
};
scoped.worker.submit_ensure(generation, request);
if let Some(session) = self.sessions.get_mut(tab.id()) {
session.cadence.note_poll_submitted(submitted_at);
}
@@ -177,42 +146,45 @@ impl WebSurfaceRuntime {
}
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec<WebSurfaceRuntimeFrame> {
self.reap_retired_workers(false);
let mut frames = Vec::new();
let now = Instant::now();
let mut responses = std::mem::take(&mut self.pending_direct_responses);
responses.extend(
self.worker.as_ref().map(|scoped| scoped.worker.drain_responses()).unwrap_or_default(),
);
let runtime_unavailable = self.collect_responses(responses, now, &mut frames);
if runtime_unavailable {
self.remove_worker();
self.remove_direct_client();
let scopes = self.workers.keys().cloned().collect::<Vec<_>>();
let mut unavailable_scopes = Vec::new();
for scope in scopes {
let responses = self
.workers
.get(&scope)
.map(|scoped| scoped.worker.drain_responses())
.unwrap_or_default();
if self.collect_responses(&scope, responses, now, &mut frames) {
unavailable_scopes.push(scope);
}
}
for scope in unavailable_scopes {
self.invalidate_scope(&scope, now);
}
let poll_now = Instant::now();
let mut direct_polls = Vec::new();
for tab_id in visible_tab_ids {
let Some(session) = self.sessions.get_mut(tab_id) else {
let Some(scope) = self.sessions.get(tab_id).and_then(|session| {
session.cadence.should_poll(poll_now).then(|| session.scope.clone())
}) else {
continue;
};
if !session.cadence.should_poll(poll_now) {
if !self.workers.contains_key(&scope) {
continue;
}
if let Some(scoped) = self.worker.as_ref() {
let _ = scoped.worker.submit_poll(tab_id.as_str().to_string());
let generation = self.next_request_generation();
let submitted = self.workers.get(&scope).is_some_and(|scoped| {
scoped.worker.submit_poll(generation, tab_id.as_str().to_string())
});
if let Some(session) = self.sessions.get_mut(tab_id) {
if submitted {
session.generation = Some(generation);
}
session.cadence.note_poll_submitted(poll_now);
} else if self.direct_client.is_some() {
direct_polls.push(tab_id.as_str().to_string());
session.cadence.note_poll_submitted(poll_now);
}
}
if !direct_polls.is_empty() {
let (responses, runtime_unavailable) = self.poll_direct(direct_polls);
let unavailable_from_responses =
self.collect_responses(responses, Instant::now(), &mut frames);
if runtime_unavailable || unavailable_from_responses {
self.remove_direct_client();
}
}
@@ -227,95 +199,104 @@ impl WebSurfaceRuntime {
visible_tab_ids
.iter()
.filter_map(|tab_id| self.sessions.get(tab_id))
.filter(|_| self.worker.is_some() || self.direct_client.is_some())
.filter(|session| self.workers.contains_key(&session.scope))
.map(|session| session.cadence.next_poll_delay(now))
.min()
}
pub(super) fn close_tab(&mut self, tab_id: &TabId) {
if self.sessions.remove(tab_id).is_none() {
let Some(session) = self.sessions.remove(tab_id) else {
return;
}
let direct_result = self
.direct_client
.as_mut()
.map(|scoped| scoped.client.close(tab_id.as_str().to_string()));
if direct_result.as_ref().is_some_and(|result| {
result.as_ref().is_err_and(|error| error.is_runtime_unavailable())
}) {
self.remove_direct_client();
}
if let Some(scoped) = self.worker.as_ref() {
};
let has_remaining_session =
self.sessions.values().any(|candidate| candidate.scope == session.scope);
if session.scope.is_transient() && !has_remaining_session {
self.remove_worker(&session.scope);
} else if let Some(scoped) = self.workers.get(&session.scope) {
scoped.worker.submit_close(tab_id.as_str().to_string());
}
}
pub(super) fn has_session(
&self,
tab_id: &TabId,
profile_id: &ely_domain::ProfileId,
profile_data_mode: ProfileDataMode,
) -> bool {
self.sessions.get(tab_id).is_some_and(|session| {
session.scope == WebSurfaceRuntimeScope::new(profile_id.clone(), profile_data_mode)
&& self.workers.contains_key(&session.scope)
})
}
pub(super) fn prepare_tab_scope(
&mut self,
tab_id: &TabId,
profile_id: &ely_domain::ProfileId,
profile_data_mode: ProfileDataMode,
) -> Result<(), String> {
let scope = WebSurfaceRuntimeScope::new(profile_id.clone(), profile_data_mode);
self.detach_tab_from_previous_scope(tab_id, &scope);
self.ensure_worker(scope.clone())?;
session_for_scope(&mut self.sessions, tab_id, scope);
Ok(())
}
fn ensure_worker(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
if self.worker.is_some() {
self.reap_retired_workers(false);
if self.workers.contains_key(&scope) {
return Ok(());
}
if scope.is_transient() && self.transient_cleanup_error.is_some() {
match cleanup_stale_transient_profile_data_dirs() {
Ok(()) => self.transient_cleanup_error = None,
Err(error) => {
let message = error.to_string();
self.transient_cleanup_error = Some(message.clone());
return Err(message);
}
}
}
let now = Instant::now();
if let Some(retry) = self.retry_state.get(&scope)
&& now < retry.retry_after
{
return Err("Servo sidecar restart is cooling down".to_string());
}
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
let client_factory = self.client_factory;
let worker = LiveRuntimeWorker::new(move || client_factory(config_dir))?;
self.worker = Some(ScopedWorker { worker, transient_profile_data_dir });
Ok(())
}
fn ensure_direct_client(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> {
if self.direct_client.is_some() {
return Ok(());
}
let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?;
let client = (self.client_factory)(config_dir)?;
self.direct_client = Some(ScopedDirectClient { client, transient_profile_data_dir });
Ok(())
}
fn ensure_direct(
&mut self,
request: ServoLiveEnsureRequest,
tab_id: String,
) -> Result<Option<WorkerResponse>, String> {
let Some(scoped) = self.direct_client.as_mut() else {
return Err("Servo client was created but is no longer registered".to_string());
};
match scoped.client.ensure(request) {
Ok(Some(frame)) => Ok(Some(WorkerResponse::Frame { tab_id, frame })),
Ok(None) => Ok(None),
let worker = match LiveRuntimeWorker::new(move || client_factory(config_dir)) {
Ok(worker) => worker,
Err(error) => {
let message = error.to_string();
if error.is_runtime_unavailable() {
self.remove_direct_client();
}
Err(message)
self.note_scope_failure(&scope, now);
return Err(error);
}
}
};
self.workers.insert(scope, ScopedWorker { worker, transient_profile_data_dir });
Ok(())
}
fn poll_direct(&mut self, tab_ids: Vec<String>) -> (Vec<WorkerResponse>, bool) {
let Some(scoped) = self.direct_client.as_mut() else {
return (Vec::new(), false);
fn detach_tab_from_previous_scope(&mut self, tab_id: &TabId, scope: &WebSurfaceRuntimeScope) {
let Some(previous_scope) = self.sessions.get(tab_id).map(|session| session.scope.clone())
else {
return;
};
let mut responses = Vec::new();
let mut runtime_unavailable = false;
for tab_id in tab_ids {
match scoped.client.poll(tab_id.clone()) {
Ok(Some(frame)) => responses.push(WorkerResponse::Frame { tab_id, frame }),
Ok(None) => {}
Err(error) if error.is_runtime_unavailable() => {
runtime_unavailable = true;
responses.push(WorkerResponse::RuntimeUnavailable);
}
Err(error) => {
responses.push(WorkerResponse::Failed { tab_id, message: error.to_string() })
}
}
if &previous_scope == scope {
return;
}
self.sessions.remove(tab_id);
let has_remaining_session =
self.sessions.values().any(|candidate| candidate.scope == previous_scope);
if previous_scope.is_transient() && !has_remaining_session {
self.remove_worker(&previous_scope);
} else if let Some(scoped) = self.workers.get(&previous_scope) {
scoped.worker.submit_close(tab_id.as_str().to_string());
}
(responses, runtime_unavailable)
}
fn collect_responses(
&mut self,
scope: &WebSurfaceRuntimeScope,
responses: Vec<WorkerResponse>,
now: Instant,
frames: &mut Vec<WebSurfaceRuntimeFrame>,
@@ -323,18 +304,23 @@ impl WebSurfaceRuntime {
let mut runtime_unavailable = false;
for response in responses {
match response {
WorkerResponse::Frame { tab_id, frame } => {
WorkerResponse::Frame { generation, tab_id, frame } => {
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
continue;
};
let session = match self.sessions.get_mut(&tab_id_obj) {
Some(session) => session,
None => continue,
if !self.sessions.get(&tab_id_obj).is_some_and(|session| {
&session.scope == scope && session.generation == Some(generation)
}) {
continue;
}
self.note_scope_success(scope);
let Some(session) = self.sessions.get_mut(&tab_id_obj) else {
continue;
};
let requested_url = session.requested_url.clone();
let scroll_offset = session.scroll_offset;
let zoom_percent = session.zoom_percent;
session.cadence.note_frame(frame.render_state(), now);
session.cadence.note_frame(frame.render_state(), frame.pixels_changed(), now);
match WebSurfaceFrame::from_live_frame(
requested_url.clone(),
scroll_offset,
@@ -356,15 +342,17 @@ impl WebSurfaceRuntime {
}),
}
}
WorkerResponse::Failed { tab_id, message } => {
WorkerResponse::Failed { generation, tab_id, message } => {
let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else {
continue;
};
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
}
WorkerResponse::RuntimeUnavailable => {
runtime_unavailable = true;
if self.sessions.get(&tab_id_obj).is_some_and(|session| {
&session.scope == scope && session.generation == Some(generation)
}) {
frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message });
}
}
WorkerResponse::RuntimeUnavailable => runtime_unavailable = true,
}
}
runtime_unavailable
@@ -374,9 +362,15 @@ impl WebSurfaceRuntime {
self.sessions.keys().find(|key| key.as_str() == tab_id).cloned()
}
fn next_request_generation(&mut self) -> RequestGeneration {
assert!(self.last_generation < u64::MAX, "web surface request generation exhausted");
self.last_generation += 1;
RequestGeneration::new(self.last_generation)
}
#[cfg(test)]
pub(super) fn client_count_for_test(&self) -> usize {
usize::from(self.worker.is_some()) + usize::from(self.direct_client.is_some())
self.workers.len()
}
#[cfg(test)]
@@ -385,39 +379,72 @@ impl WebSurfaceRuntime {
}
#[cfg(test)]
pub(super) fn flush_for_test(&self) {
if let Some(scoped) = self.worker.as_ref() {
pub(super) fn flush_for_test(&mut self) {
for scoped in self.workers.values() {
scoped.worker.wait_until_idle();
}
self.reap_retired_workers(true);
}
fn remove_worker(&mut self) {
let Some(scoped) = self.worker.take() else {
fn remove_worker(&mut self, scope: &WebSurfaceRuntimeScope) {
let Some(scoped) = self.workers.remove(scope) else {
return;
};
let ScopedWorker { worker, transient_profile_data_dir } = scoped;
drop(worker);
if let Some(path) = transient_profile_data_dir {
let _ = fs::remove_dir_all(path);
if scoped.transient_profile_data_dir.is_some() {
match std::thread::Builder::new()
.name("ely-servo-profile-cleanup".to_string())
.spawn(move || shutdown_scoped_worker(scoped))
{
Ok(handle) => self.retired_workers.push(handle),
Err(error) => self.transient_cleanup_error = Some(error.to_string()),
}
} else {
let _ = shutdown_scoped_worker(scoped);
}
}
fn remove_direct_client(&mut self) {
let Some(scoped) = self.direct_client.take() else {
return;
};
let ScopedDirectClient { client, transient_profile_data_dir } = scoped;
drop(client);
if let Some(path) = transient_profile_data_dir {
let _ = fs::remove_dir_all(path);
fn reap_retired_workers(&mut self, wait_for_all: bool) {
let mut pending = Vec::new();
for handle in self.retired_workers.drain(..) {
if !wait_for_all && !handle.is_finished() {
pending.push(handle);
continue;
}
match handle.join() {
Ok(Ok(())) => {}
Ok(Err(error)) => self.transient_cleanup_error = Some(error),
Err(_) => {
self.transient_cleanup_error =
Some("Servo profile cleanup thread panicked".to_string());
}
}
}
self.retired_workers = pending;
}
fn invalidate_scope(&mut self, scope: &WebSurfaceRuntimeScope, now: Instant) {
self.remove_worker(scope);
self.sessions.retain(|_, session| &session.scope != scope);
self.note_scope_failure(scope, now);
}
fn note_scope_failure(&mut self, scope: &WebSurfaceRuntimeScope, now: Instant) {
let retry = ScopeRetryState::after_failure(self.retry_state.get(scope), now);
self.retry_state.insert(scope.clone(), retry);
}
fn note_scope_success(&mut self, scope: &WebSurfaceRuntimeScope) {
self.retry_state.remove(scope);
}
}
impl Drop for WebSurfaceRuntime {
fn drop(&mut self) {
self.remove_worker();
self.remove_direct_client();
let scopes = self.workers.keys().cloned().collect::<Vec<_>>();
for scope in scopes {
self.remove_worker(&scope);
}
self.reap_retired_workers(true);
}
}
@@ -430,16 +457,38 @@ fn new_servo_live_client(config_dir: PathBuf) -> Result<Box<dyn LiveRuntimeClien
.map_err(|error| error.to_string())
}
struct ScopedWorker {
worker: LiveRuntimeWorker,
transient_profile_data_dir: Option<PathBuf>,
#[derive(Clone, Copy, Debug)]
struct ScopeRetryState {
failure_count: u32,
retry_after: Instant,
}
struct ScopedDirectClient {
client: Box<dyn LiveRuntimeClient>,
transient_profile_data_dir: Option<PathBuf>,
impl ScopeRetryState {
fn after_failure(previous: Option<&Self>, now: Instant) -> Self {
let failure_count = previous.map_or(1, |state| state.failure_count.saturating_add(1));
let shift = failure_count.saturating_sub(1).min(31);
let multiplier = 1_u32 << shift;
let delay =
SIDECAR_RESTART_BASE_DELAY.saturating_mul(multiplier).min(SIDECAR_RESTART_MAX_DELAY);
Self { failure_count, retry_after: now + delay }
}
}
#[path = "web_surface_runtime_cleanup.rs"]
mod cleanup;
#[cfg(test)]
#[path = "web_surface_runtime_backpressure_tests.rs"]
mod backpressure_tests;
#[cfg(test)]
#[path = "web_surface_runtime_cleanup_tests.rs"]
mod cleanup_tests;
#[cfg(test)]
#[path = "web_surface_runtime_generation_tests.rs"]
mod generation_tests;
#[cfg(test)]
#[path = "web_surface_runtime_retry_tests.rs"]
mod retry_tests;
#[cfg(test)]
#[path = "web_surface_runtime_tests.rs"]
mod tests;
@@ -0,0 +1,161 @@
use std::{
path::PathBuf,
sync::{Mutex, mpsc},
thread,
time::Duration,
};
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use crate::services::{
ProfileDataMode,
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
};
use super::{
super::{
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
web_surface_state::WebSurfacePendingInput,
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
},
WebSurfaceRuntime,
};
struct SlowPollClient;
struct BlockingEnsureClient {
ensure_started_tx: mpsc::Sender<()>,
release_ensure_rx: mpsc::Receiver<()>,
}
struct BlockingEnsureSetup {
ensure_started_tx: mpsc::Sender<()>,
release_ensure_rx: mpsc::Receiver<()>,
}
static BLOCKING_ENSURE_SETUP: Mutex<Option<BlockingEnsureSetup>> = Mutex::new(None);
impl LiveRuntimeClient for SlowPollClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
thread::sleep(Duration::from_millis(100));
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
impl LiveRuntimeClient for BlockingEnsureClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
self.ensure_started_tx.send(()).map_err(|error| error.to_string())?;
self.release_ensure_rx.recv().map_err(|error| error.to_string())?;
Ok(Some(ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])))
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Err(LiveRuntimeClientError::Message("unexpected poll".to_string()))
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
fn slow_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
Ok(Box::new(SlowPollClient))
}
fn blocking_ensure_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
let setup = BLOCKING_ENSURE_SETUP
.lock()
.map_err(|_| "blocking ensure setup lock was poisoned".to_string())?
.take()
.ok_or_else(|| "blocking ensure setup was missing".to_string())?;
Ok(Box::new(BlockingEnsureClient {
ensure_started_tx: setup.ensure_started_tx,
release_ensure_rx: setup.release_ensure_rx,
}))
}
#[test]
fn rejected_tick_poll_preserves_in_flight_ensure_generation() -> Result<(), String> {
let (ensure_started_tx, ensure_started_rx) = mpsc::channel();
let (release_ensure_tx, release_ensure_rx) = mpsc::channel();
*BLOCKING_ENSURE_SETUP
.lock()
.map_err(|_| "blocking ensure setup lock was poisoned".to_string())? =
Some(BlockingEnsureSetup { ensure_started_tx, release_ensure_rx });
let mut runtime = WebSurfaceRuntime::new_with_client_factory(blocking_ensure_client_factory);
let tab = web_tab("Blocked ensure")?;
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
ensure_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
thread::sleep(Duration::from_millis(10));
assert!(runtime.tick(std::slice::from_ref(tab.id())).is_empty());
release_ensure_tx.send(()).map_err(|error| error.to_string())?;
runtime.flush_for_test();
let frames = runtime.tick(std::slice::from_ref(tab.id()));
assert!(matches!(
frames.as_slice(),
[super::WebSurfaceRuntimeFrame::Ready { tab_id, .. }] if tab_id == tab.id()
));
Ok(())
}
#[test]
fn pending_poll_advances_deadline_under_worker_backpressure() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(slow_client_factory);
let tab = web_tab("Backpressure")?;
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test();
thread::sleep(Duration::from_millis(10));
runtime.tick(std::slice::from_ref(tab.id()));
thread::sleep(Duration::from_millis(10));
runtime.tick(std::slice::from_ref(tab.id()));
let delay = runtime
.next_poll_delay(std::slice::from_ref(tab.id()), std::time::Instant::now())
.ok_or_else(|| "visible tab lost its poll deadline".to_string())?;
assert!(delay > Duration::ZERO, "backpressured poll must not schedule a zero-delay timer");
Ok(())
}
fn web_tab(title: &str) -> Result<BrowserTab, String> {
Ok(BrowserTab::new(
TabId::new(),
SpaceId::new(),
ProfileId::new(),
title,
UrlText::parse("https://example.com/backpressure").map_err(|error| error.to_string())?,
))
}
fn surface_size() -> WebSurfaceSize {
WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 }
}
fn pending_input() -> WebSurfacePendingInput {
WebSurfacePendingInput {
enqueued_at: None,
scroll_offset: WebSurfaceScrollOffset::default(),
scroll_delta: None,
scroll_point: None,
click_point: None,
hover_point: None,
typed_text: None,
}
}
@@ -0,0 +1,19 @@
use crate::services::servo_profile_data::TransientProfileDataDir;
use super::LiveRuntimeWorker;
pub(super) struct ScopedWorker {
pub(super) worker: LiveRuntimeWorker,
pub(super) transient_profile_data_dir: Option<TransientProfileDataDir>,
}
pub(super) fn shutdown_scoped_worker(scoped: ScopedWorker) -> Result<(), String> {
let ScopedWorker { worker, transient_profile_data_dir } = scoped;
drop(worker);
let Some(directory) = transient_profile_data_dir else {
return Ok(());
};
directory
.close()
.map_err(|error| format!("failed to remove transient Servo profile data: {error}"))
}
@@ -0,0 +1,173 @@
use std::{
path::PathBuf,
sync::{
Mutex,
atomic::{AtomicUsize, Ordering},
mpsc,
},
time::Duration,
};
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use crate::{
services::{
ProfileDataMode,
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
},
shell::{
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
web_surface_state::WebSurfacePendingInput,
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
},
};
use super::WebSurfaceRuntime;
static FACTORY_CALLS: AtomicUsize = AtomicUsize::new(0);
static DROP_SETUP: Mutex<Option<DropSetup>> = Mutex::new(None);
static FACTORY_PATHS: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
struct DropSetup {
started_tx: mpsc::Sender<()>,
release_rx: mpsc::Receiver<()>,
}
struct BlockingDropClient {
started_tx: mpsc::Sender<()>,
release_rx: mpsc::Receiver<()>,
}
impl LiveRuntimeClient for BlockingDropClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
impl Drop for BlockingDropClient {
fn drop(&mut self) {
let _ = self.started_tx.send(());
let _ = self.release_rx.recv();
}
}
struct EmptyClient;
impl LiveRuntimeClient for EmptyClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
#[test]
fn private_worker_creation_uses_fresh_storage_during_previous_cleanup() -> Result<(), String> {
FACTORY_CALLS.store(0, Ordering::SeqCst);
FACTORY_PATHS.lock().map_err(|_| "factory paths lock was poisoned")?.clear();
let (started_tx, started_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
*DROP_SETUP.lock().map_err(|_| "drop setup lock was poisoned")? =
Some(DropSetup { started_tx, release_rx });
let mut runtime = WebSurfaceRuntime::new_with_client_factory(cleanup_client_factory);
let first = web_tab(ProfileId::new())?;
let second = web_tab(ProfileId::new())?;
runtime.ensure_tab(&first, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test();
runtime.close_tab(first.id());
started_rx
.recv_timeout(Duration::from_secs(2))
.map_err(|error| format!("cleanup did not start: {error}"))?;
runtime.ensure_tab(
&second,
surface_size(),
ProfileDataMode::Transient,
&[],
pending_input(),
)?;
let deadline = std::time::Instant::now() + Duration::from_secs(2);
while FACTORY_CALLS.load(Ordering::SeqCst) < 2 && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(2));
}
assert_eq!(FACTORY_CALLS.load(Ordering::SeqCst), 2);
let paths = FACTORY_PATHS.lock().map_err(|_| "factory paths lock was poisoned")?.clone();
assert_eq!(paths.len(), 2);
assert_ne!(paths[0], paths[1]);
assert!(paths.iter().all(|path| path.is_dir()));
release_tx.send(()).map_err(|error| error.to_string())?;
runtime.flush_for_test();
assert!(!paths[0].exists());
assert!(paths[1].is_dir());
Ok(())
}
fn cleanup_client_factory(
config_dir: std::path::PathBuf,
) -> Result<Box<dyn LiveRuntimeClient>, String> {
FACTORY_PATHS
.lock()
.map_err(|_| "factory paths lock was poisoned".to_string())?
.push(config_dir);
if FACTORY_CALLS.fetch_add(1, Ordering::SeqCst) == 0 {
let setup = DROP_SETUP
.lock()
.map_err(|_| "drop setup lock was poisoned".to_string())?
.take()
.ok_or_else(|| "drop setup was missing".to_string())?;
Ok(Box::new(BlockingDropClient {
started_tx: setup.started_tx,
release_rx: setup.release_rx,
}))
} else {
Ok(Box::new(EmptyClient))
}
}
fn web_tab(profile_id: ProfileId) -> Result<BrowserTab, String> {
Ok(BrowserTab::new(
TabId::new(),
SpaceId::new(),
profile_id,
"Private",
UrlText::parse("https://example.com/private").map_err(|error| error.to_string())?,
))
}
fn surface_size() -> WebSurfaceSize {
WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 }
}
fn pending_input() -> WebSurfacePendingInput {
WebSurfacePendingInput {
enqueued_at: None,
scroll_offset: WebSurfaceScrollOffset::default(),
scroll_delta: None,
scroll_point: None,
click_point: None,
hover_point: None,
typed_text: None,
}
}
@@ -0,0 +1,323 @@
use std::{
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
time::Instant,
};
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use crate::services::{
ProfileDataMode,
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
};
use super::{
super::{
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
web_surface_state::WebSurfacePendingInput,
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError, WorkerResponse},
},
WebSurfaceRuntime, WebSurfaceRuntimeFrame, WebSurfaceRuntimeScope,
};
struct EmptyClient;
static FAILING_FACTORY_CALLS: AtomicUsize = AtomicUsize::new(0);
static FAILING_FACTORY_SHUTDOWNS: AtomicUsize = AtomicUsize::new(0);
impl LiveRuntimeClient for EmptyClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
struct CloseRecordingClient;
impl LiveRuntimeClient for CloseRecordingClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
impl Drop for CloseRecordingClient {
fn drop(&mut self) {
FAILING_FACTORY_SHUTDOWNS.fetch_add(1, Ordering::SeqCst);
}
}
#[test]
fn late_frame_from_a_is_discarded_after_a_to_b() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
let tab_id = TabId::new();
let profile_a = ProfileId::new();
let profile_b = ProfileId::new();
let scope_a = scope(&profile_a);
let tab_a = web_tab(tab_id.clone(), profile_a, "https://example.com/a")?;
let tab_b = web_tab(tab_id.clone(), profile_b, "https://example.com/b")?;
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
let generation_a = runtime
.sessions
.get(&tab_id)
.and_then(|session| session.generation)
.ok_or_else(|| "profile A ensure generation was missing".to_string())?;
runtime.ensure_tab(&tab_b, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
let generation_b = runtime
.sessions
.get(&tab_id)
.and_then(|session| session.generation)
.ok_or_else(|| "profile B ensure generation was missing".to_string())?;
let mut frames = Vec::new();
runtime.collect_responses(
&scope_a,
vec![WorkerResponse::Frame {
generation: generation_a,
tab_id: tab_id.as_str().to_string(),
frame: live_frame(),
}],
Instant::now(),
&mut frames,
);
assert!(generation_a < generation_b);
assert!(frames.is_empty());
Ok(())
}
#[test]
fn generation_blocks_aba_frames_and_failures() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
let tab_id = TabId::new();
let profile_a = ProfileId::new();
let profile_b = ProfileId::new();
let scope_a = scope(&profile_a);
let tab_a = web_tab(tab_id.clone(), profile_a, "https://example.com/a")?;
let tab_b = web_tab(tab_id.clone(), profile_b, "https://example.com/b")?;
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
let first_a = current_generation(&runtime, &tab_id)?;
runtime.ensure_tab(&tab_b, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
let generation_b = current_generation(&runtime, &tab_id)?;
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
let second_a = current_generation(&runtime, &tab_id)?;
let mut frames = Vec::new();
runtime.collect_responses(
&scope_a,
vec![
WorkerResponse::Frame {
generation: first_a,
tab_id: tab_id.as_str().to_string(),
frame: live_frame(),
},
WorkerResponse::Failed {
generation: first_a,
tab_id: tab_id.as_str().to_string(),
message: "stale failure".to_string(),
},
],
Instant::now(),
&mut frames,
);
assert!(frames.is_empty());
runtime.collect_responses(
&scope_a,
vec![WorkerResponse::Frame {
generation: second_a,
tab_id: tab_id.as_str().to_string(),
frame: live_frame(),
}],
Instant::now(),
&mut frames,
);
assert!(first_a < generation_b && generation_b < second_a);
assert!(matches!(
frames.as_slice(),
[WebSurfaceRuntimeFrame::Ready { tab_id: ready_tab_id, .. }] if ready_tab_id == &tab_id
));
Ok(())
}
#[test]
fn scope_creation_failure_invalidates_previous_session_generation() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
let tab_id = TabId::new();
let profile_a = ProfileId::new();
let profile_b = ProfileId::new();
let scope_a = scope(&profile_a);
let scope_b = scope(&profile_b);
let tab_a = web_tab(tab_id.clone(), profile_a, "https://example.com/a")?;
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
let generation_a = current_generation(&runtime, &tab_id)?;
runtime.retry_state.insert(
scope_b,
super::ScopeRetryState {
failure_count: 1,
retry_after: Instant::now() + std::time::Duration::from_secs(1),
},
);
assert!(runtime.prepare_tab_scope(&tab_id, &profile_b, ProfileDataMode::Transient).is_err());
assert!(!runtime.sessions.contains_key(&tab_id));
let mut frames = Vec::new();
runtime.collect_responses(
&scope_a,
vec![WorkerResponse::Frame {
generation: generation_a,
tab_id: tab_id.as_str().to_string(),
frame: live_frame(),
}],
Instant::now(),
&mut frames,
);
assert!(frames.is_empty());
Ok(())
}
#[test]
fn stale_frame_preserves_scope_backoff_until_current_frame() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
let tab_id = TabId::new();
let profile_id = ProfileId::new();
let scope = scope(&profile_id);
let tab = web_tab(tab_id.clone(), profile_id, "https://example.com/current")?;
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test();
let current = current_generation(&runtime, &tab_id)?;
runtime.note_scope_failure(&scope, Instant::now());
let mut frames = Vec::new();
runtime.collect_responses(
&scope,
vec![WorkerResponse::Frame {
generation: super::super::web_surface_worker::RequestGeneration::new(0),
tab_id: tab_id.as_str().to_string(),
frame: live_frame(),
}],
Instant::now(),
&mut frames,
);
assert!(frames.is_empty());
assert!(runtime.retry_state.contains_key(&scope));
runtime.collect_responses(
&scope,
vec![WorkerResponse::Frame {
generation: current,
tab_id: tab_id.as_str().to_string(),
frame: live_frame(),
}],
Instant::now(),
&mut frames,
);
assert!(matches!(frames.as_slice(), [WebSurfaceRuntimeFrame::Ready { .. }]));
assert!(!runtime.retry_state.contains_key(&scope));
Ok(())
}
#[test]
fn previous_scope_shuts_down_when_new_worker_factory_fails_async() -> Result<(), String> {
FAILING_FACTORY_CALLS.store(0, Ordering::SeqCst);
FAILING_FACTORY_SHUTDOWNS.store(0, Ordering::SeqCst);
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fail_second_client_factory);
let tab_id = TabId::new();
let tab_a = web_tab(tab_id.clone(), ProfileId::new(), "https://example.com/a")?;
let tab_b = web_tab(tab_id, ProfileId::new(), "https://example.com/b")?;
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test();
runtime.ensure_tab(&tab_b, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test();
let frames = runtime.tick(std::slice::from_ref(tab_b.id()));
assert_eq!(FAILING_FACTORY_SHUTDOWNS.load(Ordering::SeqCst), 1);
assert!(matches!(
frames.as_slice(),
[WebSurfaceRuntimeFrame::Failed { message, .. }]
if message == "injected client creation failure"
));
Ok(())
}
fn current_generation(
runtime: &WebSurfaceRuntime,
tab_id: &TabId,
) -> Result<super::super::web_surface_worker::RequestGeneration, String> {
runtime
.sessions
.get(tab_id)
.and_then(|session| session.generation)
.ok_or_else(|| "session generation was missing".to_string())
}
fn empty_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
Ok(Box::new(EmptyClient))
}
fn fail_second_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
if FAILING_FACTORY_CALLS.fetch_add(1, Ordering::SeqCst) == 0 {
Ok(Box::new(CloseRecordingClient))
} else {
Err("injected client creation failure".to_string())
}
}
fn scope(profile_id: &ProfileId) -> WebSurfaceRuntimeScope {
WebSurfaceRuntimeScope::new(profile_id.clone(), ProfileDataMode::Transient)
}
fn web_tab(tab_id: TabId, profile_id: ProfileId, url: &str) -> Result<BrowserTab, String> {
Ok(BrowserTab::new(
tab_id,
SpaceId::new(),
profile_id,
"Web",
UrlText::parse(url).map_err(|error| error.to_string())?,
))
}
fn surface_size() -> WebSurfaceSize {
WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 }
}
fn pending_input() -> WebSurfacePendingInput {
WebSurfacePendingInput {
enqueued_at: None,
scroll_offset: WebSurfaceScrollOffset::default(),
scroll_delta: None,
scroll_point: None,
click_point: None,
hover_point: None,
typed_text: None,
}
}
fn live_frame() -> ServoLiveFrame {
ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])
}
@@ -0,0 +1,77 @@
use std::time::{Duration, Instant};
use ely_domain::{ProfileId, TabId};
use crate::services::{ProfileDataMode, servo_live::ServoLiveFrame};
use super::{
super::{
web_surface_runtime_session::session_for_scope,
web_surface_worker::{RequestGeneration, WorkerResponse},
},
SIDECAR_RESTART_BASE_DELAY, SIDECAR_RESTART_MAX_DELAY, ScopeRetryState, WebSurfaceRuntime,
WebSurfaceRuntimeScope,
};
#[test]
fn retry_delay_grows_exponentially_and_caps() {
let now = Instant::now();
let expected = [
Duration::from_millis(250),
Duration::from_millis(500),
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
SIDECAR_RESTART_MAX_DELAY,
SIDECAR_RESTART_MAX_DELAY,
];
let mut state = None;
for delay in expected {
let next = ScopeRetryState::after_failure(state.as_ref(), now);
assert_eq!(next.retry_after.duration_since(now), delay);
state = Some(next);
}
assert_eq!(SIDECAR_RESTART_BASE_DELAY, Duration::from_millis(250));
}
#[test]
fn retry_state_is_scoped() {
let mut runtime = WebSurfaceRuntime::new();
let scope_a = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
let scope_b = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
let now = Instant::now();
runtime.note_scope_failure(&scope_a, now);
runtime.note_scope_failure(&scope_a, now);
runtime.note_scope_failure(&scope_b, now);
assert_eq!(runtime.retry_state.get(&scope_a).map(|state| state.failure_count), Some(2));
assert_eq!(runtime.retry_state.get(&scope_b).map(|state| state.failure_count), Some(1));
assert_eq!(runtime.retry_state.get(&scope_b).map(|state| state.failure_count), Some(1));
}
#[test]
fn successful_frame_resets_scope_retry_state() {
let mut runtime = WebSurfaceRuntime::new();
let scope = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
let tab_id = TabId::new();
let generation = RequestGeneration::new(1);
session_for_scope(&mut runtime.sessions, &tab_id, scope.clone()).generation = Some(generation);
runtime.note_scope_failure(&scope, Instant::now());
let mut frames = Vec::new();
runtime.collect_responses(
&scope,
vec![WorkerResponse::Frame {
generation,
tab_id: tab_id.as_str().to_string(),
frame: ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255]),
}],
Instant::now(),
&mut frames,
);
assert!(!runtime.retry_state.contains_key(&scope));
assert_eq!(frames.len(), 1);
}
@@ -1,16 +1,19 @@
use std::{collections::BTreeMap, fs, path::PathBuf};
use ely_domain::{ProfileId, TabId};
use std::{collections::BTreeMap, path::PathBuf};
use crate::services::{
ProfileDataMode,
servo_profile_data::{default_profile_data_root, profile_data_dir, transient_profile_data_dir},
servo_profile_data::{
TransientProfileDataDir, create_profile_data_dir, default_profile_data_root,
transient_profile_data_dir,
},
};
use ely_domain::{ProfileId, TabId};
use super::{
web_surface_cadence::WebSurfacePollCadence,
web_surface_frame::WebSurfaceFrame,
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
web_surface_worker::RequestGeneration,
};
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
@@ -23,6 +26,10 @@ impl WebSurfaceRuntimeScope {
pub(super) fn new(profile_id: ProfileId, profile_data_mode: ProfileDataMode) -> Self {
Self { profile_id, profile_data_mode }
}
pub(super) fn is_transient(&self) -> bool {
self.profile_data_mode == ProfileDataMode::Transient
}
}
#[derive(Clone)]
@@ -33,6 +40,7 @@ pub(super) struct WebSurfaceSession {
pub(super) zoom_percent: u16,
pub(super) scroll_offset: WebSurfaceScrollOffset,
pub(super) pending_user_navigation: bool,
pub(super) generation: Option<RequestGeneration>,
pub(super) cadence: WebSurfacePollCadence,
}
@@ -45,6 +53,7 @@ impl WebSurfaceSession {
zoom_percent: 0,
scroll_offset: WebSurfaceScrollOffset::default(),
pending_user_navigation: false,
generation: None,
cadence: WebSurfacePollCadence::default(),
}
}
@@ -110,20 +119,20 @@ pub(super) enum WebSurfaceUrlChangeKind {
pub(super) fn config_dir_for_scope(
scope: &WebSurfaceRuntimeScope,
) -> Result<(PathBuf, Option<PathBuf>), String> {
) -> Result<(PathBuf, Option<TransientProfileDataDir>), String> {
match scope.profile_data_mode {
ProfileDataMode::Persistent => {
let root = default_profile_data_root()
.ok_or_else(|| "Profile data root is unavailable".to_string())?;
let config_dir = profile_data_dir(&root, &scope.profile_id);
fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?;
let config_dir = create_profile_data_dir(&root, &scope.profile_id)
.map_err(|error| error.to_string())?;
Ok((config_dir, None))
}
ProfileDataMode::Transient => {
let config_dir =
let transient_dir =
transient_profile_data_dir(&scope.profile_id).map_err(|error| error.to_string())?;
fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?;
Ok((config_dir.clone(), Some(config_dir)))
let config_dir = transient_dir.path().to_path_buf();
Ok((config_dir, Some(transient_dir)))
}
}
}
@@ -1,6 +1,9 @@
use std::{
collections::BTreeMap,
sync::atomic::{AtomicUsize, Ordering},
sync::{
Mutex,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
@@ -21,15 +24,28 @@ use super::*;
use crate::services::servo_live::{ServoLiveEnsureRequest, ServoLiveFrame};
static FAKE_CLOSE_COUNT: AtomicUsize = AtomicUsize::new(0);
static FAKE_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
static IDLE_SKIP_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
static RECOVERY_FACTORY_COUNT: AtomicUsize = AtomicUsize::new(0);
static FAILING_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
static REPEATED_FRAME_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
static CLOSE_CONFIG_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
#[test]
fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), String> {
fn transient_scope_owns_removable_servo_storage() -> Result<(), String> {
let scope = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
let (config_dir, guard) = config_dir_for_scope(&scope)?;
let Some(guard) = guard else {
return Err("transient scope did not return a storage guard".to_string());
};
assert_eq!(guard.path(), config_dir);
guard.close().map_err(|error| error.to_string())?;
assert!(!config_dir.exists());
Ok(())
}
#[test]
fn runtime_routes_profile_scopes_to_distinct_clients() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fake_client_factory);
let first_profile = ProfileId::new();
let second_profile = ProfileId::new();
@@ -60,7 +76,7 @@ fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), Stri
runtime.flush_for_test();
assert_eq!(runtime.client_count_for_test(), 1);
assert_eq!(runtime.client_count_for_test(), 2);
assert_eq!(
runtime.session_scope_for_test(first_tab.id()),
Some(&WebSurfaceRuntimeScope::new(first_profile, ProfileDataMode::Transient)),
@@ -73,22 +89,64 @@ fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), Stri
}
#[test]
fn close_tab_removes_session_and_closes_client() -> Result<(), String> {
let before = FAKE_CLOSE_COUNT.load(Ordering::SeqCst);
fn delayed_frame_from_previous_profile_scope_is_ignored() -> Result<(), String> {
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fake_client_factory);
let tab = web_tab(TabId::new(), ProfileId::new(), "https://example.com/close")?;
let tab_id = TabId::new();
let first_profile = ProfileId::new();
let second_profile = ProfileId::new();
let first_scope =
WebSurfaceRuntimeScope::new(first_profile.clone(), ProfileDataMode::Transient);
let second_scope = WebSurfaceRuntimeScope::new(second_profile, ProfileDataMode::Transient);
let first_tab = web_tab(tab_id.clone(), first_profile, "https://example.com/shared")?;
runtime.ensure_tab(
&first_tab,
surface_size(),
ProfileDataMode::Transient,
&[],
pending_input(),
)?;
runtime.flush_for_test();
session_for_scope(&mut runtime.sessions, &tab_id, second_scope);
let mut frames = Vec::new();
let unavailable = runtime.collect_responses(
&first_scope,
vec![WorkerResponse::Frame {
generation: RequestGeneration::new(1),
tab_id: tab_id.as_str().to_string(),
frame: repeated_live_frame(),
}],
std::time::Instant::now(),
&mut frames,
);
assert!(!unavailable);
assert!(frames.is_empty());
Ok(())
}
#[test]
fn close_last_transient_tab_removes_session_worker_and_profile_data() -> Result<(), String> {
*CLOSE_CONFIG_DIR.lock().map_err(|_| "close config lock was poisoned")? = None;
let mut runtime = WebSurfaceRuntime::new_with_client_factory(close_client_factory);
let tab = web_tab(TabId::new(), ProfileId::new(), "https://example.com/close")?;
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
runtime.flush_for_test();
let config_dir = CLOSE_CONFIG_DIR
.lock()
.map_err(|_| "close config lock was poisoned")?
.clone()
.ok_or_else(|| "close client config directory was missing".to_string())?;
assert!(config_dir.is_dir());
runtime.close_tab(tab.id());
runtime.flush_for_test();
assert_eq!(runtime.session_scope_for_test(tab.id()), None);
assert_eq!(FAKE_CLOSE_COUNT.load(Ordering::SeqCst), before + 1);
assert_eq!(runtime.client_count_for_test(), 0);
assert!(!config_dir.exists());
runtime.close_tab(tab.id());
runtime.flush_for_test();
assert_eq!(FAKE_CLOSE_COUNT.load(Ordering::SeqCst), before + 1);
assert_eq!(runtime.client_count_for_test(), 0);
Ok(())
}
@@ -207,6 +265,7 @@ fn runtime_unavailable_removes_dead_runtime_client() -> Result<(), String> {
assert_eq!(runtime.client_count_for_test(), 0);
let next_tab = web_tab(TabId::new(), profile, "https://example.com/next")?;
std::thread::sleep(super::SIDECAR_RESTART_BASE_DELAY + Duration::from_millis(10));
runtime.ensure_tab(
&next_tab,
surface_size(),
@@ -222,7 +281,7 @@ fn runtime_unavailable_removes_dead_runtime_client() -> Result<(), String> {
}
#[test]
fn failed_surface_ensure_waits_for_a_new_key_before_retrying() -> Result<(), String> {
fn failed_surface_ensure_retries_same_key_after_cooldown() -> Result<(), String> {
FAILING_ENSURE_COUNT.store(0, Ordering::SeqCst);
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
failing_client_factory,
@@ -239,21 +298,7 @@ fn failed_surface_ensure_waits_for_a_new_key_before_retrying() -> Result<(), Str
assert!(tick.changed, "the failing client must surface a state change via tick");
assert_eq!(FAILING_ENSURE_COUNT.load(Ordering::SeqCst), 1);
assert!(!store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
store.flush_runtime_for_test();
let _ = store.tick(&[tab.id().clone()]);
assert_eq!(FAILING_ENSURE_COUNT.load(Ordering::SeqCst), 1);
assert_eq!(
store.record_viewport_size(tab.id(), resized_viewport_bounds(), 1.0),
WebSurfaceInputOutcome::Applied,
);
// `record_viewport_size` stamps the viewport-resize debounce on a
// genuine size transition; in production the poll cadence retries
// `ensure_surface` once the gesture settles, but a synchronous
// unit test can't advance the clock — clear the timestamp so we
// exercise the retry-on-new-key business rule in isolation.
store.clear_viewport_resize_debounce_for_test(tab.id());
std::thread::sleep(super::SIDECAR_RESTART_BASE_DELAY + Duration::from_millis(10));
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
store.flush_runtime_for_test();
let _ = store.tick(&[tab.id().clone()]);
@@ -305,7 +350,6 @@ impl LiveRuntimeClient for FakeLiveRuntimeClient {
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
FAKE_CLOSE_COUNT.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
@@ -387,6 +431,14 @@ fn fake_client_factory(
Ok(Box::new(FakeLiveRuntimeClient))
}
fn close_client_factory(
config_dir: std::path::PathBuf,
) -> Result<Box<dyn LiveRuntimeClient>, String> {
*CLOSE_CONFIG_DIR.lock().map_err(|_| "close config lock was poisoned".to_string())? =
Some(config_dir);
Ok(Box::new(FakeLiveRuntimeClient))
}
fn idle_skip_client_factory(
_config_dir: std::path::PathBuf,
) -> Result<Box<dyn LiveRuntimeClient>, String> {
@@ -435,13 +487,6 @@ fn viewport_bounds() -> gpui::Bounds<gpui::Pixels> {
)
}
fn resized_viewport_bounds() -> gpui::Bounds<gpui::Pixels> {
gpui::Bounds::new(
gpui::point(gpui::px(0.0), gpui::px(0.0)),
gpui::size(gpui::px(720.0), gpui::px(480.0)),
)
}
fn pending_input() -> WebSurfacePendingInput {
WebSurfacePendingInput {
enqueued_at: None,
@@ -0,0 +1,234 @@
use std::{error::Error, sync::Mutex};
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
use gpui::{Bounds, point, px, size};
use crate::services::{
ProfileDataMode,
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
};
use super::super::{
web_surface_frame::WebSurfaceFrame,
web_surface_geometry::WebSurfaceScrollOffset,
web_surface_runtime::{
WebSurfaceRuntime, WebSurfaceRuntimeFrame, WebSurfaceUrlChange, WebSurfaceUrlChangeKind,
},
web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState},
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
};
use super::WebSurfaceStore;
static ENSURES: Mutex<Vec<RecordedEnsure>> = Mutex::new(Vec::new());
#[derive(Debug)]
struct RecordedEnsure {
profile_id: String,
had_input: bool,
}
struct RecordingClient;
impl LiveRuntimeClient for RecordingClient {
fn ensure(
&mut self,
request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
ENSURES.lock().map_err(|_| "ensure recorder lock was poisoned".to_string())?.push(
RecordedEnsure {
profile_id: request.profile_id,
had_input: request.scroll_delta_x != 0
|| request.scroll_delta_y != 0
|| request.click_x.is_some()
|| request.click_y.is_some()
|| request.hover_x.is_some()
|| request.hover_y.is_some()
|| request.typed_text.is_some(),
},
);
Ok(None)
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
fn recording_client_factory(
_config_dir: std::path::PathBuf,
) -> Result<Box<dyn LiveRuntimeClient>, String> {
Ok(Box::new(RecordingClient))
}
#[test]
fn profile_scope_change_clears_pixels_input_and_focus_before_ensure() -> Result<(), Box<dyn Error>>
{
ENSURES.lock().map_err(|_| "ensure recorder lock was poisoned")?.clear();
let runtime = WebSurfaceRuntime::new_with_client_factory(recording_client_factory);
let mut store = WebSurfaceStore::new_with_runtime(runtime);
let tab_id = TabId::new();
let url = UrlText::parse("https://example.com/shared")?;
let profile_a = ProfileId::new();
let profile_b = ProfileId::new();
let tab_a =
BrowserTab::new(tab_id.clone(), SpaceId::new(), profile_a.clone(), "Shared", url.clone());
let tab_b = BrowserTab::new(tab_id, SpaceId::new(), profile_b.clone(), "Shared", url);
assert_eq!(
store.record_viewport_size(tab_a.id(), viewport_bounds(), 1.0),
WebSurfaceInputOutcome::Applied,
);
assert!(store.ensure_surface(&tab_a, ProfileDataMode::Transient, &[]));
store.flush_runtime_for_test();
let frame = WebSurfaceFrame::from_live_frame(
tab_a.url().as_str().to_string(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test(1, 1, vec![0, 0, 0, 255]),
)?;
store.surface_mut(tab_a.id()).state = Some(WebSurfaceState::Ready(frame));
assert!(
store.state_for_scope(tab_a.id(), &profile_b, ProfileDataMode::Transient).is_none(),
"a model scope change must hide old pixels before the next ensure",
);
assert_eq!(
store
.record_click_point(tab_a.id(), tab_a.url().as_str(), point(px(120.0), px(80.0)), 1.0,),
WebSurfaceInputOutcome::Applied,
);
assert_eq!(
store.record_typed_text(tab_a.id(), tab_a.url().as_str(), "secret"),
WebSurfaceInputOutcome::Applied,
);
assert!(store.ensure_surface(&tab_b, ProfileDataMode::Transient, &[]));
store.flush_runtime_for_test();
assert!(store.keyboard_focus.is_none());
assert!(matches!(
store.state(tab_b.id()),
Some(WebSurfaceState::Loading { previous_frame: None, .. })
));
let ensures = ENSURES.lock().map_err(|_| "ensure recorder lock was poisoned")?;
assert_eq!(ensures.len(), 2);
assert_eq!(ensures[0].profile_id, profile_a.as_str());
assert_eq!(ensures[1].profile_id, profile_b.as_str());
assert!(!ensures[1].had_input);
Ok(())
}
fn viewport_bounds() -> Bounds<gpui::Pixels> {
Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)))
}
#[test]
fn navigation_holds_loading_white_frame_and_accepts_complete_white_page()
-> Result<(), Box<dyn Error>> {
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
recording_client_factory,
));
let tab_id = TabId::new();
let requested_url = "https://example.com/white".to_string();
let previous = WebSurfaceFrame::from_live_frame(
requested_url.clone(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test(1, 1, vec![0, 0, 0, 255]),
)?;
store.surface_mut(&tab_id).state = Some(WebSurfaceState::Loading {
requested_url: requested_url.clone(),
previous_frame: Some(previous),
});
let loading_white = WebSurfaceFrame::from_live_frame(
requested_url.clone(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_render_state(1, 1, vec![255; 4], "loading"),
)?;
let complete_white = WebSurfaceFrame::from_live_frame(
requested_url,
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_render_state(1, 1, vec![255; 4], "complete"),
)?;
assert!(store.should_hold_initial_frame(&tab_id, &loading_white, false));
assert!(!store.should_hold_initial_frame(&tab_id, &complete_white, false));
Ok(())
}
#[test]
fn progressive_loading_frame_replaces_an_earlier_ready_frame() -> Result<(), Box<dyn Error>> {
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
recording_client_factory,
));
let tab_id = TabId::new();
let requested_url = "https://example.com/progressive".to_string();
let first_loading = WebSurfaceFrame::from_live_frame(
requested_url.clone(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_render_state(1, 1, vec![16, 32, 64, 255], "loading"),
)?;
store.surface_mut(&tab_id).state = Some(WebSurfaceState::Ready(first_loading));
let next_loading = WebSurfaceFrame::from_live_frame(
requested_url,
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_render_state(1, 1, vec![64, 32, 16, 255], "loading"),
)?;
assert!(!store.should_hold_initial_frame(&tab_id, &next_loading, true));
Ok(())
}
#[test]
fn held_loading_frame_preserves_previous_pixels_and_delivers_metadata() -> Result<(), Box<dyn Error>>
{
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
recording_client_factory,
));
let tab_id = TabId::new();
let requested_url = "https://example.com/held".to_string();
let previous = WebSurfaceFrame::from_live_frame(
requested_url.clone(),
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_title(1, 1, vec![0, 0, 0, 255], "complete", "Previous"),
)?;
store.surface_mut(&tab_id).state = Some(WebSurfaceState::Loading {
requested_url: requested_url.clone(),
previous_frame: Some(previous),
});
let held = WebSurfaceFrame::from_live_frame(
requested_url,
WebSurfaceScrollOffset::default(),
100,
ServoLiveFrame::for_test_with_title(1, 1, vec![255; 4], "loading", "Updated"),
)?;
let result = store.apply_runtime_frames(vec![WebSurfaceRuntimeFrame::Ready {
tab_id: tab_id.clone(),
frame: Box::new(held),
url_change: Some(WebSurfaceUrlChange {
tab_id: tab_id.clone(),
loaded_url: "https://example.com/redirected".to_string(),
kind: WebSurfaceUrlChangeKind::Observed,
}),
}]);
assert!(matches!(
store.state(&tab_id),
Some(WebSurfaceState::Loading { previous_frame: Some(frame), .. })
if frame.title() == Some("Previous")
));
assert!(matches!(
result.page_metadata.as_slice(),
[metadata] if metadata.title.as_deref() == Some("Updated")
));
assert_eq!(result.url_changes.len(), 1);
Ok(())
}
+83 -24
View File
@@ -1,7 +1,9 @@
use std::time::{Duration, Instant};
use ely_domain::TabId;
use gpui::{Bounds, NativeSurfaceHandle, Pixels};
use ely_domain::{ProfileId, TabId};
use gpui::{Bounds, Pixels};
use crate::services::ProfileDataMode;
use super::{
web_surface_cadence::ACTIVE_POLL_INTERVAL,
@@ -133,7 +135,6 @@ pub(super) struct PerTabSurface {
/// every GPUI paint does not translate into a Servo framebuffer
/// destroy/recreate every frame (the "page flashing" symptom).
viewport_size_changed_at: Option<Instant>,
pub(super) native_surface: Option<NativeSurfaceHandle>,
pub(super) last_ensure_key: Option<WebSurfaceEnsureKey>,
pub(super) hover_point: Option<WebSurfaceClickPoint>,
last_hover_enqueued_at: Option<Instant>,
@@ -154,7 +155,6 @@ impl PerTabSurface {
viewport_bounds: None,
viewport_size: None,
viewport_size_changed_at: None,
native_surface: None,
last_ensure_key: None,
hover_point: None,
last_hover_enqueued_at: None,
@@ -196,6 +196,39 @@ impl PerTabSurface {
self.last_ensure_key.as_ref() != Some(key) || self.has_pending_input()
}
pub(super) fn has_scope(
&self,
profile_id: &ProfileId,
profile_data_mode: ProfileDataMode,
) -> bool {
self.last_ensure_key.as_ref().is_some_and(|key| {
key.profile_id == *profile_id && key.profile_data_mode == profile_data_mode
})
}
pub(super) fn reset_for_scope_change(&mut self, key: &WebSurfaceEnsureKey) -> bool {
let Some(previous_key) = self.last_ensure_key.as_ref() else {
return false;
};
if previous_key.has_same_scope(key) {
return false;
}
self.last_ensure_key = None;
self.hover_point = None;
self.last_hover_enqueued_at = None;
self.click_point = None;
self.pending_scroll_delta = None;
self.pending_scroll_point = None;
self.pending_input_started_at = None;
self.scroll_offset = None;
self.typed_text = None;
self.state = None;
self.last_input_flushed_at = None;
self.metadata_tracker = WebSurfaceMetadataTracker::default();
true
}
/// True when the viewport bounds have changed within the last
/// [`VIEWPORT_RESIZE_DEBOUNCE`] window. The renderer treats this
/// as "the user is mid-animation" and holds off telling Servo to
@@ -217,23 +250,13 @@ impl PerTabSurface {
self.viewport_size_changed_at = Some(now);
}
/// Test-only escape hatch: simulate the trailing edge of a resize
/// animation by clearing the debounce timestamp, so a synchronous
/// `record + ensure` sequence in tests behaves as if the gesture
/// has fully settled. Production code drives this via the poll
/// cadence and time elapsing — tests can't advance an `Instant`.
#[cfg(test)]
pub(super) fn clear_viewport_resize_debounce_for_test(&mut self) {
self.viewport_size_changed_at = None;
}
pub(super) fn mark_ensured(&mut self, key: WebSurfaceEnsureKey) {
self.last_ensure_key = Some(key);
}
pub(super) fn matches_ready(&self, frame: &WebSurfaceFrame) -> bool {
match self.state.as_ref() {
Some(WebSurfaceState::Ready(current)) => current.has_same_software_render_as(frame),
Some(WebSurfaceState::Ready(current)) => current.has_same_render_as(frame),
_ => false,
}
}
@@ -275,7 +298,8 @@ const VIEWPORT_RESIZE_DEBOUNCE: Duration = Duration::from_millis(80);
pub(super) struct WebSurfaceEnsureKey {
requested_url: String,
size: WebSurfaceSize,
native_surface_id: Option<usize>,
profile_id: ProfileId,
profile_data_mode: ProfileDataMode,
zoom_percent: u16,
permissions: Vec<WebSurfaceSitePermission>,
}
@@ -284,18 +308,24 @@ impl WebSurfaceEnsureKey {
pub(super) fn new(
requested_url: String,
size: WebSurfaceSize,
native_surface: Option<&NativeSurfaceHandle>,
profile_id: ProfileId,
profile_data_mode: ProfileDataMode,
zoom_percent: u16,
permissions: &[WebSurfaceSitePermission],
) -> Self {
Self {
requested_url,
size,
native_surface_id: native_surface.map(NativeSurfaceHandle::identity),
profile_id,
profile_data_mode,
zoom_percent,
permissions: permissions.to_vec(),
}
}
fn has_same_scope(&self, other: &Self) -> bool {
self.profile_id == other.profile_id && self.profile_data_mode == other.profile_data_mode
}
}
#[cfg(test)]
@@ -306,7 +336,7 @@ mod tests {
#[test]
fn unchanged_surface_without_input_skips_ensure() {
let key = ensure_key("https://example.com/", 800, 600);
let key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
let mut surface = PerTabSurface::new();
assert!(surface.should_ensure(&key));
@@ -318,7 +348,7 @@ mod tests {
#[test]
fn pending_input_forces_ensure_even_when_key_matches() {
let key = ensure_key("https://example.com/", 800, 600);
let key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
let mut surface = PerTabSurface::new();
surface.mark_ensured(key.clone());
surface.pending_scroll_delta =
@@ -329,8 +359,31 @@ mod tests {
#[test]
fn viewport_change_forces_ensure() {
let old_key = ensure_key("https://example.com/", 800, 600);
let new_key = ensure_key("https://example.com/", 1024, 768);
let profile_id = ProfileId::new();
let old_key = ensure_key("https://example.com/", 800, 600, &profile_id);
let new_key = ensure_key("https://example.com/", 1024, 768, &profile_id);
let mut surface = PerTabSurface::new();
surface.mark_ensured(old_key);
assert!(surface.should_ensure(&new_key));
}
#[test]
fn profile_change_forces_ensure() {
let old_key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
let new_key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
let mut surface = PerTabSurface::new();
surface.mark_ensured(old_key);
assert!(surface.should_ensure(&new_key));
}
#[test]
fn profile_data_mode_change_forces_ensure() {
let profile_id = ProfileId::new();
let old_key = ensure_key("https://example.com/", 800, 600, &profile_id);
let mut new_key = old_key.clone();
new_key.profile_data_mode = ProfileDataMode::Transient;
let mut surface = PerTabSurface::new();
surface.mark_ensured(old_key);
@@ -348,11 +401,17 @@ mod tests {
assert!(!surface.input_flush_is_throttled(start + Duration::from_millis(8)));
}
fn ensure_key(url: &str, width: u32, height: u32) -> WebSurfaceEnsureKey {
fn ensure_key(
url: &str,
width: u32,
height: u32,
profile_id: &ProfileId,
) -> WebSurfaceEnsureKey {
WebSurfaceEnsureKey::new(
url.to_string(),
WebSurfaceSize { width, height, device_pixel_ratio_percent: 100 },
None,
profile_id.clone(),
ProfileDataMode::Persistent,
100,
&[],
)
+42 -30
View File
@@ -1,8 +1,10 @@
use ely_domain::{BrowserTab, TabId};
use gpui::{
AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, Styled, Window, canvas, div, native_surface, px, rgb,
AnyElement, App, Entity, ImageSource, InteractiveElement, IntoElement, MouseButton, ObjectFit,
ParentElement, Pixels, Styled, StyledImage, Window, canvas, div, img, px, rgb,
};
#[cfg(target_os = "macos")]
use gpui::{Corners, SurfaceLease, surface};
use super::{
ElyShell, web_surface_frame::WebSurfaceFrame,
@@ -11,15 +13,44 @@ use super::{
use ely_design_system::colors;
pub(super) fn render_ready_web_surface(
_frame: &WebSurfaceFrame,
frame: &WebSurfaceFrame,
tab: &BrowserTab,
state_entity: Entity<ElyShell>,
bottom_corner_radius: Pixels,
) -> AnyElement {
#[cfg(target_os = "macos")]
if let Some(hardware_surface) = frame.hardware_surface.as_ref() {
return render_web_surface(
tab,
state_entity,
surface(hardware_surface.pixel_buffer().clone())
.lease(SurfaceLease::from_arc(hardware_surface.clone()))
.size_full()
.corner_radii(Corners {
top_left: px(0.0),
top_right: px(0.0),
bottom_left: bottom_corner_radius,
bottom_right: bottom_corner_radius,
})
.object_fit(ObjectFit::Fill),
);
}
let Some(image) = frame.image.as_ref() else {
return render_web_surface(
tab,
state_entity,
error_page("Web surface frame did not include renderable pixels."),
);
};
render_web_surface(
tab,
state_entity.clone(),
render_native_web_surface(tab, state_entity, bottom_corner_radius),
state_entity,
div()
.size_full()
.overflow_hidden()
.rounded_bl(bottom_corner_radius)
.rounded_br(bottom_corner_radius)
.child(img(ImageSource::Render(image.clone())).size_full().object_fit(ObjectFit::Fill)),
)
}
@@ -30,8 +61,12 @@ pub(super) fn render_loading_web_surface(
) -> AnyElement {
render_web_surface(
tab,
state_entity.clone(),
render_native_web_surface(tab, state_entity, bottom_corner_radius),
state_entity,
div()
.size_full()
.overflow_hidden()
.rounded_bl(bottom_corner_radius)
.rounded_br(bottom_corner_radius),
)
}
@@ -85,29 +120,6 @@ fn render_web_surface(
.into_any_element()
}
fn render_native_web_surface(
tab: &BrowserTab,
state_entity: Entity<ElyShell>,
bottom_corner_radius: Pixels,
) -> impl IntoElement {
let tab_id = tab.id().clone();
let element_id = ElementId::Name(format!("web-surface-{}", tab_id.as_str()).into());
// `bottom_corner_radius` is wired through the GPUI `native_surface`
// patch to the AppKit overlay's `CALayer.cornerRadius`, so the
// canvas follows the same rounded edge as its containing panel
// instead of painting past it. The top corners stay flat because
// the topbar / pane header sits flush above the canvas.
native_surface(element_id, move |surface, bounds, window: &mut Window, cx: &mut App| {
let scale_factor = window.scale_factor();
state_entity.update(cx, |shell, cx| {
shell.record_external_web_surface(tab_id.clone(), bounds, scale_factor, surface, cx);
});
})
.size_full()
.rounded_bl(bottom_corner_radius)
.rounded_br(bottom_corner_radius)
}
fn render_input_overlay(
tab_id: TabId,
url: String,
+203 -86
View File
@@ -1,5 +1,5 @@
use std::{
collections::BTreeMap,
collections::{BTreeMap, VecDeque},
io,
sync::{Arc, Condvar, Mutex, mpsc},
thread::JoinHandle,
@@ -9,11 +9,10 @@ use crate::services::servo_live::{
ServoLiveClient, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame,
};
/// Blocking surface for the embedded Servo runtime.
/// Blocking transport for one profile-scoped Servo sidecar.
///
/// Production wraps [`ServoLiveClient`] directly; tests substitute a
/// fake. The contract: every call is blocking and may run for tens of
/// milliseconds. Implementations live on the worker thread.
/// Production wraps [`ServoLiveClient`]; tests substitute a fake. Every
/// call may block on sidecar IPC, so implementations live on a worker.
pub(super) trait LiveRuntimeClient {
fn ensure(
&mut self,
@@ -57,7 +56,7 @@ impl LiveRuntimeClientError {
impl std::fmt::Display for LiveRuntimeClientError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::RuntimeUnavailable => formatter.write_str("servo live runtime is unavailable"),
Self::RuntimeUnavailable => formatter.write_str("servo sidecar runtime is unavailable"),
Self::Message(message) => formatter.write_str(message),
}
}
@@ -86,41 +85,57 @@ impl From<io::Error> for LiveRuntimeClientError {
/// Output of a worker request.
pub(super) enum WorkerResponse {
Frame { tab_id: String, frame: ServoLiveFrame },
Failed { tab_id: String, message: String },
Frame { generation: RequestGeneration, tab_id: String, frame: ServoLiveFrame },
Failed { generation: RequestGeneration, tab_id: String, message: String },
RuntimeUnavailable,
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(super) struct RequestGeneration(u64);
impl RequestGeneration {
pub(super) const fn new(value: u64) -> Self {
Self(value)
}
}
enum WorkerRequest {
Ensure(ServoLiveEnsureRequest),
Poll { tab_id: String },
Ensure { generation: RequestGeneration, request: ServoLiveEnsureRequest },
Poll { generation: RequestGeneration, tab_id: String },
}
impl WorkerRequest {
fn tab_id(&self) -> &str {
match self {
Self::Ensure { request, .. } => request.tab_id.as_str(),
Self::Poll { tab_id, .. } => tab_id.as_str(),
}
}
fn failure_parts(self) -> (RequestGeneration, String) {
match self {
Self::Ensure { generation, request } => (generation, request.tab_id),
Self::Poll { generation, tab_id } => (generation, tab_id),
}
}
}
struct WorkerQueue {
/// Latest request per tab. A new submission for a tab replaces any
/// earlier in-flight-but-not-yet-started request, so a flurry of
/// scrolls never piles up — the worker always processes the most
/// recent frame's worth of inputs.
pending: BTreeMap<String, WorkerRequest>,
/// Close orders. Sent after pending is cleared for that tab so the
/// worker never closes a tab that still has live frames in flight.
closes: Vec<String>,
/// True while the worker is processing a request. `wait_until_idle`
/// uses this alongside the queue emptiness to know when all
/// previously-submitted work has actually run.
/// Ordered inputs queue; idle and hover updates coalesce at the tail.
pending: BTreeMap<String, VecDeque<WorkerRequest>>,
/// Round-robin tab order, with each pending tab represented once.
ready_tabs: VecDeque<String>,
closes: VecDeque<String>,
in_flight: bool,
in_flight_tab: Option<String>,
initialization_failure: Option<String>,
shutdown: bool,
}
/// Owns a [`LiveRuntimeClient`] on a dedicated OS thread and exposes
/// a non-blocking API: submit ensure/poll/close, then drain responses.
///
/// The UI thread never blocks on Servo. Submissions push into a
/// coalescing queue (latest request per tab wins). The worker thread
/// drains the queue, runs the blocking calls, and emits responses on a
/// `std::sync::mpsc` channel that the UI thread reads with `try_recv`.
/// Runs one blocking profile client behind a non-blocking fair queue.
pub(super) struct LiveRuntimeWorker {
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
response_tx: mpsc::Sender<WorkerResponse>,
response_rx: mpsc::Receiver<WorkerResponse>,
thread: Option<JoinHandle<()>>,
}
@@ -132,46 +147,35 @@ impl LiveRuntimeWorker {
let queue = Arc::new((
Mutex::new(WorkerQueue {
pending: BTreeMap::new(),
closes: Vec::new(),
ready_tabs: VecDeque::new(),
closes: VecDeque::new(),
in_flight: false,
in_flight_tab: None,
initialization_failure: None,
shutdown: false,
}),
Condvar::new(),
));
let (response_tx, response_rx) = mpsc::channel();
let (init_tx, init_rx) = mpsc::channel();
let queue_for_thread = queue.clone();
let response_for_thread = response_tx.clone();
let thread = std::thread::Builder::new()
.name("ely-servo-runtime".to_string())
.spawn(move || {
let client = match client_factory() {
Ok(client) => {
let _ = init_tx.send(Ok(()));
client
}
Err(error) => {
let _ = init_tx.send(Err(error));
return;
}
};
run_worker(client, queue_for_thread, response_tx);
.spawn(move || match client_factory() {
Ok(client) => run_worker(client, queue_for_thread, response_for_thread),
Err(error) => {
fail_worker_initialization(queue_for_thread, &response_for_thread, error);
}
})
.map_err(|error| format!("failed to spawn servo live worker thread: {error}"))?;
match init_rx.recv() {
Ok(Ok(())) => {}
Ok(Err(error)) => {
let _ = thread.join();
return Err(error);
}
Err(error) => {
let _ = thread.join();
return Err(format!("servo live worker initialization failed: {error}"));
}
}
Ok(Self { queue, response_rx, thread: Some(thread) })
Ok(Self { queue, response_tx, response_rx, thread: Some(thread) })
}
pub(super) fn submit_ensure(&self, request: ServoLiveEnsureRequest) {
pub(super) fn submit_ensure(
&self,
generation: RequestGeneration,
request: ServoLiveEnsureRequest,
) {
let tab_id = request.tab_id.clone();
let (lock, cvar) = &*self.queue;
let mut q = match lock.lock() {
@@ -181,11 +185,31 @@ impl LiveRuntimeWorker {
if q.shutdown {
return;
}
q.pending.insert(tab_id, WorkerRequest::Ensure(request));
if let Some(message) = q.initialization_failure.clone() {
drop(q);
let _ = self.response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
return;
}
let mut request = WorkerRequest::Ensure { generation, request };
if let Some(pending) = q.pending.get_mut(&tab_id) {
let replace_tail = pending.back().is_some_and(|tail| {
matches!(tail, WorkerRequest::Poll { .. })
|| (!request_has_ordered_input(&request) && !request_has_ordered_input(tail))
});
if replace_tail && let Some(tail) = pending.back_mut() {
preserve_latest_hover(&mut request, tail);
*tail = request;
} else {
pending.push_back(request);
}
} else {
q.pending.insert(tab_id.clone(), VecDeque::from([request]));
q.ready_tabs.push_back(tab_id);
}
cvar.notify_one();
}
pub(super) fn submit_poll(&self, tab_id: String) -> bool {
pub(super) fn submit_poll(&self, generation: RequestGeneration, tab_id: String) -> bool {
let (lock, cvar) = &*self.queue;
let mut q = match lock.lock() {
Ok(guard) => guard,
@@ -194,15 +218,25 @@ impl LiveRuntimeWorker {
if q.shutdown {
return false;
}
if let Some(message) = q.initialization_failure.clone() {
drop(q);
let _ = self.response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
return true;
}
// A pending Ensure already produces the latest frame after its
// run; don't downgrade it to a Poll. Only insert if nothing is
// queued.
let inserted = match q.pending.entry(tab_id.clone()) {
std::collections::btree_map::Entry::Vacant(entry) => {
entry.insert(WorkerRequest::Poll { tab_id });
true
}
std::collections::btree_map::Entry::Occupied(_) => false,
let inserted = if q.pending.contains_key(&tab_id)
|| q.in_flight_tab.as_deref() == Some(tab_id.as_str())
{
false
} else {
q.pending.insert(
tab_id.clone(),
VecDeque::from([WorkerRequest::Poll { generation, tab_id: tab_id.clone() }]),
);
q.ready_tabs.push_back(tab_id);
true
};
cvar.notify_one();
inserted
@@ -217,8 +251,12 @@ impl LiveRuntimeWorker {
if q.shutdown {
return;
}
if q.initialization_failure.is_some() {
return;
}
q.pending.remove(&tab_id);
q.closes.push(tab_id);
q.ready_tabs.retain(|ready_tab_id| ready_tab_id != &tab_id);
q.closes.push_back(tab_id);
cvar.notify_one();
}
@@ -230,10 +268,7 @@ impl LiveRuntimeWorker {
out
}
/// Test-only barrier. Blocks the caller until the worker has
/// drained everything currently submitted. Production code never
/// waits — the whole point of the worker is that the UI thread
/// progresses without IPC latency.
/// Test-only barrier for all submitted work.
#[cfg(test)]
pub(super) fn wait_until_idle(&self) {
let (lock, cvar) = &*self.queue;
@@ -254,10 +289,12 @@ impl Drop for LiveRuntimeWorker {
fn drop(&mut self) {
{
let (lock, cvar) = &*self.queue;
if let Ok(mut q) = lock.lock() {
q.shutdown = true;
cvar.notify_all();
}
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
q.shutdown = true;
cvar.notify_all();
}
if let Some(handle) = self.thread.take() {
let _ = handle.join();
@@ -265,12 +302,41 @@ impl Drop for LiveRuntimeWorker {
}
}
fn fail_worker_initialization(
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
response_tx: &mpsc::Sender<WorkerResponse>,
message: String,
) {
let (lock, cvar) = &*queue;
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
q.initialization_failure = Some(message.clone());
q.ready_tabs.clear();
q.closes.clear();
q.in_flight = false;
q.in_flight_tab = None;
let pending = std::mem::take(&mut q.pending);
for request in pending.into_values().flatten() {
let (generation, tab_id) = request.failure_parts();
let _ = response_tx.send(WorkerResponse::Failed {
generation,
tab_id,
message: message.clone(),
});
}
let _ = response_tx.send(WorkerResponse::RuntimeUnavailable);
cvar.notify_all();
}
fn run_worker(
mut client: Box<dyn LiveRuntimeClient>,
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
response_tx: mpsc::Sender<WorkerResponse>,
) {
let (lock, cvar) = &*queue;
let mut last_dispatched_tab = None;
loop {
let work = {
let mut q = match lock.lock() {
@@ -278,6 +344,7 @@ fn run_worker(
Err(poisoned) => poisoned.into_inner(),
};
q.in_flight = false;
q.in_flight_tab = None;
cvar.notify_all();
while q.pending.is_empty() && q.closes.is_empty() && !q.shutdown {
q = match cvar.wait(q) {
@@ -288,19 +355,39 @@ fn run_worker(
if q.shutdown {
return;
}
let next = if let Some(close_id) = q.closes.pop() {
let next = if let Some(close_id) = q.closes.pop_front() {
Work::Close(close_id)
} else {
let key = match q.pending.keys().next().cloned() {
Some(key) => key,
if q.ready_tabs.len() > 1
&& q.ready_tabs.front() == last_dispatched_tab.as_ref()
&& let Some(last_tab) = q.ready_tabs.pop_front()
{
q.ready_tabs.push_back(last_tab);
}
let tab_id = match q.ready_tabs.pop_front() {
Some(tab_id) => tab_id,
None => continue,
};
let Some(request) = q.pending.remove(&key) else {
continue;
let (request, has_more) = match q.pending.get_mut(&tab_id) {
Some(pending) => match pending.pop_front() {
Some(request) => (request, !pending.is_empty()),
None => continue,
},
None => continue,
};
if has_more {
q.ready_tabs.push_back(tab_id.clone());
} else {
q.pending.remove(&tab_id);
}
last_dispatched_tab = Some(tab_id);
Work::Request(request)
};
q.in_flight = true;
q.in_flight_tab = match &next {
Work::Close(_) => None,
Work::Request(request) => Some(request.tab_id().to_string()),
};
next
};
@@ -309,25 +396,23 @@ fn run_worker(
let _ = client.close(tab_id);
false
}
Work::Request(WorkerRequest::Ensure(request)) => {
Work::Request(WorkerRequest::Ensure { generation, request }) => {
let tab_id = request.tab_id.clone();
dispatch_result(&response_tx, tab_id, client.ensure(request))
dispatch_result(&response_tx, generation, tab_id, client.ensure(request))
}
Work::Request(WorkerRequest::Poll { tab_id }) => {
Work::Request(WorkerRequest::Poll { generation, tab_id }) => {
let request_tab_id = tab_id.clone();
dispatch_result(&response_tx, request_tab_id, client.poll(tab_id))
dispatch_result(&response_tx, generation, request_tab_id, client.poll(tab_id))
}
};
if exit_after_dispatch {
// Release the in-flight flag and wake any flush waiter
// before exiting so wait_until_idle doesn't block forever
// on a thread that has already returned.
let mut q = match lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
q.in_flight = false;
q.in_flight_tab = None;
cvar.notify_all();
return;
}
@@ -343,19 +428,20 @@ enum Work {
/// `true` when the worker should exit.
fn dispatch_result(
response_tx: &mpsc::Sender<WorkerResponse>,
generation: RequestGeneration,
tab_id: String,
result: Result<Option<ServoLiveFrame>, LiveRuntimeClientError>,
) -> bool {
match result {
Ok(Some(frame)) => {
let _ = response_tx.send(WorkerResponse::Frame { tab_id, frame });
let _ = response_tx.send(WorkerResponse::Frame { generation, tab_id, frame });
false
}
Ok(None) => false,
Err(error) => {
let unavailable = error.is_runtime_unavailable();
let message = error.to_string();
let _ = response_tx.send(WorkerResponse::Failed { tab_id, message });
let _ = response_tx.send(WorkerResponse::Failed { generation, tab_id, message });
if unavailable {
let _ = response_tx.send(WorkerResponse::RuntimeUnavailable);
return true;
@@ -364,3 +450,34 @@ fn dispatch_result(
}
}
}
fn request_has_ordered_input(request: &WorkerRequest) -> bool {
let WorkerRequest::Ensure { request, .. } = request else {
return false;
};
request.scroll_delta_x != 0
|| request.scroll_delta_y != 0
|| request.scroll_point_x.is_some()
|| request.scroll_point_y.is_some()
|| request.click_x.is_some()
|| request.click_y.is_some()
|| request.typed_text.is_some()
}
fn preserve_latest_hover(latest: &mut WorkerRequest, previous: &WorkerRequest) {
let (
WorkerRequest::Ensure { request: latest, .. },
WorkerRequest::Ensure { request: previous, .. },
) = (latest, previous)
else {
return;
};
if latest.hover_x.is_none() && latest.hover_y.is_none() {
latest.hover_x = previous.hover_x;
latest.hover_y = previous.hover_y;
}
}
#[cfg(test)]
#[path = "web_surface_worker_tests.rs"]
mod tests;
@@ -0,0 +1,361 @@
use std::{
sync::{Arc, Mutex, mpsc},
time::Duration,
};
use crate::services::servo_live::{ServoLiveEnsureRequest, ServoLiveFrame};
use super::{
LiveRuntimeClient, LiveRuntimeClientError, LiveRuntimeWorker, RequestGeneration, WorkerResponse,
};
#[derive(Clone, Debug, Eq, PartialEq)]
enum RecordedInput {
Idle(String),
Scroll(String),
Click(String),
Text(String),
Hover(String),
}
struct SlowRecordingClient {
calls: Arc<Mutex<Vec<RecordedInput>>>,
first_started_tx: Option<mpsc::Sender<()>>,
release_first_rx: mpsc::Receiver<()>,
return_frame: bool,
}
struct GenerationClient;
impl LiveRuntimeClient for GenerationClient {
fn ensure(
&mut self,
_request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(Some(ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])))
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Err(LiveRuntimeClientError::Message("poll failed".to_string()))
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
impl LiveRuntimeClient for SlowRecordingClient {
fn ensure(
&mut self,
request: ServoLiveEnsureRequest,
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
let input = recorded_input(&request);
self.calls.lock().map_err(|_| "call recorder lock was poisoned".to_string())?.push(input);
if let Some(first_started_tx) = self.first_started_tx.take() {
first_started_tx.send(()).map_err(|error| error.to_string())?;
self.release_first_rx.recv().map_err(|error| error.to_string())?;
}
Ok(self.return_frame.then(|| ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])))
}
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
Ok(None)
}
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
Ok(())
}
}
#[test]
fn queued_edge_inputs_for_one_tab_are_preserved() -> Result<(), String> {
let calls = Arc::new(Mutex::new(Vec::new()));
let (first_started_tx, first_started_rx) = mpsc::channel();
let (release_first_tx, release_first_rx) = mpsc::channel();
let client_calls = calls.clone();
let worker = LiveRuntimeWorker::new(move || {
Ok(Box::new(SlowRecordingClient {
calls: client_calls,
first_started_tx: Some(first_started_tx),
release_first_rx,
return_frame: false,
}))
})?;
worker.submit_ensure(
RequestGeneration::new(1),
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
first_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
for (generation, input) in [
RecordedInput::Scroll("tab-a".to_string()),
RecordedInput::Click("tab-a".to_string()),
RecordedInput::Text("tab-a".to_string()),
RecordedInput::Hover("tab-a".to_string()),
]
.into_iter()
.enumerate()
{
worker.submit_ensure(
RequestGeneration::new(generation as u64 + 2),
ensure_request("tab-a", input),
);
}
release_first_tx.send(()).map_err(|error| error.to_string())?;
worker.wait_until_idle();
assert_eq!(
*calls.lock().map_err(|_| "call recorder lock was poisoned".to_string())?,
vec![
RecordedInput::Idle("tab-a".to_string()),
RecordedInput::Scroll("tab-a".to_string()),
RecordedInput::Click("tab-a".to_string()),
RecordedInput::Text("tab-a".to_string()),
RecordedInput::Hover("tab-a".to_string()),
]
);
Ok(())
}
#[test]
fn queued_tabs_are_dispatched_round_robin() -> Result<(), String> {
let calls = Arc::new(Mutex::new(Vec::new()));
let (first_started_tx, first_started_rx) = mpsc::channel();
let (release_first_tx, release_first_rx) = mpsc::channel();
let client_calls = calls.clone();
let worker = LiveRuntimeWorker::new(move || {
Ok(Box::new(SlowRecordingClient {
calls: client_calls,
first_started_tx: Some(first_started_tx),
release_first_rx,
return_frame: false,
}))
})?;
worker.submit_ensure(
RequestGeneration::new(1),
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
first_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
worker.submit_ensure(
RequestGeneration::new(2),
ensure_request("tab-a", RecordedInput::Click("tab-a".to_string())),
);
worker.submit_ensure(
RequestGeneration::new(3),
ensure_request("tab-b", RecordedInput::Click("tab-b".to_string())),
);
worker.submit_ensure(
RequestGeneration::new(4),
ensure_request("tab-a", RecordedInput::Text("tab-a".to_string())),
);
release_first_tx.send(()).map_err(|error| error.to_string())?;
worker.wait_until_idle();
assert_eq!(
*calls.lock().map_err(|_| "call recorder lock was poisoned".to_string())?,
vec![
RecordedInput::Idle("tab-a".to_string()),
RecordedInput::Click("tab-b".to_string()),
RecordedInput::Click("tab-a".to_string()),
RecordedInput::Text("tab-a".to_string()),
]
);
Ok(())
}
#[test]
fn responses_keep_their_request_generation() -> Result<(), String> {
let worker = LiveRuntimeWorker::new(|| Ok(Box::new(GenerationClient)))?;
let ensure_generation = RequestGeneration::new(41);
let poll_generation = RequestGeneration::new(42);
worker.submit_ensure(
ensure_generation,
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
worker.wait_until_idle();
assert!(matches!(
worker.drain_responses().as_slice(),
[WorkerResponse::Frame { generation, tab_id, .. }]
if *generation == ensure_generation && tab_id == "tab-a"
));
assert!(worker.submit_poll(poll_generation, "tab-a".to_string()));
worker.wait_until_idle();
assert!(matches!(
worker.drain_responses().as_slice(),
[WorkerResponse::Failed { generation, tab_id, message }]
if *generation == poll_generation && tab_id == "tab-a" && message == "poll failed"
));
Ok(())
}
#[test]
fn hover_updates_coalesce_to_latest_state() -> Result<(), String> {
let calls = Arc::new(Mutex::new(Vec::new()));
let (first_started_tx, first_started_rx) = mpsc::channel();
let (release_first_tx, release_first_rx) = mpsc::channel();
let client_calls = calls.clone();
let worker = LiveRuntimeWorker::new(move || {
Ok(Box::new(SlowRecordingClient {
calls: client_calls,
first_started_tx: Some(first_started_tx),
release_first_rx,
return_frame: false,
}))
})?;
worker.submit_ensure(
RequestGeneration::new(1),
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
first_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
for generation in 2..=101 {
worker.submit_ensure(
RequestGeneration::new(generation),
ensure_request("tab-a", RecordedInput::Hover("tab-a".to_string())),
);
}
release_first_tx.send(()).map_err(|error| error.to_string())?;
worker.wait_until_idle();
assert_eq!(
*calls.lock().map_err(|_| "call recorder lock was poisoned".to_string())?,
vec![RecordedInput::Idle("tab-a".to_string()), RecordedInput::Hover("tab-a".to_string()),]
);
Ok(())
}
#[test]
fn poll_is_rejected_while_same_tab_ensure_is_in_flight() -> Result<(), String> {
let calls = Arc::new(Mutex::new(Vec::new()));
let (first_started_tx, first_started_rx) = mpsc::channel();
let (release_first_tx, release_first_rx) = mpsc::channel();
let worker = LiveRuntimeWorker::new(move || {
Ok(Box::new(SlowRecordingClient {
calls,
first_started_tx: Some(first_started_tx),
release_first_rx,
return_frame: true,
}))
})?;
let ensure_generation = RequestGeneration::new(1);
worker.submit_ensure(
ensure_generation,
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
first_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
assert!(!worker.submit_poll(RequestGeneration::new(2), "tab-a".to_string()));
release_first_tx.send(()).map_err(|error| error.to_string())?;
worker.wait_until_idle();
assert!(matches!(
worker.drain_responses().as_slice(),
[WorkerResponse::Frame { generation, .. }] if *generation == ensure_generation
));
Ok(())
}
#[test]
fn worker_creation_does_not_wait_for_client_factory() -> Result<(), String> {
let (factory_started_tx, factory_started_rx) = mpsc::channel();
let (release_factory_tx, release_factory_rx) = mpsc::channel();
let worker = LiveRuntimeWorker::new(move || {
factory_started_tx.send(()).map_err(|error| error.to_string())?;
release_factory_rx.recv().map_err(|error| error.to_string())?;
Ok(Box::new(GenerationClient))
})?;
worker.submit_ensure(
RequestGeneration::new(1),
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
factory_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
release_factory_tx.send(()).map_err(|error| error.to_string())?;
worker.wait_until_idle();
assert!(matches!(worker.drain_responses().as_slice(), [WorkerResponse::Frame { .. }]));
Ok(())
}
#[test]
fn factory_failure_rejects_queued_request() -> Result<(), String> {
let worker = LiveRuntimeWorker::new(|| -> Result<Box<dyn LiveRuntimeClient>, String> {
Err("injected factory failure".to_string())
})?;
worker.submit_ensure(
RequestGeneration::new(1),
ensure_request("tab-a", RecordedInput::Idle("tab-a".to_string())),
);
worker.wait_until_idle();
let responses = worker.drain_responses();
assert!(responses.iter().any(|response| matches!(response, WorkerResponse::Failed { message, .. } if message == "injected factory failure")));
assert!(
responses.iter().any(|response| matches!(response, WorkerResponse::RuntimeUnavailable))
);
Ok(())
}
#[test]
#[expect(
clippy::expect_used,
clippy::panic,
reason = "this test deliberately poisons the worker queue mutex"
)]
fn poisoned_queue_still_shuts_down_worker() -> Result<(), String> {
let worker = LiveRuntimeWorker::new(|| Ok(Box::new(GenerationClient)))?;
let queue = worker.queue.clone();
let poison_result = std::thread::spawn(move || {
let (lock, _) = &*queue;
let _guard = lock.lock().expect("queue lock should begin healthy");
panic!("injected queue poison");
})
.join();
assert!(poison_result.is_err());
drop(worker);
Ok(())
}
fn recorded_input(request: &ServoLiveEnsureRequest) -> RecordedInput {
let tab_id = request.tab_id.clone();
if request.scroll_delta_x != 0 || request.scroll_delta_y != 0 {
RecordedInput::Scroll(tab_id)
} else if request.click_x.is_some() {
RecordedInput::Click(tab_id)
} else if request.typed_text.is_some() {
RecordedInput::Text(tab_id)
} else if request.hover_x.is_some() {
RecordedInput::Hover(tab_id)
} else {
RecordedInput::Idle(tab_id)
}
}
fn ensure_request(tab_id: &str, input: RecordedInput) -> ServoLiveEnsureRequest {
let scroll = matches!(input, RecordedInput::Scroll(_));
let click = matches!(input, RecordedInput::Click(_));
let text = matches!(input, RecordedInput::Text(_));
let hover = matches!(input, RecordedInput::Hover(_));
ServoLiveEnsureRequest {
tab_id: tab_id.to_string(),
profile_id: "profile".to_string(),
url: "https://example.com/".to_string(),
width: 640,
height: 480,
page_zoom_percent: 100,
device_pixel_ratio: 1.0,
scroll_delta_x: i32::from(scroll),
scroll_delta_y: i32::from(scroll),
scroll_point_x: scroll.then_some(1),
scroll_point_y: scroll.then_some(1),
click_x: click.then_some(1),
click_y: click.then_some(1),
hover_x: hover.then_some(1),
hover_y: hover.then_some(1),
typed_text: text.then(|| "text".to_string()),
site_permissions: Vec::new(),
}
}
+7
View File
@@ -70,6 +70,7 @@ pub struct InitialBrowserConfig {
pub profile_name: String,
pub profile_color_hex: u32,
pub profile_kind: ProfileKind,
pub profile_id: Option<ProfileId>,
pub new_tab_destination: NewTabDestination,
}
@@ -82,6 +83,7 @@ impl InitialBrowserConfig {
profile_name: "Default".to_string(),
profile_color_hex: 0x26251e,
profile_kind: ProfileKind::Standard,
profile_id: None,
new_tab_destination: NewTabDestination::default(),
})
}
@@ -94,6 +96,7 @@ impl InitialBrowserConfig {
profile_name: "Private".to_string(),
profile_color_hex: 0x807d72,
profile_kind: ProfileKind::Private,
profile_id: None,
new_tab_destination: NewTabDestination::default(),
})
}
@@ -181,6 +184,10 @@ impl BrowserCore {
pub fn new(config: InitialBrowserConfig) -> Result<Self, CoreError> {
let profile =
Profile::new(config.profile_name, config.profile_color_hex, config.profile_kind);
let profile = match config.profile_id {
Some(profile_id) => Profile::restore(profile_id, profile),
None => profile,
};
let active_profile_id = profile.id().clone();
let space = Space::new(
config.space_name,
+22
View File
@@ -18,19 +18,41 @@ servo-engine = [
"dep:servo",
"dep:url",
]
hardware-render = [
"servo-engine",
"dep:gleam",
"dep:glow",
"dep:image",
"dep:mach2",
"dep:objc2-io-surface",
"dep:surfman",
]
[[bin]]
name = "ely_servo_sidecar"
path = "src/bin/ely_servo_sidecar.rs"
required-features = ["servo-engine"]
[dependencies]
dpi = { workspace = true, optional = true }
ely_domain = { path = "../ely_domain" }
euclid = { version = "0.22", optional = true }
gleam = { version = "0.15", optional = true }
glow = { version = "0.17", optional = true }
image = { workspace = true, optional = true }
naga = { version = "26.0.0", features = ["termcolor"], optional = true }
raw-window-handle = { version = "0.6", optional = true }
rustls = { version = "0.23.40", default-features = false, features = ["std", "aws_lc_rs"], optional = true }
serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
servo = { workspace = true, optional = true }
surfman = { version = "0.13", features = ["chains"], optional = true }
thiserror.workspace = true
url = { workspace = true, optional = true }
[target.'cfg(target_os = "macos")'.dependencies]
mach2 = { version = "0.6", optional = true }
objc2-io-surface = { version = "0.3.2", default-features = false, features = ["std", "libc", "objc2", "objc2-core-foundation", "IOSurfaceRef", "IOSurfaceTypes"], optional = true }
[lints]
workspace = true
@@ -0,0 +1,36 @@
use thiserror::Error;
#[path = "ely_servo_sidecar/args.rs"]
mod args;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[path = "ely_servo_sidecar/iosurface_mach.rs"]
mod iosurface_mach;
#[path = "ely_servo_sidecar/live.rs"]
mod live;
#[path = "ely_servo_sidecar/live_output.rs"]
mod live_output;
#[path = "ely_servo_sidecar/live_protocol.rs"]
mod live_protocol;
#[path = "ely_servo_sidecar/live_session.rs"]
mod live_session;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[path = "ely_servo_sidecar/live_surface.rs"]
mod live_surface;
use args::SidecarCommand;
fn main() -> Result<(), SidecarError> {
match args::parse_env_command()? {
SidecarCommand::Live(args) => live::run(args)?,
}
Ok(())
}
#[derive(Debug, Error)]
enum SidecarError {
#[error(transparent)]
Args(#[from] args::SidecarArgsError),
#[error(transparent)]
Live(#[from] live_protocol::LiveSidecarError),
}
@@ -0,0 +1,166 @@
use std::{env, path::PathBuf};
use thiserror::Error;
pub(super) enum SidecarCommand {
Live(LiveArgs),
}
pub(super) struct LiveArgs {
pub(super) profile_data_dir: PathBuf,
pub(super) rendering_context: SidecarRenderingContext,
pub(super) iosurface_mach_service: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) enum SidecarRenderingContext {
#[default]
Software,
Hardware,
}
#[derive(Debug, Error)]
pub(super) enum SidecarArgsError {
#[error("missing sidecar command")]
MissingCommand,
#[error("unknown sidecar command: {value}")]
UnknownCommand { value: String },
#[error("missing argument value for {name}")]
MissingArgumentValue { name: &'static str },
#[error("missing required argument: {name}")]
MissingRequiredArgument { name: &'static str },
#[error("unknown argument: {value}")]
UnknownArgument { value: String },
#[error("{name} path is empty")]
EmptyPath { name: &'static str },
#[error("invalid rendering context: {value}")]
InvalidRenderingContext { value: String },
#[error("{name} value is empty")]
EmptyValue { name: &'static str },
}
pub(super) fn parse_env_command() -> Result<SidecarCommand, SidecarArgsError> {
parse_command(env::args())
}
fn parse_command(
args: impl IntoIterator<Item = String>,
) -> Result<SidecarCommand, SidecarArgsError> {
let mut args = args.into_iter();
let _program_name = args.next();
let command = args.next().ok_or(SidecarArgsError::MissingCommand)?;
match command.as_str() {
"live" => parse_live_args(args).map(SidecarCommand::Live),
_ => Err(SidecarArgsError::UnknownCommand { value: command }),
}
}
fn parse_live_args(args: impl IntoIterator<Item = String>) -> Result<LiveArgs, SidecarArgsError> {
let mut args = args.into_iter();
let mut profile_data_dir = None;
let mut rendering_context = SidecarRenderingContext::Software;
let mut iosurface_mach_service = None;
while let Some(name) = args.next() {
match name.as_str() {
"--profile-data-dir" => {
let value = next_argument(&mut args, "--profile-data-dir")?;
profile_data_dir = Some(parse_path("--profile-data-dir", value)?);
}
"--rendering-context" => {
let value = next_argument(&mut args, "--rendering-context")?;
rendering_context = match value.as_str() {
"software" => SidecarRenderingContext::Software,
"hardware" => SidecarRenderingContext::Hardware,
_ => return Err(SidecarArgsError::InvalidRenderingContext { value }),
};
}
"--iosurface-mach-service" => {
let value = next_argument(&mut args, "--iosurface-mach-service")?;
iosurface_mach_service = Some(parse_nonempty("--iosurface-mach-service", value)?);
}
_ => return Err(SidecarArgsError::UnknownArgument { value: name }),
}
}
let profile_data_dir = profile_data_dir
.ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?;
Ok(LiveArgs { profile_data_dir, rendering_context, iosurface_mach_service })
}
fn next_argument(
args: &mut impl Iterator<Item = String>,
name: &'static str,
) -> Result<String, SidecarArgsError> {
args.next().ok_or(SidecarArgsError::MissingArgumentValue { name })
}
fn parse_path(name: &'static str, value: String) -> Result<PathBuf, SidecarArgsError> {
if value.trim().is_empty() {
return Err(SidecarArgsError::EmptyPath { name });
}
Ok(PathBuf::from(value))
}
fn parse_nonempty(name: &'static str, value: String) -> Result<String, SidecarArgsError> {
if value.trim().is_empty() {
return Err(SidecarArgsError::EmptyValue { name });
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_live_profile_data_directory() -> Result<(), SidecarArgsError> {
let command = parse_command([
"ely_servo_sidecar".to_string(),
"live".to_string(),
"--profile-data-dir".to_string(),
"/tmp/ely-profile".to_string(),
])?;
let SidecarCommand::Live(args) = command;
assert_eq!(args.profile_data_dir, PathBuf::from("/tmp/ely-profile"));
assert_eq!(args.rendering_context, SidecarRenderingContext::Software);
assert_eq!(args.iosurface_mach_service, None);
Ok(())
}
#[test]
fn parses_hardware_rendering_context_and_mach_service() -> Result<(), SidecarArgsError> {
let command = parse_command([
"ely_servo_sidecar".to_string(),
"live".to_string(),
"--profile-data-dir".to_string(),
"/tmp/ely-profile".to_string(),
"--rendering-context".to_string(),
"hardware".to_string(),
"--iosurface-mach-service".to_string(),
"com.ely.browser.iosurface.test".to_string(),
])?;
let SidecarCommand::Live(args) = command;
assert_eq!(args.rendering_context, SidecarRenderingContext::Hardware);
assert_eq!(args.iosurface_mach_service.as_deref(), Some("com.ely.browser.iosurface.test"));
Ok(())
}
#[test]
fn requires_profile_data_directory() {
let result = parse_command(["ely_servo_sidecar".to_string(), "live".to_string()]);
assert!(matches!(
result,
Err(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })
));
}
}
@@ -0,0 +1,116 @@
use std::{ffi::CString, mem, time::Duration};
use mach2::{
bootstrap::{bootstrap_look_up, bootstrap_port},
kern_return::KERN_SUCCESS,
mach_port::mach_port_deallocate,
message::{
MACH_MSG_SUCCESS, MACH_MSG_TYPE_COPY_SEND, MACH_MSG_TYPE_MOVE_SEND, MACH_MSGH_BITS,
MACH_MSGH_BITS_COMPLEX, MACH_SEND_MSG, MACH_SEND_TIMEOUT, mach_msg, mach_msg_body_t,
mach_msg_header_t, mach_msg_port_descriptor_t,
},
port::{MACH_PORT_NULL, mach_port_t},
traps::mach_task_self,
};
use thiserror::Error;
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
const SEND_TIMEOUT: Duration = Duration::from_secs(1);
pub(super) struct IOSurfaceMachSender {
send_port: mach_port_t,
}
#[derive(Debug, Error)]
pub(super) enum IOSurfaceMachError {
#[error("Mach service name contains an interior nul byte")]
InvalidServiceName,
#[error("bootstrap_look_up returned {code}")]
LookupService { code: i32 },
#[error("mach_msg send returned {code}")]
Send { code: i32 },
}
impl IOSurfaceMachSender {
pub(super) fn connect(service_name: &str) -> Result<Self, IOSurfaceMachError> {
let service_name =
CString::new(service_name).map_err(|_| IOSurfaceMachError::InvalidServiceName)?;
let mut send_port = MACH_PORT_NULL;
#[expect(unsafe_code)]
let result =
unsafe { bootstrap_look_up(bootstrap_port, service_name.as_ptr(), &mut send_port) };
if result != KERN_SUCCESS {
return Err(IOSurfaceMachError::LookupService { code: result });
}
Ok(Self { send_port })
}
pub(super) fn send_surface_port(
&mut self,
surface_id: u64,
mach_port: mach_port_t,
) -> Result<(), IOSurfaceMachError> {
let mut message = IOSurfacePortMessage {
header: mach_msg_header_t {
msgh_bits: MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0) | MACH_MSGH_BITS_COMPLEX,
msgh_size: mem::size_of::<IOSurfacePortMessage>() as u32,
msgh_remote_port: self.send_port,
msgh_local_port: MACH_PORT_NULL,
msgh_voucher_port: MACH_PORT_NULL,
msgh_id: IOSURFACE_PORT_MESSAGE_ID,
},
body: mach_msg_body_t { msgh_descriptor_count: 1 },
surface_port: mach_msg_port_descriptor_t::new(mach_port, MACH_MSG_TYPE_MOVE_SEND),
surface_id,
};
#[expect(unsafe_code)]
let result = unsafe {
mach_msg(
&mut message.header,
MACH_SEND_MSG | MACH_SEND_TIMEOUT,
message.header.msgh_size,
0,
MACH_PORT_NULL,
timeout_millis(SEND_TIMEOUT),
MACH_PORT_NULL,
)
};
if result != MACH_MSG_SUCCESS {
destroy_message(&mut message);
return Err(IOSurfaceMachError::Send { code: result });
}
Ok(())
}
}
impl Drop for IOSurfaceMachSender {
fn drop(&mut self) {
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
unsafe {
let _ = mach_port_deallocate(task, self.send_port);
}
}
}
#[repr(C)]
struct IOSurfacePortMessage {
header: mach_msg_header_t,
body: mach_msg_body_t,
surface_port: mach_msg_port_descriptor_t,
surface_id: u64,
}
fn timeout_millis(timeout: Duration) -> u32 {
u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(1)
}
fn destroy_message(message: &mut IOSurfacePortMessage) {
#[expect(unsafe_code)]
unsafe {
mach2::message::mach_msg_destroy(&mut message.header);
}
}
@@ -0,0 +1,419 @@
use std::{
collections::HashMap,
fs,
io::{self, BufRead},
};
use ely_domain::{ProfileId, TabId, UrlText};
use ely_servo_host::{
NavigationRequest, RenderingContextKind, ServoHost, ServoSurfaceSize, SoftwareServoHost,
};
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
use super::live_surface::HardwareSurfaceTransport;
use super::{
args::{LiveArgs, SidecarRenderingContext},
live_output::write_outcome,
live_protocol::{
LIVE_PROTOCOL_VERSION, LiveFrameReport, LiveOutcome, LiveRequest, LiveSidecarError,
validated_frame_byte_count,
},
live_session::{
LiveInput, LiveSession, apply_input, apply_layout, apply_permissions, bind_profile,
ensure_session,
},
};
pub(super) fn run(args: LiveArgs) -> Result<(), LiveSidecarError> {
let LiveArgs { profile_data_dir, rendering_context, iosurface_mach_service } = args;
fs::create_dir_all(&profile_data_dir)?;
let rendering_context_kind = match rendering_context {
SidecarRenderingContext::Software => RenderingContextKind::Software,
SidecarRenderingContext::Hardware => RenderingContextKind::Hardware,
};
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let mut hardware_transport = match rendering_context {
SidecarRenderingContext::Software => None,
SidecarRenderingContext::Hardware => {
let service = iosurface_mach_service
.as_deref()
.ok_or(LiveSidecarError::IOSurfaceMachServiceRequired)?;
Some(HardwareSurfaceTransport::connect(service)?)
}
};
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
drop(iosurface_mach_service);
let mut host = SoftwareServoHost::new_with_config_dir_and_kind(
ServoSurfaceSize::new(1, 1),
Some(profile_data_dir),
rendering_context_kind,
)?;
let mut sessions = HashMap::new();
let mut active_profile = None;
let mut handshake_complete = false;
let stdin = io::stdin();
let mut stdout = io::stdout().lock();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let request = serde_json::from_str::<LiveRequest>(&line);
let should_shutdown =
request.as_ref().is_ok_and(|request| matches!(request, LiveRequest::Shutdown));
let outcome = request.map_err(LiveSidecarError::from).and_then(|request| {
handle_request(
&mut host,
&mut sessions,
&mut active_profile,
&mut handshake_complete,
rendering_context_kind,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
hardware_transport.as_mut(),
request,
)
});
write_outcome(&mut stdout, outcome)?;
if should_shutdown {
break;
}
}
Ok(())
}
fn handle_request(
host: &mut SoftwareServoHost,
sessions: &mut HashMap<String, LiveSession>,
active_profile: &mut Option<ProfileId>,
handshake_complete: &mut bool,
rendering_context_kind: RenderingContextKind,
#[cfg(all(feature = "hardware-render", target_os = "macos"))] hardware_transport: Option<
&mut HardwareSurfaceTransport,
>,
request: LiveRequest,
) -> Result<LiveOutcome, LiveSidecarError> {
if !*handshake_complete
&& !matches!(&request, LiveRequest::Handshake { .. } | LiveRequest::Shutdown)
{
return Err(LiveSidecarError::ProtocolHandshakeRequired);
}
match request {
LiveRequest::Handshake { protocol_version } => {
if protocol_version != LIVE_PROTOCOL_VERSION {
return Err(LiveSidecarError::ProtocolVersionMismatch {
expected: LIVE_PROTOCOL_VERSION,
actual: protocol_version,
});
}
*handshake_complete = true;
Ok(LiveOutcome::empty())
}
LiveRequest::Ensure {
tab_id,
profile_id,
url,
width,
height,
page_zoom_percent,
device_pixel_ratio,
scroll_delta_x,
scroll_delta_y,
scroll_point_x,
scroll_point_y,
click_x,
click_y,
hover_x,
hover_y,
typed_text,
site_permissions,
ready_surface_ids,
pending_surface_ids,
} => {
validated_frame_byte_count(width, height)?;
let tab = TabId::parse(tab_id.clone())?;
let profile = ProfileId::parse(profile_id)?;
bind_profile(active_profile, &profile)?;
let url = UrlText::parse(url)?;
let session =
ensure_session(host, sessions, tab_id.clone(), &tab, &profile, width, height)?;
apply_layout(host, session, width, height, page_zoom_percent, device_pixel_ratio)?;
apply_permissions(host, session, &profile, site_permissions)?;
if session.requested_url != url.as_str() {
let servo_current_url =
host.snapshot(&session.webview_id)?.url().map(str::to_string);
if servo_current_url.as_deref() == Some(url.as_str()) {
session.requested_url = url.as_str().to_string();
} else {
session.clear_presented_frame();
host.navigate(NavigationRequest {
webview_id: session.webview_id.clone(),
tab_id: tab,
url: url.clone(),
})?;
session.requested_url = url.as_str().to_string();
}
}
apply_input(
host,
session,
LiveInput {
scroll_delta_x,
scroll_delta_y,
scroll_point_x,
scroll_point_y,
click_x,
click_y,
hover_x,
hover_y,
typed_text,
},
)?;
let webview_id = session.webview_id.clone();
let outcome = poll_frame(
host,
session,
rendering_context_kind,
&ready_surface_ids,
&pending_surface_ids,
)?;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let outcome = {
let mut outcome = outcome;
if let Some(transport) = hardware_transport {
transport.publish_frame(
host,
&tab_id,
&webview_id,
&ready_surface_ids,
&pending_surface_ids,
&mut outcome,
)?;
}
outcome
};
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
drop((webview_id, ready_surface_ids, pending_surface_ids));
Ok(outcome)
}
LiveRequest::Poll { tab_id, ready_surface_ids, pending_surface_ids } => {
let Some(session) = sessions.get_mut(&tab_id) else {
return Ok(LiveOutcome::empty());
};
let webview_id = session.webview_id.clone();
let outcome = poll_frame(
host,
session,
rendering_context_kind,
&ready_surface_ids,
&pending_surface_ids,
)?;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let outcome = {
let mut outcome = outcome;
if let Some(transport) = hardware_transport {
transport.publish_frame(
host,
&tab_id,
&webview_id,
&ready_surface_ids,
&pending_surface_ids,
&mut outcome,
)?;
}
outcome
};
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
drop((webview_id, ready_surface_ids, pending_surface_ids));
Ok(outcome)
}
LiveRequest::Close { tab_id } => {
if let Some(session) = sessions.remove(&tab_id) {
host.close_webview(&session.webview_id);
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
if let Some(transport) = hardware_transport {
transport.close_tab(&tab_id);
}
Ok(LiveOutcome::empty())
}
LiveRequest::Shutdown => Ok(LiveOutcome::empty()),
}
}
fn poll_frame(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
rendering_context_kind: RenderingContextKind,
ready_surface_ids: &[u64],
pending_surface_ids: &[u64],
) -> Result<LiveOutcome, LiveSidecarError> {
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
let _ = (ready_surface_ids, pending_surface_ids);
match rendering_context_kind {
RenderingContextKind::Software => poll_software_frame(host, session),
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
RenderingContextKind::Hardware => {
poll_hardware_frame(host, session, ready_surface_ids, pending_surface_ids)
}
#[cfg(not(all(feature = "hardware-render", target_os = "macos")))]
RenderingContextKind::Hardware => poll_software_frame(host, session),
}
}
fn poll_software_frame(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
) -> Result<LiveOutcome, LiveSidecarError> {
host.tick();
let snapshot = host.snapshot(&session.webview_id)?;
let has_pending_frame = snapshot.has_pending_frame();
if !has_pending_frame {
if snapshot.has_pending_metadata()
&& let Some(frame) = session.last_frame.clone()
{
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
let report =
LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), false);
return Ok(LiveOutcome::frame(report, frame));
}
return Ok(LiveOutcome::empty());
}
host.paint_with_readback(&session.webview_id)?;
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
let frame = host.last_rendered_frame()?;
session.last_frame = Some(frame.clone());
let report = LiveFrameReport::new(&snapshot, &frame, session.device_pixel_ratio(), true);
Ok(LiveOutcome::frame(report, frame))
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn poll_hardware_frame(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
ready_surface_ids: &[u64],
pending_surface_ids: &[u64],
) -> Result<LiveOutcome, LiveSidecarError> {
host.acknowledge_iosurfaces(&session.webview_id, ready_surface_ids)?;
let (last_surface_became_ready, last_surface_is_missing) = session
.last_surface
.map(|identity| {
let was_ready = session.last_surface_ready;
let ready = ready_surface_ids.contains(&identity.surface_id);
let pending = pending_surface_ids.contains(&identity.surface_id);
session.last_surface_ready = ready;
(ready && !was_ready, !ready && !pending)
})
.unwrap_or((false, false));
host.tick();
let snapshot = host.snapshot(&session.webview_id)?;
match hardware_poll_action(
snapshot.has_pending_frame(),
snapshot.has_pending_metadata(),
last_surface_became_ready,
last_surface_is_missing,
session.last_surface.is_some(),
session.last_surface_ready,
) {
HardwarePollAction::ReplaySurface => {
let identity = session.last_surface.ok_or_else(|| {
ely_servo_host::ServoHostError::HardwareSurfaceUnavailable {
id: session.webview_id.clone(),
}
})?;
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
let report = LiveFrameReport::from_surface(
&snapshot,
identity.width,
identity.height,
session.device_pixel_ratio(),
false,
);
return Ok(LiveOutcome::surface(report));
}
HardwarePollAction::Empty => return Ok(LiveOutcome::empty()),
HardwarePollAction::PaintFrame => {}
}
host.paint_without_readback(&session.webview_id)?;
let snapshot = host.snapshot_and_mark_metadata_observed(&session.webview_id)?;
let identity = host.peek_iosurface_identity(&session.webview_id)?.ok_or_else(|| {
ely_servo_host::ServoHostError::HardwareSurfaceUnavailable {
id: session.webview_id.clone(),
}
})?;
session.last_surface = Some(identity);
session.last_surface_ready = ready_surface_ids.contains(&identity.surface_id);
let report = LiveFrameReport::from_surface(
&snapshot,
identity.width,
identity.height,
session.device_pixel_ratio(),
true,
);
Ok(LiveOutcome::surface(report))
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum HardwarePollAction {
ReplaySurface,
PaintFrame,
Empty,
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
fn hardware_poll_action(
has_pending_frame: bool,
has_pending_metadata: bool,
last_surface_became_ready: bool,
last_surface_is_missing: bool,
has_last_surface: bool,
last_surface_ready: bool,
) -> HardwarePollAction {
if has_last_surface && (last_surface_became_ready || last_surface_is_missing) {
HardwarePollAction::ReplaySurface
} else if has_last_surface && !last_surface_ready {
HardwarePollAction::Empty
} else if has_last_surface && !has_pending_frame && has_pending_metadata {
HardwarePollAction::ReplaySurface
} else if has_pending_frame {
HardwarePollAction::PaintFrame
} else {
HardwarePollAction::Empty
}
}
#[cfg(all(test, feature = "hardware-render", target_os = "macos"))]
mod tests {
use super::{HardwarePollAction, hardware_poll_action};
#[test]
fn newly_ready_surface_replays_before_pending_frame() {
assert_eq!(
hardware_poll_action(true, false, true, false, true, true),
HardwarePollAction::ReplaySurface
);
assert_eq!(
hardware_poll_action(true, false, false, false, true, true),
HardwarePollAction::PaintFrame
);
}
#[test]
fn awaiting_ready_surface_backpressures_pending_frame() {
assert_eq!(
hardware_poll_action(true, true, false, false, true, false),
HardwarePollAction::Empty
);
}
#[test]
fn missing_surface_replays_before_first_ready() {
assert_eq!(
hardware_poll_action(true, false, false, true, true, false),
HardwarePollAction::ReplaySurface
);
}
}
@@ -0,0 +1,64 @@
use std::io::Write;
use super::live_protocol::{LiveOutcome, LiveSidecarError, validated_frame_byte_count};
pub(super) fn write_outcome(
stdout: &mut impl Write,
outcome: Result<LiveOutcome, LiveSidecarError>,
) -> Result<(), LiveSidecarError> {
let mut outcome = match outcome {
Ok(outcome) => outcome,
Err(error) => LiveOutcome::error(error.to_string()),
};
if let Some(frame) = outcome.frame.as_ref()
&& let Err(error) = validate_frame(frame.width(), frame.height(), frame.rgba_bytes().len())
{
outcome = LiveOutcome::error(error.to_string());
}
serde_json::to_writer(&mut *stdout, &outcome.response)?;
stdout.write_all(b"\n")?;
if let Some(frame) = outcome.frame.as_ref() {
stdout.write_all(frame.rgba_bytes())?;
}
stdout.flush()?;
Ok(())
}
fn validate_frame(width: u32, height: u32, actual: usize) -> Result<(), LiveSidecarError> {
let expected = validated_frame_byte_count(width, height)?;
if expected != actual {
return Err(LiveSidecarError::FrameByteCountMismatch { expected, actual });
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mismatched_frame_becomes_header_only_error() -> Result<(), LiveSidecarError> {
let frame = ely_servo_host::RenderedFrame::from_rgba_bytes(2, 2, vec![0; 4]);
let snapshot = ely_servo_host::WebViewSnapshot::new(
ely_domain::WebViewId::new(),
ely_domain::TabId::new(),
ely_domain::ProfileId::new(),
ely_servo_host::WebViewState::Complete,
None,
None,
ely_servo_host::WebViewSnapshotPending::new(false, false),
);
let report =
super::super::live_protocol::LiveFrameReport::new(&snapshot, &frame, 1.0, true);
let mut output = Vec::new();
write_outcome(&mut output, Ok(LiveOutcome::frame(report, frame)))?;
assert!(output.ends_with(b"\n"));
let response: serde_json::Value = serde_json::from_slice(&output)?;
assert!(response["error"].as_str().is_some());
assert!(response["frame"].is_null());
Ok(())
}
}
@@ -0,0 +1,381 @@
use std::io;
use ely_servo_host::{
IOSurfaceHandle, RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
use super::iosurface_mach::IOSurfaceMachError;
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 2;
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(super) enum LiveRequest {
Handshake {
protocol_version: u32,
},
Ensure {
tab_id: String,
profile_id: String,
url: String,
width: u32,
height: u32,
#[serde(default = "default_zoom_percent")]
page_zoom_percent: u16,
#[serde(default = "default_device_pixel_ratio")]
device_pixel_ratio: f32,
#[serde(default)]
scroll_delta_x: i32,
#[serde(default)]
scroll_delta_y: i32,
#[serde(default)]
scroll_point_x: Option<u32>,
#[serde(default)]
scroll_point_y: Option<u32>,
#[serde(default)]
click_x: Option<u32>,
#[serde(default)]
click_y: Option<u32>,
#[serde(default)]
hover_x: Option<u32>,
#[serde(default)]
hover_y: Option<u32>,
#[serde(default)]
typed_text: Option<String>,
#[serde(default)]
site_permissions: Vec<LiveSitePermission>,
#[serde(default)]
ready_surface_ids: Vec<u64>,
#[serde(default)]
pending_surface_ids: Vec<u64>,
},
Poll {
tab_id: String,
#[serde(default)]
ready_surface_ids: Vec<u64>,
#[serde(default)]
pending_surface_ids: Vec<u64>,
},
Close {
tab_id: String,
},
Shutdown,
}
const fn default_zoom_percent() -> u16 {
ely_domain::DEFAULT_ZOOM_PERCENT
}
const fn default_device_pixel_ratio() -> f32 {
1.0
}
#[derive(Debug, Deserialize)]
pub(super) struct LiveSitePermission {
pub(super) origin: String,
pub(super) feature: String,
pub(super) decision: String,
}
pub(super) struct LiveOutcome {
pub(super) response: LiveResponse,
pub(super) frame: Option<RenderedFrame>,
}
impl LiveOutcome {
pub(super) fn empty() -> Self {
Self { response: LiveResponse::empty(), frame: None }
}
pub(super) fn error(message: String) -> Self {
Self { response: LiveResponse::error(message), frame: None }
}
pub(super) fn frame(report: LiveFrameReport, frame: RenderedFrame) -> Self {
Self { response: LiveResponse::frame(report), frame: Some(frame) }
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) fn surface(report: LiveFrameReport) -> Self {
Self { response: LiveResponse::frame(report), frame: None }
}
}
#[derive(Debug, Serialize)]
pub(super) struct LiveResponse {
pub(super) protocol_version: u32,
pub(super) error: Option<String>,
pub(super) frame: Option<LiveFrameReport>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) surface_handle: Option<IOSurfaceHandle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) current_surface_id: Option<u64>,
}
impl LiveResponse {
fn empty() -> Self {
Self {
protocol_version: LIVE_PROTOCOL_VERSION,
error: None,
frame: None,
surface_handle: None,
current_surface_id: None,
}
}
fn error(message: String) -> Self {
Self {
protocol_version: LIVE_PROTOCOL_VERSION,
error: Some(message),
frame: None,
surface_handle: None,
current_surface_id: None,
}
}
fn frame(frame: LiveFrameReport) -> Self {
Self {
protocol_version: LIVE_PROTOCOL_VERSION,
error: None,
frame: Some(frame),
surface_handle: None,
current_surface_id: None,
}
}
}
#[derive(Debug, Serialize)]
pub(super) struct LiveFrameReport {
pub(super) loaded_url: Option<String>,
pub(super) title: Option<String>,
pub(super) state: &'static str,
pub(super) width: u32,
pub(super) height: u32,
pub(super) device_pixel_ratio: f32,
pub(super) css_viewport_width: u32,
pub(super) css_viewport_height: u32,
pub(super) rgba_byte_count: usize,
pub(super) pixels_changed: bool,
pub(super) non_white_pixel_count: u64,
pub(super) content_pixel_count: u64,
pub(super) sample_hash: u64,
}
impl LiveFrameReport {
pub(super) fn new(
snapshot: &WebViewSnapshot,
frame: &RenderedFrame,
device_pixel_ratio: f32,
pixels_changed: bool,
) -> Self {
let device_pixel_ratio = normalized_device_pixel_ratio(device_pixel_ratio);
let css_viewport_width = css_dimension(frame.width(), device_pixel_ratio);
let css_viewport_height = css_dimension(frame.height(), device_pixel_ratio);
Self {
loaded_url: snapshot.url().map(str::to_string),
title: snapshot.title().map(str::to_string),
state: state_label(snapshot.state()),
width: frame.width(),
height: frame.height(),
device_pixel_ratio,
css_viewport_width,
css_viewport_height,
rgba_byte_count: frame.rgba_bytes().len(),
pixels_changed,
non_white_pixel_count: frame.non_white_pixel_count(),
content_pixel_count: frame.content_pixel_count(),
sample_hash: frame.sample_hash(),
}
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) fn from_surface(
snapshot: &WebViewSnapshot,
width: u32,
height: u32,
device_pixel_ratio: f32,
pixels_changed: bool,
) -> Self {
let device_pixel_ratio = normalized_device_pixel_ratio(device_pixel_ratio);
Self {
loaded_url: snapshot.url().map(str::to_string),
title: snapshot.title().map(str::to_string),
state: state_label(snapshot.state()),
width,
height,
device_pixel_ratio,
css_viewport_width: css_dimension(width, device_pixel_ratio),
css_viewport_height: css_dimension(height, device_pixel_ratio),
rgba_byte_count: 0,
pixels_changed,
non_white_pixel_count: 0,
content_pixel_count: 0,
sample_hash: 0,
}
}
}
fn normalized_device_pixel_ratio(value: f32) -> f32 {
if value.is_finite() && value > 0.0 { value.clamp(0.5, 5.0) } else { 1.0 }
}
fn css_dimension(value: u32, device_pixel_ratio: f32) -> u32 {
((value as f32) / device_pixel_ratio).round().max(1.0) as u32
}
fn state_label(state: &WebViewState) -> &'static str {
match state {
WebViewState::Created => "created",
WebViewState::Loading => "loading",
WebViewState::Complete => "complete",
WebViewState::Sleeping => "sleeping",
WebViewState::Crashed => "crashed",
}
}
#[derive(Debug, Error)]
pub(super) enum LiveSidecarError {
#[error("live protocol handshake is required before sidecar requests")]
ProtocolHandshakeRequired,
#[error("live protocol mismatch: expected {expected}, received {actual}")]
ProtocolVersionMismatch { expected: u32, actual: u32 },
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[error("hardware rendering requires --iosurface-mach-service")]
IOSurfaceMachServiceRequired,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[error(
"hardware surface {surface_id} size {surface_width}x{surface_height} does not match frame report {frame_width}x{frame_height}"
)]
HardwareSurfaceReportMismatch {
surface_id: u64,
surface_width: u32,
surface_height: u32,
frame_width: u32,
frame_height: u32,
},
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[error("hardware IOSurface handle does not match the presented surface")]
HardwareSurfaceHandleMismatch,
#[error("sidecar process is already bound to profile {expected}; received {actual}")]
ProfileMismatch { expected: String, actual: String },
#[error("{input} requires both x and y coordinates")]
IncompletePoint { input: &'static str },
#[error("frame dimensions overflow the protocol byte count: {width}x{height}")]
FrameDimensionsOverflow { width: u32, height: u32 },
#[error("frame dimensions {width}x{height} exceed the {max_dimension}px dimension limit")]
InvalidFrameDimensions { width: u32, height: u32, max_dimension: u32 },
#[error("frame requires {bytes} bytes; the protocol limit is {limit}")]
FrameByteLimitExceeded { bytes: u64, limit: usize },
#[error("frame byte count mismatch: expected {expected}, received {actual}")]
FrameByteCountMismatch { expected: usize, actual: usize },
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
#[error(transparent)]
Host(#[from] ServoHostError),
#[error(transparent)]
Io(#[from] io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[error(transparent)]
IOSurfaceMach(#[from] IOSurfaceMachError),
}
pub(super) fn validated_frame_byte_count(
width: u32,
height: u32,
) -> Result<usize, LiveSidecarError> {
if width == 0 || height == 0 || width > MAX_FRAME_DIMENSION || height > MAX_FRAME_DIMENSION {
return Err(LiveSidecarError::InvalidFrameDimensions {
width,
height,
max_dimension: MAX_FRAME_DIMENSION,
});
}
let bytes = u64::from(width)
.checked_mul(u64::from(height))
.and_then(|pixels| pixels.checked_mul(4))
.ok_or(LiveSidecarError::FrameDimensionsOverflow { width, height })?;
if bytes > MAX_FRAME_BYTE_COUNT as u64 {
return Err(LiveSidecarError::FrameByteLimitExceeded {
bytes,
limit: MAX_FRAME_BYTE_COUNT,
});
}
Ok(bytes as usize)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ensure_defaults_optional_input_fields() -> Result<(), serde_json::Error> {
let request = serde_json::from_str::<LiveRequest>(
r#"{"type":"ensure","tab_id":"tab","profile_id":"profile","url":"https://example.com","width":800,"height":600}"#,
)?;
assert!(matches!(
request,
LiveRequest::Ensure {
page_zoom_percent: 100,
device_pixel_ratio: 1.0,
scroll_delta_x: 0,
scroll_delta_y: 0,
ready_surface_ids,
pending_surface_ids,
..
} if ready_surface_ids.is_empty() && pending_surface_ids.is_empty()
));
Ok(())
}
#[test]
fn handshake_deserializes_protocol_version() -> Result<(), serde_json::Error> {
let request =
serde_json::from_str::<LiveRequest>(r#"{"type":"handshake","protocol_version":2}"#)?;
assert!(matches!(request, LiveRequest::Handshake { protocol_version: 2 }));
Ok(())
}
#[test]
fn frame_layout_enforces_dimension_and_byte_limits() {
assert!(matches!(
validated_frame_byte_count(MAX_FRAME_DIMENSION + 1, 1),
Err(LiveSidecarError::InvalidFrameDimensions { .. })
));
assert!(matches!(
validated_frame_byte_count(MAX_FRAME_DIMENSION, MAX_FRAME_DIMENSION),
Err(LiveSidecarError::FrameByteLimitExceeded { .. })
));
}
#[test]
fn shutdown_deserializes_from_wire() -> Result<(), serde_json::Error> {
let request = serde_json::from_str::<LiveRequest>(r#"{"type":"shutdown"}"#)?;
assert!(matches!(request, LiveRequest::Shutdown));
Ok(())
}
}
@@ -0,0 +1,231 @@
use std::collections::{HashMap, hash_map::Entry};
use ely_domain::{ProfileId, TabId, validate_zoom_percent};
use ely_servo_host::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest, PageZoomRequest,
PermissionDecision, PermissionRequest, RenderedFrame, ResizeRequest, ScrollRequest, ServoHost,
ServoSurfaceSize, SoftwareServoHost,
};
use super::live_protocol::{LiveSidecarError, LiveSitePermission};
pub(super) struct LiveSession {
pub(super) webview_id: ely_domain::WebViewId,
pub(super) requested_url: String,
width: u32,
height: u32,
page_zoom_percent: u16,
hidpi_scale_milli: u32,
pub(super) last_frame: Option<RenderedFrame>,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) last_surface: Option<ely_servo_host::IOSurfaceIdentity>,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) last_surface_ready: bool,
}
impl LiveSession {
fn new(webview_id: ely_domain::WebViewId) -> Self {
Self {
webview_id,
requested_url: String::new(),
width: 0,
height: 0,
page_zoom_percent: ely_domain::DEFAULT_ZOOM_PERCENT,
hidpi_scale_milli: 0,
last_frame: None,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
last_surface: None,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
last_surface_ready: false,
}
}
pub(super) fn device_pixel_ratio(&self) -> f32 {
self.hidpi_scale_milli as f32 / 1_000.0
}
pub(super) fn clear_presented_frame(&mut self) {
self.last_frame = None;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
{
self.last_surface = None;
self.last_surface_ready = false;
}
}
}
pub(super) fn bind_profile(
active_profile: &mut Option<ProfileId>,
profile: &ProfileId,
) -> Result<(), LiveSidecarError> {
match active_profile {
Some(expected) if expected != profile => Err(LiveSidecarError::ProfileMismatch {
expected: expected.to_string(),
actual: profile.to_string(),
}),
Some(_) => Ok(()),
None => {
*active_profile = Some(profile.clone());
Ok(())
}
}
}
pub(super) fn ensure_session<'a>(
host: &mut SoftwareServoHost,
sessions: &'a mut HashMap<String, LiveSession>,
key: String,
tab_id: &TabId,
profile_id: &ProfileId,
width: u32,
height: u32,
) -> Result<&'a mut LiveSession, LiveSidecarError> {
match sessions.entry(key) {
Entry::Occupied(entry) => Ok(entry.into_mut()),
Entry::Vacant(entry) => {
let webview_id = host.create_webview_with_size(
tab_id.clone(),
profile_id.clone(),
ServoSurfaceSize::new(width, height),
)?;
Ok(entry.insert(LiveSession::new(webview_id)))
}
}
}
pub(super) fn apply_layout(
host: &mut SoftwareServoHost,
session: &mut LiveSession,
width: u32,
height: u32,
page_zoom_percent: u16,
device_pixel_ratio: f32,
) -> Result<(), LiveSidecarError> {
let page_zoom_percent = validate_zoom_percent(page_zoom_percent)?;
let hidpi_scale_milli = encode_hidpi_scale_milli(device_pixel_ratio);
if session.hidpi_scale_milli != hidpi_scale_milli {
session.clear_presented_frame();
host.set_hidpi_scale(HidpiScaleRequest {
webview_id: session.webview_id.clone(),
scale_factor: hidpi_scale_milli as f32 / 1_000.0,
})?;
session.hidpi_scale_milli = hidpi_scale_milli;
}
if session.width != width || session.height != height {
session.clear_presented_frame();
host.resize(ResizeRequest { webview_id: session.webview_id.clone(), width, height })?;
session.width = width;
session.height = height;
}
if session.page_zoom_percent != page_zoom_percent {
session.clear_presented_frame();
host.set_page_zoom(PageZoomRequest {
webview_id: session.webview_id.clone(),
zoom_factor: f32::from(page_zoom_percent) / 100.0,
})?;
session.page_zoom_percent = page_zoom_percent;
}
Ok(())
}
pub(super) fn apply_permissions(
host: &mut SoftwareServoHost,
session: &LiveSession,
profile_id: &ProfileId,
permissions: Vec<LiveSitePermission>,
) -> Result<(), LiveSidecarError> {
for permission in permissions {
host.set_permission(
PermissionRequest {
webview_id: session.webview_id.clone(),
profile_id: profile_id.clone(),
origin: ely_domain::SiteOrigin::parse(permission.origin)?,
feature: ely_domain::SitePermissionFeature::parse(&permission.feature)?,
},
PermissionDecision::from(ely_domain::SitePermissionDecision::parse(
&permission.decision,
)?),
)?;
}
Ok(())
}
pub(super) struct LiveInput {
pub(super) scroll_delta_x: i32,
pub(super) scroll_delta_y: i32,
pub(super) scroll_point_x: Option<u32>,
pub(super) scroll_point_y: Option<u32>,
pub(super) click_x: Option<u32>,
pub(super) click_y: Option<u32>,
pub(super) hover_x: Option<u32>,
pub(super) hover_y: Option<u32>,
pub(super) typed_text: Option<String>,
}
pub(super) fn apply_input(
host: &mut SoftwareServoHost,
session: &LiveSession,
input: LiveInput,
) -> Result<(), LiveSidecarError> {
if input.scroll_delta_x != 0 || input.scroll_delta_y != 0 {
let (point_x, point_y) =
paired_point("scroll input", input.scroll_point_x, input.scroll_point_y)?;
host.scroll(ScrollRequest {
webview_id: session.webview_id.clone(),
delta_x: input.scroll_delta_x,
delta_y: input.scroll_delta_y,
point_x,
point_y,
})?;
}
if input.hover_x.is_some() || input.hover_y.is_some() {
let (x, y) = paired_point("hover input", input.hover_x, input.hover_y)?;
host.hover(MouseHoverRequest { webview_id: session.webview_id.clone(), x, y })?;
}
if input.click_x.is_some() || input.click_y.is_some() {
let (x, y) = paired_point("click input", input.click_x, input.click_y)?;
host.click(MouseClickRequest { webview_id: session.webview_id.clone(), x, y })?;
}
if let Some(text) = input.typed_text {
host.type_text(KeyboardTextRequest { webview_id: session.webview_id.clone(), text })?;
}
Ok(())
}
fn paired_point(
input: &'static str,
x: Option<u32>,
y: Option<u32>,
) -> Result<(u32, u32), LiveSidecarError> {
match (x, y) {
(Some(x), Some(y)) => Ok((x, y)),
_ => Err(LiveSidecarError::IncompletePoint { input }),
}
}
fn encode_hidpi_scale_milli(scale: f32) -> u32 {
if scale.is_finite() && scale > 0.0 {
(scale * 1_000.0).round().clamp(500.0, 5_000.0) as u32
} else {
1_000
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn process_binds_to_first_profile() -> Result<(), LiveSidecarError> {
let first = ProfileId::new();
let second = ProfileId::new();
let mut active = None;
bind_profile(&mut active, &first)?;
assert!(matches!(
bind_profile(&mut active, &second),
Err(LiveSidecarError::ProfileMismatch { .. })
));
Ok(())
}
}
@@ -0,0 +1,169 @@
use std::collections::{HashMap, HashSet};
use ely_servo_host::{IOSurfaceIdentity, SoftwareServoHost};
use super::{
iosurface_mach::IOSurfaceMachSender,
live_protocol::{LiveOutcome, LiveSidecarError},
};
pub(super) struct HardwareSurfaceTransport {
sender: IOSurfaceMachSender,
publications: SurfacePublications,
}
#[derive(Default)]
struct SurfacePublications {
by_tab: HashMap<String, HashMap<IOSurfaceIdentity, PublicationState>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PublicationState {
AwaitingReady,
Ready,
}
impl HardwareSurfaceTransport {
pub(super) fn connect(service_name: &str) -> Result<Self, LiveSidecarError> {
Ok(Self {
sender: IOSurfaceMachSender::connect(service_name)?,
publications: SurfacePublications::default(),
})
}
pub(super) fn publish_frame(
&mut self,
host: &SoftwareServoHost,
tab_id: &str,
webview_id: &ely_domain::WebViewId,
ready_surface_ids: &[u64],
pending_surface_ids: &[u64],
outcome: &mut LiveOutcome,
) -> Result<(), LiveSidecarError> {
self.publications.sync_client_state(tab_id, ready_surface_ids, pending_surface_ids);
let Some(report) = outcome.response.frame.as_ref() else {
return Ok(());
};
let identity = host.peek_iosurface_identity(webview_id)?.ok_or_else(|| {
ely_servo_host::ServoHostError::HardwareSurfaceUnavailable { id: webview_id.clone() }
})?;
validate_report(identity, report.width, report.height)?;
outcome.response.current_surface_id = Some(identity.surface_id);
if self.publications.contains(tab_id, identity) {
return Ok(());
}
let handle = host.current_iosurface_handle(webview_id)?.ok_or_else(|| {
ely_servo_host::ServoHostError::HardwareSurfaceUnavailable { id: webview_id.clone() }
})?;
if IOSurfaceIdentity::from_handle(handle) != identity {
return Err(LiveSidecarError::HardwareSurfaceHandleMismatch);
}
self.sender.send_surface_port(handle.surface_id, handle.mach_port_name)?;
outcome.response.surface_handle = Some(handle);
self.publications.insert(tab_id, identity);
Ok(())
}
pub(super) fn close_tab(&mut self, tab_id: &str) {
self.publications.remove(tab_id);
}
}
impl SurfacePublications {
fn sync_client_state(
&mut self,
tab_id: &str,
ready_surface_ids: &[u64],
pending_surface_ids: &[u64],
) {
let ready: HashSet<u64> = ready_surface_ids.iter().copied().collect();
let pending: HashSet<u64> = pending_surface_ids.iter().copied().collect();
let Some(surfaces) = self.by_tab.get_mut(tab_id) else {
return;
};
surfaces.retain(|identity, state| match state {
PublicationState::AwaitingReady => {
if ready.contains(&identity.surface_id) {
*state = PublicationState::Ready;
}
ready.contains(&identity.surface_id) || pending.contains(&identity.surface_id)
}
PublicationState::Ready => ready.contains(&identity.surface_id),
});
}
fn contains(&self, tab_id: &str, identity: IOSurfaceIdentity) -> bool {
self.by_tab.get(tab_id).is_some_and(|surfaces| surfaces.contains_key(&identity))
}
fn insert(&mut self, tab_id: &str, identity: IOSurfaceIdentity) {
self.by_tab
.entry(tab_id.to_string())
.or_default()
.insert(identity, PublicationState::AwaitingReady);
}
fn remove(&mut self, tab_id: &str) {
self.by_tab.remove(tab_id);
}
}
fn validate_report(
identity: IOSurfaceIdentity,
frame_width: u32,
frame_height: u32,
) -> Result<(), LiveSidecarError> {
if identity.width == frame_width && identity.height == frame_height {
return Ok(());
}
Err(LiveSidecarError::HardwareSurfaceReportMismatch {
surface_id: identity.surface_id,
surface_width: identity.width,
surface_height: identity.height,
frame_width,
frame_height,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn surface_publication_tracks_pending_ready_and_evicted_states() {
let identity = IOSurfaceIdentity { surface_id: 7, width: 800, height: 600 };
let mut publications = SurfacePublications::default();
publications.insert("tab", identity);
publications.sync_client_state("tab", &[], &[7]);
assert!(publications.contains("tab", identity));
publications.sync_client_state("tab", &[7], &[]);
assert!(publications.contains("tab", identity));
publications.sync_client_state("tab", &[], &[]);
assert!(!publications.contains("tab", identity));
publications.insert("tab", identity);
publications.sync_client_state("tab", &[], &[7]);
assert!(publications.contains("tab", identity));
publications.sync_client_state("tab", &[7], &[]);
assert!(publications.contains("tab", identity));
}
#[test]
fn seventeenth_surface_missing_before_ready_is_republished() {
let mut publications = SurfacePublications::default();
let identities = (1..=17)
.map(|surface_id| IOSurfaceIdentity { surface_id, width: 64, height: 48 })
.collect::<Vec<_>>();
for identity in &identities {
publications.insert("tab", *identity);
}
publications.sync_client_state("tab", &[], &(1..=17).collect::<Vec<_>>());
publications.sync_client_state("tab", &[], &(2..=17).collect::<Vec<_>>());
assert!(!publications.contains("tab", identities[0]));
assert!(publications.contains("tab", identities[16]));
}
}
+9
View File
@@ -24,6 +24,15 @@ pub enum ServoHostError {
#[error("servo rendering context could not be made current")]
RenderingContextNotCurrent,
#[error(
"hardware rendering requires the `hardware-render` feature; rebuild with \
--features servo-engine,hardware-render"
)]
HardwareRenderUnavailable,
#[error("servo rendered frame is unavailable")]
RenderedFrameUnavailable,
#[error("servo hardware surface is unavailable for {id}")]
HardwareSurfaceUnavailable { id: WebViewId },
}
@@ -0,0 +1,466 @@
//! Headless hardware rendering context backed by Surfman.
//!
//! Servo keeps its Surfman constructor private. This module mirrors the small
//! portion required to create a hardware `SurfaceType::Generic` context and,
//! on macOS, retain the presented IOSurface for cross-process import.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;
use dpi::PhysicalSize;
use euclid::Size2D;
use gleam::gl::{self, Gl};
use image::RgbaImage;
use servo::{DeviceIntRect, RenderingContext};
#[cfg(target_os = "macos")]
use surfman::cgl::surface::NativeSurface;
use surfman::chains::{PreserveBuffer, SwapChain, SwapChainAPI};
use surfman::{
Connection, Context, ContextAttributeFlags, ContextAttributes, Device, Error as SurfmanError,
GLApi, NativeWidget, Surface, SurfaceAccess, SurfaceType,
};
#[cfg(target_os = "macos")]
use crate::{IOSurfaceHandle, IOSurfaceIdentity};
/// A hardware-backed offscreen Servo rendering context.
pub struct HardwareOffscreenContext {
size: Cell<PhysicalSize<u32>>,
inner: SurfmanInner,
swap_chain: SwapChain<Device>,
#[cfg(target_os = "macos")]
held_presented_surfaces: RefCell<Vec<HeldPresentedSurface>>,
#[cfg(target_os = "macos")]
last_presented_iosurface: RefCell<Option<PresentedIOSurface>>,
}
impl HardwareOffscreenContext {
/// Creates a hardware context with a generic offscreen surface.
pub fn new(size: PhysicalSize<u32>) -> Result<Self, SurfmanError> {
if size.width == 0 || size.height == 0 {
return Err(SurfmanError::Failed);
}
let connection = Connection::new()?;
let adapter = connection.create_adapter()?;
let inner = SurfmanInner::new(&connection, &adapter)?;
let surface = inner.create_surface(SurfaceType::Generic {
size: Size2D::new(size.width as i32, size.height as i32),
})?;
inner.bind_surface(surface)?;
inner.make_current()?;
let swap_chain = inner.create_attached_swap_chain()?;
Ok(Self {
size: Cell::new(size),
inner,
swap_chain,
#[cfg(target_os = "macos")]
held_presented_surfaces: RefCell::new(Vec::new()),
#[cfg(target_os = "macos")]
last_presented_iosurface: RefCell::new(None),
})
}
}
impl Drop for HardwareOffscreenContext {
fn drop(&mut self) {
let device = self.inner.device.borrow();
let context = &mut self.inner.context.borrow_mut();
#[cfg(target_os = "macos")]
self.destroy_held_presented_surfaces(&device, context);
let _ = self.swap_chain.destroy(&device, context);
}
}
impl RenderingContext for HardwareOffscreenContext {
fn prepare_for_rendering(&self) {
self.inner.prepare_for_rendering();
}
fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option<RgbaImage> {
self.inner.read_to_image(source_rectangle)
}
fn size(&self) -> PhysicalSize<u32> {
self.size.get()
}
fn resize(&self, size: PhysicalSize<u32>) {
if self.size.get() == size || size.width == 0 || size.height == 0 {
return;
}
let device = self.inner.device.borrow();
let context = &mut self.inner.context.borrow_mut();
#[cfg(target_os = "macos")]
self.destroy_held_presented_surfaces(&device, context);
let surfman_size = Size2D::new(size.width as i32, size.height as i32);
if self.swap_chain.resize(&device, context, surfman_size).is_ok() {
self.size.set(size);
}
}
fn present(&self) {
let device = self.inner.device.borrow();
let context = &mut self.inner.context.borrow_mut();
#[cfg(target_os = "macos")]
self.recycle_acknowledged_surfaces();
#[cfg(target_os = "macos")]
self.last_presented_iosurface.borrow_mut().take();
if self.swap_chain.swap_buffers(&device, context, PreserveBuffer::No).is_err() {
return;
}
#[cfg(target_os = "macos")]
{
self.capture_presented_iosurface(&device);
self.recycle_acknowledged_surfaces();
}
}
fn make_current(&self) -> Result<(), SurfmanError> {
self.inner.make_current()
}
fn gleam_gl_api(&self) -> Rc<dyn Gl> {
self.inner.gleam_gl.clone()
}
fn glow_gl_api(&self) -> Arc<glow::Context> {
self.inner.glow_gl.clone()
}
fn connection(&self) -> Option<Connection> {
Some(self.inner.device.borrow().connection())
}
}
#[cfg(target_os = "macos")]
impl HardwareOffscreenContext {
/// Returns the identity of the most recently presented IOSurface.
pub fn peek_iosurface_identity(&self) -> Result<Option<IOSurfaceIdentity>, SurfmanError> {
Ok(self.last_presented_iosurface.borrow().as_ref().map(|surface| surface.identity))
}
/// Creates a Mach send right for the most recently presented IOSurface.
pub fn current_iosurface_mach_port(&self) -> Result<IOSurfaceHandle, SurfmanError> {
let presented = self.last_presented_iosurface.borrow();
let presented = presented.as_ref().ok_or(SurfmanError::Failed)?;
let mach_port_name = presented.native.0.create_mach_port();
if mach_port_name == 0 {
return Err(SurfmanError::Failed);
}
Ok(IOSurfaceHandle {
mach_port_name,
surface_id: presented.identity.surface_id,
width: presented.identity.width,
height: presented.identity.height,
})
}
/// Marks IOSurface IDs reported ready after import by the app process.
///
/// The ready acknowledgement confirms import. Acknowledged surfaces remain
/// retained while current or while `IOSurfaceIsInUse` reports active
/// consumer work. Recycling begins after that consumer work completes.
pub fn acknowledge_iosurfaces(&self, surface_ids: &[u64]) {
{
let mut held = self.held_presented_surfaces.borrow_mut();
for surface in held.iter_mut() {
if surface_ids.contains(&surface.presented.identity.surface_id) {
surface.acknowledged = true;
}
}
}
self.recycle_acknowledged_surfaces();
}
fn capture_presented_iosurface(&self, device: &Device) {
let Some(surface) = self.swap_chain.take_pending_surface() else {
self.last_presented_iosurface.borrow_mut().take();
return;
};
let info = device.surface_info(&surface);
let native = device.native_surface(&surface);
let identity = IOSurfaceIdentity {
surface_id: u64::from(native.0.id()),
width: u32::try_from(info.size.width).unwrap_or(0),
height: u32::try_from(info.size.height).unwrap_or(0),
};
let presented = PresentedIOSurface { identity, native };
self.held_presented_surfaces.borrow_mut().push(HeldPresentedSurface {
surface,
presented: presented.clone(),
acknowledged: false,
});
self.last_presented_iosurface.replace(Some(presented));
}
fn recycle_acknowledged_surfaces(&self) {
let current_id = self
.last_presented_iosurface
.borrow()
.as_ref()
.map(|surface| surface.identity.surface_id);
let mut held = self.held_presented_surfaces.borrow_mut();
let mut index = 0;
while index < held.len() {
let surface = &held[index];
let can_recycle = surface.acknowledged
&& Some(surface.presented.identity.surface_id) != current_id
&& !surface.presented.native.0.is_in_use();
if can_recycle {
let surface = held.swap_remove(index);
self.swap_chain.recycle_surface(surface.surface);
} else {
index += 1;
}
}
}
fn destroy_held_presented_surfaces(&self, device: &Device, context: &mut Context) {
self.last_presented_iosurface.borrow_mut().take();
let held = self.held_presented_surfaces.take();
for mut surface in held {
let _ = device.destroy_surface(context, &mut surface.surface);
}
}
}
#[cfg(target_os = "macos")]
struct HeldPresentedSurface {
surface: Surface,
presented: PresentedIOSurface,
acknowledged: bool,
}
#[cfg(target_os = "macos")]
#[derive(Clone)]
struct PresentedIOSurface {
identity: IOSurfaceIdentity,
native: NativeSurface,
}
struct SurfmanInner {
gleam_gl: Rc<dyn Gl>,
glow_gl: Arc<glow::Context>,
device: RefCell<Device>,
context: RefCell<Context>,
}
impl Drop for SurfmanInner {
fn drop(&mut self) {
let device = self.device.borrow();
let context = &mut self.context.borrow_mut();
let _ = device.destroy_context(context);
}
}
impl SurfmanInner {
fn new(connection: &Connection, adapter: &surfman::Adapter) -> Result<Self, SurfmanError> {
let device = connection.create_device(adapter)?;
let flags = ContextAttributeFlags::ALPHA
| ContextAttributeFlags::DEPTH
| ContextAttributeFlags::STENCIL;
let gl_api = connection.gl_api();
let version = match gl_api {
GLApi::GLES => surfman::GLVersion { major: 3, minor: 0 },
GLApi::GL => surfman::GLVersion { major: 3, minor: 2 },
};
let descriptor = device.create_context_descriptor(&ContextAttributes { flags, version })?;
let context = device.create_context(&descriptor, None)?;
// Surfman owns the current platform GL implementation and supplies matching ABI symbols.
#[expect(unsafe_code)]
let gleam_gl = match gl_api {
GLApi::GL => unsafe {
gl::GlFns::load_with(|name| device.get_proc_address(&context, name))
},
GLApi::GLES => unsafe {
gl::GlesFns::load_with(|name| device.get_proc_address(&context, name))
},
};
// The loader remains valid for the lifetime of the Surfman device and context below.
#[expect(unsafe_code)]
let glow_gl = unsafe {
glow::Context::from_loader_function(|name| device.get_proc_address(&context, name))
};
Ok(Self {
gleam_gl,
glow_gl: Arc::new(glow_gl),
device: RefCell::new(device),
context: RefCell::new(context),
})
}
fn create_surface(
&self,
surface_type: SurfaceType<NativeWidget>,
) -> Result<Surface, SurfmanError> {
self.device.borrow().create_surface(
&self.context.borrow(),
SurfaceAccess::GPUOnly,
surface_type,
)
}
fn bind_surface(&self, surface: Surface) -> Result<(), SurfmanError> {
let device = self.device.borrow();
let context = &mut self.context.borrow_mut();
device.bind_surface_to_context(context, surface).map_err(|(error, mut surface)| {
let _ = device.destroy_surface(context, &mut surface);
error
})
}
fn create_attached_swap_chain(&self) -> Result<SwapChain<Device>, SurfmanError> {
SwapChain::create_attached(
&self.device.borrow(),
&mut self.context.borrow_mut(),
SurfaceAccess::GPUOnly,
)
}
fn make_current(&self) -> Result<(), SurfmanError> {
self.device.borrow().make_context_current(&self.context.borrow())
}
fn framebuffer_id(&self) -> u32 {
self.device
.borrow()
.context_surface_info(&self.context.borrow())
.unwrap_or(None)
.and_then(|info| info.framebuffer_object)
.map_or(0, |framebuffer| framebuffer.0.into())
}
fn prepare_for_rendering(&self) {
self.gleam_gl.bind_framebuffer(gl::FRAMEBUFFER, self.framebuffer_id());
}
fn read_to_image(&self, source_rectangle: DeviceIntRect) -> Option<RgbaImage> {
self.gleam_gl.bind_framebuffer(gl::FRAMEBUFFER, self.framebuffer_id());
self.gleam_gl.bind_vertex_array(0);
let mut pixels = self.gleam_gl.read_pixels(
source_rectangle.min.x,
source_rectangle.min.y,
source_rectangle.width(),
source_rectangle.height(),
gl::RGBA,
gl::UNSIGNED_BYTE,
);
if self.gleam_gl.get_error() != gl::NO_ERROR {
return None;
}
let rectangle = source_rectangle.to_usize();
let stride = rectangle.width().checked_mul(4)?;
let original = pixels.clone();
for y in 0..rectangle.height() {
let destination_start = y.checked_mul(stride)?;
let source_start = rectangle.height().checked_sub(y + 1)?.checked_mul(stride)?;
let destination_end = destination_start.checked_add(stride)?;
let source_end = source_start.checked_add(stride)?;
pixels
.get_mut(destination_start..destination_end)?
.copy_from_slice(original.get(source_start..source_end)?);
}
RgbaImage::from_raw(rectangle.width() as u32, rectangle.height() as u32, pixels)
}
}
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
fn present(context: &HardwareOffscreenContext) -> Result<IOSurfaceIdentity, String> {
context.make_current().map_err(|error| format!("make current failed: {error:?}"))?;
context.prepare_for_rendering();
context.present();
context
.peek_iosurface_identity()
.map_err(|error| format!("identity probe failed: {error:?}"))?
.ok_or_else(|| "present did not expose an IOSurface".to_string())
}
fn held_ids(context: &HardwareOffscreenContext) -> Vec<u64> {
context
.held_presented_surfaces
.borrow()
.iter()
.map(|surface| surface.presented.identity.surface_id)
.collect()
}
#[test]
fn acknowledgement_recycles_only_noncurrent_surfaces() -> Result<(), String> {
let context = HardwareOffscreenContext::new(PhysicalSize::new(64, 48))
.map_err(|error| format!("hardware context creation failed: {error:?}"))?;
let first = present(&context)?;
let second = present(&context)?;
assert_ne!(first.surface_id, second.surface_id);
assert_eq!(held_ids(&context).len(), 2);
context.acknowledge_iosurfaces(&[first.surface_id]);
assert_eq!(held_ids(&context), vec![second.surface_id]);
context.acknowledge_iosurfaces(&[second.surface_id]);
assert_eq!(held_ids(&context), vec![second.surface_id]);
Ok(())
}
#[test]
fn acknowledged_surface_waits_for_iosurface_use_to_finish() -> Result<(), String> {
let context = HardwareOffscreenContext::new(PhysicalSize::new(64, 48))
.map_err(|error| format!("hardware context creation failed: {error:?}"))?;
let first = present(&context)?;
let second = present(&context)?;
let native = context
.held_presented_surfaces
.borrow()
.iter()
.find(|surface| surface.presented.identity == first)
.map(|surface| surface.presented.native.0.clone())
.ok_or_else(|| "first IOSurface was not retained".to_string())?;
native.increment_use_count();
context.acknowledge_iosurfaces(&[first.surface_id]);
let retained_while_in_use = held_ids(&context).contains(&first.surface_id);
native.decrement_use_count();
assert!(retained_while_in_use);
context.acknowledge_iosurfaces(&[]);
assert_eq!(held_ids(&context), vec![second.surface_id]);
Ok(())
}
#[test]
fn resize_destroys_all_retained_presentations() -> Result<(), String> {
let context = HardwareOffscreenContext::new(PhysicalSize::new(64, 48))
.map_err(|error| format!("hardware context creation failed: {error:?}"))?;
let _ = present(&context)?;
let _ = present(&context)?;
assert_eq!(held_ids(&context).len(), 2);
context.resize(PhysicalSize::new(96, 72));
assert!(held_ids(&context).is_empty());
assert_eq!(context.size(), PhysicalSize::new(96, 72));
assert_eq!(
context
.peek_iosurface_identity()
.map_err(|error| format!("identity probe failed: {error:?}"))?,
None
);
let resized = present(&context)?;
assert_eq!((resized.width, resized.height), (96, 72));
Ok(())
}
}
@@ -0,0 +1,29 @@
//! Cross-process IOSurface descriptors used by the macOS hardware path.
/// A send right for importing the current IOSurface in another process.
///
/// `surface_id` is the system IOSurface ID. The receiver owns the transferred
/// Mach send right and must deallocate it after importing the surface.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[cfg_attr(feature = "servo-engine", derive(serde::Deserialize, serde::Serialize))]
pub struct IOSurfaceHandle {
pub mach_port_name: u32,
pub surface_id: u64,
pub width: u32,
pub height: u32,
}
/// Stable identity of a presented IOSurface without creating a Mach port.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct IOSurfaceIdentity {
pub surface_id: u64,
pub width: u32,
pub height: u32,
}
impl IOSurfaceIdentity {
#[must_use]
pub fn from_handle(handle: IOSurfaceHandle) -> Self {
Self { surface_id: handle.surface_id, width: handle.width, height: handle.height }
}
}
+6
View File
@@ -1,5 +1,8 @@
mod error;
#[cfg(feature = "hardware-render")]
mod hardware_rendering_context;
mod host;
mod iosurface_handle;
#[cfg(feature = "servo-engine")]
mod keyboard;
#[cfg(feature = "servo-engine")]
@@ -14,11 +17,14 @@ mod runtime_waker;
mod runtime_webview;
pub use error::ServoHostError;
#[cfg(feature = "hardware-render")]
pub use hardware_rendering_context::HardwareOffscreenContext;
pub use host::{
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseDragRequest, MouseHoverRequest,
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, RenderedFrame,
RenderedFrameSummary, ResizeRequest, ScrollRequest, ServoHost, TouchTapRequest,
WebViewSnapshot, WebViewSnapshotPending, WebViewState,
};
pub use iosurface_handle::{IOSurfaceHandle, IOSurfaceIdentity};
#[cfg(feature = "servo-engine")]
pub use runtime::{RenderingContextKind, ServoSurfaceSize, SoftwareServoHost};
+6 -1
View File
@@ -19,6 +19,9 @@ use servo::{
#[path = "runtime_context.rs"]
mod runtime_context;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[path = "runtime_hardware.rs"]
mod runtime_hardware;
#[path = "runtime_paint.rs"]
mod runtime_paint;
@@ -365,7 +368,7 @@ impl ServoHost for SoftwareServoHost {
}
fn paint(&mut self, webview_id: &WebViewId) -> Result<(), ServoHostError> {
self.paint_with_readback(webview_id, true)
self.paint_with_readback(webview_id)
}
fn last_rendered_frame(&self) -> Result<RenderedFrame, ServoHostError> {
@@ -408,6 +411,8 @@ impl SoftwareServoHost {
tab_id,
profile_id,
rendering_context: handles.rendering_context,
#[cfg(feature = "hardware-render")]
hardware_context: handles.hardware_context,
webview,
delegate,
requested_url: None,
+30 -58
View File
@@ -1,10 +1,4 @@
use std::{
env,
rc::Rc,
sync::OnceLock,
thread,
time::{Duration, Instant},
};
use std::rc::Rc;
use dpi::PhysicalSize;
use euclid::Scale;
@@ -17,9 +11,6 @@ use servo::{
use super::SoftwareServoHost;
use crate::{RenderedFrame, ServoHostError};
const DEFAULT_PAINT_BARRIER_MS: u64 = 32;
const PAINT_BARRIER_POLL_INTERVAL: Duration = Duration::from_millis(2);
/// Wrap an `f32` scale factor in Servo's typed `Scale<f32, DeviceIndependentPixel,
/// DevicePixel>`. The clamp guards against `NaN`/`inf` reaching Servo's
/// layout (which assumes a positive finite scale).
@@ -34,17 +25,6 @@ pub(super) fn hidpi_scale_from_factor(
Scale::new(safe)
}
fn paint_barrier_budget() -> Duration {
static BUDGET: OnceLock<Duration> = OnceLock::new();
*BUDGET.get_or_init(|| {
let ms = env::var("ELY_PAINT_BARRIER_MS")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.unwrap_or(DEFAULT_PAINT_BARRIER_MS);
Duration::from_millis(ms)
})
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ServoSurfaceSize {
width: u32,
@@ -67,12 +47,15 @@ impl ServoSurfaceSize {
pub enum RenderingContextKind {
#[default]
Software,
Hardware,
}
/// Pair of rendering-context handles produced by
/// [`SoftwareServoHost::new_rendering_context`].
pub(super) struct RenderingContextHandles {
pub(super) rendering_context: Rc<dyn RenderingContext>,
#[cfg(feature = "hardware-render")]
pub(super) hardware_context: Option<Rc<crate::HardwareOffscreenContext>>,
}
impl SoftwareServoHost {
@@ -89,8 +72,28 @@ impl SoftwareServoHost {
rendering_context
.make_current()
.map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
Ok(RenderingContextHandles { rendering_context })
Ok(RenderingContextHandles {
rendering_context,
#[cfg(feature = "hardware-render")]
hardware_context: None,
})
}
#[cfg(feature = "hardware-render")]
RenderingContextKind::Hardware => {
let hardware_context = Rc::new(
crate::HardwareOffscreenContext::new(size.physical())
.map_err(|_| ServoHostError::RenderingContextUnavailable)?,
);
hardware_context
.make_current()
.map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
Ok(RenderingContextHandles {
rendering_context: hardware_context.clone(),
hardware_context: Some(hardware_context),
})
}
#[cfg(not(feature = "hardware-render"))]
RenderingContextKind::Hardware => Err(ServoHostError::HardwareRenderUnavailable),
}
}
@@ -113,42 +116,11 @@ impl SoftwareServoHost {
.map_err(|_| ServoHostError::RenderingContextUnavailable)?,
);
rendering_context.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
Ok(RenderingContextHandles { rendering_context })
}
/// Spin Servo's event loop until the webview's delegate observes a
/// fresh `notify_new_frame_ready` callback (i.e. the framebuffer is
/// consistent for readback) or [`paint_barrier_budget`] elapses. The
/// caller is responsible for clearing the pending-frame flag before
/// dispatching `webview.paint()`; otherwise this returns immediately
/// off the *previous* frame and the race is preserved.
///
/// Returns silently on timeout — `paint()` falls through to
/// `read_rendered_frame` so callers still get whatever pixels the
/// rendering context currently holds. That keeps the fast path open
/// when `ELY_PAINT_BARRIER_MS=0` disables the budget entirely, and
/// matches the pre-T15 behaviour on the (rare) case where Servo
/// can't land a frame inside two refresh intervals.
pub(super) fn wait_for_paint_completion(&mut self, webview_id: &ely_domain::WebViewId) {
let budget = paint_barrier_budget();
if budget.is_zero() {
return;
}
let started_at = Instant::now();
loop {
self.servo.spin_event_loop();
let ready = self
.webviews
.get(webview_id)
.is_some_and(|webview| webview.delegate.has_pending_frame());
if ready {
return;
}
if started_at.elapsed() >= budget {
return;
}
thread::sleep(PAINT_BARRIER_POLL_INTERVAL);
}
Ok(RenderingContextHandles {
rendering_context,
#[cfg(feature = "hardware-render")]
hardware_context: None,
})
}
pub(super) fn read_rendered_frame(
@@ -0,0 +1,51 @@
use ely_domain::WebViewId;
use super::SoftwareServoHost;
use crate::{IOSurfaceHandle, IOSurfaceIdentity, ServoHostError};
impl SoftwareServoHost {
/// Marks IOSurface IDs reported ready after import by the app process.
///
/// `IOSurfaceIsInUse == false` supplies the consumer-completion check used
/// before the rendering context recycles an acknowledged surface.
pub fn acknowledge_iosurfaces(
&self,
webview_id: &WebViewId,
surface_ids: &[u64],
) -> Result<(), ServoHostError> {
let webview = self.webview(webview_id)?;
if let Some(hardware_context) = webview.hardware_context.as_ref() {
hardware_context.acknowledge_iosurfaces(surface_ids);
}
Ok(())
}
/// Returns the most recently presented IOSurface identity for a hardware webview.
pub fn peek_iosurface_identity(
&self,
webview_id: &WebViewId,
) -> Result<Option<IOSurfaceIdentity>, ServoHostError> {
let webview = self.webview(webview_id)?;
let Some(hardware_context) = webview.hardware_context.as_ref() else {
return Ok(None);
};
hardware_context
.peek_iosurface_identity()
.map_err(|_| ServoHostError::HardwareSurfaceUnavailable { id: webview_id.clone() })
}
/// Creates a Mach send right for the most recently presented IOSurface.
pub fn current_iosurface_handle(
&self,
webview_id: &WebViewId,
) -> Result<Option<IOSurfaceHandle>, ServoHostError> {
let webview = self.webview(webview_id)?;
let Some(hardware_context) = webview.hardware_context.as_ref() else {
return Ok(None);
};
hardware_context
.current_iosurface_mach_port()
.map(Some)
.map_err(|_| ServoHostError::HardwareSurfaceUnavailable { id: webview_id.clone() })
}
}
@@ -20,7 +20,6 @@ pub(super) fn send_mouse_click(webview: &WebView, x: u32, y: u32) {
pub(super) fn send_mouse_drag(webview: &WebView, from_x: u32, from_y: u32, to_x: u32, to_y: u32) {
let from = point(from_x, from_y);
let to = point(to_x, to_y);
webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(from)));
send_mouse_button(webview, MouseButtonAction::Down, from);
webview.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(to)));
send_mouse_button(webview, MouseButtonAction::Up, to);
+30 -26
View File
@@ -1,7 +1,7 @@
use ely_domain::WebViewId;
use super::SoftwareServoHost;
use crate::{RenderedFrame, ServoHostError};
use crate::{RenderedFrame, ServoHostError, runtime_webview::HostWebViewDelegate};
/// Repaint and present coordination for [`SoftwareServoHost`].
///
@@ -13,35 +13,20 @@ use crate::{RenderedFrame, ServoHostError};
impl SoftwareServoHost {
/// Paint and present the current surface without RGBA readback.
pub fn paint_without_readback(&mut self, webview_id: &WebViewId) -> Result<(), ServoHostError> {
self.paint_without_readback_with_completion(webview_id, true)
}
pub fn paint_without_readback_with_completion(
&mut self,
webview_id: &WebViewId,
wait_for_completion: bool,
) -> Result<(), ServoHostError> {
self.paint_webview(webview_id, false, wait_for_completion).map(|_| ())
self.paint_webview(webview_id, false).map(|_| ())
}
fn paint_webview(
&mut self,
webview_id: &WebViewId,
capture_frame: bool,
wait_for_completion: bool,
) -> Result<Option<RenderedFrame>, ServoHostError> {
let rendering_context = self.webview(webview_id)?.rendering_context.clone();
rendering_context.make_current().map_err(|_| ServoHostError::RenderingContextNotCurrent)?;
rendering_context.prepare_for_rendering();
// Clear the pending-frame flag before `paint()` so barrier callers observe
// the next Servo frame-ready notification for this paint.
{
let webview = self.webview(webview_id)?;
webview.delegate.mark_frame_presented();
webview.webview.paint();
}
if wait_for_completion {
self.wait_for_paint_completion(webview_id);
consume_pending_then_paint(&webview.delegate, || webview.webview.paint());
}
let rendered_frame = if capture_frame {
Some(Self::read_rendered_frame(rendering_context.as_ref())?)
@@ -49,20 +34,39 @@ impl SoftwareServoHost {
None
};
rendering_context.present();
self.webview(webview_id)?.delegate.mark_frame_presented();
Ok(rendered_frame)
}
pub fn paint_with_readback(
&mut self,
webview_id: &WebViewId,
wait_for_completion: bool,
) -> Result<(), ServoHostError> {
let Some(rendered_frame) = self.paint_webview(webview_id, true, wait_for_completion)?
else {
pub fn paint_with_readback(&mut self, webview_id: &WebViewId) -> Result<(), ServoHostError> {
let Some(rendered_frame) = self.paint_webview(webview_id, true)? else {
return Err(ServoHostError::RenderedFrameUnavailable);
};
self.last_rendered_frame = Some(rendered_frame);
Ok(())
}
}
fn consume_pending_then_paint(delegate: &HostWebViewDelegate, paint: impl FnOnce()) {
delegate.mark_frame_presented();
paint();
}
#[cfg(test)]
mod tests {
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use ely_domain::ProfileId;
use super::{HostWebViewDelegate, consume_pending_then_paint};
#[test]
fn frame_arriving_during_paint_remains_pending() {
let delegate =
HostWebViewDelegate::new(ProfileId::new(), Rc::new(RefCell::new(HashMap::new())));
delegate.mark_frame_ready();
consume_pending_then_paint(&delegate, || delegate.mark_frame_ready());
assert!(delegate.has_pending_frame());
}
}
+7 -1
View File
@@ -13,6 +13,8 @@ pub(super) struct HostWebView {
pub(super) tab_id: TabId,
pub(super) profile_id: ProfileId,
pub(super) rendering_context: Rc<dyn RenderingContext>,
#[cfg(feature = "hardware-render")]
pub(super) hardware_context: Option<Rc<crate::HardwareOffscreenContext>>,
pub(super) webview: WebView,
pub(super) delegate: Rc<HostWebViewDelegate>,
pub(super) requested_url: Option<String>,
@@ -128,6 +130,10 @@ impl HostWebViewDelegate {
self.has_pending_frame.set(false);
}
pub(super) fn mark_frame_ready(&self) {
self.has_pending_frame.set(true);
}
pub(super) fn mark_metadata_observed(&self) {
self.has_pending_metadata.set(false);
}
@@ -147,7 +153,7 @@ impl WebViewDelegate for HostWebViewDelegate {
}
fn notify_new_frame_ready(&self, _webview: WebView) {
self.has_pending_frame.set(true);
self.mark_frame_ready();
}
fn notify_crashed(&self, _webview: WebView, _reason: String, _backtrace: Option<String>) {
@@ -0,0 +1,144 @@
#![cfg(feature = "hardware-render")]
use dpi::PhysicalSize;
use ely_servo_host::{HardwareOffscreenContext, IOSurfaceHandle, IOSurfaceIdentity};
use servo::RenderingContext;
#[cfg(target_os = "macos")]
const HARDWARE_HOST_CHILD_ENV: &str = "ELY_SERVO_HARDWARE_HOST_CHILD";
#[test]
fn identity_uses_handle_dimensions() {
let handle = IOSurfaceHandle { mach_port_name: 7, surface_id: 11, width: 640, height: 480 };
assert_eq!(
IOSurfaceIdentity::from_handle(handle),
IOSurfaceIdentity { surface_id: 11, width: 640, height: 480 }
);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn hardware_context_constructs() -> Result<(), String> {
let context = HardwareOffscreenContext::new(PhysicalSize::new(64, 64))
.map_err(|error| format!("hardware context creation failed: {error:?}"))?;
assert_eq!(context.size(), PhysicalSize::new(64, 64));
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn presented_surface_exposes_iosurface_identity_and_mach_port() -> Result<(), String> {
let size = PhysicalSize::new(256, 192);
let context = HardwareOffscreenContext::new(size)
.map_err(|error| format!("hardware context creation failed: {error:?}"))?;
assert_eq!(
context
.peek_iosurface_identity()
.map_err(|error| format!("identity probe failed: {error:?}"))?,
None
);
context.make_current().map_err(|error| format!("make current failed: {error:?}"))?;
context.prepare_for_rendering();
context.present();
let identity = context
.peek_iosurface_identity()
.map_err(|error| format!("identity probe failed: {error:?}"))?
.ok_or_else(|| "present did not expose an IOSurface".to_string())?;
let handle = context
.current_iosurface_mach_port()
.map_err(|error| format!("Mach port creation failed: {error:?}"))?;
assert_ne!(handle.mach_port_name, 0);
assert_eq!(identity, IOSurfaceIdentity::from_handle(handle));
assert_eq!(identity.width, size.width);
assert_eq!(identity.height, size.height);
assert_eq!(deallocate_mach_port(handle.mach_port_name), mach2::kern_return::KERN_SUCCESS);
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn hardware_host_paints_and_presents_an_iosurface() -> Result<(), Box<dyn std::error::Error>> {
use std::env;
use std::process::{Command, Stdio};
if env::var_os(HARDWARE_HOST_CHILD_ENV).is_some() {
return exercise_hardware_host();
}
let output = Command::new(env::current_exe()?)
.arg("--exact")
.arg("hardware_host_paints_and_presents_an_iosurface")
.env(HARDWARE_HOST_CHILD_ENV, "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()?;
if output.status.success() {
return Ok(());
}
Err(format!(
"hardware host child failed\nstatus: {}\nstdout: {}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.into())
}
#[cfg(target_os = "macos")]
fn exercise_hardware_host() -> Result<(), Box<dyn std::error::Error>> {
use std::thread;
use std::time::Duration;
use ely_domain::{ProfileId, TabId, UrlText};
use ely_servo_host::{
NavigationRequest, RenderingContextKind, ServoHost, ServoSurfaceSize, SoftwareServoHost,
};
let size = ServoSurfaceSize::new(320, 240);
let mut host = SoftwareServoHost::new_with_config_dir_and_kind(
size,
None,
RenderingContextKind::Hardware,
)?;
let tab_id = TabId::new();
let webview_id = host.create_webview(tab_id.clone(), ProfileId::new())?;
host.navigate(NavigationRequest {
webview_id: webview_id.clone(),
tab_id,
url: UrlText::parse("data:text/html,%3Cbody%20style%3D%27background%3A%230369a1%27%3E")?,
})?;
for _ in 0..5_000 {
host.tick();
if host.snapshot(&webview_id)?.has_pending_frame() {
host.paint_without_readback(&webview_id)?;
if host.peek_iosurface_identity(&webview_id)?.is_some() {
let handle = host
.current_iosurface_handle(&webview_id)?
.ok_or("hardware webview did not expose an IOSurface handle")?;
assert_eq!(handle.width, 320);
assert_eq!(handle.height, 240);
assert_eq!(
deallocate_mach_port(handle.mach_port_name),
mach2::kern_return::KERN_SUCCESS
);
return Ok(());
}
}
thread::sleep(Duration::from_millis(2));
}
Err("timed out waiting for a hardware IOSurface frame".into())
}
#[cfg(target_os = "macos")]
#[expect(unsafe_code)]
fn deallocate_mach_port(name: u32) -> i32 {
// `name` is a live send right minted by IOSurfaceCreateMachPort in this task.
unsafe { mach2::mach_port::mach_port_deallocate(mach2::traps::mach_task_self(), name) }
}
+248
View File
@@ -0,0 +1,248 @@
#![cfg(feature = "servo-engine")]
use std::{
error::Error,
io, thread,
time::{Duration, Instant},
};
use ely_domain::{ProfileId, TabId};
use serde_json::json;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[path = "sidecar/mach_receiver.rs"]
mod mach_receiver;
#[path = "sidecar/support.rs"]
mod support;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
use mach_receiver::{MachSurfaceReceiver, verify_iosurface};
use support::{
HEIGHT, LIVE_PROTOCOL_VERSION, MAX_FRAME_DIMENSION, RESPONSE_TIMEOUT, Sidecar, TestDirectory,
TestServer, WIDTH, ensure_request,
};
#[test]
fn live_sidecar_streams_rgba_and_flushes_profile_storage_on_shutdown() -> Result<(), Box<dyn Error>>
{
let server = TestServer::start()?;
let root = TestDirectory::new()?;
let persisted_dir = root.path().join("persisted");
let fresh_dir = root.path().join("fresh");
let profile_id = ProfileId::new();
let mut writer = Sidecar::spawn(&persisted_dir)?;
let stored = writer
.ensure_and_wait_visible(&profile_id, &server.url("/set"), "stored-cookie-yes-storage-yes")
.map_err(|error| io::Error::other(format!("{error}; server={}", server.diagnostics())))?;
assert_eq!(stored.width, WIDTH);
assert_eq!(stored.height, HEIGHT);
assert!(stored.non_white_pixel_count > 0);
assert!(stored.content_pixel_count > 0);
assert_ne!(stored.sample_hash, 0);
writer.shutdown()?;
assert!(persisted_dir.join("cookie_jar.json").is_file());
assert!(persisted_dir.join("localstorage.json").is_file());
assert!(persisted_dir.join("webstorage").is_dir());
let mut reader = Sidecar::spawn(&persisted_dir)?;
reader.ensure_and_wait_visible(
&profile_id,
&server.url("/read"),
"read-cookie-yes-storage-yes",
)?;
reader.shutdown()?;
let mut fresh = Sidecar::spawn(&fresh_dir)?;
fresh.ensure_and_wait_visible(
&profile_id,
&server.url("/read"),
"read-cookie-no-storage-no",
)?;
fresh.shutdown()?;
Ok(())
}
#[test]
fn servo_originated_history_url_does_not_trigger_a_second_navigation() -> Result<(), Box<dyn Error>>
{
let server = TestServer::start()?;
let root = TestDirectory::new()?;
let profile_id = ProfileId::new();
let tab_id = TabId::new();
let initial_url = server.url("/history");
let history_url = server.url("/history?state=1");
let mut sidecar = Sidecar::spawn(root.path())?;
let ensure = ensure_request(&tab_id, &profile_id, &initial_url);
let mut response = sidecar.exchange(&ensure)?;
let started_at = Instant::now();
loop {
if let Some(error) = response.error {
return Err(io::Error::other(format!("sidecar response error: {error}")).into());
}
if response.frame.as_ref().is_some_and(|frame| {
frame.loaded_url.as_deref() == Some(history_url.as_str())
&& frame.title.as_deref() == Some("history-ready")
}) {
break;
}
if started_at.elapsed() >= RESPONSE_TIMEOUT {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("timed out waiting for history URL {history_url}"),
)
.into());
}
thread::sleep(Duration::from_millis(2));
response = sidecar.exchange(&json!({ "type": "poll", "tab_id": tab_id.as_str() }))?;
}
sidecar.exchange(&ensure_request(&tab_id, &profile_id, &history_url))?;
for _ in 0..20 {
thread::sleep(Duration::from_millis(5));
sidecar.exchange(&json!({ "type": "poll", "tab_id": tab_id.as_str() }))?;
}
assert_eq!(server.request_count("/history"), 1, "{}", server.diagnostics());
sidecar.shutdown()?;
Ok(())
}
#[test]
fn live_sidecar_delivers_a_valid_white_page() -> Result<(), Box<dyn Error>> {
let server = TestServer::start()?;
let root = TestDirectory::new()?;
let profile_id = ProfileId::new();
let mut sidecar = Sidecar::spawn(root.path())?;
let frame = sidecar.ensure_and_wait(&profile_id, &server.url("/white"), "white-ready")?;
assert_eq!(frame.non_white_pixel_count, 0);
assert_eq!(frame.content_pixel_count, 0);
sidecar.shutdown()?;
Ok(())
}
#[test]
fn live_sidecar_rejects_an_incompatible_protocol() -> Result<(), Box<dyn Error>> {
let root = TestDirectory::new()?;
let mut sidecar = Sidecar::spawn_without_handshake(root.path())?;
let response = sidecar.exchange(&json!({
"type": "handshake",
"protocol_version": LIVE_PROTOCOL_VERSION + 1,
}))?;
assert_eq!(response.protocol_version, Some(LIVE_PROTOCOL_VERSION));
assert!(response.error.as_deref().is_some_and(|error| error.contains("protocol mismatch")));
Ok(())
}
#[test]
fn live_sidecar_rejects_oversized_frame_dimensions() -> Result<(), Box<dyn Error>> {
let root = TestDirectory::new()?;
let profile_id = ProfileId::new();
let tab_id = TabId::new();
let mut sidecar = Sidecar::spawn(root.path())?;
let mut ensure = ensure_request(&tab_id, &profile_id, "about:blank");
ensure["width"] = json!(MAX_FRAME_DIMENSION + 1);
let response = sidecar.exchange(&ensure)?;
assert!(response.error.as_deref().is_some_and(|error| error.contains("dimension limit")));
sidecar.shutdown()?;
Ok(())
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[test]
fn hardware_sidecar_transfers_a_real_iosurface_mach_descriptor() -> Result<(), Box<dyn Error>> {
let receiver = MachSurfaceReceiver::new()?;
let server = TestServer::start()?;
let root = TestDirectory::new()?;
let profile_id = ProfileId::new();
let tab_id = TabId::new();
let mut sidecar = Sidecar::spawn_hardware(root.path(), receiver.service_name())?;
let mut response =
sidecar.exchange(&ensure_request(&tab_id, &profile_id, &server.url("/white")))?;
let started_at = Instant::now();
let imported_surface_id = loop {
if let Some(error) = response.error {
return Err(io::Error::other(format!("hardware sidecar error: {error}")).into());
}
if let (Some(frame), Some(handle), Some(current_surface_id)) =
(response.frame.as_ref(), response.surface_handle, response.current_surface_id)
{
assert_eq!(frame.rgba_byte_count, 0);
assert_eq!(current_surface_id, handle.surface_id);
assert_eq!((frame.width, frame.height), (handle.width, handle.height));
assert_ne!(handle.mach_port_name, 0);
let received_port = receiver.receive(handle.surface_id, Duration::from_secs(2))?;
verify_iosurface(received_port, handle.width, handle.height)?;
break handle.surface_id;
}
if started_at.elapsed() >= RESPONSE_TIMEOUT {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"timed out waiting for a hardware IOSurface frame",
)
.into());
}
thread::sleep(Duration::from_millis(2));
response = sidecar.exchange(&json!({
"type": "poll",
"tab_id": tab_id.as_str(),
"ready_surface_ids": [],
"pending_surface_ids": [],
}))?;
};
let replay = sidecar.exchange(&json!({
"type": "poll",
"tab_id": tab_id.as_str(),
"ready_surface_ids": [imported_surface_id],
"pending_surface_ids": [],
}))?;
assert_eq!(replay.current_surface_id, Some(imported_surface_id));
assert!(replay.surface_handle.is_none());
assert_eq!(replay.frame.as_ref().map(|frame| frame.rgba_byte_count), Some(0));
let republished = sidecar.exchange(&json!({
"type": "poll",
"tab_id": tab_id.as_str(),
"ready_surface_ids": [],
"pending_surface_ids": [],
}))?;
let republished_handle = republished
.surface_handle
.ok_or_else(|| io::Error::other("evicted IOSurface was not republished"))?;
assert_eq!(republished.current_surface_id, Some(imported_surface_id));
assert_eq!(republished_handle.surface_id, imported_surface_id);
let republished_port = receiver.receive(imported_surface_id, Duration::from_secs(2))?;
verify_iosurface(republished_port, republished_handle.width, republished_handle.height)?;
let pending = sidecar.exchange(&json!({
"type": "poll",
"tab_id": tab_id.as_str(),
"ready_surface_ids": [],
"pending_surface_ids": [imported_surface_id],
}))?;
assert!(pending.frame.is_none());
assert!(pending.surface_handle.is_none());
let reimported = sidecar.exchange(&json!({
"type": "poll",
"tab_id": tab_id.as_str(),
"ready_surface_ids": [imported_surface_id],
"pending_surface_ids": [],
}))?;
assert_eq!(reimported.current_surface_id, Some(imported_surface_id));
assert!(reimported.surface_handle.is_none());
assert_eq!(reimported.frame.as_ref().map(|frame| frame.rgba_byte_count), Some(0));
sidecar.shutdown()?;
Ok(())
}
@@ -0,0 +1,176 @@
use std::{error::Error, ffi::CString, io, mem, time::Duration};
use mach2::{
bootstrap::{bootstrap_port, bootstrap_register},
kern_return::KERN_SUCCESS,
mach_port::{
mach_port_allocate, mach_port_deallocate, mach_port_destroy, mach_port_insert_right,
},
message::{
MACH_MSG_PORT_DESCRIPTOR, MACH_MSG_SUCCESS, MACH_MSG_TYPE_MAKE_SEND, MACH_RCV_MSG,
MACH_RCV_TIMED_OUT, MACH_RCV_TIMEOUT, mach_msg, mach_msg_body_t, mach_msg_header_t,
mach_msg_port_descriptor_t, mach_msg_trailer_t,
},
port::{MACH_PORT_NULL, MACH_PORT_RIGHT_RECEIVE, mach_port_t},
traps::mach_task_self,
};
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
pub(super) struct MachSurfaceReceiver {
service_name: String,
receive_port: mach_port_t,
}
impl MachSurfaceReceiver {
pub(super) fn new() -> Result<Self, Box<dyn Error>> {
let service_name =
format!("com.ely.browser.iosurface.test.{}", ely_domain::ProfileId::new().as_str());
let service_name_c = CString::new(service_name.as_str())?;
let mut receive_port = MACH_PORT_NULL;
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
let allocate =
unsafe { mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &mut receive_port) };
if allocate != KERN_SUCCESS {
return Err(io::Error::other(format!("mach_port_allocate returned {allocate}")).into());
}
#[expect(unsafe_code)]
let insert = unsafe {
mach_port_insert_right(task, receive_port, receive_port, MACH_MSG_TYPE_MAKE_SEND)
};
if insert != KERN_SUCCESS {
destroy_port(receive_port);
return Err(
io::Error::other(format!("mach_port_insert_right returned {insert}")).into()
);
}
#[expect(unsafe_code)]
#[allow(deprecated)]
let register = unsafe {
bootstrap_register(bootstrap_port, service_name_c.as_ptr() as *mut _, receive_port)
};
if register != KERN_SUCCESS {
destroy_port(receive_port);
return Err(io::Error::other(format!("bootstrap_register returned {register}")).into());
}
Ok(Self { service_name, receive_port })
}
pub(super) fn service_name(&self) -> &str {
self.service_name.as_str()
}
pub(super) fn receive(
&self,
expected_surface_id: u64,
timeout: Duration,
) -> Result<mach_port_t, Box<dyn Error>> {
#[expect(unsafe_code)]
let mut received: ReceivedIOSurfacePortMessage = unsafe { mem::zeroed() };
#[expect(unsafe_code)]
let result = unsafe {
mach_msg(
&mut received.message.header,
MACH_RCV_MSG | MACH_RCV_TIMEOUT,
0,
mem::size_of::<ReceivedIOSurfacePortMessage>() as u32,
self.receive_port,
timeout_millis(timeout),
MACH_PORT_NULL,
)
};
if result == MACH_RCV_TIMED_OUT {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("Mach receive timed out for surface {expected_surface_id}"),
)
.into());
}
if result != MACH_MSG_SUCCESS {
return Err(io::Error::other(format!("mach_msg receive returned {result}")).into());
}
let message = &mut received.message;
if message.header.msgh_id != IOSURFACE_PORT_MESSAGE_ID
|| message.body.msgh_descriptor_count != 1
|| message.surface_port.type_ != MACH_MSG_PORT_DESCRIPTOR as u8
|| message.surface_port.name == MACH_PORT_NULL
|| message.surface_id != expected_surface_id
{
destroy_message(message);
return Err(io::Error::other("received invalid IOSurface Mach message").into());
}
Ok(message.surface_port.name)
}
}
impl Drop for MachSurfaceReceiver {
fn drop(&mut self) {
destroy_port(self.receive_port);
}
}
pub(super) fn verify_iosurface(
mach_port: mach_port_t,
width: u32,
height: u32,
) -> Result<(), Box<dyn Error>> {
let surface = objc2_io_surface::IOSurfaceRef::lookup_from_mach_port(mach_port)
.ok_or_else(|| io::Error::other("IOSurfaceLookupFromMachPort returned null"))?;
let actual_width = u32::try_from(surface.width())?;
let actual_height = u32::try_from(surface.height())?;
deallocate_port(mach_port)?;
if actual_width != width || actual_height != height {
return Err(io::Error::other(format!(
"imported IOSurface was {actual_width}x{actual_height}; expected {width}x{height}"
))
.into());
}
Ok(())
}
#[repr(C)]
struct IOSurfacePortMessage {
header: mach_msg_header_t,
body: mach_msg_body_t,
surface_port: mach_msg_port_descriptor_t,
surface_id: u64,
}
#[repr(C)]
struct ReceivedIOSurfacePortMessage {
message: IOSurfacePortMessage,
_trailer: mach_msg_trailer_t,
}
fn timeout_millis(timeout: Duration) -> u32 {
u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(1)
}
fn destroy_message(message: &mut IOSurfacePortMessage) {
#[expect(unsafe_code)]
unsafe {
mach2::message::mach_msg_destroy(&mut message.header);
}
}
fn destroy_port(port: mach_port_t) {
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
unsafe {
let _ = mach_port_destroy(task, port);
}
}
fn deallocate_port(port: mach_port_t) -> Result<(), Box<dyn Error>> {
#[expect(unsafe_code)]
let task = unsafe { mach_task_self() };
#[expect(unsafe_code)]
let result = unsafe { mach_port_deallocate(task, port) };
if result != KERN_SUCCESS {
return Err(io::Error::other(format!("mach_port_deallocate returned {result}")).into());
}
Ok(())
}
@@ -0,0 +1,488 @@
use std::{
error::Error,
fs,
io::{self, BufRead, BufReader, Read, Write},
net::{SocketAddr, TcpListener, TcpStream},
path::{Path, PathBuf},
process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus, Stdio},
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
thread,
time::{Duration, Instant},
};
use ely_domain::{ProfileId, TabId};
use serde_json::{Value, json};
pub(super) const WIDTH: u32 = 360;
pub(super) const HEIGHT: u32 = 240;
pub(super) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(20);
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 2;
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
pub(super) fn ensure_request(tab_id: &TabId, profile_id: &ProfileId, url: &str) -> Value {
json!({
"type": "ensure",
"tab_id": tab_id.as_str(),
"profile_id": profile_id.as_str(),
"url": url,
"width": WIDTH,
"height": HEIGHT,
"page_zoom_percent": 100,
"device_pixel_ratio": 1.0,
"site_permissions": [],
})
}
pub(super) struct Sidecar {
child: Child,
stdin: Option<ChildStdin>,
stdout: BufReader<ChildStdout>,
stderr: Option<ChildStderr>,
}
impl Sidecar {
pub(super) fn spawn(profile_data_dir: &Path) -> Result<Self, Box<dyn Error>> {
let mut sidecar = Self::spawn_without_handshake(profile_data_dir)?;
sidecar.handshake()?;
Ok(sidecar)
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) fn spawn_hardware(
profile_data_dir: &Path,
mach_service: &str,
) -> Result<Self, Box<dyn Error>> {
let mut sidecar = Self::spawn_process(
profile_data_dir,
&["--rendering-context", "hardware", "--iosurface-mach-service", mach_service],
)?;
sidecar.handshake()?;
Ok(sidecar)
}
fn handshake(&mut self) -> Result<(), Box<dyn Error>> {
let response = self.exchange(&json!({
"type": "handshake",
"protocol_version": LIVE_PROTOCOL_VERSION,
}))?;
if response.protocol_version != Some(LIVE_PROTOCOL_VERSION) || response.error.is_some() {
return Err(io::Error::other("sidecar protocol handshake failed").into());
}
Ok(())
}
pub(super) fn spawn_without_handshake(profile_data_dir: &Path) -> Result<Self, Box<dyn Error>> {
Self::spawn_process(profile_data_dir, &[])
}
fn spawn_process(profile_data_dir: &Path, extra_args: &[&str]) -> Result<Self, Box<dyn Error>> {
let mut command = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"));
command.arg("live").arg("--profile-data-dir").arg(profile_data_dir);
command.args(extra_args);
let mut child =
command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
let stdin =
child.stdin.take().ok_or_else(|| io::Error::other("sidecar stdin was not piped"))?;
let stdout =
child.stdout.take().ok_or_else(|| io::Error::other("sidecar stdout was not piped"))?;
let stderr = child.stderr.take();
Ok(Self { child, stdin: Some(stdin), stdout: BufReader::new(stdout), stderr })
}
pub(super) fn ensure_and_wait(
&mut self,
profile_id: &ProfileId,
url: &str,
expected_title: &str,
) -> Result<FramePacket, Box<dyn Error>> {
self.ensure_and_wait_matching(profile_id, url, expected_title, |_| true)
}
pub(super) fn ensure_and_wait_visible(
&mut self,
profile_id: &ProfileId,
url: &str,
expected_title: &str,
) -> Result<FramePacket, Box<dyn Error>> {
self.ensure_and_wait_matching(profile_id, url, expected_title, |frame| {
frame.non_white_pixel_count > 0 && frame.content_pixel_count > 0
})
}
fn ensure_and_wait_matching(
&mut self,
profile_id: &ProfileId,
url: &str,
expected_title: &str,
matches_frame: impl Fn(&FramePacket) -> bool,
) -> Result<FramePacket, Box<dyn Error>> {
let tab_id = TabId::new();
let ensure = ensure_request(&tab_id, profile_id, url);
let mut response = self.exchange(&ensure)?;
let started_at = Instant::now();
let mut latest_title = None;
loop {
if let Some(error) = response.error {
return Err(io::Error::other(format!("sidecar response error: {error}")).into());
}
if let Some(frame) = response.frame {
latest_title = frame.title.clone();
if frame.title.as_deref() == Some(expected_title) && matches_frame(&frame) {
return Ok(frame);
}
}
if started_at.elapsed() >= RESPONSE_TIMEOUT {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!(
"timed out waiting for title {expected_title:?}; latest={latest_title:?}"
),
)
.into());
}
thread::sleep(Duration::from_millis(2));
response = self.exchange(&json!({ "type": "poll", "tab_id": tab_id.as_str() }))?;
}
}
pub(super) fn exchange(&mut self, request: &Value) -> Result<WireResponse, Box<dyn Error>> {
let stdin = self
.stdin
.as_mut()
.ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "sidecar stdin is closed"))?;
serde_json::to_writer(&mut *stdin, request)?;
stdin.write_all(b"\n")?;
stdin.flush()?;
read_response(&mut self.stdout)
}
pub(super) fn shutdown(&mut self) -> Result<(), Box<dyn Error>> {
let response = self.exchange(&json!({ "type": "shutdown" }))?;
if response.protocol_version != Some(LIVE_PROTOCOL_VERSION)
|| response.error.is_some()
|| response.frame.is_some()
{
return Err(
io::Error::other("shutdown response must be an empty acknowledgement").into()
);
}
self.stdin.take();
let status = wait_for_exit(&mut self.child, RESPONSE_TIMEOUT)?;
if status.success() {
return Ok(());
}
let mut stderr = String::new();
if let Some(mut pipe) = self.stderr.take() {
pipe.read_to_string(&mut stderr)?;
}
Err(io::Error::other(format!("sidecar exited with {status}: {stderr}")).into())
}
}
impl Drop for Sidecar {
fn drop(&mut self) {
if self.child.try_wait().ok().flatten().is_none() {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
}
fn wait_for_exit(child: &mut Child, timeout: Duration) -> Result<ExitStatus, Box<dyn Error>> {
let started_at = Instant::now();
loop {
if let Some(status) = child.try_wait()? {
return Ok(status);
}
if started_at.elapsed() >= timeout {
child.kill()?;
let _ = child.wait();
return Err(
io::Error::new(io::ErrorKind::TimedOut, "sidecar shutdown timed out").into()
);
}
thread::sleep(Duration::from_millis(5));
}
}
pub(super) struct WireResponse {
pub(super) protocol_version: Option<u32>,
pub(super) error: Option<String>,
pub(super) frame: Option<FramePacket>,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) surface_handle: Option<SurfaceHandle>,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) current_surface_id: Option<u64>,
}
pub(super) struct FramePacket {
pub(super) loaded_url: Option<String>,
pub(super) title: Option<String>,
pub(super) width: u32,
pub(super) height: u32,
pub(super) non_white_pixel_count: u64,
pub(super) content_pixel_count: u64,
pub(super) sample_hash: u64,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
pub(super) rgba_byte_count: usize,
_rgba: Vec<u8>,
}
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
#[derive(Clone, Copy, Debug)]
pub(super) struct SurfaceHandle {
pub(super) mach_port_name: u32,
pub(super) surface_id: u64,
pub(super) width: u32,
pub(super) height: u32,
}
fn read_response(stdout: &mut BufReader<ChildStdout>) -> Result<WireResponse, Box<dyn Error>> {
let mut line = String::new();
if stdout.read_line(&mut line)? == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "sidecar response ended").into());
}
let header: Value = serde_json::from_str(&line)?;
let protocol_version = header
.get("protocol_version")
.and_then(Value::as_u64)
.and_then(|value| value.try_into().ok());
let error = header.get("error").and_then(Value::as_str).map(str::to_string);
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let surface_handle = header
.get("surface_handle")
.filter(|value| !value.is_null())
.map(|handle| {
Ok::<_, Box<dyn Error>>(SurfaceHandle {
mach_port_name: u32_field(handle, "mach_port_name")?,
surface_id: u64_field(handle, "surface_id")?,
width: u32_field(handle, "width")?,
height: u32_field(handle, "height")?,
})
})
.transpose()?;
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
let current_surface_id = header.get("current_surface_id").and_then(Value::as_u64);
let Some(frame) = header.get("frame").filter(|value| !value.is_null()) else {
return Ok(WireResponse {
protocol_version,
error,
frame: None,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
surface_handle,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
current_surface_id,
});
};
let width = u32_field(frame, "width")?;
let height = u32_field(frame, "height")?;
let rgba_byte_count = usize_field(frame, "rgba_byte_count")?;
let expected = usize::try_from(width)?
.checked_mul(usize::try_from(height)?)
.and_then(|pixels| pixels.checked_mul(4))
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "frame size overflow"))?;
if (rgba_byte_count != 0 && rgba_byte_count != expected)
|| rgba_byte_count > MAX_FRAME_BYTE_COUNT
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid frame byte count {rgba_byte_count}; expected {expected}"),
)
.into());
}
let mut rgba = vec![0; rgba_byte_count];
stdout.read_exact(&mut rgba)?;
let packet = FramePacket {
loaded_url: frame.get("loaded_url").and_then(Value::as_str).map(str::to_string),
title: frame.get("title").and_then(Value::as_str).map(str::to_string),
width,
height,
non_white_pixel_count: u64_field(frame, "non_white_pixel_count")?,
content_pixel_count: u64_field(frame, "content_pixel_count")?,
sample_hash: u64_field(frame, "sample_hash")?,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
rgba_byte_count,
_rgba: rgba,
};
Ok(WireResponse {
protocol_version,
error,
frame: Some(packet),
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
surface_handle,
#[cfg(all(feature = "hardware-render", target_os = "macos"))]
current_surface_id,
})
}
fn u32_field(value: &Value, name: &str) -> Result<u32, Box<dyn Error>> {
Ok(u32::try_from(u64_field(value, name)?)?)
}
fn usize_field(value: &Value, name: &str) -> Result<usize, Box<dyn Error>> {
Ok(usize::try_from(u64_field(value, name)?)?)
}
fn u64_field(value: &Value, name: &str) -> Result<u64, Box<dyn Error>> {
value.get(name).and_then(Value::as_u64).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, format!("missing frame field {name}")).into()
})
}
pub(super) struct TestDirectory(PathBuf);
impl TestDirectory {
pub(super) fn new() -> Result<Self, io::Error> {
let path = std::env::temp_dir().join(format!(
"ely-sidecar-test-{}-{}",
std::process::id(),
ProfileId::new()
));
fs::create_dir_all(&path)?;
Ok(Self(path))
}
pub(super) fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
pub(super) struct TestServer {
address: SocketAddr,
stop: Arc<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
diagnostics: Arc<Mutex<ServerDiagnostics>>,
}
#[derive(Default)]
struct ServerDiagnostics {
requests: Vec<String>,
errors: Vec<String>,
}
impl TestServer {
pub(super) fn start() -> Result<Self, io::Error> {
let listener = TcpListener::bind(("127.0.0.1", 0))?;
listener.set_nonblocking(true)?;
let address = listener.local_addr()?;
let stop = Arc::new(AtomicBool::new(false));
let thread_stop = stop.clone();
let diagnostics = Arc::new(Mutex::new(ServerDiagnostics::default()));
let thread_diagnostics = diagnostics.clone();
let thread = thread::spawn(move || serve(listener, &thread_stop, &thread_diagnostics));
Ok(Self { address, stop, thread: Some(thread), diagnostics })
}
pub(super) fn url(&self, path: &str) -> String {
format!("http://{}{path}", self.address)
}
pub(super) fn diagnostics(&self) -> String {
self.diagnostics.lock().map_or_else(
|_| "lock poisoned".to_string(),
|diagnostics| {
format!("requests={:?}, errors={:?}", diagnostics.requests, diagnostics.errors)
},
)
}
pub(super) fn request_count(&self, path: &str) -> usize {
self.diagnostics.lock().map_or(0, |diagnostics| {
diagnostics
.requests
.iter()
.filter(|request| {
request.split_whitespace().nth(1).is_some_and(|url| {
url == path
|| url.strip_prefix(path).is_some_and(|suffix| suffix.starts_with('?'))
})
})
.count()
})
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
let _ = TcpStream::connect(self.address);
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
fn serve(listener: TcpListener, stop: &AtomicBool, diagnostics: &Mutex<ServerDiagnostics>) {
while !stop.load(Ordering::Acquire) {
match listener.accept() {
Ok((stream, _)) => {
if let Err(error) = serve_connection(stream, diagnostics)
&& let Ok(mut diagnostics) = diagnostics.lock()
{
diagnostics.errors.push(error.to_string());
}
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(2));
}
Err(_) => return,
}
}
}
fn serve_connection(
stream: TcpStream,
diagnostics: &Mutex<ServerDiagnostics>,
) -> Result<(), io::Error> {
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
let mut reader = BufReader::new(stream);
let mut request_line = String::new();
reader.read_line(&mut request_line)?;
if let Ok(mut diagnostics) = diagnostics.lock() {
diagnostics.requests.push(request_line.trim().to_string());
}
loop {
let mut header_line = String::new();
if reader.read_line(&mut header_line)? == 0 || header_line == "\r\n" {
break;
}
}
let path = request_line.split_whitespace().nth(1).unwrap_or("/");
let is_set = path == "/set";
let body = if is_set {
SET_PAGE
} else if path.starts_with("/history") {
HISTORY_PAGE
} else if path == "/white" {
WHITE_PAGE
} else {
READ_PAGE
};
let cookie_header =
if is_set { "Set-Cookie: ely_cookie=persisted; Path=/; SameSite=Lax\r\n" } else { "" };
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\n{cookie_header}Connection: close\r\n\r\n{body}",
body.len()
);
reader.get_mut().write_all(response.as_bytes())?;
reader.get_mut().flush()
}
const SET_PAGE: &str = r#"<!doctype html><title>loading</title><style>body{font:24px sans-serif;color:#111;background:#fff}</style><body>Profile persistence</body><script>localStorage.setItem('ely_storage','persisted');const cookie=document.cookie.includes('ely_cookie=persisted')?'yes':'no';const storage=localStorage.getItem('ely_storage')==='persisted'?'yes':'no';document.title=`stored-cookie-${cookie}-storage-${storage}`;</script>"#;
const READ_PAGE: &str = r#"<!doctype html><title>loading</title><style>body{font:24px sans-serif;color:#111;background:#fff}</style><body>Profile persistence</body><script>const cookie=document.cookie.includes('ely_cookie=persisted')?'yes':'no';const storage=localStorage.getItem('ely_storage')==='persisted'?'yes':'no';document.title=`read-cookie-${cookie}-storage-${storage}`;</script>"#;
const HISTORY_PAGE: &str = r#"<!doctype html><title>loading</title><style>body{font:24px sans-serif;color:#111;background:#fff}</style><body>History mutation</body><script>history.replaceState({},'', '/history?state=1');document.title='history-ready';</script>"#;
const WHITE_PAGE: &str = r#"<!doctype html><title>white-ready</title><style>html,body{margin:0;width:100%;height:100%;background:#fff}</style>"#;
+53 -120
View File
@@ -4,8 +4,6 @@ use std::{
env,
error::Error,
process::{Command, Stdio},
thread,
time::Duration,
};
use ely_domain::{ProfileId, SiteOrigin, SitePermissionFeature, TabId, UrlText};
@@ -27,9 +25,9 @@ const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[
const SOFTWARE_HOST_CHILD_ENV: &str = "ELY_SERVO_SOFTWARE_HOST_CHILD";
const DPR_VIEWPORT_CHILD_ENV: &str = "ELY_SERVO_DPR_VIEWPORT_CHILD";
const CLICK_PROBE_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EClick%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20onclick%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Clicked%27%3Bthis.textContent%3D%27Clicked%27%3B%22%3ETap%3C%2Fbutton%3E";
const DRAG_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3EDrag%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20id%3Dbox%3EDrag%3C%2Fbutton%3E%3Cscript%3Elet%20dragging%3Dfalse%3Bconst%20box%3Ddocument.getElementById%28%27box%27%29%3BaddEventListener%28%27mousedown%27%2Cevent%3D%3E%7Bif%28event.target%3D%3D%3Dbox%29%7Bdragging%3Dtrue%3B%7D%7D%29%3BaddEventListener%28%27mousemove%27%2Cevent%3D%3E%7Bif%28dragging%26%26event.clientX%3E280%29%7Bdocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Dragged%27%3Bbox.textContent%3D%27Dragged%27%3B%7D%7D%29%3BaddEventListener%28%27mouseup%27%2C%28%29%3D%3E%7Bdragging%3Dfalse%3B%7D%29%3B%3C%2Fscript%3E";
const TOUCH_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3ETouch%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3Btouch-action%3Amanipulation%3B%7D%3C%2Fstyle%3E%3Cbutton%20ontouchstart%3D%22document.body.dataset.touch%3D%27start%27%3B%22%20onpointerdown%3D%22if%28%21document.body.dataset.pointerType%29%7Bdocument.body.dataset.pointerType%3Devent.pointerType%3B%7D%22%20onclick%3D%22if%28document.body.dataset.pointerType%21%3D%3D%27touch%27%29%7Bdocument.title%3Ddocument.body.dataset.pointerType%3Breturn%3B%7Ddocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E";
const TEXT_PROBE_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EText%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f7f7f7%3Bfont%3A28px%20sans-serif%3B%7Dinput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A260px%3Bheight%3A70px%3Bfont%3A28px%20sans-serif%3B%7Doutput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A180px%3Bfont%3A32px%20sans-serif%3B%7D%3C%2Fstyle%3E%3Cinput%20id%3Dq%20autofocus%20oninput%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.getElementById%28%27out%27%29.textContent%3Dthis.value%3B%22%3E%3Coutput%20id%3Dout%3Eempty%3C%2Foutput%3E";
const DRAG_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3EDrag%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23f6d365%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3B%7D%3C%2Fstyle%3E%3Cbutton%20id%3Dbox%3EDrag%3C%2Fbutton%3E%3Cscript%3Elet%20dragging%3Dfalse%3Bconst%20box%3Ddocument.getElementById%28%27box%27%29%3BaddEventListener%28%27mousedown%27%2Cevent%3D%3E%7Bif%28event.target%3D%3D%3Dbox%29%7Bdragging%3Dtrue%3B%7D%7D%29%3BaddEventListener%28%27mousemove%27%2Cevent%3D%3E%7Bif%28dragging%26%26event.clientX%3E280%29%7Bdocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Dragged%27%3Bbox.textContent%3D%27Dragged%27%3B%7D%7D%29%3BaddEventListener%28%27mouseup%27%2C%28%29%3D%3E%7Bdragging%3Dfalse%3B%7D%29%3B%3C%2Fscript%3E";
const TOUCH_PROBE_URL: &str = "data:text/html,%3C%21doctype%20html%3E%3Ctitle%3ETouch%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23c7f5d9%3B%7Dbutton%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A220px%3Bheight%3A90px%3Bfont%3A28px%20sans-serif%3Bbackground%3A%23ffffff%3Bcolor%3A%23111111%3Btouch-action%3Amanipulation%3B%7D%3C%2Fstyle%3E%3Cbutton%20ontouchstart%3D%22document.body.dataset.touch%3D%27start%27%3B%22%20onpointerdown%3D%22if%28%21document.body.dataset.pointerType%29%7Bdocument.body.dataset.pointerType%3Devent.pointerType%3B%7D%22%20onclick%3D%22if%28document.body.dataset.pointerType%21%3D%3D%27touch%27%29%7Bdocument.title%3Ddocument.body.dataset.pointerType%3Breturn%3B%7Ddocument.body.style.background%3D%27%230039ff%27%3Bdocument.title%3D%27Touched%27%3Bthis.textContent%3D%27Touched%27%3B%22%3ETap%3C%2Fbutton%3E";
const TEXT_PROBE_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Ctitle%3EText%20Probe%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bbackground%3A%23d9e8ff%3Bfont%3A28px%20sans-serif%3B%7Dinput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A80px%3Bwidth%3A260px%3Bheight%3A70px%3Bfont%3A28px%20sans-serif%3B%7Doutput%7Bposition%3Aabsolute%3Bleft%3A80px%3Btop%3A180px%3Bfont%3A32px%20sans-serif%3B%7D%3C%2Fstyle%3E%3Cinput%20id%3Dq%20autofocus%20oninput%3D%22document.body.style.background%3D%27%230039ff%27%3Bdocument.getElementById%28%27out%27%29.textContent%3Dthis.value%3B%22%3E%3Coutput%20id%3Dout%3Eempty%3C%2Foutput%3E";
const TEXT_PROBE_VALUE: &str = "ely42";
struct PrdSiteCompatibilityCase {
@@ -258,15 +256,27 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.click(MouseClickRequest { webview_id: webview_id.clone(), x: 160, y: 120 })?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;
let snapshot = wait_for_rendered_webview_with_title(
&mut host,
&webview_id,
Some(previous_frame_hash),
"Clicked",
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_eq!(snapshot.title(), Some("Clicked"), "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html clicked", 1)?;
assert_ne!(host.last_rendered_frame()?.sample_hash(), previous_frame_hash);
let tab_id = TabId::new();
let url = UrlText::parse(DRAG_PROBE_URL)?;
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, url })?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, None)?;
let snapshot = wait_for_rendered_webview_with_center_pixel(
&mut host,
&webview_id,
Some(previous_frame_hash),
[246, 211, 101],
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html drag", 1)?;
@@ -278,21 +288,38 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
to_x: 320,
to_y: 120,
})?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;
let snapshot = wait_for_rendered_webview_with_title(
&mut host,
&webview_id,
Some(previous_frame_hash),
"Dragged",
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_eq!(snapshot.title(), Some("Dragged"), "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html dragged", 1)?;
assert_ne!(host.last_rendered_frame()?.sample_hash(), previous_frame_hash);
let tab_id = TabId::new();
let url = UrlText::parse(TOUCH_PROBE_URL)?;
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, url })?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, None)?;
let snapshot = wait_for_rendered_webview_with_center_pixel(
&mut host,
&webview_id,
Some(previous_frame_hash),
[199, 245, 217],
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html touch", 1)?;
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.touch_tap(TouchTapRequest { webview_id: webview_id.clone(), x: 160, y: 120 })?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;
let snapshot = wait_for_rendered_webview_with_title(
&mut host,
&webview_id,
Some(previous_frame_hash),
"Touched",
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_eq!(snapshot.title(), Some("Touched"), "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html touched", 1)?;
@@ -300,8 +327,14 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
let tab_id = TabId::new();
let url = UrlText::parse(TEXT_PROBE_URL)?;
let previous_frame_hash = host.last_rendered_frame()?.sample_hash();
host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, url })?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, None)?;
let snapshot = wait_for_rendered_webview_with_center_pixel(
&mut host,
&webview_id,
Some(previous_frame_hash),
[217, 232, 255],
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html input", 1)?;
@@ -311,7 +344,12 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
webview_id: webview_id.clone(),
text: TEXT_PROBE_VALUE.to_string(),
})?;
let snapshot = wait_for_rendered_webview(&mut host, &webview_id, Some(previous_frame_hash))?;
let snapshot = wait_for_rendered_webview_with_center_pixel(
&mut host,
&webview_id,
Some(previous_frame_hash),
[0, 57, 255],
)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}");
assert_rendered_frame_has_content(&host, "data:text/html typed", 1)?;
assert_ne!(host.last_rendered_frame()?.sample_hash(), previous_frame_hash);
@@ -390,111 +428,6 @@ fn exercise_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
Ok(())
}
fn wait_for_rendered_webview(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
previous_frame_hash: Option<u64>,
) -> Result<ely_servo_host::WebViewSnapshot, Box<dyn Error>> {
let mut painted_since_request = false;
for _ in 0..5_000 {
host.tick();
let snapshot = host.snapshot(webview_id)?;
if snapshot.has_pending_frame() {
host.paint(webview_id)?;
painted_since_request = true;
}
let snapshot = host.snapshot(webview_id)?;
let has_rendered_current_request = host.last_rendered_frame().is_ok_and(|frame| {
painted_since_request
&& Some(frame.sample_hash()) != previous_frame_hash
&& frame.non_white_pixel_count() > 0
});
if snapshot.state() == &WebViewState::Complete && has_rendered_current_request {
return Ok(snapshot);
}
thread::sleep(Duration::from_millis(2));
}
Err(format!("timed out waiting for rendered webview: {:?}", host.snapshot(webview_id)?).into())
}
fn assert_rendered_frame_has_content(
host: &SoftwareServoHost,
label: &str,
minimum_content_pixels: u64,
) -> Result<(), Box<dyn Error>> {
assert_rendered_frame_has_dimensions_and_content(
host,
label,
INITIAL_WIDTH,
INITIAL_HEIGHT,
minimum_content_pixels,
)
}
fn assert_rendered_frame_has_dimensions_and_content(
host: &SoftwareServoHost,
label: &str,
expected_width: u32,
expected_height: u32,
minimum_content_pixels: u64,
) -> Result<(), Box<dyn Error>> {
let frame = host.last_rendered_frame()?;
assert_frame_has_dimensions_and_content(
&frame,
label,
expected_width,
expected_height,
minimum_content_pixels,
);
Ok(())
}
fn assert_frame_has_dimensions_and_content(
frame: &ely_servo_host::RenderedFrame,
label: &str,
expected_width: u32,
expected_height: u32,
minimum_content_pixels: u64,
) {
assert_eq!(frame.width(), expected_width, "{label}: {frame:?}");
assert_eq!(frame.height(), expected_height, "{label}: {frame:?}");
assert!(frame.opaque_pixel_count() > 0, "{label}: {frame:?}");
assert!(frame.non_white_pixel_count() > 0, "{label}: {frame:?}");
assert!(frame.content_pixel_count() >= minimum_content_pixels, "{label}: {frame:?}");
assert_ne!(frame.sample_hash(), 0, "{label}: {frame:?}");
}
fn center_pixel_rgb(frame: &ely_servo_host::RenderedFrame) -> [u8; 3] {
let x = frame.width() / 2;
let y = frame.height() / 2;
let index = ((y * frame.width() + x) * 4) as usize;
let rgba = &frame.rgba_bytes()[index..index + 4];
[rgba[0], rgba[1], rgba[2]]
}
fn viewport_probe_url(min_width_threshold: u32) -> String {
let html = format!(
"<!doctype html><title>DPR Probe</title><style>\
html,body{{margin:0;width:100%;height:100%;background:rgb(238,32,77);}}\
@media (min-width:{min_width_threshold}px){{html,body{{background:rgb(0,57,255);}}}}\
</style>",
);
format!("data:text/html,{}", percent_encode_for_data_url(&html))
}
fn percent_encode_for_data_url(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
#[path = "software_host/support.rs"]
mod support;
use support::*;
@@ -0,0 +1,158 @@
use std::{error::Error, thread, time::Duration};
use ely_servo_host::{RenderedFrame, ServoHost, SoftwareServoHost, WebViewSnapshot, WebViewState};
use super::{INITIAL_HEIGHT, INITIAL_WIDTH};
pub(super) fn wait_for_rendered_webview(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
previous_frame_hash: Option<u64>,
) -> Result<WebViewSnapshot, Box<dyn Error>> {
wait_for_rendered_webview_matching(host, webview_id, previous_frame_hash, |_| true, |_| true)
}
pub(super) fn wait_for_rendered_webview_with_title(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
previous_frame_hash: Option<u64>,
expected_title: &str,
) -> Result<WebViewSnapshot, Box<dyn Error>> {
wait_for_rendered_webview_matching(
host,
webview_id,
previous_frame_hash,
|snapshot| snapshot.title() == Some(expected_title),
|_| true,
)
}
pub(super) fn wait_for_rendered_webview_with_center_pixel(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
previous_frame_hash: Option<u64>,
expected_rgb: [u8; 3],
) -> Result<WebViewSnapshot, Box<dyn Error>> {
wait_for_rendered_webview_matching(
host,
webview_id,
previous_frame_hash,
|_| true,
|frame| center_pixel_rgb(frame) == expected_rgb,
)
}
fn wait_for_rendered_webview_matching(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
previous_frame_hash: Option<u64>,
snapshot_matches: impl Fn(&WebViewSnapshot) -> bool,
frame_matches: impl Fn(&RenderedFrame) -> bool,
) -> Result<WebViewSnapshot, Box<dyn Error>> {
let mut painted_since_request = false;
for _ in 0..5_000 {
host.tick();
let snapshot = host.snapshot(webview_id)?;
if snapshot.has_pending_frame() {
host.paint(webview_id)?;
painted_since_request = true;
}
let snapshot = host.snapshot(webview_id)?;
let has_rendered_current_request = host.last_rendered_frame().is_ok_and(|frame| {
painted_since_request
&& Some(frame.sample_hash()) != previous_frame_hash
&& frame.non_white_pixel_count() > 0
&& frame_matches(&frame)
});
if snapshot.state() == &WebViewState::Complete
&& has_rendered_current_request
&& snapshot_matches(&snapshot)
{
return Ok(snapshot);
}
thread::sleep(Duration::from_millis(2));
}
Err(format!("timed out waiting for rendered webview: {:?}", host.snapshot(webview_id)?).into())
}
pub(super) fn assert_rendered_frame_has_content(
host: &SoftwareServoHost,
label: &str,
minimum_content_pixels: u64,
) -> Result<(), Box<dyn Error>> {
assert_rendered_frame_has_dimensions_and_content(
host,
label,
INITIAL_WIDTH,
INITIAL_HEIGHT,
minimum_content_pixels,
)
}
pub(super) fn assert_rendered_frame_has_dimensions_and_content(
host: &SoftwareServoHost,
label: &str,
expected_width: u32,
expected_height: u32,
minimum_content_pixels: u64,
) -> Result<(), Box<dyn Error>> {
let frame = host.last_rendered_frame()?;
assert_frame_has_dimensions_and_content(
&frame,
label,
expected_width,
expected_height,
minimum_content_pixels,
);
Ok(())
}
fn assert_frame_has_dimensions_and_content(
frame: &RenderedFrame,
label: &str,
expected_width: u32,
expected_height: u32,
minimum_content_pixels: u64,
) {
assert_eq!(frame.width(), expected_width, "{label}: {frame:?}");
assert_eq!(frame.height(), expected_height, "{label}: {frame:?}");
assert!(frame.opaque_pixel_count() > 0, "{label}: {frame:?}");
assert!(frame.non_white_pixel_count() > 0, "{label}: {frame:?}");
assert!(frame.content_pixel_count() >= minimum_content_pixels, "{label}: {frame:?}");
assert_ne!(frame.sample_hash(), 0, "{label}: {frame:?}");
}
pub(super) fn center_pixel_rgb(frame: &RenderedFrame) -> [u8; 3] {
let x = frame.width() / 2;
let y = frame.height() / 2;
let index = ((y * frame.width() + x) * 4) as usize;
let rgba = &frame.rgba_bytes()[index..index + 4];
[rgba[0], rgba[1], rgba[2]]
}
pub(super) fn viewport_probe_url(min_width_threshold: u32) -> String {
let html = format!(
"<!doctype html><title>DPR Probe</title><style>\
html,body{{margin:0;width:100%;height:100%;background:rgb(238,32,77);}}\
@media (min-width:{min_width_threshold}px){{html,body{{background:rgb(0,57,255);}}}}\
</style>",
);
format!("data:text/html,{}", percent_encode_for_data_url(&html))
}
fn percent_encode_for_data_url(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
+151 -81
View File
@@ -2,129 +2,199 @@
## Decision
ELY is a Servo-based browser. The page renderer lives in the application process and follows Servo's embedder model:
ELY runs one Servo process for each browser profile. Servo's configuration and site-data manager
are process-scoped, so the process boundary is the storage partition boundary for cookies, HTTP
cache, local storage, and other profile data.
```text
┌──────────────────────────── ELY App Process ────────────────────────────┐
│ │
│ GPUI chrome │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Sidebar Toolbar Tabs Settings │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ Servo content host │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Servo + WebView + RenderingContext │ │
│ │ notify_new_frame_ready -> repaint -> paint -> present │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
ELY App Process
GPUI chrome
WebSurfaceStore
Profile Runtime Broker
Profile A worker ── stdio IPC ──> Servo sidecar A ──> profiles/A/servo
Profile B worker ── stdio IPC ──> Servo sidecar B ──> profiles/B/servo
Private worker ── stdio IPC ──> Servo sidecar P ──> transient directory
```
The app process owns WebView lifecycle, navigation, input, permissions, frame readiness, and rendering context presentation.
Each sidecar binds to the first `ProfileId` it receives and rejects requests for another profile.
The parent broker also keys workers by `ProfileId + ProfileDataMode` and discards responses whose
scope no longer matches the tab session.
## Current Code Boundary
```text
WebSurfaceStore
-> GPUI NativeSurface
-> LiveRuntimeWorker
-> WebSurfaceRuntime
-> one LiveRuntimeWorker per profile scope
-> ServoLiveClient
-> ely_servo_sidecar live
-> SoftwareServoHost
-> servo::Servo + servo::WebView + servo::WindowRenderingContext
-> servo::Servo + servo::WebView
macOS
-> HardwareOffscreenContext (CGL + surfman)
-> IOSurface + Mach descriptor
-> CVPixelBuffer
-> GPUI surface element
Linux / Windows
-> SoftwareRenderingContext
-> RGBA frame
-> GPUI RenderImage
```
`ServoLiveClient` is now an in-process adapter over `ely_servo_host::SoftwareServoHost`. It shares one Servo runtime across profile scopes, creates multiple WebViews inside that runtime, and routes scroll, hover, click, keyboard, resize, zoom, navigation, and permissions through the `ServoHost` API.
The app process owns browser state, profile routing, input coalescing, frame cadence, and GPUI
presentation. A sidecar owns one Servo runtime, its WebViews, and the profile's persistent storage.
The line-delimited JSON protocol carries commands and frame metadata. Software frames append an
exact `width * height * 4` RGBA payload. macOS hardware frames set `rgba_byte_count` to zero and
select an imported IOSurface through `surface_handle` and `current_surface_id`. The Mach channel
transfers the IOSurface send right; the JSON port number is diagnostic metadata.
Normal page display now enters GPUI through `native_surface(...)`. GPUI creates a platform child surface for the content bounds, passes that raw handle into Servo's `WindowRenderingContext`, and presents with `paint_without_readback_with_completion`. The RGBA readback path remains inside `ely_servo_host` for low-level tests.
The protocol supports:
Current platform child surfaces:
- `handshake`: verify protocol version `2` before accepting browser commands.
- `ensure`: create or update a WebView, navigation, viewport, zoom, permissions, and input.
- `poll`: advance Servo and return pending frame or metadata state.
- `close`: destroy one tab's WebView.
- `shutdown`: acknowledge graceful process shutdown so Servo flushes profile storage.
- macOS: child `NSView` with an AppKit raw window handle.
- Windows: child `HWND` with a Win32 raw window handle.
- Linux/X11: child XCB window with XCB display/window handles.
- Linux/Wayland: child `wl_surface` attached as a `wl_subsurface` with Wayland display/surface handles.
`ensure` and `poll` carry `ready_surface_ids` and `pending_surface_ids`. The app distinguishes
completed imports from handles queued behind bounded importer backpressure. Cache entries retain
the IOSurface reference and keep only a weak reference to an active `CVPixelBuffer` backing. The
sidecar preserves pending publications, republishes a surface missing from both sets, and replays
the cached surface report once after an asynchronous import completes.
The release app locates an adjacent `ely_servo_sidecar` binary. `scripts/run_dev.sh` builds the
locked sidecar before starting the app and points the runtime at that binary. The macOS bundle
script places release executables in `Contents/MacOS`, installs `AppIcon.icns`, and verifies its
ad-hoc code signature. The native distribution script places adjacent release executables in one
directory for macOS, Linux, and Windows packaging inputs.
## Profile Data Contract
Persistent scopes use:
```text
<application-data-root>/profiles/<profile-id>/servo
```
The default Standard Profile stores its generated identity at
`<application-data-root>/profiles/default/profile-id`; later launches restore that identity before
constructing `BrowserCore`, so the persistent Servo path remains stable.
Transient scopes receive a `0700` directory under the user-specific ELY runtime directory on Linux
and the user-specific ELY cache directory on macOS and Windows. The root rejects symbolic links. A
root lock serializes creation and stale cleanup; each live directory holds an exclusive lease so
concurrent ELY instances preserve one another's data. Closing the final tab retires the profile
worker in the background, waits for sidecar shutdown, and then removes the directory. A new Private
scope starts immediately in a fresh random directory while earlier cleanup completes. Startup
removes unlocked crash remnants. A sidecar receives its directory at process launch and keeps it
for its process lifetime.
The integration gates prove these invariants with real Servo networking:
1. Cookie and local-storage values survive a graceful restart with the same directory.
2. A fresh directory starts with empty cookie and local-storage state.
3. Two simultaneous app-level profiles keep cookies, local storage, and HTTP cache isolated.
4. Reopening a tab in each profile observes only that profile's state and cached response.
5. Closing the final Private tab and reopening the same profile starts with empty site data.
## Upstream Servo Route
ELY pins Servo upstream commit `bc469fd5c17137373458508f88c3907cd1fcb69a` (workspace version
`0.4.0`) in `Cargo.toml` and `Cargo.lock`. That revision includes the July 2026 security fixes that
landed after the `v0.3.0` regular release. Servo's LTS line uses a separate six-month cadence.
ELY pins Servo upstream commit `bc469fd5c17137373458508f88c3907cd1fcb69a`, workspace version
`0.4.0`, in `Cargo.toml` and `Cargo.lock`.
Servo's own shell route is the model for the final ELY rendering path:
Servo's rendering lifecycle remains authoritative inside each sidecar:
```text
Window event
command or poll
-> Servo spin_event_loop
-> WebViewDelegate::notify_new_frame_ready
-> window request_redraw
-> WebView::paint
-> RenderingContext::present
-> RenderingContext frame
-> sidecar response
```
Relevant upstream evidence from Servo `bc469fd5c17137373458508f88c3907cd1fcb69a`:
Relevant upstream evidence at the pinned revision:
- `ports/servoshell/window.rs` creates `WebViewBuilder::new(state.servo(), platform_window.rendering_context())`.
- `ports/servoshell/window.rs` repaints with `webview.paint()` and `rendering_context().present()`.
- `ports/servoshell/running_app_state.rs` handles `notify_new_frame_ready` by marking the owning window for repaint.
- `components/paint/paint.rs` owns one WebRender painter per `RenderingContext` and keeps paint coordination inside Servo's rendering pipeline.
- `ports/servoshell/window.rs` builds WebViews from a Servo instance and rendering context.
- `ports/servoshell/window.rs` repaints with `webview.paint()` and presents the context.
- `ports/servoshell/running_app_state.rs` maps `notify_new_frame_ready` to repaint scheduling.
- `components/paint/paint.rs` owns one WebRender painter per rendering context.
## Target Boundaries
## Ownership
```text
crates/ely_browser_core
Owns browser domain state: tabs, spaces, profiles, search, settings.
Browser domain state: tabs, spaces, profiles, search, settings.
crates/ely_app/src/shell
Owns GPUI chrome and command surfaces.
GPUI chrome, profile broker, frame cadence, input, and presentation.
crates/ely_app/src/servo_embed
Owns in-process Servo runtime, platform content view attachment,
WebView lifecycle, repaint dispatch, and web input routing.
crates/ely_app/src/services
Sidecar discovery, process lifecycle, and wire client.
crates/ely_servo_host
Owns the current in-process compatibility adapter while the native
platform rendering surface lands.
Servo host API, rendering contexts, sidecar protocol, and process entry point.
```
Each production source file in the final embedding path stays below 500 lines. Large responsibilities split by ownership:
Each production source file in the embedding path stays below 500 lines. Responsibilities remain
split by lifecycle, protocol, session state, frame output, and browser-shell orchestration.
- `runtime.rs`: `Servo`, wake handling, webview registry.
- `platform_view.rs`: platform content surface attachment.
- `delegate.rs`: Servo `WebViewDelegate` implementation.
- `input.rs`: GPUI event to Servo input conversion.
- `paint.rs`: repaint and present coordination.
- `metadata.rs`: title, URL, favicon, load-state propagation.
## Rendering Transports
## Migration Slices
### macOS hardware transport
1. App process owns the Servo runtime and WebView registry.
2. App process routes web input and navigation directly into Servo.
3. GPUI exposes one `NativeSurfaceHandle` element contract for platform child surfaces.
4. macOS creates a child `NSView` and hands the AppKit raw handle to Servo.
5. Windows creates a child `HWND` and hands the Win32 raw handle to Servo.
6. Linux/X11 creates a child XCB window and hands the XCB raw handle to Servo.
7. Linux/Wayland creates a child `wl_surface`/subsurface and hands the Wayland raw handle to Servo.
8. GPUI web page display uses native content surface presentation for every platform target.
```text
Servo WebView::paint
-> CGL HardwareOffscreenContext
-> surfman swap chain
-> IOSurface
-> IOSurfaceCreateMachPort
-> mach_msg port descriptor
-> IOSurfaceLookupFromMachPort
-> CVPixelBuffer
-> GPUI surface element
-> Metal BGRA texture
```
The parent creates a unique bootstrap Mach service before spawning the sidecar. The sidecar looks
up that service and moves each new IOSurface send right in a complex Mach message. The app verifies
the system IOSurface ID and dimensions, wraps the imported surface in a `CVPixelBuffer`, and
releases the received Mach right. The cache retains up to 16 inactive IOSurfaces and temporarily
retains additional surfaces whose GPU backing is still active.
Each hardware frame carries `current_surface_id`. The first frame for an IOSurface also carries its
dimensions and system surface ID in `surface_handle`. GPUI presents the selected `CVPixelBuffer`
through its surface element and macOS Metal BGRA pipeline. This path keeps pixels on the GPU side of
the profile process boundary.
CoreVideo raises the IOSurface use count while a `CVPixelBuffer` backing is alive. The frame and
GPUI `SurfaceLease` share that backing, and the Metal command-buffer completion handler releases
the final GPU lease. The host retains presented surfman surfaces and returns an acknowledged,
non-current surface to the swap chain only after `IOSurfaceIsInUse` becomes false. A sidecar waits
for the app's import acknowledgement before painting the next hardware frame.
The macOS parent selects hardware rendering by default. Setting
`ELY_SERVO_RENDERING_CONTEXT=software` selects the RGBA path for software-specific diagnostics.
### Linux and Windows software transport
Linux and Windows use Servo's `SoftwareRenderingContext`. The sidecar paints into RGBA8 memory,
writes the JSON frame header, then writes the bounded raw payload on stdout. The app validates the
dimensions and byte count before allocating and creates a GPUI `RenderImage`.
## Acceptance Gates
- `cargo run` opens a normal web page with Servo inside the ELY app process.
- Scrolling a live web page uses Servo input events and Servo repaint callbacks.
- Page display presents through a Servo rendering context.
- macOS, Windows, Linux/X11, and Linux/Wayland each have a platform child surface implementation under the same `NativeSurfaceHandle` contract.
- Address/search, tabs, spaces, profiles, settings, permissions, and sync compile through existing domain APIs.
- Every new source file stays below 500 lines.
- User-facing chrome stays clean.
## First Platform Surface Target
```text
GPUI Window
-> GPUI NativeSurface element
-> platform child surface for web content bounds
-> Servo WindowRenderingContext
-> Servo WebView
```
This slice gives Servo a native surface in the ELY app process. The compatibility adapter remains a narrow bridge for tests and non-normal display probes.
- Workspace format, check, lint, and unit tests pass.
- Servo host check and lint pass with `servo-engine,hardware-render` enabled.
- The macOS hardware-context test presents a real IOSurface and exports a live Mach send right.
- The hardware sidecar test transfers that right across processes and resolves it with
`IOSurfaceLookupFromMachPort`.
- The app hardware test imports a real BGRA `CVPixelBuffer` and verifies its GPUI lease releases the
IOSurface use count.
- Sidecar persistence integration passes against a loopback HTTP server.
- PRD live-site tests pass through the GPUI web-surface adapter.
- Profile Cookie, local-storage, and HTTP-cache isolation passes through two live sidecars.
- The real-window render probe captures visible page content.
- The macOS application bundle contains both executables in `Contents/MacOS`.
- Native distributions place both executables at the distribution root.
- Every production source file stays below 500 lines.
+2
View File
@@ -11,6 +11,8 @@
<string>ely_app</string>
<key>CFBundleIdentifier</key>
<string>com.elydora.ely-browser</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleGetInfoString</key>
<string>ELY Browser by Elydora</string>
<key>CFBundleInfoDictionaryVersion</key>
+31 -3
View File
@@ -1,19 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
binary_path="${repo_root}/target/debug/ely_app"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
cd "${repo_root}"
target_dir="${CARGO_TARGET_DIR:-${repo_root}/target}"
case "${target_dir}" in
/*|[A-Za-z]:[\\/]*) ;;
*) target_dir="${repo_root}/${target_dir}" ;;
esac
source "${repo_root}/scripts/release_build_env.sh"
ely_configure_release_build_env "${repo_root}" "${target_dir}"
unset CARGO_BUILD_TARGET
binary_path="${target_dir}/release/ely_app"
sidecar_path="${target_dir}/release/ely_servo_sidecar"
icon_path="${repo_root}/generated-icons/macos/AppIcon.icns"
bundle_root="${repo_root}/target/macos/ELY Browser.app"
contents_dir="${bundle_root}/Contents"
macos_dir="${contents_dir}/MacOS"
resources_dir="${contents_dir}/Resources"
cargo build -p ely_app
cargo build --release --locked --manifest-path "${repo_root}/Cargo.toml" --target-dir "${target_dir}" -p ely_app
cargo build --release --locked --manifest-path "${repo_root}/Cargo.toml" --target-dir "${target_dir}" -p ely_servo_host --features servo-engine,hardware-render --bin ely_servo_sidecar
rm -rf "${bundle_root}"
mkdir -p "${macos_dir}" "${resources_dir}"
cp "${repo_root}/packaging/macos/Info.plist" "${contents_dir}/Info.plist"
cp "${icon_path}" "${resources_dir}/AppIcon.icns"
cp "${binary_path}" "${macos_dir}/ely_app"
cp "${sidecar_path}" "${macos_dir}/ely_servo_sidecar"
chmod 755 "${macos_dir}/ely_app"
chmod 755 "${macos_dir}/ely_servo_sidecar"
"${repo_root}/scripts/verify_release_artifacts.sh" \
"${macos_dir}/ely_app" \
"${macos_dir}/ely_servo_sidecar"
codesign_identity="${ELY_CODESIGN_IDENTITY:--}"
codesign --force --sign "${codesign_identity}" --timestamp=none "${macos_dir}/ely_app"
codesign --force --sign "${codesign_identity}" --timestamp=none "${macos_dir}/ely_servo_sidecar"
codesign --force --sign "${codesign_identity}" --timestamp=none "${bundle_root}"
codesign --verify --deep --strict "${bundle_root}"
[[ -x "${macos_dir}/ely_app" ]] || { echo "missing app executable in ${macos_dir}" >&2; exit 1; }
[[ -x "${macos_dir}/ely_servo_sidecar" ]] || { echo "missing sidecar executable in ${macos_dir}" >&2; exit 1; }
[[ -f "${resources_dir}/AppIcon.icns" ]] || { echo "missing app icon in ${resources_dir}" >&2; exit 1; }
[[ ! -e "${resources_dir}/ely_servo_sidecar" ]] || { echo "sidecar must be placed in ${macos_dir}" >&2; exit 1; }
echo "${bundle_root}"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
cd "${repo_root}"
target_dir="${CARGO_TARGET_DIR:-${repo_root}/target}"
case "${target_dir}" in
/*|[A-Za-z]:[\\/]*) ;;
*) target_dir="${repo_root}/${target_dir}" ;;
esac
source "${repo_root}/scripts/release_build_env.sh"
ely_configure_release_build_env "${repo_root}" "${target_dir}"
unset CARGO_BUILD_TARGET
distribution_dir="${repo_root}/target/distribution/ely-browser"
binary_suffix=""
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) binary_suffix=".exe" ;;
esac
cargo build --release --locked --manifest-path "${repo_root}/Cargo.toml" --target-dir "${target_dir}" -p ely_app
sidecar_features="servo-engine"
if [[ "$(uname -s)" == "Darwin" ]]; then
sidecar_features="servo-engine,hardware-render"
fi
cargo build --release --locked --manifest-path "${repo_root}/Cargo.toml" --target-dir "${target_dir}" -p ely_servo_host --features "${sidecar_features}" --bin ely_servo_sidecar
rm -rf "${distribution_dir}"
mkdir -p "${distribution_dir}"
cp "${target_dir}/release/ely_app${binary_suffix}" "${distribution_dir}/"
cp "${target_dir}/release/ely_servo_sidecar${binary_suffix}" "${distribution_dir}/"
case "$(uname -s)" in
Darwin)
cp "${repo_root}/packaging/macos/Info.plist" "${distribution_dir}/"
cp "${repo_root}/generated-icons/macos/AppIcon.icns" "${distribution_dir}/"
;;
Linux)
cp "${repo_root}/packaging/linux/com.elydora.ely-browser.desktop" "${distribution_dir}/"
;;
MINGW*|MSYS*|CYGWIN*)
cp "${repo_root}/packaging/windows/ely_app.exe.manifest" "${distribution_dir}/"
;;
esac
if [[ -z "${binary_suffix}" ]]; then
chmod 755 "${distribution_dir}/ely_app" "${distribution_dir}/ely_servo_sidecar"
fi
app_output="${distribution_dir}/ely_app${binary_suffix}"
sidecar_output="${distribution_dir}/ely_servo_sidecar${binary_suffix}"
"${repo_root}/scripts/verify_release_artifacts.sh" "${app_output}" "${sidecar_output}"
[[ -f "${app_output}" ]] || { echo "missing app executable at ${app_output}" >&2; exit 1; }
[[ -f "${sidecar_output}" ]] || { echo "missing sidecar executable at ${sidecar_output}" >&2; exit 1; }
if [[ -z "${binary_suffix}" ]]; then
[[ -x "${app_output}" ]] || { echo "app is not executable: ${app_output}" >&2; exit 1; }
[[ -x "${sidecar_output}" ]] || { echo "sidecar is not executable: ${sidecar_output}" >&2; exit 1; }
fi
echo "${distribution_dir}"
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
ely_append_encoded_rustflag() {
local flag="$1"
local separator=$'\x1f'
if [[ -n "${CARGO_ENCODED_RUSTFLAGS:-}" ]]; then
CARGO_ENCODED_RUSTFLAGS+="${separator}${flag}"
else
CARGO_ENCODED_RUSTFLAGS="${flag}"
fi
export CARGO_ENCODED_RUSTFLAGS
}
ely_path_variants() {
local path="$1"
local canonical_path=""
printf '%s\n' "${path}"
if [[ -d "${path}" ]]; then
canonical_path="$(cd "${path}" && pwd -P)"
if [[ "${canonical_path}" != "${path}" ]]; then
printf '%s\n' "${canonical_path}"
fi
fi
if command -v cygpath >/dev/null 2>&1; then
cygpath -m "${path}"
cygpath -w "${path}"
fi
}
ely_add_release_path_remap() {
local path="$1"
local replacement="$2"
local variant
while IFS= read -r variant; do
[[ -n "${variant}" ]] || continue
ely_append_encoded_rustflag "--remap-path-prefix=${variant}=${replacement}"
done < <(ely_path_variants "${path}")
}
ely_set_release_build_revision() {
local repo_root="$1"
local revision
if [[ -n "${ELY_BUILD_REVISION+x}" || ! -e "${repo_root}/.git" ]]; then
return 0
fi
revision="$(git -C "${repo_root}" rev-parse --short=12 HEAD)"
if [[ -n "$(git -C "${repo_root}" status --porcelain=v1 --untracked-files=normal)" ]]; then
revision+="-dirty"
fi
ELY_BUILD_REVISION="${revision}"
export ELY_BUILD_REVISION
}
ely_configure_release_build_env() {
local repo_root="$1"
local target_dir="$2"
local cargo_home="${CARGO_HOME:-${HOME}/.cargo}"
local rustup_home="${RUSTUP_HOME:-${HOME}/.rustup}"
local rust_sysroot
if [[ -n "${RUSTFLAGS:-}" ]]; then
echo "RUSTFLAGS cannot be combined safely with release path remapping; use CARGO_ENCODED_RUSTFLAGS" >&2
return 1
fi
ely_set_release_build_revision "${repo_root}"
rust_sysroot="$(rustc --print sysroot)"
ely_append_encoded_rustflag "--remap-path-scope=object"
ely_add_release_path_remap "${cargo_home}" "[cargo]"
ely_add_release_path_remap "${rustup_home}" "[rustup]"
ely_add_release_path_remap "${rust_sysroot}" "[rust-sysroot]"
ely_add_release_path_remap "${repo_root}" "."
ely_add_release_path_remap "${target_dir}" "[target]"
}
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
target_dir="${CARGO_TARGET_DIR:-${repo_root}/target}"
case "${target_dir}" in
/*|[A-Za-z]:[\\/]*) ;;
*) target_dir="${repo_root}/${target_dir}" ;;
esac
unset CARGO_BUILD_TARGET
sidecar_features="servo-engine"
rendering_context="software"
binary_suffix=""
if [[ "$(uname -s)" == "Darwin" ]]; then
sidecar_features="servo-engine,hardware-render"
rendering_context="hardware"
fi
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) binary_suffix=".exe" ;;
esac
cargo build --locked --manifest-path "${repo_root}/Cargo.toml" --target-dir "${target_dir}" \
-p ely_servo_host \
--features "${sidecar_features}" \
--bin ely_servo_sidecar
export ELY_SERVO_SIDECAR="${target_dir}/debug/ely_servo_sidecar${binary_suffix}"
export ELY_SERVO_RENDERING_CONTEXT="${rendering_context}"
exec cargo run --locked --manifest-path "${repo_root}/Cargo.toml" \
--target-dir "${target_dir}" \
-p ely_app \
-- "$@"
+1
View File
@@ -28,6 +28,7 @@ assert_value() {
assert_value "CFBundleDisplayName" "ELY Browser"
assert_value "CFBundleName" "ELY Browser"
assert_value "CFBundleIdentifier" "com.elydora.ely-browser"
assert_value "CFBundleIconFile" "AppIcon"
assert_value "CFBundleGetInfoString" "ELY Browser by Elydora"
assert_value "CFBundleExecutable" "ely_app"
assert_value "CFBundlePackageType" "APPL"
+3 -1
View File
@@ -2,4 +2,6 @@
set -euo pipefail
echo "Verifying PRD live sites through the GPUI web surface adapter"
cargo test -p ely_app --features live-site-smoke --all-targets -- --test-threads=1 --nocapture
cargo build --locked -p ely_servo_host --features servo-engine --bin ely_servo_sidecar
ELY_SERVO_RENDERING_CONTEXT=software \
cargo test --locked -p ely_app --features live-site-smoke --all-targets -- --test-threads=1 --nocapture
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
target_dir="${CARGO_TARGET_DIR:-${repo_root}/target}"
case "${target_dir}" in
/*|[A-Za-z]:[\\/]*) ;;
*) target_dir="${repo_root}/${target_dir}" ;;
esac
source "${repo_root}/scripts/release_build_env.sh"
[[ "$#" -gt 0 ]] || { echo "usage: $0 <release-artifact>..." >&2; exit 2; }
scan_value() {
local artifact="$1"
local label="$2"
local value="$3"
[[ -n "${value}" ]] || return 0
if LC_ALL=C grep -aFq -- "${value}" "${artifact}"; then
echo "release artifact contains ${label}: ${artifact}" >&2
return 1
fi
}
scan_path() {
local artifact="$1"
local label="$2"
local path="$3"
local variant
while IFS= read -r variant; do
scan_value "${artifact}" "${label}" "${variant}"
done < <(ely_path_variants "${path}")
}
cargo_home="${CARGO_HOME:-${HOME}/.cargo}"
rustup_home="${RUSTUP_HOME:-${HOME}/.rustup}"
rust_sysroot="$(rustc --print sysroot)"
for artifact in "$@"; do
[[ -f "${artifact}" ]] || { echo "missing release artifact: ${artifact}" >&2; exit 1; }
scan_path "${artifact}" "workspace path" "${repo_root}"
scan_path "${artifact}" "Cargo home path" "${cargo_home}"
scan_path "${artifact}" "Rustup home path" "${rustup_home}"
scan_path "${artifact}" "Rust sysroot path" "${rust_sysroot}"
scan_path "${artifact}" "target path" "${target_dir}"
scan_value "${artifact}" "embedded Metal source marker" "-frecord-sources=yes"
scan_value "${artifact}" "embedded Metal compiler command" "metal --driver-mode=metal"
done
+11 -4
View File
@@ -2,7 +2,7 @@
# T16 — real-window screencapture sanity check.
#
# Boots ./target/release/ely_app in the background, opens a live page through
# Servo's native surface path, captures the ELY window by its Core Graphics
# its profile-scoped Servo sidecar, captures the ELY window by its Core Graphics
# window ID, then asserts the PNG is non-trivial (size + dimensions + non-white
# center). Stderr from the app is
# tee'd to a log so a crash leaves evidence behind.
@@ -35,6 +35,7 @@ cleanup() {
# Stragglers from prior runs / child processes
pkill -f "target/release/ely_app" 2>/dev/null || true
pkill -x "ely_app" 2>/dev/null || true
pkill -x "ely_servo_sidecar" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
@@ -51,17 +52,23 @@ fail() {
echo "[1/6] killing stale ely_app processes"
pkill -f "target/release/ely_app" 2>/dev/null || true
pkill -x "ely_app" 2>/dev/null || true
pkill -x "ely_servo_sidecar" 2>/dev/null || true
sleep 0.5
echo "[2/6] cargo build --release --locked -p ely_app"
echo "[2/6] building release app and Servo sidecar"
if ! cargo build --release --locked -p ely_app; then
fail "cargo build failed"
fail "app build failed"
fi
if ! cargo build --release --locked -p ely_servo_host --features servo-engine,hardware-render --bin ely_servo_sidecar; then
fail "Servo sidecar build failed"
fi
[[ -x "${APP_BIN}" ]] || fail "binary missing: ${APP_BIN}"
[[ -x "${REPO_ROOT}/target/release/ely_servo_sidecar" ]] || fail "sidecar binary missing"
echo "[3/6] launching ${APP_BIN} ${SMOKE_URL} (stderr -> ${STDERR_LOG})"
: > "${STDERR_LOG}"
"${APP_BIN}" "${SMOKE_URL}" >/dev/null 2>"${STDERR_LOG}" &
ELY_SERVO_RENDERING_CONTEXT=hardware \
"${APP_BIN}" "${SMOKE_URL}" >/dev/null 2>"${STDERR_LOG}" &
APP_PID=$!
echo " pid=${APP_PID}"
+7 -7
View File
@@ -201,18 +201,18 @@ mod macos {
PathBuf::from(env::var("OUT_DIR").unwrap()).join("shaders.metallib");
println!("cargo:rerun-if-changed={}", shader_path);
let output = Command::new("xcrun")
let mut command = Command::new("xcrun");
command.args(["-sdk", "macosx", "metal"]);
if env::var("DEBUG").as_deref() == Ok("true") {
command.args(["-gline-tables-only", "-MO"]);
}
let output = command
.args([
"-sdk",
"macosx",
"metal",
"-gline-tables-only",
"-mmacosx-version-min=10.15.7",
"-MO",
"-c",
shader_path,
"-include",
(header_path.to_str().unwrap()),
header_path.to_str().unwrap(),
"-o",
])
.arg(&air_output_path)
+72 -1
View File
@@ -5,6 +5,33 @@ use crate::{
#[cfg(target_os = "macos")]
use core_video::pixel_buffer::CVPixelBuffer;
use refineable::Refineable;
use std::{fmt, sync::Arc};
/// An owner retained until the GPU finishes reading a surface frame.
///
/// Attach a lease with [`Surface::lease`] when the surface's producer may
/// recycle its backing storage independently of the CoreVideo pixel buffer.
#[derive(Clone)]
pub struct SurfaceLease(Arc<dyn Send + Sync>);
impl SurfaceLease {
/// Create a lease from a thread-safe shared owner.
pub fn from_arc<T>(owner: Arc<T>) -> Self
where
T: Send + Sync + 'static,
{
Self(owner)
}
}
impl fmt::Debug for SurfaceLease {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SurfaceLease")
.field("strong_count", &Arc::strong_count(&self.0))
.finish()
}
}
/// A source of a surface's content.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -24,6 +51,7 @@ impl From<CVPixelBuffer> for SurfaceSource {
/// A surface element.
pub struct Surface {
source: SurfaceSource,
lease: Option<SurfaceLease>,
object_fit: ObjectFit,
corner_radii: Option<Corners<Pixels>>,
style: StyleRefinement,
@@ -33,6 +61,7 @@ pub struct Surface {
pub fn surface(source: impl Into<SurfaceSource>) -> Surface {
Surface {
source: source.into(),
lease: None,
object_fit: ObjectFit::Contain,
corner_radii: None,
style: Default::default(),
@@ -40,6 +69,12 @@ pub fn surface(source: impl Into<SurfaceSource>) -> Surface {
}
impl Surface {
/// Retain an owner until the GPU completes this surface frame.
pub fn lease(mut self, lease: SurfaceLease) -> Self {
self.lease = Some(lease);
self
}
/// Set the object fit for the image.
pub fn object_fit(mut self, object_fit: ObjectFit) -> Self {
self.object_fit = object_fit;
@@ -110,7 +145,12 @@ impl Element for Surface {
.corner_radii
.unwrap_or_else(|| style.corner_radii.to_pixels(window.rem_size()))
.clamp_radii_for_quad_size(new_bounds.size);
window.paint_surface(new_bounds, corner_radii, surface.clone());
window.paint_surface_with_lease(
new_bounds,
corner_radii,
surface.clone(),
self.lease.clone(),
);
}
#[allow(unreachable_patterns)]
_ => {}
@@ -131,3 +171,34 @@ impl Styled for Surface {
&mut self.style
}
}
#[cfg(test)]
mod tests {
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use super::SurfaceLease;
struct DropProbe(Arc<AtomicUsize>);
impl Drop for DropProbe {
fn drop(&mut self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
#[test]
fn surface_lease_retains_its_owner() {
let drops = Arc::new(AtomicUsize::new(0));
let owner = Arc::new(DropProbe(drops.clone()));
let lease = SurfaceLease::from_arc(owner.clone());
drop(owner);
assert_eq!(drops.load(Ordering::SeqCst), 0);
drop(lease);
assert_eq!(drops.load(Ordering::SeqCst), 1);
}
}
+47 -15
View File
@@ -384,19 +384,17 @@ impl MetalRenderer {
let mut instance_buffer = self.instance_buffer_pool.lock().acquire(&self.device);
let command_buffer =
self.draw_primitives(scene, &mut instance_buffer, drawable, viewport_size);
self.draw_primitives(scene, &mut instance_buffer, drawable.texture(), viewport_size);
match command_buffer {
Ok(command_buffer) => {
let instance_buffer_pool = self.instance_buffer_pool.clone();
let instance_buffer = Cell::new(Some(instance_buffer));
let block = ConcreteBlock::new(move |_| {
if let Some(instance_buffer) = instance_buffer.take() {
instance_buffer_pool.lock().release(instance_buffer);
}
});
let block = block.copy();
command_buffer.add_completed_handler(&block);
retain_frame_resources_until_completion(
command_buffer.as_ref(),
scene,
instance_buffer,
self.instance_buffer_pool.clone(),
None,
);
if self.presents_with_transaction {
command_buffer.commit();
@@ -433,7 +431,7 @@ impl MetalRenderer {
&mut self,
scene: &Scene,
instance_buffer: &mut InstanceBuffer,
drawable: &metal::MetalDrawableRef,
target_texture: &metal::TextureRef,
viewport_size: Size<DevicePixels>,
) -> Result<metal::CommandBuffer> {
let command_queue = self.command_queue.clone();
@@ -443,7 +441,7 @@ impl MetalRenderer {
let mut command_encoder = new_command_encoder(
command_buffer,
drawable,
target_texture,
viewport_size,
|color_attachment| {
color_attachment.set_load_action(metal::MTLLoadAction::Clear);
@@ -480,7 +478,7 @@ impl MetalRenderer {
command_encoder = new_command_encoder(
command_buffer,
drawable,
target_texture,
viewport_size,
|color_attachment| {
color_attachment.set_load_action(metal::MTLLoadAction::Load);
@@ -1197,9 +1195,36 @@ impl MetalRenderer {
}
}
fn retain_frame_resources_until_completion(
command_buffer: &metal::CommandBufferRef,
scene: &Scene,
instance_buffer: InstanceBuffer,
instance_buffer_pool: Arc<Mutex<InstanceBufferPool>>,
completion_tx: Option<std::sync::mpsc::Sender<()>>,
) {
let instance_buffer = Cell::new(Some(instance_buffer));
let surface_leases = Cell::new(Some(
scene
.surfaces
.iter()
.filter_map(|surface| surface.lease.clone())
.collect::<Vec<_>>(),
));
let block = ConcreteBlock::new(move |_| {
let _ = surface_leases.take();
if let Some(instance_buffer) = instance_buffer.take() {
instance_buffer_pool.lock().release(instance_buffer);
}
if let Some(completion_tx) = completion_tx.as_ref() {
let _ = completion_tx.send(());
}
});
command_buffer.add_completed_handler(&block.copy());
}
fn new_command_encoder<'a>(
command_buffer: &'a metal::CommandBufferRef,
drawable: &'a metal::MetalDrawableRef,
target_texture: &'a metal::TextureRef,
viewport_size: Size<DevicePixels>,
configure_color_attachment: impl Fn(&RenderPassColorAttachmentDescriptorRef),
) -> &'a metal::RenderCommandEncoderRef {
@@ -1208,7 +1233,7 @@ fn new_command_encoder<'a>(
.color_attachments()
.object_at(0)
.unwrap();
color_attachment.set_texture(Some(drawable.texture()));
color_attachment.set_texture(Some(target_texture));
color_attachment.set_store_action(metal::MTLStoreAction::Store);
configure_color_attachment(color_attachment);
@@ -1224,6 +1249,13 @@ fn new_command_encoder<'a>(
command_encoder
}
#[cfg(feature = "test-support")]
#[path = "metal_renderer_test_support.rs"]
mod test_support;
#[cfg(feature = "test-support")]
pub use test_support::{MetalSurfaceTestSubmission, submit_surface_to_metal_for_test};
fn build_pipeline_state(
device: &metal::DeviceRef,
library: &metal::LibraryRef,
@@ -0,0 +1,162 @@
use std::{
ffi::CStr,
os::raw::c_char,
sync::{Arc, mpsc},
time::Duration,
};
use core_video::pixel_buffer::CVPixelBuffer;
use metal::{MTLCommandBufferStatus, MTLPixelFormat, MTLTextureUsage, TextureDescriptor};
use parking_lot::Mutex;
use objc::{msg_send, runtime::Object, sel, sel_impl};
use super::{
InstanceBufferPool, MetalRenderer, retain_frame_resources_until_completion,
};
use crate::{
Bounds, ContentMask, Corners, DevicePixels, PaintSurface, ScaledPixels, Scene, SurfaceLease,
point, size,
};
const COMPLETION_TIMEOUT: Duration = Duration::from_secs(5);
const COMPLETION_GATE_VALUE: u64 = 1;
/// A gated offscreen Metal submission that uses GPUI's production surface pipeline.
pub struct MetalSurfaceTestSubmission {
gate: metal::SharedEvent,
command_buffer: metal::CommandBuffer,
completion_rx: mpsc::Receiver<()>,
_target_texture: metal::Texture,
finished: bool,
}
impl MetalSurfaceTestSubmission {
/// Releases the GPU gate and waits for the production completion handler.
pub fn finish(mut self) -> crate::Result<()> {
self.gate.set_signaled_value(COMPLETION_GATE_VALUE);
self.command_buffer.wait_until_completed();
self.completion_rx.recv_timeout(COMPLETION_TIMEOUT)?;
anyhow::ensure!(
self.command_buffer.status() == MTLCommandBufferStatus::Completed,
"Metal surface test submission ended with status {:?}: {}",
self.command_buffer.status(),
command_buffer_error(self.command_buffer.as_ref()),
);
self.finished = true;
Ok(())
}
}
impl Drop for MetalSurfaceTestSubmission {
fn drop(&mut self) {
if !self.finished {
self.gate.set_signaled_value(COMPLETION_GATE_VALUE);
}
}
}
/// Submits a CoreVideo surface through GPUI's production BGRA Metal pipeline.
pub fn submit_surface_to_metal_for_test(
image_buffer: CVPixelBuffer,
lease: SurfaceLease,
) -> crate::Result<MetalSurfaceTestSubmission> {
let width = u32::try_from(image_buffer.get_width())?;
let height = u32::try_from(image_buffer.get_height())?;
anyhow::ensure!(width > 0 && height > 0, "surface dimensions must be positive");
let viewport_size = size(
DevicePixels(i32::try_from(width)?),
DevicePixels(i32::try_from(height)?),
);
let instance_buffer_pool = Arc::new(Mutex::new(InstanceBufferPool::default()));
let mut renderer = MetalRenderer::new(instance_buffer_pool.clone());
let target_texture = build_target_texture(&renderer.device, width, height);
let mut scene = build_surface_scene(image_buffer, lease, width, height);
scene.finish();
let mut instance_buffer = instance_buffer_pool.lock().acquire(&renderer.device);
let command_buffer = renderer.draw_primitives(
&scene,
&mut instance_buffer,
target_texture.as_ref(),
viewport_size,
)?;
let gate = renderer.device.new_shared_event();
gate.set_signaled_value(0);
command_buffer.encode_wait_for_event(&gate, COMPLETION_GATE_VALUE);
let (completion_tx, completion_rx) = mpsc::channel();
retain_frame_resources_until_completion(
command_buffer.as_ref(),
&scene,
instance_buffer,
instance_buffer_pool,
Some(completion_tx),
);
command_buffer.commit();
anyhow::ensure!(
!matches!(
command_buffer.status(),
MTLCommandBufferStatus::Completed | MTLCommandBufferStatus::Error
),
"gated Metal surface submission ended early with status {:?}: {error}",
command_buffer.status(),
error = command_buffer_error(command_buffer.as_ref()),
);
Ok(MetalSurfaceTestSubmission {
gate,
command_buffer,
completion_rx,
_target_texture: target_texture,
finished: false,
})
}
fn command_buffer_error(command_buffer: &metal::CommandBufferRef) -> String {
#[expect(unsafe_code)]
unsafe {
let error: *mut Object = msg_send![command_buffer, error];
if error.is_null() {
return "no Metal error description".to_string();
}
let description: *mut Object = msg_send![error, localizedDescription];
if description.is_null() {
return "Metal error description was null".to_string();
}
let utf8: *const c_char = msg_send![description, UTF8String];
if utf8.is_null() {
return "Metal error description had no UTF-8 representation".to_string();
}
CStr::from_ptr(utf8).to_string_lossy().into_owned()
}
}
fn build_target_texture(device: &metal::DeviceRef, width: u32, height: u32) -> metal::Texture {
let descriptor = TextureDescriptor::new();
descriptor.set_width(u64::from(width));
descriptor.set_height(u64::from(height));
descriptor.set_pixel_format(MTLPixelFormat::BGRA8Unorm);
descriptor.set_storage_mode(metal::MTLStorageMode::Private);
descriptor.set_usage(MTLTextureUsage::RenderTarget | MTLTextureUsage::ShaderRead);
device.new_texture(&descriptor)
}
fn build_surface_scene(
image_buffer: CVPixelBuffer,
lease: SurfaceLease,
width: u32,
height: u32,
) -> Scene {
let bounds = Bounds::new(
point(ScaledPixels::default(), ScaledPixels::default()),
size(ScaledPixels::from(width as f32), ScaledPixels::from(height as f32)),
);
let mut scene = Scene::default();
scene.insert_primitive(PaintSurface {
order: 0,
bounds,
content_mask: ContentMask { bounds },
corner_radii: Corners::default(),
image_buffer,
lease: Some(lease),
});
scene
}
+2
View File
@@ -661,6 +661,8 @@ pub(crate) struct PaintSurface {
pub corner_radii: Corners<ScaledPixels>,
#[cfg(target_os = "macos")]
pub image_buffer: core_video::pixel_buffer::CVPixelBuffer,
#[cfg(target_os = "macos")]
pub lease: Option<crate::SurfaceLease>,
}
impl From<PaintSurface> for Primitive {
+5
View File
@@ -35,6 +35,11 @@ use std::{
pin::Pin,
};
#[cfg(all(target_os = "macos", feature = "test-support", not(feature = "macos-blade")))]
pub use crate::metal_renderer::{
MetalSurfaceTestSubmission, submit_surface_to_metal_for_test,
};
/// Run the given test function with the configured parameters.
/// This is intended for use with the `gpui::test` macro
/// and generally should not be used directly.
+12
View File
@@ -3184,6 +3184,17 @@ impl Window {
bounds: Bounds<Pixels>,
corner_radii: Corners<Pixels>,
image_buffer: CVPixelBuffer,
) {
self.paint_surface_with_lease(bounds, corner_radii, image_buffer, None);
}
#[cfg(target_os = "macos")]
pub(crate) fn paint_surface_with_lease(
&mut self,
bounds: Bounds<Pixels>,
corner_radii: Corners<Pixels>,
image_buffer: CVPixelBuffer,
lease: Option<crate::SurfaceLease>,
) {
use crate::PaintSurface;
@@ -3199,6 +3210,7 @@ impl Window {
content_mask,
corner_radii,
image_buffer,
lease,
});
}