From 5667bd53c30361d52c9bb87dd6bbb9bba61883b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 09:29:48 +0000 Subject: [PATCH] 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 --- ...LifecycleAwareKeyDataSourceSubscription.kt | 169 ++++++------------ 1 file changed, 57 insertions(+), 112 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt index 6a5df9fa91..ce86ad760c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt @@ -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 LifecycleAwareKeyDataSourceSubscription( state: T, dataSource: ComposeSubscriptionManager, ) { - 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 LifecycleAwareKeyDataSourceSubscription( state: T, dataSource: MutableComposeSubscriptionManager, ) { - 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() } } }