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:
Claude
2026-05-29 09:29:48 +00:00
parent 16afed0f3f
commit 5667bd53c3
@@ -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()
}
}
}