Skip to content

Commit e6e2b61

Browse files
authored
Retry transient empty native accessibility snapshots after tap (#23)
* Retry transient native-ax accessibility snapshots * Fix native accessibility retry clippy
1 parent a3e0d93 commit e6e2b61

1 file changed

Lines changed: 96 additions & 11 deletions

File tree

server/src/native/bridge.rs

Lines changed: 96 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ use std::time::Duration;
99

1010
const RECOVERABLE_RESTART_EXIT_CODE: i32 = 75;
1111
const RESTART_ON_CORE_SIMULATOR_MISMATCH_ENV: &str = "SIMDECK_RESTART_ON_CORE_SIMULATOR_MISMATCH";
12+
const ACCESSIBILITY_SNAPSHOT_MAX_ATTEMPTS: usize = 10;
13+
const ACCESSIBILITY_SNAPSHOT_RETRY_DELAY_MS: u64 = 100;
1214

1315
static RECOVERABLE_RESTART_SCHEDULED: AtomicBool = AtomicBool::new(false);
1416

@@ -289,17 +291,25 @@ impl NativeBridge {
289291
) -> Result<serde_json::Value, AppError> {
290292
let udid = CString::new(udid).map_err(|e| AppError::bad_request(e.to_string()))?;
291293
let max_depth = max_depth.unwrap_or(80).min(80);
292-
let json = match native_accessibility_snapshot_json(&udid, point, max_depth) {
293-
Ok(json) => json,
294-
Err(error) if is_core_simulator_service_mismatch(&error.to_string()) => {
295-
std::thread::sleep(Duration::from_millis(250));
296-
native_accessibility_snapshot_json(&udid, point, max_depth)?
294+
for attempt in 1..=ACCESSIBILITY_SNAPSHOT_MAX_ATTEMPTS {
295+
let json = match native_accessibility_snapshot_json(&udid, point, max_depth) {
296+
Ok(json) => json,
297+
Err(error) if is_core_simulator_service_mismatch(&error.to_string()) => {
298+
std::thread::sleep(Duration::from_millis(250));
299+
native_accessibility_snapshot_json(&udid, point, max_depth)?
300+
}
301+
Err(error) => return Err(error),
302+
};
303+
let snapshot: serde_json::Value =
304+
serde_json::from_str(&json).map_err(|e| AppError::internal(e.to_string()))?;
305+
if !accessibility_snapshot_is_transient_empty(&snapshot)
306+
|| attempt == ACCESSIBILITY_SNAPSHOT_MAX_ATTEMPTS
307+
{
308+
return Ok(snapshot);
297309
}
298-
Err(error) => return Err(error),
299-
};
300-
let snapshot: serde_json::Value =
301-
serde_json::from_str(&json).map_err(|e| AppError::internal(e.to_string()))?;
302-
Ok(snapshot)
310+
std::thread::sleep(Duration::from_millis(ACCESSIBILITY_SNAPSHOT_RETRY_DELAY_MS));
311+
}
312+
unreachable!("accessibility snapshot retry loop always returns")
303313
}
304314

305315
pub fn send_touch(&self, udid: &str, x: f64, y: f64, phase: &str) -> Result<(), AppError> {
@@ -769,6 +779,40 @@ fn is_core_simulator_service_mismatch(message: &str) -> bool {
769779
&& message.contains("does not match expected service version")
770780
}
771781

782+
fn accessibility_snapshot_is_transient_empty(snapshot: &serde_json::Value) -> bool {
783+
let Some(roots) = snapshot.get("roots").and_then(serde_json::Value::as_array) else {
784+
return true;
785+
};
786+
roots.is_empty() || roots.iter().all(node_is_zero_sized_leaf)
787+
}
788+
789+
fn node_is_zero_sized_leaf(node: &serde_json::Value) -> bool {
790+
let has_children = node
791+
.get("children")
792+
.and_then(serde_json::Value::as_array)
793+
.is_some_and(|children| !children.is_empty());
794+
!has_children && node_frame_is_empty(node)
795+
}
796+
797+
fn node_frame_is_empty(node: &serde_json::Value) -> bool {
798+
let Some(frame) = node
799+
.get("frame")
800+
.or_else(|| node.get("frameInScreen"))
801+
.or_else(|| node.get("bounds"))
802+
else {
803+
return true;
804+
};
805+
let width = frame
806+
.get("width")
807+
.and_then(serde_json::Value::as_f64)
808+
.unwrap_or(0.0);
809+
let height = frame
810+
.get("height")
811+
.and_then(serde_json::Value::as_f64)
812+
.unwrap_or(0.0);
813+
width <= 0.0 || height <= 0.0
814+
}
815+
772816
unsafe fn string_from_raw(raw: *mut i8, error: *mut i8) -> Result<String, AppError> {
773817
if raw.is_null() {
774818
return Err(take_error(error).unwrap_or_else(|| AppError::native("Unknown native error.")));
@@ -814,7 +858,8 @@ fn schedule_recoverable_restart_if_needed(message: &str) {
814858
#[cfg(test)]
815859
mod tests {
816860
use super::{
817-
is_core_simulator_service_mismatch, log_entry_matches, LogEntry, LogFilters, Simulator,
861+
accessibility_snapshot_is_transient_empty, is_core_simulator_service_mismatch,
862+
log_entry_matches, LogEntry, LogFilters, Simulator,
818863
};
819864
use serde_json::json;
820865

@@ -903,4 +948,44 @@ mod tests {
903948
"Unable to initialize the private simulator display bridge."
904949
));
905950
}
951+
952+
#[test]
953+
fn accessibility_snapshot_retry_detects_empty_native_ax_tree() {
954+
assert!(accessibility_snapshot_is_transient_empty(&json!({
955+
"source": "native-ax",
956+
"roots": []
957+
})));
958+
assert!(accessibility_snapshot_is_transient_empty(&json!({
959+
"source": "native-ax",
960+
"roots": [{
961+
"role": "Application",
962+
"frame": { "x": 0, "y": 0, "width": 0, "height": 0 },
963+
"children": []
964+
}]
965+
})));
966+
}
967+
968+
#[test]
969+
fn accessibility_snapshot_retry_keeps_usable_native_ax_tree() {
970+
assert!(!accessibility_snapshot_is_transient_empty(&json!({
971+
"source": "native-ax",
972+
"roots": [{
973+
"role": "Application",
974+
"frame": { "x": 0, "y": 0, "width": 393, "height": 852 },
975+
"children": []
976+
}]
977+
})));
978+
assert!(!accessibility_snapshot_is_transient_empty(&json!({
979+
"source": "native-ax",
980+
"roots": [{
981+
"role": "Application",
982+
"frame": { "x": 0, "y": 0, "width": 0, "height": 0 },
983+
"children": [{
984+
"role": "Button",
985+
"label": "Continue",
986+
"frame": { "x": 10, "y": 20, "width": 100, "height": 44 }
987+
}]
988+
}]
989+
})));
990+
}
906991
}

0 commit comments

Comments
 (0)