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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
internal object DashboardSessionTokenRefresher {
private val tokenPattern = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""")
private val refreshMutex = Mutex()
// Synchronized so concurrent 401s / reconnect storms don't each fire a
// redundant GET at the dashboard SPA (thundering-herd guard).
@Synchronized
fun refresh(): String? =
try {
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
AuthManager.setToken(token)
token
} catch (_: Exception) {
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,
client: OkHttpClient,
): String? =
@@ -32,32 +70,31 @@ internal object DashboardSessionTokenRefresher {
.url(baseUrl)
.get()
.build()
// execute() + body read on Dispatchers.IO: the body read touches
// the socket on the CALLING thread otherwise — when that is main
// (loopback ticket mint from the kanban events connect), it throws
// NetworkOnMainThreadException (same bug class as requestWsTicket).
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() }
}
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
extractToken(response)
}
} catch (_: Exception) {
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
}
@@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.await
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
@@ -547,7 +548,7 @@ object HermesWsClient {
* or because ticket refresh succeeded). Returns false if we are in gated mode
* and ticket refresh failed or cannot be performed.
*/
internal fun refreshWsTicketIfNeeded(generation: Int): Boolean {
internal suspend fun refreshWsTicketIfNeeded(generation: Int): Boolean {
val isGated =
try {
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
@@ -563,11 +564,9 @@ object HermesWsClient {
// restart. Refresh it before each WebSocket handshake so automatic
// reconnect does not get stuck in AUTH_EXPIRED with a stale token.
val token =
synchronized(DashboardSessionTokenRefresher) {
runCatching {
DashboardSessionTokenRefresher.fetch(AuthManager.baseUrl(), OkHttpProvider.probe)
}.getOrNull()
}
runCatching {
DashboardSessionTokenRefresher.refreshAsync()
}.getOrNull()
synchronized(outboundLock) {
if (isCurrentGeneration(generation) && token != null) {
runCatching { AuthManager.setToken(token) }
@@ -610,7 +609,7 @@ object HermesWsClient {
* ticket. Loopback mode: refresh the dashboard token and return it.
* Returns null when a ticket could not be obtained.
*/
internal fun mintWsTicket(): String? {
internal suspend fun mintWsTicket(): String? {
val isGated =
try {
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
@@ -618,7 +617,7 @@ object HermesWsClient {
false
}
if (!isGated) {
DashboardSessionTokenRefresher.refresh()
DashboardSessionTokenRefresher.refreshAsync()
return AuthManager.getToken()
}
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. */
private fun requestWsTicket(): TicketRequestResult {
private suspend fun requestWsTicket(): TicketRequestResult {
try {
val client = OkHttpProvider.probe
val request =
@@ -641,16 +640,12 @@ object HermesWsClient {
.post("{}".toRequestBody())
.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) =
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
withContext(Dispatchers.IO) {
if (CookieManager.isInitialized()) {
CookieManager.useStore(CookieManager.cookieJar.currentServer())
}
client.newCall(request).execute().use { resp ->
client.newCall(request).await().use { resp ->
resp.code to resp.body.string()
}
}
@@ -1184,22 +1179,24 @@ object HermesWsClient {
if (intentionalClose.get()) return
connectionGeneration.incrementAndGet()
}
if (!refreshWsTicketIfNeeded(generation)) {
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
return
}
if (!isCurrentGeneration(generation)) return
val url = AuthManager.wsUrl()
val safeUrl = url.replace(Regex("token=[^&]+"), "token=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
wsScope.launch(Dispatchers.IO) {
if (!refreshWsTicketIfNeeded(generation)) {
Log.w(TAG, "Aborting openSocket: WS ticket refresh failed")
return@launch
}
if (!isCurrentGeneration(generation)) return@launch
val url = AuthManager.wsUrl()
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 newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
synchronized(outboundLock) {
if (connectionGeneration.get() == generation && !intentionalClose.get()) {
webSocket = newSocket
} else {
newSocket.cancel()
val request = Request.Builder().url(url).build()
val newSocket = OkHttpProvider.websocket.newWebSocket(request, WsListenerImpl(generation))
synchronized(outboundLock) {
if (connectionGeneration.get() == generation && !intentionalClose.get()) {
webSocket = newSocket
} else {
newSocket.cancel()
}
}
}
}
@@ -5,6 +5,7 @@ import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -163,86 +164,89 @@ class KanbanEventsClient {
) {
if (closed || gen != generation) return
val credential = HermesWsClient.mintWsTicket()
if (credential.isNullOrBlank()) {
onStatus(KanbanLiveStatus.AUTH_FAILED)
return
}
scope.launch(Dispatchers.IO) {
val credential = HermesWsClient.mintWsTicket()
if (closed || gen != generation) return@launch
if (credential.isNullOrBlank()) {
scope.launch { onStatus(KanbanLiveStatus.AUTH_FAILED) }
return@launch
}
val rawAuthParam = AuthManager.serverStore.getLatestState().wsAuthParam
val authParam = if (rawAuthParam.isBlank()) "token" else rawAuthParam
val url =
AuthManager.endpointForBuild().webSocketUrl(
path = KANBAN_EVENTS_PATH,
authParameter = authParam,
credential = credential,
extraParams = mapOf("since" to cursor.toString(), "board" to board),
)
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val rawAuthParam = AuthManager.serverStore.getLatestState().wsAuthParam
val authParam = if (rawAuthParam.isBlank()) "token" else rawAuthParam
val url =
AuthManager.endpointForBuild().webSocketUrl(
path = KANBAN_EVENTS_PATH,
authParameter = authParam,
credential = credential,
extraParams = mapOf("since" to cursor.toString(), "board" to board),
)
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val request = Request.Builder().url(url).build()
webSocket =
httpClient.newWebSocket(
request,
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
if (closed || gen != generation) return
backoffMs = INITIAL_BACKOFF_MS
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
val request = Request.Builder().url(url).build()
webSocket =
httpClient.newWebSocket(
request,
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
if (closed || gen != generation) return
backoffMs = INITIAL_BACKOFF_MS
scope.launch { onStatus(KanbanLiveStatus.CONNECTED) }
}
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)
}
},
)
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(
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(