@@ -3,16 +3,20 @@ package dev.openfeature.kotlin.sdk
33import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents
44import dev.openfeature.kotlin.sdk.exceptions.OpenFeatureError
55import kotlinx.coroutines.ExperimentalCoroutinesApi
6+ import kotlinx.coroutines.delay
67import kotlinx.coroutines.flow.MutableSharedFlow
78import kotlinx.coroutines.flow.first
9+ import kotlinx.coroutines.flow.flow
810import kotlinx.coroutines.launch
11+ import kotlinx.coroutines.sync.withLock
912import kotlinx.coroutines.test.StandardTestDispatcher
1013import kotlinx.coroutines.test.advanceUntilIdle
1114import kotlinx.coroutines.test.runTest
1215import kotlin.test.Test
1316import kotlin.test.assertEquals
1417import kotlin.test.assertNotEquals
1518import kotlin.test.assertSame
19+ import kotlin.test.assertTrue
1620
1721@OptIn(ExperimentalCoroutinesApi ::class )
1822class ProviderRepositoryTest {
@@ -157,4 +161,146 @@ class ProviderRepositoryTest {
157161 // Verify the newly mapped listener successfully overrides state
158162 assertEquals(OpenFeatureStatus .Stale , state.getStatus())
159163 }
164+
165+ @Test
166+ fun `DomainState should restart event listener if dispatcher changes` () = runTest {
167+ val state = DomainState ()
168+
169+ val dispatcher1 = StandardTestDispatcher (testScheduler)
170+ val dispatcher2 = StandardTestDispatcher (testScheduler)
171+
172+ val providerEvents = MutableSharedFlow <OpenFeatureProviderEvents >()
173+ class MockEventProvider : FeatureProvider by NoOpProvider () {
174+ override fun observe () = providerEvents
175+ }
176+ val provider = MockEventProvider ()
177+ state.providersFlow.value = provider
178+
179+ // Step 1: Initialize with dispatcher1
180+ state.initializeListener(dispatcher1)
181+ testScheduler.advanceUntilIdle()
182+
183+ // Emit event to verify dispatcher1 listener is working
184+ providerEvents.emit(OpenFeatureProviderEvents .ProviderReady ())
185+ testScheduler.advanceUntilIdle()
186+ assertEquals(OpenFeatureStatus .Ready , state.getStatus())
187+
188+ // Step 2: Initialize with the EXACT SAME dispatcher, shouldn't disrupt anything
189+ state.initializeListener(dispatcher1)
190+ testScheduler.advanceUntilIdle()
191+
192+ // Step 3: Initialize with a DIFFERENT dispatcher (dispatcher2)
193+ state.initializeListener(dispatcher2)
194+ testScheduler.advanceUntilIdle()
195+
196+ // Fire another event, it should be processed by the NEW listener that was just hot-swapped
197+ providerEvents.emit(OpenFeatureProviderEvents .ProviderStale ())
198+ testScheduler.advanceUntilIdle()
199+ assertEquals(OpenFeatureStatus .Stale , state.getStatus())
200+ }
201+
202+ @Test
203+ fun `DomainState should automatically retry and emit error on provider unhandled exception` () = runTest {
204+ val state = DomainState ()
205+ val errorMsg = " Simulated internal crash"
206+ var observeCallCount = 0
207+
208+ class UnstableProvider : FeatureProvider by NoOpProvider () {
209+ override fun observe (): kotlinx.coroutines.flow.Flow <OpenFeatureProviderEvents > = flow {
210+ observeCallCount++
211+ throw RuntimeException (errorMsg)
212+ }
213+ }
214+
215+ state.providersFlow.value = UnstableProvider ()
216+ val testDispatcher = StandardTestDispatcher (testScheduler)
217+ state.initializeListener(testDispatcher)
218+
219+ testScheduler.advanceTimeBy(100L ) // Process first crash without entering infinite loop
220+
221+ val status = state.getStatus()
222+ assertTrue(status is OpenFeatureStatus .Error )
223+ assertEquals(errorMsg, status.error.message)
224+ assertEquals(1 , observeCallCount)
225+
226+ // Then, advance by 3000ms. It should trigger retryWhen delay and retry
227+ testScheduler.advanceTimeBy(3500L )
228+ // Ensure it re-observed!
229+ assertEquals(2 , observeCallCount)
230+
231+ // Cancel scope explicitly to avoid hanging the `runTest` finalizer loop
232+ state.shutdown()
233+ testScheduler.advanceUntilIdle()
234+ }
235+
236+ @Test
237+ fun `DomainState should drop oldest status seamlessly and avoid suspending backpressure when blasted` () = runTest {
238+ val state = DomainState ()
239+ val eventsFlow = MutableSharedFlow <OpenFeatureProviderEvents >()
240+
241+ class SpammyProvider : FeatureProvider by NoOpProvider () {
242+ override fun observe () = eventsFlow
243+ }
244+
245+ state.providersFlow.value = SpammyProvider ()
246+ val testDispatcher = StandardTestDispatcher (testScheduler)
247+ state.initializeListener(testDispatcher)
248+ testScheduler.advanceUntilIdle()
249+
250+ // Mimic a slow processor that purposefully does not collect from statusFlow.
251+ val slowSubscriber = launch(StandardTestDispatcher (testScheduler)) {
252+ state.statusFlow.collect {
253+ delay(10000L ) // Extremely slow
254+ }
255+ }
256+ testScheduler.advanceUntilIdle()
257+
258+ // Blast 50 items simultaneously into eventsFlow -> emitStatus.
259+ // If it was BufferOverflow.SUSPEND this wouldn't finish.
260+ val job = launch(StandardTestDispatcher (testScheduler)) {
261+ for (i in 1 .. 50 ) {
262+ eventsFlow.emit(
263+ if (i % 2 == 0 ) {
264+ OpenFeatureProviderEvents .ProviderReady ()
265+ } else {
266+ OpenFeatureProviderEvents .ProviderStale ()
267+ }
268+ )
269+ }
270+ }
271+ testScheduler.advanceUntilIdle()
272+
273+ // Assert the job actually finished and didn't hang
274+ assertTrue(job.isCompleted)
275+ slowSubscriber.cancel()
276+
277+ // Assert the state correctly processed them
278+ val finalStatus = state.getStatus()
279+ assertTrue(finalStatus is OpenFeatureStatus .Ready || finalStatus is OpenFeatureStatus .Stale )
280+ }
281+
282+ @Test
283+ fun `ProviderRepository clearAll should not deadlock against busy domain state shutdowns` () = runTest {
284+ val repository = ProviderRepository ()
285+ val state = repository.getOrCreateState(" deadlock-domain" )
286+
287+ // Thread A: Holds providerMutex and tries to get repositoryMutex
288+ val threadA = launch(StandardTestDispatcher (testScheduler)) {
289+ state.providerMutex.withLock {
290+ delay(50L ) // Wait to ensure Thread B traps repositoryMutex
291+ repository.getOrCreateState(" new-domain" ) // Wants repositoryMutex
292+ }
293+ }
294+
295+ // Thread B: Runs clearAll
296+ val threadB = launch(StandardTestDispatcher (testScheduler)) {
297+ repository.clearAll() // Holds repositoryMutex initially, then wants providerMutex (via shutdown)
298+ }
299+
300+ testScheduler.advanceUntilIdle()
301+
302+ // If the vulnerability exists, both threads will be permanently blocked!
303+ assertTrue(threadA.isCompleted)
304+ assertTrue(threadB.isCompleted)
305+ }
160306}
0 commit comments