mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 11:16:12 -04:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user