perf: optimize synchronous I/O in AuthLoginViewModel (#699)

* perf: optimize synchronous I/O in AuthLoginViewModel

Replace blocking OkHttp .execute() with a suspending .await() extension
(CallExt.kt) backed by suspendCancellableCoroutine, freeing Dispatchers.IO
threads during network waits. Probe/connect paths made suspend.

- Call.await() guards both onResponse/onFailure with continuation.isActive
  to avoid AlreadyResume on cancellation.
- Removes non-asserting AuthLoginViewModelBenchmark timing dump.

* perf: optimize synchronous I/O in AuthLoginViewModel

Replaced the blocking `.execute()` OkHttp calls with a suspending `.await()` extension function inside `AuthLoginViewModel`. This frees up the `Dispatchers.IO` threads by deferring the blocking operations to OkHttp's internal task runner, providing a safer async approach and preventing Thread pool starvation on slow network responses.

Modified `probeDashboardInternal` and `connectBasicAuth` to be proper `suspend` functions and resolved corresponding unit tests.

* fix(test): add missing import io.mockk.any in AuthLoginViewModelTest

* fix(test): fix mockk imports and Log matchers in AuthLoginViewModelTest

* fix(test): use persistent mockProbeClient in AuthLoginViewModelTest

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-23 22:23:53 +03:00
1 parent 152b7d5c0d
commit 8c13f9bed0
4 files changed
+116 -13

No files matched your search

@@ -0,0 +1,37 @@
package com.m57.hermescontrol.data.remote
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Response
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
suspend fun Call.await(): Response =
suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
cancel()
}
enqueue(
object : Callback {
override fun onFailure(
call: Call,
e: IOException,
) {
if (continuation.isActive) {
continuation.resumeWithException(e)
}
}
override fun onResponse(
call: Call,
response: Response,
) {
if (continuation.isActive) {
continuation.resume(response)
}
}
},
)
}
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.remote.AuthPayloads
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.await
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.Dispatchers
@@ -213,7 +214,7 @@ class AuthLoginViewModel(
* is non-null, the session token was found embedded in the dashboard SPA HTML
* (loopback mode only) and can be auto-populated.
*/
private fun probeDashboardInternal(endpoint: ServerEndpoint): ProbeResult? {
private suspend fun probeDashboardInternal(endpoint: ServerEndpoint): ProbeResult? {
// Step 1: reachability + auth mode from the public status endpoint.
val statusJson =
try {
@@ -223,7 +224,7 @@ class AuthLoginViewModel(
.url(endpoint.resolve("api/status").toString())
.get()
.build()
val resp = probeClient.newCall(req).execute()
val resp = probeClient.newCall(req).await()
if (!resp.isSuccessful) return null
resp.body.string()
} catch (e: Exception) {
@@ -256,7 +257,7 @@ class AuthLoginViewModel(
.url(endpoint.resolve("").toString())
.get()
.build()
val spaResp = probeClient.newCall(spaReq).execute()
val spaResp = probeClient.newCall(spaReq).await()
val body = spaResp.body.string()
val tokenMatch = Regex("""__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"""").find(body)
extractedToken = tokenMatch?.groupValues?.getOrNull(1)
@@ -406,7 +407,7 @@ class AuthLoginViewModel(
* Authenticate with basic auth, then mint a WS ticket.
* Returns the WS ticket and the session cookie for REST auth.
*/
private fun connectBasicAuth(
private suspend fun connectBasicAuth(
endpoint: ServerEndpoint,
username: String,
password: String,
@@ -439,7 +440,7 @@ class AuthLoginViewModel(
.header("Content-Type", "application/json")
.post(jsonBody.toRequestBody())
.build()
val loginResp = OkHttpProvider.probe.newCall(loginReq).execute()
val loginResp = OkHttpProvider.probe.newCall(loginReq).await()
if (!loginResp.isSuccessful) {
val msg =
@@ -462,7 +463,7 @@ class AuthLoginViewModel(
.url(endpoint.resolve("api/auth/ws-ticket").toString())
.post("{}".toRequestBody())
.build()
val ticketResp = OkHttpProvider.probe.newCall(ticketReq).execute()
val ticketResp = OkHttpProvider.probe.newCall(ticketReq).await()
if (!ticketResp.isSuccessful) {
_uiState.update {