feat(servo): isolate profiles with hardware sidecars
This commit is contained in:
@@ -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
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -15,7 +15,6 @@ ed25519-dalek.workspace = true
|
||||
ely_browser_core = { path = "../ely_browser_core" }
|
||||
ely_design_system = { path = "../ely_design_system" }
|
||||
ely_domain = { path = "../ely_domain" }
|
||||
ely_servo_host = { path = "../ely_servo_host", features = ["servo-engine"] }
|
||||
ely_sync_client = { path = "../ely_sync_client" }
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
@@ -24,13 +23,24 @@ image.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
ureq.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
core-video = "0.4"
|
||||
io-surface = "0.16"
|
||||
mach2 = "0.6"
|
||||
objc2-core-foundation = { version = "0.3.2", features = ["CFBase", "CFDictionary", "CFNumber", "CFString"] }
|
||||
objc2-io-surface = "0.3.2"
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ely_servo_host = { path = "../ely_servo_host" }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
+79
-31
@@ -11,24 +11,19 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
|
||||
let workspace_root = workspace_root(&manifest_dir)?;
|
||||
let workspace_manifest_path = workspace_root.join("Cargo.toml");
|
||||
let git_head_path = workspace_root.join(".git/HEAD");
|
||||
let workspace_manifest = read_workspace_manifest(&workspace_manifest_path)?;
|
||||
let workspace = table(&workspace_manifest, "workspace")?;
|
||||
let package = table(workspace, "package")?;
|
||||
let dependencies = table(workspace, "dependencies")?;
|
||||
|
||||
emit_cargo_directive(format!("cargo:rerun-if-changed={}", workspace_manifest_path.display()))?;
|
||||
emit_cargo_directive(format!("cargo:rerun-if-changed={}", git_head_path.display()))?;
|
||||
if let Some(ref_path) = git_head_ref(&git_head_path)? {
|
||||
emit_cargo_directive(format!(
|
||||
"cargo:rerun-if-changed={}",
|
||||
workspace_root.join(".git").join(ref_path).display()
|
||||
))?;
|
||||
}
|
||||
emit_cargo_directive("cargo:rerun-if-env-changed=ELY_BUILD_REVISION")?;
|
||||
|
||||
emit_env("ELY_BUILD_REVISION", &git_revision(workspace_root)?)?;
|
||||
emit_env("ELY_BUILD_REVISION", &build_revision(workspace_root)?)?;
|
||||
emit_env("ELY_WORKSPACE_LICENSE", string_value(package, "license")?)?;
|
||||
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());
|
||||
}
|
||||
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());
|
||||
}
|
||||
Ok(revision)
|
||||
Err(env::VarError::NotPresent) => {}
|
||||
}
|
||||
|
||||
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 {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
fn git_revision(workspace_root: &Path) -> Result<String, Box<dyn Error>> {
|
||||
emit_git_watch(workspace_root, "HEAD")?;
|
||||
emit_git_watch(workspace_root, "packed-refs")?;
|
||||
if let Some(reference) = git_optional_output(workspace_root, &["symbolic-ref", "-q", "HEAD"])? {
|
||||
emit_git_watch(workspace_root, &reference)?;
|
||||
}
|
||||
let revision = git_output(workspace_root, &["rev-parse", "--short=12", "HEAD"])?;
|
||||
validated_revision(revision)
|
||||
}
|
||||
|
||||
fn emit_git_watch(workspace_root: &Path, git_path: &str) -> Result<(), Box<dyn Error>> {
|
||||
let path = PathBuf::from(git_output(workspace_root, &["rev-parse", "--git-path", git_path])?);
|
||||
let path = if path.is_absolute() { path } else { workspace_root.join(path) };
|
||||
emit_cargo_directive(format!("cargo:rerun-if-changed={}", path.display()))
|
||||
}
|
||||
|
||||
fn git_output(workspace_root: &Path, arguments: &[&str]) -> Result<String, Box<dyn Error>> {
|
||||
let output = Command::new("git").args(arguments).current_dir(workspace_root).output()?;
|
||||
if !output.status.success() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("git {} failed", arguments.join(" ")),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
String::from_utf8(output.stdout).map(|value| value.trim().to_string()).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn git_optional_output(
|
||||
workspace_root: &Path,
|
||||
arguments: &[&str],
|
||||
) -> Result<Option<String>, Box<dyn Error>> {
|
||||
let output = Command::new("git").args(arguments).current_dir(workspace_root).output()?;
|
||||
if output.status.success() {
|
||||
return String::from_utf8(output.stdout)
|
||||
.map(|value| Some(value.trim().to_string()))
|
||||
.map_err(Into::into);
|
||||
}
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(ref_path.to_string()))
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::InvalidData, format!("git {} failed", arguments.join(" ")))
|
||||
.into())
|
||||
}
|
||||
|
||||
fn validated_revision(revision: String) -> Result<String, Box<dyn Error>> {
|
||||
let revision = revision.trim();
|
||||
validate_env_value("ELY_BUILD_REVISION", revision)?;
|
||||
Ok(revision.to_string())
|
||||
}
|
||||
|
||||
fn emit_env(key: &str, value: &str) -> Result<(), Box<dyn Error>> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("{key} is empty")).into());
|
||||
}
|
||||
validate_env_value(key, value)?;
|
||||
emit_cargo_directive(format!("cargo:rustc-env={key}={value}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_env_value(key: &str, value: &str) -> Result<(), Box<dyn Error>> {
|
||||
if value.trim().is_empty() || value.contains('\r') || value.contains('\n') {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("invalid {key}")).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn emit_cargo_directive(directive: impl AsRef<str>) -> Result<(), Box<dyn Error>> {
|
||||
writeln!(io::stdout(), "{}", directive.as_ref())?;
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
ffi::CString,
|
||||
mem,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use mach2::{
|
||||
bootstrap::{bootstrap_port, bootstrap_register},
|
||||
kern_return::KERN_SUCCESS,
|
||||
mach_port::{
|
||||
mach_port_allocate, mach_port_deallocate, mach_port_destroy, mach_port_insert_right,
|
||||
},
|
||||
message::{
|
||||
MACH_MSG_PORT_DESCRIPTOR, MACH_MSG_SUCCESS, MACH_MSG_TIMEOUT_NONE, MACH_MSG_TYPE_MAKE_SEND,
|
||||
MACH_MSG_TYPE_MOVE_SEND, MACH_MSGH_BITS_COMPLEX, MACH_RCV_MSG, MACH_RCV_TIMED_OUT,
|
||||
MACH_RCV_TIMEOUT, mach_msg, mach_msg_body_t, mach_msg_header_t, mach_msg_port_descriptor_t,
|
||||
mach_msg_trailer_t,
|
||||
},
|
||||
port::{MACH_PORT_NULL, MACH_PORT_RIGHT_RECEIVE, mach_port_t},
|
||||
traps::mach_task_self,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
const IOSURFACE_PORT_MESSAGE_ID: i32 = 0x454c_5901;
|
||||
const SERVICE_PREFIX: &str = "com.ely.browser.iosurface";
|
||||
const MAX_PENDING_SURFACE_PORTS: usize = 16;
|
||||
|
||||
pub(crate) struct IOSurfaceMachReceiver {
|
||||
service_name: String,
|
||||
receive_port: mach_port_t,
|
||||
pending_ports: BTreeMap<u64, mach_port_t>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum IOSurfaceMachError {
|
||||
#[error("Mach service name contains an interior nul byte")]
|
||||
InvalidServiceName,
|
||||
#[error("mach_port_allocate returned {code}")]
|
||||
AllocatePort { code: i32 },
|
||||
#[error("mach_port_insert_right returned {code}")]
|
||||
InsertSendRight { code: i32 },
|
||||
#[error("bootstrap_register returned {code}")]
|
||||
RegisterService { code: i32 },
|
||||
#[error("mach_msg receive timed out for IOSurface surface {surface_id:#x}")]
|
||||
ReceiveTimedOut { surface_id: u64 },
|
||||
#[error("mach_msg receive returned {code}")]
|
||||
Receive { code: i32 },
|
||||
#[error("received unexpected Mach message id {message_id}")]
|
||||
UnexpectedMessage { message_id: i32 },
|
||||
#[error("received invalid IOSurface Mach message")]
|
||||
InvalidMessage,
|
||||
}
|
||||
|
||||
impl IOSurfaceMachReceiver {
|
||||
pub(crate) fn new() -> Result<Self, IOSurfaceMachError> {
|
||||
let service_name = unique_service_name();
|
||||
let service_name_c = CString::new(service_name.as_str())
|
||||
.map_err(|_| IOSurfaceMachError::InvalidServiceName)?;
|
||||
let mut receive_port = MACH_PORT_NULL;
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
let task = unsafe { mach_task_self() };
|
||||
#[expect(unsafe_code)]
|
||||
let allocate =
|
||||
unsafe { mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &mut receive_port) };
|
||||
if allocate != KERN_SUCCESS {
|
||||
return Err(IOSurfaceMachError::AllocatePort { code: allocate });
|
||||
}
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
let insert = unsafe {
|
||||
mach_port_insert_right(task, receive_port, receive_port, MACH_MSG_TYPE_MAKE_SEND)
|
||||
};
|
||||
if insert != KERN_SUCCESS {
|
||||
destroy_port(receive_port);
|
||||
return Err(IOSurfaceMachError::InsertSendRight { code: insert });
|
||||
}
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
#[allow(deprecated)]
|
||||
let register = unsafe {
|
||||
bootstrap_register(bootstrap_port, service_name_c.as_ptr() as *mut _, receive_port)
|
||||
};
|
||||
if register != KERN_SUCCESS {
|
||||
destroy_port(receive_port);
|
||||
return Err(IOSurfaceMachError::RegisterService { code: register });
|
||||
}
|
||||
|
||||
Ok(Self { service_name, receive_port, pending_ports: BTreeMap::new() })
|
||||
}
|
||||
|
||||
pub(crate) fn service_name(&self) -> &str {
|
||||
self.service_name.as_str()
|
||||
}
|
||||
|
||||
pub(crate) fn receive_port_for_surface(
|
||||
&mut self,
|
||||
surface_id: u64,
|
||||
timeout: Duration,
|
||||
) -> Result<mach_port_t, IOSurfaceMachError> {
|
||||
if let Some(port) = self.pending_ports.remove(&surface_id) {
|
||||
return Ok(port);
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id });
|
||||
}
|
||||
let Some(received) = self.receive_one(remaining)? else {
|
||||
return Err(IOSurfaceMachError::ReceiveTimedOut { surface_id });
|
||||
};
|
||||
if received.surface_id == surface_id {
|
||||
return Ok(received.mach_port);
|
||||
}
|
||||
if let Some(discarded) = insert_pending_port(
|
||||
&mut self.pending_ports,
|
||||
received.surface_id,
|
||||
received.mach_port,
|
||||
) {
|
||||
deallocate_port(discarded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn receive_one(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<ReceivedSurfacePort>, IOSurfaceMachError> {
|
||||
#[expect(unsafe_code)]
|
||||
let mut received_message: ReceivedIOSurfacePortMessage = unsafe { mem::zeroed() };
|
||||
#[expect(unsafe_code)]
|
||||
let result = unsafe {
|
||||
mach_msg(
|
||||
&mut received_message.message.header,
|
||||
MACH_RCV_MSG | MACH_RCV_TIMEOUT,
|
||||
0,
|
||||
mem::size_of::<ReceivedIOSurfacePortMessage>() as u32,
|
||||
self.receive_port,
|
||||
timeout_millis(timeout),
|
||||
MACH_PORT_NULL,
|
||||
)
|
||||
};
|
||||
|
||||
if result == MACH_RCV_TIMED_OUT {
|
||||
return Ok(None);
|
||||
}
|
||||
if result != MACH_MSG_SUCCESS {
|
||||
return Err(IOSurfaceMachError::Receive { code: result });
|
||||
}
|
||||
let message = &mut received_message.message;
|
||||
if message.header.msgh_id != IOSURFACE_PORT_MESSAGE_ID {
|
||||
let message_id = message.header.msgh_id;
|
||||
destroy_message(message);
|
||||
return Err(IOSurfaceMachError::UnexpectedMessage { message_id });
|
||||
}
|
||||
if !is_valid_surface_port_message(message) {
|
||||
destroy_message(message);
|
||||
return Err(IOSurfaceMachError::InvalidMessage);
|
||||
}
|
||||
|
||||
Ok(Some(ReceivedSurfacePort {
|
||||
surface_id: message.surface_id,
|
||||
mach_port: message.surface_port.name,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_pending_port(
|
||||
pending_ports: &mut BTreeMap<u64, mach_port_t>,
|
||||
surface_id: u64,
|
||||
mach_port: mach_port_t,
|
||||
) -> Option<mach_port_t> {
|
||||
if let Some(replaced) = pending_ports.insert(surface_id, mach_port) {
|
||||
return Some(replaced);
|
||||
}
|
||||
if pending_ports.len() > MAX_PENDING_SURFACE_PORTS
|
||||
&& let Some(evicted_id) = pending_ports.keys().next().copied()
|
||||
{
|
||||
return pending_ports.remove(&evicted_id);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl Drop for IOSurfaceMachReceiver {
|
||||
fn drop(&mut self) {
|
||||
for port in std::mem::take(&mut self.pending_ports).into_values() {
|
||||
deallocate_port(port);
|
||||
}
|
||||
destroy_port(self.receive_port);
|
||||
}
|
||||
}
|
||||
|
||||
struct ReceivedSurfacePort {
|
||||
surface_id: u64,
|
||||
mach_port: mach_port_t,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct IOSurfacePortMessage {
|
||||
header: mach_msg_header_t,
|
||||
body: mach_msg_body_t,
|
||||
surface_port: mach_msg_port_descriptor_t,
|
||||
surface_id: u64,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct ReceivedIOSurfacePortMessage {
|
||||
message: IOSurfacePortMessage,
|
||||
_trailer: mach_msg_trailer_t,
|
||||
}
|
||||
|
||||
fn is_valid_surface_port_message(message: &IOSurfacePortMessage) -> bool {
|
||||
message.header.msgh_bits & MACH_MSGH_BITS_COMPLEX != 0
|
||||
&& message.header.msgh_size == mem::size_of::<IOSurfacePortMessage>() as u32
|
||||
&& message.body.msgh_descriptor_count == 1
|
||||
&& message.surface_port.type_ == MACH_MSG_PORT_DESCRIPTOR as u8
|
||||
&& message.surface_port.disposition == MACH_MSG_TYPE_MOVE_SEND as u8
|
||||
&& message.surface_port.name != MACH_PORT_NULL
|
||||
}
|
||||
|
||||
fn unique_service_name() -> String {
|
||||
format!("{SERVICE_PREFIX}.{}", Uuid::now_v7().as_simple())
|
||||
}
|
||||
|
||||
fn timeout_millis(timeout: Duration) -> u32 {
|
||||
u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX).max(MACH_MSG_TIMEOUT_NONE + 1)
|
||||
}
|
||||
|
||||
fn destroy_message(message: &mut IOSurfacePortMessage) {
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
mach2::message::mach_msg_destroy(&mut message.header);
|
||||
}
|
||||
}
|
||||
|
||||
fn destroy_port(port: mach_port_t) {
|
||||
#[expect(unsafe_code)]
|
||||
let task = unsafe { mach_task_self() };
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
let _ = mach_port_destroy(task, port);
|
||||
}
|
||||
}
|
||||
|
||||
fn deallocate_port(port: mach_port_t) {
|
||||
#[expect(unsafe_code)]
|
||||
let task = unsafe { mach_task_self() };
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
let _ = mach_port_deallocate(task, port);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn surface_port_message() -> IOSurfacePortMessage {
|
||||
IOSurfacePortMessage {
|
||||
header: mach_msg_header_t {
|
||||
msgh_bits: MACH_MSGH_BITS_COMPLEX,
|
||||
msgh_size: mem::size_of::<IOSurfacePortMessage>() as u32,
|
||||
msgh_id: IOSURFACE_PORT_MESSAGE_ID,
|
||||
..mach_msg_header_t::default()
|
||||
},
|
||||
body: mach_msg_body_t { msgh_descriptor_count: 1 },
|
||||
surface_port: mach_msg_port_descriptor_t::new(42, MACH_MSG_TYPE_MOVE_SEND),
|
||||
surface_id: 7,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_port_message_requires_kernel_translated_complex_descriptor() {
|
||||
let mut message = surface_port_message();
|
||||
assert!(is_valid_surface_port_message(&message));
|
||||
|
||||
message.header.msgh_bits &= !MACH_MSGH_BITS_COMPLEX;
|
||||
assert!(!is_valid_surface_port_message(&message));
|
||||
|
||||
message = surface_port_message();
|
||||
message.header.msgh_size -= 1;
|
||||
assert!(!is_valid_surface_port_message(&message));
|
||||
|
||||
message = surface_port_message();
|
||||
message.surface_port.disposition = mach2::message::MACH_MSG_TYPE_COPY_SEND as u8;
|
||||
assert!(!is_valid_surface_port_message(&message));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_rejects_simple_message_with_forged_port_descriptor() -> Result<(), IOSurfaceMachError>
|
||||
{
|
||||
let receiver = IOSurfaceMachReceiver::new()?;
|
||||
let mut message = surface_port_message();
|
||||
message.header.msgh_bits =
|
||||
mach2::message::MACH_MSGH_BITS(mach2::message::MACH_MSG_TYPE_COPY_SEND, 0);
|
||||
message.header.msgh_remote_port = receiver.receive_port;
|
||||
message.surface_port.name = receiver.receive_port;
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
let send = unsafe {
|
||||
mach_msg(
|
||||
&mut message.header,
|
||||
mach2::message::MACH_SEND_MSG,
|
||||
message.header.msgh_size,
|
||||
0,
|
||||
MACH_PORT_NULL,
|
||||
MACH_MSG_TIMEOUT_NONE,
|
||||
MACH_PORT_NULL,
|
||||
)
|
||||
};
|
||||
assert_eq!(send, MACH_MSG_SUCCESS);
|
||||
assert!(matches!(
|
||||
receiver.receive_one(Duration::from_secs(1)),
|
||||
Err(IOSurfaceMachError::InvalidMessage)
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_port_map_evicts_lowest_surface_at_capacity() {
|
||||
let mut ports = BTreeMap::new();
|
||||
for surface_id in 0..=MAX_PENDING_SURFACE_PORTS as u64 {
|
||||
let discarded = insert_pending_port(&mut ports, surface_id, surface_id as mach_port_t);
|
||||
if surface_id < MAX_PENDING_SURFACE_PORTS as u64 {
|
||||
assert_eq!(discarded, None);
|
||||
} else {
|
||||
assert_eq!(discarded, Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(ports.len(), MAX_PENDING_SURFACE_PORTS);
|
||||
assert!(!ports.contains_key(&0));
|
||||
assert!(ports.contains_key(&(MAX_PENDING_SURFACE_PORTS as u64)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, VecDeque},
|
||||
sync::{Arc, Weak},
|
||||
};
|
||||
|
||||
use core_foundation::base::TCFType as _;
|
||||
use core_video::pixel_buffer::CVPixelBuffer;
|
||||
#[allow(deprecated)]
|
||||
use io_surface::IOSurface;
|
||||
use mach2::{mach_port::mach_port_deallocate, traps::mach_task_self};
|
||||
use objc2_core_foundation::CFRetained;
|
||||
use objc2_io_surface::IOSurfaceRef;
|
||||
use thiserror::Error;
|
||||
|
||||
pub(crate) struct IOSurfaceCache {
|
||||
surfaces: BTreeMap<u64, CachedSurface>,
|
||||
insertion_order: VecDeque<u64>,
|
||||
}
|
||||
|
||||
const MAX_CACHED_SURFACES: usize = 16;
|
||||
|
||||
struct CachedSurface {
|
||||
iosurface: CFRetained<IOSurfaceRef>,
|
||||
pending_backing: Option<Arc<HardwareSurfaceBacking>>,
|
||||
active_backing: Weak<HardwareSurfaceBacking>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum SurfaceImportError {
|
||||
#[error("IOSurfaceLookupFromMachPort returned null for port 0x{port:x}")]
|
||||
LookupFailed { port: u32 },
|
||||
#[error("CVPixelBufferCreateWithIOSurface returned status {status}")]
|
||||
PixelBufferBuildFailed { status: i32 },
|
||||
}
|
||||
|
||||
pub(crate) struct ImportedPixelBuffer {
|
||||
pub(crate) system_surface_id: u64,
|
||||
pub(crate) iosurface: CFRetained<IOSurfaceRef>,
|
||||
pub(crate) backing: Arc<HardwareSurfaceBacking>,
|
||||
}
|
||||
|
||||
pub(crate) struct HardwareSurfaceBacking {
|
||||
pixel_buffer: CVPixelBuffer,
|
||||
}
|
||||
|
||||
// SAFETY: `CVPixelBuffer` is a retained, read-only CoreVideo frame here.
|
||||
// CoreFoundation retain/release is thread-safe, and both the runtime and GPU
|
||||
// paths only clone the handle or submit the immutable IOSurface-backed image.
|
||||
#[expect(unsafe_code)]
|
||||
unsafe impl Send for HardwareSurfaceBacking {}
|
||||
// SAFETY: The backing exposes immutable pixel-buffer metadata and clones.
|
||||
// IOSurface lifetime tracking remains owned by CoreVideo's retained buffer.
|
||||
#[expect(unsafe_code)]
|
||||
unsafe impl Sync for HardwareSurfaceBacking {}
|
||||
|
||||
impl HardwareSurfaceBacking {
|
||||
pub(crate) fn new(pixel_buffer: CVPixelBuffer) -> Arc<Self> {
|
||||
Arc::new(Self { pixel_buffer })
|
||||
}
|
||||
|
||||
pub(crate) fn pixel_buffer(&self) -> &CVPixelBuffer {
|
||||
&self.pixel_buffer
|
||||
}
|
||||
}
|
||||
|
||||
impl IOSurfaceCache {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self { surfaces: BTreeMap::new(), insertion_order: VecDeque::new() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn import(&mut self, mach_port_name: u32, surface_id: u64) -> Result<(), SurfaceImportError> {
|
||||
let imported = import_pixel_buffer_from_mach_port(mach_port_name)?;
|
||||
self.insert_imported(surface_id, imported);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn insert_imported(&mut self, surface_id: u64, imported: ImportedPixelBuffer) {
|
||||
let width = imported.backing.pixel_buffer().get_width() as u32;
|
||||
let height = imported.backing.pixel_buffer().get_height() as u32;
|
||||
if self
|
||||
.surfaces
|
||||
.get(&surface_id)
|
||||
.is_some_and(|cached| cached.width == width && cached.height == height)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.surfaces.contains_key(&surface_id) {
|
||||
while self.surfaces.len() >= MAX_CACHED_SURFACES {
|
||||
if !self.evict_preferred_surface() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.insertion_order.push_back(surface_id);
|
||||
}
|
||||
self.surfaces.insert(
|
||||
surface_id,
|
||||
CachedSurface {
|
||||
iosurface: imported.iosurface,
|
||||
pending_backing: Some(imported.backing),
|
||||
active_backing: Weak::new(),
|
||||
width,
|
||||
height,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn hardware_surface_for(
|
||||
&mut self,
|
||||
surface_id: u64,
|
||||
) -> Result<Option<Arc<HardwareSurfaceBacking>>, SurfaceImportError> {
|
||||
let Some(cached) = self.surfaces.get_mut(&surface_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let backing = cached
|
||||
.active_backing
|
||||
.upgrade()
|
||||
.or_else(|| cached.pending_backing.take())
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| build_hardware_surface_backing(&cached.iosurface))?;
|
||||
cached.active_backing = Arc::downgrade(&backing);
|
||||
self.trim_to_capacity();
|
||||
Ok(Some(backing))
|
||||
}
|
||||
|
||||
pub(crate) fn surface_ids(&self) -> Vec<u64> {
|
||||
self.surfaces.keys().copied().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn cached_surface_count(&self) -> usize {
|
||||
self.surfaces.len()
|
||||
}
|
||||
|
||||
fn trim_to_capacity(&mut self) {
|
||||
while self.surfaces.len() > MAX_CACHED_SURFACES {
|
||||
if !self.evict_preferred_surface() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn evict_preferred_surface(&mut self) -> bool {
|
||||
let inactive_index = self.insertion_order.iter().position(|surface_id| {
|
||||
self.surfaces
|
||||
.get(surface_id)
|
||||
.is_none_or(|cached| cached.active_backing.upgrade().is_none())
|
||||
});
|
||||
let surface_id = match inactive_index {
|
||||
Some(index) => self.insertion_order.remove(index),
|
||||
None => self.insertion_order.pop_front(),
|
||||
};
|
||||
let Some(surface_id) = surface_id else {
|
||||
return false;
|
||||
};
|
||||
self.surfaces.remove(&surface_id);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn import_pixel_buffer_from_mach_port(
|
||||
mach_port_name: u32,
|
||||
) -> Result<ImportedPixelBuffer, SurfaceImportError> {
|
||||
let result = build_pixel_buffer_from_mach_port(mach_port_name);
|
||||
deallocate_mach_port(mach_port_name);
|
||||
result
|
||||
}
|
||||
|
||||
fn build_pixel_buffer_from_mach_port(
|
||||
mach_port_name: u32,
|
||||
) -> Result<ImportedPixelBuffer, SurfaceImportError> {
|
||||
let Some(iosurface) = objc2_io_surface::IOSurfaceRef::lookup_from_mach_port(mach_port_name)
|
||||
else {
|
||||
return Err(SurfaceImportError::LookupFailed { port: mach_port_name });
|
||||
};
|
||||
let backing = build_hardware_surface_backing(&iosurface)?;
|
||||
Ok(ImportedPixelBuffer { system_surface_id: u64::from(iosurface.id()), iosurface, backing })
|
||||
}
|
||||
|
||||
fn build_hardware_surface_backing(
|
||||
iosurface: &CFRetained<IOSurfaceRef>,
|
||||
) -> Result<Arc<HardwareSurfaceBacking>, SurfaceImportError> {
|
||||
let raw_ptr: *const std::ffi::c_void =
|
||||
(&**iosurface) as *const objc2_io_surface::IOSurfaceRef as *const std::ffi::c_void;
|
||||
#[allow(deprecated)]
|
||||
let io_surface_view: IOSurface = {
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
IOSurface::wrap_under_get_rule(raw_ptr as io_surface::IOSurfaceRef)
|
||||
}
|
||||
};
|
||||
|
||||
let pixel_buffer = CVPixelBuffer::from_io_surface(&io_surface_view, None)
|
||||
.map_err(|status| SurfaceImportError::PixelBufferBuildFailed { status })?;
|
||||
Ok(HardwareSurfaceBacking::new(pixel_buffer))
|
||||
}
|
||||
|
||||
fn deallocate_mach_port(port: u32) {
|
||||
#[expect(unsafe_code)]
|
||||
let task = unsafe { mach_task_self() };
|
||||
#[expect(unsafe_code)]
|
||||
let _ = unsafe { mach_port_deallocate(task, port) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::os::raw::c_void;
|
||||
use std::sync::Arc;
|
||||
|
||||
use objc2_core_foundation::{
|
||||
CFDictionary, CFIndex, CFNumber, CFString, kCFAllocatorDefault,
|
||||
kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks,
|
||||
};
|
||||
use objc2_io_surface::{
|
||||
IOSurfaceRef, kIOSurfaceBytesPerElement, kIOSurfaceBytesPerRow, kIOSurfaceHeight,
|
||||
kIOSurfacePixelFormat, kIOSurfaceWidth,
|
||||
};
|
||||
|
||||
use super::IOSurfaceCache;
|
||||
|
||||
fn build_local_iosurface(
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<objc2_core_foundation::CFRetained<IOSurfaceRef>, String> {
|
||||
let pixel_format = i32::from_be_bytes(*b"BGRA");
|
||||
let bytes_per_element = 4_i32;
|
||||
let bytes_per_row = (width as i32) * bytes_per_element;
|
||||
let width_num = CFNumber::new_i32(width as i32);
|
||||
let height_num = CFNumber::new_i32(height as i32);
|
||||
let bpe_num = CFNumber::new_i32(bytes_per_element);
|
||||
let bpr_num = CFNumber::new_i32(bytes_per_row);
|
||||
let pf_num = CFNumber::new_i32(pixel_format);
|
||||
|
||||
#[expect(unsafe_code)]
|
||||
unsafe {
|
||||
let keys: [&CFString; 5] = [
|
||||
kIOSurfaceWidth,
|
||||
kIOSurfaceHeight,
|
||||
kIOSurfaceBytesPerElement,
|
||||
kIOSurfaceBytesPerRow,
|
||||
kIOSurfacePixelFormat,
|
||||
];
|
||||
let values: [&CFNumber; 5] = [&width_num, &height_num, &bpe_num, &bpr_num, &pf_num];
|
||||
let properties = CFDictionary::new(
|
||||
kCFAllocatorDefault,
|
||||
keys.as_ptr() as *mut *const c_void,
|
||||
values.as_ptr() as *mut *const c_void,
|
||||
keys.len() as CFIndex,
|
||||
&kCFTypeDictionaryKeyCallBacks,
|
||||
&kCFTypeDictionaryValueCallBacks,
|
||||
)
|
||||
.ok_or_else(|| "CFDictionaryCreate returned null".to_string())?;
|
||||
IOSurfaceRef::new(&properties)
|
||||
.ok_or_else(|| "IOSurfaceCreate returned null".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_iosurface_into_pixel_buffer_cache() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let iosurface = build_local_iosurface(64, 48)?;
|
||||
let surface_id = u64::from(iosurface.id());
|
||||
|
||||
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
|
||||
.map_err(|error| error.to_string())?;
|
||||
assert_eq!(imported.system_surface_id, surface_id);
|
||||
cache.insert_imported(surface_id, imported);
|
||||
|
||||
let surface = cache
|
||||
.hardware_surface_for(surface_id)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "imported pixel buffer was missing".to_string())?;
|
||||
assert_eq!(surface.pixel_buffer().get_width(), 64);
|
||||
assert_eq!(surface.pixel_buffer().get_height(), 48);
|
||||
assert_eq!(cache.cached_surface_count(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_surface_id_reuses_cache_entry() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let iosurface = build_local_iosurface(64, 48)?;
|
||||
let surface_id = u64::from(iosurface.id());
|
||||
|
||||
cache
|
||||
.import(iosurface.create_mach_port(), surface_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
cache
|
||||
.import(iosurface.create_mach_port(), surface_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
assert_eq!(cache.cached_surface_count(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_surface_backing_reuses_and_releases_use_count() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let iosurface = build_local_iosurface(64, 48)?;
|
||||
let surface_id = u64::from(iosurface.id());
|
||||
let baseline_use_count = iosurface.use_count();
|
||||
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
|
||||
.map_err(|error| error.to_string())?;
|
||||
assert_eq!(iosurface.use_count(), baseline_use_count + 1);
|
||||
cache.insert_imported(surface_id, imported);
|
||||
|
||||
let first = cache
|
||||
.hardware_surface_for(surface_id)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "first hardware surface was missing".to_string())?;
|
||||
let second = cache
|
||||
.hardware_surface_for(surface_id)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "second hardware surface was missing".to_string())?;
|
||||
assert!(Arc::ptr_eq(&first, &second));
|
||||
assert_eq!(iosurface.use_count(), baseline_use_count + 1);
|
||||
|
||||
drop(first);
|
||||
drop(second);
|
||||
assert_eq!(iosurface.use_count(), baseline_use_count);
|
||||
|
||||
let rebuilt = cache
|
||||
.hardware_surface_for(surface_id)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or_else(|| "rebuilt hardware surface was missing".to_string())?;
|
||||
assert_eq!(iosurface.use_count(), baseline_use_count + 1);
|
||||
drop(rebuilt);
|
||||
assert_eq!(iosurface.use_count(), baseline_use_count);
|
||||
drop(cache);
|
||||
assert_eq!(iosurface.use_count(), baseline_use_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_evicts_oldest_surface_at_capacity() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let mut surface_ids = Vec::new();
|
||||
for _ in 0..=super::MAX_CACHED_SURFACES {
|
||||
let iosurface = build_local_iosurface(8, 8)?;
|
||||
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
|
||||
.map_err(|error| error.to_string())?;
|
||||
surface_ids.push(imported.system_surface_id);
|
||||
cache.insert_imported(imported.system_surface_id, imported);
|
||||
}
|
||||
|
||||
assert_eq!(cache.cached_surface_count(), super::MAX_CACHED_SURFACES);
|
||||
assert!(
|
||||
cache
|
||||
.hardware_surface_for(surface_ids[0])
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
cache
|
||||
.hardware_surface_for(surface_ids[super::MAX_CACHED_SURFACES])
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_some()
|
||||
);
|
||||
assert!(!cache.surface_ids().contains(&surface_ids[0]));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_stays_bounded_while_gpu_backings_are_active() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let mut surface_ids = Vec::new();
|
||||
let mut native_surfaces = Vec::new();
|
||||
let mut active_backings = Vec::new();
|
||||
for _ in 0..super::MAX_CACHED_SURFACES {
|
||||
let iosurface = build_local_iosurface(8, 8)?;
|
||||
let imported = super::import_pixel_buffer_from_mach_port(iosurface.create_mach_port())
|
||||
.map_err(|error| error.to_string())?;
|
||||
surface_ids.push(imported.system_surface_id);
|
||||
native_surfaces.push(iosurface);
|
||||
cache.insert_imported(imported.system_surface_id, imported);
|
||||
active_backings.push(
|
||||
cache
|
||||
.hardware_surface_for(*surface_ids.last().ok_or("surface id was missing")?)
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or("active surface backing was missing")?,
|
||||
);
|
||||
}
|
||||
let overflow = build_local_iosurface(8, 8)?;
|
||||
let overflow = super::import_pixel_buffer_from_mach_port(overflow.create_mach_port())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let overflow_id = overflow.system_surface_id;
|
||||
cache.insert_imported(overflow_id, overflow);
|
||||
|
||||
assert_eq!(cache.cached_surface_count(), super::MAX_CACHED_SURFACES);
|
||||
assert!(!cache.surface_ids().contains(&surface_ids[0]));
|
||||
assert_eq!(active_backings[0].pixel_buffer().get_width(), 8);
|
||||
assert!(
|
||||
cache
|
||||
.hardware_surface_for(surface_ids[0])
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
cache.hardware_surface_for(overflow_id).map_err(|error| error.to_string())?.is_some()
|
||||
);
|
||||
|
||||
cache
|
||||
.import(native_surfaces[0].create_mach_port(), surface_ids[0])
|
||||
.map_err(|error| error.to_string())?;
|
||||
let reimported = cache
|
||||
.hardware_surface_for(surface_ids[0])
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or("reimported surface backing was missing")?;
|
||||
|
||||
assert_eq!(cache.cached_surface_count(), super::MAX_CACHED_SURFACES);
|
||||
assert!(cache.surface_ids().contains(&surface_ids[0]));
|
||||
assert!(!Arc::ptr_eq(&active_backings[0], &reimported));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
pub mod download_checksums;
|
||||
pub mod download_files;
|
||||
pub mod http_downloads;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) mod iosurface_mach;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) mod iosurface_metal;
|
||||
pub mod plugin_package_store;
|
||||
pub mod plugin_packages;
|
||||
pub mod plugin_signatures;
|
||||
pub(crate) mod profile_identity;
|
||||
pub mod servo_live;
|
||||
pub(crate) mod servo_profile_data;
|
||||
mod servo_sidecar_command;
|
||||
|
||||
pub(crate) use servo_profile_data::ProfileDataMode;
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use ely_domain::ProfileId;
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(not(test))]
|
||||
use super::servo_profile_data::{create_profile_data_dir, default_profile_data_root};
|
||||
|
||||
#[cfg(not(test))]
|
||||
const DEFAULT_PROFILE_ID_FILE: &str = "profile-id";
|
||||
#[cfg(not(test))]
|
||||
const DEFAULT_PROFILE_DIRECTORY: &str = "default";
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn default_standard_profile_id() -> Result<ProfileId, ProfileIdentityError> {
|
||||
static TEST_PROFILE_ID: std::sync::OnceLock<ProfileId> = std::sync::OnceLock::new();
|
||||
Ok(TEST_PROFILE_ID.get_or_init(ProfileId::new).clone())
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn default_standard_profile_id() -> Result<ProfileId, ProfileIdentityError> {
|
||||
let root = default_profile_data_root().ok_or(ProfileIdentityError::DataRootUnavailable)?;
|
||||
let profile_id = load_or_create_profile_id(
|
||||
&root.join(DEFAULT_PROFILE_DIRECTORY).join(DEFAULT_PROFILE_ID_FILE),
|
||||
)?;
|
||||
create_profile_data_dir(&root, &profile_id)?;
|
||||
Ok(profile_id)
|
||||
}
|
||||
|
||||
fn load_or_create_profile_id(path: &Path) -> Result<ProfileId, ProfileIdentityError> {
|
||||
match read_profile_id(path) {
|
||||
Ok(profile_id) => return Ok(profile_id),
|
||||
Err(ProfileIdentityError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| ProfileIdentityError::ParentUnavailable { path: path.to_path_buf() })?;
|
||||
create_private_directory(parent)?;
|
||||
let profile_id = ProfileId::new();
|
||||
let mut file = tempfile::NamedTempFile::new_in(parent)?;
|
||||
file.write_all(profile_id.as_str().as_bytes())?;
|
||||
file.write_all(b"\n")?;
|
||||
file.as_file().sync_all()?;
|
||||
match file.persist_noclobber(path) {
|
||||
Ok(_) => {
|
||||
sync_parent_directory(parent)?;
|
||||
Ok(profile_id)
|
||||
}
|
||||
Err(error) if error.error.kind() == io::ErrorKind::AlreadyExists => read_profile_id(path),
|
||||
Err(error) => Err(error.error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_private_directory(path: &Path) -> Result<(), io::Error> {
|
||||
fs::create_dir_all(path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sync_parent_directory(path: &Path) -> Result<(), io::Error> {
|
||||
fs::File::open(path)?.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn sync_parent_directory(_path: &Path) -> Result<(), io::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_profile_id(path: &Path) -> Result<ProfileId, ProfileIdentityError> {
|
||||
let raw = fs::read_to_string(path)?;
|
||||
ProfileId::parse(raw.trim()).map_err(ProfileIdentityError::Domain)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum ProfileIdentityError {
|
||||
#[cfg(not(test))]
|
||||
#[error("profile data root is unavailable")]
|
||||
DataRootUnavailable,
|
||||
|
||||
#[error("profile identity parent directory is unavailable for {path}")]
|
||||
ParentUnavailable { path: PathBuf },
|
||||
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Domain(#[from] ely_domain::DomainError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::load_or_create_profile_id;
|
||||
use crate::services::servo_profile_data::profile_data_dir;
|
||||
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
||||
|
||||
#[test]
|
||||
fn default_profile_identity_survives_restart() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let root = directory.path().join("profiles");
|
||||
let path = root.join("default").join("profile-id");
|
||||
|
||||
let first_start = load_or_create_profile_id(&path)?;
|
||||
let second_start = load_or_create_profile_id(&path)?;
|
||||
let mut first_config = InitialBrowserConfig::ely_defaults()?;
|
||||
first_config.profile_id = Some(first_start.clone());
|
||||
let first_core = BrowserCore::new(first_config)?;
|
||||
let mut second_config = InitialBrowserConfig::ely_defaults()?;
|
||||
second_config.profile_id = Some(second_start.clone());
|
||||
let second_core = BrowserCore::new(second_config)?;
|
||||
|
||||
assert_eq!(first_start, second_start);
|
||||
assert_eq!(std::fs::read_to_string(&path)?.trim(), first_start.as_str());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(path.parent().ok_or("missing marker parent")?)?
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
}
|
||||
assert_eq!(first_core.active_tab()?.profile_id(), second_core.active_tab()?.profile_id());
|
||||
assert_eq!(
|
||||
profile_data_dir(&root, first_core.active_tab()?.profile_id()),
|
||||
profile_data_dir(&root, second_core.active_tab()?.profile_id()),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_starts_converge_on_one_profile_identity() -> Result<(), Box<dyn std::error::Error>>
|
||||
{
|
||||
let directory = tempfile::tempdir()?;
|
||||
let path = std::sync::Arc::new(directory.path().join("default").join("profile-id"));
|
||||
let threads = (0..4)
|
||||
.map(|_| {
|
||||
let path = path.clone();
|
||||
std::thread::spawn(move || load_or_create_profile_id(&path))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut identities = Vec::new();
|
||||
for thread in threads {
|
||||
identities.push(
|
||||
thread
|
||||
.join()
|
||||
.map_err(|_| std::io::Error::other("profile identity thread panicked"))??,
|
||||
);
|
||||
}
|
||||
|
||||
assert!(identities.windows(2).all(|pair| pair[0] == pair[1]));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,498 +1,465 @@
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
use ely_domain::{
|
||||
ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, TabId, UrlText, WebViewId,
|
||||
};
|
||||
use ely_servo_host::{
|
||||
HidpiScaleRequest, KeyboardTextRequest, MouseClickRequest, MouseHoverRequest,
|
||||
NavigationRequest, PageZoomRequest, PermissionDecision, PermissionRequest, ResizeRequest,
|
||||
ScrollRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost,
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
process::{Child, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[path = "servo_live_iosurface_importer.rs"]
|
||||
mod iosurface_importer;
|
||||
#[path = "servo_live_ipc.rs"]
|
||||
mod ipc;
|
||||
#[path = "servo_live_types.rs"]
|
||||
mod types;
|
||||
#[path = "servo_live_wire.rs"]
|
||||
mod wire;
|
||||
|
||||
pub(crate) use types::{
|
||||
ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame, ServoLiveSitePermission,
|
||||
};
|
||||
|
||||
/// Servo's `WebViewBuilder` defaults the hidpi (device → CSS) scale to
|
||||
/// 1.0 — see the contract documented on
|
||||
/// [`ely_servo_host::HidpiScaleRequest`]. Mirroring the post-build state
|
||||
/// in [`DirectWebViewSession`] makes the diff inside `apply_viewport`
|
||||
/// the source of truth for whether `set_hidpi_scale` has to run.
|
||||
const SERVO_DEFAULT_DEVICE_PIXEL_RATIO: f32 = 1.0;
|
||||
use self::{
|
||||
ipc::{IpcReply, ServoLiveIpc, validate_frame_layout},
|
||||
wire::{LIVE_PROTOCOL_VERSION, LiveRequest, LiveSurfaceHandle},
|
||||
};
|
||||
use super::servo_sidecar_command::{
|
||||
SidecarRenderingContext, default_sidecar_command, rendering_context_from_env,
|
||||
};
|
||||
|
||||
/// Servo's `WebViewBuilder` likewise defaults page zoom to 1.0 (100%).
|
||||
const SERVO_DEFAULT_PAGE_ZOOM_PERCENT: u16 = 100;
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::iosurface_metal::IOSurfaceCache;
|
||||
#[cfg(target_os = "macos")]
|
||||
use iosurface_importer::{IOSurfaceImportResult, IOSurfaceImportWorker};
|
||||
|
||||
const SIDECAR_TIMEOUTS: SidecarTimeouts = SidecarTimeouts {
|
||||
request: Duration::from_secs(10),
|
||||
shutdown: Duration::from_secs(2),
|
||||
exit: Duration::from_secs(2),
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct SidecarTimeouts {
|
||||
request: Duration,
|
||||
shutdown: Duration,
|
||||
exit: Duration,
|
||||
}
|
||||
|
||||
pub(crate) struct ServoLiveClient {
|
||||
host: SoftwareServoHost,
|
||||
sessions: BTreeMap<String, DirectWebViewSession>,
|
||||
child: Child,
|
||||
ipc: ServoLiveIpc,
|
||||
timeouts: SidecarTimeouts,
|
||||
active: bool,
|
||||
#[cfg(target_os = "macos")]
|
||||
iosurface_cache: IOSurfaceCache,
|
||||
#[cfg(target_os = "macos")]
|
||||
iosurface_importer: Option<IOSurfaceImportWorker>,
|
||||
#[cfg(target_os = "macos")]
|
||||
pending_surface_ids: BTreeSet<u64>,
|
||||
}
|
||||
|
||||
impl ServoLiveClient {
|
||||
pub fn new(profile_data_dir: PathBuf) -> Result<Self, ServoLiveError> {
|
||||
let host = SoftwareServoHost::new_with_config_dir(
|
||||
ServoSurfaceSize::new(1, 1),
|
||||
Some(profile_data_dir),
|
||||
)?;
|
||||
Ok(Self { host, sessions: BTreeMap::new() })
|
||||
let rendering_context = rendering_context_from_env();
|
||||
let command_target = default_sidecar_command()?;
|
||||
if let Some(path) = command_target.missing_binary_path() {
|
||||
return Err(ServoLiveError::SidecarBinaryUnavailable { path: path.to_path_buf() });
|
||||
}
|
||||
|
||||
let mut command = command_target.command();
|
||||
command.arg("live").arg("--profile-data-dir").arg(profile_data_dir);
|
||||
command.arg("--rendering-context").arg(rendering_context.cli_arg());
|
||||
#[cfg(target_os = "macos")]
|
||||
let iosurface_importer = if rendering_context == SidecarRenderingContext::Hardware {
|
||||
let receiver = super::iosurface_mach::IOSurfaceMachReceiver::new()?;
|
||||
command.arg("--iosurface-mach-service").arg(receiver.service_name());
|
||||
Some(
|
||||
IOSurfaceImportWorker::new(receiver)
|
||||
.map_err(ServoLiveError::IOSurfaceImportWorker)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(ServoLiveError::Command)?;
|
||||
let mut client = Self::from_spawned_child(child, SIDECAR_TIMEOUTS)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
client.iosurface_importer = iosurface_importer;
|
||||
}
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn from_spawned_child(
|
||||
mut child: Child,
|
||||
timeouts: SidecarTimeouts,
|
||||
) -> Result<Self, ServoLiveError> {
|
||||
let Some(stdin) = child.stdin.take() else {
|
||||
terminate_child(&mut child, timeouts.exit);
|
||||
return Err(ServoLiveError::PipeUnavailable { name: "stdin" });
|
||||
};
|
||||
let Some(stdout) = child.stdout.take() else {
|
||||
drop(stdin);
|
||||
terminate_child(&mut child, timeouts.exit);
|
||||
return Err(ServoLiveError::PipeUnavailable { name: "stdout" });
|
||||
};
|
||||
let mut client = Self {
|
||||
child,
|
||||
ipc: ServoLiveIpc::spawn(stdin, stdout),
|
||||
timeouts,
|
||||
active: true,
|
||||
#[cfg(target_os = "macos")]
|
||||
iosurface_cache: IOSurfaceCache::new(),
|
||||
#[cfg(target_os = "macos")]
|
||||
iosurface_importer: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
pending_surface_ids: BTreeSet::new(),
|
||||
};
|
||||
if let Err(error) = client.handshake() {
|
||||
client.terminate();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
pub fn ensure(
|
||||
&mut self,
|
||||
request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
||||
let tab_id = TabId::parse(request.tab_id.clone())?;
|
||||
let profile_id = ProfileId::parse(request.profile_id.clone())?;
|
||||
let requested_url = UrlText::parse(request.url.clone())?;
|
||||
let webview_id = self.ensure_webview(&request, &tab_id, &profile_id)?;
|
||||
|
||||
self.apply_viewport(&request, &webview_id)?;
|
||||
self.apply_permissions(&request, &webview_id, &profile_id)?;
|
||||
self.apply_navigation(&request, &webview_id, tab_id, requested_url)?;
|
||||
self.apply_input(&request, &webview_id)?;
|
||||
// Match Servo's `examples/winit_minimal.rs`: spin the event loop on
|
||||
// the embedder-side hot path, never paint. Servo's public
|
||||
// rendering contract says `notify_new_frame_ready` is the signal
|
||||
// for `WebView::paint`; URL/title/load-status callbacks are
|
||||
// metadata updates and travel through snapshots. Painting here
|
||||
// would present before Servo has composited the navigated page,
|
||||
// which produced per-redirect white flashes.
|
||||
self.host.tick();
|
||||
if self.session_uses_native_surface(&request.tab_id) {
|
||||
return self.presented_frame_from_session(&request.tab_id, &webview_id).map(Some);
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
#[cfg(target_os = "macos")]
|
||||
self.drain_iosurface_imports()?;
|
||||
validate_frame_layout(request.width, request.height)?;
|
||||
let ready_surface_ids = self.ready_surface_ids();
|
||||
let pending_surface_ids = self.pending_surface_ids();
|
||||
self.request(LiveRequest::Ensure {
|
||||
tab_id: request.tab_id,
|
||||
profile_id: request.profile_id,
|
||||
url: request.url,
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
page_zoom_percent: request.page_zoom_percent,
|
||||
device_pixel_ratio: request.device_pixel_ratio,
|
||||
scroll_delta_x: request.scroll_delta_x,
|
||||
scroll_delta_y: request.scroll_delta_y,
|
||||
scroll_point_x: request.scroll_point_x,
|
||||
scroll_point_y: request.scroll_point_y,
|
||||
click_x: request.click_x,
|
||||
click_y: request.click_y,
|
||||
hover_x: request.hover_x,
|
||||
hover_y: request.hover_y,
|
||||
typed_text: request.typed_text,
|
||||
site_permissions: request.site_permissions,
|
||||
ready_surface_ids,
|
||||
pending_surface_ids,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn poll(&mut self, tab_id: String) -> Result<Option<ServoLiveFrame>, ServoLiveError> {
|
||||
let Some(session) = self.sessions.get(&tab_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let webview_id = session.webview_id.clone();
|
||||
let uses_native_surface = session.native_surface_id.is_some();
|
||||
|
||||
self.host.tick();
|
||||
let snapshot = self.host.snapshot(&webview_id)?;
|
||||
if !snapshot.has_pending_frame() && !snapshot.has_pending_metadata() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if uses_native_surface {
|
||||
if snapshot.has_pending_frame() {
|
||||
self.host.paint_without_readback_with_completion(&webview_id, false)?;
|
||||
}
|
||||
return self.presented_frame_from_session(&tab_id, &webview_id).map(Some);
|
||||
}
|
||||
|
||||
if snapshot.has_pending_frame() {
|
||||
self.host.paint(&webview_id)?;
|
||||
}
|
||||
self.rendered_frame_from_session(&tab_id, &webview_id)
|
||||
#[cfg(target_os = "macos")]
|
||||
self.drain_iosurface_imports()?;
|
||||
self.request(LiveRequest::Poll {
|
||||
tab_id,
|
||||
ready_surface_ids: self.ready_surface_ids(),
|
||||
pending_surface_ids: self.pending_surface_ids(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn close(&mut self, tab_id: String) -> Result<(), ServoLiveError> {
|
||||
let Some(session) = self.sessions.remove(&tab_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
self.host.close_webview(&session.webview_id);
|
||||
Ok(())
|
||||
self.request(LiveRequest::Close { tab_id }).map(|_| ())
|
||||
}
|
||||
|
||||
fn ensure_webview(
|
||||
&mut self,
|
||||
request: &ServoLiveEnsureRequest,
|
||||
tab_id: &TabId,
|
||||
profile_id: &ProfileId,
|
||||
) -> Result<WebViewId, ServoLiveError> {
|
||||
if self
|
||||
.sessions
|
||||
.get(&request.tab_id)
|
||||
.is_some_and(|session| session.profile_id != *profile_id)
|
||||
&& let Some(session) = self.sessions.remove(&request.tab_id)
|
||||
{
|
||||
self.host.close_webview(&session.webview_id);
|
||||
}
|
||||
|
||||
let native_surface_id =
|
||||
request.native_surface.as_ref().map(gpui::NativeSurfaceHandle::identity);
|
||||
if self
|
||||
.sessions
|
||||
.get(&request.tab_id)
|
||||
.is_some_and(|session| session.native_surface_id != native_surface_id)
|
||||
&& let Some(session) = self.sessions.remove(&request.tab_id)
|
||||
{
|
||||
self.host.close_webview(&session.webview_id);
|
||||
}
|
||||
|
||||
if let Some(session) = self.sessions.get(&request.tab_id) {
|
||||
return Ok(session.webview_id.clone());
|
||||
}
|
||||
|
||||
let surface_size = ServoSurfaceSize::new(request.width, request.height);
|
||||
let webview_id = match request.native_surface.as_ref() {
|
||||
Some(native_surface) => self.host.create_webview_with_native_surface(
|
||||
tab_id.clone(),
|
||||
profile_id.clone(),
|
||||
surface_size,
|
||||
native_surface,
|
||||
)?,
|
||||
None => self.host.create_webview_with_size(
|
||||
tab_id.clone(),
|
||||
profile_id.clone(),
|
||||
surface_size,
|
||||
)?,
|
||||
};
|
||||
self.sessions.insert(
|
||||
request.tab_id.clone(),
|
||||
DirectWebViewSession {
|
||||
webview_id: webview_id.clone(),
|
||||
profile_id: profile_id.clone(),
|
||||
requested_url: None,
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
// 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(
|
||||
&mut self,
|
||||
request: &ServoLiveEnsureRequest,
|
||||
webview_id: &WebViewId,
|
||||
) -> Result<(), ServoLiveError> {
|
||||
let Some(session) = self.sessions.get_mut(&request.tab_id) 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(())
|
||||
}
|
||||
|
||||
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),
|
||||
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()
|
||||
{
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "handshake response contains a frame",
|
||||
});
|
||||
}
|
||||
match reply.error {
|
||||
Some(message) => Err(ServoLiveError::SidecarFailed { message }),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
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: 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 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());
|
||||
}
|
||||
fn drain_iosurface_imports(&mut self) -> Result<(), ServoLiveError> {
|
||||
let Some(importer) = self.iosurface_importer.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
apply_iosurface_import_results(
|
||||
&mut self.iosurface_cache,
|
||||
&mut self.pending_surface_ids,
|
||||
importer.drain(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let should_navigate = self
|
||||
.sessions
|
||||
.get(&request.tab_id)
|
||||
.and_then(|session| session.requested_url.as_deref())
|
||||
.is_none_or(|current| current != requested_url.as_str());
|
||||
if should_navigate {
|
||||
self.host.navigate(NavigationRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
tab_id,
|
||||
url: requested_url.clone(),
|
||||
})?;
|
||||
if let Some(session) = self.sessions.get_mut(&request.tab_id) {
|
||||
session.requested_url = Some(requested_url.as_str().to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_input(
|
||||
&mut self,
|
||||
request: &ServoLiveEnsureRequest,
|
||||
webview_id: &WebViewId,
|
||||
#[cfg(target_os = "macos")]
|
||||
fn apply_iosurface_import_results(
|
||||
cache: &mut IOSurfaceCache,
|
||||
pending_surface_ids: &mut BTreeSet<u64>,
|
||||
results: Vec<IOSurfaceImportResult>,
|
||||
) -> 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,
|
||||
})?;
|
||||
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);
|
||||
}
|
||||
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(_) => {}
|
||||
}
|
||||
}
|
||||
first_failure.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
if let (Some(x), Some(y)) = (request.hover_x, request.hover_y) {
|
||||
self.host.hover(MouseHoverRequest { webview_id: webview_id.clone(), x, y })?;
|
||||
impl Drop for ServoLiveClient {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(x), Some(y)) = (request.click_x, request.click_y) {
|
||||
self.host.click(MouseClickRequest { webview_id: webview_id.clone(), x, y })?;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(text) = request.typed_text.as_ref() {
|
||||
self.host.type_text(KeyboardTextRequest {
|
||||
webview_id: webview_id.clone(),
|
||||
text: text.clone(),
|
||||
})?;
|
||||
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::*;
|
||||
|
||||
const TEST_TIMEOUTS: SidecarTimeouts = SidecarTimeouts {
|
||||
request: Duration::from_millis(50),
|
||||
shutdown: Duration::from_millis(50),
|
||||
exit: Duration::from_millis(250),
|
||||
};
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
#[test]
|
||||
fn request_timeout_kills_and_reaps_a_hung_sidecar() -> Result<(), Box<dyn Error>> {
|
||||
let mut client = hung_sidecar_client()?;
|
||||
let started_at = Instant::now();
|
||||
|
||||
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,
|
||||
)))
|
||||
}
|
||||
let result = client.poll("tab-hung".to_string());
|
||||
|
||||
fn session_uses_native_surface(&self, tab_id: &str) -> bool {
|
||||
self.sessions.get(tab_id).is_some_and(|session| session.native_surface_id.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectWebViewSession {
|
||||
webview_id: WebViewId,
|
||||
profile_id: ProfileId,
|
||||
requested_url: Option<String>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
page_zoom_percent: u16,
|
||||
device_pixel_ratio: f32,
|
||||
native_surface_id: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
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_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 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 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),
|
||||
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"
|
||||
);
|
||||
assert_eq!(change, ViewportChange::default());
|
||||
let child = spawn_script(&script).map_err(ServoLiveError::Command)?;
|
||||
ServoLiveClient::from_spawned_child(child, TEST_TIMEOUTS)
|
||||
}
|
||||
|
||||
#[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 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()
|
||||
}
|
||||
}
|
||||
|
||||
#[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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
#[path = "servo_live_iosurface_tests.rs"]
|
||||
mod iosurface_tests;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use std::{
|
||||
io,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::services::{
|
||||
iosurface_mach::IOSurfaceMachReceiver,
|
||||
iosurface_metal::{ImportedPixelBuffer, import_pixel_buffer_from_mach_port},
|
||||
};
|
||||
|
||||
use super::wire::LiveSurfaceHandle;
|
||||
|
||||
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const IMPORT_QUEUE_CAPACITY: usize = 16;
|
||||
|
||||
pub(super) struct IOSurfaceImportWorker {
|
||||
request_tx: Option<mpsc::SyncSender<LiveSurfaceHandle>>,
|
||||
result_rx: mpsc::Receiver<IOSurfaceImportResult>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl IOSurfaceImportWorker {
|
||||
pub(super) fn new(receiver: IOSurfaceMachReceiver) -> Result<Self, io::Error> {
|
||||
let (request_tx, request_rx) = mpsc::sync_channel(IMPORT_QUEUE_CAPACITY);
|
||||
let (result_tx, result_rx) = mpsc::channel();
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let worker_shutdown = shutdown.clone();
|
||||
let thread = thread::Builder::new()
|
||||
.name("ely-iosurface-import".to_string())
|
||||
.spawn(move || run_import_worker(receiver, request_rx, result_tx, worker_shutdown))?;
|
||||
Ok(Self { request_tx: Some(request_tx), result_rx, shutdown, thread: Some(thread) })
|
||||
}
|
||||
|
||||
pub(super) fn submit(&self, handle: LiveSurfaceHandle) -> Result<(), IOSurfaceImportFailure> {
|
||||
let Some(request_tx) = self.request_tx.as_ref() else {
|
||||
return Err(IOSurfaceImportFailure::worker_stopped(handle));
|
||||
};
|
||||
request_tx.send(handle).map_err(|error| IOSurfaceImportFailure::worker_stopped(error.0))
|
||||
}
|
||||
|
||||
pub(super) fn drain(&self) -> Vec<IOSurfaceImportResult> {
|
||||
let mut results = Vec::new();
|
||||
while let Ok(result) = self.result_rx.try_recv() {
|
||||
results.push(result);
|
||||
}
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for IOSurfaceImportWorker {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown.store(true, Ordering::Release);
|
||||
self.request_tx.take();
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum IOSurfaceImportResult {
|
||||
Imported(ImportedIOSurface),
|
||||
Failed(IOSurfaceImportFailure),
|
||||
}
|
||||
|
||||
pub(super) struct ImportedIOSurface {
|
||||
pub(super) surface_id: u64,
|
||||
pub(super) imported: ImportedPixelBuffer,
|
||||
}
|
||||
|
||||
// SAFETY: `ImportedPixelBuffer` owns retained IOSurface and CVPixelBuffer
|
||||
// handles plus a use-count guard. CoreFoundation retain/release and IOSurface
|
||||
// use-count operations are thread-safe while this value crosses the channel.
|
||||
#[expect(unsafe_code)]
|
||||
unsafe impl Send for ImportedIOSurface {}
|
||||
|
||||
pub(super) struct IOSurfaceImportFailure {
|
||||
pub(super) surface_id: u64,
|
||||
pub(super) mach_port_name: u32,
|
||||
pub(super) message: String,
|
||||
}
|
||||
|
||||
impl IOSurfaceImportFailure {
|
||||
fn worker_stopped(handle: LiveSurfaceHandle) -> Self {
|
||||
Self::queue_rejected(handle, "IOSurface import worker stopped")
|
||||
}
|
||||
|
||||
fn queue_rejected(handle: LiveSurfaceHandle, message: &str) -> Self {
|
||||
Self {
|
||||
surface_id: handle.surface_id,
|
||||
mach_port_name: handle.mach_port_name,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_import_worker(
|
||||
mut receiver: IOSurfaceMachReceiver,
|
||||
request_rx: mpsc::Receiver<LiveSurfaceHandle>,
|
||||
result_tx: mpsc::Sender<IOSurfaceImportResult>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
) {
|
||||
run_import_requests(request_rx, result_tx, shutdown, |handle| {
|
||||
import_surface_handle(&mut receiver, handle)
|
||||
});
|
||||
}
|
||||
|
||||
fn run_import_requests(
|
||||
request_rx: mpsc::Receiver<LiveSurfaceHandle>,
|
||||
result_tx: mpsc::Sender<IOSurfaceImportResult>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
mut import: impl FnMut(LiveSurfaceHandle) -> IOSurfaceImportResult,
|
||||
) {
|
||||
while let Ok(handle) = request_rx.recv() {
|
||||
if shutdown.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let result = import(handle);
|
||||
if shutdown.load(Ordering::Acquire) || result_tx.send(result).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn import_surface_handle(
|
||||
receiver: &mut IOSurfaceMachReceiver,
|
||||
handle: LiveSurfaceHandle,
|
||||
) -> IOSurfaceImportResult {
|
||||
let mach_port_name = match receiver.receive_port_for_surface(handle.surface_id, RECEIVE_TIMEOUT)
|
||||
{
|
||||
Ok(mach_port_name) => mach_port_name,
|
||||
Err(error) => {
|
||||
return IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
|
||||
surface_id: handle.surface_id,
|
||||
mach_port_name: handle.mach_port_name,
|
||||
message: error.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
match import_pixel_buffer_from_mach_port(mach_port_name) {
|
||||
Ok(imported)
|
||||
if imported_surface_matches(
|
||||
&handle,
|
||||
imported.system_surface_id,
|
||||
imported.backing.pixel_buffer().get_width(),
|
||||
imported.backing.pixel_buffer().get_height(),
|
||||
) =>
|
||||
{
|
||||
IOSurfaceImportResult::Imported(ImportedIOSurface {
|
||||
surface_id: handle.surface_id,
|
||||
imported,
|
||||
})
|
||||
}
|
||||
Ok(imported) => IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
|
||||
surface_id: handle.surface_id,
|
||||
mach_port_name,
|
||||
message: format!(
|
||||
"IOSurface id/size {:#x} {}x{} did not match handle {:#x} {}x{}",
|
||||
imported.system_surface_id,
|
||||
imported.backing.pixel_buffer().get_width(),
|
||||
imported.backing.pixel_buffer().get_height(),
|
||||
handle.surface_id,
|
||||
handle.width,
|
||||
handle.height,
|
||||
),
|
||||
}),
|
||||
Err(error) => IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
|
||||
surface_id: handle.surface_id,
|
||||
mach_port_name,
|
||||
message: error.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn imported_surface_matches(
|
||||
handle: &LiveSurfaceHandle,
|
||||
system_surface_id: u64,
|
||||
width: usize,
|
||||
height: usize,
|
||||
) -> bool {
|
||||
system_surface_id == handle.surface_id
|
||||
&& width == handle.width as usize
|
||||
&& height == handle.height as usize
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{cell::Cell, sync::mpsc};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shutdown_stops_after_current_import() {
|
||||
let (request_tx, request_rx) = mpsc::channel();
|
||||
let (result_tx, result_rx) = mpsc::channel();
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
for surface_id in 1..=3 {
|
||||
assert!(request_tx.send(handle(surface_id)).is_ok());
|
||||
}
|
||||
drop(request_tx);
|
||||
let processed = Cell::new(0);
|
||||
let import_shutdown = shutdown.clone();
|
||||
|
||||
run_import_requests(request_rx, result_tx, shutdown, |handle| {
|
||||
processed.set(processed.get() + 1);
|
||||
import_shutdown.store(true, Ordering::Release);
|
||||
IOSurfaceImportResult::Failed(IOSurfaceImportFailure::worker_stopped(handle))
|
||||
});
|
||||
|
||||
assert_eq!(processed.get(), 1);
|
||||
assert!(result_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_surface_requires_matching_system_id_and_dimensions() {
|
||||
assert!(imported_surface_matches(&handle(7), 7, 64, 48));
|
||||
assert!(!imported_surface_matches(&handle(8), 7, 64, 48));
|
||||
let mut wrong_size = handle(7);
|
||||
wrong_size.width = 96;
|
||||
assert!(!imported_surface_matches(&wrong_size, 7, 64, 48));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_backpressures_surface_burst_at_queue_capacity() {
|
||||
let (request_tx, request_rx) = mpsc::sync_channel(IMPORT_QUEUE_CAPACITY);
|
||||
let (_result_tx, result_rx) = mpsc::channel();
|
||||
let worker = IOSurfaceImportWorker {
|
||||
request_tx: Some(request_tx),
|
||||
result_rx,
|
||||
shutdown: Arc::new(AtomicBool::new(false)),
|
||||
thread: None,
|
||||
};
|
||||
for surface_id in 0..IMPORT_QUEUE_CAPACITY as u64 {
|
||||
assert!(worker.submit(handle(surface_id)).is_ok());
|
||||
}
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
let consumer = std::thread::spawn(move || {
|
||||
let received = request_rx.recv();
|
||||
let _ = release_rx.recv();
|
||||
received
|
||||
});
|
||||
|
||||
assert!(worker.submit(handle(99)).is_ok());
|
||||
assert!(release_tx.send(()).is_ok());
|
||||
assert!(consumer.join().is_ok());
|
||||
}
|
||||
|
||||
fn handle(surface_id: u64) -> LiveSurfaceHandle {
|
||||
LiveSurfaceHandle { mach_port_name: surface_id as u32, surface_id, width: 64, height: 48 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::{
|
||||
ServoLiveError, apply_iosurface_import_results,
|
||||
iosurface_importer::{IOSurfaceImportFailure, IOSurfaceImportResult},
|
||||
};
|
||||
use crate::services::iosurface_metal::IOSurfaceCache;
|
||||
|
||||
#[test]
|
||||
fn import_failure_clears_every_completed_pending_surface() -> Result<(), String> {
|
||||
let mut cache = IOSurfaceCache::new();
|
||||
let mut pending = BTreeSet::from([1, 2, 3]);
|
||||
let results = vec![failed_import(1), failed_import(2), failed_import(3)];
|
||||
|
||||
let Err(error) = apply_iosurface_import_results(&mut cache, &mut pending, results) else {
|
||||
return Err("the failed import batch succeeded".to_string());
|
||||
};
|
||||
|
||||
assert!(matches!(error, ServoLiveError::IOSurfaceImportFailed { surface_id: 1, .. }));
|
||||
assert!(pending.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn failed_import(surface_id: u64) -> IOSurfaceImportResult {
|
||||
IOSurfaceImportResult::Failed(IOSurfaceImportFailure {
|
||||
surface_id,
|
||||
mach_port_name: surface_id as u32,
|
||||
message: format!("surface {surface_id} failed"),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
process::{ChildStdin, ChildStdout},
|
||||
sync::mpsc::{self, Receiver, Sender, SyncSender},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use super::{
|
||||
ServoLiveError, ServoLiveFrame,
|
||||
wire::{
|
||||
LiveRequest, LiveResponse, LiveSurfaceHandle, MAX_FRAME_BYTE_COUNT, MAX_FRAME_DIMENSION,
|
||||
},
|
||||
};
|
||||
|
||||
const MAX_RESPONSE_HEADER_BYTES: usize = 256 * 1024;
|
||||
|
||||
pub(super) struct ServoLiveIpc {
|
||||
requests: Option<Sender<IpcRequest>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
pub(super) struct IpcReply {
|
||||
pub(super) protocol_version: Option<u32>,
|
||||
pub(super) error: Option<String>,
|
||||
pub(super) frame: Option<ServoLiveFrame>,
|
||||
pub(super) surface_handle: Option<LiveSurfaceHandle>,
|
||||
pub(super) current_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
struct IpcRequest {
|
||||
request: LiveRequest,
|
||||
response: SyncSender<Result<IpcReply, ServoLiveError>>,
|
||||
}
|
||||
|
||||
impl ServoLiveIpc {
|
||||
pub(super) fn spawn(stdin: ChildStdin, stdout: ChildStdout) -> Self {
|
||||
let (requests, receiver) = mpsc::channel();
|
||||
let thread = thread::spawn(move || run_io(stdin, BufReader::new(stdout), receiver));
|
||||
Self { requests: Some(requests), thread: Some(thread) }
|
||||
}
|
||||
|
||||
pub(super) fn exchange(
|
||||
&self,
|
||||
request: LiveRequest,
|
||||
timeout: Duration,
|
||||
operation: &'static str,
|
||||
) -> Result<IpcReply, ServoLiveError> {
|
||||
let requests = self.requests.as_ref().ok_or(ServoLiveError::SidecarExited)?;
|
||||
let (response, receiver) = mpsc::sync_channel(1);
|
||||
requests
|
||||
.send(IpcRequest { request, response })
|
||||
.map_err(|_| ServoLiveError::SidecarExited)?;
|
||||
receiver.recv_timeout(timeout).map_err(|error| match error {
|
||||
mpsc::RecvTimeoutError::Timeout => {
|
||||
ServoLiveError::RequestTimedOut { operation, timeout_millis: timeout.as_millis() }
|
||||
}
|
||||
mpsc::RecvTimeoutError::Disconnected => ServoLiveError::SidecarExited,
|
||||
})?
|
||||
}
|
||||
|
||||
pub(super) fn close_and_join(&mut self, timeout: Duration) {
|
||||
self.requests.take();
|
||||
let Some(thread) = self.thread.take() else {
|
||||
return;
|
||||
};
|
||||
let started_at = Instant::now();
|
||||
while !thread.is_finished() && started_at.elapsed() < timeout {
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
if thread.is_finished() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_io(
|
||||
mut stdin: ChildStdin,
|
||||
mut stdout: BufReader<ChildStdout>,
|
||||
requests: Receiver<IpcRequest>,
|
||||
) {
|
||||
while let Ok(message) = requests.recv() {
|
||||
let should_shutdown = matches!(message.request, LiveRequest::Shutdown);
|
||||
let response = exchange_pipes(&mut stdin, &mut stdout, &message.request);
|
||||
let should_stop = should_shutdown || response.is_err();
|
||||
let _ = message.response.send(response);
|
||||
if should_stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exchange_pipes(
|
||||
stdin: &mut ChildStdin,
|
||||
stdout: &mut BufReader<ChildStdout>,
|
||||
request: &LiveRequest,
|
||||
) -> Result<IpcReply, ServoLiveError> {
|
||||
serde_json::to_writer(&mut *stdin, request)?;
|
||||
stdin.write_all(b"\n").map_err(ServoLiveError::Command)?;
|
||||
stdin.flush().map_err(ServoLiveError::Command)?;
|
||||
read_reply(stdout)
|
||||
}
|
||||
|
||||
fn read_reply(stdout: &mut impl BufRead) -> Result<IpcReply, ServoLiveError> {
|
||||
let mut line = String::new();
|
||||
let bytes = Read::by_ref(stdout)
|
||||
.take((MAX_RESPONSE_HEADER_BYTES + 1) as u64)
|
||||
.read_line(&mut line)
|
||||
.map_err(ServoLiveError::Command)?;
|
||||
if bytes == 0 {
|
||||
return Err(ServoLiveError::SidecarExited);
|
||||
}
|
||||
if bytes > MAX_RESPONSE_HEADER_BYTES {
|
||||
return Err(ServoLiveError::ResponseHeaderTooLarge { limit: MAX_RESPONSE_HEADER_BYTES });
|
||||
}
|
||||
if !line.ends_with('\n') {
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "response header is missing its newline delimiter",
|
||||
});
|
||||
}
|
||||
|
||||
let response: LiveResponse = serde_json::from_str(&line)?;
|
||||
if response.error.is_some()
|
||||
&& (response.frame.is_some()
|
||||
|| response.surface_handle.is_some()
|
||||
|| response.current_surface_id.is_some())
|
||||
{
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "error response contains a frame or hardware surface",
|
||||
});
|
||||
}
|
||||
if response.frame.is_none()
|
||||
&& (response.surface_handle.is_some() || response.current_surface_id.is_some())
|
||||
{
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "hardware surface response is missing its frame report",
|
||||
});
|
||||
}
|
||||
if let Some(handle) = response.surface_handle.as_ref() {
|
||||
if response.current_surface_id != Some(handle.surface_id) {
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "surface_handle does not match current_surface_id",
|
||||
});
|
||||
}
|
||||
if response
|
||||
.frame
|
||||
.as_ref()
|
||||
.is_none_or(|frame| frame.width != handle.width || frame.height != handle.height)
|
||||
{
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "surface_handle dimensions do not match frame report",
|
||||
});
|
||||
}
|
||||
}
|
||||
let frame = response.frame.map(|report| {
|
||||
let rgba_byte_count = validate_frame_layout(report.width, report.height)?;
|
||||
let hardware_frame = report.rgba_byte_count == 0;
|
||||
if hardware_frame && response.current_surface_id.is_none() {
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "hardware frame is missing current_surface_id",
|
||||
});
|
||||
}
|
||||
if !hardware_frame
|
||||
&& (response.surface_handle.is_some() || response.current_surface_id.is_some())
|
||||
{
|
||||
return Err(ServoLiveError::InvalidResponse {
|
||||
message: "software frame contains hardware surface metadata",
|
||||
});
|
||||
}
|
||||
if !hardware_frame && report.rgba_byte_count != rgba_byte_count {
|
||||
return Err(ServoLiveError::InvalidFrameByteCount {
|
||||
advertised: report.rgba_byte_count,
|
||||
expected: rgba_byte_count as u64,
|
||||
width: report.width,
|
||||
height: report.height,
|
||||
});
|
||||
}
|
||||
let rgba_bytes = if hardware_frame {
|
||||
None
|
||||
} else {
|
||||
let mut bytes = Vec::new();
|
||||
bytes.try_reserve_exact(rgba_byte_count).map_err(|source| {
|
||||
ServoLiveError::FrameAllocation { bytes: rgba_byte_count, source }
|
||||
})?;
|
||||
bytes.resize(rgba_byte_count, 0);
|
||||
stdout.read_exact(&mut bytes).map_err(ServoLiveError::FrameRead)?;
|
||||
Some(bytes)
|
||||
};
|
||||
Ok(ServoLiveFrame::from_parts(report, rgba_bytes))
|
||||
});
|
||||
Ok(IpcReply {
|
||||
protocol_version: response.protocol_version,
|
||||
error: response.error,
|
||||
frame: frame.transpose()?,
|
||||
surface_handle: response.surface_handle,
|
||||
current_surface_id: response.current_surface_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn validate_frame_layout(width: u32, height: u32) -> Result<usize, ServoLiveError> {
|
||||
if width == 0 || height == 0 || width > MAX_FRAME_DIMENSION || height > MAX_FRAME_DIMENSION {
|
||||
return Err(ServoLiveError::InvalidFrameDimensions {
|
||||
width,
|
||||
height,
|
||||
max_dimension: MAX_FRAME_DIMENSION,
|
||||
});
|
||||
}
|
||||
let bytes = u64::from(width)
|
||||
.checked_mul(u64::from(height))
|
||||
.and_then(|pixels| pixels.checked_mul(4))
|
||||
.ok_or(ServoLiveError::InvalidFrameDimensions {
|
||||
width,
|
||||
height,
|
||||
max_dimension: MAX_FRAME_DIMENSION,
|
||||
})?;
|
||||
if bytes > MAX_FRAME_BYTE_COUNT as u64 {
|
||||
return Err(ServoLiveError::FrameByteLimitExceeded { bytes, limit: MAX_FRAME_BYTE_COUNT });
|
||||
}
|
||||
Ok(bytes as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Cursor;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_layout_rejects_oversized_frames() {
|
||||
assert!(matches!(
|
||||
validate_frame_layout(MAX_FRAME_DIMENSION, MAX_FRAME_DIMENSION),
|
||||
Err(ServoLiveError::FrameByteLimitExceeded { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
validate_frame_layout(MAX_FRAME_DIMENSION + 1, 1),
|
||||
Err(ServoLiveError::InvalidFrameDimensions { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_rejects_oversized_frame_before_readback_allocation() {
|
||||
let header = format!(
|
||||
"{{\"protocol_version\":2,\"error\":null,\"frame\":{{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",\"width\":{0},\"height\":{0},\"device_pixel_ratio\":1.0,\"css_viewport_width\":{0},\"css_viewport_height\":{0},\"rgba_byte_count\":1073741824,\"pixels_changed\":true}}}}\n",
|
||||
MAX_FRAME_DIMENSION
|
||||
);
|
||||
let mut input = Cursor::new(header.into_bytes());
|
||||
|
||||
assert!(matches!(
|
||||
read_reply(&mut input),
|
||||
Err(ServoLiveError::FrameByteLimitExceeded { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn hardware_reply_uses_surface_without_rgba_allocation() -> Result<(), ServoLiveError> {
|
||||
let header = concat!(
|
||||
"{\"protocol_version\":2,\"error\":null,",
|
||||
"\"surface_handle\":{\"mach_port_name\":91,\"surface_id\":7,\"width\":64,\"height\":48},",
|
||||
"\"current_surface_id\":7,",
|
||||
"\"frame\":{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",",
|
||||
"\"width\":64,\"height\":48,\"device_pixel_ratio\":1.0,",
|
||||
"\"css_viewport_width\":64,\"css_viewport_height\":48,",
|
||||
"\"rgba_byte_count\":0,\"pixels_changed\":true}}\n"
|
||||
);
|
||||
let mut input = Cursor::new(header.as_bytes());
|
||||
|
||||
let reply = read_reply(&mut input)?;
|
||||
let Some(frame) = reply.frame else {
|
||||
return Err(ServoLiveError::InvalidResponse { message: "test frame was missing" });
|
||||
};
|
||||
|
||||
assert_eq!(reply.current_surface_id, Some(7));
|
||||
assert_eq!(reply.surface_handle.map(|handle| handle.surface_id), Some(7));
|
||||
assert!(frame.into_rgba_bytes().is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn hardware_reply_rejects_mismatched_surface_identity() {
|
||||
let header = hardware_header(8, 64, 48);
|
||||
let mut input = Cursor::new(header.into_bytes());
|
||||
|
||||
assert!(matches!(
|
||||
read_reply(&mut input),
|
||||
Err(ServoLiveError::InvalidResponse {
|
||||
message: "surface_handle does not match current_surface_id"
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn hardware_reply_rejects_mismatched_surface_dimensions() {
|
||||
let header = hardware_header(7, 96, 72);
|
||||
let mut input = Cursor::new(header.into_bytes());
|
||||
|
||||
assert!(matches!(
|
||||
read_reply(&mut input),
|
||||
Err(ServoLiveError::InvalidResponse {
|
||||
message: "surface_handle dimensions do not match frame report"
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn hardware_header(current_surface_id: u64, handle_width: u32, handle_height: u32) -> String {
|
||||
format!(
|
||||
"{{\"protocol_version\":2,\"error\":null,\"surface_handle\":{{\"mach_port_name\":91,\"surface_id\":7,\"width\":{handle_width},\"height\":{handle_height}}},\"current_surface_id\":{current_surface_id},\"frame\":{{\"loaded_url\":null,\"title\":null,\"state\":\"complete\",\"width\":64,\"height\":48,\"device_pixel_ratio\":1.0,\"css_viewport_width\":64,\"css_viewport_height\":48,\"rgba_byte_count\":0,\"pixels_changed\":true}}}}\n"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::sync::Arc;
|
||||
use std::{collections::TryReserveError, io, path::PathBuf};
|
||||
|
||||
use ely_domain::SitePermissionDecision;
|
||||
use ely_servo_host::{RenderedFrame, ServoHostError, WebViewSnapshot, WebViewState};
|
||||
use gpui::NativeSurfaceHandle;
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::wire::LiveFrameReport;
|
||||
use crate::services::servo_sidecar_command::SidecarCommandError;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::services::iosurface_mach::IOSurfaceMachError;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::services::iosurface_metal::HardwareSurfaceBacking;
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
use core_video::pixel_buffer::CVPixelBuffer;
|
||||
|
||||
pub(crate) struct ServoLiveEnsureRequest {
|
||||
pub(crate) tab_id: String,
|
||||
pub(crate) profile_id: String,
|
||||
@@ -11,10 +23,8 @@ pub(crate) struct ServoLiveEnsureRequest {
|
||||
pub(crate) width: u32,
|
||||
pub(crate) height: u32,
|
||||
pub(crate) page_zoom_percent: u16,
|
||||
/// Display scale factor (1.0 standard, 2.0 Retina). Servo's
|
||||
/// WebView lays out CSS pixels = device pixels / hidpi factor.
|
||||
/// Display scale factor used to derive Servo's CSS viewport.
|
||||
pub(crate) device_pixel_ratio: f32,
|
||||
pub(crate) native_surface: Option<NativeSurfaceHandle>,
|
||||
pub(crate) scroll_delta_x: i32,
|
||||
pub(crate) scroll_delta_y: i32,
|
||||
pub(crate) scroll_point_x: Option<u32>,
|
||||
@@ -53,6 +63,7 @@ pub(crate) struct ServoLiveFrame {
|
||||
device_pixel_ratio: f32,
|
||||
css_viewport_width: u32,
|
||||
css_viewport_height: u32,
|
||||
pixels_changed: bool,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
@@ -60,62 +71,63 @@ pub(crate) struct ServoLiveFrame {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: u64,
|
||||
rgba_bytes: Option<Vec<u8>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface: Option<Arc<HardwareSurfaceBacking>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl ServoLiveFrame {
|
||||
pub(super) fn from_presented(
|
||||
snapshot: WebViewSnapshot,
|
||||
width: u32,
|
||||
height: u32,
|
||||
device_pixel_ratio: f32,
|
||||
) -> Self {
|
||||
let (css_viewport_width, css_viewport_height) =
|
||||
css_viewport_size(width, height, device_pixel_ratio);
|
||||
pub(super) fn from_parts(report: LiveFrameReport, rgba_bytes: Option<Vec<u8>>) -> Self {
|
||||
let (css_viewport_width, css_viewport_height) = css_viewport_size_from_report(&report);
|
||||
Self {
|
||||
loaded_url: snapshot.url().map(str::to_string),
|
||||
title: snapshot.title().map(str::to_string),
|
||||
render_state: render_state_label(snapshot.state()).to_string(),
|
||||
width,
|
||||
height,
|
||||
device_pixel_ratio,
|
||||
loaded_url: report.loaded_url,
|
||||
title: report.title,
|
||||
render_state: report.state,
|
||||
width: report.width,
|
||||
height: report.height,
|
||||
device_pixel_ratio: report.device_pixel_ratio,
|
||||
css_viewport_width,
|
||||
css_viewport_height,
|
||||
pixels_changed: report.pixels_changed,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: 1,
|
||||
non_white_pixel_count: report.non_white_pixel_count,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
content_pixel_count: 1,
|
||||
content_pixel_count: report.content_pixel_count,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: 0,
|
||||
rgba_bytes: None,
|
||||
sample_hash: report.sample_hash,
|
||||
rgba_bytes,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn from_rendered(
|
||||
snapshot: WebViewSnapshot,
|
||||
rendered_frame: RenderedFrame,
|
||||
device_pixel_ratio: f32,
|
||||
) -> Self {
|
||||
let width = rendered_frame.width();
|
||||
let height = rendered_frame.height();
|
||||
let (css_viewport_width, css_viewport_height) =
|
||||
css_viewport_size(width, height, device_pixel_ratio);
|
||||
Self {
|
||||
loaded_url: snapshot.url().map(str::to_string),
|
||||
title: snapshot.title().map(str::to_string),
|
||||
render_state: render_state_label(snapshot.state()).to_string(),
|
||||
width,
|
||||
height,
|
||||
device_pixel_ratio,
|
||||
css_viewport_width,
|
||||
css_viewport_height,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: rendered_frame.non_white_pixel_count(),
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
content_pixel_count: rendered_frame.content_pixel_count(),
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: rendered_frame.sample_hash(),
|
||||
rgba_bytes: Some(rendered_frame.rgba_bytes().to_vec()),
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn set_hardware_surface(
|
||||
&mut self,
|
||||
surface_id: u64,
|
||||
surface: Option<Arc<HardwareSurfaceBacking>>,
|
||||
) {
|
||||
self.hardware_surface_id = surface.as_ref().map(|_| surface_id);
|
||||
self.hardware_surface = surface;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[must_use]
|
||||
pub fn hardware_surface_id(&self) -> Option<u64> {
|
||||
self.hardware_surface_id
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[must_use]
|
||||
pub(crate) fn hardware_surface(&self) -> Option<&Arc<HardwareSurfaceBacking>> {
|
||||
self.hardware_surface.as_ref()
|
||||
}
|
||||
|
||||
pub(super) fn has_software_payload(&self) -> bool {
|
||||
self.rgba_bytes.is_some()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -158,6 +170,11 @@ impl ServoLiveFrame {
|
||||
self.css_viewport_height
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn pixels_changed(&self) -> bool {
|
||||
self.pixels_changed
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[must_use]
|
||||
pub fn non_white_pixel_count(&self) -> u64 {
|
||||
@@ -183,18 +200,29 @@ impl ServoLiveFrame {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_test(width: u32, height: u32, rgba_bytes: Vec<u8>) -> Self {
|
||||
Self::for_test_with_render_state(width, height, rgba_bytes, "complete")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_test_with_render_state(
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgba_bytes: Vec<u8>,
|
||||
render_state: &str,
|
||||
) -> Self {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
let summary =
|
||||
ely_servo_host::RenderedFrameSummary::from_rgba_bytes(width, height, &rgba_bytes);
|
||||
Self {
|
||||
loaded_url: Some("https://example.com/".to_string()),
|
||||
title: Some("Example".to_string()),
|
||||
render_state: "complete".to_string(),
|
||||
render_state: render_state.to_string(),
|
||||
width,
|
||||
height,
|
||||
device_pixel_ratio: 1.0,
|
||||
css_viewport_width: width,
|
||||
css_viewport_height: height,
|
||||
pixels_changed: true,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: summary.non_white_pixel_count(),
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
@@ -202,49 +230,197 @@ impl ServoLiveFrame {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: summary.sample_hash(),
|
||||
rgba_bytes: Some(rgba_bytes),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(target_os = "macos")]
|
||||
hardware_surface: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
#[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_from_report(report: &LiveFrameReport) -> (u32, u32) {
|
||||
let scale = if report.device_pixel_ratio.is_finite() && report.device_pixel_ratio > 0.0 {
|
||||
report.device_pixel_ratio
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let fallback_width = ((report.width as f32) / scale).round().max(1.0) as u32;
|
||||
let fallback_height = ((report.height as f32) / scale).round().max(1.0) as u32;
|
||||
(
|
||||
((width as f32) / scale).round().max(1.0) as u32,
|
||||
((height as f32) / scale).round().max(1.0) as u32,
|
||||
if report.css_viewport_width > 0 { report.css_viewport_width } else { fallback_width },
|
||||
if report.css_viewport_height > 0 { report.css_viewport_height } else { fallback_height },
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum ServoLiveError {
|
||||
#[error("servo native surface is unavailable")]
|
||||
NativeSurfaceUnavailable,
|
||||
#[error("servo sidecar binary is unavailable at {path}")]
|
||||
SidecarBinaryUnavailable { path: PathBuf },
|
||||
|
||||
#[error("servo scroll input is missing a viewport point")]
|
||||
MissingScrollPoint,
|
||||
#[error("failed to run servo live sidecar: {0}")]
|
||||
Command(#[source] io::Error),
|
||||
|
||||
#[error("servo live sidecar pipe is unavailable: {name}")]
|
||||
PipeUnavailable { name: &'static str },
|
||||
|
||||
#[error("servo live sidecar exited")]
|
||||
SidecarExited,
|
||||
|
||||
#[error("servo live sidecar protocol mismatch: expected {expected}, received {actual:?}")]
|
||||
ProtocolVersionMismatch { expected: u32, actual: Option<u32> },
|
||||
|
||||
#[error("servo live sidecar {operation} timed out after {timeout_millis} ms")]
|
||||
RequestTimedOut { operation: &'static str, timeout_millis: u128 },
|
||||
|
||||
#[error("servo live sidecar failed: {message}")]
|
||||
SidecarFailed { message: String },
|
||||
|
||||
#[error("servo live sidecar response header exceeded {limit} bytes")]
|
||||
ResponseHeaderTooLarge { limit: usize },
|
||||
|
||||
#[error("failed to read servo live frame bytes: {0}")]
|
||||
FrameRead(#[source] io::Error),
|
||||
|
||||
#[error(
|
||||
"servo live frame dimensions {width}x{height} exceed the {max_dimension}px dimension limit"
|
||||
)]
|
||||
InvalidFrameDimensions { width: u32, height: u32, max_dimension: u32 },
|
||||
|
||||
#[error("servo live frame requires {bytes} bytes; the limit is {limit}")]
|
||||
FrameByteLimitExceeded { bytes: u64, limit: usize },
|
||||
|
||||
#[error(
|
||||
"servo live sidecar advertised {advertised} frame bytes for {width}x{height}; expected {expected}"
|
||||
)]
|
||||
InvalidFrameByteCount { advertised: usize, expected: u64, width: u32, height: u32 },
|
||||
|
||||
#[error("failed to reserve {bytes} bytes for a servo live frame: {source}")]
|
||||
FrameAllocation {
|
||||
bytes: usize,
|
||||
#[source]
|
||||
source: TryReserveError,
|
||||
},
|
||||
|
||||
#[error("invalid servo live sidecar response: {message}")]
|
||||
InvalidResponse { message: &'static str },
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error(
|
||||
"servo live IOSurface import failed for surface {surface_id:#x} mach port 0x{mach_port_name:x}: {message}"
|
||||
)]
|
||||
IOSurfaceImportFailed { surface_id: u64, mach_port_name: u32, message: String },
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error("servo live IOSurface backing failed for surface {surface_id:#x}: {message}")]
|
||||
IOSurfaceBackingFailed { surface_id: u64, message: String },
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error("failed to spawn servo live IOSurface importer: {0}")]
|
||||
IOSurfaceImportWorker(#[source] io::Error),
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error(transparent)]
|
||||
IOSurfaceMach(#[from] IOSurfaceMachError),
|
||||
|
||||
#[error(transparent)]
|
||||
Domain(#[from] ely_domain::DomainError),
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Host(#[from] ServoHostError),
|
||||
SidecarCommand(#[from] SidecarCommandError),
|
||||
}
|
||||
|
||||
impl ServoLiveError {
|
||||
pub(crate) fn is_runtime_unavailable(&self) -> bool {
|
||||
matches!(self, Self::Host(ServoHostError::RuntimeAlreadyStarted))
|
||||
match self {
|
||||
Self::SidecarExited
|
||||
| Self::ProtocolVersionMismatch { .. }
|
||||
| Self::RequestTimedOut { .. }
|
||||
| Self::ResponseHeaderTooLarge { .. }
|
||||
| Self::InvalidFrameDimensions { .. }
|
||||
| Self::FrameByteLimitExceeded { .. }
|
||||
| Self::InvalidFrameByteCount { .. }
|
||||
| Self::FrameAllocation { .. }
|
||||
| Self::InvalidResponse { .. }
|
||||
| Self::Json(_) => true,
|
||||
#[cfg(target_os = "macos")]
|
||||
Self::IOSurfaceImportFailed { .. }
|
||||
| Self::IOSurfaceBackingFailed { .. }
|
||||
| Self::IOSurfaceImportWorker(_)
|
||||
| Self::IOSurfaceMach(_) => true,
|
||||
Self::Command(error) | Self::FrameRead(error) => matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::BrokenPipe
|
||||
| io::ErrorKind::ConnectionAborted
|
||||
| io::ErrorKind::ConnectionReset
|
||||
| io::ErrorKind::UnexpectedEof
|
||||
),
|
||||
Self::SidecarBinaryUnavailable { .. }
|
||||
| Self::PipeUnavailable { .. }
|
||||
| Self::SidecarFailed { .. }
|
||||
| Self::SidecarCommand(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn iosurface_transport_failures_invalidate_runtime() {
|
||||
let errors = [
|
||||
ServoLiveError::IOSurfaceImportFailed {
|
||||
surface_id: 7,
|
||||
mach_port_name: 11,
|
||||
message: "injected".to_string(),
|
||||
},
|
||||
ServoLiveError::IOSurfaceImportWorker(io::Error::other("injected")),
|
||||
ServoLiveError::IOSurfaceMach(IOSurfaceMachError::InvalidMessage),
|
||||
ServoLiveError::IOSurfaceBackingFailed {
|
||||
surface_id: 7,
|
||||
message: "injected".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
assert!(errors.iter().all(ServoLiveError::is_runtime_unavailable));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ServoLiveSitePermission;
|
||||
|
||||
pub(super) const LIVE_PROTOCOL_VERSION: u32 = 2;
|
||||
pub(super) const MAX_FRAME_DIMENSION: u32 = 16_384;
|
||||
pub(super) const MAX_FRAME_BYTE_COUNT: usize = 256 * 1024 * 1024;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(super) enum LiveRequest {
|
||||
Handshake {
|
||||
protocol_version: u32,
|
||||
},
|
||||
Ensure {
|
||||
tab_id: String,
|
||||
profile_id: String,
|
||||
url: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
page_zoom_percent: u16,
|
||||
device_pixel_ratio: f32,
|
||||
scroll_delta_x: i32,
|
||||
scroll_delta_y: i32,
|
||||
scroll_point_x: Option<u32>,
|
||||
scroll_point_y: Option<u32>,
|
||||
click_x: Option<u32>,
|
||||
click_y: Option<u32>,
|
||||
hover_x: Option<u32>,
|
||||
hover_y: Option<u32>,
|
||||
typed_text: Option<String>,
|
||||
site_permissions: Vec<ServoLiveSitePermission>,
|
||||
ready_surface_ids: Vec<u64>,
|
||||
pending_surface_ids: Vec<u64>,
|
||||
},
|
||||
Poll {
|
||||
tab_id: String,
|
||||
ready_surface_ids: Vec<u64>,
|
||||
pending_surface_ids: Vec<u64>,
|
||||
},
|
||||
Close {
|
||||
tab_id: String,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LiveResponse {
|
||||
#[serde(default)]
|
||||
pub(super) protocol_version: Option<u32>,
|
||||
pub(super) error: Option<String>,
|
||||
pub(super) frame: Option<LiveFrameReport>,
|
||||
#[serde(default)]
|
||||
pub(super) surface_handle: Option<LiveSurfaceHandle>,
|
||||
#[serde(default)]
|
||||
pub(super) current_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
pub(super) struct LiveSurfaceHandle {
|
||||
pub(super) mach_port_name: u32,
|
||||
pub(super) surface_id: u64,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LiveFrameReport {
|
||||
pub(super) loaded_url: Option<String>,
|
||||
pub(super) title: Option<String>,
|
||||
pub(super) state: String,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
#[serde(default = "default_device_pixel_ratio")]
|
||||
pub(super) device_pixel_ratio: f32,
|
||||
#[serde(default)]
|
||||
pub(super) css_viewport_width: u32,
|
||||
#[serde(default)]
|
||||
pub(super) css_viewport_height: u32,
|
||||
pub(super) rgba_byte_count: usize,
|
||||
pub(super) pixels_changed: bool,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[serde(default)]
|
||||
pub(super) non_white_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[serde(default)]
|
||||
pub(super) content_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[serde(default)]
|
||||
pub(super) sample_hash: u64,
|
||||
}
|
||||
|
||||
fn default_device_pixel_ratio() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{LIVE_PROTOCOL_VERSION, LiveRequest};
|
||||
|
||||
#[test]
|
||||
fn handshake_request_serializes_protocol_version() -> Result<(), serde_json::Error> {
|
||||
let value = serde_json::to_value(LiveRequest::Handshake {
|
||||
protocol_version: LIVE_PROTOCOL_VERSION,
|
||||
})?;
|
||||
|
||||
assert_eq!(value, json!({"type": "handshake", "protocol_version": LIVE_PROTOCOL_VERSION}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_request_serializes_to_wire() -> Result<(), serde_json::Error> {
|
||||
let value =
|
||||
serde_json::to_value(LiveRequest::Close { tab_id: "tab-live-close".to_string() })?;
|
||||
|
||||
assert_eq!(value, json!({"type": "close", "tab_id": "tab-live-close"}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_serializes_hardware_surface_states() -> Result<(), serde_json::Error> {
|
||||
let value = serde_json::to_value(LiveRequest::Poll {
|
||||
tab_id: "tab-live".to_string(),
|
||||
ready_surface_ids: vec![7, 11],
|
||||
pending_surface_ids: vec![13],
|
||||
})?;
|
||||
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"type": "poll",
|
||||
"tab_id": "tab-live",
|
||||
"ready_surface_ids": [7, 11],
|
||||
"pending_surface_ids": [13]
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,66 @@
|
||||
use std::{
|
||||
env,
|
||||
fs::{File, OpenOptions},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, SystemTimeError, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use ely_domain::{ProfileId, ProfileKind};
|
||||
use ely_domain::ProfileId;
|
||||
|
||||
const ELY_QUALIFIER: &str = "com";
|
||||
const ELY_ORGANIZATION: &str = "elydora";
|
||||
const ELY_APPLICATION: &str = "ELY Browser";
|
||||
const DEFAULT_STANDARD_PROFILE_DIR: &str = "default";
|
||||
const TRANSIENT_PROFILE_ROOT: &str = "ely-browser-servo-profiles";
|
||||
const TRANSIENT_PROFILE_PREFIX: &str = "profile-";
|
||||
const TRANSIENT_PROFILE_LEASE: &str = ".lease";
|
||||
const TRANSIENT_ROOT_LOCK: &str = ".lock";
|
||||
|
||||
pub(crate) struct TransientProfileDataDir {
|
||||
directory: Option<tempfile::TempDir>,
|
||||
lease: Option<File>,
|
||||
root: PathBuf,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TransientProfileDataDir {
|
||||
pub(crate) fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub(crate) fn close(mut self) -> Result<(), io::Error> {
|
||||
self.remove()
|
||||
}
|
||||
|
||||
fn remove(&mut self) -> Result<(), io::Error> {
|
||||
let Some(directory) = self.directory.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let path = directory.keep();
|
||||
let root_lock = match open_lock_file(&self.root.join(TRANSIENT_ROOT_LOCK)) {
|
||||
Ok(root_lock) => root_lock,
|
||||
Err(error) => {
|
||||
self.lease.take();
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Err(error) = lock_file(&root_lock) {
|
||||
self.lease.take();
|
||||
return Err(error);
|
||||
}
|
||||
self.lease.take();
|
||||
match std::fs::remove_dir_all(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TransientProfileDataDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.remove();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn default_profile_data_root() -> Option<PathBuf> {
|
||||
ProjectDirs::from(ELY_QUALIFIER, ELY_ORGANIZATION, ELY_APPLICATION)
|
||||
@@ -21,27 +71,147 @@ pub(crate) fn profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId)
|
||||
profile_data_root.join(profile_id.as_str()).join("servo")
|
||||
}
|
||||
|
||||
pub(crate) fn sync_profile_data_dir(
|
||||
pub(crate) fn sync_profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId) -> PathBuf {
|
||||
profile_data_dir(profile_data_root, profile_id)
|
||||
}
|
||||
|
||||
pub(crate) fn create_profile_data_dir(
|
||||
profile_data_root: &Path,
|
||||
profile_id: &ProfileId,
|
||||
profile_name: &str,
|
||||
profile_kind: &ProfileKind,
|
||||
) -> PathBuf {
|
||||
if profile_name == "Default" && matches!(profile_kind, ProfileKind::Standard) {
|
||||
return profile_data_root.join(DEFAULT_STANDARD_PROFILE_DIR).join("servo");
|
||||
) -> Result<PathBuf, io::Error> {
|
||||
let profile_dir = profile_data_root.join(profile_id.as_str());
|
||||
let servo_dir = profile_dir.join("servo");
|
||||
std::fs::create_dir_all(&servo_dir)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&profile_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
std::fs::set_permissions(&servo_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
profile_data_dir(profile_data_root, profile_id)
|
||||
Ok(servo_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn cleanup_stale_transient_profile_data_dirs() -> Result<(), io::Error> {
|
||||
cleanup_stale_transient_profile_data_dirs_at(&transient_profile_data_root()?)
|
||||
}
|
||||
|
||||
pub(crate) fn transient_profile_data_dir(
|
||||
profile_id: &ProfileId,
|
||||
) -> Result<PathBuf, SystemTimeError> {
|
||||
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
|
||||
Ok(env::temp_dir().join("ely-browser-servo-profiles").join(format!(
|
||||
"{}-{}-{timestamp}",
|
||||
std::process::id(),
|
||||
profile_id.as_str()
|
||||
)))
|
||||
) -> Result<TransientProfileDataDir, io::Error> {
|
||||
let root = transient_profile_data_root()?;
|
||||
create_private_directory(&root)?;
|
||||
let root_lock = open_lock_file(&root.join(TRANSIENT_ROOT_LOCK))?;
|
||||
lock_file(&root_lock)?;
|
||||
cleanup_stale_transient_profile_data_dirs_locked(&root, |path| std::fs::remove_dir_all(path))?;
|
||||
|
||||
let directory = tempfile::Builder::new()
|
||||
.prefix(&format!("{TRANSIENT_PROFILE_PREFIX}{}-", profile_id.as_str()))
|
||||
.tempdir_in(&root)?;
|
||||
create_private_directory(directory.path())?;
|
||||
let lease = open_lock_file(&directory.path().join(TRANSIENT_PROFILE_LEASE))?;
|
||||
lock_file(&lease)?;
|
||||
let path = directory.path().to_path_buf();
|
||||
drop(root_lock);
|
||||
Ok(TransientProfileDataDir { directory: Some(directory), lease: Some(lease), root, path })
|
||||
}
|
||||
|
||||
fn transient_profile_data_root() -> Result<PathBuf, io::Error> {
|
||||
let project_dirs = ProjectDirs::from(ELY_QUALIFIER, ELY_ORGANIZATION, ELY_APPLICATION)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "ELY project directory unavailable")
|
||||
})?;
|
||||
let base = project_dirs.runtime_dir().unwrap_or_else(|| project_dirs.cache_dir());
|
||||
Ok(base.join(TRANSIENT_PROFILE_ROOT))
|
||||
}
|
||||
|
||||
fn cleanup_stale_transient_profile_data_dirs_at(root: &Path) -> Result<(), io::Error> {
|
||||
create_private_directory(root)?;
|
||||
let root_lock = open_lock_file(&root.join(TRANSIENT_ROOT_LOCK))?;
|
||||
lock_file(&root_lock)?;
|
||||
cleanup_stale_transient_profile_data_dirs_locked(root, |path| std::fs::remove_dir_all(path))
|
||||
}
|
||||
|
||||
fn cleanup_stale_transient_profile_data_dirs_locked(
|
||||
root: &Path,
|
||||
remove_dir: impl Fn(&Path) -> Result<(), io::Error>,
|
||||
) -> Result<(), io::Error> {
|
||||
for entry in std::fs::read_dir(root)? {
|
||||
let entry = entry?;
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(file_type) => file_type,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if !file_type.is_dir()
|
||||
|| !entry.file_name().to_string_lossy().starts_with(TRANSIENT_PROFILE_PREFIX)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let lease = match open_lock_file(&entry.path().join(TRANSIENT_PROFILE_LEASE)) {
|
||||
Ok(lease) => lease,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
remove_directory_if_present(&remove_dir, &entry.path())?;
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if try_lock_file(&lease)? {
|
||||
remove_directory_if_present(&remove_dir, &entry.path())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_directory_if_present(
|
||||
remove_dir: &impl Fn(&Path) -> Result<(), io::Error>,
|
||||
path: &Path,
|
||||
) -> Result<(), io::Error> {
|
||||
match remove_dir(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_lock_file(path: &Path) -> Result<File, io::Error> {
|
||||
OpenOptions::new().create(true).read(true).write(true).truncate(false).open(path)
|
||||
}
|
||||
|
||||
fn lock_file(file: &File) -> Result<(), io::Error> {
|
||||
file.lock()
|
||||
}
|
||||
|
||||
fn try_lock_file(file: &File) -> Result<bool, io::Error> {
|
||||
match file.try_lock() {
|
||||
Ok(()) => Ok(true),
|
||||
Err(std::fs::TryLockError::WouldBlock) => Ok(false),
|
||||
Err(std::fs::TryLockError::Error(error)) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_private_directory(path: &Path) -> Result<(), io::Error> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
match std::fs::create_dir(path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||
let metadata = std::fs::symlink_metadata(path)?;
|
||||
if !metadata.file_type().is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("private directory path is not a directory: {}", path.display()),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
@@ -52,19 +222,46 @@ pub(crate) enum ProfileDataMode {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{profile_data_dir, sync_profile_data_dir};
|
||||
use ely_domain::{ProfileId, ProfileKind};
|
||||
use std::{cell::Cell, io};
|
||||
|
||||
use super::{
|
||||
TRANSIENT_PROFILE_LEASE, TRANSIENT_PROFILE_PREFIX,
|
||||
cleanup_stale_transient_profile_data_dirs_at,
|
||||
cleanup_stale_transient_profile_data_dirs_locked, create_profile_data_dir, open_lock_file,
|
||||
profile_data_dir, sync_profile_data_dir, transient_profile_data_dir,
|
||||
transient_profile_data_root,
|
||||
};
|
||||
use ely_domain::ProfileId;
|
||||
|
||||
#[test]
|
||||
fn default_standard_sync_profile_dir_is_stable() {
|
||||
fn default_standard_sync_profile_dir_keeps_profile_identity() {
|
||||
let root = std::path::Path::new("/profiles");
|
||||
let first_id = ProfileId::new();
|
||||
let second_id = ProfileId::new();
|
||||
|
||||
assert_eq!(sync_profile_data_dir(root, &first_id), 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!(
|
||||
sync_profile_data_dir(root, &first_id, "Default", &ProfileKind::Standard),
|
||||
sync_profile_data_dir(root, &second_id, "Default", &ProfileKind::Standard)
|
||||
std::fs::metadata(directory.path().join(profile_id.as_str()))?.permissions().mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(std::fs::metadata(servo_dir)?.permissions().mode() & 0o777, 0o700);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -72,9 +269,124 @@ mod tests {
|
||||
let root = std::path::Path::new("/profiles");
|
||||
let profile_id = ProfileId::new();
|
||||
|
||||
assert_eq!(sync_profile_data_dir(root, &profile_id), profile_data_dir(root, &profile_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_profile_directory_is_private_and_removed_on_close() -> Result<(), io::Error> {
|
||||
let directory = transient_profile_data_dir(&ProfileId::new())?;
|
||||
let path = directory.path().to_path_buf();
|
||||
assert!(path.is_dir());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(std::fs::metadata(&path)?.permissions().mode() & 0o777, 0o700);
|
||||
}
|
||||
|
||||
directory.close()?;
|
||||
|
||||
assert!(!path.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_profile_root_uses_the_user_project_directory() -> Result<(), io::Error> {
|
||||
let project_dirs = directories::ProjectDirs::from(
|
||||
super::ELY_QUALIFIER,
|
||||
super::ELY_ORGANIZATION,
|
||||
super::ELY_APPLICATION,
|
||||
)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "project directory unavailable"))?;
|
||||
let expected_base = project_dirs.runtime_dir().unwrap_or_else(|| project_dirs.cache_dir());
|
||||
|
||||
assert_eq!(
|
||||
sync_profile_data_dir(root, &profile_id, "Personal", &ProfileKind::Standard),
|
||||
profile_data_dir(root, &profile_id)
|
||||
transient_profile_data_root()?,
|
||||
expected_base.join(super::TRANSIENT_PROFILE_ROOT)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_cleanup_preserves_leased_profile_and_removes_stale_profile() -> Result<(), io::Error>
|
||||
{
|
||||
let root = tempfile::tempdir()?;
|
||||
let active = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}active"));
|
||||
let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale"));
|
||||
std::fs::create_dir_all(&active)?;
|
||||
std::fs::create_dir_all(&stale)?;
|
||||
let active_lease = open_lock_file(&active.join(TRANSIENT_PROFILE_LEASE))?;
|
||||
super::lock_file(&active_lease)?;
|
||||
open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?;
|
||||
|
||||
cleanup_stale_transient_profile_data_dirs_at(root.path())?;
|
||||
|
||||
assert!(active.is_dir());
|
||||
assert!(!stale.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_failure_keeps_stale_profile_for_retry() -> Result<(), io::Error> {
|
||||
let root = tempfile::tempdir()?;
|
||||
let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale"));
|
||||
std::fs::create_dir_all(&stale)?;
|
||||
open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?;
|
||||
let attempts = Cell::new(0_u8);
|
||||
|
||||
let result = cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| {
|
||||
attempts.set(attempts.get() + 1);
|
||||
if attempts.get() == 1 {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected failure"));
|
||||
}
|
||||
std::fs::remove_dir_all(path)
|
||||
});
|
||||
let Err(error) = result else {
|
||||
return Err(io::Error::other("the first cleanup attempt succeeded"));
|
||||
};
|
||||
|
||||
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
|
||||
assert!(stale.is_dir());
|
||||
cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| {
|
||||
std::fs::remove_dir_all(path)
|
||||
})?;
|
||||
assert!(!stale.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_accepts_a_profile_removed_concurrently() -> Result<(), io::Error> {
|
||||
let root = tempfile::tempdir()?;
|
||||
let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale"));
|
||||
std::fs::create_dir(&stale)?;
|
||||
open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?;
|
||||
|
||||
cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| {
|
||||
std::fs::remove_dir_all(path)?;
|
||||
Err(io::Error::new(io::ErrorKind::NotFound, "removed by cleanup worker"))
|
||||
})?;
|
||||
|
||||
assert!(!stale.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn cleanup_rejects_a_symlinked_transient_root() -> Result<(), io::Error> {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let parent = tempfile::tempdir()?;
|
||||
let victim = tempfile::tempdir()?;
|
||||
let stale = victim.path().join(format!("{TRANSIENT_PROFILE_PREFIX}victim"));
|
||||
std::fs::create_dir(&stale)?;
|
||||
let root = parent.path().join("transient-root");
|
||||
symlink(victim.path(), &root)?;
|
||||
|
||||
let Err(error) = cleanup_stale_transient_profile_data_dirs_at(&root) else {
|
||||
return Err(io::Error::other("symlinked transient root was accepted"));
|
||||
};
|
||||
|
||||
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
|
||||
assert!(stale.is_dir());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
use std::{
|
||||
env, io,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
const SIDECAR_PATH_ENV: &str = "ELY_SERVO_SIDECAR";
|
||||
const RENDERING_CONTEXT_ENV: &str = "ELY_SERVO_RENDERING_CONTEXT";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct SidecarCommandTarget(PathBuf);
|
||||
|
||||
impl SidecarCommandTarget {
|
||||
pub(super) fn command(&self) -> Command {
|
||||
Command::new(&self.0)
|
||||
}
|
||||
|
||||
pub(super) fn missing_binary_path(&self) -> Option<&Path> {
|
||||
(!self.0.is_file()).then_some(self.0.as_path())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum SidecarRenderingContext {
|
||||
Software,
|
||||
Hardware,
|
||||
}
|
||||
|
||||
impl SidecarRenderingContext {
|
||||
pub(super) fn cli_arg(self) -> &'static str {
|
||||
match self {
|
||||
Self::Software => "software",
|
||||
Self::Hardware => "hardware",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum SidecarCommandError {
|
||||
#[error("current executable path is unavailable: {0}")]
|
||||
CurrentExecutable(#[source] io::Error),
|
||||
|
||||
#[error("current executable directory is unavailable for {path}")]
|
||||
CurrentExecutableDirectoryUnavailable { path: PathBuf },
|
||||
}
|
||||
|
||||
pub(super) fn default_sidecar_command() -> Result<SidecarCommandTarget, SidecarCommandError> {
|
||||
if let Some(path) = env::var_os(SIDECAR_PATH_ENV) {
|
||||
return Ok(SidecarCommandTarget(PathBuf::from(path)));
|
||||
}
|
||||
|
||||
let current_exe = env::current_exe().map_err(SidecarCommandError::CurrentExecutable)?;
|
||||
let exe_dir = current_exe.parent().ok_or_else(|| {
|
||||
SidecarCommandError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() }
|
||||
})?;
|
||||
let adjacent_sidecar = exe_dir.join(sidecar_binary_name());
|
||||
let workspace_manifest = workspace_manifest_path();
|
||||
if adjacent_sidecar.is_file() || is_macos_app_bundle_exe_dir(exe_dir) {
|
||||
return Ok(SidecarCommandTarget(adjacent_sidecar));
|
||||
}
|
||||
|
||||
if let Some(target_sidecar) =
|
||||
workspace_manifest.as_deref().and_then(workspace_target_sidecar_path)
|
||||
{
|
||||
return Ok(SidecarCommandTarget(target_sidecar));
|
||||
}
|
||||
|
||||
Ok(SidecarCommandTarget(adjacent_sidecar))
|
||||
}
|
||||
|
||||
pub(super) fn rendering_context_from_env() -> SidecarRenderingContext {
|
||||
let raw = env::var(RENDERING_CONTEXT_ENV).ok();
|
||||
rendering_context_selection(raw.as_deref())
|
||||
}
|
||||
|
||||
fn rendering_context_selection(raw: Option<&str>) -> SidecarRenderingContext {
|
||||
match raw.map(str::to_lowercase).as_deref() {
|
||||
Some("software") => SidecarRenderingContext::Software,
|
||||
Some("hardware") => SidecarRenderingContext::Hardware,
|
||||
_ if cfg!(target_os = "macos") => SidecarRenderingContext::Hardware,
|
||||
_ => SidecarRenderingContext::Software,
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_manifest_path() -> Option<PathBuf> {
|
||||
option_env!("ELY_WORKSPACE_MANIFEST").filter(|path| !path.is_empty()).map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn workspace_target_sidecar_path(manifest_path: &Path) -> Option<PathBuf> {
|
||||
let profile = if cfg!(debug_assertions) { "debug" } else { "release" };
|
||||
Some(manifest_path.parent()?.join("target").join(profile).join(sidecar_binary_name()))
|
||||
}
|
||||
|
||||
fn sidecar_binary_name() -> String {
|
||||
format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX)
|
||||
}
|
||||
|
||||
fn is_macos_app_bundle_exe_dir(path: &Path) -> bool {
|
||||
path.file_name().is_some_and(|name| name == "MacOS")
|
||||
&& path
|
||||
.parent()
|
||||
.is_some_and(|contents| contents.file_name().is_some_and(|name| name == "Contents"))
|
||||
&& path
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.is_some_and(|bundle| bundle.extension().is_some_and(|extension| extension == "app"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
SidecarRenderingContext, is_macos_app_bundle_exe_dir, rendering_context_selection,
|
||||
sidecar_binary_name, workspace_target_sidecar_path,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn workspace_sidecar_path_uses_current_build_profile() {
|
||||
let manifest = std::path::Path::new("/workspace/Cargo.toml");
|
||||
let profile = if cfg!(debug_assertions) { "debug" } else { "release" };
|
||||
|
||||
assert_eq!(
|
||||
workspace_target_sidecar_path(manifest),
|
||||
Some(
|
||||
std::path::Path::new("/workspace/target").join(profile).join(sidecar_binary_name())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendering_context_follows_explicit_environment_selection() {
|
||||
let hardware = rendering_context_selection(Some("HARDWARE"));
|
||||
let software = rendering_context_selection(Some("software"));
|
||||
|
||||
assert_eq!(hardware, SidecarRenderingContext::Hardware);
|
||||
assert_eq!(software, SidecarRenderingContext::Software);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_macos_app_bundle_executable_directory() {
|
||||
assert!(is_macos_app_bundle_exe_dir(std::path::Path::new(
|
||||
"/tmp/ELY Browser.app/Contents/MacOS"
|
||||
)));
|
||||
assert!(!is_macos_app_bundle_exe_dir(std::path::Path::new("/tmp/target/debug")));
|
||||
}
|
||||
}
|
||||
@@ -104,12 +104,7 @@ impl ElyShell {
|
||||
};
|
||||
return;
|
||||
};
|
||||
let profile_dir = sync_profile_data_dir(
|
||||
&profile_root,
|
||||
&active_profile.id,
|
||||
&active_profile.name,
|
||||
&active_profile.kind,
|
||||
);
|
||||
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile.id);
|
||||
self.auth_flow_phase = AuthFlowPhase::Verifying { email: email.clone() };
|
||||
let tx = self.sync_inbox_tx.clone();
|
||||
spawn_verify_otp(email, normalized_otp, profile_dir, tx);
|
||||
@@ -127,12 +122,7 @@ impl ElyShell {
|
||||
let Some(profile_root) = default_profile_data_root() else {
|
||||
return;
|
||||
};
|
||||
let profile_dir = sync_profile_data_dir(
|
||||
&profile_root,
|
||||
&active_profile.id,
|
||||
&active_profile.name,
|
||||
&active_profile.kind,
|
||||
);
|
||||
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile.id);
|
||||
match SyncEngine::for_profile_dir(&profile_dir, "ELY", sync_platform_label()) {
|
||||
Ok(mut engine) => {
|
||||
let _ = engine.install_bearer("");
|
||||
|
||||
@@ -271,6 +271,6 @@ fn identical_live_frames_share_render_image_arc() -> Result<(), String> {
|
||||
Arc::as_ptr(first_image),
|
||||
Arc::as_ptr(second_image),
|
||||
);
|
||||
assert!(first.has_same_software_render_as(&second));
|
||||
assert!(first.has_same_render_as(&second));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ fn render_about_rows(snapshot: &BrowserSnapshot) -> AnyElement {
|
||||
format!("scheme: {DEEP_LINK_SCHEME}; auth: {AUTH_CALLBACK_URL}"),
|
||||
))
|
||||
.child(about_row(IconName::Info, "Version", APP_VERSION, "Cargo package version"))
|
||||
.child(about_row(IconName::GitHub, "Build", BUILD_REVISION, "Git revision"))
|
||||
.child(about_row(IconName::GitHub, "Build", BUILD_REVISION, "Build source"))
|
||||
.child(about_row(
|
||||
IconName::Frame,
|
||||
"GPUI",
|
||||
|
||||
@@ -257,7 +257,7 @@ fn render_compatibility_rows(
|
||||
format!("gpui {GPUI_VERSION}"),
|
||||
"Native shell renderer",
|
||||
))
|
||||
.child(compatibility_row(IconName::GitHub, "Build", BUILD_REVISION, "Git revision"))
|
||||
.child(compatibility_row(IconName::GitHub, "Build", BUILD_REVISION, "Build source"))
|
||||
.child(compatibility_row(
|
||||
IconName::CircleCheck,
|
||||
"Site Permissions",
|
||||
|
||||
@@ -121,15 +121,28 @@ pub struct ElyShell {
|
||||
|
||||
impl ElyShell {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self::new_with_config(InitialBrowserConfig::ely_defaults(), window, cx)
|
||||
let config = InitialBrowserConfig::ely_defaults()
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|mut config| {
|
||||
config.profile_id = Some(
|
||||
crate::services::profile_identity::default_standard_profile_id()
|
||||
.map_err(|error| error.to_string())?,
|
||||
);
|
||||
Ok(config)
|
||||
});
|
||||
Self::new_with_config(config, window, cx)
|
||||
}
|
||||
|
||||
pub fn new_private(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self::new_with_config(InitialBrowserConfig::private_window(), window, cx)
|
||||
Self::new_with_config(
|
||||
InitialBrowserConfig::private_window().map_err(|error| error.to_string()),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
fn new_with_config(
|
||||
config: Result<InitialBrowserConfig, ely_domain::DomainError>,
|
||||
config: Result<InitialBrowserConfig, String>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
@@ -192,14 +205,11 @@ impl ElyShell {
|
||||
},
|
||||
);
|
||||
|
||||
let state = match config.and_then(|config| {
|
||||
BrowserCore::new(config).map_err(|error| match error {
|
||||
ely_browser_core::CoreError::Domain(source) => source,
|
||||
_ => ely_domain::DomainError::InvalidCommand,
|
||||
})
|
||||
}) {
|
||||
let state = match config
|
||||
.and_then(|config| BrowserCore::new(config).map_err(|error| error.to_string()))
|
||||
{
|
||||
Ok(core) => ShellState::Ready(Box::new(core)),
|
||||
Err(error) => ShellState::StartupError(error.to_string()),
|
||||
Err(error) => ShellState::StartupError(error),
|
||||
};
|
||||
|
||||
let (sync_inbox_tx, sync_inbox_rx) = std::sync::mpsc::channel();
|
||||
|
||||
@@ -269,19 +269,12 @@ impl ElyShell {
|
||||
return;
|
||||
};
|
||||
let active_profile_id = snapshot.active_profile_id.clone();
|
||||
let active_profile_name = snapshot.active_profile_name.clone();
|
||||
let active_profile_kind = snapshot.active_profile_kind.clone();
|
||||
let device_name = format!("ELY · {}", snapshot.active_profile_name);
|
||||
let Some(profile_root) = default_profile_data_root() else {
|
||||
tracing::warn!(target: "ely::sync", "profile data root is unavailable");
|
||||
return;
|
||||
};
|
||||
let profile_dir = sync_profile_data_dir(
|
||||
&profile_root,
|
||||
&active_profile_id,
|
||||
&active_profile_name,
|
||||
&active_profile_kind,
|
||||
);
|
||||
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile_id);
|
||||
let bytes = match core.build_sync_snapshot_bytes() {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
|
||||
@@ -116,8 +116,6 @@ impl ElyShell {
|
||||
let profile_dir = crate::services::servo_profile_data::sync_profile_data_dir(
|
||||
&profile_root,
|
||||
&snapshot.active_profile_id,
|
||||
&snapshot.active_profile_name,
|
||||
&snapshot.active_profile_kind,
|
||||
);
|
||||
if snapshot.active_profile_name == "Default"
|
||||
&& matches!(snapshot.active_profile_kind, ProfileKind::Standard)
|
||||
@@ -237,16 +235,9 @@ 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;
|
||||
}
|
||||
let candidate = profile_root.join("default").join("servo").join("sync");
|
||||
if !bearer_token_file_present(&candidate.join("bearer.token")) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
if let Err(error) = copy_dir_recursive(&candidate, &stable_sync_dir) {
|
||||
tracing::warn!(
|
||||
@@ -256,8 +247,6 @@ fn migrate_legacy_default_sync_dir(profile_root: &Path, stable_profile_dir: &Pat
|
||||
"legacy sync profile migration failed",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()> {
|
||||
@@ -277,7 +266,7 @@ fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()>
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::bearer_token_file_present;
|
||||
use super::{bearer_token_file_present, migrate_legacy_default_sync_dir};
|
||||
|
||||
#[test]
|
||||
fn bearer_token_file_presence_requires_bytes() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -297,4 +286,33 @@ mod tests {
|
||||
std::fs::remove_dir_all(dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_default_sync_directory_is_migrated() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let legacy = directory.path().join("default/servo/sync");
|
||||
let stable = directory.path().join("profile_stable/servo");
|
||||
std::fs::create_dir_all(&legacy)?;
|
||||
std::fs::write(legacy.join("bearer.token"), "default-token")?;
|
||||
|
||||
migrate_legacy_default_sync_dir(directory.path(), &stable);
|
||||
|
||||
assert_eq!(std::fs::read_to_string(stable.join("sync/bearer.token"))?, "default-token");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_profile_bearer_is_ignored_during_default_migration()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let custom = directory.path().join("profile_custom/servo/sync");
|
||||
let stable = directory.path().join("profile_stable/servo");
|
||||
std::fs::create_dir_all(&custom)?;
|
||||
std::fs::write(custom.join("bearer.token"), "custom-token")?;
|
||||
|
||||
migrate_legacy_default_sync_dir(directory.path(), &stable);
|
||||
|
||||
assert!(!stable.join("sync/bearer.token").exists());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,23 @@ impl WebSurfaceStore {
|
||||
Self { runtime, surfaces: BTreeMap::new(), keyboard_focus: None }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn state(&self, tab_id: &TabId) -> Option<&WebSurfaceState> {
|
||||
self.surfaces.get(tab_id).and_then(|surface| surface.state.as_ref())
|
||||
}
|
||||
|
||||
pub(super) fn state_for_scope(
|
||||
&self,
|
||||
tab_id: &TabId,
|
||||
profile_id: &ely_domain::ProfileId,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
) -> Option<&WebSurfaceState> {
|
||||
self.surfaces
|
||||
.get(tab_id)
|
||||
.filter(|surface| surface.has_scope(profile_id, profile_data_mode))
|
||||
.and_then(|surface| surface.state.as_ref())
|
||||
}
|
||||
|
||||
pub(super) fn ensure_surface(
|
||||
&mut self,
|
||||
tab: &BrowserTab,
|
||||
@@ -53,23 +66,28 @@ impl WebSurfaceStore {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let native_surface =
|
||||
self.surfaces.get(tab.id()).and_then(|surface| surface.native_surface.clone());
|
||||
#[cfg(not(test))]
|
||||
let Some(native_surface) = native_surface else {
|
||||
return false;
|
||||
};
|
||||
let ensure_key = WebSurfaceEnsureKey::new(
|
||||
requested_url.clone(),
|
||||
size,
|
||||
#[cfg(test)]
|
||||
native_surface.as_ref(),
|
||||
#[cfg(not(test))]
|
||||
Some(&native_surface),
|
||||
tab.profile_id().clone(),
|
||||
profile_data_mode,
|
||||
tab.zoom_percent(),
|
||||
permissions,
|
||||
);
|
||||
if self.surfaces.get(tab.id()).is_some_and(|surface| !surface.should_ensure(&ensure_key)) {
|
||||
let scope_changed = self.surface_mut(tab.id()).reset_for_scope_change(&ensure_key);
|
||||
if scope_changed
|
||||
&& self.keyboard_focus.as_ref().is_some_and(|focus| focus.tab_id == *tab.id())
|
||||
{
|
||||
self.keyboard_focus = None;
|
||||
}
|
||||
let runtime_has_session =
|
||||
self.runtime.has_session(tab.id(), tab.profile_id(), profile_data_mode);
|
||||
if runtime_has_session
|
||||
&& self
|
||||
.surfaces
|
||||
.get(tab.id())
|
||||
.is_some_and(|surface| !surface.should_ensure(&ensure_key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Once the page has ensured at least once, defer further ensures
|
||||
@@ -89,31 +107,23 @@ impl WebSurfaceStore {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let input = self.take_pending_input(tab.id(), requested_url.as_str());
|
||||
let previous_frame =
|
||||
self.previous_ready_frame(tab.id(), requested_url.as_str(), tab.zoom_percent());
|
||||
|
||||
#[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,
|
||||
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());
|
||||
|
||||
let ensure_result =
|
||||
self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input);
|
||||
|
||||
match ensure_result {
|
||||
Ok(result) => {
|
||||
@@ -127,9 +137,35 @@ impl WebSurfaceStore {
|
||||
}
|
||||
false
|
||||
}
|
||||
Err(message) => {
|
||||
self.surface_mut(tab.id()).mark_ensured(ensure_key);
|
||||
self.surface_mut(tab.id()).state = Some(WebSurfaceState::Failed { message });
|
||||
Err(message) => self.record_ensure_failure(
|
||||
tab.id(),
|
||||
ensure_key,
|
||||
requested_url,
|
||||
previous_frame,
|
||||
message,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_ensure_failure(
|
||||
&mut self,
|
||||
tab_id: &TabId,
|
||||
ensure_key: WebSurfaceEnsureKey,
|
||||
requested_url: String,
|
||||
previous_frame: Option<WebSurfaceFrame>,
|
||||
message: String,
|
||||
) -> bool {
|
||||
self.surface_mut(tab_id).mark_ensured(ensure_key);
|
||||
match previous_frame {
|
||||
Some(previous_frame) => {
|
||||
self.surface_mut(tab_id).state = Some(WebSurfaceState::Loading {
|
||||
requested_url,
|
||||
previous_frame: Some(previous_frame),
|
||||
});
|
||||
false
|
||||
}
|
||||
None => {
|
||||
self.surface_mut(tab_id).state = Some(WebSurfaceState::Failed { message });
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -137,6 +173,13 @@ impl WebSurfaceStore {
|
||||
|
||||
pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> WebSurfaceTickResult {
|
||||
let frames = self.runtime.tick(visible_tab_ids);
|
||||
self.apply_runtime_frames(frames)
|
||||
}
|
||||
|
||||
fn apply_runtime_frames(
|
||||
&mut self,
|
||||
frames: Vec<WebSurfaceRuntimeFrame>,
|
||||
) -> WebSurfaceTickResult {
|
||||
let mut result = WebSurfaceTickResult::default();
|
||||
|
||||
for frame in frames {
|
||||
@@ -146,29 +189,12 @@ impl WebSurfaceStore {
|
||||
self.surfaces.get(&tab_id).and_then(|surface| surface.state.as_ref()),
|
||||
Some(WebSurfaceState::Ready(_))
|
||||
);
|
||||
match self.initial_display_gate_message(&tab_id, &frame, had_ready) {
|
||||
Ok(()) => {}
|
||||
Err(message) => {
|
||||
if !had_ready {
|
||||
self.surface_mut(&tab_id).state =
|
||||
Some(WebSurfaceState::Failed { message });
|
||||
result.changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if self.should_hold_initial_frame(&tab_id, &frame, had_ready) {
|
||||
if !had_ready {
|
||||
self.surface_mut(&tab_id).state = Some(WebSurfaceState::Loading {
|
||||
requested_url: frame.requested_url.clone(),
|
||||
previous_frame: None,
|
||||
});
|
||||
result.changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let metadata = self.surface_mut(&tab_id).changed_page_metadata(&tab_id, &frame);
|
||||
result.page_metadata.extend(metadata);
|
||||
result.url_changes.extend(url_change);
|
||||
if self.should_hold_initial_frame(&tab_id, &frame, had_ready) {
|
||||
continue;
|
||||
}
|
||||
if self
|
||||
.surfaces
|
||||
.get(&tab_id)
|
||||
@@ -177,7 +203,6 @@ impl WebSurfaceStore {
|
||||
self.surface_mut(&tab_id).state = Some(WebSurfaceState::Ready(*frame));
|
||||
result.changed = true;
|
||||
}
|
||||
result.url_changes.extend(url_change);
|
||||
}
|
||||
WebSurfaceRuntimeFrame::Failed { tab_id, message } => {
|
||||
let had_ready = matches!(
|
||||
@@ -303,32 +328,11 @@ impl WebSurfaceStore {
|
||||
frame: &WebSurfaceFrame,
|
||||
has_previous_frame: bool,
|
||||
) -> bool {
|
||||
!has_previous_frame
|
||||
frame.render_state() != "complete"
|
||||
&& !has_previous_frame
|
||||
&& self
|
||||
.previous_ready_frame(tab_id, frame.requested_url.as_str(), frame.zoom_percent())
|
||||
.is_none()
|
||||
&& matches!(frame.has_visible_content_for_initial_display(), Ok(false))
|
||||
}
|
||||
|
||||
fn initial_display_gate_message(
|
||||
&self,
|
||||
tab_id: &TabId,
|
||||
frame: &WebSurfaceFrame,
|
||||
has_previous_frame: bool,
|
||||
) -> Result<(), String> {
|
||||
if has_previous_frame
|
||||
|| self
|
||||
.previous_ready_frame(tab_id, frame.requested_url.as_str(), frame.zoom_percent())
|
||||
.is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
frame.has_visible_content_for_initial_display().map(|_| ()).map_err(|error| {
|
||||
format!(
|
||||
"Servo hardware surface initial content check failed for {}: {error}",
|
||||
frame.requested_url
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn surface_mut(&mut self, tab_id: &TabId) -> &mut PerTabSurface {
|
||||
@@ -349,14 +353,7 @@ impl WebSurfaceStore {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn clear_viewport_resize_debounce_for_test(&mut self, tab_id: &TabId) {
|
||||
if let Some(surface) = self.surfaces.get_mut(tab_id) {
|
||||
surface.clear_viewport_resize_debounce_for_test();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn flush_runtime_for_test(&self) {
|
||||
pub(super) fn flush_runtime_for_test(&mut self) {
|
||||
self.runtime.flush_for_test();
|
||||
}
|
||||
}
|
||||
@@ -372,3 +369,15 @@ mod tests;
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[path = "web_surface_live_site_tests.rs"]
|
||||
mod web_surface_live_site_tests;
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[path = "web_surface_profile_isolation_tests.rs"]
|
||||
mod web_surface_profile_isolation_tests;
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))]
|
||||
#[path = "web_surface_hardware_import_tests.rs"]
|
||||
mod web_surface_hardware_import_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_scope_tests.rs"]
|
||||
mod web_surface_scope_tests;
|
||||
|
||||
@@ -41,7 +41,10 @@ impl WebSurfacePollCadence {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn note_frame(&mut self, render_state: &str, now: Instant) {
|
||||
pub(super) fn note_frame(&mut self, render_state: &str, pixels_changed: bool, now: Instant) {
|
||||
if pixels_changed {
|
||||
extend_deadline(&mut self.settle_active_until, now + FRAME_SETTLE_WINDOW);
|
||||
}
|
||||
let phase = WebSurfaceRenderPhase::from_render_state(render_state);
|
||||
match phase {
|
||||
WebSurfaceRenderPhase::Created | WebSurfaceRenderPhase::Loading
|
||||
@@ -173,7 +176,7 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_frame("loading", start);
|
||||
cadence.note_frame("loading", true, start);
|
||||
cadence.note_poll_submitted(start);
|
||||
|
||||
assert!(cadence.next_poll_delay(start) <= FRAME_BUDGET_120HZ);
|
||||
@@ -209,7 +212,7 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_frame("complete", start);
|
||||
cadence.note_frame("complete", true, start);
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(300));
|
||||
|
||||
assert!(!cadence.should_poll(start + Duration::from_millis(379)));
|
||||
@@ -222,7 +225,7 @@ mod tests {
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_ensure(WebSurfaceInputKind::Idle, true, start);
|
||||
cadence.note_frame("complete", start + Duration::from_secs(1));
|
||||
cadence.note_frame("complete", true, start + Duration::from_secs(1));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(1_300));
|
||||
|
||||
assert_eq!(
|
||||
@@ -237,7 +240,7 @@ mod tests {
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_ensure(WebSurfaceInputKind::Idle, true, start);
|
||||
cadence.note_frame("loading", start + Duration::from_secs(1));
|
||||
cadence.note_frame("loading", true, start + Duration::from_secs(1));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(1_300));
|
||||
|
||||
assert_eq!(
|
||||
@@ -252,7 +255,7 @@ mod tests {
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_ensure(WebSurfaceInputKind::Scroll, false, start);
|
||||
cadence.note_frame("complete", start + Duration::from_millis(100));
|
||||
cadence.note_frame("complete", true, start + Duration::from_millis(100));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(500));
|
||||
|
||||
assert_eq!(
|
||||
@@ -267,7 +270,7 @@ mod tests {
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_ensure(WebSurfaceInputKind::Scroll, false, start);
|
||||
cadence.note_frame("loading", start + Duration::from_millis(100));
|
||||
cadence.note_frame("loading", true, start + Duration::from_millis(100));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(500));
|
||||
|
||||
assert_eq!(
|
||||
@@ -281,8 +284,8 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_frame("complete", start);
|
||||
cadence.note_frame("complete", start + Duration::from_millis(200));
|
||||
cadence.note_frame("complete", false, start);
|
||||
cadence.note_frame("complete", false, start + Duration::from_millis(200));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(260));
|
||||
|
||||
assert!(!cadence.should_poll(start + Duration::from_millis(339)));
|
||||
@@ -294,8 +297,8 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_frame("loading", start);
|
||||
cadence.note_frame("loading", start + Duration::from_millis(200));
|
||||
cadence.note_frame("loading", false, start);
|
||||
cadence.note_frame("loading", false, start + Duration::from_millis(200));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(5_010));
|
||||
|
||||
assert_eq!(
|
||||
@@ -309,10 +312,25 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_frame("sleeping", start);
|
||||
cadence.note_frame("sleeping", start + Duration::from_millis(200));
|
||||
cadence.note_frame("sleeping", false, start);
|
||||
cadence.note_frame("sleeping", false, start + Duration::from_millis(200));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(610));
|
||||
|
||||
assert_eq!(cadence.next_poll_delay(start + Duration::from_millis(610)), IDLE_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_pixel_frames_keep_animation_cadence_active() {
|
||||
let start = Instant::now();
|
||||
let mut cadence = WebSurfacePollCadence::default();
|
||||
|
||||
cadence.note_frame("complete", true, start);
|
||||
cadence.note_frame("complete", true, start + Duration::from_millis(240));
|
||||
cadence.note_poll_submitted(start + Duration::from_millis(300));
|
||||
|
||||
assert_eq!(
|
||||
cadence.next_poll_delay(start + Duration::from_millis(300)),
|
||||
ACTIVE_POLL_INTERVAL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use ely_browser_core::{BrowserCore, BrowserSnapshot};
|
||||
use ely_domain::{BrowserTab, ProfileKind, TabId, UrlText};
|
||||
use gpui::{AnyElement, Bounds, Context, NativeSurfaceHandle, Pixels, Point};
|
||||
use gpui::{AnyElement, Bounds, Context, Pixels, Point};
|
||||
|
||||
use crate::services::ProfileDataMode;
|
||||
|
||||
@@ -26,11 +26,11 @@ impl ElyShell {
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let state_entity = cx.entity().clone();
|
||||
if profile_data_mode_for(tab, snapshot).is_none() {
|
||||
let Some(profile_data_mode) = profile_data_mode_for(tab, snapshot) else {
|
||||
return render_failed_web_surface(tab, "Profile context is unavailable.", state_entity);
|
||||
}
|
||||
};
|
||||
|
||||
match self.web_surfaces.state(tab.id()) {
|
||||
match self.web_surfaces.state_for_scope(tab.id(), tab.profile_id(), profile_data_mode) {
|
||||
Some(WebSurfaceState::Ready(frame)) => {
|
||||
render_ready_web_surface(frame, tab, state_entity, bottom_corner_radius)
|
||||
}
|
||||
@@ -102,25 +102,6 @@ impl ElyShell {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record_external_web_surface(
|
||||
&mut self,
|
||||
tab_id: TabId,
|
||||
bounds: Bounds<Pixels>,
|
||||
scale_factor: f32,
|
||||
native_surface: NativeSurfaceHandle,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let viewport_changed =
|
||||
self.web_surfaces.record_viewport_size(&tab_id, bounds, scale_factor)
|
||||
== WebSurfaceInputOutcome::Applied;
|
||||
let surface_changed = self.web_surfaces.record_native_surface(&tab_id, native_surface)
|
||||
== WebSurfaceInputOutcome::Applied;
|
||||
|
||||
if viewport_changed || surface_changed {
|
||||
self.flush_external_web_surface_tick(cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn scroll_external_web_viewport(
|
||||
&mut self,
|
||||
tab_id: TabId,
|
||||
|
||||
@@ -3,20 +3,31 @@ use std::hash::Hasher;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ahash::AHasher;
|
||||
#[cfg(target_os = "macos")]
|
||||
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
|
||||
use gpui::RenderImage;
|
||||
use image::{ImageBuffer, Rgba};
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::services::iosurface_metal::HardwareSurfaceBacking;
|
||||
use crate::services::servo_live::ServoLiveFrame;
|
||||
|
||||
thread_local! {
|
||||
/// Single-slot cache for byte-identical software frames.
|
||||
/// AHash keeps the 1080p hash pass below the frame budget while
|
||||
/// avoiding repeated GPUI texture allocation for idle pages.
|
||||
static LAST_FRAME_IMAGE: RefCell<Option<(u64, Arc<RenderImage>)>> =
|
||||
static LAST_FRAME_IMAGE: RefCell<Option<CachedRenderImage>> =
|
||||
const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct CachedRenderImage {
|
||||
width: u32,
|
||||
height: u32,
|
||||
bytes_hash: u64,
|
||||
image: Arc<RenderImage>,
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
use super::web_surface_geometry::WebSurfaceSize;
|
||||
use super::web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset};
|
||||
@@ -37,6 +48,7 @@ pub(super) struct WebSurfaceFrame {
|
||||
zoom_percent: u16,
|
||||
click_point: Option<WebSurfaceClickPoint>,
|
||||
typed_text: Option<String>,
|
||||
pixels_changed: bool,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
@@ -44,6 +56,10 @@ pub(super) struct WebSurfaceFrame {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: u64,
|
||||
pub(super) image: Option<Arc<RenderImage>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) hardware_surface: Option<Arc<HardwareSurfaceBacking>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl WebSurfaceFrame {
|
||||
@@ -53,6 +69,10 @@ impl WebSurfaceFrame {
|
||||
zoom_percent: u16,
|
||||
frame: ServoLiveFrame,
|
||||
) -> Result<Self, WebSurfaceError> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let hardware_surface = frame.hardware_surface().cloned();
|
||||
#[cfg(target_os = "macos")]
|
||||
let hardware_surface_id = frame.hardware_surface_id();
|
||||
Self::from_parts(WebSurfaceFrameParts {
|
||||
requested_url,
|
||||
loaded_url: frame.loaded_url().map(str::to_string),
|
||||
@@ -67,6 +87,7 @@ impl WebSurfaceFrame {
|
||||
zoom_percent,
|
||||
click_point: None,
|
||||
typed_text: None,
|
||||
pixels_changed: frame.pixels_changed(),
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: frame.non_white_pixel_count(),
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
@@ -74,10 +95,26 @@ impl WebSurfaceFrame {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: frame.sample_hash(),
|
||||
rgba_bytes: frame.into_rgba_bytes(),
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn from_parts(parts: WebSurfaceFrameParts) -> Result<Self, WebSurfaceError> {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(surface) = parts.hardware_surface.as_ref() {
|
||||
validate_hardware_pixel_buffer(surface.pixel_buffer(), parts.width, parts.height)?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
let has_hardware_surface = parts.hardware_surface.is_some();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let has_hardware_surface = false;
|
||||
if parts.rgba_bytes.is_none() && !has_hardware_surface {
|
||||
return Err(WebSurfaceError::MissingRenderablePayload);
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
let pixel_sample = pixel_sample_for_parts(&parts)?;
|
||||
|
||||
@@ -108,6 +145,7 @@ impl WebSurfaceFrame {
|
||||
zoom_percent: parts.zoom_percent,
|
||||
click_point: parts.click_point,
|
||||
typed_text: parts.typed_text,
|
||||
pixels_changed: parts.pixels_changed,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: pixel_sample.non_white_pixel_count,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
@@ -115,6 +153,10 @@ impl WebSurfaceFrame {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: pixel_sample.sample_hash,
|
||||
image,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface: parts.hardware_surface,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id: parts.hardware_surface_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -158,7 +200,6 @@ impl WebSurfaceFrame {
|
||||
(self.css_viewport_width, self.css_viewport_height)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn render_state(&self) -> &str {
|
||||
self.render_state.as_str()
|
||||
}
|
||||
@@ -180,15 +221,26 @@ impl WebSurfaceFrame {
|
||||
self.title.as_deref()
|
||||
}
|
||||
|
||||
pub(super) fn has_same_software_render_as(&self, other: &Self) -> bool {
|
||||
pub(super) fn has_same_render_as(&self, other: &Self) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.hardware_surface.is_some() || other.hardware_surface.is_some() {
|
||||
return self.hardware_surface.is_some()
|
||||
&& other.hardware_surface.is_some()
|
||||
&& self.hardware_surface_id == other.hardware_surface_id
|
||||
&& !other.pixels_changed
|
||||
&& self.metadata_matches(other);
|
||||
}
|
||||
let image_matches = match (self.image.as_ref(), other.image.as_ref()) {
|
||||
(Some(image), Some(other_image)) => Arc::ptr_eq(image, other_image),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
image_matches
|
||||
&& self.requested_url == other.requested_url
|
||||
image_matches && self.metadata_matches(other)
|
||||
}
|
||||
|
||||
fn metadata_matches(&self, other: &Self) -> bool {
|
||||
self.requested_url == other.requested_url
|
||||
&& self.loaded_url == other.loaded_url
|
||||
&& self.title == other.title
|
||||
&& self.render_state == other.render_state
|
||||
@@ -203,17 +255,6 @@ impl WebSurfaceFrame {
|
||||
&& self.typed_text == other.typed_text
|
||||
}
|
||||
|
||||
pub(super) fn has_visible_content_for_initial_display(&self) -> Result<bool, WebSurfaceError> {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
{
|
||||
Ok(self.non_white_pixel_count > 0 && self.content_pixel_count > 0)
|
||||
}
|
||||
#[cfg(not(all(test, feature = "live-site-smoke")))]
|
||||
{
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
pub(super) fn non_white_pixel_count(&self) -> u64 {
|
||||
self.non_white_pixel_count
|
||||
@@ -229,12 +270,24 @@ impl WebSurfaceFrame {
|
||||
self.sample_hash
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
#[cfg(test)]
|
||||
pub(super) fn has_hardware_surface(&self) -> bool {
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
struct WebSurfaceFrameParts {
|
||||
requested_url: String,
|
||||
loaded_url: Option<String>,
|
||||
@@ -249,6 +302,7 @@ struct WebSurfaceFrameParts {
|
||||
zoom_percent: u16,
|
||||
click_point: Option<WebSurfaceClickPoint>,
|
||||
typed_text: Option<String>,
|
||||
pixels_changed: bool,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
non_white_pixel_count: u64,
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
@@ -256,6 +310,10 @@ struct WebSurfaceFrameParts {
|
||||
#[cfg(all(test, feature = "live-site-smoke"))]
|
||||
sample_hash: u64,
|
||||
rgba_bytes: Option<Vec<u8>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface: Option<Arc<HardwareSurfaceBacking>>,
|
||||
#[cfg(target_os = "macos")]
|
||||
hardware_surface_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -264,6 +322,42 @@ pub(super) enum WebSurfaceError {
|
||||
InvalidFrameBuffer { width: u32, height: u32 },
|
||||
#[error("servo live frame did not include renderable pixels")]
|
||||
MissingRenderablePayload,
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error(
|
||||
"servo hardware surface size {actual_width}x{actual_height} did not match frame report {expected_width}x{expected_height}"
|
||||
)]
|
||||
HardwareSurfaceSizeMismatch {
|
||||
expected_width: u32,
|
||||
expected_height: u32,
|
||||
actual_width: usize,
|
||||
actual_height: usize,
|
||||
},
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error("servo hardware surface pixel format 0x{actual:x} is unsupported; expected 32BGRA")]
|
||||
UnsupportedHardwareSurfaceFormat { actual: u32 },
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn validate_hardware_pixel_buffer(
|
||||
pixel_buffer: &CVPixelBuffer,
|
||||
expected_width: u32,
|
||||
expected_height: u32,
|
||||
) -> Result<(), WebSurfaceError> {
|
||||
let actual_width = pixel_buffer.get_width();
|
||||
let actual_height = pixel_buffer.get_height();
|
||||
if actual_width != expected_width as usize || actual_height != expected_height as usize {
|
||||
return Err(WebSurfaceError::HardwareSurfaceSizeMismatch {
|
||||
expected_width,
|
||||
expected_height,
|
||||
actual_width,
|
||||
actual_height,
|
||||
});
|
||||
}
|
||||
let actual_format = pixel_buffer.get_pixel_format();
|
||||
if actual_format != kCVPixelFormatType_32BGRA {
|
||||
return Err(WebSurfaceError::UnsupportedHardwareSurfaceFormat { actual: actual_format });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Swap byte 0 and byte 2 of every 4-byte pixel, converting Servo's
|
||||
@@ -307,16 +401,27 @@ fn resolve_render_image(
|
||||
) -> Result<Arc<RenderImage>, WebSurfaceError> {
|
||||
LAST_FRAME_IMAGE.with(|cache| -> Result<Arc<RenderImage>, WebSurfaceError> {
|
||||
let mut cache = cache.borrow_mut();
|
||||
if let Some((cached_hash, cached_image)) = cache.as_ref()
|
||||
&& *cached_hash == bytes_hash
|
||||
if let Some(cached) = cache.as_ref()
|
||||
&& cached.width == width
|
||||
&& cached.height == height
|
||||
&& cached.bytes_hash == bytes_hash
|
||||
&& cached.image.as_bytes(0) == Some(rgba_bytes.as_slice())
|
||||
{
|
||||
return Ok(cached_image.clone());
|
||||
return Ok(cached.image.clone());
|
||||
}
|
||||
|
||||
let image_buffer = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, rgba_bytes)
|
||||
.ok_or(WebSurfaceError::InvalidFrameBuffer { width, height })?;
|
||||
let new_image = Arc::new(RenderImage::new([image::Frame::new(image_buffer)]));
|
||||
*cache = Some((bytes_hash, new_image.clone()));
|
||||
*cache = Some(CachedRenderImage { width, height, bytes_hash, image: new_image.clone() });
|
||||
Ok(new_image)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_frame_cache_tests.rs"]
|
||||
mod cache_tests;
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
#[path = "web_surface_frame_hardware_tests.rs"]
|
||||
mod hardware_tests;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::resolve_render_image;
|
||||
|
||||
#[test]
|
||||
fn cache_key_includes_frame_dimensions() -> Result<(), super::WebSurfaceError> {
|
||||
let bytes = vec![0, 0, 0, 255, 255, 255, 255, 255];
|
||||
let first = resolve_render_image(1, 2, bytes.clone(), 41)?;
|
||||
let reshaped = resolve_render_image(2, 1, bytes, 41)?;
|
||||
|
||||
assert!(!Arc::ptr_eq(&first, &reshaped));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_verifies_bytes_after_hash_match() -> Result<(), super::WebSurfaceError> {
|
||||
let first = resolve_render_image(1, 1, vec![1, 2, 3, 255], 99)?;
|
||||
let collision = resolve_render_image(1, 1, vec![3, 2, 1, 255], 99)?;
|
||||
|
||||
assert!(!Arc::ptr_eq(&first, &collision));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_frame_reuses_render_image() -> Result<(), super::WebSurfaceError> {
|
||||
let bytes = vec![1, 2, 3, 255];
|
||||
let first = resolve_render_image(1, 1, bytes.clone(), 7)?;
|
||||
let repeated = resolve_render_image(1, 1, bytes, 7)?;
|
||||
|
||||
assert!(Arc::ptr_eq(&first, &repeated));
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use core_video::pixel_buffer::{CVPixelBuffer, kCVPixelFormatType_32BGRA};
|
||||
|
||||
use crate::services::servo_live::ServoLiveFrame;
|
||||
|
||||
use super::{WebSurfaceError, WebSurfaceFrame};
|
||||
use crate::shell::web_surface_geometry::WebSurfaceScrollOffset;
|
||||
|
||||
#[test]
|
||||
fn hardware_frame_keeps_pixel_buffer_without_software_image() -> Result<(), WebSurfaceError> {
|
||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
|
||||
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
|
||||
let live_frame = ServoLiveFrame::for_test_with_pixel_buffer(64, 48, 7, pixel_buffer);
|
||||
|
||||
let frame = WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
live_frame,
|
||||
)?;
|
||||
|
||||
assert!(frame.has_hardware_surface());
|
||||
assert!(frame.hardware_surface.is_some());
|
||||
assert!(frame.image.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardware_frame_identity_participates_in_equality() -> Result<(), WebSurfaceError> {
|
||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
|
||||
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
|
||||
let first = WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_pixel_buffer(64, 48, 7, pixel_buffer.clone()),
|
||||
)?;
|
||||
let second = WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_pixel_buffer(64, 48, 8, pixel_buffer),
|
||||
)?;
|
||||
|
||||
assert!(!first.has_same_render_as(&second));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_hardware_surface_with_new_pixels_triggers_repaint() -> Result<(), WebSurfaceError> {
|
||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
|
||||
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
|
||||
let first = hardware_frame(7, pixel_buffer.clone(), false)?;
|
||||
let changed = hardware_frame(7, pixel_buffer, true)?;
|
||||
|
||||
assert!(!first.has_same_render_as(&changed));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_only_hardware_frame_reuses_same_surface() -> Result<(), WebSurfaceError> {
|
||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
|
||||
.map_err(|_| WebSurfaceError::MissingRenderablePayload)?;
|
||||
let first = hardware_frame(7, pixel_buffer.clone(), true)?;
|
||||
let metadata_only = hardware_frame(7, pixel_buffer, false)?;
|
||||
|
||||
assert!(first.has_same_render_as(&metadata_only));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardware_frame_validates_reported_dimensions() -> Result<(), String> {
|
||||
let pixel_buffer = CVPixelBuffer::new(kCVPixelFormatType_32BGRA, 64, 48, None)
|
||||
.map_err(|status| format!("test pixel buffer creation failed: {status}"))?;
|
||||
let live_frame = ServoLiveFrame::for_test_with_pixel_buffer(96, 72, 7, pixel_buffer);
|
||||
|
||||
assert!(matches!(
|
||||
WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
live_frame,
|
||||
),
|
||||
Err(WebSurfaceError::HardwareSurfaceSizeMismatch { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hardware_frame(
|
||||
surface_id: u64,
|
||||
pixel_buffer: CVPixelBuffer,
|
||||
pixels_changed: bool,
|
||||
) -> Result<WebSurfaceFrame, WebSurfaceError> {
|
||||
WebSurfaceFrame::from_live_frame(
|
||||
"https://example.com/".to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_hardware_change(
|
||||
64,
|
||||
48,
|
||||
surface_id,
|
||||
pixel_buffer,
|
||||
pixels_changed,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use std::{
|
||||
env,
|
||||
error::Error,
|
||||
ffi::OsString,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use core_video::pixel_buffer::kCVPixelFormatType_32BGRA;
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
use gpui::{Bounds, SurfaceLease, point, px, size, submit_surface_to_metal_for_test};
|
||||
use objc2_io_surface::IOSurfaceRef;
|
||||
|
||||
use crate::{
|
||||
services::ProfileDataMode,
|
||||
shell::web_surface_frame::WebSurfaceFrame,
|
||||
shell::web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState},
|
||||
};
|
||||
|
||||
use super::{WebSurfaceStore, web_surface_profile_isolation_tests::ProfileProbeServer};
|
||||
|
||||
const CHILD_ENV: &str = "ELY_APP_HARDWARE_IOSURFACE_CHILD";
|
||||
const TEST_NAME: &str = concat!(
|
||||
"shell::web_surface::web_surface_hardware_import_tests::",
|
||||
"web_surface_imports_hardware_iosurface",
|
||||
);
|
||||
const PROBE_TITLE: &str = "request=empty|document=hardware|storage=hardware|cache=cache-1";
|
||||
const PROBE_WIDTH: u32 = 640;
|
||||
const PROBE_HEIGHT: u32 = 480;
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[test]
|
||||
fn web_surface_imports_hardware_iosurface() -> Result<(), Box<dyn Error>> {
|
||||
if env::var_os(CHILD_ENV).is_some() {
|
||||
return run_hardware_iosurface_probe();
|
||||
}
|
||||
|
||||
let sidecar = build_hardware_sidecar()?;
|
||||
let output = Command::new(env::current_exe()?)
|
||||
.arg(TEST_NAME)
|
||||
.arg("--exact")
|
||||
.arg("--test-threads=1")
|
||||
.env(CHILD_ENV, "1")
|
||||
.env("ELY_SERVO_RENDERING_CONTEXT", "hardware")
|
||||
.env("ELY_SERVO_SIDECAR", sidecar)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
if output.status.success() && stdout.contains("running 1 test") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"isolated hardware IOSurface test failed\nstatus: {}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
stdout,
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
fn build_hardware_sidecar() -> Result<PathBuf, Box<dyn Error>> {
|
||||
let workspace_manifest = PathBuf::from(env!("ELY_WORKSPACE_MANIFEST"));
|
||||
let workspace_root = workspace_manifest.parent().ok_or("missing workspace root")?;
|
||||
let cargo = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"));
|
||||
let output = Command::new(cargo)
|
||||
.args([
|
||||
"build",
|
||||
"--locked",
|
||||
"-p",
|
||||
"ely_servo_host",
|
||||
"--features",
|
||||
"servo-engine,hardware-render",
|
||||
"--bin",
|
||||
"ely_servo_sidecar",
|
||||
])
|
||||
.current_dir(workspace_root)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"hardware sidecar build failed\nstatus: {}\nstdout: {}\nstderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let sidecar = sidecar_binary_path(workspace_root);
|
||||
if !sidecar.is_file() {
|
||||
return Err(format!("hardware sidecar is missing at {}", sidecar.display()).into());
|
||||
}
|
||||
Ok(sidecar)
|
||||
}
|
||||
|
||||
fn sidecar_binary_path(workspace_root: &Path) -> PathBuf {
|
||||
let mut target_dir = env::var_os("CARGO_TARGET_DIR").map_or_else(
|
||||
|| workspace_root.join("target"),
|
||||
|path| {
|
||||
let path = PathBuf::from(path);
|
||||
if path.is_absolute() { path } else { workspace_root.join(path) }
|
||||
},
|
||||
);
|
||||
if let Some(target) = env::var_os("CARGO_BUILD_TARGET") {
|
||||
target_dir.push(target);
|
||||
}
|
||||
target_dir.join("debug").join(format!("ely_servo_sidecar{}", env::consts::EXE_SUFFIX))
|
||||
}
|
||||
|
||||
fn run_hardware_iosurface_probe() -> Result<(), Box<dyn Error>> {
|
||||
let mut server = ProfileProbeServer::start()?;
|
||||
let mut store = WebSurfaceStore::new();
|
||||
let url = format!("{}/probe?value=hardware", server.origin());
|
||||
let tab = BrowserTab::new(
|
||||
TabId::new(),
|
||||
SpaceId::new(),
|
||||
ProfileId::new(),
|
||||
"Hardware IOSurface probe",
|
||||
UrlText::parse(&url)?,
|
||||
);
|
||||
assert_eq!(
|
||||
store.record_viewport_size(tab.id(), probe_bounds(), 1.0),
|
||||
WebSurfaceInputOutcome::Applied,
|
||||
);
|
||||
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
|
||||
|
||||
let frame = wait_for_hardware_surface(&mut store, &tab)?;
|
||||
store.close_surface(tab.id());
|
||||
store.flush_runtime_for_test();
|
||||
assert_hardware_lease_lifecycle(frame)?;
|
||||
drop(store);
|
||||
server.finish()
|
||||
}
|
||||
|
||||
fn wait_for_hardware_surface(
|
||||
store: &mut WebSurfaceStore,
|
||||
tab: &BrowserTab,
|
||||
) -> Result<WebSurfaceFrame, Box<dyn Error>> {
|
||||
let started_at = Instant::now();
|
||||
let mut last_frame = None;
|
||||
loop {
|
||||
store.tick(std::slice::from_ref(tab.id()));
|
||||
match store.state(tab.id()) {
|
||||
Some(WebSurfaceState::Ready(frame)) => {
|
||||
last_frame = Some(format!(
|
||||
"title={:?}, state={}, hardware={}",
|
||||
frame.title(),
|
||||
frame.render_state(),
|
||||
frame.has_hardware_surface(),
|
||||
));
|
||||
if frame.title() == Some(PROBE_TITLE) && frame.has_hardware_surface() {
|
||||
return Ok(frame.clone());
|
||||
}
|
||||
}
|
||||
Some(WebSurfaceState::Failed { message }) => {
|
||||
return Err(format!("hardware IOSurface probe failed: {message}").into());
|
||||
}
|
||||
Some(WebSurfaceState::Loading { .. }) | None => {}
|
||||
}
|
||||
if started_at.elapsed() >= PROBE_TIMEOUT {
|
||||
return Err(format!(
|
||||
"timed out waiting for imported hardware IOSurface; last frame: {last_frame:?}",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_hardware_lease_lifecycle(frame: WebSurfaceFrame) -> Result<(), Box<dyn Error>> {
|
||||
let hardware_surface = frame.hardware_surface.clone().ok_or("hardware backing is missing")?;
|
||||
let pixel_buffer = hardware_surface.pixel_buffer();
|
||||
assert_eq!(pixel_buffer.get_pixel_format(), kCVPixelFormatType_32BGRA);
|
||||
|
||||
let surface_id = frame
|
||||
.hardware_surface_id_for_test()
|
||||
.and_then(|surface_id| u32::try_from(surface_id).ok())
|
||||
.ok_or("hardware IOSurface ID is invalid")?;
|
||||
let iosurface = IOSurfaceRef::lookup(surface_id).ok_or("hardware IOSurface lookup failed")?;
|
||||
let active_use_count = iosurface.use_count();
|
||||
let released_use_count = active_use_count
|
||||
.checked_sub(1)
|
||||
.ok_or("hardware IOSurface use count was not incremented")?;
|
||||
assert_eq!(Arc::strong_count(&hardware_surface), 2);
|
||||
|
||||
let weak_backing = Arc::downgrade(&hardware_surface);
|
||||
let submission = submit_surface_to_metal_for_test(
|
||||
pixel_buffer.clone(),
|
||||
SurfaceLease::from_arc(hardware_surface.clone()),
|
||||
)?;
|
||||
drop(frame);
|
||||
drop(hardware_surface);
|
||||
|
||||
assert_eq!(weak_backing.strong_count(), 1);
|
||||
assert_eq!(iosurface.use_count(), active_use_count);
|
||||
submission.finish()?;
|
||||
|
||||
assert_eq!(weak_backing.strong_count(), 0);
|
||||
assert_eq!(iosurface.use_count(), released_use_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn probe_bounds() -> Bounds<gpui::Pixels> {
|
||||
Bounds::new(point(px(0.0), px(0.0)), size(px(PROBE_WIDTH as f32), px(PROBE_HEIGHT as f32)))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use ely_domain::TabId;
|
||||
use gpui::{Bounds, NativeSurfaceHandle, Pixels, Point};
|
||||
use gpui::{Bounds, Pixels, Point};
|
||||
|
||||
use super::{
|
||||
web_surface::WebSurfaceStore,
|
||||
@@ -104,24 +104,6 @@ impl WebSurfaceStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record_native_surface(
|
||||
&mut self,
|
||||
tab_id: &TabId,
|
||||
native_surface: NativeSurfaceHandle,
|
||||
) -> WebSurfaceInputOutcome {
|
||||
let surface = self.surface_mut(tab_id);
|
||||
if surface
|
||||
.native_surface
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.identity() == native_surface.identity())
|
||||
{
|
||||
return WebSurfaceInputOutcome::NoChange;
|
||||
}
|
||||
surface.native_surface = Some(native_surface);
|
||||
surface.last_ensure_key = None;
|
||||
WebSurfaceInputOutcome::Applied
|
||||
}
|
||||
|
||||
pub(super) fn record_hover_point(
|
||||
&mut self,
|
||||
tab_id: &TabId,
|
||||
|
||||
@@ -92,6 +92,7 @@ fn run_isolated_live_site_test(
|
||||
let output = Command::new(env::current_exe()?)
|
||||
.arg(test_name)
|
||||
.env(LIVE_SITE_CHILD_ENV, "1")
|
||||
.env("ELY_SERVO_RENDERING_CONTEXT", "software")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()?;
|
||||
|
||||
@@ -124,6 +124,7 @@ mod tests {
|
||||
profile_name: "Default".to_string(),
|
||||
profile_color_hex: 0x26251e,
|
||||
profile_kind: ely_domain::ProfileKind::Standard,
|
||||
profile_id: None,
|
||||
new_tab_destination: Default::default(),
|
||||
})?;
|
||||
core.open_tab(UrlText::parse(url)?);
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
use std::{
|
||||
error::Error,
|
||||
io::{self, Read, Write},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
use gpui::{Bounds, point, px, size};
|
||||
|
||||
use crate::{
|
||||
services::ProfileDataMode,
|
||||
shell::web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState},
|
||||
};
|
||||
|
||||
use super::WebSurfaceStore;
|
||||
|
||||
const PROBE_WIDTH: u32 = 640;
|
||||
const PROBE_HEIGHT: u32 = 480;
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[test]
|
||||
fn web_surface_keeps_profile_site_data_isolated() -> Result<(), Box<dyn Error>> {
|
||||
let mut server = ProfileProbeServer::start()?;
|
||||
let mut store = WebSurfaceStore::new();
|
||||
let profile_a = ProfileId::new();
|
||||
let profile_b = ProfileId::new();
|
||||
|
||||
let tab_a_seed = render_probe(
|
||||
&mut store,
|
||||
&profile_a,
|
||||
&format!("{}/probe?value=alpha", server.origin()),
|
||||
"request=empty|document=alpha|storage=alpha|cache=cache-1",
|
||||
)?;
|
||||
let tab_b_seed = render_probe(
|
||||
&mut store,
|
||||
&profile_b,
|
||||
&format!("{}/probe?value=beta", server.origin()),
|
||||
"request=empty|document=beta|storage=beta|cache=cache-2",
|
||||
)?;
|
||||
let tab_a_inspect = render_probe(
|
||||
&mut store,
|
||||
&profile_a,
|
||||
&format!("{}/probe?inspect=alpha", server.origin()),
|
||||
"request=alpha|document=alpha|storage=alpha|cache=cache-1",
|
||||
)?;
|
||||
let tab_b_inspect = render_probe(
|
||||
&mut store,
|
||||
&profile_b,
|
||||
&format!("{}/probe?inspect=beta", server.origin()),
|
||||
"request=beta|document=beta|storage=beta|cache=cache-2",
|
||||
)?;
|
||||
|
||||
assert_eq!(server.cache_request_count(), 2);
|
||||
store.close_surface(&tab_a_seed);
|
||||
store.close_surface(&tab_a_inspect);
|
||||
store.flush_runtime_for_test();
|
||||
|
||||
let tab_a_reopened = render_probe(
|
||||
&mut store,
|
||||
&profile_a,
|
||||
&format!("{}/probe?value=gamma", server.origin()),
|
||||
"request=empty|document=gamma|storage=gamma|cache=cache-3",
|
||||
)?;
|
||||
assert_eq!(server.cache_request_count(), 3);
|
||||
|
||||
for tab_id in [tab_b_seed, tab_b_inspect, tab_a_reopened] {
|
||||
store.close_surface(&tab_id);
|
||||
}
|
||||
store.flush_runtime_for_test();
|
||||
drop(store);
|
||||
server.finish()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_probe(
|
||||
store: &mut WebSurfaceStore,
|
||||
profile_id: &ProfileId,
|
||||
url: &str,
|
||||
expected_title: &str,
|
||||
) -> Result<TabId, Box<dyn Error>> {
|
||||
let tab = BrowserTab::new(
|
||||
TabId::new(),
|
||||
SpaceId::new(),
|
||||
profile_id.clone(),
|
||||
"Profile probe",
|
||||
UrlText::parse(url)?,
|
||||
);
|
||||
assert_eq!(
|
||||
store.record_viewport_size(tab.id(), probe_bounds(), 1.0),
|
||||
WebSurfaceInputOutcome::Applied,
|
||||
);
|
||||
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
|
||||
|
||||
let started_at = Instant::now();
|
||||
let mut last_title = None;
|
||||
loop {
|
||||
store.tick(std::slice::from_ref(tab.id()));
|
||||
match store.state(tab.id()) {
|
||||
Some(WebSurfaceState::Ready(frame)) => {
|
||||
last_title = frame.title().map(str::to_string);
|
||||
if frame.title() == Some(expected_title) {
|
||||
return Ok(tab.id().clone());
|
||||
}
|
||||
}
|
||||
Some(WebSurfaceState::Failed { message }) => {
|
||||
return Err(format!("profile probe failed for {url}: {message}").into());
|
||||
}
|
||||
Some(WebSurfaceState::Loading { .. }) | None => {}
|
||||
}
|
||||
|
||||
if started_at.elapsed() >= PROBE_TIMEOUT {
|
||||
return Err(format!(
|
||||
"timed out waiting for profile probe title `{expected_title}` at {url}; last title: {last_title:?}",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_bounds() -> Bounds<gpui::Pixels> {
|
||||
Bounds::new(point(px(0.0), px(0.0)), size(px(PROBE_WIDTH as f32), px(PROBE_HEIGHT as f32)))
|
||||
}
|
||||
|
||||
pub(super) struct ProfileProbeServer {
|
||||
origin: String,
|
||||
cache_requests: Arc<AtomicUsize>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ProfileProbeServer {
|
||||
pub(super) fn start() -> Result<Self, Box<dyn Error>> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
listener.set_nonblocking(true)?;
|
||||
let address = listener.local_addr()?;
|
||||
let cache_requests = Arc::new(AtomicUsize::new(0));
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let error = Arc::new(Mutex::new(None));
|
||||
let thread_cache_requests = cache_requests.clone();
|
||||
let thread_shutdown = shutdown.clone();
|
||||
let thread_error = error.clone();
|
||||
let thread = thread::Builder::new().name("ely-profile-probe-server".to_string()).spawn(
|
||||
move || {
|
||||
serve_profile_probes(
|
||||
listener,
|
||||
thread_cache_requests,
|
||||
thread_shutdown,
|
||||
thread_error,
|
||||
);
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
origin: format!("http://{address}"),
|
||||
cache_requests,
|
||||
shutdown,
|
||||
error,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn origin(&self) -> &str {
|
||||
self.origin.as_str()
|
||||
}
|
||||
|
||||
fn cache_request_count(&self) -> usize {
|
||||
self.cache_requests.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub(super) fn finish(&mut self) -> Result<(), Box<dyn Error>> {
|
||||
self.shutdown.store(true, Ordering::SeqCst);
|
||||
if let Some(thread) = self.thread.take()
|
||||
&& thread.join().is_err()
|
||||
{
|
||||
return Err("profile probe server thread panicked".into());
|
||||
}
|
||||
let error = self.error.lock().map_err(|_| "profile probe error lock was poisoned")?.take();
|
||||
match error {
|
||||
Some(message) => Err(message.into()),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProfileProbeServer {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown.store(true, Ordering::SeqCst);
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_profile_probes(
|
||||
listener: TcpListener,
|
||||
cache_requests: Arc<AtomicUsize>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
error: Arc<Mutex<Option<String>>>,
|
||||
) {
|
||||
while !shutdown.load(Ordering::SeqCst) {
|
||||
match listener.accept() {
|
||||
Ok((stream, _)) => {
|
||||
let connection_cache_requests = cache_requests.clone();
|
||||
let connection_error = error.clone();
|
||||
_ = thread::spawn(move || {
|
||||
if let Err(server_error) = serve_connection(stream, &connection_cache_requests)
|
||||
{
|
||||
record_server_error(&connection_error, server_error.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(server_error) if server_error.kind() == io::ErrorKind::WouldBlock => {
|
||||
thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
Err(server_error) => {
|
||||
record_server_error(&error, server_error.to_string());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_server_error(error: &Arc<Mutex<Option<String>>>, message: String) {
|
||||
if let Ok(mut slot) = error.lock() {
|
||||
*slot = Some(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_connection(mut stream: TcpStream, cache_requests: &AtomicUsize) -> Result<(), io::Error> {
|
||||
stream.set_nonblocking(false)?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||
let request = read_http_request(&mut stream)?;
|
||||
let request_text = String::from_utf8_lossy(&request);
|
||||
let path = request_text
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_ascii_whitespace().nth(1))
|
||||
.unwrap_or("/");
|
||||
|
||||
if path.starts_with("/cache-token") {
|
||||
let request_number = cache_requests.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
return write_http_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
"text/plain; charset=utf-8",
|
||||
"public, max-age=3600, immutable",
|
||||
None,
|
||||
format!("cache-{request_number}").as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
if path.starts_with("/probe") {
|
||||
let request_cookie = cookie_value(&request_text, "ely_profile").unwrap_or("empty");
|
||||
let seed = query_value(path, "value");
|
||||
let set_cookie =
|
||||
seed.map(|value| format!("ely_profile={value}; Path=/; Max-Age=3600; SameSite=Lax"));
|
||||
let body = profile_probe_html(request_cookie);
|
||||
return write_http_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
"text/html; charset=utf-8",
|
||||
"no-store",
|
||||
set_cookie.as_deref(),
|
||||
body.as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
write_http_response(
|
||||
&mut stream,
|
||||
"404 Not Found",
|
||||
"text/plain; charset=utf-8",
|
||||
"no-store",
|
||||
None,
|
||||
b"missing",
|
||||
)
|
||||
}
|
||||
|
||||
fn read_http_request(stream: &mut TcpStream) -> Result<Vec<u8>, io::Error> {
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
loop {
|
||||
let read = stream.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
if request.len() > 64 * 1024 {
|
||||
return Err(io::Error::other("profile probe request exceeded 64 KiB"));
|
||||
}
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn write_http_response(
|
||||
stream: &mut TcpStream,
|
||||
status: &str,
|
||||
content_type: &str,
|
||||
cache_control: &str,
|
||||
set_cookie: Option<&str>,
|
||||
body: &[u8],
|
||||
) -> Result<(), io::Error> {
|
||||
let cookie_header =
|
||||
set_cookie.map(|cookie| format!("Set-Cookie: {cookie}\r\n")).unwrap_or_default();
|
||||
let headers = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nCache-Control: {cache_control}\r\n{cookie_header}Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len(),
|
||||
);
|
||||
stream.write_all(headers.as_bytes())?;
|
||||
stream.write_all(body)?;
|
||||
stream.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cookie_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
|
||||
request.lines().find_map(|line| {
|
||||
let (header, value) = line.split_once(':')?;
|
||||
if !header.eq_ignore_ascii_case("cookie") {
|
||||
return None;
|
||||
}
|
||||
value.split(';').find_map(|pair| {
|
||||
let (cookie_name, cookie_value) = pair.trim().split_once('=')?;
|
||||
(cookie_name == name).then_some(cookie_value)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn query_value<'a>(path: &'a str, name: &str) -> Option<&'a str> {
|
||||
let (_, query) = path.split_once('?')?;
|
||||
query.split('&').find_map(|pair| {
|
||||
let (key, value) = pair.split_once('=')?;
|
||||
(key == name).then_some(value)
|
||||
})
|
||||
}
|
||||
|
||||
fn profile_probe_html(request_cookie: &str) -> String {
|
||||
format!(
|
||||
"<!doctype html><title>loading</title><pre id=state>loading</pre><script>\
|
||||
const seed = new URLSearchParams(location.search).get('value');\
|
||||
if (seed) localStorage.setItem('ely_profile', seed);\
|
||||
const cookie = () => (document.cookie.match(/(?:^|; )ely_profile=([^;]*)/) || [,'empty'])[1];\
|
||||
fetch('/cache-token', {{cache:'force-cache'}}).then(response => response.text()).then(cache => {{\
|
||||
const title = 'request={request_cookie}|document=' + cookie() + '|storage=' + (localStorage.getItem('ely_profile') || 'empty') + '|cache=' + cache;\
|
||||
document.title = title; document.getElementById('state').textContent = title;\
|
||||
}});</script>",
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::PathBuf,
|
||||
thread::JoinHandle,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ely_domain::{BrowserTab, TabId};
|
||||
use gpui::NativeSurfaceHandle;
|
||||
|
||||
use crate::services::{
|
||||
ProfileDataMode,
|
||||
servo_live::{ServoLiveClient, ServoLiveEnsureRequest, ServoLiveSitePermission},
|
||||
servo_profile_data::cleanup_stale_transient_profile_data_dirs,
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -25,44 +25,54 @@ use super::{
|
||||
scroll_wire_fields,
|
||||
},
|
||||
web_surface_state::WebSurfacePendingInput,
|
||||
web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, WorkerResponse},
|
||||
web_surface_worker::{LiveRuntimeClient, LiveRuntimeWorker, RequestGeneration, WorkerResponse},
|
||||
};
|
||||
use cleanup::{ScopedWorker, shutdown_scoped_worker};
|
||||
|
||||
pub(super) use super::web_surface_runtime_session::{
|
||||
WebSurfaceEnsureResult, WebSurfaceRuntimeFrame, WebSurfaceUrlChange, WebSurfaceUrlChangeKind,
|
||||
};
|
||||
|
||||
pub(super) struct WebSurfaceRuntime {
|
||||
worker: Option<ScopedWorker>,
|
||||
direct_client: Option<ScopedDirectClient>,
|
||||
pending_direct_responses: Vec<WorkerResponse>,
|
||||
workers: BTreeMap<WebSurfaceRuntimeScope, ScopedWorker>,
|
||||
sessions: BTreeMap<TabId, WebSurfaceSession>,
|
||||
retry_state: BTreeMap<WebSurfaceRuntimeScope, ScopeRetryState>,
|
||||
retired_workers: Vec<JoinHandle<Result<(), String>>>,
|
||||
transient_cleanup_error: Option<String>,
|
||||
client_factory: LiveRuntimeClientFactory,
|
||||
last_generation: u64,
|
||||
}
|
||||
|
||||
const SIDECAR_RESTART_BASE_DELAY: Duration = Duration::from_millis(250);
|
||||
const SIDECAR_RESTART_MAX_DELAY: Duration = Duration::from_secs(5);
|
||||
impl WebSurfaceRuntime {
|
||||
pub(super) fn new() -> Self {
|
||||
let transient_cleanup_error =
|
||||
cleanup_stale_transient_profile_data_dirs().err().map(|error| error.to_string());
|
||||
Self {
|
||||
worker: None,
|
||||
direct_client: None,
|
||||
pending_direct_responses: Vec::new(),
|
||||
workers: BTreeMap::new(),
|
||||
sessions: BTreeMap::new(),
|
||||
retry_state: BTreeMap::new(),
|
||||
retired_workers: Vec::new(),
|
||||
transient_cleanup_error,
|
||||
client_factory: new_servo_live_client,
|
||||
last_generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn new_with_client_factory(client_factory: LiveRuntimeClientFactory) -> Self {
|
||||
Self {
|
||||
worker: None,
|
||||
direct_client: None,
|
||||
pending_direct_responses: Vec::new(),
|
||||
workers: BTreeMap::new(),
|
||||
sessions: BTreeMap::new(),
|
||||
retry_state: BTreeMap::new(),
|
||||
retired_workers: Vec::new(),
|
||||
transient_cleanup_error: None,
|
||||
client_factory,
|
||||
last_generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn ensure_tab(
|
||||
&mut self,
|
||||
tab: &BrowserTab,
|
||||
@@ -70,48 +80,11 @@ impl WebSurfaceRuntime {
|
||||
profile_data_mode: ProfileDataMode,
|
||||
permissions: &[WebSurfaceSitePermission],
|
||||
input: WebSurfacePendingInput,
|
||||
) -> Result<WebSurfaceEnsureResult, String> {
|
||||
self.ensure_tab_inner(tab, size, None, profile_data_mode, permissions, input)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_tab_with_native_surface(
|
||||
&mut self,
|
||||
tab: &BrowserTab,
|
||||
size: WebSurfaceSize,
|
||||
native_surface: NativeSurfaceHandle,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
permissions: &[WebSurfaceSitePermission],
|
||||
input: WebSurfacePendingInput,
|
||||
) -> Result<WebSurfaceEnsureResult, String> {
|
||||
self.ensure_tab_inner(
|
||||
tab,
|
||||
size,
|
||||
Some(native_surface),
|
||||
profile_data_mode,
|
||||
permissions,
|
||||
input,
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_tab_inner(
|
||||
&mut self,
|
||||
tab: &BrowserTab,
|
||||
size: WebSurfaceSize,
|
||||
native_surface: Option<NativeSurfaceHandle>,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
permissions: &[WebSurfaceSitePermission],
|
||||
input: WebSurfacePendingInput,
|
||||
) -> Result<WebSurfaceEnsureResult, String> {
|
||||
let scope = WebSurfaceRuntimeScope::new(tab.profile_id().clone(), profile_data_mode);
|
||||
let use_direct_client = native_surface.is_some();
|
||||
if use_direct_client {
|
||||
self.ensure_direct_client(scope.clone())?;
|
||||
} else {
|
||||
self.ensure_worker(scope.clone())?;
|
||||
}
|
||||
self.prepare_tab_scope(tab.id(), tab.profile_id(), profile_data_mode)?;
|
||||
|
||||
let requested_url = tab.url().as_str().to_string();
|
||||
let tab_id_string = tab.id().as_str().to_string();
|
||||
let zoom_percent = tab.zoom_percent();
|
||||
let enqueued_at = input.enqueued_at;
|
||||
let input_kind = pending_input_kind(&input);
|
||||
@@ -119,6 +92,7 @@ impl WebSurfaceRuntime {
|
||||
scroll_wire_fields(input.scroll_delta, input.scroll_point)?;
|
||||
let user_navigation_input = input_requests_history_navigation(&input);
|
||||
let next_scroll_offset = input.scroll_offset;
|
||||
let generation = self.next_request_generation();
|
||||
|
||||
let submitted_at = Instant::now();
|
||||
let started_loading = {
|
||||
@@ -134,6 +108,7 @@ impl WebSurfaceRuntime {
|
||||
session.size = size;
|
||||
session.zoom_percent = zoom_percent;
|
||||
session.scroll_offset = next_scroll_offset;
|
||||
session.generation = Some(generation);
|
||||
session.cadence.note_ensure(input_kind, started_loading, submitted_at);
|
||||
started_loading
|
||||
};
|
||||
@@ -146,7 +121,6 @@ impl WebSurfaceRuntime {
|
||||
height: size.height,
|
||||
page_zoom_percent: zoom_percent,
|
||||
device_pixel_ratio: size.device_pixel_ratio_f32(),
|
||||
native_surface,
|
||||
scroll_delta_x,
|
||||
scroll_delta_y,
|
||||
scroll_point_x,
|
||||
@@ -159,15 +133,10 @@ impl WebSurfaceRuntime {
|
||||
site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(),
|
||||
};
|
||||
|
||||
if use_direct_client {
|
||||
let response = self.ensure_direct(request, tab_id_string.clone())?;
|
||||
self.pending_direct_responses.extend(response);
|
||||
} else {
|
||||
let Some(scoped) = self.worker.as_ref() else {
|
||||
return Err("Servo worker was created but is no longer registered".to_string());
|
||||
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(request);
|
||||
}
|
||||
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());
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
session.cadence.note_poll_submitted(poll_now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
let worker = match LiveRuntimeWorker::new(move || client_factory(config_dir)) {
|
||||
Ok(worker) => worker,
|
||||
Err(error) => {
|
||||
self.note_scope_failure(&scope, now);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
self.workers.insert(scope, 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());
|
||||
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;
|
||||
};
|
||||
match scoped.client.ensure(request) {
|
||||
Ok(Some(frame)) => Ok(Some(WorkerResponse::Frame { tab_id, frame })),
|
||||
Ok(None) => Ok(None),
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
if error.is_runtime_unavailable() {
|
||||
self.remove_direct_client();
|
||||
if &previous_scope == scope {
|
||||
return;
|
||||
}
|
||||
Err(message)
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_direct(&mut self, tab_ids: Vec<String>) -> (Vec<WorkerResponse>, bool) {
|
||||
let Some(scoped) = self.direct_client.as_mut() else {
|
||||
return (Vec::new(), false);
|
||||
};
|
||||
let mut responses = Vec::new();
|
||||
let mut runtime_unavailable = false;
|
||||
for tab_id in tab_ids {
|
||||
match scoped.client.poll(tab_id.clone()) {
|
||||
Ok(Some(frame)) => responses.push(WorkerResponse::Frame { tab_id, frame }),
|
||||
Ok(None) => {}
|
||||
Err(error) if error.is_runtime_unavailable() => {
|
||||
runtime_unavailable = true;
|
||||
responses.push(WorkerResponse::RuntimeUnavailable);
|
||||
}
|
||||
Err(error) => {
|
||||
responses.push(WorkerResponse::Failed { tab_id, message: error.to_string() })
|
||||
}
|
||||
}
|
||||
}
|
||||
(responses, runtime_unavailable)
|
||||
}
|
||||
|
||||
fn collect_responses(
|
||||
&mut self,
|
||||
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;
|
||||
};
|
||||
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;
|
||||
}
|
||||
WorkerResponse::RuntimeUnavailable => runtime_unavailable = true,
|
||||
}
|
||||
}
|
||||
runtime_unavailable
|
||||
@@ -374,9 +362,15 @@ impl WebSurfaceRuntime {
|
||||
self.sessions.keys().find(|key| key.as_str() == tab_id).cloned()
|
||||
}
|
||||
|
||||
fn next_request_generation(&mut self) -> RequestGeneration {
|
||||
assert!(self.last_generation < u64::MAX, "web surface request generation exhausted");
|
||||
self.last_generation += 1;
|
||||
RequestGeneration::new(self.last_generation)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn client_count_for_test(&self) -> usize {
|
||||
usize::from(self.worker.is_some()) + usize::from(self.direct_client.is_some())
|
||||
self.workers.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -385,39 +379,72 @@ impl WebSurfaceRuntime {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn flush_for_test(&self) {
|
||||
if let Some(scoped) = self.worker.as_ref() {
|
||||
pub(super) fn flush_for_test(&mut self) {
|
||||
for scoped in self.workers.values() {
|
||||
scoped.worker.wait_until_idle();
|
||||
}
|
||||
self.reap_retired_workers(true);
|
||||
}
|
||||
|
||||
fn remove_worker(&mut self) {
|
||||
let Some(scoped) = self.worker.take() else {
|
||||
fn remove_worker(&mut self, scope: &WebSurfaceRuntimeScope) {
|
||||
let Some(scoped) = self.workers.remove(scope) else {
|
||||
return;
|
||||
};
|
||||
let ScopedWorker { worker, transient_profile_data_dir } = scoped;
|
||||
drop(worker);
|
||||
if let Some(path) = transient_profile_data_dir {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
if scoped.transient_profile_data_dir.is_some() {
|
||||
match std::thread::Builder::new()
|
||||
.name("ely-servo-profile-cleanup".to_string())
|
||||
.spawn(move || shutdown_scoped_worker(scoped))
|
||||
{
|
||||
Ok(handle) => self.retired_workers.push(handle),
|
||||
Err(error) => self.transient_cleanup_error = Some(error.to_string()),
|
||||
}
|
||||
} else {
|
||||
let _ = shutdown_scoped_worker(scoped);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_direct_client(&mut self) {
|
||||
let Some(scoped) = self.direct_client.take() else {
|
||||
return;
|
||||
};
|
||||
let ScopedDirectClient { client, transient_profile_data_dir } = scoped;
|
||||
drop(client);
|
||||
if let Some(path) = transient_profile_data_dir {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
fn reap_retired_workers(&mut self, wait_for_all: bool) {
|
||||
let mut pending = Vec::new();
|
||||
for handle in self.retired_workers.drain(..) {
|
||||
if !wait_for_all && !handle.is_finished() {
|
||||
pending.push(handle);
|
||||
continue;
|
||||
}
|
||||
match handle.join() {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => self.transient_cleanup_error = Some(error),
|
||||
Err(_) => {
|
||||
self.transient_cleanup_error =
|
||||
Some("Servo profile cleanup thread panicked".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
self.retired_workers = pending;
|
||||
}
|
||||
|
||||
fn invalidate_scope(&mut self, scope: &WebSurfaceRuntimeScope, now: Instant) {
|
||||
self.remove_worker(scope);
|
||||
self.sessions.retain(|_, session| &session.scope != scope);
|
||||
self.note_scope_failure(scope, now);
|
||||
}
|
||||
|
||||
fn note_scope_failure(&mut self, scope: &WebSurfaceRuntimeScope, now: Instant) {
|
||||
let retry = ScopeRetryState::after_failure(self.retry_state.get(scope), now);
|
||||
self.retry_state.insert(scope.clone(), retry);
|
||||
}
|
||||
|
||||
fn note_scope_success(&mut self, scope: &WebSurfaceRuntimeScope) {
|
||||
self.retry_state.remove(scope);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WebSurfaceRuntime {
|
||||
fn drop(&mut self) {
|
||||
self.remove_worker();
|
||||
self.remove_direct_client();
|
||||
let scopes = self.workers.keys().cloned().collect::<Vec<_>>();
|
||||
for scope in scopes {
|
||||
self.remove_worker(&scope);
|
||||
}
|
||||
self.reap_retired_workers(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,16 +457,38 @@ fn new_servo_live_client(config_dir: PathBuf) -> Result<Box<dyn LiveRuntimeClien
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
struct ScopedWorker {
|
||||
worker: LiveRuntimeWorker,
|
||||
transient_profile_data_dir: Option<PathBuf>,
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ScopeRetryState {
|
||||
failure_count: u32,
|
||||
retry_after: Instant,
|
||||
}
|
||||
|
||||
struct ScopedDirectClient {
|
||||
client: Box<dyn LiveRuntimeClient>,
|
||||
transient_profile_data_dir: Option<PathBuf>,
|
||||
impl ScopeRetryState {
|
||||
fn after_failure(previous: Option<&Self>, now: Instant) -> Self {
|
||||
let failure_count = previous.map_or(1, |state| state.failure_count.saturating_add(1));
|
||||
let shift = failure_count.saturating_sub(1).min(31);
|
||||
let multiplier = 1_u32 << shift;
|
||||
let delay =
|
||||
SIDECAR_RESTART_BASE_DELAY.saturating_mul(multiplier).min(SIDECAR_RESTART_MAX_DELAY);
|
||||
Self { failure_count, retry_after: now + delay }
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "web_surface_runtime_cleanup.rs"]
|
||||
mod cleanup;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_runtime_backpressure_tests.rs"]
|
||||
mod backpressure_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_runtime_cleanup_tests.rs"]
|
||||
mod cleanup_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_runtime_generation_tests.rs"]
|
||||
mod generation_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_runtime_retry_tests.rs"]
|
||||
mod retry_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "web_surface_runtime_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Mutex, mpsc},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
|
||||
use crate::services::{
|
||||
ProfileDataMode,
|
||||
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
|
||||
};
|
||||
|
||||
use super::{
|
||||
super::{
|
||||
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
|
||||
web_surface_state::WebSurfacePendingInput,
|
||||
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
|
||||
},
|
||||
WebSurfaceRuntime,
|
||||
};
|
||||
|
||||
struct SlowPollClient;
|
||||
|
||||
struct BlockingEnsureClient {
|
||||
ensure_started_tx: mpsc::Sender<()>,
|
||||
release_ensure_rx: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
struct BlockingEnsureSetup {
|
||||
ensure_started_tx: mpsc::Sender<()>,
|
||||
release_ensure_rx: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
static BLOCKING_ENSURE_SETUP: Mutex<Option<BlockingEnsureSetup>> = Mutex::new(None);
|
||||
|
||||
impl LiveRuntimeClient for SlowPollClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveRuntimeClient for BlockingEnsureClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
self.ensure_started_tx.send(()).map_err(|error| error.to_string())?;
|
||||
self.release_ensure_rx.recv().map_err(|error| error.to_string())?;
|
||||
Ok(Some(ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])))
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Err(LiveRuntimeClientError::Message("unexpected poll".to_string()))
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn slow_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
Ok(Box::new(SlowPollClient))
|
||||
}
|
||||
|
||||
fn blocking_ensure_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
let setup = BLOCKING_ENSURE_SETUP
|
||||
.lock()
|
||||
.map_err(|_| "blocking ensure setup lock was poisoned".to_string())?
|
||||
.take()
|
||||
.ok_or_else(|| "blocking ensure setup was missing".to_string())?;
|
||||
Ok(Box::new(BlockingEnsureClient {
|
||||
ensure_started_tx: setup.ensure_started_tx,
|
||||
release_ensure_rx: setup.release_ensure_rx,
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_tick_poll_preserves_in_flight_ensure_generation() -> Result<(), String> {
|
||||
let (ensure_started_tx, ensure_started_rx) = mpsc::channel();
|
||||
let (release_ensure_tx, release_ensure_rx) = mpsc::channel();
|
||||
*BLOCKING_ENSURE_SETUP
|
||||
.lock()
|
||||
.map_err(|_| "blocking ensure setup lock was poisoned".to_string())? =
|
||||
Some(BlockingEnsureSetup { ensure_started_tx, release_ensure_rx });
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(blocking_ensure_client_factory);
|
||||
let tab = web_tab("Blocked ensure")?;
|
||||
|
||||
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
ensure_started_rx.recv_timeout(Duration::from_secs(1)).map_err(|error| error.to_string())?;
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
assert!(runtime.tick(std::slice::from_ref(tab.id())).is_empty());
|
||||
|
||||
release_ensure_tx.send(()).map_err(|error| error.to_string())?;
|
||||
runtime.flush_for_test();
|
||||
let frames = runtime.tick(std::slice::from_ref(tab.id()));
|
||||
|
||||
assert!(matches!(
|
||||
frames.as_slice(),
|
||||
[super::WebSurfaceRuntimeFrame::Ready { tab_id, .. }] if tab_id == tab.id()
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_poll_advances_deadline_under_worker_backpressure() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(slow_client_factory);
|
||||
let tab = web_tab("Backpressure")?;
|
||||
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
runtime.flush_for_test();
|
||||
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
runtime.tick(std::slice::from_ref(tab.id()));
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
runtime.tick(std::slice::from_ref(tab.id()));
|
||||
|
||||
let delay = runtime
|
||||
.next_poll_delay(std::slice::from_ref(tab.id()), std::time::Instant::now())
|
||||
.ok_or_else(|| "visible tab lost its poll deadline".to_string())?;
|
||||
assert!(delay > Duration::ZERO, "backpressured poll must not schedule a zero-delay timer");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn web_tab(title: &str) -> Result<BrowserTab, String> {
|
||||
Ok(BrowserTab::new(
|
||||
TabId::new(),
|
||||
SpaceId::new(),
|
||||
ProfileId::new(),
|
||||
title,
|
||||
UrlText::parse("https://example.com/backpressure").map_err(|error| error.to_string())?,
|
||||
))
|
||||
}
|
||||
|
||||
fn surface_size() -> WebSurfaceSize {
|
||||
WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 }
|
||||
}
|
||||
|
||||
fn pending_input() -> WebSurfacePendingInput {
|
||||
WebSurfacePendingInput {
|
||||
enqueued_at: None,
|
||||
scroll_offset: WebSurfaceScrollOffset::default(),
|
||||
scroll_delta: None,
|
||||
scroll_point: None,
|
||||
click_point: None,
|
||||
hover_point: None,
|
||||
typed_text: None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
use crate::services::servo_profile_data::TransientProfileDataDir;
|
||||
|
||||
use super::LiveRuntimeWorker;
|
||||
|
||||
pub(super) struct ScopedWorker {
|
||||
pub(super) worker: LiveRuntimeWorker,
|
||||
pub(super) transient_profile_data_dir: Option<TransientProfileDataDir>,
|
||||
}
|
||||
|
||||
pub(super) fn shutdown_scoped_worker(scoped: ScopedWorker) -> Result<(), String> {
|
||||
let ScopedWorker { worker, transient_profile_data_dir } = scoped;
|
||||
drop(worker);
|
||||
let Some(directory) = transient_profile_data_dir else {
|
||||
return Ok(());
|
||||
};
|
||||
directory
|
||||
.close()
|
||||
.map_err(|error| format!("failed to remove transient Servo profile data: {error}"))
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
|
||||
use crate::{
|
||||
services::{
|
||||
ProfileDataMode,
|
||||
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
|
||||
},
|
||||
shell::{
|
||||
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
|
||||
web_surface_state::WebSurfacePendingInput,
|
||||
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
|
||||
},
|
||||
};
|
||||
|
||||
use super::WebSurfaceRuntime;
|
||||
|
||||
static FACTORY_CALLS: AtomicUsize = AtomicUsize::new(0);
|
||||
static DROP_SETUP: Mutex<Option<DropSetup>> = Mutex::new(None);
|
||||
static FACTORY_PATHS: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
|
||||
|
||||
struct DropSetup {
|
||||
started_tx: mpsc::Sender<()>,
|
||||
release_rx: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
struct BlockingDropClient {
|
||||
started_tx: mpsc::Sender<()>,
|
||||
release_rx: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
impl LiveRuntimeClient for BlockingDropClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BlockingDropClient {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.started_tx.send(());
|
||||
let _ = self.release_rx.recv();
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyClient;
|
||||
|
||||
impl LiveRuntimeClient for EmptyClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_worker_creation_uses_fresh_storage_during_previous_cleanup() -> Result<(), String> {
|
||||
FACTORY_CALLS.store(0, Ordering::SeqCst);
|
||||
FACTORY_PATHS.lock().map_err(|_| "factory paths lock was poisoned")?.clear();
|
||||
let (started_tx, started_rx) = mpsc::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
*DROP_SETUP.lock().map_err(|_| "drop setup lock was poisoned")? =
|
||||
Some(DropSetup { started_tx, release_rx });
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(cleanup_client_factory);
|
||||
let first = web_tab(ProfileId::new())?;
|
||||
let second = web_tab(ProfileId::new())?;
|
||||
|
||||
runtime.ensure_tab(&first, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
runtime.flush_for_test();
|
||||
runtime.close_tab(first.id());
|
||||
started_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.map_err(|error| format!("cleanup did not start: {error}"))?;
|
||||
|
||||
runtime.ensure_tab(
|
||||
&second,
|
||||
surface_size(),
|
||||
ProfileDataMode::Transient,
|
||||
&[],
|
||||
pending_input(),
|
||||
)?;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
while FACTORY_CALLS.load(Ordering::SeqCst) < 2 && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
assert_eq!(FACTORY_CALLS.load(Ordering::SeqCst), 2);
|
||||
let paths = FACTORY_PATHS.lock().map_err(|_| "factory paths lock was poisoned")?.clone();
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert_ne!(paths[0], paths[1]);
|
||||
assert!(paths.iter().all(|path| path.is_dir()));
|
||||
|
||||
release_tx.send(()).map_err(|error| error.to_string())?;
|
||||
runtime.flush_for_test();
|
||||
assert!(!paths[0].exists());
|
||||
assert!(paths[1].is_dir());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_client_factory(
|
||||
config_dir: std::path::PathBuf,
|
||||
) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
FACTORY_PATHS
|
||||
.lock()
|
||||
.map_err(|_| "factory paths lock was poisoned".to_string())?
|
||||
.push(config_dir);
|
||||
if FACTORY_CALLS.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
let setup = DROP_SETUP
|
||||
.lock()
|
||||
.map_err(|_| "drop setup lock was poisoned".to_string())?
|
||||
.take()
|
||||
.ok_or_else(|| "drop setup was missing".to_string())?;
|
||||
Ok(Box::new(BlockingDropClient {
|
||||
started_tx: setup.started_tx,
|
||||
release_rx: setup.release_rx,
|
||||
}))
|
||||
} else {
|
||||
Ok(Box::new(EmptyClient))
|
||||
}
|
||||
}
|
||||
|
||||
fn web_tab(profile_id: ProfileId) -> Result<BrowserTab, String> {
|
||||
Ok(BrowserTab::new(
|
||||
TabId::new(),
|
||||
SpaceId::new(),
|
||||
profile_id,
|
||||
"Private",
|
||||
UrlText::parse("https://example.com/private").map_err(|error| error.to_string())?,
|
||||
))
|
||||
}
|
||||
|
||||
fn surface_size() -> WebSurfaceSize {
|
||||
WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 }
|
||||
}
|
||||
|
||||
fn pending_input() -> WebSurfacePendingInput {
|
||||
WebSurfacePendingInput {
|
||||
enqueued_at: None,
|
||||
scroll_offset: WebSurfaceScrollOffset::default(),
|
||||
scroll_delta: None,
|
||||
scroll_point: None,
|
||||
click_point: None,
|
||||
hover_point: None,
|
||||
typed_text: None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
|
||||
use crate::services::{
|
||||
ProfileDataMode,
|
||||
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
|
||||
};
|
||||
|
||||
use super::{
|
||||
super::{
|
||||
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
|
||||
web_surface_state::WebSurfacePendingInput,
|
||||
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError, WorkerResponse},
|
||||
},
|
||||
WebSurfaceRuntime, WebSurfaceRuntimeFrame, WebSurfaceRuntimeScope,
|
||||
};
|
||||
|
||||
struct EmptyClient;
|
||||
|
||||
static FAILING_FACTORY_CALLS: AtomicUsize = AtomicUsize::new(0);
|
||||
static FAILING_FACTORY_SHUTDOWNS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
impl LiveRuntimeClient for EmptyClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct CloseRecordingClient;
|
||||
|
||||
impl LiveRuntimeClient for CloseRecordingClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
_request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CloseRecordingClient {
|
||||
fn drop(&mut self) {
|
||||
FAILING_FACTORY_SHUTDOWNS.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_frame_from_a_is_discarded_after_a_to_b() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
|
||||
let tab_id = TabId::new();
|
||||
let profile_a = ProfileId::new();
|
||||
let profile_b = ProfileId::new();
|
||||
let scope_a = scope(&profile_a);
|
||||
let tab_a = web_tab(tab_id.clone(), profile_a, "https://example.com/a")?;
|
||||
let tab_b = web_tab(tab_id.clone(), profile_b, "https://example.com/b")?;
|
||||
|
||||
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
let generation_a = runtime
|
||||
.sessions
|
||||
.get(&tab_id)
|
||||
.and_then(|session| session.generation)
|
||||
.ok_or_else(|| "profile A ensure generation was missing".to_string())?;
|
||||
runtime.ensure_tab(&tab_b, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
let generation_b = runtime
|
||||
.sessions
|
||||
.get(&tab_id)
|
||||
.and_then(|session| session.generation)
|
||||
.ok_or_else(|| "profile B ensure generation was missing".to_string())?;
|
||||
|
||||
let mut frames = Vec::new();
|
||||
runtime.collect_responses(
|
||||
&scope_a,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation: generation_a,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: live_frame(),
|
||||
}],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
|
||||
assert!(generation_a < generation_b);
|
||||
assert!(frames.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_blocks_aba_frames_and_failures() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
|
||||
let tab_id = TabId::new();
|
||||
let profile_a = ProfileId::new();
|
||||
let profile_b = ProfileId::new();
|
||||
let scope_a = scope(&profile_a);
|
||||
let tab_a = web_tab(tab_id.clone(), profile_a, "https://example.com/a")?;
|
||||
let tab_b = web_tab(tab_id.clone(), profile_b, "https://example.com/b")?;
|
||||
|
||||
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
let first_a = current_generation(&runtime, &tab_id)?;
|
||||
runtime.ensure_tab(&tab_b, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
let generation_b = current_generation(&runtime, &tab_id)?;
|
||||
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
let second_a = current_generation(&runtime, &tab_id)?;
|
||||
|
||||
let mut frames = Vec::new();
|
||||
runtime.collect_responses(
|
||||
&scope_a,
|
||||
vec![
|
||||
WorkerResponse::Frame {
|
||||
generation: first_a,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: live_frame(),
|
||||
},
|
||||
WorkerResponse::Failed {
|
||||
generation: first_a,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
message: "stale failure".to_string(),
|
||||
},
|
||||
],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
assert!(frames.is_empty());
|
||||
|
||||
runtime.collect_responses(
|
||||
&scope_a,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation: second_a,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: live_frame(),
|
||||
}],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
|
||||
assert!(first_a < generation_b && generation_b < second_a);
|
||||
assert!(matches!(
|
||||
frames.as_slice(),
|
||||
[WebSurfaceRuntimeFrame::Ready { tab_id: ready_tab_id, .. }] if ready_tab_id == &tab_id
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_creation_failure_invalidates_previous_session_generation() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
|
||||
let tab_id = TabId::new();
|
||||
let profile_a = ProfileId::new();
|
||||
let profile_b = ProfileId::new();
|
||||
let scope_a = scope(&profile_a);
|
||||
let scope_b = scope(&profile_b);
|
||||
let tab_a = web_tab(tab_id.clone(), profile_a, "https://example.com/a")?;
|
||||
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
let generation_a = current_generation(&runtime, &tab_id)?;
|
||||
runtime.retry_state.insert(
|
||||
scope_b,
|
||||
super::ScopeRetryState {
|
||||
failure_count: 1,
|
||||
retry_after: Instant::now() + std::time::Duration::from_secs(1),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(runtime.prepare_tab_scope(&tab_id, &profile_b, ProfileDataMode::Transient).is_err());
|
||||
assert!(!runtime.sessions.contains_key(&tab_id));
|
||||
|
||||
let mut frames = Vec::new();
|
||||
runtime.collect_responses(
|
||||
&scope_a,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation: generation_a,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: live_frame(),
|
||||
}],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
assert!(frames.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_frame_preserves_scope_backoff_until_current_frame() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(empty_client_factory);
|
||||
let tab_id = TabId::new();
|
||||
let profile_id = ProfileId::new();
|
||||
let scope = scope(&profile_id);
|
||||
let tab = web_tab(tab_id.clone(), profile_id, "https://example.com/current")?;
|
||||
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
runtime.flush_for_test();
|
||||
let current = current_generation(&runtime, &tab_id)?;
|
||||
runtime.note_scope_failure(&scope, Instant::now());
|
||||
|
||||
let mut frames = Vec::new();
|
||||
runtime.collect_responses(
|
||||
&scope,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation: super::super::web_surface_worker::RequestGeneration::new(0),
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: live_frame(),
|
||||
}],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
assert!(frames.is_empty());
|
||||
assert!(runtime.retry_state.contains_key(&scope));
|
||||
|
||||
runtime.collect_responses(
|
||||
&scope,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation: current,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: live_frame(),
|
||||
}],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
assert!(matches!(frames.as_slice(), [WebSurfaceRuntimeFrame::Ready { .. }]));
|
||||
assert!(!runtime.retry_state.contains_key(&scope));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_scope_shuts_down_when_new_worker_factory_fails_async() -> Result<(), String> {
|
||||
FAILING_FACTORY_CALLS.store(0, Ordering::SeqCst);
|
||||
FAILING_FACTORY_SHUTDOWNS.store(0, Ordering::SeqCst);
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fail_second_client_factory);
|
||||
let tab_id = TabId::new();
|
||||
let tab_a = web_tab(tab_id.clone(), ProfileId::new(), "https://example.com/a")?;
|
||||
let tab_b = web_tab(tab_id, ProfileId::new(), "https://example.com/b")?;
|
||||
|
||||
runtime.ensure_tab(&tab_a, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
runtime.flush_for_test();
|
||||
runtime.ensure_tab(&tab_b, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
runtime.flush_for_test();
|
||||
let frames = runtime.tick(std::slice::from_ref(tab_b.id()));
|
||||
|
||||
assert_eq!(FAILING_FACTORY_SHUTDOWNS.load(Ordering::SeqCst), 1);
|
||||
assert!(matches!(
|
||||
frames.as_slice(),
|
||||
[WebSurfaceRuntimeFrame::Failed { message, .. }]
|
||||
if message == "injected client creation failure"
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_generation(
|
||||
runtime: &WebSurfaceRuntime,
|
||||
tab_id: &TabId,
|
||||
) -> Result<super::super::web_surface_worker::RequestGeneration, String> {
|
||||
runtime
|
||||
.sessions
|
||||
.get(tab_id)
|
||||
.and_then(|session| session.generation)
|
||||
.ok_or_else(|| "session generation was missing".to_string())
|
||||
}
|
||||
|
||||
fn empty_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
Ok(Box::new(EmptyClient))
|
||||
}
|
||||
|
||||
fn fail_second_client_factory(_path: PathBuf) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
if FAILING_FACTORY_CALLS.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
Ok(Box::new(CloseRecordingClient))
|
||||
} else {
|
||||
Err("injected client creation failure".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn scope(profile_id: &ProfileId) -> WebSurfaceRuntimeScope {
|
||||
WebSurfaceRuntimeScope::new(profile_id.clone(), ProfileDataMode::Transient)
|
||||
}
|
||||
|
||||
fn web_tab(tab_id: TabId, profile_id: ProfileId, url: &str) -> Result<BrowserTab, String> {
|
||||
Ok(BrowserTab::new(
|
||||
tab_id,
|
||||
SpaceId::new(),
|
||||
profile_id,
|
||||
"Web",
|
||||
UrlText::parse(url).map_err(|error| error.to_string())?,
|
||||
))
|
||||
}
|
||||
|
||||
fn surface_size() -> WebSurfaceSize {
|
||||
WebSurfaceSize { width: 640, height: 480, device_pixel_ratio_percent: 100 }
|
||||
}
|
||||
|
||||
fn pending_input() -> WebSurfacePendingInput {
|
||||
WebSurfacePendingInput {
|
||||
enqueued_at: None,
|
||||
scroll_offset: WebSurfaceScrollOffset::default(),
|
||||
scroll_delta: None,
|
||||
scroll_point: None,
|
||||
click_point: None,
|
||||
hover_point: None,
|
||||
typed_text: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn live_frame() -> ServoLiveFrame {
|
||||
ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255])
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ely_domain::{ProfileId, TabId};
|
||||
|
||||
use crate::services::{ProfileDataMode, servo_live::ServoLiveFrame};
|
||||
|
||||
use super::{
|
||||
super::{
|
||||
web_surface_runtime_session::session_for_scope,
|
||||
web_surface_worker::{RequestGeneration, WorkerResponse},
|
||||
},
|
||||
SIDECAR_RESTART_BASE_DELAY, SIDECAR_RESTART_MAX_DELAY, ScopeRetryState, WebSurfaceRuntime,
|
||||
WebSurfaceRuntimeScope,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn retry_delay_grows_exponentially_and_caps() {
|
||||
let now = Instant::now();
|
||||
let expected = [
|
||||
Duration::from_millis(250),
|
||||
Duration::from_millis(500),
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(2),
|
||||
Duration::from_secs(4),
|
||||
SIDECAR_RESTART_MAX_DELAY,
|
||||
SIDECAR_RESTART_MAX_DELAY,
|
||||
];
|
||||
let mut state = None;
|
||||
|
||||
for delay in expected {
|
||||
let next = ScopeRetryState::after_failure(state.as_ref(), now);
|
||||
assert_eq!(next.retry_after.duration_since(now), delay);
|
||||
state = Some(next);
|
||||
}
|
||||
assert_eq!(SIDECAR_RESTART_BASE_DELAY, Duration::from_millis(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_state_is_scoped() {
|
||||
let mut runtime = WebSurfaceRuntime::new();
|
||||
let scope_a = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
|
||||
let scope_b = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
|
||||
let now = Instant::now();
|
||||
|
||||
runtime.note_scope_failure(&scope_a, now);
|
||||
runtime.note_scope_failure(&scope_a, now);
|
||||
runtime.note_scope_failure(&scope_b, now);
|
||||
|
||||
assert_eq!(runtime.retry_state.get(&scope_a).map(|state| state.failure_count), Some(2));
|
||||
assert_eq!(runtime.retry_state.get(&scope_b).map(|state| state.failure_count), Some(1));
|
||||
assert_eq!(runtime.retry_state.get(&scope_b).map(|state| state.failure_count), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_frame_resets_scope_retry_state() {
|
||||
let mut runtime = WebSurfaceRuntime::new();
|
||||
let scope = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
|
||||
let tab_id = TabId::new();
|
||||
let generation = RequestGeneration::new(1);
|
||||
session_for_scope(&mut runtime.sessions, &tab_id, scope.clone()).generation = Some(generation);
|
||||
runtime.note_scope_failure(&scope, Instant::now());
|
||||
let mut frames = Vec::new();
|
||||
|
||||
runtime.collect_responses(
|
||||
&scope,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation,
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255]),
|
||||
}],
|
||||
Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
|
||||
assert!(!runtime.retry_state.contains_key(&scope));
|
||||
assert_eq!(frames.len(), 1);
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
use std::{collections::BTreeMap, fs, path::PathBuf};
|
||||
|
||||
use ely_domain::{ProfileId, TabId};
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
use crate::services::{
|
||||
ProfileDataMode,
|
||||
servo_profile_data::{default_profile_data_root, profile_data_dir, transient_profile_data_dir},
|
||||
servo_profile_data::{
|
||||
TransientProfileDataDir, create_profile_data_dir, default_profile_data_root,
|
||||
transient_profile_data_dir,
|
||||
},
|
||||
};
|
||||
use ely_domain::{ProfileId, TabId};
|
||||
|
||||
use super::{
|
||||
web_surface_cadence::WebSurfacePollCadence,
|
||||
web_surface_frame::WebSurfaceFrame,
|
||||
web_surface_geometry::{WebSurfaceScrollOffset, WebSurfaceSize},
|
||||
web_surface_worker::RequestGeneration,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
@@ -23,6 +26,10 @@ impl WebSurfaceRuntimeScope {
|
||||
pub(super) fn new(profile_id: ProfileId, profile_data_mode: ProfileDataMode) -> Self {
|
||||
Self { profile_id, profile_data_mode }
|
||||
}
|
||||
|
||||
pub(super) fn is_transient(&self) -> bool {
|
||||
self.profile_data_mode == ProfileDataMode::Transient
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -33,6 +40,7 @@ pub(super) struct WebSurfaceSession {
|
||||
pub(super) zoom_percent: u16,
|
||||
pub(super) scroll_offset: WebSurfaceScrollOffset,
|
||||
pub(super) pending_user_navigation: bool,
|
||||
pub(super) generation: Option<RequestGeneration>,
|
||||
pub(super) cadence: WebSurfacePollCadence,
|
||||
}
|
||||
|
||||
@@ -45,6 +53,7 @@ impl WebSurfaceSession {
|
||||
zoom_percent: 0,
|
||||
scroll_offset: WebSurfaceScrollOffset::default(),
|
||||
pending_user_navigation: false,
|
||||
generation: None,
|
||||
cadence: WebSurfacePollCadence::default(),
|
||||
}
|
||||
}
|
||||
@@ -110,20 +119,20 @@ pub(super) enum WebSurfaceUrlChangeKind {
|
||||
|
||||
pub(super) fn config_dir_for_scope(
|
||||
scope: &WebSurfaceRuntimeScope,
|
||||
) -> Result<(PathBuf, Option<PathBuf>), String> {
|
||||
) -> Result<(PathBuf, Option<TransientProfileDataDir>), String> {
|
||||
match scope.profile_data_mode {
|
||||
ProfileDataMode::Persistent => {
|
||||
let root = default_profile_data_root()
|
||||
.ok_or_else(|| "Profile data root is unavailable".to_string())?;
|
||||
let config_dir = profile_data_dir(&root, &scope.profile_id);
|
||||
fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?;
|
||||
let config_dir = create_profile_data_dir(&root, &scope.profile_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok((config_dir, None))
|
||||
}
|
||||
ProfileDataMode::Transient => {
|
||||
let config_dir =
|
||||
let transient_dir =
|
||||
transient_profile_data_dir(&scope.profile_id).map_err(|error| error.to_string())?;
|
||||
fs::create_dir_all(&config_dir).map_err(|error| error.to_string())?;
|
||||
Ok((config_dir.clone(), Some(config_dir)))
|
||||
let config_dir = transient_dir.path().to_path_buf();
|
||||
Ok((config_dir, Some(transient_dir)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
sync::{
|
||||
Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
@@ -21,15 +24,28 @@ use super::*;
|
||||
|
||||
use crate::services::servo_live::{ServoLiveEnsureRequest, ServoLiveFrame};
|
||||
|
||||
static FAKE_CLOSE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static FAKE_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static IDLE_SKIP_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static RECOVERY_FACTORY_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static FAILING_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static REPEATED_FRAME_ENSURE_COUNT: AtomicUsize = AtomicUsize::new(0);
|
||||
static CLOSE_CONFIG_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
|
||||
|
||||
#[test]
|
||||
fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), String> {
|
||||
fn transient_scope_owns_removable_servo_storage() -> Result<(), String> {
|
||||
let scope = WebSurfaceRuntimeScope::new(ProfileId::new(), ProfileDataMode::Transient);
|
||||
let (config_dir, guard) = config_dir_for_scope(&scope)?;
|
||||
let Some(guard) = guard else {
|
||||
return Err("transient scope did not return a storage guard".to_string());
|
||||
};
|
||||
assert_eq!(guard.path(), config_dir);
|
||||
guard.close().map_err(|error| error.to_string())?;
|
||||
assert!(!config_dir.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_routes_profile_scopes_to_distinct_clients() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fake_client_factory);
|
||||
let first_profile = ProfileId::new();
|
||||
let second_profile = ProfileId::new();
|
||||
@@ -60,7 +76,7 @@ fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), Stri
|
||||
|
||||
runtime.flush_for_test();
|
||||
|
||||
assert_eq!(runtime.client_count_for_test(), 1);
|
||||
assert_eq!(runtime.client_count_for_test(), 2);
|
||||
assert_eq!(
|
||||
runtime.session_scope_for_test(first_tab.id()),
|
||||
Some(&WebSurfaceRuntimeScope::new(first_profile, ProfileDataMode::Transient)),
|
||||
@@ -73,22 +89,64 @@ fn runtime_shares_direct_servo_client_across_profile_scopes() -> Result<(), Stri
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_tab_removes_session_and_closes_client() -> Result<(), String> {
|
||||
let before = FAKE_CLOSE_COUNT.load(Ordering::SeqCst);
|
||||
fn delayed_frame_from_previous_profile_scope_is_ignored() -> Result<(), String> {
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(fake_client_factory);
|
||||
let tab = web_tab(TabId::new(), ProfileId::new(), "https://example.com/close")?;
|
||||
let tab_id = TabId::new();
|
||||
let first_profile = ProfileId::new();
|
||||
let second_profile = ProfileId::new();
|
||||
let first_scope =
|
||||
WebSurfaceRuntimeScope::new(first_profile.clone(), ProfileDataMode::Transient);
|
||||
let second_scope = WebSurfaceRuntimeScope::new(second_profile, ProfileDataMode::Transient);
|
||||
let first_tab = web_tab(tab_id.clone(), first_profile, "https://example.com/shared")?;
|
||||
|
||||
runtime.ensure_tab(
|
||||
&first_tab,
|
||||
surface_size(),
|
||||
ProfileDataMode::Transient,
|
||||
&[],
|
||||
pending_input(),
|
||||
)?;
|
||||
runtime.flush_for_test();
|
||||
session_for_scope(&mut runtime.sessions, &tab_id, second_scope);
|
||||
|
||||
let mut frames = Vec::new();
|
||||
let unavailable = runtime.collect_responses(
|
||||
&first_scope,
|
||||
vec![WorkerResponse::Frame {
|
||||
generation: RequestGeneration::new(1),
|
||||
tab_id: tab_id.as_str().to_string(),
|
||||
frame: repeated_live_frame(),
|
||||
}],
|
||||
std::time::Instant::now(),
|
||||
&mut frames,
|
||||
);
|
||||
|
||||
assert!(!unavailable);
|
||||
assert!(frames.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_last_transient_tab_removes_session_worker_and_profile_data() -> Result<(), String> {
|
||||
*CLOSE_CONFIG_DIR.lock().map_err(|_| "close config lock was poisoned")? = None;
|
||||
let mut runtime = WebSurfaceRuntime::new_with_client_factory(close_client_factory);
|
||||
let tab = web_tab(TabId::new(), ProfileId::new(), "https://example.com/close")?;
|
||||
runtime.ensure_tab(&tab, surface_size(), ProfileDataMode::Transient, &[], pending_input())?;
|
||||
runtime.flush_for_test();
|
||||
let config_dir = CLOSE_CONFIG_DIR
|
||||
.lock()
|
||||
.map_err(|_| "close config lock was poisoned")?
|
||||
.clone()
|
||||
.ok_or_else(|| "close client config directory was missing".to_string())?;
|
||||
assert!(config_dir.is_dir());
|
||||
runtime.close_tab(tab.id());
|
||||
runtime.flush_for_test();
|
||||
|
||||
assert_eq!(runtime.session_scope_for_test(tab.id()), None);
|
||||
assert_eq!(FAKE_CLOSE_COUNT.load(Ordering::SeqCst), before + 1);
|
||||
|
||||
assert_eq!(runtime.client_count_for_test(), 0);
|
||||
assert!(!config_dir.exists());
|
||||
runtime.close_tab(tab.id());
|
||||
runtime.flush_for_test();
|
||||
assert_eq!(FAKE_CLOSE_COUNT.load(Ordering::SeqCst), before + 1);
|
||||
assert_eq!(runtime.client_count_for_test(), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -207,6 +265,7 @@ fn runtime_unavailable_removes_dead_runtime_client() -> Result<(), String> {
|
||||
assert_eq!(runtime.client_count_for_test(), 0);
|
||||
|
||||
let next_tab = web_tab(TabId::new(), profile, "https://example.com/next")?;
|
||||
std::thread::sleep(super::SIDECAR_RESTART_BASE_DELAY + Duration::from_millis(10));
|
||||
runtime.ensure_tab(
|
||||
&next_tab,
|
||||
surface_size(),
|
||||
@@ -222,7 +281,7 @@ fn runtime_unavailable_removes_dead_runtime_client() -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_surface_ensure_waits_for_a_new_key_before_retrying() -> Result<(), String> {
|
||||
fn failed_surface_ensure_retries_same_key_after_cooldown() -> Result<(), String> {
|
||||
FAILING_ENSURE_COUNT.store(0, Ordering::SeqCst);
|
||||
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
|
||||
failing_client_factory,
|
||||
@@ -239,21 +298,7 @@ fn failed_surface_ensure_waits_for_a_new_key_before_retrying() -> Result<(), Str
|
||||
assert!(tick.changed, "the failing client must surface a state change via tick");
|
||||
assert_eq!(FAILING_ENSURE_COUNT.load(Ordering::SeqCst), 1);
|
||||
|
||||
assert!(!store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
|
||||
store.flush_runtime_for_test();
|
||||
let _ = store.tick(&[tab.id().clone()]);
|
||||
assert_eq!(FAILING_ENSURE_COUNT.load(Ordering::SeqCst), 1);
|
||||
|
||||
assert_eq!(
|
||||
store.record_viewport_size(tab.id(), resized_viewport_bounds(), 1.0),
|
||||
WebSurfaceInputOutcome::Applied,
|
||||
);
|
||||
// `record_viewport_size` stamps the viewport-resize debounce on a
|
||||
// genuine size transition; in production the poll cadence retries
|
||||
// `ensure_surface` once the gesture settles, but a synchronous
|
||||
// unit test can't advance the clock — clear the timestamp so we
|
||||
// exercise the retry-on-new-key business rule in isolation.
|
||||
store.clear_viewport_resize_debounce_for_test(tab.id());
|
||||
std::thread::sleep(super::SIDECAR_RESTART_BASE_DELAY + Duration::from_millis(10));
|
||||
assert!(store.ensure_surface(&tab, ProfileDataMode::Transient, &[]));
|
||||
store.flush_runtime_for_test();
|
||||
let _ = store.tick(&[tab.id().clone()]);
|
||||
@@ -305,7 +350,6 @@ impl LiveRuntimeClient for FakeLiveRuntimeClient {
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
FAKE_CLOSE_COUNT.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -387,6 +431,14 @@ fn fake_client_factory(
|
||||
Ok(Box::new(FakeLiveRuntimeClient))
|
||||
}
|
||||
|
||||
fn close_client_factory(
|
||||
config_dir: std::path::PathBuf,
|
||||
) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
*CLOSE_CONFIG_DIR.lock().map_err(|_| "close config lock was poisoned".to_string())? =
|
||||
Some(config_dir);
|
||||
Ok(Box::new(FakeLiveRuntimeClient))
|
||||
}
|
||||
|
||||
fn idle_skip_client_factory(
|
||||
_config_dir: std::path::PathBuf,
|
||||
) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
@@ -435,13 +487,6 @@ fn viewport_bounds() -> gpui::Bounds<gpui::Pixels> {
|
||||
)
|
||||
}
|
||||
|
||||
fn resized_viewport_bounds() -> gpui::Bounds<gpui::Pixels> {
|
||||
gpui::Bounds::new(
|
||||
gpui::point(gpui::px(0.0), gpui::px(0.0)),
|
||||
gpui::size(gpui::px(720.0), gpui::px(480.0)),
|
||||
)
|
||||
}
|
||||
|
||||
fn pending_input() -> WebSurfacePendingInput {
|
||||
WebSurfacePendingInput {
|
||||
enqueued_at: None,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
use std::{error::Error, sync::Mutex};
|
||||
|
||||
use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText};
|
||||
use gpui::{Bounds, point, px, size};
|
||||
|
||||
use crate::services::{
|
||||
ProfileDataMode,
|
||||
servo_live::{ServoLiveEnsureRequest, ServoLiveFrame},
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
web_surface_frame::WebSurfaceFrame,
|
||||
web_surface_geometry::WebSurfaceScrollOffset,
|
||||
web_surface_runtime::{
|
||||
WebSurfaceRuntime, WebSurfaceRuntimeFrame, WebSurfaceUrlChange, WebSurfaceUrlChangeKind,
|
||||
},
|
||||
web_surface_state::{WebSurfaceInputOutcome, WebSurfaceState},
|
||||
web_surface_worker::{LiveRuntimeClient, LiveRuntimeClientError},
|
||||
};
|
||||
use super::WebSurfaceStore;
|
||||
|
||||
static ENSURES: Mutex<Vec<RecordedEnsure>> = Mutex::new(Vec::new());
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RecordedEnsure {
|
||||
profile_id: String,
|
||||
had_input: bool,
|
||||
}
|
||||
|
||||
struct RecordingClient;
|
||||
|
||||
impl LiveRuntimeClient for RecordingClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
request: ServoLiveEnsureRequest,
|
||||
) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
ENSURES.lock().map_err(|_| "ensure recorder lock was poisoned".to_string())?.push(
|
||||
RecordedEnsure {
|
||||
profile_id: request.profile_id,
|
||||
had_input: request.scroll_delta_x != 0
|
||||
|| request.scroll_delta_y != 0
|
||||
|| request.click_x.is_some()
|
||||
|| request.click_y.is_some()
|
||||
|| request.hover_x.is_some()
|
||||
|| request.hover_y.is_some()
|
||||
|| request.typed_text.is_some(),
|
||||
},
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn poll(&mut self, _tab_id: String) -> Result<Option<ServoLiveFrame>, LiveRuntimeClientError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn recording_client_factory(
|
||||
_config_dir: std::path::PathBuf,
|
||||
) -> Result<Box<dyn LiveRuntimeClient>, String> {
|
||||
Ok(Box::new(RecordingClient))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_scope_change_clears_pixels_input_and_focus_before_ensure() -> Result<(), Box<dyn Error>>
|
||||
{
|
||||
ENSURES.lock().map_err(|_| "ensure recorder lock was poisoned")?.clear();
|
||||
let runtime = WebSurfaceRuntime::new_with_client_factory(recording_client_factory);
|
||||
let mut store = WebSurfaceStore::new_with_runtime(runtime);
|
||||
let tab_id = TabId::new();
|
||||
let url = UrlText::parse("https://example.com/shared")?;
|
||||
let profile_a = ProfileId::new();
|
||||
let profile_b = ProfileId::new();
|
||||
let tab_a =
|
||||
BrowserTab::new(tab_id.clone(), SpaceId::new(), profile_a.clone(), "Shared", url.clone());
|
||||
let tab_b = BrowserTab::new(tab_id, SpaceId::new(), profile_b.clone(), "Shared", url);
|
||||
|
||||
assert_eq!(
|
||||
store.record_viewport_size(tab_a.id(), viewport_bounds(), 1.0),
|
||||
WebSurfaceInputOutcome::Applied,
|
||||
);
|
||||
assert!(store.ensure_surface(&tab_a, ProfileDataMode::Transient, &[]));
|
||||
store.flush_runtime_for_test();
|
||||
let frame = WebSurfaceFrame::from_live_frame(
|
||||
tab_a.url().as_str().to_string(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test(1, 1, vec![0, 0, 0, 255]),
|
||||
)?;
|
||||
store.surface_mut(tab_a.id()).state = Some(WebSurfaceState::Ready(frame));
|
||||
assert!(
|
||||
store.state_for_scope(tab_a.id(), &profile_b, ProfileDataMode::Transient).is_none(),
|
||||
"a model scope change must hide old pixels before the next ensure",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.record_click_point(tab_a.id(), tab_a.url().as_str(), point(px(120.0), px(80.0)), 1.0,),
|
||||
WebSurfaceInputOutcome::Applied,
|
||||
);
|
||||
assert_eq!(
|
||||
store.record_typed_text(tab_a.id(), tab_a.url().as_str(), "secret"),
|
||||
WebSurfaceInputOutcome::Applied,
|
||||
);
|
||||
assert!(store.ensure_surface(&tab_b, ProfileDataMode::Transient, &[]));
|
||||
store.flush_runtime_for_test();
|
||||
|
||||
assert!(store.keyboard_focus.is_none());
|
||||
assert!(matches!(
|
||||
store.state(tab_b.id()),
|
||||
Some(WebSurfaceState::Loading { previous_frame: None, .. })
|
||||
));
|
||||
let ensures = ENSURES.lock().map_err(|_| "ensure recorder lock was poisoned")?;
|
||||
assert_eq!(ensures.len(), 2);
|
||||
assert_eq!(ensures[0].profile_id, profile_a.as_str());
|
||||
assert_eq!(ensures[1].profile_id, profile_b.as_str());
|
||||
assert!(!ensures[1].had_input);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn viewport_bounds() -> Bounds<gpui::Pixels> {
|
||||
Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_holds_loading_white_frame_and_accepts_complete_white_page()
|
||||
-> Result<(), Box<dyn Error>> {
|
||||
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
|
||||
recording_client_factory,
|
||||
));
|
||||
let tab_id = TabId::new();
|
||||
let requested_url = "https://example.com/white".to_string();
|
||||
let previous = WebSurfaceFrame::from_live_frame(
|
||||
requested_url.clone(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test(1, 1, vec![0, 0, 0, 255]),
|
||||
)?;
|
||||
store.surface_mut(&tab_id).state = Some(WebSurfaceState::Loading {
|
||||
requested_url: requested_url.clone(),
|
||||
previous_frame: Some(previous),
|
||||
});
|
||||
let loading_white = WebSurfaceFrame::from_live_frame(
|
||||
requested_url.clone(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_render_state(1, 1, vec![255; 4], "loading"),
|
||||
)?;
|
||||
let complete_white = WebSurfaceFrame::from_live_frame(
|
||||
requested_url,
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_render_state(1, 1, vec![255; 4], "complete"),
|
||||
)?;
|
||||
|
||||
assert!(store.should_hold_initial_frame(&tab_id, &loading_white, false));
|
||||
assert!(!store.should_hold_initial_frame(&tab_id, &complete_white, false));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_loading_frame_replaces_an_earlier_ready_frame() -> Result<(), Box<dyn Error>> {
|
||||
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
|
||||
recording_client_factory,
|
||||
));
|
||||
let tab_id = TabId::new();
|
||||
let requested_url = "https://example.com/progressive".to_string();
|
||||
let first_loading = WebSurfaceFrame::from_live_frame(
|
||||
requested_url.clone(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_render_state(1, 1, vec![16, 32, 64, 255], "loading"),
|
||||
)?;
|
||||
store.surface_mut(&tab_id).state = Some(WebSurfaceState::Ready(first_loading));
|
||||
let next_loading = WebSurfaceFrame::from_live_frame(
|
||||
requested_url,
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_render_state(1, 1, vec![64, 32, 16, 255], "loading"),
|
||||
)?;
|
||||
|
||||
assert!(!store.should_hold_initial_frame(&tab_id, &next_loading, true));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn held_loading_frame_preserves_previous_pixels_and_delivers_metadata() -> Result<(), Box<dyn Error>>
|
||||
{
|
||||
let mut store = WebSurfaceStore::new_with_runtime(WebSurfaceRuntime::new_with_client_factory(
|
||||
recording_client_factory,
|
||||
));
|
||||
let tab_id = TabId::new();
|
||||
let requested_url = "https://example.com/held".to_string();
|
||||
let previous = WebSurfaceFrame::from_live_frame(
|
||||
requested_url.clone(),
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_title(1, 1, vec![0, 0, 0, 255], "complete", "Previous"),
|
||||
)?;
|
||||
store.surface_mut(&tab_id).state = Some(WebSurfaceState::Loading {
|
||||
requested_url: requested_url.clone(),
|
||||
previous_frame: Some(previous),
|
||||
});
|
||||
let held = WebSurfaceFrame::from_live_frame(
|
||||
requested_url,
|
||||
WebSurfaceScrollOffset::default(),
|
||||
100,
|
||||
ServoLiveFrame::for_test_with_title(1, 1, vec![255; 4], "loading", "Updated"),
|
||||
)?;
|
||||
let result = store.apply_runtime_frames(vec![WebSurfaceRuntimeFrame::Ready {
|
||||
tab_id: tab_id.clone(),
|
||||
frame: Box::new(held),
|
||||
url_change: Some(WebSurfaceUrlChange {
|
||||
tab_id: tab_id.clone(),
|
||||
loaded_url: "https://example.com/redirected".to_string(),
|
||||
kind: WebSurfaceUrlChangeKind::Observed,
|
||||
}),
|
||||
}]);
|
||||
|
||||
assert!(matches!(
|
||||
store.state(&tab_id),
|
||||
Some(WebSurfaceState::Loading { previous_frame: Some(frame), .. })
|
||||
if frame.title() == Some("Previous")
|
||||
));
|
||||
assert!(matches!(
|
||||
result.page_metadata.as_slice(),
|
||||
[metadata] if metadata.title.as_deref() == Some("Updated")
|
||||
));
|
||||
assert_eq!(result.url_changes.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ely_domain::TabId;
|
||||
use gpui::{Bounds, NativeSurfaceHandle, Pixels};
|
||||
use ely_domain::{ProfileId, TabId};
|
||||
use gpui::{Bounds, Pixels};
|
||||
|
||||
use crate::services::ProfileDataMode;
|
||||
|
||||
use super::{
|
||||
web_surface_cadence::ACTIVE_POLL_INTERVAL,
|
||||
@@ -133,7 +135,6 @@ pub(super) struct PerTabSurface {
|
||||
/// every GPUI paint does not translate into a Servo framebuffer
|
||||
/// destroy/recreate every frame (the "page flashing" symptom).
|
||||
viewport_size_changed_at: Option<Instant>,
|
||||
pub(super) native_surface: Option<NativeSurfaceHandle>,
|
||||
pub(super) last_ensure_key: Option<WebSurfaceEnsureKey>,
|
||||
pub(super) hover_point: Option<WebSurfaceClickPoint>,
|
||||
last_hover_enqueued_at: Option<Instant>,
|
||||
@@ -154,7 +155,6 @@ impl PerTabSurface {
|
||||
viewport_bounds: None,
|
||||
viewport_size: None,
|
||||
viewport_size_changed_at: None,
|
||||
native_surface: None,
|
||||
last_ensure_key: None,
|
||||
hover_point: None,
|
||||
last_hover_enqueued_at: None,
|
||||
@@ -196,6 +196,39 @@ impl PerTabSurface {
|
||||
self.last_ensure_key.as_ref() != Some(key) || self.has_pending_input()
|
||||
}
|
||||
|
||||
pub(super) fn has_scope(
|
||||
&self,
|
||||
profile_id: &ProfileId,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
) -> bool {
|
||||
self.last_ensure_key.as_ref().is_some_and(|key| {
|
||||
key.profile_id == *profile_id && key.profile_data_mode == profile_data_mode
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn reset_for_scope_change(&mut self, key: &WebSurfaceEnsureKey) -> bool {
|
||||
let Some(previous_key) = self.last_ensure_key.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
if previous_key.has_same_scope(key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.last_ensure_key = None;
|
||||
self.hover_point = None;
|
||||
self.last_hover_enqueued_at = None;
|
||||
self.click_point = None;
|
||||
self.pending_scroll_delta = None;
|
||||
self.pending_scroll_point = None;
|
||||
self.pending_input_started_at = None;
|
||||
self.scroll_offset = None;
|
||||
self.typed_text = None;
|
||||
self.state = None;
|
||||
self.last_input_flushed_at = None;
|
||||
self.metadata_tracker = WebSurfaceMetadataTracker::default();
|
||||
true
|
||||
}
|
||||
|
||||
/// True when the viewport bounds have changed within the last
|
||||
/// [`VIEWPORT_RESIZE_DEBOUNCE`] window. The renderer treats this
|
||||
/// as "the user is mid-animation" and holds off telling Servo to
|
||||
@@ -217,23 +250,13 @@ impl PerTabSurface {
|
||||
self.viewport_size_changed_at = Some(now);
|
||||
}
|
||||
|
||||
/// Test-only escape hatch: simulate the trailing edge of a resize
|
||||
/// animation by clearing the debounce timestamp, so a synchronous
|
||||
/// `record + ensure` sequence in tests behaves as if the gesture
|
||||
/// has fully settled. Production code drives this via the poll
|
||||
/// cadence and time elapsing — tests can't advance an `Instant`.
|
||||
#[cfg(test)]
|
||||
pub(super) fn clear_viewport_resize_debounce_for_test(&mut self) {
|
||||
self.viewport_size_changed_at = None;
|
||||
}
|
||||
|
||||
pub(super) fn mark_ensured(&mut self, key: WebSurfaceEnsureKey) {
|
||||
self.last_ensure_key = Some(key);
|
||||
}
|
||||
|
||||
pub(super) fn matches_ready(&self, frame: &WebSurfaceFrame) -> bool {
|
||||
match self.state.as_ref() {
|
||||
Some(WebSurfaceState::Ready(current)) => current.has_same_software_render_as(frame),
|
||||
Some(WebSurfaceState::Ready(current)) => current.has_same_render_as(frame),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -275,7 +298,8 @@ const VIEWPORT_RESIZE_DEBOUNCE: Duration = Duration::from_millis(80);
|
||||
pub(super) struct WebSurfaceEnsureKey {
|
||||
requested_url: String,
|
||||
size: WebSurfaceSize,
|
||||
native_surface_id: Option<usize>,
|
||||
profile_id: ProfileId,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
zoom_percent: u16,
|
||||
permissions: Vec<WebSurfaceSitePermission>,
|
||||
}
|
||||
@@ -284,18 +308,24 @@ impl WebSurfaceEnsureKey {
|
||||
pub(super) fn new(
|
||||
requested_url: String,
|
||||
size: WebSurfaceSize,
|
||||
native_surface: Option<&NativeSurfaceHandle>,
|
||||
profile_id: ProfileId,
|
||||
profile_data_mode: ProfileDataMode,
|
||||
zoom_percent: u16,
|
||||
permissions: &[WebSurfaceSitePermission],
|
||||
) -> Self {
|
||||
Self {
|
||||
requested_url,
|
||||
size,
|
||||
native_surface_id: native_surface.map(NativeSurfaceHandle::identity),
|
||||
profile_id,
|
||||
profile_data_mode,
|
||||
zoom_percent,
|
||||
permissions: permissions.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_same_scope(&self, other: &Self) -> bool {
|
||||
self.profile_id == other.profile_id && self.profile_data_mode == other.profile_data_mode
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -306,7 +336,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unchanged_surface_without_input_skips_ensure() {
|
||||
let key = ensure_key("https://example.com/", 800, 600);
|
||||
let key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
|
||||
let mut surface = PerTabSurface::new();
|
||||
|
||||
assert!(surface.should_ensure(&key));
|
||||
@@ -318,7 +348,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pending_input_forces_ensure_even_when_key_matches() {
|
||||
let key = ensure_key("https://example.com/", 800, 600);
|
||||
let key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
|
||||
let mut surface = PerTabSurface::new();
|
||||
surface.mark_ensured(key.clone());
|
||||
surface.pending_scroll_delta =
|
||||
@@ -329,8 +359,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn viewport_change_forces_ensure() {
|
||||
let old_key = ensure_key("https://example.com/", 800, 600);
|
||||
let new_key = ensure_key("https://example.com/", 1024, 768);
|
||||
let profile_id = ProfileId::new();
|
||||
let old_key = ensure_key("https://example.com/", 800, 600, &profile_id);
|
||||
let new_key = ensure_key("https://example.com/", 1024, 768, &profile_id);
|
||||
let mut surface = PerTabSurface::new();
|
||||
surface.mark_ensured(old_key);
|
||||
|
||||
assert!(surface.should_ensure(&new_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_change_forces_ensure() {
|
||||
let old_key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
|
||||
let new_key = ensure_key("https://example.com/", 800, 600, &ProfileId::new());
|
||||
let mut surface = PerTabSurface::new();
|
||||
surface.mark_ensured(old_key);
|
||||
|
||||
assert!(surface.should_ensure(&new_key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_data_mode_change_forces_ensure() {
|
||||
let profile_id = ProfileId::new();
|
||||
let old_key = ensure_key("https://example.com/", 800, 600, &profile_id);
|
||||
let mut new_key = old_key.clone();
|
||||
new_key.profile_data_mode = ProfileDataMode::Transient;
|
||||
let mut surface = PerTabSurface::new();
|
||||
surface.mark_ensured(old_key);
|
||||
|
||||
@@ -348,11 +401,17 @@ mod tests {
|
||||
assert!(!surface.input_flush_is_throttled(start + Duration::from_millis(8)));
|
||||
}
|
||||
|
||||
fn ensure_key(url: &str, width: u32, height: u32) -> WebSurfaceEnsureKey {
|
||||
fn ensure_key(
|
||||
url: &str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
profile_id: &ProfileId,
|
||||
) -> WebSurfaceEnsureKey {
|
||||
WebSurfaceEnsureKey::new(
|
||||
url.to_string(),
|
||||
WebSurfaceSize { width, height, device_pixel_ratio_percent: 100 },
|
||||
None,
|
||||
profile_id.clone(),
|
||||
ProfileDataMode::Persistent,
|
||||
100,
|
||||
&[],
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use ely_domain::{BrowserTab, TabId};
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, Pixels, Styled, Window, canvas, div, native_surface, px, rgb,
|
||||
AnyElement, App, Entity, ImageSource, InteractiveElement, IntoElement, MouseButton, ObjectFit,
|
||||
ParentElement, Pixels, Styled, StyledImage, Window, canvas, div, img, px, rgb,
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
use gpui::{Corners, SurfaceLease, surface};
|
||||
|
||||
use super::{
|
||||
ElyShell, web_surface_frame::WebSurfaceFrame,
|
||||
@@ -11,15 +13,44 @@ use super::{
|
||||
use ely_design_system::colors;
|
||||
|
||||
pub(super) fn render_ready_web_surface(
|
||||
_frame: &WebSurfaceFrame,
|
||||
frame: &WebSurfaceFrame,
|
||||
tab: &BrowserTab,
|
||||
state_entity: Entity<ElyShell>,
|
||||
bottom_corner_radius: Pixels,
|
||||
) -> AnyElement {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(hardware_surface) = frame.hardware_surface.as_ref() {
|
||||
return render_web_surface(
|
||||
tab,
|
||||
state_entity,
|
||||
surface(hardware_surface.pixel_buffer().clone())
|
||||
.lease(SurfaceLease::from_arc(hardware_surface.clone()))
|
||||
.size_full()
|
||||
.corner_radii(Corners {
|
||||
top_left: px(0.0),
|
||||
top_right: px(0.0),
|
||||
bottom_left: bottom_corner_radius,
|
||||
bottom_right: bottom_corner_radius,
|
||||
})
|
||||
.object_fit(ObjectFit::Fill),
|
||||
);
|
||||
}
|
||||
let Some(image) = frame.image.as_ref() else {
|
||||
return render_web_surface(
|
||||
tab,
|
||||
state_entity,
|
||||
error_page("Web surface frame did not include renderable pixels."),
|
||||
);
|
||||
};
|
||||
render_web_surface(
|
||||
tab,
|
||||
state_entity.clone(),
|
||||
render_native_web_surface(tab, state_entity, bottom_corner_radius),
|
||||
state_entity,
|
||||
div()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.rounded_bl(bottom_corner_radius)
|
||||
.rounded_br(bottom_corner_radius)
|
||||
.child(img(ImageSource::Render(image.clone())).size_full().object_fit(ObjectFit::Fill)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,8 +61,12 @@ pub(super) fn render_loading_web_surface(
|
||||
) -> AnyElement {
|
||||
render_web_surface(
|
||||
tab,
|
||||
state_entity.clone(),
|
||||
render_native_web_surface(tab, state_entity, bottom_corner_radius),
|
||||
state_entity,
|
||||
div()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.rounded_bl(bottom_corner_radius)
|
||||
.rounded_br(bottom_corner_radius),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,29 +120,6 @@ fn render_web_surface(
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_native_web_surface(
|
||||
tab: &BrowserTab,
|
||||
state_entity: Entity<ElyShell>,
|
||||
bottom_corner_radius: Pixels,
|
||||
) -> impl IntoElement {
|
||||
let tab_id = tab.id().clone();
|
||||
let element_id = ElementId::Name(format!("web-surface-{}", tab_id.as_str()).into());
|
||||
// `bottom_corner_radius` is wired through the GPUI `native_surface`
|
||||
// patch to the AppKit overlay's `CALayer.cornerRadius`, so the
|
||||
// canvas follows the same rounded edge as its containing panel
|
||||
// instead of painting past it. The top corners stay flat because
|
||||
// the topbar / pane header sits flush above the canvas.
|
||||
native_surface(element_id, move |surface, bounds, window: &mut Window, cx: &mut App| {
|
||||
let scale_factor = window.scale_factor();
|
||||
state_entity.update(cx, |shell, cx| {
|
||||
shell.record_external_web_surface(tab_id.clone(), bounds, scale_factor, surface, cx);
|
||||
});
|
||||
})
|
||||
.size_full()
|
||||
.rounded_bl(bottom_corner_radius)
|
||||
.rounded_br(bottom_corner_radius)
|
||||
}
|
||||
|
||||
fn render_input_overlay(
|
||||
tab_id: TabId,
|
||||
url: String,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
collections::{BTreeMap, VecDeque},
|
||||
io,
|
||||
sync::{Arc, Condvar, Mutex, mpsc},
|
||||
thread::JoinHandle,
|
||||
@@ -9,11 +9,10 @@ use crate::services::servo_live::{
|
||||
ServoLiveClient, ServoLiveEnsureRequest, ServoLiveError, ServoLiveFrame,
|
||||
};
|
||||
|
||||
/// Blocking surface for the embedded Servo runtime.
|
||||
/// Blocking transport for one profile-scoped Servo sidecar.
|
||||
///
|
||||
/// Production wraps [`ServoLiveClient`] directly; tests substitute a
|
||||
/// fake. The contract: every call is blocking and may run for tens of
|
||||
/// milliseconds. Implementations live on the worker thread.
|
||||
/// Production wraps [`ServoLiveClient`]; tests substitute a fake. Every
|
||||
/// call may block on sidecar IPC, so implementations live on a worker.
|
||||
pub(super) trait LiveRuntimeClient {
|
||||
fn ensure(
|
||||
&mut self,
|
||||
@@ -57,7 +56,7 @@ impl LiveRuntimeClientError {
|
||||
impl std::fmt::Display for LiveRuntimeClientError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::RuntimeUnavailable => formatter.write_str("servo live runtime is unavailable"),
|
||||
Self::RuntimeUnavailable => formatter.write_str("servo sidecar runtime is unavailable"),
|
||||
Self::Message(message) => formatter.write_str(message),
|
||||
}
|
||||
}
|
||||
@@ -86,41 +85,57 @@ impl From<io::Error> for LiveRuntimeClientError {
|
||||
|
||||
/// Output of a worker request.
|
||||
pub(super) enum WorkerResponse {
|
||||
Frame { tab_id: String, frame: ServoLiveFrame },
|
||||
Failed { tab_id: String, message: String },
|
||||
Frame { generation: RequestGeneration, tab_id: String, frame: ServoLiveFrame },
|
||||
Failed { generation: RequestGeneration, tab_id: String, message: String },
|
||||
RuntimeUnavailable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub(super) struct RequestGeneration(u64);
|
||||
|
||||
impl RequestGeneration {
|
||||
pub(super) const fn new(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
enum WorkerRequest {
|
||||
Ensure(ServoLiveEnsureRequest),
|
||||
Poll { tab_id: String },
|
||||
Ensure { generation: RequestGeneration, request: ServoLiveEnsureRequest },
|
||||
Poll { generation: RequestGeneration, tab_id: String },
|
||||
}
|
||||
|
||||
impl WorkerRequest {
|
||||
fn tab_id(&self) -> &str {
|
||||
match self {
|
||||
Self::Ensure { request, .. } => request.tab_id.as_str(),
|
||||
Self::Poll { tab_id, .. } => tab_id.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure_parts(self) -> (RequestGeneration, String) {
|
||||
match self {
|
||||
Self::Ensure { generation, request } => (generation, request.tab_id),
|
||||
Self::Poll { generation, tab_id } => (generation, tab_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkerQueue {
|
||||
/// Latest request per tab. A new submission for a tab replaces any
|
||||
/// earlier in-flight-but-not-yet-started request, so a flurry of
|
||||
/// scrolls never piles up — the worker always processes the most
|
||||
/// recent frame's worth of inputs.
|
||||
pending: BTreeMap<String, WorkerRequest>,
|
||||
/// Close orders. Sent after pending is cleared for that tab so the
|
||||
/// worker never closes a tab that still has live frames in flight.
|
||||
closes: Vec<String>,
|
||||
/// True while the worker is processing a request. `wait_until_idle`
|
||||
/// uses this alongside the queue emptiness to know when all
|
||||
/// previously-submitted work has actually run.
|
||||
/// Ordered inputs queue; idle and hover updates coalesce at the tail.
|
||||
pending: BTreeMap<String, VecDeque<WorkerRequest>>,
|
||||
/// Round-robin tab order, with each pending tab represented once.
|
||||
ready_tabs: VecDeque<String>,
|
||||
closes: VecDeque<String>,
|
||||
in_flight: bool,
|
||||
in_flight_tab: Option<String>,
|
||||
initialization_failure: Option<String>,
|
||||
shutdown: bool,
|
||||
}
|
||||
|
||||
/// Owns a [`LiveRuntimeClient`] on a dedicated OS thread and exposes
|
||||
/// a non-blocking API: submit ensure/poll/close, then drain responses.
|
||||
///
|
||||
/// The UI thread never blocks on Servo. Submissions push into a
|
||||
/// coalescing queue (latest request per tab wins). The worker thread
|
||||
/// drains the queue, runs the blocking calls, and emits responses on a
|
||||
/// `std::sync::mpsc` channel that the UI thread reads with `try_recv`.
|
||||
/// Runs one blocking profile client behind a non-blocking fair queue.
|
||||
pub(super) struct LiveRuntimeWorker {
|
||||
queue: Arc<(Mutex<WorkerQueue>, Condvar)>,
|
||||
response_tx: mpsc::Sender<WorkerResponse>,
|
||||
response_rx: mpsc::Receiver<WorkerResponse>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
@@ -132,46 +147,35 @@ impl LiveRuntimeWorker {
|
||||
let queue = Arc::new((
|
||||
Mutex::new(WorkerQueue {
|
||||
pending: BTreeMap::new(),
|
||||
closes: Vec::new(),
|
||||
ready_tabs: VecDeque::new(),
|
||||
closes: VecDeque::new(),
|
||||
in_flight: false,
|
||||
in_flight_tab: None,
|
||||
initialization_failure: None,
|
||||
shutdown: false,
|
||||
}),
|
||||
Condvar::new(),
|
||||
));
|
||||
let (response_tx, response_rx) = mpsc::channel();
|
||||
let (init_tx, init_rx) = mpsc::channel();
|
||||
let queue_for_thread = queue.clone();
|
||||
let response_for_thread = response_tx.clone();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("ely-servo-runtime".to_string())
|
||||
.spawn(move || {
|
||||
let client = match client_factory() {
|
||||
Ok(client) => {
|
||||
let _ = init_tx.send(Ok(()));
|
||||
client
|
||||
}
|
||||
.spawn(move || match client_factory() {
|
||||
Ok(client) => run_worker(client, queue_for_thread, response_for_thread),
|
||||
Err(error) => {
|
||||
let _ = init_tx.send(Err(error));
|
||||
return;
|
||||
fail_worker_initialization(queue_for_thread, &response_for_thread, error);
|
||||
}
|
||||
};
|
||||
run_worker(client, queue_for_thread, response_tx);
|
||||
})
|
||||
.map_err(|error| format!("failed to spawn servo live worker thread: {error}"))?;
|
||||
match init_rx.recv() {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => {
|
||||
let _ = thread.join();
|
||||
return Err(error);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = thread.join();
|
||||
return Err(format!("servo live worker initialization failed: {error}"));
|
||||
}
|
||||
}
|
||||
Ok(Self { queue, response_rx, thread: Some(thread) })
|
||||
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 });
|
||||
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
|
||||
}
|
||||
std::collections::btree_map::Entry::Occupied(_) => false,
|
||||
};
|
||||
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,23 +289,54 @@ impl Drop for LiveRuntimeWorker {
|
||||
fn drop(&mut self) {
|
||||
{
|
||||
let (lock, cvar) = &*self.queue;
|
||||
if let Ok(mut q) = lock.lock() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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>"#;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Executable
+60
@@ -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}"
|
||||
Executable
+75
@@ -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]"
|
||||
}
|
||||
Executable
+33
@@ -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 \
|
||||
-- "$@"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+49
@@ -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
|
||||
@@ -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,16 +52,22 @@ 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}"
|
||||
ELY_SERVO_RENDERING_CONTEXT=hardware \
|
||||
"${APP_BIN}" "${SMOKE_URL}" >/dev/null 2>"${STDERR_LOG}" &
|
||||
APP_PID=$!
|
||||
echo " pid=${APP_PID}"
|
||||
|
||||
Vendored
+7
-7
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Vendored
+2
@@ -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 {
|
||||
|
||||
Vendored
+5
@@ -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.
|
||||
|
||||
Vendored
+12
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user