perf: keep Local Blossom resolver/probe off the main thread

BlossomServerResolver.findServers and LocalBlossomCacheProbe.probe are
suspend functions that don't confine to Dispatchers.IO. They're reached
from Compose produceState/LaunchedEffect (RichTextViewer,
MarmotGroupIconDisplay, ZoomableContentView), which run on the main
dispatcher, so the pre-suspension work — BlossomUri regex parsing,
LruCache lookups, the probe's OkHttpClient build on TTL expiry, and the
server-list flow setup — executed on the UI thread on every uncached
resolution during feed scroll.

Wrap findServersInner and the probe's client-build + HEAD in
withContext(Dispatchers.IO) so that work stays off the main thread.
Behaviour is unchanged; callers already on IO (Coil BlossomFetcher,
PlaybackService) are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PCgrM83QetNPJTg3Z5VWnF
This commit is contained in:
Claude
2026-07-29 19:19:00 +00:00
parent 07267e254b
commit 202163f4b9
2 changed files with 44 additions and 25 deletions
@@ -29,11 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import com.vitorpamplona.quartz.utils.firstNotNullOrNullAsync
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
@@ -57,9 +60,16 @@ class BlossomServerResolver(
suspend fun findServers(uriStr: String): BlossomUriServer? {
uriToUrlCache[uriStr]?.let { return it }
// Confined to Dispatchers.IO: this is reached from Compose
// `produceState`/`LaunchedEffect` (RichTextViewer, MarmotGroupIconDisplay),
// which run on the main dispatcher. The pre-suspension work here —
// BlossomUri parsing, LruCache lookups, the local-cache probe's client
// build, and the server-list flow setup — must stay off the UI thread.
val result =
withTimeoutOrNull(10000) {
findServersInner(uriStr)
withContext(Dispatchers.IO) {
withTimeoutOrNull(10000) {
findServersInner(uriStr)
}
}
if (result != null) {
@@ -22,10 +22,13 @@ package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.util.concurrent.TimeUnit
@@ -77,33 +80,39 @@ class LocalBlossomCacheProbe(
cachedAtMs = 0L
}
// Confined to Dispatchers.IO because callers reach this through suspend
// resolvers invoked from Compose `LaunchedEffect`/`produceState`, which run
// on the main dispatcher: building the OkHttp client and issuing the HEAD
// must not touch the UI thread.
private suspend fun probe(): Boolean =
try {
val baseClient = httpClientBuilder.okHttpClientForPreview(LOCAL_CACHE_BASE)
val client =
baseClient
.newBuilder()
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
withContext(Dispatchers.IO) {
try {
val baseClient = httpClientBuilder.okHttpClientForPreview(LOCAL_CACHE_BASE)
val client =
baseClient
.newBuilder()
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
val request =
Request
.Builder()
.url("$LOCAL_CACHE_BASE/")
.head()
.build()
val request =
Request
.Builder()
.url("$LOCAL_CACHE_BASE/")
.head()
.build()
client.newCall(request).executeAsync().use { response ->
// Spec says HEAD / returns 2xx when available. Some implementations
// may answer 405 (method not allowed) while still being a working
// Blossom cache, so treat that as available too.
response.isSuccessful || response.code == 405
client.newCall(request).executeAsync().use { response ->
// Spec says HEAD / returns 2xx when available. Some implementations
// may answer 405 (method not allowed) while still being a working
// Blossom cache, so treat that as available too.
response.isSuccessful || response.code == 405
}
} catch (e: Exception) {
if (e is CancellationException) throw e
false
}
} catch (e: Exception) {
if (e is CancellationException) throw e
false
}
private fun currentTimeMs(): Long = System.currentTimeMillis()