Skip to content

Commit 83f79b4

Browse files
committed
Fix provider registry refresh race
1 parent 268e9be commit 83f79b4

3 files changed

Lines changed: 95 additions & 1 deletion

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Issue 36 Provider Registry Race Plan
2+
3+
## Goal
4+
5+
Prevent Gemini requests from failing with `api("google-generative-ai")` when the first user message and background title generation overlap.
6+
7+
## Current State
8+
9+
`BridgeAIProviderRegistry.registerProviders()` refreshes providers by unregistering the entire `bridge-ai` source and then registering each provider again. The unregister/register sequence contains multiple async actor calls. If another request starts between the clear and the Gemini registration, `APIRegistry` can temporarily have no provider for `google-generative-ai`.
10+
11+
## Proposed Changes
12+
13+
- Add a regression test that keeps probing the Gemini provider while concurrent provider refreshes run.
14+
- Change `registerProviders()` so normal refreshes overwrite current provider registrations in place instead of clearing the whole source first.
15+
- Explicitly unregister only optional providers that are no longer valid, such as Azure OpenAI Responses without a valid endpoint.
16+
17+
## If We Do Not Change It
18+
19+
Users can keep seeing intermittent provider-not-found failures and may need to retry messages even with a valid Gemini API key.
20+
21+
## Expected Behavior After Change
22+
23+
Provider settings still refresh, but stable providers such as Gemini remain available throughout concurrent refreshes and background title generation.
24+
25+
## Acceptance Criteria
26+
27+
- A regression test covers concurrent provider refreshes while Gemini remains resolvable.
28+
- Existing macOS unit tests pass.
29+
- Manual validation confirms the registry no longer exposes an empty Gemini provider window; full GUI validation is reported separately if environment-limited.

macos/OpenBridge/Agent/BridgeAIProviderRegistry.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import KWWKAI
33

44
enum BridgeAIProviderRegistry {
55
private static let sourceId = "bridge-ai"
6+
private static let azureOpenAIResponsesAPI = "azure-openai-responses"
67

78
static func registerProviders() async {
8-
await APIRegistry.shared.unregisterSource(sourceId)
99
var settings = await BridgeAIProviderSecretStore.readSettings()
1010
let anthropicConfig = settings[.anthropic]
1111
if anthropicConfig.authMethod == .oauth {
@@ -24,6 +24,8 @@ enum BridgeAIProviderRegistry {
2424
endpoint: azureEndpoint,
2525
apiKey: nil
2626
), sourceId: sourceId)
27+
} else {
28+
await APIRegistry.shared.unregister(api: azureOpenAIResponsesAPI)
2729
}
2830
var openAIConfig = settings[.openAI]
2931
if openAIConfig.oauthAccountID == nil {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import Foundation
2+
import KWWKAI
3+
@testable import OpenBridge
4+
import Testing
5+
6+
private actor ProviderAvailabilityProbe {
7+
private(set) var missingCount = 0
8+
9+
func recordMissingProvider() {
10+
missingCount += 1
11+
}
12+
}
13+
14+
struct BridgeAIProviderRegistryTests {
15+
@Test
16+
func `concurrent provider refreshes keep Gemini registered`() async throws {
17+
let storeDirectory = FileManager.default.temporaryDirectory
18+
.appendingPathComponent(UUID().uuidString, isDirectory: true)
19+
let storeURL = storeDirectory.appendingPathComponent("secrets.json", isDirectory: false)
20+
BridgeAIProviderSecretStore.setStoreURLForTesting(storeURL)
21+
defer {
22+
BridgeAIProviderSecretStore.setStoreURLForTesting(nil)
23+
try? FileManager.default.removeItem(at: storeDirectory)
24+
}
25+
26+
var settings = BridgeAIProviderSettings(selectedModelProvider: "google", selectedModelID: "gemini-2.5-flash")
27+
var geminiConfig = settings[.googleGemini]
28+
geminiConfig.isEnabled = true
29+
geminiConfig.authMethod = .apiKey
30+
settings[.googleGemini] = geminiConfig
31+
try await BridgeAIProviderSecretStore.saveSecret("test-key", for: .googleGemini, kind: .apiKey)
32+
try await BridgeAIProviderSecretStore.saveSettings(settings)
33+
34+
await BridgeAIProviderRegistry.registerProviders()
35+
let initiallyRegistered = await APIRegistry.shared.provider(for: "google-generative-ai")
36+
#expect(initiallyRegistered != nil)
37+
38+
let probe = ProviderAvailabilityProbe()
39+
await withTaskGroup(of: Void.self) { group in
40+
for _ in 0 ..< 40 {
41+
group.addTask {
42+
await BridgeAIProviderRegistry.registerProviders()
43+
}
44+
}
45+
46+
for _ in 0 ..< 4 {
47+
group.addTask {
48+
for _ in 0 ..< 1000 {
49+
let provider = await APIRegistry.shared.provider(for: "google-generative-ai")
50+
if provider == nil {
51+
await probe.recordMissingProvider()
52+
return
53+
}
54+
await Task.yield()
55+
}
56+
}
57+
}
58+
}
59+
60+
let missingCount = await probe.missingCount
61+
#expect(missingCount == 0)
62+
}
63+
}

0 commit comments

Comments
 (0)