fix(upgrade): Settle FOSS entitlement errors instead of hanging

A thrown cache read used to die inside shareIn's sharing coroutine, leaving
every collector waiting forever. Catch inside flatMapLatest, keep the last
known entitlement on late failures, and let a successful persist revive an
error-stuck inner flow.
This commit is contained in:
darken
2026-08-03 20:28:01 +02:00
committed by Matthias Urhahn
parent b77f4a9581
commit 9b60ed945f
2 changed files with 160 additions and 15 deletions
@@ -3,15 +3,20 @@ package eu.darken.capod.common.upgrade.core
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.common.upgrade.UpgradeRepo
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import java.time.Instant
import java.util.UUID
@@ -31,20 +36,39 @@ class UpgradeRepoFoss @Inject constructor(
private val refreshTrigger = MutableStateFlow(UUID.randomUUID())
override val upgradeInfo: Flow<UpgradeRepo.Info> = combine(
fossCache.upgrade.flow,
refreshTrigger
) { data, _ ->
if (data == null) {
Info()
} else {
Info(
isPro = true,
upgradedAt = data.upgradedAt,
upgradeReason = data.reason,
)
// Written only from the sharing coroutine (single collector) — no synchronization needed.
private var lastKnownInfo: Info? = null
override val upgradeInfo: Flow<UpgradeRepo.Info> = refreshTrigger
.flatMapLatest {
fossCache.upgrade.flow
.map { data ->
if (data == null) {
Info()
} else {
Info(
isPro = true,
upgradedAt = data.upgradedAt,
upgradeReason = data.reason,
)
}
}
.catch { e ->
// A SharedFlow cannot fail: without this, a thrown cache read dies inside
// shareIn's sharing coroutine and every collector hangs forever (VM state stuck
// on Loading, checkSponsorReturn suspended mid-unlock). The catch sits INSIDE
// flatMapLatest so the error completes only this inner subscription — refresh()
// resubscribes the cache and recovery stays possible. Last-known preservation:
// a late read failure must not revoke an entitlement we already saw; the error
// rides on the previous Info instead. Contrast: gplay keeps a retryWhen loop
// because billing re-settles in-place — the FOSS read is a local one-shot, and
// refresh-driven resubscription IS the retry.
if (e is CancellationException) throw e
log(TAG, WARN) { "upgradeInfo read failed: ${e.asLog()}" }
emit((lastKnownInfo ?: Info()).copy(error = e))
}
}
}
.onEach { if (it.error == null) lastKnownInfo = it }
.setupCommonEventHandlers(TAG) { "upgradeInfo" }
.shareIn(appScope, SharingStarted.WhileSubscribed(3000L, 0L), replay = 1)
@@ -68,6 +92,8 @@ class UpgradeRepoFoss @Inject constructor(
* decode reads as null and therefore counts as ABSENT to this transaction, i.e. it gets
* replaced. That matches the pre-existing read behaviour — such a record already presents the
* user as free — and re-creating it on the next successful sponsor visit is the recovery path.
* Decode failures therefore fall back to absent by design (the flag), so the error path around
* [upgradeInfo] covers IO/corruption throws, not schema mismatches.
*
* @return true if a new record was created, false if an existing record was kept.
*/
@@ -79,6 +105,9 @@ class UpgradeRepoFoss @Inject constructor(
reason = FossUpgrade.Reason.DONATED,
)
}
// A returned transaction proves the store is readable again: revive a possibly error-stuck
// inner flow so the record propagates to collectors still holding the error replay.
refresh()
return if (updated.old == null) {
true
} else {