@@ -19,13 +19,15 @@ import android.content.Context.CONNECTIVITY_SERVICE
1919import android.net.ConnectivityManager
2020import androidx.test.ext.junit.runners.AndroidJUnit4
2121import app.cash.turbine.ReceiveTurbine
22+ import app.cash.turbine.TurbineContext
2223import app.cash.turbine.test
2324import app.cash.turbine.turbineScope
2425import com.google.firebase.appcheck.interop.InteropAppCheckTokenProvider
2526import com.google.firebase.auth.internal.InternalAuthProvider
2627import com.google.firebase.dataconnect.DataConnectSettings
2728import com.google.firebase.dataconnect.FirebaseDataConnect.CallerSdkType
2829import com.google.firebase.dataconnect.QueryRef
30+ import com.google.firebase.dataconnect.QuerySubscriptionResult
2931import com.google.firebase.dataconnect.core.DataConnectAuth.GetAuthTokenResult
3032import com.google.firebase.dataconnect.core.DataConnectBidiConnectStream.Companion.setReconnectPendingAuthTokenForTesting
3133import com.google.firebase.dataconnect.core.DataConnectBidiConnectStream.Companion.unsetReconnectPendingAuthTokenForTesting
@@ -91,9 +93,11 @@ import io.kotest.assertions.print.print
9193import io.kotest.assertions.withClue
9294import io.kotest.common.DelicateKotest
9395import io.kotest.common.ExperimentalKotest
96+ import io.kotest.matchers.booleans.shouldBeTrue
9497import io.kotest.matchers.collections.shouldBeIn
9598import io.kotest.matchers.collections.shouldContainExactly
9699import io.kotest.matchers.maps.shouldBeEmpty
100+ import io.kotest.matchers.nulls.shouldBeNull
97101import io.kotest.matchers.result.shouldBeSuccess
98102import io.kotest.matchers.shouldBe
99103import io.kotest.matchers.shouldNotBe
@@ -111,6 +115,7 @@ import io.kotest.property.arbitrary.az
111115import io.kotest.property.arbitrary.distinct
112116import io.kotest.property.arbitrary.enum
113117import io.kotest.property.arbitrary.int
118+ import io.kotest.property.arbitrary.long
114119import io.kotest.property.arbitrary.map
115120import io.kotest.property.arbitrary.next
116121import io.kotest.property.arbitrary.of
@@ -2036,6 +2041,133 @@ class QuerySubscriptionImplUnitTest {
20362041 }
20372042 }
20382043
2044+ @Test
2045+ fun `connection is kept alive for exactly the grace period after last subscriber unsubscribes` () =
2046+ testConnectionGracePeriod(Arb .long(0L until CONNECTION_GRACE_PERIOD_MS )) { context ->
2047+ context.clientCollector1.cancelAndIgnoreRemainingEvents()
2048+
2049+ // Wait a random duration less than CONNECTION_GRACE_PERIOD_MS
2050+ delay(context.delayMillis.milliseconds)
2051+
2052+ // Verify that the connection is still open (no close event received on serverCollector).
2053+ // Based on the timing, we _may_ receive the "cancel" event, which is expected.
2054+ val event = context.serverCollector.asChannel().tryReceive().getOrNull()
2055+ if (event != null ) {
2056+ val streamRequest = event.shouldBeInstanceOf<StreamRequestReceived >().streamRequest
2057+ streamRequest.hasCancel().shouldBeTrue()
2058+ context.serverCollector.asChannel().tryReceive().getOrNull().shouldBeNull()
2059+ }
2060+
2061+ // Measure the remaining time until the client closes the connection
2062+ val time1 = @OptIn(ExperimentalCoroutinesApi ::class ) context.testScheduler.currentTime
2063+ context.serverCollector.awaitUntilClientClosesConnection()
2064+ val time2 = @OptIn(ExperimentalCoroutinesApi ::class ) context.testScheduler.currentTime
2065+
2066+ (time2 - time1) shouldBe (CONNECTION_GRACE_PERIOD_MS - context.delayMillis)
2067+ }
2068+
2069+ @Test
2070+ fun `re-subscribing within grace period keeps the connection alive and reuses it` () =
2071+ testConnectionGracePeriod(Arb .long(100L until CONNECTION_GRACE_PERIOD_MS )) { context ->
2072+ context.clientCollector1.cancelAndIgnoreRemainingEvents()
2073+
2074+ // Wait a random duration less than CONNECTION_GRACE_PERIOD_MS
2075+ delay(context.delayMillis.milliseconds)
2076+
2077+ // Re-subscribe clientCollector2
2078+ val clientCollector2 =
2079+ context.subscription.flow.testIn(context.backgroundScope, name = " clientCollector2" )
2080+
2081+ // Verify that the server receives a subscribe request for the connection, but the connection
2082+ // ID remains the same (proving reuse)
2083+ val subscribeRequest = context.serverCollector.awaitUntilSubscribeStreamRequest()
2084+ subscribeRequest.connectionId shouldBe context.initialConnectionId
2085+
2086+ clientCollector2.cancelAndIgnoreRemainingEvents()
2087+ // Wait for the new grace period to expire so we don't leak connection closure errors/events
2088+ context.serverCollector.awaitUntilClientClosesConnection()
2089+ }
2090+
2091+ @Test
2092+ fun `re-subscribing after grace period establishes a new connection` () =
2093+ testConnectionGracePeriod(
2094+ Arb .long(CONNECTION_GRACE_PERIOD_MS .. (CONNECTION_GRACE_PERIOD_MS * 4 ))
2095+ ) { context ->
2096+ context.clientCollector1.cancelAndIgnoreRemainingEvents()
2097+
2098+ // Wait a random duration of at least CONNECTION_GRACE_PERIOD_MS
2099+ delay(context.delayMillis.milliseconds)
2100+
2101+ // Verify that the connection is closed
2102+ context.serverCollector.awaitUntilClientClosesConnection()
2103+
2104+ // Re-subscribe clientCollector2
2105+ val clientCollector2 =
2106+ context.subscription.flow.testIn(context.backgroundScope, name = " clientCollector2" )
2107+
2108+ // Verify that a new connection is established
2109+ val connection2 = context.serverCollector.awaitConnectRpcStarted()
2110+ connection2.connectionId shouldNotBe context.initialConnectionId
2111+ context.serverCollector.awaitUntilInitStreamRequest()
2112+ context.serverCollector.awaitUntilSubscribeStreamRequest()
2113+
2114+ clientCollector2.cancelAndIgnoreRemainingEvents()
2115+ // Wait for connection to close to keep clean state
2116+ context.serverCollector.awaitUntilClientClosesConnection()
2117+ }
2118+
2119+ @OptIn(ExperimentalCoroutinesApi ::class )
2120+ private fun testConnectionGracePeriod (
2121+ delayMillisArb : Arb <Long >,
2122+ block : suspend TurbineContext .(TestConnectionGracePeriodContext ) -> Unit
2123+ ) = runTest {
2124+ val server = runningInProcessDataConnectServer()
2125+ checkAll(propTestConfig, delayMillisArb, Arb .dataConnect.operationName(), testVariablesArb()) {
2126+ delayMillis,
2127+ operationName,
2128+ variables ->
2129+ runWithDataConnect(server) { dataConnect ->
2130+ val subscription = querySubscription(dataConnect, operationName, variables)
2131+
2132+ turbineScope {
2133+ val serverCollector = server.events.testIn(backgroundScope, name = " serverCollector" )
2134+ val clientCollector1 =
2135+ subscription.flow.testIn(backgroundScope, name = " clientCollector1" )
2136+
2137+ // Wait for initial connection and subscribe
2138+ val connection = serverCollector.awaitConnectRpcStarted()
2139+ serverCollector.awaitUntilInitStreamRequest()
2140+ serverCollector.awaitUntilSubscribeStreamRequest()
2141+
2142+ val context =
2143+ TestConnectionGracePeriodContext (
2144+ delayMillis = delayMillis,
2145+ serverCollector = serverCollector,
2146+ clientCollector1 = clientCollector1,
2147+ initialConnectionId = connection.connectionId,
2148+ subscription = subscription,
2149+ backgroundScope = backgroundScope,
2150+ testScheduler = testScheduler,
2151+ )
2152+
2153+ block(context)
2154+
2155+ serverCollector.cancelAndIgnoreRemainingEvents()
2156+ }
2157+ }
2158+ }
2159+ }
2160+
2161+ private data class TestConnectionGracePeriodContext (
2162+ val delayMillis : Long ,
2163+ val serverCollector : ReceiveTurbine <InProcessDataConnectGrpcStreamingServer .Event >,
2164+ val clientCollector1 : ReceiveTurbine <QuerySubscriptionResult <TestData , TestVariables >>,
2165+ val initialConnectionId : InProcessDataConnectGrpcStreamingServer .ConnectionId ,
2166+ val subscription : QuerySubscriptionImpl <TestData , TestVariables >,
2167+ val backgroundScope : kotlinx.coroutines.CoroutineScope ,
2168+ val testScheduler : TestCoroutineScheduler ,
2169+ )
2170+
20392171 private fun runningInProcessDataConnectServer (): InProcessDataConnectGrpcStreamingServer {
20402172 val server = InProcessDataConnectGrpcStreamingServer ()
20412173 cleanups.register(server)
@@ -2273,3 +2405,14 @@ private class SequenceRandom(jitters: Sequence<Double>) : Random() {
22732405 return lock.withLock { iterator.next() + 0.5 }
22742406 }
22752407}
2408+
2409+ /* *
2410+ * The amount of time, in milliseconds, that [DataConnectBidiConnectStream] keeps the physical
2411+ * connection with the backend alive after the last subscriber unsubscribes. By keeping the
2412+ * connection alive for a short amount of time rather than closing it immediately it improves the
2413+ * latency and reduces the backend load if a new subscriber were to subscribe within this grace
2414+ * period. This rapid unsubscription and resubscription could happen, for example, between activity
2415+ * or fragment transitions in an application where the old activity/fragment unsubscribes in its
2416+ * onDestory() and the new activity/fragment subscribes in its onCreate().
2417+ */
2418+ const val CONNECTION_GRACE_PERIOD_MS = 15_000L
0 commit comments