From 50fb5a1d118937db5d3c4aaa174de42baaaee829 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 18:55:43 +0100 Subject: [PATCH 1/2] fix(accessibility): Recover missing translation objects Retry transient failures before restarting only the affected simulator's CoreSimulator bridge. Bound bridge and testmanagerd recovery to one restart per command and return actionable errors. Refs getsentry/XcodeBuildMCP#458 --- CHANGELOG.md | 6 + ...ssibilityFetcher+TranslationRecovery.swift | 97 +++++++++ .../AXe/Utilities/AccessibilityFetcher.swift | 25 +-- Tests/AccessibilityFetcherTests.swift | 6 +- ...ccessibilityTranslationRecoveryTests.swift | 188 ++++++++++++++++++ 5 files changed, 296 insertions(+), 26 deletions(-) create mode 100644 Sources/AXe/Utilities/AccessibilityFetcher+TranslationRecovery.swift create mode 100644 Tests/AccessibilityTranslationRecoveryTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index eaf435a..c690442 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 accessibility translation failures by retrying transient failures and restarting only the affected simulator's CoreSimulator bridge when recovery is required ([#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..fed5544 --- /dev/null +++ b/Sources/AXe/Utilities/AccessibilityFetcher+TranslationRecovery.swift @@ -0,0 +1,97 @@ +import Foundation +import FBSimulatorControl + +extension AccessibilityFetcher { + static func retryingAfterAccessibilityRecovery( + simulatorUDID: String, + logger: AxeLogger, + dependencies: AccessibilityRecoveryDependencies, + operation: @MainActor () async throws -> T + ) async throws -> T { + var didRecoverTestManagerDaemon = false + var didRetryMissingTranslation = false + var didRecoverCoreSimulatorBridge = false + + while true { + 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 + ) + continue + } + + guard shouldRecoverCoreSimulatorBridge(from: error) else { + throw error + } + + if !didRetryMissingTranslation { + didRetryMissingTranslation = true + logger.info().log("Accessibility translation returned no object; retrying before recovery") + try await dependencies.wait(.milliseconds(100)) + continue + } + + if !didRecoverCoreSimulatorBridge { + didRecoverCoreSimulatorBridge = true + logger.info().log( + "Accessibility translation remained unavailable; restarting the CoreSimulator bridge for simulator \(simulatorUDID) and retrying once" + ) + try await recoverCoreSimulatorBridge( + simulatorUDID: simulatorUDID, + dependencies: dependencies + ) + continue + } + + throw CLIError( + errorDescription: "AXe could not obtain accessibility information for simulator \(simulatorUDID) after retrying and restarting its CoreSimulator bridge. Restart the simulator and try again." + ) + } + } + } + + 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." + ) + } + try await dependencies.wait(.milliseconds(250)) + } +} diff --git a/Sources/AXe/Utilities/AccessibilityFetcher.swift b/Sources/AXe/Utilities/AccessibilityFetcher.swift index 3f21140..97e8fba 100644 --- a/Sources/AXe/Utilities/AccessibilityFetcher.swift +++ b/Sources/AXe/Utilities/AccessibilityFetcher.swift @@ -43,7 +43,7 @@ struct AccessibilityFetcher { throw CLIError.simulatorNotFound(udid: simulatorUDID) } - return try await retryingAfterTestManagerRecovery( + return try await retryingAfterAccessibilityRecovery( simulatorUDID: simulatorUDID, logger: logger, dependencies: recoveryDependencies @@ -120,27 +120,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 +176,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/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/AccessibilityTranslationRecoveryTests.swift b/Tests/AccessibilityTranslationRecoveryTests.swift new file mode 100644 index 0000000..9ae3783 --- /dev/null +++ b/Tests/AccessibilityTranslationRecoveryTests.swift @@ -0,0 +1,188 @@ +import Foundation +import FBSimulatorControl +import Testing +@testable import AXe + +@Suite("Accessibility Translation Recovery Tests") +@MainActor +struct AccessibilityTranslationRecoveryTests { + @Test("Retries a transient missing translation without restarting the bridge") + func retriesTransientFailureWithoutRestart() async throws { + var operationCount = 0 + var restartCount = 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 + ) { + operationCount += 1 + if operationCount == 1 { + throw FBAccessibilityError.noTranslationObject + } + return "recovered" + } + + #expect(result == "recovered") + #expect(operationCount == 2) + #expect(restartCount == 0) + #expect(waits == [.milliseconds(100)]) + } + + @Test("Restarts only the target simulator bridge after a persistent translation failure") + func restartsTargetSimulatorBridgeOnce() async throws { + var operationCount = 0 + var executableURL: URL? + var arguments: [String] = [] + var timeout: TimeInterval? + var restartCount = 0 + var waits: [Duration] = [] + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { receivedURL, receivedArguments, receivedTimeout in + restartCount += 1 + 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 + ) { + operationCount += 1 + if operationCount < 3 { + throw FBAccessibilityError.noTranslationObject + } + return "recovered" + } + + #expect(result == "recovered") + #expect(operationCount == 3) + #expect(restartCount == 1) + #expect(executableURL?.path == "/usr/bin/xcrun") + #expect(arguments == [ + "simctl", + "spawn", + "TARGET-UDID", + "launchctl", + "kickstart", + "-k", + "user/foreground/com.apple.CoreSimulator.bridge", + ]) + #expect(timeout == 3) + #expect(waits == [.milliseconds(100), .milliseconds(250)]) + } + + @Test("Reports a scoped bridge restart failure without another operation attempt") + func reportsBridgeRestartFailure() async { + var operationCount = 0 + var restartCount = 0 + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { _, _, _ in + restartCount += 1 + return 13 + }, + wait: { _ in } + ) + + do { + _ = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( + simulatorUDID: "TARGET-UDID", + logger: AxeLogger(), + dependencies: dependencies + ) { + 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) + #expect(restartCount == 1) + } + + @Test("Bounds persistent translation recovery to one bridge restart") + func boundsPersistentFailureRecovery() async { + var operationCount = 0 + var restartCount = 0 + let dependencies = AccessibilityRecoveryDependencies( + runProcess: { _, _, _ in + restartCount += 1 + return 0 + }, + wait: { _ in } + ) + + do { + _ = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( + simulatorUDID: "TARGET-UDID", + logger: AxeLogger(), + dependencies: dependencies + ) { + 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 == 3) + #expect(restartCount == 1) + } + + @Test("Preserves unrelated errors at every recovery stage") + 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 + ) { + 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) + } +} From 8a440b491e77288fc97f87462319e9f19ab09b67 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Tue, 21 Jul 2026 20:00:14 +0100 Subject: [PATCH 2/2] fix(accessibility): Coordinate translation recovery Bound frontmost-hierarchy readiness polling, serialize bridge recovery per simulator across processes, and coalesce overlapping failed recovery attempts without mutating point-query behavior. Refs getsentry/XcodeBuildMCP#458 --- CHANGELOG.md | 2 +- ...ssibilityFetcher+TranslationRecovery.swift | 135 ++++++-- .../AXe/Utilities/AccessibilityFetcher.swift | 3 +- .../Utilities/AccessibilityRecoveryLock.swift | 201 +++++++++++ Tests/AccessibilityRecoveryLockTests.swift | 52 +++ ...ccessibilityTranslationRecoveryTests.swift | 322 ++++++++++++++++-- 6 files changed, 653 insertions(+), 62 deletions(-) create mode 100644 Sources/AXe/Utilities/AccessibilityRecoveryLock.swift create mode 100644 Tests/AccessibilityRecoveryLockTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c690442..fa8eee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fixed persistent accessibility translation failures by retrying transient failures and restarting only the affected simulator's CoreSimulator bridge when recovery is required ([#458](https://github.com/getsentry/XcodeBuildMCP/issues/458)). +- 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 diff --git a/Sources/AXe/Utilities/AccessibilityFetcher+TranslationRecovery.swift b/Sources/AXe/Utilities/AccessibilityFetcher+TranslationRecovery.swift index fed5544..ff60e43 100644 --- a/Sources/AXe/Utilities/AccessibilityFetcher+TranslationRecovery.swift +++ b/Sources/AXe/Utilities/AccessibilityFetcher+TranslationRecovery.swift @@ -2,58 +2,96 @@ 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, - operation: @MainActor () async throws -> T + 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 - var didRetryMissingTranslation = false - var didRecoverCoreSimulatorBridge = false - while true { + 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") + logger.info().log( + "Accessibility transport failed; restarting testmanagerd and retrying once" + ) try await recoverTestManagerDaemon( simulatorUDID: simulatorUDID, dependencies: dependencies ) - continue + return try await operation() } + throw error + } + } - guard shouldRecoverCoreSimulatorBridge(from: error) else { - throw error - } + guard allowsCoreSimulatorBridgeRecovery else { + return try await operationWithTestManagerRecovery() + } - if !didRetryMissingTranslation { - didRetryMissingTranslation = true - logger.info().log("Accessibility translation returned no object; retrying before recovery") - try await dependencies.wait(.milliseconds(100)) - continue - } + 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 + } - if !didRecoverCoreSimulatorBridge { - didRecoverCoreSimulatorBridge = true - logger.info().log( - "Accessibility translation remained unavailable; restarting the CoreSimulator bridge for simulator \(simulatorUDID) and retrying once" - ) - try await recoverCoreSimulatorBridge( - simulatorUDID: simulatorUDID, - dependencies: dependencies - ) - continue - } + let recoveryLease = try await lockAcquirer(simulatorUDID) + defer { recoveryLease.release() } - throw CLIError( - errorDescription: "AXe could not obtain accessibility information for simulator \(simulatorUDID) after retrying and restarting its CoreSimulator bridge. Restart the simulator and try again." - ) + 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 { @@ -92,6 +130,43 @@ extension AccessibilityFetcher { errorDescription: "AXe could not restart the CoreSimulator bridge for simulator \(simulatorUDID) (exit status \(status)). Restart the simulator and try again." ) } - try await dependencies.wait(.milliseconds(250)) } + + 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 97e8fba..830f1b1 100644 --- a/Sources/AXe/Utilities/AccessibilityFetcher.swift +++ b/Sources/AXe/Utilities/AccessibilityFetcher.swift @@ -46,7 +46,8 @@ struct AccessibilityFetcher { 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) 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/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 index 9ae3783..834a53c 100644 --- a/Tests/AccessibilityTranslationRecoveryTests.swift +++ b/Tests/AccessibilityTranslationRecoveryTests.swift @@ -6,10 +6,13 @@ import Testing @Suite("Accessibility Translation Recovery Tests") @MainActor struct AccessibilityTranslationRecoveryTests { - @Test("Retries a transient missing translation without restarting the bridge") - func retriesTransientFailureWithoutRestart() async throws { + 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 @@ -22,32 +25,41 @@ struct AccessibilityTranslationRecoveryTests { let result = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( simulatorUDID: "TEST-UDID", logger: AxeLogger(), - dependencies: dependencies + dependencies: dependencies, + lockAcquirer: { _ in + lockCount += 1 + return AccessibilityRecoveryLockLease {} + }, + generationReader: missingGeneration ) { operationCount += 1 - if operationCount == 1 { + guard operationCount == 17 else { throw FBAccessibilityError.noTranslationObject } - return "recovered" + return "ready" } - #expect(result == "recovered") - #expect(operationCount == 2) + #expect(result == "ready") + #expect(operationCount == 17) #expect(restartCount == 0) - #expect(waits == [.milliseconds(100)]) + #expect(lockCount == 0) + #expect(waits == readinessIntervals) + #expect(waits.reduce(.zero, +) == .seconds(8)) } - @Test("Restarts only the target simulator bridge after a persistent translation failure") - func restartsTargetSimulatorBridgeOnce() async throws { + @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 restartCount = 0 var waits: [Duration] = [] let dependencies = AccessibilityRecoveryDependencies( runProcess: { receivedURL, receivedArguments, receivedTimeout in restartCount += 1 + didRestart = true executableURL = receivedURL arguments = receivedArguments timeout = receivedTimeout @@ -59,18 +71,21 @@ struct AccessibilityTranslationRecoveryTests { let result = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( simulatorUDID: "TARGET-UDID", logger: AxeLogger(), - dependencies: dependencies + dependencies: dependencies, + lockAcquirer: immediateLock, + generationReader: missingGeneration ) { operationCount += 1 - if operationCount < 3 { + guard didRestart else { throw FBAccessibilityError.noTranslationObject } return "recovered" } #expect(result == "recovered") - #expect(operationCount == 3) + #expect(operationCount == 19) #expect(restartCount == 1) + #expect(waits == readinessIntervals) #expect(executableURL?.path == "/usr/bin/xcrun") #expect(arguments == [ "simctl", @@ -82,26 +97,67 @@ struct AccessibilityTranslationRecoveryTests { "user/foreground/com.apple.CoreSimulator.bridge", ]) #expect(timeout == 3) - #expect(waits == [.milliseconds(100), .milliseconds(250)]) } - @Test("Reports a scoped bridge restart failure without another operation attempt") - func reportsBridgeRestartFailure() async { + @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 13 + return 0 }, - wait: { _ in } + wait: { waits.append($0) } ) do { _ = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( simulatorUDID: "TARGET-UDID", logger: AxeLogger(), - dependencies: dependencies + dependencies: dependencies, + lockAcquirer: immediateLock, + generationReader: missingGeneration ) { operationCount += 1 throw FBAccessibilityError.noTranslationObject @@ -110,23 +166,164 @@ struct AccessibilityTranslationRecoveryTests { } 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." + == "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 == 2) + #expect(operationCount == 35) #expect(restartCount == 1) + #expect(waits == readinessIntervals + readinessIntervals) } - @Test("Bounds persistent translation recovery to one bridge restart") - func boundsPersistentFailureRecovery() async { + @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 } ) @@ -134,7 +331,10 @@ struct AccessibilityTranslationRecoveryTests { _ = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( simulatorUDID: "TARGET-UDID", logger: AxeLogger(), - dependencies: dependencies + dependencies: dependencies, + readinessPollIntervals: [], + lockAcquirer: immediateLock, + generationReader: missingGeneration ) { operationCount += 1 throw FBAccessibilityError.noTranslationObject @@ -143,15 +343,14 @@ struct AccessibilityTranslationRecoveryTests { } 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." + == "AXe could not restart the CoreSimulator bridge for simulator TARGET-UDID (exit status 13). Restart the simulator and try again." ) } - #expect(operationCount == 3) - #expect(restartCount == 1) + #expect(operationCount == 2) } - @Test("Preserves unrelated errors at every recovery stage") + @Test("Preserves unrelated errors without recovery") func preservesUnrelatedErrors() async { var operationCount = 0 var restartCount = 0 @@ -172,7 +371,9 @@ struct AccessibilityTranslationRecoveryTests { _ = try await AccessibilityFetcher.retryingAfterAccessibilityRecovery( simulatorUDID: "TARGET-UDID", logger: AxeLogger(), - dependencies: dependencies + dependencies: dependencies, + lockAcquirer: immediateLock, + generationReader: missingGeneration ) { operationCount += 1 throw unrelated @@ -185,4 +386,65 @@ struct AccessibilityTranslationRecoveryTests { #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()) + } }