Merge pull request #3103 from greenart7c3/claude/elegant-knuth-grbfU

Fix lifecycle-aware subscription grace timer starvation
This commit is contained in:
Vitor Pamplona
2026-05-29 06:34:53 -04:00
committed by GitHub
@@ -22,15 +22,17 @@ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableComposeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState
import kotlinx.coroutines.Job
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L
@@ -47,6 +49,17 @@ private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L
* rebuilding the relay REQ — which would otherwise lose EOSE state and
* trigger a refetch on return.
*
* The grace timer runs on a dedicated [Dispatchers.Default] scope driven by
* [Lifecycle.currentStateFlow] rather than on the composition's frame-clock
* coupled scope (`rememberCoroutineScope`). On a backgrounded app the UI
* frame clock stops ticking, so a timer scheduled there could be starved and
* the unsubscribe — and therefore the relay disconnect it triggers — might
* never run. This is most visible on the relay feed, whose dedicated one-off
* relay is kept connected by nothing else and would leak forever. Using a
* plain coroutine dispatcher keeps the timer firing while backgrounded;
* [collectLatest] cancels the pending delay automatically the moment the
* lifecycle returns to STARTED.
*
* Use this for heavy feed subscriptions (home, video, discovery, chatroom list)
* that should NOT run when the app is truly in the background. When an
* always-on notification service keeps the relay client connected, these
@@ -60,66 +73,11 @@ fun <T> LifecycleAwareKeyDataSourceSubscription(
state: T,
dataSource: ComposeSubscriptionManager<T>,
) {
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
DisposableEffect(state, lifecycleOwner) {
var isSubscribed = false
var pendingUnsubscribe: Job? = null
fun subscribeNow() {
pendingUnsubscribe?.cancel()
pendingUnsubscribe = null
if (!isSubscribed) {
dataSource.subscribe(state)
isSubscribed = true
}
}
fun scheduleUnsubscribe() {
if (!isSubscribed || pendingUnsubscribe != null) return
pendingUnsubscribe =
scope.launch {
delay(UNSUBSCRIBE_GRACE_MILLIS)
if (isSubscribed) {
dataSource.unsubscribe(state)
isSubscribed = false
}
pendingUnsubscribe = null
}
}
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
subscribeNow()
}
Lifecycle.Event.ON_STOP -> {
scheduleUnsubscribe()
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
subscribeNow()
}
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
pendingUnsubscribe?.cancel()
pendingUnsubscribe = null
if (isSubscribed) {
dataSource.unsubscribe(state)
isSubscribed = false
}
}
}
LifecycleAwareSubscription(
key = state,
subscribe = { dataSource.subscribe(state) },
unsubscribe = { dataSource.unsubscribe(state) },
)
}
@Composable
@@ -127,64 +85,51 @@ fun <T : MutableQueryState> LifecycleAwareKeyDataSourceSubscription(
state: T,
dataSource: MutableComposeSubscriptionManager<T>,
) {
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
LifecycleAwareSubscription(
key = state,
subscribe = { dataSource.subscribe(state) },
unsubscribe = { dataSource.unsubscribe(state) },
)
}
DisposableEffect(state, lifecycleOwner) {
var isSubscribed = false
var pendingUnsubscribe: Job? = null
@Composable
private fun LifecycleAwareSubscription(
key: Any?,
subscribe: () -> Unit,
unsubscribe: () -> Unit,
) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
fun subscribeNow() {
pendingUnsubscribe?.cancel()
pendingUnsubscribe = null
if (!isSubscribed) {
dataSource.subscribe(state)
isSubscribed = true
}
}
DisposableEffect(key, lifecycle) {
// Background scope so the grace timer is not gated by the UI frame clock,
// which stops ticking while the app is backgrounded.
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
fun scheduleUnsubscribe() {
if (!isSubscribed || pendingUnsubscribe != null) return
pendingUnsubscribe =
scope.launch {
scope.launch {
// `subscribed` is confined to this single collector coroutine, so no
// cross-thread synchronization is needed for it.
var subscribed = false
lifecycle.currentStateFlow.collectLatest { current ->
if (current.isAtLeast(Lifecycle.State.STARTED)) {
if (!subscribed) {
subscribe()
subscribed = true
}
} else if (subscribed) {
// Stopped: keep the REQ alive for a short grace period.
// collectLatest cancels this delay if we return to STARTED first.
delay(UNSUBSCRIBE_GRACE_MILLIS)
if (isSubscribed) {
dataSource.unsubscribe(state)
isSubscribed = false
}
pendingUnsubscribe = null
}
}
val observer =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
subscribeNow()
}
Lifecycle.Event.ON_STOP -> {
scheduleUnsubscribe()
}
else -> {}
unsubscribe()
subscribed = false
}
}
lifecycleOwner.lifecycle.addObserver(observer)
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
subscribeNow()
}
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
pendingUnsubscribe?.cancel()
pendingUnsubscribe = null
if (isSubscribed) {
dataSource.unsubscribe(state)
isSubscribed = false
}
scope.cancel()
// Idempotent: removing an absent key is a cheap no-op. Guarantees the
// subscription is released even if the grace timer was still pending.
unsubscribe()
}
}
}