diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf435a..fa8eee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). + ## [v1.8.0] - 2026-07-20 ### Added diff --git a/Sources/AXe/Utilities/AccessibilityFetcher+TranslationRecovery.swift b/Sources/AXe/Utilities/AccessibilityFetcher+TranslationRecovery.swift new file mode 100644 index 0000000..ff60e43 --- /dev/null +++ b/Sources/AXe/Utilities/AccessibilityFetcher+TranslationRecovery.swift @@ -0,0 +1,172 @@ +import Foundation +import FBSimulatorControl + +extension AccessibilityFetcher { + nonisolated static let translationReadinessPollIntervals = Array( + repeating: Duration.milliseconds(500), + count: 16 + ) + + static func retryingAfterAccessibilityRecovery( + 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 + 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 + } + 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( + intervals: [Duration], + wait: AccessibilityRecoveryDependencies.Waiter, + operation: @MainActor () async throws -> T + ) async throws -> AccessibilityTranslationPollResult { + 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 { + case available(T) + case unavailable +} diff --git a/Sources/AXe/Utilities/AccessibilityFetcher.swift b/Sources/AXe/Utilities/AccessibilityFetcher.swift index 3f21140..830f1b1 100644 --- a/Sources/AXe/Utilities/AccessibilityFetcher.swift +++ b/Sources/AXe/Utilities/AccessibilityFetcher.swift @@ -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) @@ -120,27 +121,6 @@ struct AccessibilityFetcher { return latestData } - static func retryingAfterTestManagerRecovery( - 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 @@ -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 = [] diff --git a/Sources/AXe/Utilities/AccessibilityRecoveryLock.swift b/Sources/AXe/Utilities/AccessibilityRecoveryLock.swift new file mode 100644 index 0000000..74dd908 --- /dev/null +++ b/Sources/AXe/Utilities/AccessibilityRecoveryLock.swift @@ -0,0 +1,201 @@ +import Darwin +import Foundation + +@MainActor +final class AccessibilityRecoveryLockLease { + private var didMarkRecoveryCompleted = false + private var releaseHandler: (() -> Void)? + private let markRecoveryCompletedHandler: () throws -> Void + let generation: AccessibilityRecoveryGeneration + + init( + generation: AccessibilityRecoveryGeneration = .initial, + markRecoveryCompletedHandler: @escaping () throws -> Void = {}, + releaseHandler: @escaping () -> Void + ) { + self.generation = generation + self.markRecoveryCompletedHandler = markRecoveryCompletedHandler + self.releaseHandler = releaseHandler + } + + func markRecoveryCompleted() throws { + guard !didMarkRecoveryCompleted else { + return + } + try markRecoveryCompletedHandler() + didMarkRecoveryCompleted = true + } + + func release() { + releaseHandler?() + releaseHandler = nil + } +} + +struct AccessibilityRecoveryGeneration: Equatable { + static let initial = AccessibilityRecoveryGeneration(value: 0) + + let value: Int64 +} + +enum AccessibilityRecoveryLock { + typealias Acquirer = @MainActor (String) async throws -> AccessibilityRecoveryLockLease + typealias GenerationReader = @MainActor (String) throws -> AccessibilityRecoveryGeneration + + private static let retryInterval = Duration.milliseconds(100) + private static let acquisitionTimeout = Duration.seconds(30) + + @MainActor + static func acquire(simulatorUDID: String) async throws -> AccessibilityRecoveryLockLease { + let descriptor = try openLockFile(simulatorUDID: simulatorUDID) + var ownsDescriptor = true + defer { + if ownsDescriptor { + Darwin.close(descriptor) + } + } + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: acquisitionTimeout) + + while true { + if flock(descriptor, LOCK_EX | LOCK_NB) == 0 { + let currentGeneration = try generation(descriptor: descriptor) + ownsDescriptor = false + return AccessibilityRecoveryLockLease( + generation: currentGeneration, + markRecoveryCompletedHandler: { + var marker: UInt8 = 1 + guard Darwin.pwrite(descriptor, &marker, 1, currentGeneration.value) == 1 else { + throw posixError( + "Failed to update accessibility recovery generation", + code: errno + ) + } + guard Darwin.fsync(descriptor) == 0 else { + throw posixError( + "Failed to persist accessibility recovery generation", + code: errno + ) + } + }, + releaseHandler: { + _ = flock(descriptor, LOCK_UN) + Darwin.close(descriptor) + } + ) + } + + let lockError = errno + if lockError == EINTR { + continue + } + guard lockError == EWOULDBLOCK else { + throw posixError("Failed to lock accessibility recovery state", code: lockError) + } + guard clock.now < deadline else { + throw CLIError( + errorDescription: "Timed out waiting for accessibility recovery for simulator \(simulatorUDID)." + ) + } + + try await Task.sleep(for: retryInterval) + } + } + + @MainActor + static func currentGeneration(simulatorUDID: String) throws -> AccessibilityRecoveryGeneration { + let descriptor = try openLockFile(simulatorUDID: simulatorUDID) + defer { Darwin.close(descriptor) } + return try generation(descriptor: descriptor) + } + + private static func openLockFile(simulatorUDID: String) throws -> Int32 { + guard !simulatorUDID.isEmpty, + simulatorUDID.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" }) + else { + throw CLIError( + errorDescription: "Invalid simulator UDID for accessibility recovery: \(simulatorUDID)" + ) + } + + let directory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .resolvingSymlinksInPath() + .appendingPathComponent("axe-accessibility-\(getuid())", isDirectory: true) + try ensurePrivateDirectory(directory) + + let path = directory.appendingPathComponent("bridge-\(simulatorUDID).lock").path + let descriptor = Darwin.open(path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0o600) + guard descriptor >= 0 else { + throw posixError("Failed to open accessibility recovery lock", code: errno) + } + + do { + try validateLockFile(descriptor: descriptor) + return descriptor + } catch { + Darwin.close(descriptor) + throw error + } + } + + private static func ensurePrivateDirectory(_ directory: URL) throws { + var metadata = stat() + if Darwin.lstat(directory.path, &metadata) == 0 { + guard (metadata.st_mode & S_IFMT) == S_IFDIR, + metadata.st_uid == getuid(), + metadata.st_mode & 0o077 == 0 + else { + throw CLIError( + errorDescription: "Accessibility recovery directory is not private: \(directory.path)" + ) + } + return + } + + let lookupError = errno + guard lookupError == ENOENT else { + throw posixError("Failed to inspect accessibility recovery directory", code: lookupError) + } + guard Darwin.mkdir(directory.path, 0o700) == 0 || errno == EEXIST else { + throw posixError("Failed to create accessibility recovery directory", code: errno) + } + + guard Darwin.lstat(directory.path, &metadata) == 0, + (metadata.st_mode & S_IFMT) == S_IFDIR, + metadata.st_uid == getuid(), + metadata.st_mode & 0o077 == 0 + else { + throw CLIError( + errorDescription: "Accessibility recovery directory is not private: \(directory.path)" + ) + } + } + + private static func validateLockFile(descriptor: Int32) throws { + var metadata = stat() + guard Darwin.fstat(descriptor, &metadata) == 0 else { + throw posixError("Failed to inspect accessibility recovery lock", code: errno) + } + guard (metadata.st_mode & S_IFMT) == S_IFREG, + metadata.st_uid == getuid(), + metadata.st_mode & 0o077 == 0 + else { + throw CLIError(errorDescription: "Accessibility recovery lock is not a private regular file.") + } + } + + private static func generation(descriptor: Int32) throws -> AccessibilityRecoveryGeneration { + var metadata = stat() + guard Darwin.fstat(descriptor, &metadata) == 0 else { + throw posixError("Failed to inspect accessibility recovery generation", code: errno) + } + return AccessibilityRecoveryGeneration( + value: metadata.st_size + ) + } + + private static func posixError(_ message: String, code: Int32) -> CLIError { + CLIError(errorDescription: "\(message): \(String(cString: strerror(code)))") + } +} diff --git a/Tests/AccessibilityFetcherTests.swift b/Tests/AccessibilityFetcherTests.swift index 47dc327..4005c31 100644 --- a/Tests/AccessibilityFetcherTests.swift +++ b/Tests/AccessibilityFetcherTests.swift @@ -376,7 +376,7 @@ struct AccessibilityFetcherTests { userInfo: [NSLocalizedDescriptionKey: "Channel disconnected"] ) - let result = try await AccessibilityFetcher.retryingAfterTestManagerRecovery( + let result = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( simulatorUDID: "TEST-UDID", logger: AxeLogger(), dependencies: dependencies @@ -411,7 +411,7 @@ struct AccessibilityFetcherTests { ) do { - _ = try await AccessibilityFetcher.retryingAfterTestManagerRecovery( + _ = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( simulatorUDID: "TEST-UDID", logger: AxeLogger(), dependencies: dependencies @@ -445,7 +445,7 @@ struct AccessibilityFetcherTests { ) do { - _ = try await AccessibilityFetcher.retryingAfterTestManagerRecovery( + _ = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( simulatorUDID: "TEST-UDID", logger: AxeLogger(), dependencies: dependencies diff --git a/Tests/AccessibilityRecoveryLockTests.swift b/Tests/AccessibilityRecoveryLockTests.swift new file mode 100644 index 0000000..57fcc9a --- /dev/null +++ b/Tests/AccessibilityRecoveryLockTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import AXe + +@Suite("Accessibility Recovery Lock Tests") +@MainActor +struct AccessibilityRecoveryLockTests { + @Test("A second lease for one simulator waits for the first lease") + func serializesSameSimulatorRecovery() async throws { + let simulatorUDID = UUID().uuidString + let firstLease = try await AccessibilityRecoveryLock.acquire(simulatorUDID: simulatorUDID) + var secondLeaseWasAcquired = false + let secondTask = Task { @MainActor in + let lease = try await AccessibilityRecoveryLock.acquire(simulatorUDID: simulatorUDID) + secondLeaseWasAcquired = true + return lease + } + + try await Task.sleep(for: .milliseconds(150)) + #expect(!secondLeaseWasAcquired) + + firstLease.release() + let secondLease = try await secondTask.value + #expect(secondLeaseWasAcquired) + secondLease.release() + } + + @Test("Cancelling a lock waiter does not retain its descriptor") + func cancellationReleasesWaitingDescriptor() async throws { + let simulatorUDID = UUID().uuidString + let firstLease = try await AccessibilityRecoveryLock.acquire(simulatorUDID: simulatorUDID) + let cancelledTask = Task { @MainActor in + try await AccessibilityRecoveryLock.acquire(simulatorUDID: simulatorUDID) + } + + try await Task.sleep(for: .milliseconds(150)) + cancelledTask.cancel() + do { + _ = try await cancelledTask.value + Issue.record("Expected the waiting lock acquisition to be cancelled") + } catch is CancellationError { + } catch { + Issue.record("Expected CancellationError, received \(error)") + } + + firstLease.release() + let replacementLease = try await AccessibilityRecoveryLock.acquire( + simulatorUDID: simulatorUDID + ) + replacementLease.release() + } +} diff --git a/Tests/AccessibilityTranslationRecoveryTests.swift b/Tests/AccessibilityTranslationRecoveryTests.swift new file mode 100644 index 0000000..834a53c --- /dev/null +++ b/Tests/AccessibilityTranslationRecoveryTests.swift @@ -0,0 +1,450 @@ +import Foundation +import FBSimulatorControl +import Testing +@testable import AXe + +@Suite("Accessibility Translation Recovery Tests") +@MainActor +struct AccessibilityTranslationRecoveryTests { + private let readinessIntervals = AccessibilityFetcher.translationReadinessPollIntervals + + @Test("Accepts translation that becomes ready at the end of the initial window") + func acceptsLatePreRecoverySuccess() async throws { + var operationCount = 0 + var restartCount = 0 + var lockCount = 0 + var waits: [Duration] = [] + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { _, _, _ in + restartCount += 1 + return 0 + }, + wait: { waits.append($0) } + ) + + let result = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( + simulatorUDID: "TEST-UDID", + logger: AxeLogger(), + dependencies: dependencies, + lockAcquirer: { _ in + lockCount += 1 + return AccessibilityRecoveryLockLease {} + }, + generationReader: missingGeneration + ) { + operationCount += 1 + guard operationCount == 17 else { + throw FBAccessibilityError.noTranslationObject + } + return "ready" + } + + #expect(result == "ready") + #expect(operationCount == 17) + #expect(restartCount == 0) + #expect(lockCount == 0) + #expect(waits == readinessIntervals) + #expect(waits.reduce(.zero, +) == .seconds(8)) + } + + @Test("Restarts only after the full readiness window") + func restartsAfterReadinessWindow() async throws { + var operationCount = 0 + var restartCount = 0 + var didRestart = false + var executableURL: URL? + var arguments: [String] = [] + var timeout: TimeInterval? + var waits: [Duration] = [] + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { receivedURL, receivedArguments, receivedTimeout in + restartCount += 1 + didRestart = true + executableURL = receivedURL + arguments = receivedArguments + timeout = receivedTimeout + return 0 + }, + wait: { waits.append($0) } + ) + + let result = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( + simulatorUDID: "TARGET-UDID", + logger: AxeLogger(), + dependencies: dependencies, + lockAcquirer: immediateLock, + generationReader: missingGeneration + ) { + operationCount += 1 + guard didRestart else { + throw FBAccessibilityError.noTranslationObject + } + return "recovered" + } + + #expect(result == "recovered") + #expect(operationCount == 19) + #expect(restartCount == 1) + #expect(waits == readinessIntervals) + #expect(executableURL?.path == "/usr/bin/xcrun") + #expect(arguments == [ + "simctl", + "spawn", + "TARGET-UDID", + "launchctl", + "kickstart", + "-k", + "user/foreground/com.apple.CoreSimulator.bridge", + ]) + #expect(timeout == 3) + } + + @Test("Accepts translation that becomes ready at the end of the post-restart window") + func acceptsLatePostRecoverySuccess() async throws { + var preRestartOperationCount = 0 + var postRestartOperationCount = 0 + var didRestart = false + var waits: [Duration] = [] + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { _, _, _ in + didRestart = true + return 0 + }, + wait: { waits.append($0) } + ) + + let result = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( + simulatorUDID: "TARGET-UDID", + logger: AxeLogger(), + dependencies: dependencies, + lockAcquirer: immediateLock, + generationReader: missingGeneration + ) { + if didRestart { + postRestartOperationCount += 1 + guard postRestartOperationCount == 17 else { + throw FBAccessibilityError.noTranslationObject + } + return "recovered" + } + preRestartOperationCount += 1 + throw FBAccessibilityError.noTranslationObject + } + + #expect(result == "recovered") + #expect(preRestartOperationCount == 18) + #expect(postRestartOperationCount == 17) + #expect(waits == readinessIntervals + readinessIntervals) + #expect(waits.reduce(.zero, +) == .seconds(16)) + } + + @Test("Bounds persistent translation recovery to one restart and two readiness windows") + func boundsPersistentFailureRecovery() async { + var operationCount = 0 + var restartCount = 0 + var waits: [Duration] = [] + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { _, _, _ in + restartCount += 1 + return 0 + }, + wait: { waits.append($0) } + ) + + do { + _ = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( + simulatorUDID: "TARGET-UDID", + logger: AxeLogger(), + dependencies: dependencies, + lockAcquirer: immediateLock, + generationReader: missingGeneration + ) { + operationCount += 1 + throw FBAccessibilityError.noTranslationObject + } as String + Issue.record("Expected recovery to fail") + } catch { + #expect( + String(describing: error) + == "AXe could not obtain accessibility information for simulator TARGET-UDID after retrying and restarting its CoreSimulator bridge. Restart the simulator and try again." + ) + } + + #expect(operationCount == 35) + #expect(restartCount == 1) + #expect(waits == readinessIntervals + readinessIntervals) + } + + @Test("Does not mutate simulator state for point queries") + func pointQueryPreservesMissingTranslationError() async { + var operationCount = 0 + var restartCount = 0 + var lockCount = 0 + var waitCount = 0 + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { _, _, _ in + restartCount += 1 + return 0 + }, + wait: { _ in waitCount += 1 } + ) + + do { + _ = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( + simulatorUDID: "TARGET-UDID", + logger: AxeLogger(), + dependencies: dependencies, + allowsCoreSimulatorBridgeRecovery: false, + lockAcquirer: { _ in + lockCount += 1 + return AccessibilityRecoveryLockLease {} + } + ) { + operationCount += 1 + throw FBAccessibilityError.noTranslationObject + } as String + Issue.record("Expected point query to preserve the translation error") + } catch let error as FBAccessibilityError { + guard case .noTranslationObject = error else { + Issue.record("Expected noTranslationObject, received \(error)") + return + } + } catch { + Issue.record("Expected FBAccessibilityError, received \(error)") + } + + #expect(operationCount == 1) + #expect(restartCount == 0) + #expect(lockCount == 0) + #expect(waitCount == 0) + } + + @Test("Concurrent requests for one simulator coalesce to one bridge restart") + func concurrentRequestsRestartOnce() async throws { + let coordinator = TestRecoveryCoordinator() + var bridgeIsReady = false + var restartCount = 0 + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { _, _, _ in + while coordinator.waitingCount == 0 { + await Task.yield() + } + restartCount += 1 + bridgeIsReady = true + return 0 + }, + wait: { _ in } + ) + let operation: @MainActor () async throws -> String = { + guard bridgeIsReady else { + throw FBAccessibilityError.noTranslationObject + } + return "ready" + } + + let first = Task { @MainActor in + try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( + simulatorUDID: "TARGET-UDID", + logger: AxeLogger(), + dependencies: dependencies, + readinessPollIntervals: [], + lockAcquirer: coordinator.acquire, + generationReader: coordinator.readGeneration, + operation: operation + ) + } + let second = Task { @MainActor in + try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( + simulatorUDID: "TARGET-UDID", + logger: AxeLogger(), + dependencies: dependencies, + readinessPollIntervals: [], + lockAcquirer: coordinator.acquire, + generationReader: coordinator.readGeneration, + operation: operation + ) + } + + let results = try await [first.value, second.value] + #expect(results == ["ready", "ready"]) + #expect(restartCount == 1) + #expect(coordinator.acquisitionCount == 2) + } + + @Test("Concurrent persistent failures share one attempt while a later request may retry") + func concurrentPersistentFailuresShareAttempt() async { + let coordinator = TestRecoveryCoordinator() + var restartCount = 0 + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { _, _, _ in + if restartCount == 0 { + while coordinator.waitingCount == 0 { + await Task.yield() + } + } + restartCount += 1 + return 0 + }, + wait: { _ in } + ) + let request: @MainActor () async -> String = { + do { + _ = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( + simulatorUDID: "TARGET-UDID", + logger: AxeLogger(), + dependencies: dependencies, + readinessPollIntervals: [], + lockAcquirer: coordinator.acquire, + generationReader: coordinator.readGeneration + ) { + throw FBAccessibilityError.noTranslationObject + } as String + return "unexpected success" + } catch { + return String(describing: error) + } + } + + let first = Task { @MainActor in await request() } + let second = Task { @MainActor in await request() } + let concurrentResults = await [first.value, second.value] + let expectedError = + "AXe could not obtain accessibility information for simulator TARGET-UDID after retrying and restarting its CoreSimulator bridge. Restart the simulator and try again." + + #expect(concurrentResults == [expectedError, expectedError]) + #expect(restartCount == 1) + + let laterResult = await request() + #expect(laterResult == expectedError) + #expect(restartCount == 2) + } + + @Test("Reports a scoped bridge restart failure") + func reportsBridgeRestartFailure() async { + var operationCount = 0 + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { _, _, _ in 13 }, + wait: { _ in } + ) + + do { + _ = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( + simulatorUDID: "TARGET-UDID", + logger: AxeLogger(), + dependencies: dependencies, + readinessPollIntervals: [], + lockAcquirer: immediateLock, + generationReader: missingGeneration + ) { + operationCount += 1 + throw FBAccessibilityError.noTranslationObject + } as String + Issue.record("Expected recovery to fail") + } catch { + #expect( + String(describing: error) + == "AXe could not restart the CoreSimulator bridge for simulator TARGET-UDID (exit status 13). Restart the simulator and try again." + ) + } + + #expect(operationCount == 2) + } + + @Test("Preserves unrelated errors without recovery") + func preservesUnrelatedErrors() async { + var operationCount = 0 + var restartCount = 0 + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { _, _, _ in + restartCount += 1 + return 0 + }, + wait: { _ in } + ) + let unrelated = NSError( + domain: "Accessibility", + code: 99, + userInfo: [NSLocalizedDescriptionKey: "Unrelated failure"] + ) + + do { + _ = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( + simulatorUDID: "TARGET-UDID", + logger: AxeLogger(), + dependencies: dependencies, + lockAcquirer: immediateLock, + generationReader: missingGeneration + ) { + operationCount += 1 + throw unrelated + } as String + Issue.record("Expected the operation to fail") + } catch { + #expect(error.localizedDescription == "Unrelated failure") + } + + #expect(operationCount == 1) + #expect(restartCount == 0) + } + + private func immediateLock(_: String) async throws -> AccessibilityRecoveryLockLease { + AccessibilityRecoveryLockLease {} + } + + private func missingGeneration(_: String) throws -> AccessibilityRecoveryGeneration { + .initial + } +} + +@MainActor +private final class TestRecoveryCoordinator { + private var generation = AccessibilityRecoveryGeneration.initial + private var isHeld = false + private var waiters: [CheckedContinuation] = [] + private(set) var acquisitionCount = 0 + + var waitingCount: Int { + waiters.count + } + + func readGeneration(_: String) -> AccessibilityRecoveryGeneration { + generation + } + + func acquire(_: String) async -> AccessibilityRecoveryLockLease { + acquisitionCount += 1 + if !isHeld { + isHeld = true + return makeLease() + } + + return await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + private func makeLease() -> AccessibilityRecoveryLockLease { + AccessibilityRecoveryLockLease( + generation: generation, + markRecoveryCompletedHandler: { [weak self] in + guard let self else { + return + } + self.generation = AccessibilityRecoveryGeneration(value: self.generation.value + 1) + }, + releaseHandler: { [weak self] in + self?.release() + } + ) + } + + private func release() { + guard !waiters.isEmpty else { + isHeld = false + return + } + + let continuation = waiters.removeFirst() + continuation.resume(returning: makeLease()) + } +}