mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
fix(relays): keep lifecycle-aware grace timer running while backgrounded
The grace-period unsubscribe in LifecycleAwareKeyDataSourceSubscription ran on the composition scope from rememberCoroutineScope(), whose dispatcher is coupled to the UI frame clock. When the app is backgrounded the frame clock stops ticking, so the pending unsubscribe could be starved and never fire. Because closing the REQ is what drives the relay disconnect (via desiredRelays -> RelayPool.updatePool), the connection could linger indefinitely. This is most visible on the relay feed, whose dedicated one-off relay is kept alive by nothing else. Drive the grace timer from Lifecycle.currentStateFlow on a dedicated Dispatchers.Default scope instead. collectLatest cancels the pending delay automatically when the lifecycle returns to STARTED, preserving the 30s app-switch grace while ensuring the timer fires reliably in the background. https://claude.ai/code/session_01SesftJphLwvLtn1fJB5zx8
This commit is contained in:
+57
-112
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user