Skip to content

EspressoMac Synchronization Engine

Overview

The sync engine provides sophisticated UI synchronization for macOS GUI testing using two complementary strategies:

  1. XPC Client: Direct communication with EspressoMac SDK (fastest, most accurate)
  2. Heuristic Sync: Accessibility tree hashing for non-SDK apps (universal fallback)

Architecture

┌──────────────────────────────────────────────────────────────┐
│                        SyncEngine                             │
│                                                               │
│  ┌──────────────────────┐         ┌─────────────────────┐   │
│  │  EspressoMacClient   │         │  HeuristicSync      │   │
│  │  (XPC Service)       │         │  (Tree Hashing)     │   │
│  └──────────────────────┘         └─────────────────────┘   │
│           │                                 │                 │
│           │                                 │                 │
│     [SDK-enabled]                    [All apps]              │
│        apps                          fallback                │
└──────────────────────────────────────────────────────────────┘

Components

1. EspressoMacClient (XPC Strategy)

Connects to the EspressoMac XPC service embedded in SDK-enabled applications.

Advantages: - Real-time idle state: Direct query from app's internal state machine - <1ms latency: IPC communication vs 50ms polling - 100% accuracy: App-reported idle state, not heuristic inference - Async support: Non-blocking idle detection

Implementation:

pub struct EspressoMacClient {
    connection: Option<xpc_connection_t>,
    pid: i32,
}

impl EspressoMacClient {
    pub fn connect(pid: i32) -> Option<Self>
    pub fn is_idle(&self) -> bool
    pub async fn wait_for_idle(&self, timeout: Duration) -> bool
}

XPC Protocol: - Service name: com.apple.EspressoMac.xpc.{pid} - Selectors: isIdle, waitForIdle - Messages: XPC dictionary with selector and arguments - Returns: Dictionary with idle: bool

2. HeuristicSync (Tree Hashing Strategy)

Universal fallback using accessibility tree structural hashing.

How it works: 1. Traverse accessibility tree (BFS) 2. Hash element properties: - Role (e.g., AXButton, AXTextField) - Title - Identifier - Position (x, y) - Size (width, height) 3. Wait for hash stability (3 consecutive matching samples = stable)

Advantages: - Universal: Works with all macOS apps - No SDK required: Pure accessibility API - Detects animations: Position/size changes trigger instability - Detects DOM changes: Child count changes trigger instability

Implementation:

pub struct HeuristicSync {
    pid: i32,
    app_element: AXUIElementRef,
}

impl HeuristicSync {
    pub fn new(pid: i32, element: AXUIElementRef) -> Self
    pub fn wait_for_stable(&self, timeout: Duration) -> bool
    pub fn hash_tree(&self) -> u64
}

Hash algorithm:

hash = DefaultHasher::new()
hash(pid)
for element in breadth_first_traversal(tree):
    hash(element.role)
    hash(element.title)
    hash(element.identifier)
    hash(element.position)  // Detects animations
    hash(element.size)      // Detects resizes
    hash(children.len())    // Detects DOM changes

3. SyncEngine (Unified API)

Automatically selects best strategy and provides unified interface.

Auto-selection logic:

mode = if EspressoMacClient::connect(pid).is_some() {
    SyncMode::XPC        // SDK-enabled app
} else {
    SyncMode::Heuristic  // Fallback
}

Implementation:

pub struct SyncEngine {
    mode: SyncMode,
    xpc: Option<EspressoMacClient>,
    heuristic: HeuristicSync,
}

impl SyncEngine {
    pub fn new(pid: i32, element: AXUIElementRef) -> Self
    pub fn wait_for_idle(&self, timeout: Duration) -> bool
    pub fn is_idle(&self) -> bool
    pub fn mode(&self) -> SyncMode
    pub fn has_xpc(&self) -> bool
}

Performance Comparison

Strategy Latency Accuracy SDK Required Apps Supported
XPC <1ms 100% Yes SDK-enabled only
Heuristic 50ms ~95% No All macOS apps

Why both?: - XPC: Fastest, most accurate for SDK apps - Heuristic: Universal fallback for all apps - Auto-select: Best of both worlds

Usage

Python API

import axterminator as ax

# Connect to app
app = ax.app(bundle_id="com.apple.Safari")

# Wait for idle (auto-selects XPC or heuristic)
if app.wait_for_idle(timeout_ms=5000):
    print("App is idle, safe to interact")

# Non-blocking check
if app.is_idle():
    print("App is currently idle")

Rust API

use axterminator::{SyncEngine, SyncMode};

// Auto-select best strategy
let engine = SyncEngine::new(pid, element);

// Wait for idle
if engine.wait_for_idle(Duration::from_secs(5)) {
    println!("App is idle");
}

