Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to the AXe iOS testing framework will be documented in this
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- Fixed persistent frontmost-hierarchy accessibility translation failures with bounded readiness polling and coordinated recovery of only the affected simulator's CoreSimulator bridge ([#458](https://github.com/getsentry/XcodeBuildMCP/issues/458)).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## [v1.8.0] - 2026-07-20

### Added
Expand Down
172 changes: 172 additions & 0 deletions Sources/AXe/Utilities/AccessibilityFetcher+TranslationRecovery.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import Foundation
import FBSimulatorControl

extension AccessibilityFetcher {
nonisolated static let translationReadinessPollIntervals = Array(
repeating: Duration.milliseconds(500),
count: 16
)

static func retryingAfterAccessibilityRecovery<T>(
simulatorUDID: String,
logger: AxeLogger,
dependencies: AccessibilityRecoveryDependencies,
allowsCoreSimulatorBridgeRecovery: Bool = true,
readinessPollIntervals: [Duration] = translationReadinessPollIntervals,
lockAcquirer: AccessibilityRecoveryLock.Acquirer = AccessibilityRecoveryLock.acquire,
generationReader: AccessibilityRecoveryLock.GenerationReader = AccessibilityRecoveryLock.currentGeneration,
operation: @escaping @MainActor () async throws -> T
) async throws -> T {
var didRecoverTestManagerDaemon = false

let operationWithTestManagerRecovery: @MainActor () async throws -> T = {
do {
return try await operation()
} catch {
if shouldRecoverTestManagerDaemon(from: error), !didRecoverTestManagerDaemon {
didRecoverTestManagerDaemon = true
logger.info().log(
"Accessibility transport failed; restarting testmanagerd and retrying once"
)
try await recoverTestManagerDaemon(
simulatorUDID: simulatorUDID,
dependencies: dependencies
)
return try await operation()
}
throw error
}
}

guard allowsCoreSimulatorBridgeRecovery else {
return try await operationWithTestManagerRecovery()
}

let observedGeneration = try generationReader(simulatorUDID)
let preRecoveryResult = try await pollForAccessibilityTranslation(
intervals: readinessPollIntervals,
wait: dependencies.wait,
operation: operationWithTestManagerRecovery
)
if case let .available(value) = preRecoveryResult {
return value
}

let recoveryLease = try await lockAcquirer(simulatorUDID)
defer { recoveryLease.release() }

do {
return try await operationWithTestManagerRecovery()
} catch {
guard shouldRecoverCoreSimulatorBridge(from: error) else {
throw error
}
}

guard recoveryLease.generation == observedGeneration else {
throw persistentTranslationError(simulatorUDID: simulatorUDID)
}

logger.info().log(
"Accessibility translation remained unavailable after the readiness window; restarting the CoreSimulator bridge for simulator \(simulatorUDID)"
)
let postRecoveryResult: AccessibilityTranslationPollResult<T>
do {
try await recoverCoreSimulatorBridge(
simulatorUDID: simulatorUDID,
dependencies: dependencies
)
postRecoveryResult = try await pollForAccessibilityTranslation(
intervals: readinessPollIntervals,
wait: dependencies.wait,
operation: operationWithTestManagerRecovery
)
} catch {
try recoveryLease.markRecoveryCompleted()
throw error
}
Comment thread
cameroncooke marked this conversation as resolved.
try recoveryLease.markRecoveryCompleted()

if case let .available(value) = postRecoveryResult {
return value
}

throw persistentTranslationError(simulatorUDID: simulatorUDID)
}

static func shouldRecoverCoreSimulatorBridge(from error: Error) -> Bool {
if let accessibilityError = error as? FBAccessibilityError,
case .noTranslationObject = accessibilityError {
return true
}

return errorChain(from: error).contains { error in
error.localizedDescription.localizedCaseInsensitiveContains(
"no translation object returned for simulator"
)
}
}

static func recoverCoreSimulatorBridge(
simulatorUDID: String,
dependencies: AccessibilityRecoveryDependencies
) async throws {
let arguments = [
"simctl",
"spawn",
simulatorUDID,
"launchctl",
"kickstart",
"-k",
"user/foreground/com.apple.CoreSimulator.bridge",
]
let status = try await dependencies.runProcess(
URL(fileURLWithPath: "/usr/bin/xcrun"),
arguments,
3
)
guard status == 0 else {
throw CLIError(
errorDescription: "AXe could not restart the CoreSimulator bridge for simulator \(simulatorUDID) (exit status \(status)). Restart the simulator and try again."
)
}
}

private static func pollForAccessibilityTranslation<T>(
intervals: [Duration],
wait: AccessibilityRecoveryDependencies.Waiter,
operation: @MainActor () async throws -> T
) async throws -> AccessibilityTranslationPollResult<T> {
do {
return .available(try await operation())
} catch {
guard shouldRecoverCoreSimulatorBridge(from: error) else {
throw error
}
}

for interval in intervals {
try await wait(interval)
do {
return .available(try await operation())
} catch {
guard shouldRecoverCoreSimulatorBridge(from: error) else {
throw error
}
}
}

return .unavailable
}

private static func persistentTranslationError(simulatorUDID: String) -> CLIError {
CLIError(
errorDescription: "AXe could not obtain accessibility information for simulator \(simulatorUDID) after retrying and restarting its CoreSimulator bridge. Restart the simulator and try again."
)
}
}

private enum AccessibilityTranslationPollResult<T> {
case available(T)
case unavailable
}
28 changes: 4 additions & 24 deletions Sources/AXe/Utilities/AccessibilityFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ struct AccessibilityFetcher {
throw CLIError.simulatorNotFound(udid: simulatorUDID)
}

return try await retryingAfterTestManagerRecovery(
return try await retryingAfterAccessibilityRecovery(
simulatorUDID: simulatorUDID,
logger: logger,
dependencies: recoveryDependencies
dependencies: recoveryDependencies,
allowsCoreSimulatorBridgeRecovery: point == nil
) {
if let point {
return try await fetchAccessibilityInfoJSONData(from: target, at: point)
Expand Down Expand Up @@ -120,27 +121,6 @@ struct AccessibilityFetcher {
return latestData
}

static func retryingAfterTestManagerRecovery<T>(
simulatorUDID: String,
logger: AxeLogger,
dependencies: AccessibilityRecoveryDependencies,
operation: @MainActor () async throws -> T
) async throws -> T {
do {
return try await operation()
} catch {
guard shouldRecoverTestManagerDaemon(from: error) else {
throw error
}
logger.info().log("Accessibility transport failed; restarting testmanagerd and retrying once")
try await recoverTestManagerDaemon(
simulatorUDID: simulatorUDID,
dependencies: dependencies
)
return try await operation()
}
}

static func shouldRecoverTestManagerDaemon(from error: Error) -> Bool {
let errors = errorChain(from: error)
let details = errors.flatMap { error in
Expand Down Expand Up @@ -197,7 +177,7 @@ struct AccessibilityFetcher {
try await dependencies.wait(.milliseconds(250))
}

private static func errorChain(from error: Error) -> [NSError] {
static func errorChain(from error: Error) -> [NSError] {
var errors: [NSError] = []
var current: NSError? = error as NSError
var visited: Set<ObjectIdentifier> = []
Expand Down
Loading
Loading