Skip to content

Comments

Fix globalState race condition crash during disconnect#6122

Merged
VelikovPetar merged 2 commits intodevelopfrom
bug/globalstate-disconnect-crash
Feb 3, 2026
Merged

Fix globalState race condition crash during disconnect#6122
VelikovPetar merged 2 commits intodevelopfrom
bug/globalstate-disconnect-crash

Conversation

@VelikovPetar
Copy link
Contributor

@VelikovPetar VelikovPetar commented Feb 2, 2026

🎯 Goal

Fix a race condition that causes IllegalStateException: Plugin 'StatePlugin' was not found crash when globalStateFlow is being collected during user disconnect.

The Problem:

The globalStateFlow extension filters for InitializationState.COMPLETE before accessing globalState:

public val ChatClient.globalStateFlow: Flow<GlobalState>
    get() = clientState.initializationState
        .filter { it == InitializationState.COMPLETE }
        .map { globalState }  // Calls resolveDependency<StatePlugin>()

In disconnectUserSuspend(), the ordering is:

// ChatClient.kt

plugins.forEach { it.onUserDisconnected() }
plugins = emptyList()                    // plugins cleared FIRST
// ... other cleanup ...
mutableClientState.clearState()          // Sets InitializationState to NOT_INTIALIZED

This created a race window where:

  • initializationState == COMPLETE (not yet cleared)
  • plugins == emptyList() (already cleared)

Any globalStateFlow collection could potentially pass the initializationState == COMPLETE check, but try to access an empty plugins field.

Resolves: https://linear.app/stream/issue/AND-1038

🛠 Implementation details

Set initializationState to NOT_INITIALIZED before clearing plugins:

notifications.onLogout()
// Set initializationState to NOT_INITIALIZED BEFORE clearing plugins to prevent race condition.
// This ensures globalStateFlow's filter { it == COMPLETE } won't pass while plugins is being cleared.
mutableClientState.setInitializationState(InitializationState.NOT_INITIALIZED)

plugins.forEach { it.onUserDisconnected() }
plugins = emptyList()

This ensures the flow's .filter { it == COMPLETE } stops emitting before plugins becomes empty.

🎨 UI Changes

No UI changes.

🧪 Testing

This issue was reported by a customer and is not reproducible with the sample apps under normal conditions due to timing dependencies.

To simulate the race condition:

  1. Add a delay in resolvePluginDependency() after the initialization state check:

    internal inline fun <reified P : DependencyResolver, reified T : Any> resolvePluginDependency(): T {
        val initState = awaitInitializationState(RESOLVE_DEPENDENCY_TIMEOUT)
        if (initState != InitializationState.COMPLETE) {
            throw IllegalStateException("...")
        }
        Thread.sleep(500)  // Add this to widen the race window
        val resolver = plugins.find { it is P } ?: throw IllegalStateException("...")
        // ...
    }
  2. Trigger a disconnect shortly after connect completes while globalStateFlow is being collected:

    MainScope().launch {
        delay(350)
        ChatHelper.disconnectUser()
    }
  3. Without the fix: crash with IllegalStateException: Plugin 'StatePlugin' was not found

  4. With the fix: no crash, flow stops emitting gracefully

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a race condition crash that could occur when disconnecting the chat client.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 2, 2026

SDK Size Comparison 📏

SDK Before After Difference Status
stream-chat-android-client 5.26 MB 5.26 MB 0.00 MB 🟢
stream-chat-android-offline 5.48 MB 5.48 MB 0.00 MB 🟢
stream-chat-android-ui-components 10.63 MB 10.63 MB 0.00 MB 🟢
stream-chat-android-compose 12.84 MB 12.84 MB 0.00 MB 🟢

Co-Authored-By: Claude <[email protected]>
@sonarqubecloud
Copy link

sonarqubecloud bot commented Feb 2, 2026

@VelikovPetar VelikovPetar marked this pull request as ready for review February 2, 2026 17:44
@VelikovPetar VelikovPetar requested a review from a team as a code owner February 2, 2026 17:44
@aleksandar-apostolov
Copy link
Contributor

LGTM 👍

One minor question: are there any onUserDisconnected() plugin callbacks that depend on initializationState == COMPLETE? If so, they now see NOT_INITIALIZED. Probably fine since they're shutting down anyway, but wanted to flag it.

@VelikovPetar
Copy link
Contributor Author

LGTM 👍

One minor question: are there any onUserDisconnected() plugin callbacks that depend on initializationState == COMPLETE? If so, they now see NOT_INITIALIZED. Probably fine since they're shutting down anyway, but wanted to flag it.

No, the onUserDisconnected() implementations don't depend on the initializationState at all. They are all empty actually, only the internal ThrottlingPlugin has logic in it, but not one that depends on `initializationState

@VelikovPetar VelikovPetar merged commit 7cbcfcb into develop Feb 3, 2026
18 of 19 checks passed
@VelikovPetar VelikovPetar deleted the bug/globalstate-disconnect-crash branch February 3, 2026 10:48
@aleksandar-apostolov aleksandar-apostolov added the pr:bug Bug fix label Feb 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants