Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions Sources/XCDocs/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import XCDocsSupport
/// resolves search results into stable Swift value types.
@available(macOS 26, *)
public final class Client {
private var cachedSearchClient: VectorSearchClient?

/// Creates a client for interacting with the local documentation asset.
public init() {}

Expand Down Expand Up @@ -36,11 +38,9 @@ public final class Client {
limit: Int = 10,
omitContent: Bool = true
) async throws -> [SearchResult] {
let databaseDirectoryURL = try DocumentationAssetLocator().locateDatabaseDirectoryURL()
let searchClient = try await searchClient()
let vector = try await embeddingVector(for: query)

let searchClient = try await VectorSearchClient(databaseDirectoryURL: databaseDirectoryURL, readOnly: true)

let hits = try await searchClient.search(
vector: vector,
frameworks: frameworks,
Expand Down Expand Up @@ -74,10 +74,7 @@ public final class Client {
/// - Throws: An error if the documentation asset cannot be found, if the identifier does
/// not exist, or if the underlying storage backend fails to load the entry.
public func fetch(_ identifier: String) async throws -> DocumentationEntry {
let databaseDirectoryURL = try DocumentationAssetLocator().locateDatabaseDirectoryURL()

let searchClient = try await VectorSearchClient(databaseDirectoryURL: databaseDirectoryURL, readOnly: true)

let searchClient = try await searchClient()
let result = try await searchClient.fetch(identifier: identifier)

return DocumentationEntry(
Expand All @@ -91,6 +88,15 @@ public final class Client {

// MARK: Private

private func searchClient() async throws -> VectorSearchClient {
if let cachedSearchClient { return cachedSearchClient }

let databaseDirectoryURL = try DocumentationAssetLocator().locateDatabaseDirectoryURL()
let client = try await VectorSearchClient(databaseDirectoryURL: databaseDirectoryURL, readOnly: true)
cachedSearchClient = client
return client
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition in lazy client caching across suspension points

Medium Severity

The searchClient() method has a race condition: the nil check on cachedSearchClient at the top is separated from the assignment by await suspension points (DocumentationAssetLocator and VectorSearchClient init). Concurrent callers can both observe nil, both create a new VectorSearchClient, and both write to the property — defeating the caching goal. The PR description mentions a SearchClientCache actor to solve this, but no such actor exists in the code. Without actor isolation or another synchronization mechanism, the check-then-set pattern is not safe across suspension points.

Fix in Cursor Fix in Web


private func embeddingVector(for text: String) async throws -> Data {
let service = try await MADServiceObject()
let request = try await MADTextEmbeddingRequestObject()
Expand Down
Loading