From cfc4319c288f930628df5da9c6e407322fbae9ad Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:49:36 +0900 Subject: [PATCH 1/5] fix(docs): recover from incompatible search assets Treat text encoder initialization failures as provider failures, select and retry documentation assets by Xcode and host compatibility, and preserve the installed fallback across concurrent native responses. --- .../Session/DocumentationProvider.swift | 249 +++++++-- .../DocumentationProviderTests.swift | 479 +++++++++++++++++- .../RuntimeCoordinatorTestSupport.swift | 8 +- 3 files changed, 675 insertions(+), 61 deletions(-) diff --git a/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift b/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift index d0ebb39d..12069adf 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift @@ -131,21 +131,25 @@ struct LiveDocumentationSearchServiceRepairer: DocumentationSearchServiceRepairi private static let configURLDefaultsKey = "IDEChatDocumentationSearchConfigURL" private let assetRoot: URL + private let currentOSVersion: @Sendable () -> String private let readConfigURLOverride: @Sendable () -> String? private let writeConfigURLOverride: @Sendable (String) -> Bool init( assetRoot: URL = DocumentationSearchAssetLocator.defaultAssetRoot, + currentOSVersion: @escaping @Sendable () -> String = + DocumentationSearchAssetLocator.currentOperatingSystemVersionString, readConfigURLOverride: @escaping @Sendable () -> String? = Self.currentConfigURLOverride, writeConfigURLOverride: @escaping @Sendable (String) -> Bool = Self.writeConfigURLOverride ) { self.assetRoot = assetRoot + self.currentOSVersion = currentOSVersion self.readConfigURLOverride = readConfigURLOverride self.writeConfigURLOverride = writeConfigURLOverride } func repairDocumentationSearch( - for _: XcodeProcessTarget + for target: XcodeProcessTarget ) async -> DocumentationSearchServiceRepairResult { let scan: DocumentationSearchAssetScan do { @@ -153,7 +157,11 @@ struct LiveDocumentationSearchServiceRepairer: DocumentationSearchServiceRepairi } catch { return .failed("asset_scan_failed: \(error)") } - guard let asset = DocumentationSearchAssetLocator.latestAsset(from: scan.assets) else { + guard let asset = DocumentationSearchAssetLocator.bestAsset( + for: target.xcodeVersion, + currentOSVersion: currentOSVersion(), + from: scan.assets + ) else { return .skipped(scan.noAssetReason) } @@ -365,16 +373,26 @@ extension DocumentationProvider { } static func responseIsDocumentationProviderFailure(_ data: Data) -> Bool { - responseErrorTexts(in: data).contains { text in - let normalized = text.lowercased() - return normalized.contains("config.json") - || normalized.contains("documentation database") - || normalized.contains("asset is not installed") - || normalized.contains("unable to obtain asset location") - || normalized.contains("no matching asset") - || normalized.contains("cannot complete asset query") - || normalized.contains("cannot resolve asset query") - } + responseErrorTexts(in: data).contains(where: errorTextIsDocumentationProviderFailure) + } + + static func errorTextIsDocumentationProviderFailure(_ text: String) -> Bool { + let normalized = text.lowercased() + return normalized.contains("config.json") + || normalized.contains("documentation database") + || normalized.contains("asset is not installed") + || normalized.contains("unable to obtain asset location") + || normalized.contains("no matching asset") + || normalized.contains("cannot complete asset query") + || normalized.contains("cannot resolve asset query") + || errorTextIsTextEncoderInitializationFailure(normalized) + } + + static func errorTextIsTextEncoderInitializationFailure(_ text: String) -> Bool { + let normalized = text.lowercased() + return normalized.contains("text encoding failed") + || normalized.contains("failed to create text encoder configuration") + || normalized.contains("text embedding model file not found") } private static func replacingDocumentationSearch( @@ -669,8 +687,25 @@ enum DocumentationSearchAssetLocator { currentOSVersion: String, from assets: [DocumentationSearchInstalledAsset] ) -> DocumentationSearchInstalledAsset? { - assets.max { lhs, rhs in - isBetter(rhs, than: lhs, targetXcodeVersion: targetXcodeVersion, currentOSVersion: currentOSVersion) + assetsOrderedByCompatibility( + for: targetXcodeVersion, + currentOSVersion: currentOSVersion, + from: assets + ).first + } + + static func assetsOrderedByCompatibility( + for targetXcodeVersion: String, + currentOSVersion: String, + from assets: [DocumentationSearchInstalledAsset] + ) -> [DocumentationSearchInstalledAsset] { + assets.sorted { lhs, rhs in + isBetter( + lhs, + than: rhs, + targetXcodeVersion: targetXcodeVersion, + currentOSVersion: currentOSVersion + ) } } @@ -1665,29 +1700,78 @@ actor LiveDocumentationSearchActionInvoker: DocumentationSearchActionInvoking { } private actor DocumentationAssetSelectionCache { + private struct SelectionKey: Hashable { + let appPath: String + let xcodeVersion: String + let currentOSVersion: String + } + private struct RootSignature: Equatable { let path: String let modificationDate: Date? } private var cachedRootSignature: RootSignature? - private var cachedAsset: DocumentationSearchInstalledAsset? + private var cachedAssets: [DocumentationSearchInstalledAsset]? + private var successfulAssetPathBySelectionKey: [SelectionKey: String] = [:] - func latestInstalledAsset(assetRoot: URL) -> DocumentationSearchInstalledAsset? { + func installedAssets( + assetRoot: URL, + target: XcodeProcessTarget, + currentOSVersion: String + ) -> [DocumentationSearchInstalledAsset] { let signature = Self.rootSignature(for: assetRoot) - if cachedRootSignature == signature, let cachedAsset { - return cachedAsset - } - guard let scan = try? DocumentationSearchAssetLocator.scanInstalledAssets(in: assetRoot) - else { - return nil + let assets: [DocumentationSearchInstalledAsset] + if cachedRootSignature == signature, let cachedAssets { + assets = cachedAssets + } else { + guard let scan = try? DocumentationSearchAssetLocator.scanInstalledAssets(in: assetRoot) + else { + return [] + } + cachedRootSignature = signature + cachedAssets = scan.assets + successfulAssetPathBySelectionKey.removeAll() + assets = scan.assets + } + var orderedAssets = DocumentationSearchAssetLocator.assetsOrderedByCompatibility( + for: target.xcodeVersion, + currentOSVersion: currentOSVersion, + from: assets + ) + let key = SelectionKey( + appPath: target.appPath, + xcodeVersion: target.xcodeVersion, + currentOSVersion: currentOSVersion + ) + guard let successfulAssetPath = successfulAssetPathBySelectionKey[key], + let index = orderedAssets.firstIndex(where: { + $0.assetURL.path == successfulAssetPath + }), + index != orderedAssets.startIndex else { + return orderedAssets } - guard let asset = DocumentationSearchAssetLocator.latestAsset(from: scan.assets) else { - return nil + orderedAssets.insert(orderedAssets.remove(at: index), at: orderedAssets.startIndex) + return orderedAssets + } + + func recordSuccessfulAsset( + _ asset: DocumentationSearchInstalledAsset, + assetRoot: URL, + target: XcodeProcessTarget, + currentOSVersion: String + ) { + guard cachedRootSignature == Self.rootSignature(for: assetRoot), + cachedAssets?.contains(asset) == true else { + return } - cachedRootSignature = signature - cachedAsset = asset - return asset + successfulAssetPathBySelectionKey[ + SelectionKey( + appPath: target.appPath, + xcodeVersion: target.xcodeVersion, + currentOSVersion: currentOSVersion + ) + ] = asset.assetURL.path } private static func rootSignature(for assetRoot: URL) -> RootSignature { @@ -1710,22 +1794,26 @@ struct DocumentationSearchActionProvider: DocumentationSearchProviding { private let assetCache: DocumentationAssetSelectionCache private let invoker: any DocumentationSearchActionInvoking private let defaultTargetResolver: @Sendable () -> XcodeProcessTarget? + private let currentOSVersion: @Sendable () -> String init( assetRoot: URL = DocumentationSearchAssetLocator.defaultAssetRoot, invoker: any DocumentationSearchActionInvoking = LiveDocumentationSearchActionInvoker(), defaultTargetResolver: @escaping @Sendable () -> XcodeProcessTarget? = - Self.defaultXcodeProcessTarget + Self.defaultXcodeProcessTarget, + currentOSVersion: @escaping @Sendable () -> String = + DocumentationSearchAssetLocator.currentOperatingSystemVersionString ) { self.assetRoot = assetRoot self.invoker = invoker self.defaultTargetResolver = defaultTargetResolver + self.currentOSVersion = currentOSVersion assetCache = DocumentationAssetSelectionCache() } func descriptor(for target: XcodeProcessTarget) async -> JSONValue? { - guard await latestInstalledAsset() != nil, - let resolvedTarget = resolveTarget(target), + guard let resolvedTarget = resolveTarget(target), + await installedAssets(for: resolvedTarget).isEmpty == false, await invoker.isAvailable(for: resolvedTarget) else { return nil } @@ -1741,28 +1829,76 @@ struct DocumentationSearchActionProvider: DocumentationSearchProviding { throw TimeoutError() } let arguments = try Self.searchArguments(from: requestData) - guard let asset = await latestInstalledAsset(), - let resolvedTarget = resolveTarget(target) else { + guard let resolvedTarget = resolveTarget(target) else { throw UpstreamSlotScheduler.AcquisitionError.unavailable } - let output = try await invoker.invoke( - DocumentationSearchActionInvocation( - target: resolvedTarget, - asset: asset, - query: arguments.query, - frameworks: arguments.frameworks, - limit: arguments.limit - ), - timeout: timeout + let currentOSVersion = currentOSVersion() + let assets = await installedAssets( + for: resolvedTarget, + currentOSVersion: currentOSVersion ) - return try Self.makeResponse( - requestID: arguments.requestID, - output: output + guard assets.isEmpty == false else { + throw UpstreamSlotScheduler.AcquisitionError.unavailable + } + let deadline = Deadline.fromNow(timeout) + var lastTextEncoderInitializationError: (any Error)? + for asset in assets { + let remainingTimeout = deadline?.remaining() + if remainingTimeout?.nanoseconds == 0 { + throw TimeoutError() + } + do { + let output = try await invoker.invoke( + DocumentationSearchActionInvocation( + target: resolvedTarget, + asset: asset, + query: arguments.query, + frameworks: arguments.frameworks, + limit: arguments.limit + ), + timeout: remainingTimeout + ) + await assetCache.recordSuccessfulAsset( + asset, + assetRoot: assetRoot, + target: resolvedTarget, + currentOSVersion: currentOSVersion + ) + return try Self.makeResponse( + requestID: arguments.requestID, + output: output + ) + } catch is CancellationError { + throw CancellationError() + } catch { + guard Self.isTextEncoderInitializationFailure(error) else { + throw error + } + lastTextEncoderInitializationError = error + } + } + throw lastTextEncoderInitializationError + ?? UpstreamSlotScheduler.AcquisitionError.unavailable + } + + private func installedAssets( + for target: XcodeProcessTarget, + currentOSVersion: String? = nil + ) async -> [DocumentationSearchInstalledAsset] { + await assetCache.installedAssets( + assetRoot: assetRoot, + target: target, + currentOSVersion: currentOSVersion ?? self.currentOSVersion() ) } - private func latestInstalledAsset() async -> DocumentationSearchInstalledAsset? { - await assetCache.latestInstalledAsset(assetRoot: assetRoot) + private static func isTextEncoderInitializationFailure(_ error: any Error) -> Bool { + guard let controlPlaneError = error as? ControlPlane.Error, + case .invalidResponse(let message) = controlPlaneError else { + return false + } + return DocumentationProvider.ToolCatalog + .errorTextIsTextEncoderInitializationFailure(message) } private func resolveTarget(_ target: XcodeProcessTarget) -> XcodeProcessTarget? { @@ -3061,16 +3197,23 @@ actor DocumentationProviderManager: DocumentationProviderManaging { } private func promoteToActive(_ profile: CandidateProfile) async -> Bool { - let previous = activeProvider - activeProvider = ActiveProvider(profile: profile) - preparedProviders.removeValue(forKey: profile.target.processID) - guard let previous else { - return true + if let activeProvider, + activeProvider.profile.target.processID == profile.target.processID { + if preparedProviders[profile.target.processID]?.id == profile.id { + preparedProviders.removeValue(forKey: profile.target.processID) + } + return false } - guard previous.profile.id != profile.id else { + guard let prepared = preparedProviders[profile.target.processID], + prepared.id == profile.id else { return false } - await closeTransportRouteIfPresent(previous.profile, awaitTermination: isShutdown) + preparedProviders.removeValue(forKey: profile.target.processID) + let previous = activeProvider + activeProvider = ActiveProvider(profile: prepared) + if let previous { + await closeTransportRouteIfPresent(previous.profile, awaitTermination: isShutdown) + } return true } diff --git a/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift index 15583bae..439e22dc 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift @@ -23,15 +23,21 @@ private struct StubDocumentationSearchActionInvoker: DocumentationSearchActionIn let available: Bool let output: DocumentationSearchActionOutput let recorder: DocumentationSearchActionInvocationRecorder? + let failureMessagesByEmbeddingModelName: [String: String] + let timeoutEmbeddingModelNames: Set init( available: Bool = true, output: DocumentationSearchActionOutput, - recorder: DocumentationSearchActionInvocationRecorder? = nil + recorder: DocumentationSearchActionInvocationRecorder? = nil, + failureMessagesByEmbeddingModelName: [String: String] = [:], + timeoutEmbeddingModelNames: Set = [] ) { self.available = available self.output = output self.recorder = recorder + self.failureMessagesByEmbeddingModelName = failureMessagesByEmbeddingModelName + self.timeoutEmbeddingModelNames = timeoutEmbeddingModelNames } func isAvailable(for _: XcodeProcessTarget) async -> Bool { @@ -43,10 +49,96 @@ private struct StubDocumentationSearchActionInvoker: DocumentationSearchActionIn timeout _: TimeAmount? ) async throws -> DocumentationSearchActionOutput { await recorder?.record(invocation) + if timeoutEmbeddingModelNames.contains(invocation.asset.embeddingModelName) { + throw TimeoutError() + } + if let failureMessage = failureMessagesByEmbeddingModelName[ + invocation.asset.embeddingModelName + ] { + throw ControlPlane.Error.invalidResponse(failureMessage) + } return output } } +private actor ControllableDocumentationProviderTransport: DocumentationProviderRouting { + private let firstCallStarted: TestSignal + private let secondCallStarted: TestSignal + private var callContinuations: [CheckedContinuation] = [] + + init(firstCallStarted: TestSignal, secondCallStarted: TestSignal) { + self.firstCallStarted = firstCallStarted + self.secondCallStarted = secondCallStarted + } + + func openRoute( + for target: XcodeProcessTarget, + requestTimeout _: TimeAmount?, + initializeParams _: [String: JSONValue] + ) async throws -> DocumentationProviderRoute { + DocumentationProviderRoute( + id: "controllable-\(target.processID)", + target: target, + upstreamIndex: nil, + serverVersion: target.xcodeVersion + ) + } + + func toolsList( + route: DocumentationProviderRoute, + timeout _: TimeAmount? + ) async throws -> JSONValue { + .object([ + "tools": .array([ + documentationDescriptor(version: route.serverVersion), + ]), + ]) + } + + func callDocumentationSearch( + route _: DocumentationProviderRoute, + requestData _: Data, + timeout _: TimeAmount? + ) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + callContinuations.append(continuation) + switch callContinuations.count { + case 1: + firstCallStarted.signal() + case 2: + secondCallStarted.signal() + default: + break + } + } + } + + func respondToCall(at index: Int, with data: Data) { + callContinuations[index].resume(returning: data) + } + + func close(route _: DocumentationProviderRoute) async {} +} + +private func makeTextEncoderFallbackAssets(in root: URL) throws { + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27-md7v2", + xcodeVersion: "27.0", + osVersion: "26.4", + documentationRelease: 950001, + embeddingModelName: "md7v2" + ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5-md8", + xcodeVersion: "26.5", + osVersion: "26.6", + documentationRelease: 900340, + embeddingModelName: "md8" + ) +} + private actor DocumentationSearchActionProcessRecorder: ProcessRunning { private var requests: [ProcessRequest] = [] @@ -1558,6 +1650,103 @@ struct DocumentationProviderTests { #expect(await localProvider.requestedQueries() == ["SwiftUI", "UIKit"]) } + @Test func concurrentNativeSuccessDoesNotReplaceInstalledAssetFallback() + async throws + { + let target = xcodeProcessTarget(processID: 751, xcodeVersion: "26.6") + let firstCallStarted = TestSignal() + let secondCallStarted = TestSignal() + let transport = ControllableDocumentationProviderTransport( + firstCallStarted: firstCallStarted, + secondCallStarted: secondCallStarted + ) + let localProvider = StubDocumentationSearchProvider( + descriptor: documentationDescriptor(version: "asset-fallback"), + responseData: try makeDocumentationSearchResponse( + id: 149, + text: "{\"answer\":\"asset\"}" + ) + ) + let providerManager = DocumentationProviderManager( + discovery: StubXcodeTargetDiscovery(targets: [target]), + transport: transport, + providerSelectionTimeout: .seconds(1), + localSearchProvider: localProvider + ) + + let fallbackTask = Task { + try await providerManager.callDocumentationSearch( + requestData: makeDocumentationSearchRequest( + id: 149, + query: "EstimationTechnique" + ), + requestTimeoutOverride: .seconds(2) + ) + } + try await firstCallStarted.wait( + description: "waiting for first native DocumentationSearch call" + ) + + let nativeSuccessTask = Task { + try await providerManager.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 150, query: "UIView"), + requestTimeoutOverride: .seconds(2) + ) + } + try await secondCallStarted.wait( + description: "waiting for second native DocumentationSearch call" + ) + + let textEncoderFailure = try makeDocumentationSearchToolErrorResponse( + id: 149, + text: "Search failed for 'EstimationTechnique' with error: " + + "Error Domain=NSOSStatusErrorDomain Code=-18 " + + "UserInfo={NSLocalizedDescription=Text encoding failed ((null))}" + ) + await transport.respondToCall( + at: 0, + with: textEncoderFailure + ) + let fallbackOutcome = try await waitWithTimeout( + "waiting for installed asset fallback" + ) { + try await fallbackTask.value + } + guard case .handled(let fallbackData, let invalidatedProvider) = fallbackOutcome else { + Issue.record("expected handled fallback outcome, got \(fallbackOutcome)") + return + } + #expect(invalidatedProvider) + #expect(try toolContentText(in: fallbackData) == "{\"answer\":\"asset\"}") + + let nativeSuccess = try makeDocumentationSearchResponse( + id: 150, + text: "{\"answer\":\"native\"}" + ) + await transport.respondToCall( + at: 1, + with: nativeSuccess + ) + let nativeSuccessOutcome = try await waitWithTimeout( + "waiting for concurrent native response" + ) { + try await nativeSuccessTask.value + } + guard case .handled(let nativeData, _) = nativeSuccessOutcome else { + Issue.record("expected handled native outcome, got \(nativeSuccessOutcome)") + return + } + #expect(try toolContentText(in: nativeData) == "{\"answer\":\"native\"}") + + let update = await providerManager.toolListUpdate(requestTimeout: .seconds(1)) + let result = DocumentationProvider.ToolCatalog.applying( + update, + to: try jsonValue(["tools": []]) + ) + #expect(documentationDescriptorDescription(in: result) == "docs-asset-fallback") + #expect(await localProvider.requestedQueries() == ["EstimationTechnique"]) + } + @Test func runtimeDocumentationTransportKeepsBorrowedRouteAfterInitialAssetFallbackTimeout() async throws { @@ -4048,6 +4237,122 @@ struct DocumentationProviderTests { #expect(await factory.requestCount(processID: target.processID, method: "tools/call") == 1) } + @Test func documentationProviderFallsBackToInstalledAssetWhenTextEncoderIsUnavailable() + async throws + { + let target = xcodeProcessTarget(processID: 125, xcodeVersion: "26.6") + let factory = ScriptedDocumentationSessionFactory( + plansByPID: [ + target.processID: [ + .init( + serverVersion: "26.6", + toolCount: 21, + includesDocumentationSearch: true, + firstDocumentationResponse: .toolErrorText( + "Search failed for 'EstimationTechnique' with error: " + + "Error Domain=NSOSStatusErrorDomain Code=-18 " + + "UserInfo={NSLocalizedDescription=Text encoding failed ((null))}" + ), + userCallResponses: [.successText("{\"answer\":\"after-error\"}")] + ), + ], + ] + ) + let localProvider = StubDocumentationSearchProvider( + descriptor: documentationDescriptor(version: "asset-fallback"), + responseData: try makeDocumentationSearchResponse( + id: 125, + text: "{\"answer\":\"asset\"}" + ) + ) + let manager = DocumentationProviderManager( + discovery: StubXcodeTargetDiscovery(targets: [target]), + sessionFactory: factory, + localSearchProvider: localProvider + ) + + let firstOutcome = try await manager.callDocumentationSearch( + requestData: makeDocumentationSearchRequest( + id: 125, + query: "EstimationTechnique" + ), + requestTimeoutOverride: .seconds(1) + ) + let secondOutcome = try await manager.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 126, query: "UIView"), + requestTimeoutOverride: .seconds(1) + ) + + guard case .handled(let firstData, let firstInvalidatedProvider) = firstOutcome, + case .handled(let secondData, let secondInvalidatedProvider) = secondOutcome else { + Issue.record("expected handled outcomes, got \(firstOutcome) and \(secondOutcome)") + return + } + #expect(firstInvalidatedProvider) + #expect(secondInvalidatedProvider == false) + #expect(try toolContentText(in: firstData) == "{\"answer\":\"asset\"}") + #expect(try toolContentText(in: secondData) == "{\"answer\":\"asset\"}") + #expect(await localProvider.requestedCallPIDs() == [target.processID, target.processID]) + #expect(await localProvider.requestedQueries() == ["EstimationTechnique", "UIView"]) + #expect(await factory.documentationQueries(for: target.processID) == ["EstimationTechnique"]) + #expect(await factory.requestCount(processID: target.processID, method: "tools/call") == 1) + } + + @Test func liveDocumentationSearchServiceRepairerSelectsCompatibleAsset() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27", + xcodeVersion: "27.0", + osVersion: "26.4", + documentationRelease: 950001 + ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5-old-os", + xcodeVersion: "26.5", + osVersion: "26.2", + documentationRelease: 900339 + ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5-current-os", + xcodeVersion: "26.5", + osVersion: "26.6", + documentationRelease: 900340 + ) + let writtenValues = NIOLockedValueBox<[String]>([]) + let repairer = LiveDocumentationSearchServiceRepairer( + assetRoot: root, + currentOSVersion: { "26.6.1" }, + readConfigURLOverride: { nil }, + writeConfigURLOverride: { value in + writtenValues.withLockedValue { $0.append(value) } + return true + } + ) + + let result = await repairer.repairDocumentationSearch( + for: xcodeProcessTarget(processID: 126, xcodeVersion: "26.6") + ) + + guard case .repaired(let report) = result else { + Issue.record("expected repaired result, got \(result)") + return + } + #expect(report.xcodeVersion == "26.5") + #expect(report.osVersion == "26.6") + #expect(report.documentationRelease == 900340) + #expect(report.changedDefault) + #expect(report.configURL.contains("xcode-26-5-current-os.asset/AssetData/config.json")) + #expect(writtenValues.withLockedValue { $0 } == [report.configURL]) + } + @Test func documentationAssetLocatorTreatsTrailingZeroXcodeVersionsAsExactMatch() throws { @@ -4129,16 +4434,23 @@ struct DocumentationProviderTests { try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) try makeInstalledDocumentationAsset( root: root, - name: "xcode-26-5", + name: "xcode-26-5-old-os", xcodeVersion: "26.5", osVersion: "26.2", documentationRelease: 900339 ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5-current-os", + xcodeVersion: "26.5", + osVersion: "26.6", + documentationRelease: 900340 + ) try makeInstalledDocumentationAsset( root: root, name: "xcode-27", xcodeVersion: "27.0", - osVersion: "27.0", + osVersion: "26.4", documentationRelease: 950001 ) let recorder = DocumentationSearchActionInvocationRecorder() @@ -4156,7 +4468,8 @@ struct DocumentationProviderTests { ), ]), recorder: recorder - ) + ), + currentOSVersion: { "26.6.1" } ) #expect(await provider.descriptor(for: target) != nil) @@ -4196,13 +4509,167 @@ struct DocumentationProviderTests { let invocation = try #require(await recorder.recordedValues().first) #expect(invocation.target == target) - #expect(invocation.asset.xcodeVersion == "27.0") - #expect(invocation.asset.documentationRelease == 950001) + #expect(invocation.asset.xcodeVersion == "26.5") + #expect(invocation.asset.osVersion == "26.6") + #expect(invocation.asset.documentationRelease == 900340) #expect(invocation.query == "UIView") #expect(invocation.frameworks == ["UIKit"]) #expect(invocation.limit == 2) } + @Test func documentationSearchActionProviderCachesAssetsInsteadOfOneTargetSelection() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5", + xcodeVersion: "26.5", + osVersion: "26.6", + documentationRelease: 900340 + ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27", + xcodeVersion: "27.0", + osVersion: "26.6", + documentationRelease: 950001 + ) + let recorder = DocumentationSearchActionInvocationRecorder() + let provider = DocumentationSearchActionProvider( + assetRoot: root, + invoker: StubDocumentationSearchActionInvoker( + output: DocumentationSearchActionOutput(documents: []), + recorder: recorder + ), + currentOSVersion: { "26.6.1" } + ) + let xcode26 = xcodeProcessTarget(processID: 127, xcodeVersion: "26.6") + let xcode27 = xcodeProcessTarget(processID: 128, xcodeVersion: "27.0") + + _ = try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 127, query: "UIView"), + for: xcode26, + timeout: .seconds(1) + ) + _ = try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 128, query: "SwiftUI"), + for: xcode27, + timeout: .seconds(1) + ) + + let invocations = await recorder.recordedValues() + #expect(invocations.map(\.target) == [xcode26, xcode27]) + #expect(invocations.map(\.asset.xcodeVersion) == ["26.5", "27.0"]) + } + + @Test func documentationSearchActionProviderRetriesTextEncoderFailureAndCachesSuccess() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeTextEncoderFallbackAssets(in: root) + let recorder = DocumentationSearchActionInvocationRecorder() + let provider = DocumentationSearchActionProvider( + assetRoot: root, + invoker: StubDocumentationSearchActionInvoker( + output: DocumentationSearchActionOutput(documents: []), + recorder: recorder, + failureMessagesByEmbeddingModelName: [ + "md7v2": "DocumentationSearchAction helper failed: Text encoding failed ((null))", + ] + ), + currentOSVersion: { "26.6.1" } + ) + let target = xcodeProcessTarget(processID: 129, xcodeVersion: "27.0") + + let firstResponse = try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest( + id: 129, + query: "EstimationTechnique" + ), + for: target, + timeout: .seconds(1) + ) + let secondResponse = try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 130, query: "UIView"), + for: target, + timeout: .seconds(1) + ) + + #expect(try responseID(in: firstResponse) == 129) + #expect(try responseID(in: secondResponse) == 130) + let invocations = await recorder.recordedValues() + #expect(invocations.map(\.asset.embeddingModelName) == ["md7v2", "md8", "md8"]) + } + + @Test func documentationSearchActionProviderDoesNotRetryUnrelatedHelperFailure() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeTextEncoderFallbackAssets(in: root) + let recorder = DocumentationSearchActionInvocationRecorder() + let provider = DocumentationSearchActionProvider( + assetRoot: root, + invoker: StubDocumentationSearchActionInvoker( + output: DocumentationSearchActionOutput(documents: []), + recorder: recorder, + failureMessagesByEmbeddingModelName: [ + "md7v2": "DocumentationSearchAction helper failed: query-specific failure", + ] + ), + currentOSVersion: { "26.6.1" } + ) + + await #expect(throws: ControlPlane.Error.self) { + try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 131, query: "bad query"), + for: xcodeProcessTarget(processID: 131, xcodeVersion: "27.0"), + timeout: .seconds(1) + ) + } + + #expect(await recorder.recordedValues().map(\.asset.embeddingModelName) == ["md7v2"]) + } + + @Test func documentationSearchActionProviderDoesNotRetryTimeout() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeTextEncoderFallbackAssets(in: root) + let recorder = DocumentationSearchActionInvocationRecorder() + let provider = DocumentationSearchActionProvider( + assetRoot: root, + invoker: StubDocumentationSearchActionInvoker( + output: DocumentationSearchActionOutput(documents: []), + recorder: recorder, + timeoutEmbeddingModelNames: ["md7v2"] + ), + currentOSVersion: { "26.6.1" } + ) + + await #expect(throws: TimeoutError.self) { + try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 132, query: "UIView"), + for: xcodeProcessTarget(processID: 132, xcodeVersion: "27.0"), + timeout: .seconds(1) + ) + } + + #expect(await recorder.recordedValues().map(\.asset.embeddingModelName) == ["md7v2"]) + } + @Test func documentationSearchActionInvokerPassesLatestAssetWithActionDefaults() async throws { diff --git a/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTestSupport.swift b/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTestSupport.swift index 2e72da49..f6e0e3cd 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTestSupport.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/RuntimeCoordinatorTestSupport.swift @@ -484,7 +484,8 @@ func makeInstalledDocumentationAsset( name: String, xcodeVersion: String, osVersion: String, - documentationRelease: Int + documentationRelease: Int, + embeddingModelName: String? = nil ) throws { let assetURL = root.appendingPathComponent("\(name).asset", isDirectory: true) let assetDataURL = assetURL.appendingPathComponent("AssetData", isDirectory: true) @@ -503,7 +504,10 @@ func makeInstalledDocumentationAsset( try PropertyListEncoder().encode(plist).write( to: assetURL.appendingPathComponent("Info.plist", isDirectory: false) ) - try Data("{}".utf8).write( + let config: [String: String] = embeddingModelName.map { + ["embeddingModelName": $0] + } ?? [:] + try JSONEncoder().encode(config).write( to: assetDataURL.appendingPathComponent("config.json", isDirectory: false) ) try Data().write( From ca3e48334ca4857dc1644025419025fcdd308a89 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:09:06 +0900 Subject: [PATCH 2/5] fix(docs): prefer host-compatible repair assets Prioritize assets that do not target a newer host OS and prevent the service repairer from persisting an ineligible configuration when no host-compatible asset exists. --- .../Session/DocumentationProvider.swift | 28 ++++--- .../DocumentationProviderTests.swift | 77 ++++++++++++++++++- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift b/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift index 12069adf..e3319b6d 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift @@ -157,12 +157,18 @@ struct LiveDocumentationSearchServiceRepairer: DocumentationSearchServiceRepairi } catch { return .failed("asset_scan_failed: \(error)") } - guard let asset = DocumentationSearchAssetLocator.bestAsset( + let hostOSVersion = currentOSVersion() + guard let asset = DocumentationSearchAssetLocator.bestHostCompatibleAsset( for: target.xcodeVersion, - currentOSVersion: currentOSVersion(), + currentOSVersion: hostOSVersion, from: scan.assets ) else { - return .skipped(scan.noAssetReason) + guard scan.assets.isEmpty == false else { + return .skipped(scan.noAssetReason) + } + return .skipped( + "no_host_compatible_documentation_asset current_os=\(hostOSVersion)" + ) } let configURLString = asset.configURL.path @@ -682,7 +688,7 @@ enum DocumentationSearchAssetLocator { )) } - static func bestAsset( + static func bestHostCompatibleAsset( for targetXcodeVersion: String, currentOSVersion: String, from assets: [DocumentationSearchInstalledAsset] @@ -691,7 +697,9 @@ enum DocumentationSearchAssetLocator { for: targetXcodeVersion, currentOSVersion: currentOSVersion, from: assets - ).first + ).first { asset in + compareVersion(asset.osVersion, currentOSVersion) != .orderedDescending + } } static func assetsOrderedByCompatibility( @@ -745,6 +753,9 @@ enum DocumentationSearchAssetLocator { ) -> Bool { let lhsRank = rank(lhs, targetXcodeVersion: targetXcodeVersion, currentOSVersion: currentOSVersion) let rhsRank = rank(rhs, targetXcodeVersion: targetXcodeVersion, currentOSVersion: currentOSVersion) + if lhsRank.notNewerThanCurrentOS != rhsRank.notNewerThanCurrentOS { + return lhsRank.notNewerThanCurrentOS + } if lhsRank.exactXcodeVersion != rhsRank.exactXcodeVersion { return lhsRank.exactXcodeVersion } @@ -757,9 +768,6 @@ enum DocumentationSearchAssetLocator { if lhsRank.xcodeVersionDistance != rhsRank.xcodeVersionDistance { return lhsRank.xcodeVersionDistance < rhsRank.xcodeVersionDistance } - if lhsRank.notNewerThanCurrentOS != rhsRank.notNewerThanCurrentOS { - return lhsRank.notNewerThanCurrentOS - } if lhsRank.osVersionDistance != rhsRank.osVersionDistance { return lhsRank.osVersionDistance < rhsRank.osVersionDistance } @@ -770,11 +778,11 @@ enum DocumentationSearchAssetLocator { } private struct AssetRank { + let notNewerThanCurrentOS: Bool let exactXcodeVersion: Bool let sameXcodeMajor: Bool let notNewerThanTargetXcode: Bool let xcodeVersionDistance: Int - let notNewerThanCurrentOS: Bool let osVersionDistance: Int let documentationRelease: Int } @@ -789,11 +797,11 @@ enum DocumentationSearchAssetLocator { let assetOSParts = numericVersionParts(asset.osVersion) let currentOSParts = numericVersionParts(currentOSVersion) return AssetRank( + notNewerThanCurrentOS: compareVersion(asset.osVersion, currentOSVersion) != .orderedDescending, exactXcodeVersion: compareVersion(asset.xcodeVersion, targetXcodeVersion) == .orderedSame, sameXcodeMajor: assetXcodeParts.first != nil && assetXcodeParts.first == targetXcodeParts.first, notNewerThanTargetXcode: compareVersion(asset.xcodeVersion, targetXcodeVersion) != .orderedDescending, xcodeVersionDistance: versionDistance(assetXcodeParts, targetXcodeParts), - notNewerThanCurrentOS: compareVersion(asset.osVersion, currentOSVersion) != .orderedDescending, osVersionDistance: versionDistance(assetOSParts, currentOSParts), documentationRelease: asset.documentationRelease ?? 0 ) diff --git a/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift index 439e22dc..e5da7f58 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift @@ -4298,7 +4298,7 @@ struct DocumentationProviderTests { #expect(await factory.requestCount(processID: target.processID, method: "tools/call") == 1) } - @Test func liveDocumentationSearchServiceRepairerSelectsCompatibleAsset() + @Test func liveDocumentationSearchServiceRepairerPrefersHostCompatibleAssetOverExactXcodeVersion() async throws { let root = FileManager.default.temporaryDirectory @@ -4309,7 +4309,7 @@ struct DocumentationProviderTests { root: root, name: "xcode-27", xcodeVersion: "27.0", - osVersion: "26.4", + osVersion: "27.0", documentationRelease: 950001 ) try makeInstalledDocumentationAsset( @@ -4338,7 +4338,7 @@ struct DocumentationProviderTests { ) let result = await repairer.repairDocumentationSearch( - for: xcodeProcessTarget(processID: 126, xcodeVersion: "26.6") + for: xcodeProcessTarget(processID: 126, xcodeVersion: "27.0") ) guard case .repaired(let report) = result else { @@ -4353,6 +4353,75 @@ struct DocumentationProviderTests { #expect(writtenValues.withLockedValue { $0 } == [report.configURL]) } + @Test func liveDocumentationSearchServiceRepairerSkipsWithoutHostCompatibleAsset() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27", + xcodeVersion: "27.0", + osVersion: "27.0", + documentationRelease: 950001 + ) + let writtenValues = NIOLockedValueBox<[String]>([]) + let repairer = LiveDocumentationSearchServiceRepairer( + assetRoot: root, + currentOSVersion: { "26.6.1" }, + readConfigURLOverride: { nil }, + writeConfigURLOverride: { value in + writtenValues.withLockedValue { $0.append(value) } + return true + } + ) + + let result = await repairer.repairDocumentationSearch( + for: xcodeProcessTarget(processID: 126, xcodeVersion: "27.0") + ) + + #expect( + result == .skipped( + "no_host_compatible_documentation_asset current_os=26.6.1" + ) + ) + #expect(writtenValues.withLockedValue { $0 }.isEmpty) + } + + @Test func documentationAssetLocatorOrdersHostCompatibilityBeforeExactXcodeVersion() + throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27-newer-os", + xcodeVersion: "27.0", + osVersion: "27.0", + documentationRelease: 950001 + ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5-current-os", + xcodeVersion: "26.5", + osVersion: "26.6", + documentationRelease: 900340 + ) + let scan = try DocumentationSearchAssetLocator.scanInstalledAssets(in: root) + + let orderedAssets = DocumentationSearchAssetLocator.assetsOrderedByCompatibility( + for: "27.0", + currentOSVersion: "26.6.1", + from: scan.assets + ) + + #expect(orderedAssets.map(\.xcodeVersion) == ["26.5", "27.0"]) + } + @Test func documentationAssetLocatorTreatsTrailingZeroXcodeVersionsAsExactMatch() throws { @@ -4377,7 +4446,7 @@ struct DocumentationProviderTests { let scan = try DocumentationSearchAssetLocator.scanInstalledAssets(in: root) let asset = try #require( - DocumentationSearchAssetLocator.bestAsset( + DocumentationSearchAssetLocator.bestHostCompatibleAsset( for: "26.0", currentOSVersion: "26.0", from: scan.assets From 44d73a30906231a1a93e87df029c4776adfe03d3 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:18:20 +0900 Subject: [PATCH 3/5] fix(docs): reject nonpositive search timeouts --- .../Session/DocumentationProvider.swift | 2 +- .../DocumentationProviderTests.swift | 21 ++++++++++++------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift b/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift index e3319b6d..d5b2f4aa 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift @@ -1833,7 +1833,7 @@ struct DocumentationSearchActionProvider: DocumentationSearchProviding { for target: XcodeProcessTarget, timeout: TimeAmount? ) async throws -> Data { - guard timeout?.nanoseconds != 0 else { + guard timeout.map({ $0.nanoseconds > 0 }) ?? true else { throw TimeoutError() } let arguments = try Self.searchArguments(from: requestData) diff --git a/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift index e5da7f58..952b5fed 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift @@ -5122,7 +5122,7 @@ struct DocumentationProviderTests { #expect(await unavailableInvokerProvider.descriptor(for: target) == nil) } - @Test func documentationSearchActionProviderHonorsSearchTimeout() + @Test func documentationSearchActionProviderRejectsNonPositiveSearchTimeout() async throws { let root = FileManager.default.temporaryDirectory @@ -5136,6 +5136,7 @@ struct DocumentationProviderTests { osVersion: "26.2", documentationRelease: 900339 ) + let recorder = DocumentationSearchActionInvocationRecorder() let provider = DocumentationSearchActionProvider( assetRoot: root, invoker: StubDocumentationSearchActionInvoker( @@ -5147,18 +5148,22 @@ struct DocumentationProviderTests { score: 0.92, kind: "symbol" ), - ]) + ]), + recorder: recorder ) ) let target = xcodeProcessTarget(processID: 125, xcodeVersion: "26.6") - await #expect(throws: TimeoutError.self) { - try await provider.callDocumentationSearch( - requestData: makeDocumentationSearchRequest(id: 125, query: "UIView"), - for: target, - timeout: .nanoseconds(0) - ) + for timeout in [TimeAmount.nanoseconds(0), .nanoseconds(-1)] { + await #expect(throws: TimeoutError.self) { + try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 125, query: "UIView"), + for: target, + timeout: timeout + ) + } } + #expect(await recorder.recordedValues().isEmpty) } @Test func documentationProviderBackgroundDiscoveryRemovesStaleDescriptorWhenAbsent() From 3528a034e3dbd27f65d9b263e6da40314eb90d1f Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:32:10 +0900 Subject: [PATCH 4/5] fix(docs): retire stale provider routes --- .../Session/DocumentationProvider.swift | 7 +++++++ .../DocumentationProviderTests.swift | 12 ++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift b/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift index d5b2f4aa..6bb053a3 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift @@ -3210,6 +3210,13 @@ actor DocumentationProviderManager: DocumentationProviderManaging { if preparedProviders[profile.target.processID]?.id == profile.id { preparedProviders.removeValue(forKey: profile.target.processID) } + if activeProvider.profile.id != profile.id, + let route = profile.route, + activeProvider.profile.route?.id != route.id, + preparedProviders.values.contains(where: { $0.route?.id == route.id }) == false + { + await closeTransportRouteIfPresent(profile, awaitTermination: isShutdown) + } return false } guard let prepared = preparedProviders[profile.target.processID], diff --git a/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift index 952b5fed..49959c6b 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift @@ -65,6 +65,7 @@ private actor ControllableDocumentationProviderTransport: DocumentationProviderR private let firstCallStarted: TestSignal private let secondCallStarted: TestSignal private var callContinuations: [CheckedContinuation] = [] + private var closedRouteIDs: [String] = [] init(firstCallStarted: TestSignal, secondCallStarted: TestSignal) { self.firstCallStarted = firstCallStarted @@ -117,7 +118,13 @@ private actor ControllableDocumentationProviderTransport: DocumentationProviderR callContinuations[index].resume(returning: data) } - func close(route _: DocumentationProviderRoute) async {} + func close(route: DocumentationProviderRoute) async { + closedRouteIDs.append(route.id) + } + + func closedRoutes() -> [String] { + closedRouteIDs + } } private func makeTextEncoderFallbackAssets(in root: URL) throws { @@ -1650,7 +1657,7 @@ struct DocumentationProviderTests { #expect(await localProvider.requestedQueries() == ["SwiftUI", "UIKit"]) } - @Test func concurrentNativeSuccessDoesNotReplaceInstalledAssetFallback() + @Test func concurrentNativeSuccessPreservesFallbackAfterNativeRouteCloses() async throws { let target = xcodeProcessTarget(processID: 751, xcodeVersion: "26.6") @@ -1745,6 +1752,7 @@ struct DocumentationProviderTests { ) #expect(documentationDescriptorDescription(in: result) == "docs-asset-fallback") #expect(await localProvider.requestedQueries() == ["EstimationTechnique"]) + #expect(await transport.closedRoutes() == ["controllable-751"]) } @Test func runtimeDocumentationTransportKeepsBorrowedRouteAfterInitialAssetFallbackTimeout() From 2c0710c3c7c5dc1824d23b0e72a88fe5f6736d09 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:50:28 +0900 Subject: [PATCH 5/5] fix(docs): exclude assets newer than host --- .../Session/DocumentationProvider.swift | 44 ++++----- .../DocumentationProviderTests.swift | 97 +++++++++++++++++-- 2 files changed, 111 insertions(+), 30 deletions(-) diff --git a/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift b/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift index 6bb053a3..c0a56495 100644 --- a/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift +++ b/Sources/XcodeMCPProxyRuntime/Session/DocumentationProvider.swift @@ -693,28 +693,30 @@ enum DocumentationSearchAssetLocator { currentOSVersion: String, from assets: [DocumentationSearchInstalledAsset] ) -> DocumentationSearchInstalledAsset? { - assetsOrderedByCompatibility( + hostCompatibleAssetsOrderedByCompatibility( for: targetXcodeVersion, currentOSVersion: currentOSVersion, from: assets - ).first { asset in - compareVersion(asset.osVersion, currentOSVersion) != .orderedDescending - } + ).first } - static func assetsOrderedByCompatibility( + static func hostCompatibleAssetsOrderedByCompatibility( for targetXcodeVersion: String, currentOSVersion: String, from assets: [DocumentationSearchInstalledAsset] ) -> [DocumentationSearchInstalledAsset] { - assets.sorted { lhs, rhs in - isBetter( - lhs, - than: rhs, - targetXcodeVersion: targetXcodeVersion, - currentOSVersion: currentOSVersion - ) - } + assets + .filter { + compareVersion($0.osVersion, currentOSVersion) != .orderedDescending + } + .sorted { lhs, rhs in + isBetter( + lhs, + than: rhs, + targetXcodeVersion: targetXcodeVersion, + currentOSVersion: currentOSVersion + ) + } } static func latestAsset( @@ -753,9 +755,6 @@ enum DocumentationSearchAssetLocator { ) -> Bool { let lhsRank = rank(lhs, targetXcodeVersion: targetXcodeVersion, currentOSVersion: currentOSVersion) let rhsRank = rank(rhs, targetXcodeVersion: targetXcodeVersion, currentOSVersion: currentOSVersion) - if lhsRank.notNewerThanCurrentOS != rhsRank.notNewerThanCurrentOS { - return lhsRank.notNewerThanCurrentOS - } if lhsRank.exactXcodeVersion != rhsRank.exactXcodeVersion { return lhsRank.exactXcodeVersion } @@ -778,7 +777,6 @@ enum DocumentationSearchAssetLocator { } private struct AssetRank { - let notNewerThanCurrentOS: Bool let exactXcodeVersion: Bool let sameXcodeMajor: Bool let notNewerThanTargetXcode: Bool @@ -797,7 +795,6 @@ enum DocumentationSearchAssetLocator { let assetOSParts = numericVersionParts(asset.osVersion) let currentOSParts = numericVersionParts(currentOSVersion) return AssetRank( - notNewerThanCurrentOS: compareVersion(asset.osVersion, currentOSVersion) != .orderedDescending, exactXcodeVersion: compareVersion(asset.xcodeVersion, targetXcodeVersion) == .orderedSame, sameXcodeMajor: assetXcodeParts.first != nil && assetXcodeParts.first == targetXcodeParts.first, notNewerThanTargetXcode: compareVersion(asset.xcodeVersion, targetXcodeVersion) != .orderedDescending, @@ -1742,11 +1739,12 @@ private actor DocumentationAssetSelectionCache { successfulAssetPathBySelectionKey.removeAll() assets = scan.assets } - var orderedAssets = DocumentationSearchAssetLocator.assetsOrderedByCompatibility( - for: target.xcodeVersion, - currentOSVersion: currentOSVersion, - from: assets - ) + var orderedAssets = DocumentationSearchAssetLocator + .hostCompatibleAssetsOrderedByCompatibility( + for: target.xcodeVersion, + currentOSVersion: currentOSVersion, + from: assets + ) let key = SelectionKey( appPath: target.appPath, xcodeVersion: target.xcodeVersion, diff --git a/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift b/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift index 49959c6b..dd844697 100644 --- a/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift +++ b/Tests/XcodeMCPProxyRuntimeTests/DocumentationProviderTests.swift @@ -4398,7 +4398,7 @@ struct DocumentationProviderTests { #expect(writtenValues.withLockedValue { $0 }.isEmpty) } - @Test func documentationAssetLocatorOrdersHostCompatibilityBeforeExactXcodeVersion() + @Test func documentationAssetLocatorExcludesAssetsNewerThanHostOS() throws { let root = FileManager.default.temporaryDirectory @@ -4421,13 +4421,14 @@ struct DocumentationProviderTests { ) let scan = try DocumentationSearchAssetLocator.scanInstalledAssets(in: root) - let orderedAssets = DocumentationSearchAssetLocator.assetsOrderedByCompatibility( - for: "27.0", - currentOSVersion: "26.6.1", - from: scan.assets - ) + let orderedAssets = DocumentationSearchAssetLocator + .hostCompatibleAssetsOrderedByCompatibility( + for: "27.0", + currentOSVersion: "26.6.1", + from: scan.assets + ) - #expect(orderedAssets.map(\.xcodeVersion) == ["26.5", "27.0"]) + #expect(orderedAssets.map(\.xcodeVersion) == ["26.5"]) } @Test func documentationAssetLocatorTreatsTrailingZeroXcodeVersionsAsExactMatch() @@ -4502,6 +4503,42 @@ struct DocumentationProviderTests { #expect(asset.assetURL.path.contains("xcode-27-new-release.asset")) } + @Test func documentationSearchActionProviderIsUnavailableWithOnlyNewerOSAsset() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27-newer-os", + xcodeVersion: "27.0", + osVersion: "27.0", + documentationRelease: 950001 + ) + let recorder = DocumentationSearchActionInvocationRecorder() + let provider = DocumentationSearchActionProvider( + assetRoot: root, + invoker: StubDocumentationSearchActionInvoker( + output: DocumentationSearchActionOutput(documents: []), + recorder: recorder + ), + currentOSVersion: { "26.6.1" } + ) + let target = xcodeProcessTarget(processID: 122, xcodeVersion: "27.0") + + #expect(await provider.descriptor(for: target) == nil) + await #expect(throws: UpstreamSlotScheduler.AcquisitionError.self) { + try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 122, query: "UIView"), + for: target, + timeout: .seconds(1) + ) + } + #expect(await recorder.recordedValues().isEmpty) + } + @Test func documentationSearchActionProviderReturnsHelperOutput() async throws { @@ -4685,6 +4722,52 @@ struct DocumentationProviderTests { #expect(invocations.map(\.asset.embeddingModelName) == ["md7v2", "md8", "md8"]) } + @Test func documentationSearchActionProviderDoesNotRetryWithAssetNewerThanHostOS() + async throws + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("xcode-doc-assets-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-26-5-md8", + xcodeVersion: "26.5", + osVersion: "26.6", + documentationRelease: 900340, + embeddingModelName: "md8" + ) + try makeInstalledDocumentationAsset( + root: root, + name: "xcode-27-md7v2", + xcodeVersion: "27.0", + osVersion: "27.0", + documentationRelease: 950001, + embeddingModelName: "md7v2" + ) + let recorder = DocumentationSearchActionInvocationRecorder() + let provider = DocumentationSearchActionProvider( + assetRoot: root, + invoker: StubDocumentationSearchActionInvoker( + output: DocumentationSearchActionOutput(documents: []), + recorder: recorder, + failureMessagesByEmbeddingModelName: [ + "md8": "DocumentationSearchAction helper failed: Text encoding failed ((null))", + ] + ), + currentOSVersion: { "26.6.1" } + ) + + await #expect(throws: ControlPlane.Error.self) { + try await provider.callDocumentationSearch( + requestData: makeDocumentationSearchRequest(id: 133, query: "UIView"), + for: xcodeProcessTarget(processID: 133, xcodeVersion: "27.0"), + timeout: .seconds(1) + ) + } + #expect(await recorder.recordedValues().map(\.asset.embeddingModelName) == ["md8"]) + } + @Test func documentationSearchActionProviderDoesNotRetryUnrelatedHelperFailure() async throws {