perf(#1170): remove runBlocking network IO from token refresh and WS setup (#1185)

This commit is contained in:
M57 authored and GitHub committed 2026-09-17 22:00:25 +03:00
1 parent c2cb08a563
commit 7951acc6eb
5 files changed
+258 -170

No files matched your search

@@ -2,26 +2,64 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response
import okio.Buffer import okio.Buffer
internal object DashboardSessionTokenRefresher { internal object DashboardSessionTokenRefresher {
private val tokenPattern = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""") private val tokenPattern = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""")
private val refreshMutex = Mutex()
// Synchronized so concurrent 401s / reconnect storms don't each fire a // Synchronized so concurrent 401s / reconnect storms don't each fire a
// redundant GET at the dashboard SPA (thundering-herd guard). // redundant GET at the dashboard SPA (thundering-herd guard).
@Synchronized @Synchronized
fun refresh(): String? = fun refresh(): String? =
try { try {
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
AuthManager.setToken(token) AuthManager.setToken(token)
token token
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
internal fun fetch( suspend fun refreshAsync(): String? =
refreshMutex.withLock {
try {
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null
AuthManager.setToken(token)
token
} catch (_: Exception) {
null
}
}
internal suspend fun fetch(
baseUrl: String,
client: OkHttpClient,
): String? =
withContext(Dispatchers.IO) {
try {
val request =
Request
.Builder()
.url(baseUrl)
.get()
.build()
client.newCall(request).await().use { response ->
if (!response.isSuccessful) return@withContext null
extractToken(response)
}
} catch (_: Exception) {
null
}
}
internal fun fetchSync(
baseUrl: String, baseUrl: String,
client: OkHttpClient, client: OkHttpClient,
): String? = ): String? =
@@ -32,32 +70,31 @@ internal object DashboardSessionTokenRefresher {
.url(baseUrl) .url(baseUrl)
.get() .get()
.build() .build()
// execute() + body read on Dispatchers.IO: the body read touches
// the socket on the CALLING thread otherwise — when that is main client.newCall(request).execute().use { response ->
// (loopback ticket mint from the kanban events connect), it throws if (!response.isSuccessful) return null
// NetworkOnMainThreadException (same bug class as requestWsTicket). extractToken(response)
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@runBlocking null
// Cap the read — the SPA HTML can be large, and we only need the
// injected token near the top of the document. Reading the whole
// body into a String risks OOM on low-end devices.
val html =
response.body.source().use { source ->
val buffer = Buffer()
source.read(buffer, MAX_BODY_BYTES)
buffer.readUtf8()
}
tokenPattern
.find(html)
?.groupValues
?.getOrNull(1)
?.takeIf { it.isNotBlank() }
}
} }
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
private fun extractToken(response: Response): String? {
// Cap the read — the SPA HTML can be large, and we only need the
// injected token near the top of the document. Reading the whole
// body into a String risks OOM on low-end devices.
val html =
response.body.source().use { source ->
val buffer = Buffer()
source.read(buffer, MAX_BODY_BYTES)
buffer.readUtf8()
}
return tokenPattern
.find(html)
?.groupValues
?.getOrNull(1)
?.takeIf { it.isNotBlank() }
}
private const val MAX_BODY_BYTES: Long = 64 * 1024 private const val MAX_BODY_BYTES: Long = 64 * 1024
} }
@@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.await
import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ActiveSessionHolder
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
@@ -547,7 +548,7 @@ object HermesWsClient {
* or because ticket refresh succeeded). Returns false if we are in gated mode * or because ticket refresh succeeded). Returns false if we are in gated mode
* and ticket refresh failed or cannot be performed. * and ticket refresh failed or cannot be performed.
*/ */
internal fun refreshWsTicketIfNeeded(generation: Int): Boolean { internal suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean {
val isGated = val isGated =
try { try {
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
@@ -563,11 +564,9 @@ object HermesWsClient {
// restart. Refresh it before each WebSocket handshake so automatic // restart. Refresh it before each WebSocket handshake so automatic
// reconnect does not get stuck in AUTH_EXPIRED with a stale token. // reconnect does not get stuck in AUTH_EXPIRED with a stale token.
val token = val token =
synchronized(DashboardSessionTokenRefresher) { runCatching {
runCatching { DashboardSessionTokenRefresher.refreshAsync()
DashboardSessionTokenRefresher.fetch(AuthManager.baseUrl(), OkHttpProvider.probe) }.getOrNull()
}.getOrNull()
}
synchronized(outboundLock) { synchronized(outboundLock) {
if (isCurrentGeneration(generation) && token != null) { if (isCurrentGeneration(generation) && token != null) {
runCatching { AuthManager.setToken(token) } runCatching { AuthManager.setToken(token) }
@@ -610,7 +609,7 @@ object HermesWsClient {
* ticket. Loopback mode: refresh the dashboard token and return it. * ticket. Loopback mode: refresh the dashboard token and return it.
* Returns null when a ticket could not be obtained. * Returns null when a ticket could not be obtained.
*/ */
internal fun mintWsTicket(): String? { internal suspend fun mintWsTicket(): String? {
val isGated = val isGated =
try { try {
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
@@ -618,7 +617,7 @@ object HermesWsClient {
false false
} }
if (!isGated) { if (!isGated) {
DashboardSessionTokenRefresher.refresh() DashboardSessionTokenRefresher.refreshAsync()
return AuthManager.getToken() return AuthManager.getToken()
} }
return requestWsTicket().ticket return requestWsTicket().ticket
@@ -631,7 +630,7 @@ object HermesWsClient {
) )
/** POST /api/auth/ws-ticket (cookie-auth'd via the shared CookieJar) and parse the ticket. */ /** POST /api/auth/ws-ticket (cookie-auth'd via the shared CookieJar) and parse the ticket. */
private fun requestWsTicket(): TicketRequestResult { private suspend fun requestWsTicket(): TicketRequestResult {
try { try {
val client = OkHttpProvider.probe val client = OkHttpProvider.probe
val request = val request =
@@ -641,16 +640,12 @@ object HermesWsClient {
.post("{}".toRequestBody()) .post("{}".toRequestBody())
.build() .build()
// Run the ENTIRE call on Dispatchers.IO — execute() already hops,
// but ResponseBody.string() reads the socket on the CALLING
// thread. When the caller is main (kanban events connect), that
// read throws NetworkOnMainThreadException and the mint fails.
val (code, body) = val (code, body) =
kotlinx.coroutines.runBlocking(Dispatchers.IO) { withContext(Dispatchers.IO) {
if (CookieManager.isInitialized()) { if (CookieManager.isInitialized()) {
CookieManager.useStore(CookieManager.cookieJar.currentServer()) CookieManager.useStore(CookieManager.cookieJar.currentServer())
} }
client.newCall(request).execute().use { resp -> client.newCall(request).await().use { resp ->
resp.code to resp.body.string() resp.code to resp.body.string()
} }
} }
@@ -1184,22 +1179,24 @@ object HermesWsClient {
if (intentionalClose.get()) return if (intentionalClose.get()) return
connectionGeneration.incrementAndGet() connectionGeneration.incrementAndGet()
} }
if (!refreshWsTicketIfNeeded(generation)) { wsScope.launch(Dispatchers.IO) {
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed") if (!refreshWsTicketIfNeeded(generation)) {
return Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
} return@launch
if (!isCurrentGeneration(generation)) return }
val url = AuthManager.wsUrl() if (!isCurrentGeneration(generation)) return@launch
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED") val url = AuthManager.wsUrl()
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl") val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation)) val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
synchronized(outboundLock) { synchronized(outboundLock) {
if (connectionGeneration.get() == generation && !intentionalClose.get()) { if (connectionGeneration.get() == generation && !intentionalClose.get()) {
webSocket = newSocket webSocket = newSocket
} else { } else {
newSocket.cancel() newSocket.cancel()
}
} }
} }
} }
@@ -5,6 +5,7 @@ import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -163,86 +164,89 @@ class KanbanEventsClient {
) { ) {
if (closed || gen != generation) return if (closed || gen != generation) return
val credential = HermesWsClient.mintWsTicket() scope.launch(Dispatchers.IO) {
if (credential.isNullOrBlank()) { val credential = HermesWsClient.mintWsTicket()
onStatus(KanbanLiveStatus.AUTH_FAILED) if (closed || gen != generation) return@launch
return if (credential.isNullOrBlank()) {
} scope.launch { onStatus(KanbanLiveStatus.AUTH_FAILED) }
return@launch
}
val rawAuthParam = AuthManager.serverStore.getLatestState().wsAuthParam val rawAuthParam = AuthManager.serverStore.getLatestState().wsAuthParam
val authParam = if (rawAuthParam.isBlank()) "token" else rawAuthParam val authParam = if (rawAuthParam.isBlank()) "token" else rawAuthParam
val url = val url =
AuthManager.endpointForBuild().webSocketUrl( AuthManager.endpointForBuild().webSocketUrl(
path = KANBAN_EVENTS_PATH, path = KANBAN_EVENTS_PATH,
authParameter = authParam, authParameter = authParam,
credential = credential, credential = credential,
extraParams = mapOf("since" to cursor.toString(), "board" to board), extraParams = mapOf("since" to cursor.toString(), "board" to board),
) )
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED") val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl") if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val request = Request.Builder().url(url).build() val request = Request.Builder().url(url).build()
webSocket = webSocket =
httpClient.newWebSocket( httpClient.newWebSocket(
request, request,
object : WebSocketListener() { object : WebSocketListener() {
override fun onOpen( override fun onOpen(
webSocket: WebSocket, webSocket: WebSocket,
response: Response, response: Response,
) { ) {
if (closed || gen != generation) return if (closed || gen != generation) return
backoffMs = INITIAL_BACKOFF_MS backoffMs = INITIAL_BACKOFF_MS
scope.launch { onStatus(KanbanLiveStatus.CONNECTED) } scope.launch { onStatus(KanbanLiveStatus.CONNECTED) }
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
if (closed || gen != generation) return
val envelope = parseKanbanEventsFrame(text) ?: return
if (envelope.events.isEmpty()) return
envelope.cursor?.let { cursor = it }
scope.launch { onEvents(envelope) }
}
override fun onMessage(
webSocket: WebSocket,
bytes: ByteString,
) = Unit
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
webSocket.close(code, reason)
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
if (closed || gen != generation) return
if (code == WS_1008_POLICY_VIOLATION) {
scope.launch { onStatus(KanbanLiveStatus.AUTH_FAILED) }
return
} }
scheduleReconnect(scope, gen, board, onEvents, onStatus)
}
override fun onFailure( override fun onMessage(
webSocket: WebSocket, webSocket: WebSocket,
t: Throwable, text: String,
response: Response?, ) {
) { if (closed || gen != generation) return
if (closed || gen != generation) return val envelope = parseKanbanEventsFrame(text) ?: return
if (BuildConfig.DEBUG) Log.d(TAG, "Stream failure: ${t.javaClass.simpleName}") if (envelope.events.isEmpty()) return
scheduleReconnect(scope, gen, board, onEvents, onStatus) envelope.cursor?.let { cursor = it }
} scope.launch { onEvents(envelope) }
}, }
)
override fun onMessage(
webSocket: WebSocket,
bytes: ByteString,
) = Unit
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
webSocket.close(code, reason)
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
if (closed || gen != generation) return
if (code == WS_1008_POLICY_VIOLATION) {
scope.launch { onStatus(KanbanLiveStatus.AUTH_FAILED) }
return
}
scheduleReconnect(scope, gen, board, onEvents, onStatus)
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: Response?,
) {
if (closed || gen != generation) return
if (BuildConfig.DEBUG) Log.d(TAG, "Stream failure: ${t.javaClass.simpleName}")
scheduleReconnect(scope, gen, board, onEvents, onStatus)
}
},
)
}
} }
private fun scheduleReconnect( private fun scheduleReconnect(
@@ -5,6 +5,7 @@ import io.mockk.every
import io.mockk.mockkObject import io.mockk.mockkObject
import io.mockk.unmockkAll import io.mockk.unmockkAll
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.MockWebServer
@@ -32,50 +33,68 @@ class DashboardSessionTokenRefresherTest {
} }
@Test @Test
fun fetchExtractsInjectedDashboardToken() { fun fetchExtractsInjectedDashboardToken() =
runBlocking {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""<script>window.__HERMES_SESSION_TOKEN__ = "new-token";</script>"""),
)
val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient())
assertEquals("new-token", token)
assertEquals("/", server.takeRequest().path)
}
@Test
fun fetchReturnsNullWhenDashboardDoesNotInjectToken() =
runBlocking {
server.enqueue(MockResponse().setResponseCode(200).setBody("<html></html>"))
val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient())
assertNull(token)
}
@Test
fun fetchReturnsNullForFailedResponse() =
runBlocking {
server.enqueue(MockResponse().setResponseCode(500))
val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient())
assertNull(token)
}
@Test
fun fetchReturnsNullWhenTokenIsBlank() =
runBlocking {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""<script>window.__HERMES_SESSION_TOKEN__ = "";</script>"""),
)
val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient())
assertNull(token)
}
@Test
fun fetchSyncExtractsInjectedDashboardToken() {
server.enqueue( server.enqueue(
MockResponse() MockResponse()
.setResponseCode(200) .setResponseCode(200)
.setBody("""<script>window.__HERMES_SESSION_TOKEN__ = "new-token";</script>"""), .setBody("""<script>window.__HERMES_SESSION_TOKEN__ = "sync-token";</script>"""),
) )
val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient()) val token = DashboardSessionTokenRefresher.fetchSync(server.url("/").toString(), OkHttpClient())
assertEquals("new-token", token) assertEquals("sync-token", token)
assertEquals("/", server.takeRequest().path) assertEquals("/", server.takeRequest().path)
} }
@Test
fun fetchReturnsNullWhenDashboardDoesNotInjectToken() {
server.enqueue(MockResponse().setResponseCode(200).setBody("<html></html>"))
val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient())
assertNull(token)
}
@Test
fun fetchReturnsNullForFailedResponse() {
server.enqueue(MockResponse().setResponseCode(500))
val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient())
assertNull(token)
}
@Test
fun fetchReturnsNullWhenTokenIsBlank() {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""<script>window.__HERMES_SESSION_TOKEN__ = "";</script>"""),
)
val token = DashboardSessionTokenRefresher.fetch(server.url("/").toString(), OkHttpClient())
assertNull(token)
}
@Test @Test
fun refreshUpdatesAuthManagerOnSuccess() { fun refreshUpdatesAuthManagerOnSuccess() {
mockkObject(AuthManager) mockkObject(AuthManager)
@@ -94,6 +113,25 @@ class DashboardSessionTokenRefresherTest {
verify { AuthManager.setToken("mocked-token") } verify { AuthManager.setToken("mocked-token") }
} }
@Test
fun refreshAsyncUpdatesAuthManagerOnSuccess() =
runBlocking {
mockkObject(AuthManager)
every { AuthManager.baseUrl() } returns server.url("/").toString()
every { AuthManager.setToken(any()) } returns Unit
server.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""<script>window.__HERMES_SESSION_TOKEN__ = "async-token";</script>"""),
)
val token = DashboardSessionTokenRefresher.refreshAsync()
assertEquals("async-token", token)
verify { AuthManager.setToken("async-token") }
}
@Test @Test
fun refreshReturnsNullOnException() { fun refreshReturnsNullOnException() {
mockkObject(AuthManager) mockkObject(AuthManager)
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.ServerEndpoint import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import com.m57.hermescontrol.data.session.ActiveSessionHolder import com.m57.hermescontrol.data.session.ActiveSessionHolder
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkObject import io.mockk.mockkObject
@@ -84,6 +85,10 @@ class HermesWsClientTest {
// the WS stack can build its OkHttp clients without app context. // the WS stack can build its OkHttp clients without app context.
CookieManager.setJarForTest(buildFakePersistentCookieJar()) CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
// Reset state // Reset state
val connectedField = HermesWsClient::class.java.getDeclaredField("connected") val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true connectedField.isAccessible = true
@@ -850,12 +855,11 @@ class HermesWsClientTest {
@Test @Test
fun testDisconnectThenConnectSupersedesBlockedSocketOpen() = fun testDisconnectThenConnectSupersedesBlockedSocketOpen() =
runBlocking { runBlocking {
mockkObject(DashboardSessionTokenRefresher)
every { AuthManager.baseUrl() } returns mockWebServer.url("/").toString() every { AuthManager.baseUrl() } returns mockWebServer.url("/").toString()
val refreshStarted = CountDownLatch(1) val refreshStarted = CountDownLatch(1)
val releaseRefresh = CountDownLatch(1) val releaseRefresh = CountDownLatch(1)
val refreshCalls = AtomicInteger(0) val refreshCalls = AtomicInteger(0)
every { DashboardSessionTokenRefresher.fetch(any(), any()) } answers { coEvery { DashboardSessionTokenRefresher.refreshAsync() } answers {
if (refreshCalls.getAndIncrement() == 0) { if (refreshCalls.getAndIncrement() == 0) {
refreshStarted.countDown() refreshStarted.countDown()
releaseRefresh.await(5, TimeUnit.SECONDS) releaseRefresh.await(5, TimeUnit.SECONDS)
@@ -1181,10 +1185,8 @@ class HermesWsClientTest {
// 5-6s latch windows routinely overshoot under parallel load). // 5-6s latch windows routinely overshoot under parallel load).
HermesWsClient.setReconnectBackoffForTest(0L) HermesWsClient.setReconnectBackoffForTest(0L)
// The reconnect path refreshes the WS token over the network before // The reconnect path refreshes the WS token over the network before
// opening the socket. Stub it so no real HTTP call sits inside the // opening the socket. Stubbed in setUp() so no real HTTP call sits inside the
// test's timing window (CI network latency was the dominant flake). // test's timing window (CI network latency was the dominant flake).
mockkObject(DashboardSessionTokenRefresher)
every { DashboardSessionTokenRefresher.fetch(any(), any()) } returns null
var serverSocket1: WebSocket? = null var serverSocket1: WebSocket? = null
var serverSocket2: WebSocket? = null var serverSocket2: WebSocket? = null
@@ -1523,6 +1525,11 @@ class HermesWsClientTest {
HermesWsClient.connect() HermesWsClient.connect()
runBlocking {
withTimeout(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
}
}
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value) assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
ticketServer.shutdown() ticketServer.shutdown()
} }
@@ -1549,6 +1556,11 @@ class HermesWsClientTest {
HermesWsClient.connect() HermesWsClient.connect()
runBlocking {
withTimeout(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.RECONNECTING }
}
}
assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value) assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value)
} }