Skip to content
Open
Show file tree
Hide file tree
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
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ internal constructor(
*
* @see [Plugin]
*/
@Volatile
@InternalStreamChatApi
public var plugins: List<Plugin> = emptyList()

Expand Down Expand Up @@ -396,12 +397,16 @@ internal constructor(
@Suppress("ThrowsCount")
internal inline fun <reified P : DependencyResolver, reified T : Any> resolvePluginDependency(): T {
StreamLog.v(TAG) { "[resolvePluginDependency] P: ${P::class.simpleName}, T: ${T::class.simpleName}" }
// Snapshot plugins BEFORE checking initializationState to avoid a race with disconnect().
// disconnect() sets initializationState to NOT_INITIALIZED before clearing plugins,
// so if we snapshot plugins first and then see COMPLETE, the snapshot is guaranteed valid.
val currentPlugins = plugins
val initState = awaitInitializationState(RESOLVE_DEPENDENCY_TIMEOUT)
if (initState != InitializationState.COMPLETE) {
StreamLog.e(TAG) { "[resolvePluginDependency] failed (initializationState is not COMPLETE): $initState " }
throw IllegalStateException("ChatClient::connectUser() must be called before resolving any dependency")
}
val resolver = plugins.find { plugin ->
val resolver = currentPlugins.find { plugin ->
Copy link
Contributor

@VelikovPetar VelikovPetar Mar 20, 2026

Choose a reason for hiding this comment

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

Just a quick question: Do we have some potential implications if we reach this point in the code - but disconnect() was already called? (fully understand that this is an extreme edge case, but I am just thinking of possible side effects)

Also, is it possible that currentPlugins is resolved as emptyList() before the awaitInitializationState()?

plugin is P
} ?: throw IllegalStateException(
"Plugin '${P::class.qualifiedName}' was not found. Did you init it within ChatClient?",
Expand Down Expand Up @@ -1566,9 +1571,9 @@ internal constructor(

notifications.onLogout()
// Set initializationState to NOT_INITIALIZED BEFORE clearing plugins to prevent race condition.
// This ensures the StatePlugin extension methods don't access the plugin during disconnect.
// resolvePluginDependency() snapshots plugins before checking state, so if it sees COMPLETE
// here, the snapshot is guaranteed to still contain the plugins.
mutableClientState.setInitializationState(InitializationState.NOT_INITIALIZED)

plugins.forEach { it.onUserDisconnected() }
plugins = emptyList()
userStateService.onLogout()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import io.getstream.chat.android.models.NoOpMessageTransformer
import io.getstream.chat.android.models.NoOpUserTransformer
import io.getstream.chat.android.models.User
import io.getstream.chat.android.test.TestCoroutineExtension
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.TestResult
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.invoking
Expand All @@ -43,6 +46,7 @@ import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.reflect.KClass

public class DependencyResolverTest {
Expand Down Expand Up @@ -128,6 +132,22 @@ public class DependencyResolverTest {
fResult `should be` expectedDependency
}

@Test
public fun `Should resolve dependency when plugins are cleared during resolution`(): TestResult = runTest {
val expectedDependency = SomeDependency()
val fixture = Fixture()
.with(PluginDependency(mapOf(SomeDependency::class to expectedDependency)))

val client = fixture.get()

val racingFlow = DisconnectSimulatingStateFlow(client)
whenever(fixture.mutableClientState.initializationState).thenReturn(racingFlow)

val result = client.resolveDependency<PluginDependency, SomeDependency>()

result `should be` expectedDependency
}

public companion object {

@JvmField
Expand Down Expand Up @@ -217,4 +237,28 @@ public class DependencyResolverTest {
}

private class SomeDependency

private class DisconnectSimulatingStateFlow(
private val client: ChatClient,
) : StateFlow<InitializationState> {

private val disconnected = AtomicBoolean(false)

override val value: InitializationState
get() {
if (disconnected.compareAndSet(false, true)) {
client.plugins = emptyList()
return InitializationState.COMPLETE
}
return InitializationState.NOT_INITIALIZED
}

override val replayCache: List<InitializationState>
get() = listOf(InitializationState.COMPLETE)

override suspend fun collect(collector: FlowCollector<InitializationState>): Nothing {
collector.emit(InitializationState.COMPLETE)
awaitCancellation()
}
}
}
Loading