From f13c6a4914b686596ca277882f1f1cd1e1577efb Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 3 Jun 2026 09:23:04 +0200 Subject: [PATCH] fix(core): make apiCall wait for guestUserJob to avoid anonymous push device registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guest user setup runs asynchronously: StreamVideoBuilder.build returns immediately while setupGuestUser kicks off a background createGuest call to fetch the JWT. Any authenticated request that fires in that window goes out with stream-auth-type "anonymous" and no Authorization header, so the backend silently registers it against the wrong identity. The customer-visible effect is push device registration succeeding under !anon and incoming-call pushes never reaching the guest user. apiCall now awaits guestUserJob before invoking the request block, with a self-job guard so createGuestUser — which also goes through apiCall — does not await its own enclosing job and deadlock. Adds two regression tests: one for the wait, one for the deadlock guard. AND-1202 --- .../video/android/core/StreamVideoClient.kt | 13 +++ .../android/core/StreamVideoClientTest.kt | 85 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt index b2dad697f8..32075c6dad 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt @@ -133,9 +133,11 @@ import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter @@ -282,6 +284,17 @@ internal class StreamVideoClient internal constructor( internal suspend fun apiCall( apiCall: suspend () -> T, ): Result = safeSuspendingCallWithResult { + // Guest users have an asynchronous setup (createGuest) that fetches their JWT. + // Any authenticated request that fires before that completes goes out without + // an Authorization header and stream-auth-type "anonymous", so the backend + // can't associate it with the guest — push device registration silently lands + // on the wrong identity. Wait here so every API call sees the right auth. + // Skip the wait when this apiCall is itself running inside the guest setup, + // otherwise createGuestUser would await its own enclosing job. Let the + // await throw if setupGuestUser failed — safeSuspendingCallWithResult turns + // it into Result.Failure, which is the right outcome (the caller didn't get + // a valid guest session, so the request can't proceed). + guestUserJob?.takeIf { currentCoroutineContext()[Job] !== it }?.await() try { apiCall() } catch (e: HttpException) { diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt index e44b1053d2..d081013117 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt @@ -35,7 +35,14 @@ import io.mockk.mockk import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import kotlin.test.Test @@ -266,4 +273,82 @@ class StreamVideoClientTest { verify(exactly = 1) { clientState.handleEvent(event) } unmockkAll() } + + // Regression: a guest user's createGuest call runs on a background `guestUserJob`. + // If an authenticated API request (e.g. createDevice) fires before that job completes, + // it leaves the SDK with no Authorization header and stream-auth-type "anonymous", + // so the backend silently associates the request with the wrong identity. + // apiCall must block until the guest setup is done. AND-1202. + @Test + fun `apiCall waits for guestUserJob to complete before invoking the block`() = runTest { + val guestJob = CompletableDeferred() + client::class.java.getDeclaredField("guestUserJob").apply { + isAccessible = true + set(client, guestJob) + } + + var blockRan = false + val apiCallJob = launch { + client.apiCall { + blockRan = true + "ok" + } + } + + runCurrent() + assertFalse(blockRan, "apiCall must not run while guestUserJob is still pending") + + guestJob.complete(Unit) + apiCallJob.join() + assertTrue(blockRan, "apiCall must run once guestUserJob completes") + } + + // The guard inside apiCall must skip the await when apiCall is itself running inside + // the guest setup's coroutine — otherwise createGuestUser, which goes through apiCall, + // would await its own enclosing job and deadlock. + @Test + fun `apiCall does not deadlock when invoked from within guestUserJob`() = runTest { + var blockRan = false + val guestJob: Deferred = async(start = CoroutineStart.LAZY) { + client.apiCall { + blockRan = true + "ok" + } + Unit + } + client::class.java.getDeclaredField("guestUserJob").apply { + isAccessible = true + set(client, guestJob) + } + + guestJob.await() + assertTrue(blockRan, "apiCall inside the guest setup must run without deadlocking") + } + + // If setupGuestUser fails the SDK has no valid guest session, so subsequent API + // calls must NOT proceed under anonymous/empty-token state. The bare await on + // guestUserJob lets the failure propagate; safeSuspendingCallWithResult then + // turns it into Result.Failure rather than silently re-issuing as anonymous. + @Test + fun `apiCall surfaces guestUserJob failure instead of swallowing it`() = runTest { + val failed = CompletableDeferred().apply { + completeExceptionally(IllegalStateException("Failed to create guest user")) + } + client::class.java.getDeclaredField("guestUserJob").apply { + isAccessible = true + set(client, failed) + } + + var blockRan = false + val result = client.apiCall { + blockRan = true + "should-not-run" + } + + assertFalse(blockRan, "apiCall must not invoke the request block when guest setup failed") + assertTrue( + result is io.getstream.result.Result.Failure, + "expected Result.Failure, got $result", + ) + } }