diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0965303..aaa02c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index e24594d..6e2ec4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 09f111b..3254611 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 70dc57d..47e20ee 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/crates/ely_app/Cargo.toml b/crates/ely_app/Cargo.toml index bd44f09..a11a5d0 100644 --- a/crates/ely_app/Cargo.toml +++ b/crates/ely_app/Cargo.toml @@ -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] diff --git a/crates/ely_app/build.rs b/crates/ely_app/build.rs index 398c213..10134b7 100644 --- a/crates/ely_app/build.rs +++ b/crates/ely_app/build.rs @@ -11,24 +11,19 @@ fn main() -> Result<(), Box> { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); let workspace_root = workspace_root(&manifest_dir)?; let workspace_manifest_path = workspace_root.join("Cargo.toml"); - let git_head_path = workspace_root.join(".git/HEAD"); let workspace_manifest = read_workspace_manifest(&workspace_manifest_path)?; let workspace = table(&workspace_manifest, "workspace")?; let package = table(workspace, "package")?; let dependencies = table(workspace, "dependencies")?; emit_cargo_directive(format!("cargo:rerun-if-changed={}", workspace_manifest_path.display()))?; - emit_cargo_directive(format!("cargo:rerun-if-changed={}", git_head_path.display()))?; - if let Some(ref_path) = git_head_ref(&git_head_path)? { - emit_cargo_directive(format!( - "cargo:rerun-if-changed={}", - workspace_root.join(".git").join(ref_path).display() - ))?; - } + emit_cargo_directive("cargo:rerun-if-env-changed=ELY_BUILD_REVISION")?; - emit_env("ELY_BUILD_REVISION", &git_revision(workspace_root)?)?; + emit_env("ELY_BUILD_REVISION", &build_revision(workspace_root)?)?; emit_env("ELY_WORKSPACE_LICENSE", string_value(package, "license")?)?; - emit_env("ELY_WORKSPACE_MANIFEST", path_value(&workspace_manifest_path)?)?; + if env::var("PROFILE").as_deref() == Ok("debug") { + emit_env("ELY_WORKSPACE_MANIFEST", path_value(&workspace_manifest_path)?)?; + } emit_env("ELY_GPUI_VERSION", dependency_version(dependencies, "gpui")?)?; emit_env("ELY_GPUI_COMPONENT_VERSION", dependency_version(dependencies, "gpui-component")?)?; emit_env("ELY_SERVO_VERSION", dependency_version(dependencies, "servo")?)?; @@ -90,39 +85,92 @@ fn dependency_version<'a>( } } -fn git_revision(workspace_root: &Path) -> Result> { - 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> { + match env::var("ELY_BUILD_REVISION") { + Ok(revision) => return validated_revision(revision), + Err(env::VarError::NotUnicode(_)) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ELY_BUILD_REVISION is not UTF-8", + ) + .into()); + } + Err(env::VarError::NotPresent) => {} } - let revision = String::from_utf8(output.stdout)?.trim().to_string(); - if revision.is_empty() { - return Err(io::Error::new(io::ErrorKind::InvalidData, "git revision is empty").into()); + + match fs::symlink_metadata(workspace_root.join(".git")) { + Ok(_) => git_revision(workspace_root), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + validated_revision(format!("source-{}", env::var("CARGO_PKG_VERSION")?)) + } + Err(error) => Err(error.into()), } - Ok(revision) } -fn git_head_ref(git_head_path: &Path) -> Result, Box> { - let head = fs::read_to_string(git_head_path)?; - let Some(ref_path) = head.trim().strip_prefix("ref: ") else { +fn git_revision(workspace_root: &Path) -> Result> { + 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> { + 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> { + 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, Box> { + 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> { + let revision = revision.trim(); + validate_env_value("ELY_BUILD_REVISION", revision)?; + Ok(revision.to_string()) } fn emit_env(key: &str, value: &str) -> Result<(), Box> { - 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> { + 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) -> Result<(), Box> { writeln!(io::stdout(), "{}", directive.as_ref())?; Ok(()) diff --git a/crates/ely_app/src/services/iosurface_mach.rs b/crates/ely_app/src/services/iosurface_mach.rs new file mode 100644 index 0000000..0ad7baf --- /dev/null +++ b/crates/ely_app/src/services/iosurface_mach.rs @@ -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, +} + +#[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 { + 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 { + 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, 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::() 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, + surface_id: u64, + mach_port: mach_port_t, +) -> Option { + 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::() 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::() 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))); + } +} diff --git a/crates/ely_app/src/services/iosurface_metal.rs b/crates/ely_app/src/services/iosurface_metal.rs new file mode 100644 index 0000000..51e423f --- /dev/null +++ b/crates/ely_app/src/services/iosurface_metal.rs @@ -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, + insertion_order: VecDeque, +} + +const MAX_CACHED_SURFACES: usize = 16; + +struct CachedSurface { + iosurface: CFRetained, + pending_backing: Option>, + active_backing: Weak, + 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, + pub(crate) backing: Arc, +} + +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 { + 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>, 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 { + 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 { + 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 { + 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, +) -> Result, 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, 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(()) + } +} diff --git a/crates/ely_app/src/services/mod.rs b/crates/ely_app/src/services/mod.rs index 288f835..f7501a4 100644 --- a/crates/ely_app/src/services/mod.rs +++ b/crates/ely_app/src/services/mod.rs @@ -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; diff --git a/crates/ely_app/src/services/profile_identity.rs b/crates/ely_app/src/services/profile_identity.rs new file mode 100644 index 0000000..2432c1c --- /dev/null +++ b/crates/ely_app/src/services/profile_identity.rs @@ -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 { + static TEST_PROFILE_ID: std::sync::OnceLock = 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 { + 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 { + 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 { + 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> { + 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> + { + 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::>(); + 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(()) + } +} diff --git a/crates/ely_app/src/services/servo_live.rs b/crates/ely_app/src/services/servo_live.rs index a6f93bd..27ea938 100644 --- a/crates/ely_app/src/services/servo_live.rs +++ b/crates/ely_app/src/services/servo_live.rs @@ -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, + child: Child, + ipc: ServoLiveIpc, + timeouts: SidecarTimeouts, + active: bool, + #[cfg(target_os = "macos")] + iosurface_cache: IOSurfaceCache, + #[cfg(target_os = "macos")] + iosurface_importer: Option, + #[cfg(target_os = "macos")] + pending_surface_ids: BTreeSet, } impl ServoLiveClient { pub fn new(profile_data_dir: PathBuf) -> Result { - 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 { + 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, 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, 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 { - if self - .sessions - .get(&request.tab_id) - .is_some_and(|session| session.profile_id != *profile_id) - && let Some(session) = self.sessions.remove(&request.tab_id) + fn handshake(&mut self) -> Result<(), ServoLiveError> { + let reply = self.exchange( + LiveRequest::Handshake { protocol_version: LIVE_PROTOCOL_VERSION }, + self.timeouts.request, + "handshake", + )?; + if reply.frame.is_some() + || reply.surface_handle.is_some() + || reply.current_surface_id.is_some() { - self.host.close_webview(&session.webview_id); + return Err(ServoLiveError::InvalidResponse { + message: "handshake response contains a frame", + }); } - - let native_surface_id = - request.native_surface.as_ref().map(gpui::NativeSurfaceHandle::identity); - if self - .sessions - .get(&request.tab_id) - .is_some_and(|session| session.native_surface_id != native_surface_id) - && let Some(session) = self.sessions.remove(&request.tab_id) - { - self.host.close_webview(&session.webview_id); + match reply.error { + Some(message) => Err(ServoLiveError::SidecarFailed { message }), + None => Ok(()), } - - if let Some(session) = self.sessions.get(&request.tab_id) { - return Ok(session.webview_id.clone()); - } - - let surface_size = ServoSurfaceSize::new(request.width, request.height); - let webview_id = match request.native_surface.as_ref() { - Some(native_surface) => self.host.create_webview_with_native_surface( - tab_id.clone(), - profile_id.clone(), - surface_size, - native_surface, - )?, - None => self.host.create_webview_with_size( - tab_id.clone(), - profile_id.clone(), - surface_size, - )?, - }; - self.sessions.insert( - request.tab_id.clone(), - DirectWebViewSession { - webview_id: webview_id.clone(), - profile_id: profile_id.clone(), - requested_url: None, - width: request.width, - height: request.height, - // Servo's WebViewBuilder defaults page zoom to 1.0 (100%) and - // hidpi scale to 1.0; record both as Servo's actual post-build - // state so `apply_viewport` pushes the embedder-requested - // values on the first ensure. Without this, a request whose - // zoom/DPR happens to equal the cached request value would - // bypass `set_page_zoom` / `set_hidpi_scale` and leave the - // WebView at Servo's defaults — on Retina that collapses CSS - // pixels onto device pixels and renders pages at half size. - page_zoom_percent: SERVO_DEFAULT_PAGE_ZOOM_PERCENT, - device_pixel_ratio: SERVO_DEFAULT_DEVICE_PIXEL_RATIO, - native_surface_id, - }, - ); - Ok(webview_id) } - fn apply_viewport( + fn request(&mut self, request: LiveRequest) -> Result, ServoLiveError> { + let reply = self.exchange(request, self.timeouts.request, "request")?; + if let Some(message) = reply.error { + return Err(ServoLiveError::SidecarFailed { message }); + } + #[cfg(target_os = "macos")] + if let Some(handle) = reply.surface_handle { + self.queue_iosurface_handle(handle)?; + self.drain_iosurface_imports()?; + } + + let mut frame = reply.frame; + #[cfg(target_os = "macos")] + if let (Some(frame), Some(surface_id)) = (frame.as_mut(), reply.current_surface_id) { + let hardware_surface = + self.iosurface_cache.hardware_surface_for(surface_id).map_err(|error| { + ServoLiveError::IOSurfaceBackingFailed { + surface_id, + message: error.to_string(), + } + })?; + if hardware_surface.is_none() && !frame.has_software_payload() { + return Ok(None); + } + frame.set_hardware_surface(surface_id, hardware_surface); + } + Ok(frame) + } + + fn exchange( &mut self, - request: &ServoLiveEnsureRequest, - webview_id: &WebViewId, - ) -> Result<(), ServoLiveError> { - let Some(session) = self.sessions.get_mut(&request.tab_id) else { + request: LiveRequest, + timeout: Duration, + operation: &'static str, + ) -> Result { + 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 { + Vec::new() + } + + fn pending_surface_ids(&self) -> Vec { + Vec::new() + } +} + +#[cfg(target_os = "macos")] +impl ServoLiveClient { + fn ready_surface_ids(&self) -> Vec { + self.iosurface_cache.surface_ids() + } + + fn pending_surface_ids(&self) -> Vec { + self.pending_surface_ids.iter().copied().collect() + } + + fn queue_iosurface_handle(&mut self, handle: LiveSurfaceHandle) -> Result<(), ServoLiveError> { + let Some(importer) = self.iosurface_importer.as_ref() else { + return Err(ServoLiveError::IOSurfaceImportFailed { + surface_id: handle.surface_id, + mach_port_name: handle.mach_port_name, + message: "IOSurface import worker is unavailable".to_string(), + }); + }; + let surface_id = handle.surface_id; + importer.submit(handle).map_err(|failure| ServoLiveError::IOSurfaceImportFailed { + surface_id: failure.surface_id, + mach_port_name: failure.mach_port_name, + message: failure.message, + })?; + self.pending_surface_ids.insert(surface_id); + Ok(()) + } + + fn drain_iosurface_imports(&mut self) -> Result<(), ServoLiveError> { + let Some(importer) = self.iosurface_importer.as_ref() else { return Ok(()); }; - let change = ViewportChange::between(request, session); - - if let Some((width, height)) = change.resize { - self.host.resize(ResizeRequest { webview_id: webview_id.clone(), width, height })?; - session.width = width; - session.height = height; - } - - if let Some(scale_factor) = change.set_hidpi { - self.host.set_hidpi_scale(HidpiScaleRequest { - webview_id: webview_id.clone(), - scale_factor, - })?; - session.device_pixel_ratio = scale_factor; - } - - if let Some(page_zoom_percent) = change.set_page_zoom { - self.host.set_page_zoom(PageZoomRequest { - webview_id: webview_id.clone(), - zoom_factor: f32::from(page_zoom_percent) / 100.0, - })?; - session.page_zoom_percent = page_zoom_percent; - } - - Ok(()) + apply_iosurface_import_results( + &mut self.iosurface_cache, + &mut self.pending_surface_ids, + importer.drain(), + ) } +} - fn apply_permissions( - &mut self, - request: &ServoLiveEnsureRequest, - webview_id: &WebViewId, - profile_id: &ProfileId, - ) -> Result<(), ServoLiveError> { - for permission in &request.site_permissions { - let origin = SiteOrigin::parse(permission.origin.clone())?; - let feature = SitePermissionFeature::parse(permission.feature.as_str())?; - let decision = SitePermissionDecision::parse(permission.decision.as_str())?; - self.host.set_permission( - PermissionRequest { - webview_id: webview_id.clone(), - profile_id: profile_id.clone(), - origin, - feature, - }, - PermissionDecision::from(decision), - )?; - } - Ok(()) - } - - fn apply_navigation( - &mut self, - request: &ServoLiveEnsureRequest, - webview_id: &WebViewId, - tab_id: TabId, - requested_url: UrlText, - ) -> Result<(), ServoLiveError> { - // Servo's `set_history` fires `notify_url_changed` on *every* - // history mutation — full navigations, redirects, in-page - // links, and JS-driven `history.pushState` / `replaceState`. - // Our embedder fans that back through `WebSurfaceUrlChange` - // into `tab.url`, which makes the next `ensure_surface` see a - // "different" URL and call back into this method. Without a - // guard we then send Servo `WebView::load(new_url)` for a URL - // Servo just informed us it is *already* at — and `load` is a - // hard navigation that aborts the live document, clears the - // surface, and refetches. - // - // The google.com homepage `replaceState`s a `?zx=` - // every second; under the unguarded path that turned into a - // load-clear-refetch cycle per second, i.e. the continuous - // white flash. Servo's own `webview.url()` (driven by - // `set_history`) is the source of truth — if it already - // matches the requested URL, this URL change came *from* - // Servo and only needs an embedder-side bookkeeping sync. - let servo_current_url = self.host.snapshot(webview_id)?.url().map(str::to_string); - if servo_current_url.as_deref() == Some(requested_url.as_str()) { - if let Some(session) = self.sessions.get_mut(&request.tab_id) { - session.requested_url = Some(requested_url.as_str().to_string()); +#[cfg(target_os = "macos")] +fn apply_iosurface_import_results( + cache: &mut IOSurfaceCache, + pending_surface_ids: &mut BTreeSet, + results: Vec, +) -> Result<(), ServoLiveError> { + let mut first_failure = None; + for result in results { + let surface_id = match &result { + IOSurfaceImportResult::Imported(imported) => imported.surface_id, + IOSurfaceImportResult::Failed(failure) => failure.surface_id, + }; + pending_surface_ids.remove(&surface_id); + match result { + IOSurfaceImportResult::Imported(imported) => { + cache.insert_imported(imported.surface_id, imported.imported); } - return Ok(()); - } - - let should_navigate = self - .sessions - .get(&request.tab_id) - .and_then(|session| session.requested_url.as_deref()) - .is_none_or(|current| current != requested_url.as_str()); - if should_navigate { - self.host.navigate(NavigationRequest { - webview_id: webview_id.clone(), - tab_id, - url: requested_url.clone(), - })?; - if let Some(session) = self.sessions.get_mut(&request.tab_id) { - session.requested_url = Some(requested_url.as_str().to_string()); + IOSurfaceImportResult::Failed(failure) if first_failure.is_none() => { + first_failure = Some(ServoLiveError::IOSurfaceImportFailed { + surface_id: failure.surface_id, + mach_port_name: failure.mach_port_name, + message: failure.message, + }); } + IOSurfaceImportResult::Failed(_) => {} } - Ok(()) } + first_failure.map_or(Ok(()), Err) +} - fn apply_input( - &mut self, - request: &ServoLiveEnsureRequest, - webview_id: &WebViewId, - ) -> Result<(), ServoLiveError> { - if request.scroll_delta_x != 0 || request.scroll_delta_y != 0 { - let point_x = request.scroll_point_x.ok_or(ServoLiveError::MissingScrollPoint)?; - let point_y = request.scroll_point_y.ok_or(ServoLiveError::MissingScrollPoint)?; - self.host.scroll(ScrollRequest { - webview_id: webview_id.clone(), - delta_x: request.scroll_delta_x, - delta_y: request.scroll_delta_y, - point_x, - point_y, - })?; - } - - if let (Some(x), Some(y)) = (request.hover_x, request.hover_y) { - self.host.hover(MouseHoverRequest { webview_id: webview_id.clone(), x, y })?; - } - - if let (Some(x), Some(y)) = (request.click_x, request.click_y) { - self.host.click(MouseClickRequest { webview_id: webview_id.clone(), x, y })?; - } - - if let Some(text) = request.typed_text.as_ref() { - self.host.type_text(KeyboardTextRequest { - webview_id: webview_id.clone(), - text: text.clone(), - })?; - } - - Ok(()) - } - - fn presented_frame_from_session( - &self, - tab_id: &str, - webview_id: &WebViewId, - ) -> Result { - let Some(session) = self.sessions.get(tab_id) else { - return Err(ServoLiveError::Host(ely_servo_host::ServoHostError::WebViewNotFound { - id: webview_id.clone(), - })); - }; - if session.native_surface_id.is_some() { - let snapshot = self.host.snapshot_and_mark_metadata_observed(webview_id)?; - return Ok(ServoLiveFrame::from_presented( - snapshot, - session.width, - session.height, - session.device_pixel_ratio, - )); - } - Err(ServoLiveError::NativeSurfaceUnavailable) - } - - fn rendered_frame_from_session( - &self, - tab_id: &str, - webview_id: &WebViewId, - ) -> Result, ServoLiveError> { - let Some(session) = self.sessions.get(tab_id) else { - return Err(ServoLiveError::Host(ServoHostError::WebViewNotFound { - id: webview_id.clone(), - })); - }; - let rendered_frame = match self.host.last_rendered_frame() { - Ok(frame) => frame, - Err(ServoHostError::RenderedFrameUnavailable) => return Ok(None), - Err(error) => return Err(ServoLiveError::Host(error)), - }; - let snapshot = self.host.snapshot_and_mark_metadata_observed(webview_id)?; - Ok(Some(ServoLiveFrame::from_rendered( - snapshot, - rendered_frame, - session.device_pixel_ratio, - ))) - } - - fn session_uses_native_surface(&self, tab_id: &str) -> bool { - self.sessions.get(tab_id).is_some_and(|session| session.native_surface_id.is_some()) +impl Drop for ServoLiveClient { + fn drop(&mut self) { + self.shutdown(); } } -struct DirectWebViewSession { - webview_id: WebViewId, - profile_id: ProfileId, - requested_url: Option, - width: u32, - height: u32, - page_zoom_percent: u16, - device_pixel_ratio: f32, - native_surface_id: Option, -} - -/// 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, - set_page_zoom: Option, -} - -impl ViewportChange { - fn between(request: &ServoLiveEnsureRequest, session: &DirectWebViewSession) -> Self { - Self { - resize: (session.width != request.width || session.height != request.height) - .then_some((request.width, request.height)), - set_hidpi: (session.device_pixel_ratio != request.device_pixel_ratio) - .then_some(request.device_pixel_ratio), - set_page_zoom: (session.page_zoom_percent != request.page_zoom_percent) - .then_some(request.page_zoom_percent), +fn wait_for_exit(child: &mut Child, timeout: Duration) -> bool { + let started_at = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => return true, + Ok(None) => {} + Err(_) => return false, } + if started_at.elapsed() >= timeout { + return false; + } + thread::sleep(Duration::from_millis(5)); } } -#[cfg(test)] +fn terminate_child(child: &mut Child, timeout: Duration) { + if child.try_wait().ok().flatten().is_none() { + let _ = child.kill(); + } + let _ = wait_for_exit(child, timeout); +} + +#[cfg(all(test, unix))] mod tests { + use std::{error::Error, process::Command}; + use super::*; - use ely_domain::WebViewId; - fn session(width: u32, height: u32, dpr: f32, zoom: u16) -> DirectWebViewSession { - DirectWebViewSession { - webview_id: WebViewId::new(), - profile_id: ProfileId::new(), - requested_url: None, - width, - height, - page_zoom_percent: zoom, - device_pixel_ratio: dpr, - native_surface_id: Some(0xC0FFEE), - } - } + const TEST_TIMEOUTS: SidecarTimeouts = SidecarTimeouts { + request: Duration::from_millis(50), + shutdown: Duration::from_millis(50), + exit: Duration::from_millis(250), + }; - fn request(width: u32, height: u32, dpr: f32, zoom: u16) -> ServoLiveEnsureRequest { - ServoLiveEnsureRequest { - tab_id: String::from("tab"), - profile_id: String::from("profile"), - url: String::from("https://example.com/"), - width, - height, - page_zoom_percent: zoom, - device_pixel_ratio: dpr, - native_surface: None, - scroll_delta_x: 0, - scroll_delta_y: 0, - scroll_point_x: None, - scroll_point_y: None, - click_x: None, - click_y: None, - hover_x: None, - hover_y: None, - typed_text: None, - site_permissions: Vec::new(), - } + #[test] + fn startup_rejects_a_sidecar_without_the_current_protocol() -> Result<(), Box> { + let child = spawn_script( + "IFS= read -r request; printf '%s\\n' '{\"error\":\"unknown request\",\"frame\":null}'", + )?; + + let result = ServoLiveClient::from_spawned_child(child, TEST_TIMEOUTS); + + assert!(matches!(result, Err(ServoLiveError::ProtocolVersionMismatch { .. }))); + Ok(()) } #[test] - fn fresh_retina_session_pushes_hidpi_only() { - // A 1440×900 Retina viewport arrives as physical 2880×1800 with - // DPR=2.0. `ensure_webview` stamps the session with Servo's - // post-build defaults (1.0 DPR, 100 % zoom) and the request's - // physical dimensions, so only the hidpi push should fire. - let change = ViewportChange::between( - &request(2880, 1800, 2.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT), - &session(2880, 1800, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT), - ); - assert_eq!( - change, - ViewportChange { resize: None, set_hidpi: Some(2.0), set_page_zoom: None }, - ); + fn request_timeout_kills_and_reaps_a_hung_sidecar() -> Result<(), Box> { + let mut client = hung_sidecar_client()?; + let started_at = Instant::now(); + + let result = client.poll("tab-hung".to_string()); + + assert!(matches!(result, Err(ServoLiveError::RequestTimedOut { .. }))); + assert!(started_at.elapsed() < Duration::from_secs(1)); + assert!(client.child.try_wait()?.is_some()); + Ok(()) } #[test] - fn fresh_standard_dpi_session_pushes_nothing() { - // On a 1.0-DPR display the fresh session already matches the - // request — Servo's defaults are exactly what we asked for. - let change = ViewportChange::between( - &request(1280, 720, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT), - &session(1280, 720, SERVO_DEFAULT_DEVICE_PIXEL_RATIO, SERVO_DEFAULT_PAGE_ZOOM_PERCENT), - ); - assert_eq!(change, ViewportChange::default()); + fn shutdown_timeout_kills_and_reaps_a_hung_sidecar() -> Result<(), Box> { + let mut client = hung_sidecar_client()?; + let started_at = Instant::now(); + + client.shutdown(); + + assert!(started_at.elapsed() < Duration::from_secs(1)); + assert!(client.child.try_wait()?.is_some()); + Ok(()) } - #[test] - fn page_zoom_change_pushes_only_zoom() { - // User toggled zoom to 150 % on a session that's already at the - // right size and DPR — only `set_page_zoom` should fire. - let change = ViewportChange::between( - &request(2880, 1800, 2.0, 150), - &session(2880, 1800, 2.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT), - ); - assert_eq!( - change, - ViewportChange { resize: None, set_hidpi: None, set_page_zoom: Some(150) }, + fn hung_sidecar_client() -> Result { + let script = format!( + "IFS= read -r request; printf '%s\\n' '{{\"protocol_version\":{LIVE_PROTOCOL_VERSION},\"error\":null,\"frame\":null}}'; while IFS= read -r request; do :; done" ); + let child = spawn_script(&script).map_err(ServoLiveError::Command)?; + ServoLiveClient::from_spawned_child(child, TEST_TIMEOUTS) } - #[test] - fn cross_monitor_dpr_change_pushes_only_hidpi() { - // A window dragged from a 2.0-DPR monitor to a 1.0-DPR one keeps - // the same physical surface (GPUI re-emits the viewport at the - // new scale) — only the DPR push should fire. - let change = ViewportChange::between( - &request(2880, 1800, 1.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT), - &session(2880, 1800, 2.0, SERVO_DEFAULT_PAGE_ZOOM_PERCENT), - ); - assert_eq!( - change, - ViewportChange { resize: None, set_hidpi: Some(1.0), set_page_zoom: None }, - ); + fn spawn_script(script: &str) -> Result { + Command::new("sh") + .arg("-c") + .arg(script) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() } } + +#[cfg(all(test, target_os = "macos"))] +#[path = "servo_live_iosurface_tests.rs"] +mod iosurface_tests; diff --git a/crates/ely_app/src/services/servo_live_iosurface_importer.rs b/crates/ely_app/src/services/servo_live_iosurface_importer.rs new file mode 100644 index 0000000..2f39936 --- /dev/null +++ b/crates/ely_app/src/services/servo_live_iosurface_importer.rs @@ -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>, + result_rx: mpsc::Receiver, + shutdown: Arc, + thread: Option>, +} + +impl IOSurfaceImportWorker { + pub(super) fn new(receiver: IOSurfaceMachReceiver) -> Result { + 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 { + 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, + result_tx: mpsc::Sender, + shutdown: Arc, +) { + run_import_requests(request_rx, result_tx, shutdown, |handle| { + import_surface_handle(&mut receiver, handle) + }); +} + +fn run_import_requests( + request_rx: mpsc::Receiver, + result_tx: mpsc::Sender, + shutdown: Arc, + 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 } + } +} diff --git a/crates/ely_app/src/services/servo_live_iosurface_tests.rs b/crates/ely_app/src/services/servo_live_iosurface_tests.rs new file mode 100644 index 0000000..876d252 --- /dev/null +++ b/crates/ely_app/src/services/servo_live_iosurface_tests.rs @@ -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"), + }) +} diff --git a/crates/ely_app/src/services/servo_live_ipc.rs b/crates/ely_app/src/services/servo_live_ipc.rs new file mode 100644 index 0000000..411df31 --- /dev/null +++ b/crates/ely_app/src/services/servo_live_ipc.rs @@ -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>, + thread: Option>, +} + +pub(super) struct IpcReply { + pub(super) protocol_version: Option, + pub(super) error: Option, + pub(super) frame: Option, + pub(super) surface_handle: Option, + pub(super) current_surface_id: Option, +} + +struct IpcRequest { + request: LiveRequest, + response: SyncSender>, +} + +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 { + 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, + requests: Receiver, +) { + 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, + request: &LiveRequest, +) -> Result { + 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 { + 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 { + 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" + ) + } +} diff --git a/crates/ely_app/src/services/servo_live_types.rs b/crates/ely_app/src/services/servo_live_types.rs index 9280bb2..da908cb 100644 --- a/crates/ely_app/src/services/servo_live_types.rs +++ b/crates/ely_app/src/services/servo_live_types.rs @@ -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, pub(crate) scroll_delta_x: i32, pub(crate) scroll_delta_y: i32, pub(crate) scroll_point_x: Option, @@ -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>, + #[cfg(target_os = "macos")] + hardware_surface: Option>, + #[cfg(target_os = "macos")] + hardware_surface_id: Option, } 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>) -> 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>, + ) { + 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 { + self.hardware_surface_id + } + + #[cfg(target_os = "macos")] + #[must_use] + pub(crate) fn hardware_surface(&self) -> Option<&Arc> { + 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) -> 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, + render_state: &str, + ) -> Self { #[cfg(all(test, feature = "live-site-smoke"))] let summary = ely_servo_host::RenderedFrameSummary::from_rgba_bytes(width, height, &rgba_bytes); Self { loaded_url: Some("https://example.com/".to_string()), title: Some("Example".to_string()), - render_state: "complete".to_string(), + render_state: render_state.to_string(), width, height, device_pixel_ratio: 1.0, css_viewport_width: width, css_viewport_height: height, + pixels_changed: true, #[cfg(all(test, feature = "live-site-smoke"))] non_white_pixel_count: summary.non_white_pixel_count(), #[cfg(all(test, feature = "live-site-smoke"))] @@ -202,49 +230,197 @@ impl ServoLiveFrame { #[cfg(all(test, feature = "live-site-smoke"))] sample_hash: summary.sample_hash(), rgba_bytes: Some(rgba_bytes), + #[cfg(target_os = "macos")] + hardware_surface: None, + #[cfg(target_os = "macos")] + hardware_surface_id: None, } } -} -fn render_state_label(state: &WebViewState) -> &'static str { - match state { - WebViewState::Created => "created", - WebViewState::Loading => "loading", - WebViewState::Complete => "complete", - WebViewState::Sleeping => "sleeping", - WebViewState::Crashed => "crashed", + #[cfg(test)] + pub(crate) fn for_test_with_title( + width: u32, + height: u32, + rgba_bytes: Vec, + render_state: &str, + title: &str, + ) -> Self { + let mut frame = Self::for_test_with_render_state(width, height, rgba_bytes, render_state); + frame.title = Some(title.to_string()); + frame + } + + #[cfg(all(test, target_os = "macos"))] + pub(crate) fn for_test_with_pixel_buffer( + width: u32, + height: u32, + surface_id: u64, + pixel_buffer: CVPixelBuffer, + ) -> Self { + Self::for_test_with_hardware_change(width, height, surface_id, pixel_buffer, true) + } + + #[cfg(all(test, target_os = "macos"))] + pub(crate) fn for_test_with_hardware_change( + width: u32, + height: u32, + surface_id: u64, + pixel_buffer: CVPixelBuffer, + pixels_changed: bool, + ) -> Self { + let mut frame = Self::for_test(width, height, Vec::new()); + frame.rgba_bytes = None; + frame.hardware_surface = Some(HardwareSurfaceBacking::new(pixel_buffer)); + frame.hardware_surface_id = Some(surface_id); + frame.pixels_changed = pixels_changed; + frame } } -fn css_viewport_size(width: u32, height: u32, device_pixel_ratio: f32) -> (u32, u32) { - let scale = if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 { - device_pixel_ratio +fn css_viewport_size_from_report(report: &LiveFrameReport) -> (u32, u32) { + let scale = if report.device_pixel_ratio.is_finite() && report.device_pixel_ratio > 0.0 { + report.device_pixel_ratio } else { 1.0 }; + let fallback_width = ((report.width as f32) / scale).round().max(1.0) as u32; + let fallback_height = ((report.height as f32) / scale).round().max(1.0) as u32; ( - ((width as f32) / scale).round().max(1.0) as u32, - ((height as f32) / scale).round().max(1.0) as u32, + if report.css_viewport_width > 0 { report.css_viewport_width } else { fallback_width }, + if report.css_viewport_height > 0 { report.css_viewport_height } else { fallback_height }, ) } #[derive(Debug, Error)] pub(crate) enum ServoLiveError { - #[error("servo native surface is unavailable")] - NativeSurfaceUnavailable, + #[error("servo sidecar binary is unavailable at {path}")] + SidecarBinaryUnavailable { path: PathBuf }, - #[error("servo scroll input is missing a viewport point")] - MissingScrollPoint, + #[error("failed to run servo live sidecar: {0}")] + Command(#[source] io::Error), + + #[error("servo live sidecar pipe is unavailable: {name}")] + PipeUnavailable { name: &'static str }, + + #[error("servo live sidecar exited")] + SidecarExited, + + #[error("servo live sidecar protocol mismatch: expected {expected}, received {actual:?}")] + ProtocolVersionMismatch { expected: u32, actual: Option }, + + #[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)); } } diff --git a/crates/ely_app/src/services/servo_live_wire.rs b/crates/ely_app/src/services/servo_live_wire.rs new file mode 100644 index 0000000..06cd210 --- /dev/null +++ b/crates/ely_app/src/services/servo_live_wire.rs @@ -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, + scroll_point_y: Option, + click_x: Option, + click_y: Option, + hover_x: Option, + hover_y: Option, + typed_text: Option, + site_permissions: Vec, + ready_surface_ids: Vec, + pending_surface_ids: Vec, + }, + Poll { + tab_id: String, + ready_surface_ids: Vec, + pending_surface_ids: Vec, + }, + Close { + tab_id: String, + }, + Shutdown, +} + +#[derive(Deserialize)] +pub(super) struct LiveResponse { + #[serde(default)] + pub(super) protocol_version: Option, + pub(super) error: Option, + pub(super) frame: Option, + #[serde(default)] + pub(super) surface_handle: Option, + #[serde(default)] + pub(super) current_surface_id: Option, +} + +#[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, + pub(super) title: Option, + 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(()) + } +} diff --git a/crates/ely_app/src/services/servo_profile_data.rs b/crates/ely_app/src/services/servo_profile_data.rs index 575b101..0cbe43c 100644 --- a/crates/ely_app/src/services/servo_profile_data.rs +++ b/crates/ely_app/src/services/servo_profile_data.rs @@ -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, + lease: Option, + 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 { 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 { + 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 { - 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 { + 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 { + 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 { + 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 { + match file.try_lock() { + Ok(()) => Ok(true), + Err(std::fs::TryLockError::WouldBlock) => Ok(false), + Err(std::fs::TryLockError::Error(error)) => Err(error), + } +} + +fn create_private_directory(path: &Path) -> Result<(), io::Error> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + match std::fs::create_dir(path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.file_type().is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("private directory path is not a directory: {}", path.display()), + )); + } + } + Err(error) => return Err(error), + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + } + Ok(()) } #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] @@ -52,19 +222,46 @@ pub(crate) enum ProfileDataMode { #[cfg(test)] mod tests { - use super::{profile_data_dir, sync_profile_data_dir}; - use ely_domain::{ProfileId, ProfileKind}; + use std::{cell::Cell, io}; + + use super::{ + TRANSIENT_PROFILE_LEASE, TRANSIENT_PROFILE_PREFIX, + cleanup_stale_transient_profile_data_dirs_at, + cleanup_stale_transient_profile_data_dirs_locked, create_profile_data_dir, open_lock_file, + profile_data_dir, sync_profile_data_dir, transient_profile_data_dir, + transient_profile_data_root, + }; + use ely_domain::ProfileId; #[test] - fn default_standard_sync_profile_dir_is_stable() { + fn default_standard_sync_profile_dir_keeps_profile_identity() { let root = std::path::Path::new("/profiles"); let first_id = ProfileId::new(); let second_id = ProfileId::new(); - assert_eq!( - sync_profile_data_dir(root, &first_id, "Default", &ProfileKind::Standard), - sync_profile_data_dir(root, &second_id, "Default", &ProfileKind::Standard) - ); + assert_eq!(sync_profile_data_dir(root, &first_id), profile_data_dir(root, &first_id)); + assert_eq!(sync_profile_data_dir(root, &second_id), profile_data_dir(root, &second_id)); + assert_ne!(profile_data_dir(root, &first_id), profile_data_dir(root, &second_id)); + } + + #[test] + fn persistent_profile_directory_is_private() -> Result<(), std::io::Error> { + let directory = tempfile::tempdir()?; + let profile_id = ProfileId::new(); + let servo_dir = create_profile_data_dir(directory.path(), &profile_id)?; + + assert_eq!(servo_dir, profile_data_dir(directory.path(), &profile_id)); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(directory.path().join(profile_id.as_str()))?.permissions().mode() + & 0o777, + 0o700 + ); + assert_eq!(std::fs::metadata(servo_dir)?.permissions().mode() & 0o777, 0o700); + } + Ok(()) } #[test] @@ -72,9 +269,124 @@ mod tests { let root = std::path::Path::new("/profiles"); let profile_id = ProfileId::new(); + assert_eq!(sync_profile_data_dir(root, &profile_id), profile_data_dir(root, &profile_id)); + } + + #[test] + fn transient_profile_directory_is_private_and_removed_on_close() -> Result<(), io::Error> { + let directory = transient_profile_data_dir(&ProfileId::new())?; + let path = directory.path().to_path_buf(); + assert!(path.is_dir()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&path)?.permissions().mode() & 0o777, 0o700); + } + + directory.close()?; + + assert!(!path.exists()); + Ok(()) + } + + #[test] + fn transient_profile_root_uses_the_user_project_directory() -> Result<(), io::Error> { + let project_dirs = directories::ProjectDirs::from( + super::ELY_QUALIFIER, + super::ELY_ORGANIZATION, + super::ELY_APPLICATION, + ) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "project directory unavailable"))?; + let expected_base = project_dirs.runtime_dir().unwrap_or_else(|| project_dirs.cache_dir()); + assert_eq!( - sync_profile_data_dir(root, &profile_id, "Personal", &ProfileKind::Standard), - profile_data_dir(root, &profile_id) + transient_profile_data_root()?, + expected_base.join(super::TRANSIENT_PROFILE_ROOT) ); + Ok(()) + } + + #[test] + fn startup_cleanup_preserves_leased_profile_and_removes_stale_profile() -> Result<(), io::Error> + { + let root = tempfile::tempdir()?; + let active = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}active")); + let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale")); + std::fs::create_dir_all(&active)?; + std::fs::create_dir_all(&stale)?; + let active_lease = open_lock_file(&active.join(TRANSIENT_PROFILE_LEASE))?; + super::lock_file(&active_lease)?; + open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?; + + cleanup_stale_transient_profile_data_dirs_at(root.path())?; + + assert!(active.is_dir()); + assert!(!stale.exists()); + Ok(()) + } + + #[test] + fn cleanup_failure_keeps_stale_profile_for_retry() -> Result<(), io::Error> { + let root = tempfile::tempdir()?; + let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale")); + std::fs::create_dir_all(&stale)?; + open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?; + let attempts = Cell::new(0_u8); + + let result = cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| { + attempts.set(attempts.get() + 1); + if attempts.get() == 1 { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected failure")); + } + std::fs::remove_dir_all(path) + }); + let Err(error) = result else { + return Err(io::Error::other("the first cleanup attempt succeeded")); + }; + + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert!(stale.is_dir()); + cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| { + std::fs::remove_dir_all(path) + })?; + assert!(!stale.exists()); + Ok(()) + } + + #[test] + fn cleanup_accepts_a_profile_removed_concurrently() -> Result<(), io::Error> { + let root = tempfile::tempdir()?; + let stale = root.path().join(format!("{TRANSIENT_PROFILE_PREFIX}stale")); + std::fs::create_dir(&stale)?; + open_lock_file(&stale.join(TRANSIENT_PROFILE_LEASE))?; + + cleanup_stale_transient_profile_data_dirs_locked(root.path(), |path| { + std::fs::remove_dir_all(path)?; + Err(io::Error::new(io::ErrorKind::NotFound, "removed by cleanup worker")) + })?; + + assert!(!stale.exists()); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn cleanup_rejects_a_symlinked_transient_root() -> Result<(), io::Error> { + use std::os::unix::fs::symlink; + + let parent = tempfile::tempdir()?; + let victim = tempfile::tempdir()?; + let stale = victim.path().join(format!("{TRANSIENT_PROFILE_PREFIX}victim")); + std::fs::create_dir(&stale)?; + let root = parent.path().join("transient-root"); + symlink(victim.path(), &root)?; + + let Err(error) = cleanup_stale_transient_profile_data_dirs_at(&root) else { + return Err(io::Error::other("symlinked transient root was accepted")); + }; + + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert!(stale.is_dir()); + Ok(()) } } diff --git a/crates/ely_app/src/services/servo_sidecar_command.rs b/crates/ely_app/src/services/servo_sidecar_command.rs new file mode 100644 index 0000000..1702cce --- /dev/null +++ b/crates/ely_app/src/services/servo_sidecar_command.rs @@ -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 { + 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 { + option_env!("ELY_WORKSPACE_MANIFEST").filter(|path| !path.is_empty()).map(PathBuf::from) +} + +fn workspace_target_sidecar_path(manifest_path: &Path) -> Option { + 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"))); + } +} diff --git a/crates/ely_app/src/shell/auth.rs b/crates/ely_app/src/shell/auth.rs index f30cbb8..18cb59a 100644 --- a/crates/ely_app/src/shell/auth.rs +++ b/crates/ely_app/src/shell/auth.rs @@ -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(""); diff --git a/crates/ely_app/src/shell/gpui_harness_tests_c.rs b/crates/ely_app/src/shell/gpui_harness_tests_c.rs index 98611c9..d9d209c 100644 --- a/crates/ely_app/src/shell/gpui_harness_tests_c.rs +++ b/crates/ely_app/src/shell/gpui_harness_tests_c.rs @@ -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(()) } diff --git a/crates/ely_app/src/shell/internal_pages/about.rs b/crates/ely_app/src/shell/internal_pages/about.rs index c41a77b..e26dea1 100644 --- a/crates/ely_app/src/shell/internal_pages/about.rs +++ b/crates/ely_app/src/shell/internal_pages/about.rs @@ -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", diff --git a/crates/ely_app/src/shell/internal_pages/site_compatibility.rs b/crates/ely_app/src/shell/internal_pages/site_compatibility.rs index c4aadf2..45b434d 100644 --- a/crates/ely_app/src/shell/internal_pages/site_compatibility.rs +++ b/crates/ely_app/src/shell/internal_pages/site_compatibility.rs @@ -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", diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 047f489..c0d801d 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -121,15 +121,28 @@ pub struct ElyShell { impl ElyShell { pub fn new(window: &mut Window, cx: &mut Context) -> 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::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, + config: Result, window: &mut Window, cx: &mut Context, ) -> 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(); diff --git a/crates/ely_app/src/shell/settings_actions.rs b/crates/ely_app/src/shell/settings_actions.rs index adb1028..05309c6 100644 --- a/crates/ely_app/src/shell/settings_actions.rs +++ b/crates/ely_app/src/shell/settings_actions.rs @@ -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) => { diff --git a/crates/ely_app/src/shell/sync_state.rs b/crates/ely_app/src/shell/sync_state.rs index 9283686..41c7772 100644 --- a/crates/ely_app/src/shell/sync_state.rs +++ b/crates/ely_app/src/shell/sync_state.rs @@ -116,8 +116,6 @@ impl ElyShell { let profile_dir = crate::services::servo_profile_data::sync_profile_data_dir( &profile_root, &snapshot.active_profile_id, - &snapshot.active_profile_name, - &snapshot.active_profile_kind, ); if snapshot.active_profile_name == "Default" && matches!(snapshot.active_profile_kind, ProfileKind::Standard) @@ -237,27 +235,18 @@ fn migrate_legacy_default_sync_dir(profile_root: &Path, stable_profile_dir: &Pat return; } - let Ok(entries) = std::fs::read_dir(profile_root) else { - return; - }; - for entry in entries.flatten() { - let candidate = entry.path().join("servo").join("sync"); - if candidate == stable_sync_dir { - continue; - } - if !bearer_token_file_present(&candidate.join("bearer.token")) { - continue; - } - if let Err(error) = copy_dir_recursive(&candidate, &stable_sync_dir) { - tracing::warn!( - target: "ely::sync", - error = %error, - source = %candidate.display(), - "legacy sync profile migration failed", - ); - } + let candidate = profile_root.join("default").join("servo").join("sync"); + if !bearer_token_file_present(&candidate.join("bearer.token")) { return; } + if let Err(error) = copy_dir_recursive(&candidate, &stable_sync_dir) { + tracing::warn!( + target: "ely::sync", + error = %error, + source = %candidate.display(), + "legacy sync profile migration failed", + ); + } } fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()> { @@ -277,7 +266,7 @@ fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()> #[cfg(test)] mod tests { - use super::bearer_token_file_present; + use super::{bearer_token_file_present, migrate_legacy_default_sync_dir}; #[test] fn bearer_token_file_presence_requires_bytes() -> Result<(), Box> { @@ -297,4 +286,33 @@ mod tests { std::fs::remove_dir_all(dir)?; Ok(()) } + + #[test] + fn known_default_sync_directory_is_migrated() -> Result<(), Box> { + 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> { + 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(()) + } } diff --git a/crates/ely_app/src/shell/web_surface.rs b/crates/ely_app/src/shell/web_surface.rs index 76324d5..99a6f44 100644 --- a/crates/ely_app/src/shell/web_surface.rs +++ b/crates/ely_app/src/shell/web_surface.rs @@ -35,10 +35,23 @@ impl WebSurfaceStore { Self { runtime, surfaces: BTreeMap::new(), keyboard_focus: None } } + #[cfg(test)] pub(super) fn state(&self, tab_id: &TabId) -> Option<&WebSurfaceState> { self.surfaces.get(tab_id).and_then(|surface| surface.state.as_ref()) } + pub(super) fn state_for_scope( + &self, + tab_id: &TabId, + profile_id: &ely_domain::ProfileId, + profile_data_mode: ProfileDataMode, + ) -> Option<&WebSurfaceState> { + self.surfaces + .get(tab_id) + .filter(|surface| surface.has_scope(profile_id, profile_data_mode)) + .and_then(|surface| surface.state.as_ref()) + } + pub(super) fn ensure_surface( &mut self, tab: &BrowserTab, @@ -53,23 +66,28 @@ impl WebSurfaceStore { else { return false; }; - let native_surface = - self.surfaces.get(tab.id()).and_then(|surface| surface.native_surface.clone()); - #[cfg(not(test))] - let Some(native_surface) = native_surface else { - return false; - }; let ensure_key = WebSurfaceEnsureKey::new( requested_url.clone(), size, - #[cfg(test)] - native_surface.as_ref(), - #[cfg(not(test))] - Some(&native_surface), + tab.profile_id().clone(), + profile_data_mode, tab.zoom_percent(), permissions, ); - if self.surfaces.get(tab.id()).is_some_and(|surface| !surface.should_ensure(&ensure_key)) { + let scope_changed = self.surface_mut(tab.id()).reset_for_scope_change(&ensure_key); + if scope_changed + && self.keyboard_focus.as_ref().is_some_and(|focus| focus.tab_id == *tab.id()) + { + self.keyboard_focus = None; + } + let runtime_has_session = + self.runtime.has_session(tab.id(), tab.profile_id(), profile_data_mode); + if runtime_has_session + && self + .surfaces + .get(tab.id()) + .is_some_and(|surface| !surface.should_ensure(&ensure_key)) + { return false; } // Once the page has ensured at least once, defer further ensures @@ -89,31 +107,23 @@ impl WebSurfaceStore { { return false; } - let input = self.take_pending_input(tab.id(), requested_url.as_str()); let previous_frame = self.previous_ready_frame(tab.id(), requested_url.as_str(), tab.zoom_percent()); + if let Err(message) = + self.runtime.prepare_tab_scope(tab.id(), tab.profile_id(), profile_data_mode) + { + return self.record_ensure_failure( + tab.id(), + ensure_key, + requested_url, + previous_frame, + message, + ); + } + let input = self.take_pending_input(tab.id(), requested_url.as_str()); - #[cfg(test)] - let ensure_result = match native_surface { - Some(native_surface) => self.runtime.ensure_tab_with_native_surface( - tab, - size, - native_surface, - profile_data_mode, - permissions, - input, - ), - None => self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input), - }; - #[cfg(not(test))] - let ensure_result = self.runtime.ensure_tab_with_native_surface( - tab, - size, - native_surface, - profile_data_mode, - permissions, - input, - ); + let ensure_result = + self.runtime.ensure_tab(tab, size, profile_data_mode, permissions, input); match ensure_result { Ok(result) => { @@ -127,9 +137,35 @@ impl WebSurfaceStore { } false } - Err(message) => { - self.surface_mut(tab.id()).mark_ensured(ensure_key); - self.surface_mut(tab.id()).state = Some(WebSurfaceState::Failed { message }); + Err(message) => self.record_ensure_failure( + tab.id(), + ensure_key, + requested_url, + previous_frame, + message, + ), + } + } + + fn record_ensure_failure( + &mut self, + tab_id: &TabId, + ensure_key: WebSurfaceEnsureKey, + requested_url: String, + previous_frame: Option, + 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, + ) -> 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; diff --git a/crates/ely_app/src/shell/web_surface_cadence.rs b/crates/ely_app/src/shell/web_surface_cadence.rs index ff08642..7759b8e 100644 --- a/crates/ely_app/src/shell/web_surface_cadence.rs +++ b/crates/ely_app/src/shell/web_surface_cadence.rs @@ -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 + ); + } } diff --git a/crates/ely_app/src/shell/web_surface_controller.rs b/crates/ely_app/src/shell/web_surface_controller.rs index 4eb20df..da94dd8 100644 --- a/crates/ely_app/src/shell/web_surface_controller.rs +++ b/crates/ely_app/src/shell/web_surface_controller.rs @@ -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, ) -> 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, - scale_factor: f32, - native_surface: NativeSurfaceHandle, - cx: &mut Context, - ) { - 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, diff --git a/crates/ely_app/src/shell/web_surface_frame.rs b/crates/ely_app/src/shell/web_surface_frame.rs index 8af9a3d..1b075e9 100644 --- a/crates/ely_app/src/shell/web_surface_frame.rs +++ b/crates/ely_app/src/shell/web_surface_frame.rs @@ -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)>> = + static LAST_FRAME_IMAGE: RefCell> = const { RefCell::new(None) }; } +struct CachedRenderImage { + width: u32, + height: u32, + bytes_hash: u64, + image: Arc, +} + #[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, typed_text: Option, + 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>, + #[cfg(target_os = "macos")] + pub(super) hardware_surface: Option>, + #[cfg(target_os = "macos")] + hardware_surface_id: Option, } impl WebSurfaceFrame { @@ -53,6 +69,10 @@ impl WebSurfaceFrame { zoom_percent: u16, frame: ServoLiveFrame, ) -> Result { + #[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 { + #[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 { - #[cfg(all(test, feature = "live-site-smoke"))] - { - Ok(self.non_white_pixel_count > 0 && self.content_pixel_count > 0) - } - #[cfg(not(all(test, feature = "live-site-smoke")))] - { - Ok(true) - } - } - #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn non_white_pixel_count(&self) -> u64 { self.non_white_pixel_count @@ -229,9 +270,21 @@ impl WebSurfaceFrame { self.sample_hash } - #[cfg(all(test, feature = "live-site-smoke"))] + #[cfg(test)] pub(super) fn has_hardware_surface(&self) -> bool { - false + #[cfg(target_os = "macos")] + { + self.hardware_surface.is_some() + } + #[cfg(not(target_os = "macos"))] + { + false + } + } + + #[cfg(all(test, feature = "live-site-smoke", target_os = "macos"))] + pub(super) fn hardware_surface_id_for_test(&self) -> Option { + self.hardware_surface_id } } @@ -249,6 +302,7 @@ struct WebSurfaceFrameParts { zoom_percent: u16, click_point: Option, typed_text: Option, + 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>, + #[cfg(target_os = "macos")] + hardware_surface: Option>, + #[cfg(target_os = "macos")] + hardware_surface_id: Option, } #[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, WebSurfaceError> { LAST_FRAME_IMAGE.with(|cache| -> Result, 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::, _>::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; diff --git a/crates/ely_app/src/shell/web_surface_frame_cache_tests.rs b/crates/ely_app/src/shell/web_surface_frame_cache_tests.rs new file mode 100644 index 0000000..d739cb9 --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_frame_cache_tests.rs @@ -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(()) +} diff --git a/crates/ely_app/src/shell/web_surface_frame_hardware_tests.rs b/crates/ely_app/src/shell/web_surface_frame_hardware_tests.rs new file mode 100644 index 0000000..dd0e2b3 --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_frame_hardware_tests.rs @@ -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::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, + ), + ) +} diff --git a/crates/ely_app/src/shell/web_surface_hardware_import_tests.rs b/crates/ely_app/src/shell/web_surface_hardware_import_tests.rs new file mode 100644 index 0000000..880af0b --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_hardware_import_tests.rs @@ -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> { + 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> { + 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> { + 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> { + 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> { + 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 { + Bounds::new(point(px(0.0), px(0.0)), size(px(PROBE_WIDTH as f32), px(PROBE_HEIGHT as f32))) +} diff --git a/crates/ely_app/src/shell/web_surface_input.rs b/crates/ely_app/src/shell/web_surface_input.rs index 7ff8f39..8c564bd 100644 --- a/crates/ely_app/src/shell/web_surface_input.rs +++ b/crates/ely_app/src/shell/web_surface_input.rs @@ -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, diff --git a/crates/ely_app/src/shell/web_surface_live_site_tests.rs b/crates/ely_app/src/shell/web_surface_live_site_tests.rs index 876eb89..efd3770 100644 --- a/crates/ely_app/src/shell/web_surface_live_site_tests.rs +++ b/crates/ely_app/src/shell/web_surface_live_site_tests.rs @@ -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()?; diff --git a/crates/ely_app/src/shell/web_surface_permissions.rs b/crates/ely_app/src/shell/web_surface_permissions.rs index 5e715d7..ab16362 100644 --- a/crates/ely_app/src/shell/web_surface_permissions.rs +++ b/crates/ely_app/src/shell/web_surface_permissions.rs @@ -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)?); diff --git a/crates/ely_app/src/shell/web_surface_profile_isolation_tests.rs b/crates/ely_app/src/shell/web_surface_profile_isolation_tests.rs new file mode 100644 index 0000000..441df30 --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_profile_isolation_tests.rs @@ -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> { + 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> { + 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 { + 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, + shutdown: Arc, + error: Arc>>, + thread: Option>, +} + +impl ProfileProbeServer { + pub(super) fn start() -> Result> { + 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> { + 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, + shutdown: Arc, + error: Arc>>, +) { + 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>>, 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, 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!( + "loading
loading
", + ) +} diff --git a/crates/ely_app/src/shell/web_surface_runtime.rs b/crates/ely_app/src/shell/web_surface_runtime.rs index fc2acf9..ee162d8 100644 --- a/crates/ely_app/src/shell/web_surface_runtime.rs +++ b/crates/ely_app/src/shell/web_surface_runtime.rs @@ -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, - direct_client: Option, - pending_direct_responses: Vec, + workers: BTreeMap, sessions: BTreeMap, + retry_state: BTreeMap, + retired_workers: Vec>>, + transient_cleanup_error: Option, 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 { - 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 { - 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, - profile_data_mode: ProfileDataMode, - permissions: &[WebSurfaceSitePermission], - input: WebSurfacePendingInput, ) -> Result { let scope = WebSurfaceRuntimeScope::new(tab.profile_id().clone(), profile_data_mode); - let use_direct_client = native_surface.is_some(); - if use_direct_client { - self.ensure_direct_client(scope.clone())?; - } else { - self.ensure_worker(scope.clone())?; - } + self.prepare_tab_scope(tab.id(), tab.profile_id(), profile_data_mode)?; let requested_url = tab.url().as_str().to_string(); - let tab_id_string = tab.id().as_str().to_string(); let zoom_percent = tab.zoom_percent(); let enqueued_at = input.enqueued_at; let input_kind = pending_input_kind(&input); @@ -119,6 +92,7 @@ impl WebSurfaceRuntime { scroll_wire_fields(input.scroll_delta, input.scroll_point)?; let user_navigation_input = input_requests_history_navigation(&input); let next_scroll_offset = input.scroll_offset; + let generation = self.next_request_generation(); let submitted_at = Instant::now(); let started_loading = { @@ -134,6 +108,7 @@ impl WebSurfaceRuntime { session.size = size; session.zoom_percent = zoom_percent; session.scroll_offset = next_scroll_offset; + session.generation = Some(generation); session.cadence.note_ensure(input_kind, started_loading, submitted_at); started_loading }; @@ -146,7 +121,6 @@ impl WebSurfaceRuntime { height: size.height, page_zoom_percent: zoom_percent, device_pixel_ratio: size.device_pixel_ratio_f32(), - native_surface, scroll_delta_x, scroll_delta_y, scroll_point_x, @@ -159,15 +133,10 @@ impl WebSurfaceRuntime { site_permissions: permissions.iter().map(ServoLiveSitePermission::from).collect(), }; - if use_direct_client { - let response = self.ensure_direct(request, tab_id_string.clone())?; - self.pending_direct_responses.extend(response); - } else { - let Some(scoped) = self.worker.as_ref() else { - return Err("Servo worker was created but is no longer registered".to_string()); - }; - scoped.worker.submit_ensure(request); - } + let Some(scoped) = self.workers.get(&scope) else { + return Err("Servo sidecar worker was created but is no longer registered".to_string()); + }; + scoped.worker.submit_ensure(generation, request); if let Some(session) = self.sessions.get_mut(tab.id()) { session.cadence.note_poll_submitted(submitted_at); } @@ -177,42 +146,45 @@ impl WebSurfaceRuntime { } pub(super) fn tick(&mut self, visible_tab_ids: &[TabId]) -> Vec { + 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::>(); + let mut unavailable_scopes = Vec::new(); + + for scope in scopes { + let responses = self + .workers + .get(&scope) + .map(|scoped| scoped.worker.drain_responses()) + .unwrap_or_default(); + if self.collect_responses(&scope, responses, now, &mut frames) { + unavailable_scopes.push(scope); + } + } + for scope in unavailable_scopes { + self.invalidate_scope(&scope, now); } let poll_now = Instant::now(); - let mut direct_polls = Vec::new(); for tab_id in visible_tab_ids { - let Some(session) = self.sessions.get_mut(tab_id) else { + let Some(scope) = self.sessions.get(tab_id).and_then(|session| { + session.cadence.should_poll(poll_now).then(|| session.scope.clone()) + }) else { continue; }; - if !session.cadence.should_poll(poll_now) { + if !self.workers.contains_key(&scope) { continue; } - if let Some(scoped) = self.worker.as_ref() { - let _ = scoped.worker.submit_poll(tab_id.as_str().to_string()); + let generation = self.next_request_generation(); + let submitted = self.workers.get(&scope).is_some_and(|scoped| { + scoped.worker.submit_poll(generation, tab_id.as_str().to_string()) + }); + if let Some(session) = self.sessions.get_mut(tab_id) { + if submitted { + session.generation = Some(generation); + } session.cadence.note_poll_submitted(poll_now); - } else if self.direct_client.is_some() { - direct_polls.push(tab_id.as_str().to_string()); - session.cadence.note_poll_submitted(poll_now); - } - } - - if !direct_polls.is_empty() { - let (responses, runtime_unavailable) = self.poll_direct(direct_polls); - let unavailable_from_responses = - self.collect_responses(responses, Instant::now(), &mut frames); - if runtime_unavailable || unavailable_from_responses { - self.remove_direct_client(); } } @@ -227,95 +199,104 @@ impl WebSurfaceRuntime { visible_tab_ids .iter() .filter_map(|tab_id| self.sessions.get(tab_id)) - .filter(|_| self.worker.is_some() || self.direct_client.is_some()) + .filter(|session| self.workers.contains_key(&session.scope)) .map(|session| session.cadence.next_poll_delay(now)) .min() } pub(super) fn close_tab(&mut self, tab_id: &TabId) { - if self.sessions.remove(tab_id).is_none() { + let Some(session) = self.sessions.remove(tab_id) else { return; - } - let direct_result = self - .direct_client - .as_mut() - .map(|scoped| scoped.client.close(tab_id.as_str().to_string())); - if direct_result.as_ref().is_some_and(|result| { - result.as_ref().is_err_and(|error| error.is_runtime_unavailable()) - }) { - self.remove_direct_client(); - } - if let Some(scoped) = self.worker.as_ref() { + }; + let has_remaining_session = + self.sessions.values().any(|candidate| candidate.scope == session.scope); + if session.scope.is_transient() && !has_remaining_session { + self.remove_worker(&session.scope); + } else if let Some(scoped) = self.workers.get(&session.scope) { scoped.worker.submit_close(tab_id.as_str().to_string()); } } + pub(super) fn has_session( + &self, + tab_id: &TabId, + profile_id: &ely_domain::ProfileId, + profile_data_mode: ProfileDataMode, + ) -> bool { + self.sessions.get(tab_id).is_some_and(|session| { + session.scope == WebSurfaceRuntimeScope::new(profile_id.clone(), profile_data_mode) + && self.workers.contains_key(&session.scope) + }) + } + + pub(super) fn prepare_tab_scope( + &mut self, + tab_id: &TabId, + profile_id: &ely_domain::ProfileId, + profile_data_mode: ProfileDataMode, + ) -> Result<(), String> { + let scope = WebSurfaceRuntimeScope::new(profile_id.clone(), profile_data_mode); + self.detach_tab_from_previous_scope(tab_id, &scope); + self.ensure_worker(scope.clone())?; + session_for_scope(&mut self.sessions, tab_id, scope); + Ok(()) + } + fn ensure_worker(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> { - if self.worker.is_some() { + self.reap_retired_workers(false); + if self.workers.contains_key(&scope) { return Ok(()); } + if scope.is_transient() && self.transient_cleanup_error.is_some() { + match cleanup_stale_transient_profile_data_dirs() { + Ok(()) => self.transient_cleanup_error = None, + Err(error) => { + let message = error.to_string(); + self.transient_cleanup_error = Some(message.clone()); + return Err(message); + } + } + } + let now = Instant::now(); + if let Some(retry) = self.retry_state.get(&scope) + && now < retry.retry_after + { + return Err("Servo sidecar restart is cooling down".to_string()); + } let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?; let client_factory = self.client_factory; - let worker = LiveRuntimeWorker::new(move || client_factory(config_dir))?; - self.worker = Some(ScopedWorker { worker, transient_profile_data_dir }); - Ok(()) - } - - fn ensure_direct_client(&mut self, scope: WebSurfaceRuntimeScope) -> Result<(), String> { - if self.direct_client.is_some() { - return Ok(()); - } - let (config_dir, transient_profile_data_dir) = config_dir_for_scope(&scope)?; - let client = (self.client_factory)(config_dir)?; - self.direct_client = Some(ScopedDirectClient { client, transient_profile_data_dir }); - Ok(()) - } - - fn ensure_direct( - &mut self, - request: ServoLiveEnsureRequest, - tab_id: String, - ) -> Result, String> { - let Some(scoped) = self.direct_client.as_mut() else { - return Err("Servo client was created but is no longer registered".to_string()); - }; - match scoped.client.ensure(request) { - Ok(Some(frame)) => Ok(Some(WorkerResponse::Frame { tab_id, frame })), - Ok(None) => Ok(None), + let worker = match LiveRuntimeWorker::new(move || client_factory(config_dir)) { + Ok(worker) => worker, Err(error) => { - let message = error.to_string(); - if error.is_runtime_unavailable() { - self.remove_direct_client(); - } - Err(message) + self.note_scope_failure(&scope, now); + return Err(error); } - } + }; + self.workers.insert(scope, ScopedWorker { worker, transient_profile_data_dir }); + Ok(()) } - fn poll_direct(&mut self, tab_ids: Vec) -> (Vec, bool) { - let Some(scoped) = self.direct_client.as_mut() else { - return (Vec::new(), false); + fn detach_tab_from_previous_scope(&mut self, tab_id: &TabId, scope: &WebSurfaceRuntimeScope) { + let Some(previous_scope) = self.sessions.get(tab_id).map(|session| session.scope.clone()) + else { + return; }; - let mut responses = Vec::new(); - let mut runtime_unavailable = false; - for tab_id in tab_ids { - match scoped.client.poll(tab_id.clone()) { - Ok(Some(frame)) => responses.push(WorkerResponse::Frame { tab_id, frame }), - Ok(None) => {} - Err(error) if error.is_runtime_unavailable() => { - runtime_unavailable = true; - responses.push(WorkerResponse::RuntimeUnavailable); - } - Err(error) => { - responses.push(WorkerResponse::Failed { tab_id, message: error.to_string() }) - } - } + if &previous_scope == scope { + return; + } + self.sessions.remove(tab_id); + let has_remaining_session = + self.sessions.values().any(|candidate| candidate.scope == previous_scope); + if previous_scope.is_transient() && !has_remaining_session { + self.remove_worker(&previous_scope); + } else if let Some(scoped) = self.workers.get(&previous_scope) { + scoped.worker.submit_close(tab_id.as_str().to_string()); } - (responses, runtime_unavailable) } fn collect_responses( &mut self, + scope: &WebSurfaceRuntimeScope, responses: Vec, now: Instant, frames: &mut Vec, @@ -323,18 +304,23 @@ impl WebSurfaceRuntime { let mut runtime_unavailable = false; for response in responses { match response { - WorkerResponse::Frame { tab_id, frame } => { + WorkerResponse::Frame { generation, tab_id, frame } => { let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else { continue; }; - let session = match self.sessions.get_mut(&tab_id_obj) { - Some(session) => session, - None => continue, + if !self.sessions.get(&tab_id_obj).is_some_and(|session| { + &session.scope == scope && session.generation == Some(generation) + }) { + continue; + } + self.note_scope_success(scope); + let Some(session) = self.sessions.get_mut(&tab_id_obj) else { + continue; }; let requested_url = session.requested_url.clone(); let scroll_offset = session.scroll_offset; let zoom_percent = session.zoom_percent; - session.cadence.note_frame(frame.render_state(), now); + session.cadence.note_frame(frame.render_state(), frame.pixels_changed(), now); match WebSurfaceFrame::from_live_frame( requested_url.clone(), scroll_offset, @@ -356,15 +342,17 @@ impl WebSurfaceRuntime { }), } } - WorkerResponse::Failed { tab_id, message } => { + WorkerResponse::Failed { generation, tab_id, message } => { let Some(tab_id_obj) = self.lookup_session_tab_id(&tab_id) else { continue; }; - frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message }); - } - WorkerResponse::RuntimeUnavailable => { - runtime_unavailable = true; + if self.sessions.get(&tab_id_obj).is_some_and(|session| { + &session.scope == scope && session.generation == Some(generation) + }) { + frames.push(WebSurfaceRuntimeFrame::Failed { tab_id: tab_id_obj, message }); + } } + WorkerResponse::RuntimeUnavailable => runtime_unavailable = true, } } runtime_unavailable @@ -374,9 +362,15 @@ impl WebSurfaceRuntime { self.sessions.keys().find(|key| key.as_str() == tab_id).cloned() } + fn next_request_generation(&mut self) -> RequestGeneration { + assert!(self.last_generation < u64::MAX, "web surface request generation exhausted"); + self.last_generation += 1; + RequestGeneration::new(self.last_generation) + } + #[cfg(test)] pub(super) fn client_count_for_test(&self) -> usize { - usize::from(self.worker.is_some()) + usize::from(self.direct_client.is_some()) + self.workers.len() } #[cfg(test)] @@ -385,39 +379,72 @@ impl WebSurfaceRuntime { } #[cfg(test)] - pub(super) fn flush_for_test(&self) { - if let Some(scoped) = self.worker.as_ref() { + pub(super) fn flush_for_test(&mut self) { + for scoped in self.workers.values() { scoped.worker.wait_until_idle(); } + self.reap_retired_workers(true); } - fn remove_worker(&mut self) { - let Some(scoped) = self.worker.take() else { + fn remove_worker(&mut self, scope: &WebSurfaceRuntimeScope) { + let Some(scoped) = self.workers.remove(scope) else { return; }; - let ScopedWorker { worker, transient_profile_data_dir } = scoped; - drop(worker); - if let Some(path) = transient_profile_data_dir { - let _ = fs::remove_dir_all(path); + if scoped.transient_profile_data_dir.is_some() { + match std::thread::Builder::new() + .name("ely-servo-profile-cleanup".to_string()) + .spawn(move || shutdown_scoped_worker(scoped)) + { + Ok(handle) => self.retired_workers.push(handle), + Err(error) => self.transient_cleanup_error = Some(error.to_string()), + } + } else { + let _ = shutdown_scoped_worker(scoped); } } - fn remove_direct_client(&mut self) { - let Some(scoped) = self.direct_client.take() else { - return; - }; - let ScopedDirectClient { client, transient_profile_data_dir } = scoped; - drop(client); - if let Some(path) = transient_profile_data_dir { - let _ = fs::remove_dir_all(path); + fn reap_retired_workers(&mut self, wait_for_all: bool) { + let mut pending = Vec::new(); + for handle in self.retired_workers.drain(..) { + if !wait_for_all && !handle.is_finished() { + pending.push(handle); + continue; + } + match handle.join() { + Ok(Ok(())) => {} + Ok(Err(error)) => self.transient_cleanup_error = Some(error), + Err(_) => { + self.transient_cleanup_error = + Some("Servo profile cleanup thread panicked".to_string()); + } + } } + self.retired_workers = pending; + } + + fn invalidate_scope(&mut self, scope: &WebSurfaceRuntimeScope, now: Instant) { + self.remove_worker(scope); + self.sessions.retain(|_, session| &session.scope != scope); + self.note_scope_failure(scope, now); + } + + fn note_scope_failure(&mut self, scope: &WebSurfaceRuntimeScope, now: Instant) { + let retry = ScopeRetryState::after_failure(self.retry_state.get(scope), now); + self.retry_state.insert(scope.clone(), retry); + } + + fn note_scope_success(&mut self, scope: &WebSurfaceRuntimeScope) { + self.retry_state.remove(scope); } } impl Drop for WebSurfaceRuntime { fn drop(&mut self) { - self.remove_worker(); - self.remove_direct_client(); + let scopes = self.workers.keys().cloned().collect::>(); + 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, +#[derive(Clone, Copy, Debug)] +struct ScopeRetryState { + failure_count: u32, + retry_after: Instant, } -struct ScopedDirectClient { - client: Box, - transient_profile_data_dir: Option, +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; diff --git a/crates/ely_app/src/shell/web_surface_runtime_backpressure_tests.rs b/crates/ely_app/src/shell/web_surface_runtime_backpressure_tests.rs new file mode 100644 index 0000000..02ddf4f --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_runtime_backpressure_tests.rs @@ -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> = Mutex::new(None); + +impl LiveRuntimeClient for SlowPollClient { + fn ensure( + &mut self, + _request: ServoLiveEnsureRequest, + ) -> Result, LiveRuntimeClientError> { + Ok(None) + } + + fn poll(&mut self, _tab_id: String) -> Result, 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, 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, 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, String> { + Ok(Box::new(SlowPollClient)) +} + +fn blocking_ensure_client_factory(_path: PathBuf) -> Result, 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 { + 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, + } +} diff --git a/crates/ely_app/src/shell/web_surface_runtime_cleanup.rs b/crates/ely_app/src/shell/web_surface_runtime_cleanup.rs new file mode 100644 index 0000000..6fe724b --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_runtime_cleanup.rs @@ -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, +} + +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}")) +} diff --git a/crates/ely_app/src/shell/web_surface_runtime_cleanup_tests.rs b/crates/ely_app/src/shell/web_surface_runtime_cleanup_tests.rs new file mode 100644 index 0000000..c7fb479 --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_runtime_cleanup_tests.rs @@ -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> = Mutex::new(None); +static FACTORY_PATHS: Mutex> = 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, LiveRuntimeClientError> { + Ok(None) + } + + fn poll(&mut self, _tab_id: String) -> Result, 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, LiveRuntimeClientError> { + Ok(None) + } + + fn poll(&mut self, _tab_id: String) -> Result, 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, 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 { + 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, + } +} diff --git a/crates/ely_app/src/shell/web_surface_runtime_generation_tests.rs b/crates/ely_app/src/shell/web_surface_runtime_generation_tests.rs new file mode 100644 index 0000000..a4dfa8b --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_runtime_generation_tests.rs @@ -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, LiveRuntimeClientError> { + Ok(None) + } + + fn poll(&mut self, _tab_id: String) -> Result, 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, LiveRuntimeClientError> { + Ok(None) + } + + fn poll(&mut self, _tab_id: String) -> Result, 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 { + 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, String> { + Ok(Box::new(EmptyClient)) +} + +fn fail_second_client_factory(_path: PathBuf) -> Result, 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 { + 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]) +} diff --git a/crates/ely_app/src/shell/web_surface_runtime_retry_tests.rs b/crates/ely_app/src/shell/web_surface_runtime_retry_tests.rs new file mode 100644 index 0000000..590d580 --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_runtime_retry_tests.rs @@ -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); +} diff --git a/crates/ely_app/src/shell/web_surface_runtime_session.rs b/crates/ely_app/src/shell/web_surface_runtime_session.rs index a5ecf3e..50e4f8d 100644 --- a/crates/ely_app/src/shell/web_surface_runtime_session.rs +++ b/crates/ely_app/src/shell/web_surface_runtime_session.rs @@ -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, 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), String> { +) -> Result<(PathBuf, Option), 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))) } } } diff --git a/crates/ely_app/src/shell/web_surface_runtime_tests.rs b/crates/ely_app/src/shell/web_surface_runtime_tests.rs index 8b5bf06..f94e5af 100644 --- a/crates/ely_app/src/shell/web_surface_runtime_tests.rs +++ b/crates/ely_app/src/shell/web_surface_runtime_tests.rs @@ -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> = 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, 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, String> { @@ -435,13 +487,6 @@ fn viewport_bounds() -> gpui::Bounds { ) } -fn resized_viewport_bounds() -> gpui::Bounds { - 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, diff --git a/crates/ely_app/src/shell/web_surface_scope_tests.rs b/crates/ely_app/src/shell/web_surface_scope_tests.rs new file mode 100644 index 0000000..26e2bb1 --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_scope_tests.rs @@ -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> = 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, 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, LiveRuntimeClientError> { + Ok(None) + } + + fn close(&mut self, _tab_id: String) -> Result<(), LiveRuntimeClientError> { + Ok(()) + } +} + +fn recording_client_factory( + _config_dir: std::path::PathBuf, +) -> Result, String> { + Ok(Box::new(RecordingClient)) +} + +#[test] +fn profile_scope_change_clears_pixels_input_and_focus_before_ensure() -> Result<(), Box> +{ + 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 { + 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> { + 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> { + 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> +{ + 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(()) +} diff --git a/crates/ely_app/src/shell/web_surface_state.rs b/crates/ely_app/src/shell/web_surface_state.rs index 0874059..bf7d1ab 100644 --- a/crates/ely_app/src/shell/web_surface_state.rs +++ b/crates/ely_app/src/shell/web_surface_state.rs @@ -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, - pub(super) native_surface: Option, pub(super) last_ensure_key: Option, pub(super) hover_point: Option, last_hover_enqueued_at: Option, @@ -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, + profile_id: ProfileId, + profile_data_mode: ProfileDataMode, zoom_percent: u16, permissions: Vec, } @@ -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, &[], ) diff --git a/crates/ely_app/src/shell/web_surface_view.rs b/crates/ely_app/src/shell/web_surface_view.rs index bdc93ef..0403c1b 100644 --- a/crates/ely_app/src/shell/web_surface_view.rs +++ b/crates/ely_app/src/shell/web_surface_view.rs @@ -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, 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, - 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, diff --git a/crates/ely_app/src/shell/web_surface_worker.rs b/crates/ely_app/src/shell/web_surface_worker.rs index 26fa900..8f1fa86 100644 --- a/crates/ely_app/src/shell/web_surface_worker.rs +++ b/crates/ely_app/src/shell/web_surface_worker.rs @@ -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 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, - /// 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, - /// 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>, + /// Round-robin tab order, with each pending tab represented once. + ready_tabs: VecDeque, + closes: VecDeque, in_flight: bool, + in_flight_tab: Option, + initialization_failure: Option, 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, Condvar)>, + response_tx: mpsc::Sender, response_rx: mpsc::Receiver, thread: Option>, } @@ -132,46 +147,35 @@ impl LiveRuntimeWorker { let queue = Arc::new(( Mutex::new(WorkerQueue { pending: BTreeMap::new(), - closes: Vec::new(), + ready_tabs: VecDeque::new(), + closes: VecDeque::new(), in_flight: false, + in_flight_tab: None, + initialization_failure: None, shutdown: false, }), Condvar::new(), )); let (response_tx, response_rx) = mpsc::channel(); - let (init_tx, init_rx) = mpsc::channel(); let queue_for_thread = queue.clone(); + let response_for_thread = response_tx.clone(); let thread = std::thread::Builder::new() .name("ely-servo-runtime".to_string()) - .spawn(move || { - let client = match client_factory() { - Ok(client) => { - let _ = init_tx.send(Ok(())); - client - } - Err(error) => { - let _ = init_tx.send(Err(error)); - return; - } - }; - run_worker(client, queue_for_thread, response_tx); + .spawn(move || match client_factory() { + Ok(client) => run_worker(client, queue_for_thread, response_for_thread), + Err(error) => { + fail_worker_initialization(queue_for_thread, &response_for_thread, error); + } }) .map_err(|error| format!("failed to spawn servo live worker thread: {error}"))?; - match init_rx.recv() { - Ok(Ok(())) => {} - Ok(Err(error)) => { - let _ = thread.join(); - return Err(error); - } - Err(error) => { - let _ = thread.join(); - return Err(format!("servo live worker initialization failed: {error}")); - } - } - Ok(Self { queue, response_rx, thread: Some(thread) }) + Ok(Self { queue, response_tx, response_rx, thread: Some(thread) }) } - pub(super) fn submit_ensure(&self, request: ServoLiveEnsureRequest) { + pub(super) fn submit_ensure( + &self, + generation: RequestGeneration, + request: ServoLiveEnsureRequest, + ) { let tab_id = request.tab_id.clone(); let (lock, cvar) = &*self.queue; let mut q = match lock.lock() { @@ -181,11 +185,31 @@ impl LiveRuntimeWorker { if q.shutdown { return; } - q.pending.insert(tab_id, WorkerRequest::Ensure(request)); + if let Some(message) = q.initialization_failure.clone() { + drop(q); + let _ = self.response_tx.send(WorkerResponse::Failed { generation, tab_id, message }); + return; + } + let mut request = WorkerRequest::Ensure { generation, request }; + if let Some(pending) = q.pending.get_mut(&tab_id) { + let replace_tail = pending.back().is_some_and(|tail| { + matches!(tail, WorkerRequest::Poll { .. }) + || (!request_has_ordered_input(&request) && !request_has_ordered_input(tail)) + }); + if replace_tail && let Some(tail) = pending.back_mut() { + preserve_latest_hover(&mut request, tail); + *tail = request; + } else { + pending.push_back(request); + } + } else { + q.pending.insert(tab_id.clone(), VecDeque::from([request])); + q.ready_tabs.push_back(tab_id); + } cvar.notify_one(); } - pub(super) fn submit_poll(&self, tab_id: String) -> bool { + pub(super) fn submit_poll(&self, generation: RequestGeneration, tab_id: String) -> bool { let (lock, cvar) = &*self.queue; let mut q = match lock.lock() { Ok(guard) => guard, @@ -194,15 +218,25 @@ impl LiveRuntimeWorker { if q.shutdown { return false; } + if let Some(message) = q.initialization_failure.clone() { + drop(q); + let _ = self.response_tx.send(WorkerResponse::Failed { generation, tab_id, message }); + return true; + } // A pending Ensure already produces the latest frame after its // run; don't downgrade it to a Poll. Only insert if nothing is // queued. - let inserted = match q.pending.entry(tab_id.clone()) { - std::collections::btree_map::Entry::Vacant(entry) => { - entry.insert(WorkerRequest::Poll { tab_id }); - true - } - std::collections::btree_map::Entry::Occupied(_) => false, + let inserted = if q.pending.contains_key(&tab_id) + || q.in_flight_tab.as_deref() == Some(tab_id.as_str()) + { + false + } else { + q.pending.insert( + tab_id.clone(), + VecDeque::from([WorkerRequest::Poll { generation, tab_id: tab_id.clone() }]), + ); + q.ready_tabs.push_back(tab_id); + true }; cvar.notify_one(); inserted @@ -217,8 +251,12 @@ impl LiveRuntimeWorker { if q.shutdown { return; } + if q.initialization_failure.is_some() { + return; + } q.pending.remove(&tab_id); - q.closes.push(tab_id); + q.ready_tabs.retain(|ready_tab_id| ready_tab_id != &tab_id); + q.closes.push_back(tab_id); cvar.notify_one(); } @@ -230,10 +268,7 @@ impl LiveRuntimeWorker { out } - /// Test-only barrier. Blocks the caller until the worker has - /// drained everything currently submitted. Production code never - /// waits — the whole point of the worker is that the UI thread - /// progresses without IPC latency. + /// Test-only barrier for all submitted work. #[cfg(test)] pub(super) fn wait_until_idle(&self) { let (lock, cvar) = &*self.queue; @@ -254,10 +289,12 @@ impl Drop for LiveRuntimeWorker { fn drop(&mut self) { { let (lock, cvar) = &*self.queue; - if let Ok(mut q) = lock.lock() { - q.shutdown = true; - cvar.notify_all(); - } + let mut q = match lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + q.shutdown = true; + cvar.notify_all(); } if let Some(handle) = self.thread.take() { let _ = handle.join(); @@ -265,12 +302,41 @@ impl Drop for LiveRuntimeWorker { } } +fn fail_worker_initialization( + queue: Arc<(Mutex, Condvar)>, + response_tx: &mpsc::Sender, + 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, queue: Arc<(Mutex, Condvar)>, response_tx: mpsc::Sender, ) { 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, + generation: RequestGeneration, tab_id: String, result: Result, 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; diff --git a/crates/ely_app/src/shell/web_surface_worker_tests.rs b/crates/ely_app/src/shell/web_surface_worker_tests.rs new file mode 100644 index 0000000..48650db --- /dev/null +++ b/crates/ely_app/src/shell/web_surface_worker_tests.rs @@ -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>>, + first_started_tx: Option>, + release_first_rx: mpsc::Receiver<()>, + return_frame: bool, +} + +struct GenerationClient; + +impl LiveRuntimeClient for GenerationClient { + fn ensure( + &mut self, + _request: ServoLiveEnsureRequest, + ) -> Result, LiveRuntimeClientError> { + Ok(Some(ServoLiveFrame::for_test(1, 1, vec![16, 32, 64, 255]))) + } + + fn poll(&mut self, _tab_id: String) -> Result, 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, 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, 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, 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(), + } +} diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index b25a2f7..e9d2692 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -70,6 +70,7 @@ pub struct InitialBrowserConfig { pub profile_name: String, pub profile_color_hex: u32, pub profile_kind: ProfileKind, + pub profile_id: Option, 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 { 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, diff --git a/crates/ely_servo_host/Cargo.toml b/crates/ely_servo_host/Cargo.toml index 91533fd..2cfb871 100644 --- a/crates/ely_servo_host/Cargo.toml +++ b/crates/ely_servo_host/Cargo.toml @@ -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 diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs new file mode 100644 index 0000000..e893073 --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs @@ -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), +} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs new file mode 100644 index 0000000..5d75aba --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs @@ -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, +} + +#[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 { + parse_command(env::args()) +} + +fn parse_command( + args: impl IntoIterator, +) -> Result { + 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) -> Result { + 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, + name: &'static str, +) -> Result { + args.next().ok_or(SidecarArgsError::MissingArgumentValue { name }) +} + +fn parse_path(name: &'static str, value: String) -> Result { + if value.trim().is_empty() { + return Err(SidecarArgsError::EmptyPath { name }); + } + Ok(PathBuf::from(value)) +} + +fn parse_nonempty(name: &'static str, value: String) -> Result { + 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" }) + )); + } +} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/iosurface_mach.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/iosurface_mach.rs new file mode 100644 index 0000000..303a898 --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/iosurface_mach.rs @@ -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 { + 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::() 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); + } +} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs new file mode 100644 index 0000000..848e2b3 --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live.rs @@ -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::(&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, + active_profile: &mut Option, + handshake_complete: &mut bool, + rendering_context_kind: RenderingContextKind, + #[cfg(all(feature = "hardware-render", target_os = "macos"))] hardware_transport: Option< + &mut HardwareSurfaceTransport, + >, + request: LiveRequest, +) -> Result { + 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 { + #[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 { + 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 { + 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 + ); + } +} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs new file mode 100644 index 0000000..4720c13 --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_output.rs @@ -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, +) -> 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(()) + } +} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs new file mode 100644 index 0000000..b1ec4ef --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_protocol.rs @@ -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, + #[serde(default)] + scroll_point_y: Option, + #[serde(default)] + click_x: Option, + #[serde(default)] + click_y: Option, + #[serde(default)] + hover_x: Option, + #[serde(default)] + hover_y: Option, + #[serde(default)] + typed_text: Option, + #[serde(default)] + site_permissions: Vec, + #[serde(default)] + ready_surface_ids: Vec, + #[serde(default)] + pending_surface_ids: Vec, + }, + Poll { + tab_id: String, + #[serde(default)] + ready_surface_ids: Vec, + #[serde(default)] + pending_surface_ids: Vec, + }, + 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, +} + +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, + pub(super) frame: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) surface_handle: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) current_surface_id: Option, +} + +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, + pub(super) title: Option, + 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 { + 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::( + 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::(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::(r#"{"type":"shutdown"}"#)?; + + assert!(matches!(request, LiveRequest::Shutdown)); + Ok(()) + } +} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_session.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_session.rs new file mode 100644 index 0000000..2f126bf --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_session.rs @@ -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, + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + pub(super) last_surface: Option, + #[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, + 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, + 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, +) -> 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, + pub(super) scroll_point_y: Option, + pub(super) click_x: Option, + pub(super) click_y: Option, + pub(super) hover_x: Option, + pub(super) hover_y: Option, + pub(super) typed_text: Option, +} + +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, + y: Option, +) -> 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(()) + } +} diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_surface.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_surface.rs new file mode 100644 index 0000000..4f5d7ff --- /dev/null +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/live_surface.rs @@ -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>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PublicationState { + AwaitingReady, + Ready, +} + +impl HardwareSurfaceTransport { + pub(super) fn connect(service_name: &str) -> Result { + 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 = ready_surface_ids.iter().copied().collect(); + let pending: HashSet = 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::>(); + for identity in &identities { + publications.insert("tab", *identity); + } + + publications.sync_client_state("tab", &[], &(1..=17).collect::>()); + publications.sync_client_state("tab", &[], &(2..=17).collect::>()); + + assert!(!publications.contains("tab", identities[0])); + assert!(publications.contains("tab", identities[16])); + } +} diff --git a/crates/ely_servo_host/src/error.rs b/crates/ely_servo_host/src/error.rs index 67b2dac..a1abd72 100644 --- a/crates/ely_servo_host/src/error.rs +++ b/crates/ely_servo_host/src/error.rs @@ -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 }, } diff --git a/crates/ely_servo_host/src/hardware_rendering_context.rs b/crates/ely_servo_host/src/hardware_rendering_context.rs new file mode 100644 index 0000000..ae1ff24 --- /dev/null +++ b/crates/ely_servo_host/src/hardware_rendering_context.rs @@ -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>, + inner: SurfmanInner, + swap_chain: SwapChain, + #[cfg(target_os = "macos")] + held_presented_surfaces: RefCell>, + #[cfg(target_os = "macos")] + last_presented_iosurface: RefCell>, +} + +impl HardwareOffscreenContext { + /// Creates a hardware context with a generic offscreen surface. + pub fn new(size: PhysicalSize) -> Result { + 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 { + self.inner.read_to_image(source_rectangle) + } + + fn size(&self) -> PhysicalSize { + self.size.get() + } + + fn resize(&self, size: PhysicalSize) { + 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 { + self.inner.gleam_gl.clone() + } + + fn glow_gl_api(&self) -> Arc { + self.inner.glow_gl.clone() + } + + fn connection(&self) -> Option { + 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, 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 { + 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, + glow_gl: Arc, + device: RefCell, + context: RefCell, +} + +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 { + 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, + ) -> Result { + 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, 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 { + 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 { + 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 { + 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(()) + } +} diff --git a/crates/ely_servo_host/src/iosurface_handle.rs b/crates/ely_servo_host/src/iosurface_handle.rs new file mode 100644 index 0000000..b35f528 --- /dev/null +++ b/crates/ely_servo_host/src/iosurface_handle.rs @@ -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 } + } +} diff --git a/crates/ely_servo_host/src/lib.rs b/crates/ely_servo_host/src/lib.rs index 2c40fa5..b7041ae 100644 --- a/crates/ely_servo_host/src/lib.rs +++ b/crates/ely_servo_host/src/lib.rs @@ -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}; diff --git a/crates/ely_servo_host/src/runtime.rs b/crates/ely_servo_host/src/runtime.rs index 4e70b62..b89d588 100644 --- a/crates/ely_servo_host/src/runtime.rs +++ b/crates/ely_servo_host/src/runtime.rs @@ -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 { @@ -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, diff --git a/crates/ely_servo_host/src/runtime_context.rs b/crates/ely_servo_host/src/runtime_context.rs index 8845583..cf8471d 100644 --- a/crates/ely_servo_host/src/runtime_context.rs +++ b/crates/ely_servo_host/src/runtime_context.rs @@ -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`. 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 = OnceLock::new(); - *BUDGET.get_or_init(|| { - let ms = env::var("ELY_PAINT_BARRIER_MS") - .ok() - .and_then(|raw| raw.parse::().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, + #[cfg(feature = "hardware-render")] + pub(super) hardware_context: Option>, } 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( diff --git a/crates/ely_servo_host/src/runtime_hardware.rs b/crates/ely_servo_host/src/runtime_hardware.rs new file mode 100644 index 0000000..0782a73 --- /dev/null +++ b/crates/ely_servo_host/src/runtime_hardware.rs @@ -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, 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, 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() }) + } +} diff --git a/crates/ely_servo_host/src/runtime_input.rs b/crates/ely_servo_host/src/runtime_input.rs index 79d286b..c34dda9 100644 --- a/crates/ely_servo_host/src/runtime_input.rs +++ b/crates/ely_servo_host/src/runtime_input.rs @@ -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); diff --git a/crates/ely_servo_host/src/runtime_paint.rs b/crates/ely_servo_host/src/runtime_paint.rs index 16f4b55..aad2441 100644 --- a/crates/ely_servo_host/src/runtime_paint.rs +++ b/crates/ely_servo_host/src/runtime_paint.rs @@ -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, 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()); + } +} diff --git a/crates/ely_servo_host/src/runtime_webview.rs b/crates/ely_servo_host/src/runtime_webview.rs index 5196e62..519a560 100644 --- a/crates/ely_servo_host/src/runtime_webview.rs +++ b/crates/ely_servo_host/src/runtime_webview.rs @@ -13,6 +13,8 @@ pub(super) struct HostWebView { pub(super) tab_id: TabId, pub(super) profile_id: ProfileId, pub(super) rendering_context: Rc, + #[cfg(feature = "hardware-render")] + pub(super) hardware_context: Option>, pub(super) webview: WebView, pub(super) delegate: Rc, pub(super) requested_url: Option, @@ -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) { diff --git a/crates/ely_servo_host/tests/hardware_rendering_context.rs b/crates/ely_servo_host/tests/hardware_rendering_context.rs new file mode 100644 index 0000000..de2bd41 --- /dev/null +++ b/crates/ely_servo_host/tests/hardware_rendering_context.rs @@ -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> { + 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> { + 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) } +} diff --git a/crates/ely_servo_host/tests/sidecar.rs b/crates/ely_servo_host/tests/sidecar.rs new file mode 100644 index 0000000..8879bca --- /dev/null +++ b/crates/ely_servo_host/tests/sidecar.rs @@ -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> +{ + 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> +{ + 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> { + 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> { + 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> { + 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> { + 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(()) +} diff --git a/crates/ely_servo_host/tests/sidecar/mach_receiver.rs b/crates/ely_servo_host/tests/sidecar/mach_receiver.rs new file mode 100644 index 0000000..c410945 --- /dev/null +++ b/crates/ely_servo_host/tests/sidecar/mach_receiver.rs @@ -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> { + 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> { + #[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::() 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> { + 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> { + #[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(()) +} diff --git a/crates/ely_servo_host/tests/sidecar/support.rs b/crates/ely_servo_host/tests/sidecar/support.rs new file mode 100644 index 0000000..650ce39 --- /dev/null +++ b/crates/ely_servo_host/tests/sidecar/support.rs @@ -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, + stdout: BufReader, + stderr: Option, +} + +impl Sidecar { + pub(super) fn spawn(profile_data_dir: &Path) -> Result> { + 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> { + 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> { + 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::spawn_process(profile_data_dir, &[]) + } + + fn spawn_process(profile_data_dir: &Path, extra_args: &[&str]) -> Result> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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, + pub(super) error: Option, + pub(super) frame: Option, + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + pub(super) surface_handle: Option, + #[cfg(all(feature = "hardware-render", target_os = "macos"))] + pub(super) current_surface_id: Option, +} + +pub(super) struct FramePacket { + pub(super) loaded_url: Option, + pub(super) title: Option, + 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, +} + +#[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) -> Result> { + 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>(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> { + Ok(u32::try_from(u64_field(value, name)?)?) +} + +fn usize_field(value: &Value, name: &str) -> Result> { + Ok(usize::try_from(u64_field(value, name)?)?) +} + +fn u64_field(value: &Value, name: &str) -> Result> { + 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 { + 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, + thread: Option>, + diagnostics: Arc>, +} + +#[derive(Default)] +struct ServerDiagnostics { + requests: Vec, + errors: Vec, +} + +impl TestServer { + pub(super) fn start() -> Result { + 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) { + 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, +) -> 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#"loadingProfile persistence"#; + +const READ_PAGE: &str = r#"loadingProfile persistence"#; + +const HISTORY_PAGE: &str = r#"loadingHistory mutation"#; + +const WHITE_PAGE: &str = r#"white-ready"#; diff --git a/crates/ely_servo_host/tests/software_host.rs b/crates/ely_servo_host/tests/software_host.rs index e77663d..bce4448 100644 --- a/crates/ely_servo_host/tests/software_host.rs +++ b/crates/ely_servo_host/tests/software_host.rs @@ -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> { 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> { 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> { 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> { 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> { Ok(()) } -fn wait_for_rendered_webview( - host: &mut SoftwareServoHost, - webview_id: &ely_domain::WebViewId, - previous_frame_hash: Option, -) -> Result> { - 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> { - 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> { - 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!( - "DPR Probe", - ); - 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::*; diff --git a/crates/ely_servo_host/tests/software_host/support.rs b/crates/ely_servo_host/tests/software_host/support.rs new file mode 100644 index 0000000..c6d14a1 --- /dev/null +++ b/crates/ely_servo_host/tests/software_host/support.rs @@ -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, +) -> Result> { + 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, + expected_title: &str, +) -> Result> { + 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, + expected_rgb: [u8; 3], +) -> Result> { + 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, + snapshot_matches: impl Fn(&WebViewSnapshot) -> bool, + frame_matches: impl Fn(&RenderedFrame) -> bool, +) -> Result> { + 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> { + 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> { + 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!( + "DPR Probe", + ); + 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() +} diff --git a/docs/servo-embedding-architecture.md b/docs/servo-embedding-architecture.md index 526728f..3dea012 100644 --- a/docs/servo-embedding-architecture.md +++ b/docs/servo-embedding-architecture.md @@ -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 +/profiles//servo +``` + +The default Standard Profile stores its generated identity at +`/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. diff --git a/packaging/macos/Info.plist b/packaging/macos/Info.plist index 5c735af..45fc15b 100644 --- a/packaging/macos/Info.plist +++ b/packaging/macos/Info.plist @@ -11,6 +11,8 @@ ely_app CFBundleIdentifier com.elydora.ely-browser + CFBundleIconFile + AppIcon CFBundleGetInfoString ELY Browser by Elydora CFBundleInfoDictionaryVersion diff --git a/scripts/create_macos_app_bundle.sh b/scripts/create_macos_app_bundle.sh index 0977a75..2a730c5 100755 --- a/scripts/create_macos_app_bundle.sh +++ b/scripts/create_macos_app_bundle.sh @@ -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}" diff --git a/scripts/create_native_distribution.sh b/scripts/create_native_distribution.sh new file mode 100755 index 0000000..c872bf0 --- /dev/null +++ b/scripts/create_native_distribution.sh @@ -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}" diff --git a/scripts/release_build_env.sh b/scripts/release_build_env.sh new file mode 100755 index 0000000..47d02f1 --- /dev/null +++ b/scripts/release_build_env.sh @@ -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]" +} diff --git a/scripts/run_dev.sh b/scripts/run_dev.sh new file mode 100755 index 0000000..490e94d --- /dev/null +++ b/scripts/run_dev.sh @@ -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 \ + -- "$@" diff --git a/scripts/verify_macos_bundle_metadata.sh b/scripts/verify_macos_bundle_metadata.sh index 2606514..686d4b9 100755 --- a/scripts/verify_macos_bundle_metadata.sh +++ b/scripts/verify_macos_bundle_metadata.sh @@ -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" diff --git a/scripts/verify_prd_site_rendering.sh b/scripts/verify_prd_site_rendering.sh index 8e3c4b8..b99a994 100755 --- a/scripts/verify_prd_site_rendering.sh +++ b/scripts/verify_prd_site_rendering.sh @@ -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 diff --git a/scripts/verify_release_artifacts.sh b/scripts/verify_release_artifacts.sh new file mode 100755 index 0000000..3bdb696 --- /dev/null +++ b/scripts/verify_release_artifacts.sh @@ -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 ..." >&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 diff --git a/scripts/verify_render.sh b/scripts/verify_render.sh index 36106d3..193bd85 100755 --- a/scripts/verify_render.sh +++ b/scripts/verify_render.sh @@ -2,7 +2,7 @@ # T16 — real-window screencapture sanity check. # # Boots ./target/release/ely_app in the background, opens a live page through -# Servo's native surface path, captures the ELY window by its Core Graphics +# its profile-scoped Servo sidecar, captures the ELY window by its Core Graphics # window ID, then asserts the PNG is non-trivial (size + dimensions + non-white # center). Stderr from the app is # tee'd to a log so a crash leaves evidence behind. @@ -35,6 +35,7 @@ cleanup() { # Stragglers from prior runs / child processes pkill -f "target/release/ely_app" 2>/dev/null || true pkill -x "ely_app" 2>/dev/null || true + pkill -x "ely_servo_sidecar" 2>/dev/null || true } trap cleanup EXIT INT TERM @@ -51,17 +52,23 @@ fail() { echo "[1/6] killing stale ely_app processes" pkill -f "target/release/ely_app" 2>/dev/null || true pkill -x "ely_app" 2>/dev/null || true +pkill -x "ely_servo_sidecar" 2>/dev/null || true sleep 0.5 -echo "[2/6] cargo build --release --locked -p ely_app" +echo "[2/6] building release app and Servo sidecar" if ! cargo build --release --locked -p ely_app; then - fail "cargo build failed" + fail "app build failed" +fi +if ! cargo build --release --locked -p ely_servo_host --features servo-engine,hardware-render --bin ely_servo_sidecar; then + fail "Servo sidecar build failed" fi [[ -x "${APP_BIN}" ]] || fail "binary missing: ${APP_BIN}" +[[ -x "${REPO_ROOT}/target/release/ely_servo_sidecar" ]] || fail "sidecar binary missing" echo "[3/6] launching ${APP_BIN} ${SMOKE_URL} (stderr -> ${STDERR_LOG})" : > "${STDERR_LOG}" -"${APP_BIN}" "${SMOKE_URL}" >/dev/null 2>"${STDERR_LOG}" & +ELY_SERVO_RENDERING_CONTEXT=hardware \ + "${APP_BIN}" "${SMOKE_URL}" >/dev/null 2>"${STDERR_LOG}" & APP_PID=$! echo " pid=${APP_PID}" diff --git a/third_party/gpui/build.rs b/third_party/gpui/build.rs index 83aea8a..e56b065 100644 --- a/third_party/gpui/build.rs +++ b/third_party/gpui/build.rs @@ -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) diff --git a/third_party/gpui/src/elements/surface.rs b/third_party/gpui/src/elements/surface.rs index d6eb84c..ee92990 100644 --- a/third_party/gpui/src/elements/surface.rs +++ b/third_party/gpui/src/elements/surface.rs @@ -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); + +impl SurfaceLease { + /// Create a lease from a thread-safe shared owner. + pub fn from_arc(owner: Arc) -> 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 for SurfaceSource { /// A surface element. pub struct Surface { source: SurfaceSource, + lease: Option, object_fit: ObjectFit, corner_radii: Option>, style: StyleRefinement, @@ -33,6 +61,7 @@ pub struct Surface { pub fn surface(source: impl Into) -> 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) -> 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); + + 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); + } +} diff --git a/third_party/gpui/src/platform/mac/metal_renderer.rs b/third_party/gpui/src/platform/mac/metal_renderer.rs index 1f637b1..b4a8fa6 100644 --- a/third_party/gpui/src/platform/mac/metal_renderer.rs +++ b/third_party/gpui/src/platform/mac/metal_renderer.rs @@ -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, ) -> Result { 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>, + completion_tx: Option>, +) { + let instance_buffer = Cell::new(Some(instance_buffer)); + let surface_leases = Cell::new(Some( + scene + .surfaces + .iter() + .filter_map(|surface| surface.lease.clone()) + .collect::>(), + )); + 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, 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, diff --git a/third_party/gpui/src/platform/mac/metal_renderer_test_support.rs b/third_party/gpui/src/platform/mac/metal_renderer_test_support.rs new file mode 100644 index 0000000..81f4566 --- /dev/null +++ b/third_party/gpui/src/platform/mac/metal_renderer_test_support.rs @@ -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 { + 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 +} diff --git a/third_party/gpui/src/scene.rs b/third_party/gpui/src/scene.rs index 50334de..4de9151 100644 --- a/third_party/gpui/src/scene.rs +++ b/third_party/gpui/src/scene.rs @@ -661,6 +661,8 @@ pub(crate) struct PaintSurface { pub corner_radii: Corners, #[cfg(target_os = "macos")] pub image_buffer: core_video::pixel_buffer::CVPixelBuffer, + #[cfg(target_os = "macos")] + pub lease: Option, } impl From for Primitive { diff --git a/third_party/gpui/src/test.rs b/third_party/gpui/src/test.rs index 5ae72d2..c92c2ba 100644 --- a/third_party/gpui/src/test.rs +++ b/third_party/gpui/src/test.rs @@ -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. diff --git a/third_party/gpui/src/window.rs b/third_party/gpui/src/window.rs index f5a6eff..8908d82 100644 --- a/third_party/gpui/src/window.rs +++ b/third_party/gpui/src/window.rs @@ -3184,6 +3184,17 @@ impl Window { bounds: Bounds, corner_radii: Corners, 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, + corner_radii: Corners, + image_buffer: CVPixelBuffer, + lease: Option, ) { use crate::PaintSurface; @@ -3199,6 +3210,7 @@ impl Window { content_mask, corner_radii, image_buffer, + lease, }); }