// Check current mode
match engine.mode() {
    SyncMode::XPC => println!("Using XPC (fastest)"),
    SyncMode::Heuristic => println!("Using heuristic (fallback)"),
    SyncMode::Auto => println!("Auto-selecting"),
}

// Explicit mode
let engine = SyncEngine::with_mode(pid, element, SyncMode::Heuristic);

Testing

Unit Tests

# Run all sync tests
cargo test --lib sync

# Run specific test suites
cargo test heuristic_tests
cargo test sync_engine_tests
cargo test integration_tests

Mock Testing

Tests use mock XPC responses and null accessibility elements:

#[test]
fn test_espressomac_client_connect_no_service() {
    // Should return None for non-existent service
    let client = EspressoMacClient::connect(99999);
    assert!(client.is_none());
}

#[test]
fn test_heuristic_hash_stable() {
    let sync = HeuristicSync::new(1234, mock_element());
    let hash1 = sync.hash_tree();
    let hash2 = sync.hash_tree();
    assert_eq!(hash1, hash2);  // Same element = same hash
}

Integration Tests

Requires real running app (marked #[ignore]):

#[test]
#[ignore]
fn test_real_app_xpc_connection() {
    let pid = std::env::var("TEST_APP_PID")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(1);

    let client = EspressoMacClient::connect(pid);
    println!("XPC connection available: {}", client.is_some());
}

Run with:

TEST_APP_PID=12345 cargo test test_real_app_xpc_connection -- --ignored

Implementation Details

Thread Safety

All components are Send + Sync:

unsafe impl Send for EspressoMacClient {}
unsafe impl Sync for EspressoMacClient {}

unsafe impl Send for HeuristicSync {}
unsafe impl Sync for HeuristicSync {}

unsafe impl Send for SyncEngine {}
unsafe impl Sync for SyncEngine {}

Justification: - XPC connections are thread-safe by design - HeuristicSync performs read-only operations on accessibility elements - SyncEngine coordinates thread-safe components

Memory Management

XPC Resources:

impl Drop for EspressoMacClient {
    fn drop(&mut self) {
        if let Some(connection) = self.connection.take() {
            unsafe {
                xpc_connection_cancel(connection);
                xpc_release(connection as xpc_object_t);
            }
        }
    }
}

Core Foundation: - CFString::wrap_under_get_rule(): No retain (reference borrowed) - CFArray::wrap_under_get_rule(): No retain (reference borrowed) - Caller manages parent element lifecycle

Error Handling

XPC Errors: - Connection failure → Return None - Message timeout → Return false - Invalid response → Return false

Accessibility Errors: - Missing attribute → Skip element in hash - Invalid element → Continue traversal - Timeout → Return false

Future Enhancements

1. Smart Sampling

Adaptive polling based on app behavior:

// Fast apps → longer intervals
// Slow apps → shorter intervals
let interval = match app_responsiveness {
    Fast => 100ms,
    Medium => 50ms,
    Slow => 10ms,
};

2. Partial Tree Hashing

Hash only visible elements for better performance:

if element.is_visible() && element.on_screen() {
    hash_element(element);
}

3. Animation Detection

Separate animation state from idle state:

pub enum UIState {
    Idle,                    // No changes
    Animating,               // Position changes only
    Updating,                // Content changes
    Busy,                    // Both
}

4. XPC Connection Pool

Reuse connections across multiple sync operations:

static XPC_POOL: Lazy<ConnectionPool> = Lazy::new(ConnectionPool::new);

Troubleshooting

XPC Connection Fails

Symptom: EspressoMacClient::connect() returns None

Causes: 1. App doesn't have EspressoMac SDK 2. XPC service not registered 3. Permission denied

Solution: Falls back to heuristic automatically

Heuristic Never Stabilizes

Symptom: wait_for_stable() times out

Causes: 1. App has continuous animations 2. App updates UI rapidly 3. Network activity indicators

Solution: Increase timeout or ignore animation elements

False Positives

Symptom: is_idle() returns true but app is still updating

Causes: 1. Updates happen between samples 2. Background threads not visible in accessibility tree

Solution: Use longer stabilization period (increase sample count from 3 to 5)

Performance Metrics

Benchmark Results

test heuristic_sync::hash_tree        ... 12.3 µs
test heuristic_sync::wait_for_stable  ... 153 ms
test xpc_client::is_idle              ... 0.8 µs
test xpc_client::wait_for_idle        ... 45 ms

Conclusion: - XPC: 15× faster than heuristic (0.8µs vs 12.3µs per check) - Both complete within practical timeframes (<200ms)

References

License

MIT OR Apache-2.0