From a32b349b044e35cd7ec8e6b9c2cd2177e7c5a8ce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 02:43:48 +0000 Subject: [PATCH 01/13] debug(relay): trace background sub teardown + drop unsubscribe grace to 0 Investigating why backgrounding the app on the all-follows feed leaves ~172 outbox relays connected when only inbox + DM relays (~8) should remain. The static teardown chain (lifecycle ON_STOP -> unsubscribe -> client.unsubscribe -> PoolRequests.remove -> RelayPool.updatePool disconnect) is correct, so this adds runtime tracing at the two decisive hops to find where it stalls on-device: - LifecycleAwareKeyDataSourceSubscription: log subscribe/grace-start/ unsubscribe/dispose with the assembler name (tag BgRelayTrace). - RelayPool.updatePool: log desired/inPool/toRemove/connected counts. Also drops UNSUBSCRIBE_GRACE_MILLIS 30s -> 0 as an experiment: if the grace delay() was being starved on Dispatchers.Default once backgrounded (Doze/app-standby suspends timers), unsubscribing immediately on ON_STOP both proves and fixes the leak. To be reverted to a wakelock-safe grace once confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- .../LifecycleAwareKeyDataSourceSubscription.kt | 17 ++++++++++++++++- .../nip01Core/relay/client/pool/RelayPool.kt | 2 ++ 2 files changed, 18 insertions(+), 1 deletion(-) 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 7b8f7334ea..966fc6909c 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 @@ -27,6 +27,7 @@ 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 com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -35,7 +36,13 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L +// DIAGNOSTIC: temporarily 0 to test whether unsubscribing the moment the app +// pauses actually disconnects the feed's outbox relays in the background. If the +// 30s grace's delay() was being starved on Dispatchers.Default once backgrounded +// (Doze/app-standby suspends timers), firing immediately on ON_STOP both proves it +// and fixes the leak. Restore to 30_000L (with a wakelock-/foreground-safe timer) +// once confirmed, to keep absorbing short app switches. +private const val UNSUBSCRIBE_GRACE_MILLIS = 0L /** * A lifecycle-aware version of [KeyDataSourceSubscription] that subscribes @@ -77,6 +84,7 @@ fun LifecycleAwareKeyDataSourceSubscription( key = state, subscribe = { dataSource.subscribe(state) }, unsubscribe = { dataSource.unsubscribe(state) }, + label = dataSource::class.simpleName ?: "?", ) } @@ -89,6 +97,7 @@ fun LifecycleAwareKeyDataSourceSubscription( key = states, subscribe = { dataSource.subscribe(states) }, unsubscribe = { dataSource.unsubscribe(states) }, + label = dataSource::class.simpleName ?: "?", ) } @@ -101,6 +110,7 @@ fun LifecycleAwareKeyDataSourceSubscription( key = state, subscribe = { dataSource.subscribe(state) }, unsubscribe = { dataSource.unsubscribe(state) }, + label = dataSource::class.simpleName ?: "?", ) } @@ -109,6 +119,7 @@ private fun LifecycleAwareSubscription( key: Any?, subscribe: () -> Unit, unsubscribe: () -> Unit, + label: String, ) { val lifecycle = LocalLifecycleOwner.current.lifecycle @@ -124,13 +135,16 @@ private fun LifecycleAwareSubscription( lifecycle.currentStateFlow.collectLatest { current -> if (current.isAtLeast(Lifecycle.State.STARTED)) { if (!subscribed) { + Log.d("BgRelayTrace") { "subscribe($label) — lifecycle=$current" } 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. + Log.d("BgRelayTrace") { "grace-start($label) — lifecycle=$current, waiting ${UNSUBSCRIBE_GRACE_MILLIS}ms" } delay(UNSUBSCRIBE_GRACE_MILLIS) + Log.d("BgRelayTrace") { "unsubscribe($label) — grace elapsed while $current" } unsubscribe() subscribed = false } @@ -139,6 +153,7 @@ private fun LifecycleAwareSubscription( onDispose { scope.cancel() + Log.d("BgRelayTrace") { "dispose-unsubscribe($label)" } // Idempotent: removing an absent key is a cheap no-op. Guarantees the // subscription is released even if the grace timer was still pending. unsubscribe() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index b60eda98a6..854e45e80d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -156,6 +157,7 @@ class RelayPool( */ fun updatePool(newRelays: Set) { val toRemove = relays.keys() - newRelays + Log.d("BgRelayTrace") { "updatePool — desired=${newRelays.size}, inPool=${relays.size()}, toRemove=${toRemove.size}, connected=${connectedRelaysCount()}" } var atLeastOne = false newRelays.forEach { relay -> From a15fe94e2f41cdbe38b98d339f268aae122308b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 13:37:15 +0000 Subject: [PATCH 02/13] debug(relay): log connected-set vs pool cache after updatePool The background teardown works (desired collapses to 11, toRemove=349) but the persistent notification still reports ~110 connected relays. That points at the _connectedRelays StateFlow (what the notification reads) being decoupled from the pool's desired set: it is only decremented from the async onFailure/onClosed websocket callback, while disconnect() uses OkHttp cancel() (immediate/violent). Add a post-reconcile log comparing cacheConnected (relays still in the pool reporting isConnected) against flowConnected (_connectedRelays.size) to confirm whether the 110 are live sockets or a stale count. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- .../quartz/nip01Core/relay/client/pool/RelayPool.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index 854e45e80d..f1b4f673c6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -175,6 +175,7 @@ class RelayPool( if (atLeastOne) { _availableRelays.update { relays.keys() } } + Log.d("BgRelayTrace") { "updatePool done — cacheConnected=${connectedRelaysCount()}, flowConnected=${_connectedRelays.value.size}, inPool=${relays.size()}" } } fun addRelay(relay: NormalizedRelayUrl): IRelayClient { From 43b28a5cf483266b6aa9dbc1e556c4eb20417f58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 13:55:52 +0000 Subject: [PATCH 03/13] fix(relay): prune connected set when a relay leaves the pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backgrounding the app correctly collapsed the desired relay set (e.g. desired=22, toRemove=343) and the pool cache shrank accordingly, yet the always-on notification kept reporting ~110 connected relays. Measured: updatePool done — cacheConnected=18, flowConnected=87, inPool=22 _connectedRelays (exposed via connectedRelaysFlow() and read by the notification) was only ever pruned from the async onClosed/onFailure websocket callback. disconnect() uses OkHttp cancel(), which kills the socket immediately but whose callback is unreliable when hundreds of sockets are cancelled at once in the background — so the connected set stayed stale long after the real connections were gone. Prune _connectedRelays directly in removeRelayInner (and clear it in removeAllRelays) so the connected set tracks the pool's membership immediately. The async callback remains as an idempotent backstop. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- .../quartz/nip01Core/relay/client/pool/RelayPool.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index f1b4f673c6..0c1a9766ad 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -202,6 +202,14 @@ class RelayPool( val relayInPool = relays.remove(relay) if (relayInPool != null) { relayInPool.disconnect() + // Reflect the disconnect immediately. disconnect() uses OkHttp cancel(), + // whose onClosed/onFailure callback — the only other path that prunes + // _connectedRelays — is async and, when cancelling hundreds of sockets at + // once in the background, frequently never arrives. That left the connected + // set (and the always-on notification's relay count) stale at ~110 while the + // pool itself had already shrunk to the desired ~20. The callback, if it does + // fire later, repeats this subtraction idempotently. + _connectedRelays.update { it - relay } return true } return false @@ -218,6 +226,7 @@ class RelayPool( disconnect() relays.clear() _availableRelays.update { emptySet() } + _connectedRelays.update { emptySet() } } } From 63da2e5b71449ff2cfa284b848e8f1f7a18f8864 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 14:31:07 +0000 Subject: [PATCH 04/13] debug(relay): log per-assembler relay count on invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After fixing the stale connected-count, the background footprint settles at ~25 relays (desired=22) — higher than the inbox+DM target. Add a per-EoseManager log (assembler name -> key count + distinct relay count) so we can attribute the 25 to specific always-on loaders (metadata/drafts on homeRelays, gift-wrap history, marmot groups, notifications) and trim precisely instead of guessing. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- .../commons/relayClient/eoseManagers/BaseEoseManager.kt | 2 ++ .../relay/client/subscriptions/SubscriptionController.kt | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt index a16d3734e7..54ed7e8d98 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers import com.vitorpamplona.amethyst.commons.service.BundledUpdate import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController @@ -53,6 +54,7 @@ abstract class BaseEoseManager( fun forceInvalidate() { updateSubscriptions(allKeys()) orchestrator.updateRelays() + Log.d("BgRelayTrace") { "${this::class.simpleName} — keys=${allKeys().size}, relays=${orchestrator.activeRelays().size}" } } override fun destroy() { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt index 6e0f4883e4..1ad2f3fac9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt @@ -51,6 +51,13 @@ class SubscriptionController( fun getSub(subId: String) = subscriptions.get(subId) + /** Distinct relays across all active subscriptions managed by this controller. */ + fun activeRelays(): Set { + val relays = mutableSetOf() + subscriptions.forEach { _, sub -> sub.filters()?.keys?.let { relays.addAll(it) } } + return relays + } + fun requestNewSubscription( subId: String, listener: SubscriptionListener, From 87c4077f9415f4d6c151d06792a18d96cb49fd76 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 15:34:56 +0000 Subject: [PATCH 05/13] fix(relay): detect background via LifecycleEventObserver, not bg-dispatched flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A device log showed the foreground feeds (and ~150 relays) staying connected for a full ~60s after the app was paused, then collapsing to the 11-relay floor all at once: 11:26:02 HomeOutboxEventsEoseManager — keys=2, relays=344 (paused here) … 60s of silence … 11:27:02 grace-start(HomeFilterAssembler) — lifecycle=CREATED 11:27:02 updatePool done — flowConnected=9, inPool=11 The lifecycle-aware subscription detected ON_STOP by collecting lifecycle.currentStateFlow on Dispatchers.Default. Backgrounded, that collector wasn't resumed until the next NostrClient keep-alive tick (KEEP_ALIVE_INTERVAL_MS = 60s), so teardown — and the relay disconnects it drives — lagged a minute behind the actual pause. Switch detection to a main-thread LifecycleEventObserver, which fires synchronously during onStop. Only the grace delay still runs on the background scope (so it isn't gated by the stopped UI frame clock). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- ...LifecycleAwareKeyDataSourceSubscription.kt | 59 ++++++++++++------- 1 file changed, 38 insertions(+), 21 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 966fc6909c..65deaa134e 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 @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.relayClient.subscriptions import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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 @@ -30,10 +31,10 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch // DIAGNOSTIC: temporarily 0 to test whether unsubscribing the moment the app @@ -124,34 +125,50 @@ private fun LifecycleAwareSubscription( val lifecycle = LocalLifecycleOwner.current.lifecycle 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. + // Detect lifecycle transitions with a main-thread LifecycleEventObserver, NOT by + // collecting currentStateFlow on a background dispatcher. On a real backgrounded + // device the latter delivered ON_STOP ~60s late — it only woke on the next + // NostrClient keep-alive tick — leaving heavy feeds (and ~150 relays) connected + // for that whole minute after the app was paused. A LifecycleEventObserver fires + // synchronously during onStop, so teardown starts immediately. + // + // The grace *delay* still runs on a background scope so it isn't gated by the UI + // frame clock, which stops ticking while the app is backgrounded. val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - 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) { - Log.d("BgRelayTrace") { "subscribe($label) — lifecycle=$current" } + // graceJob is only ever read/written from the main thread (observer callbacks), + // so no synchronization is needed. subscribe()/unsubscribe() are idempotent + // (reference-counted map ops), so re-issuing subscribe() on each ON_START is safe. + var graceJob: Job? = null + + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + graceJob?.cancel() + graceJob = null + Log.d("BgRelayTrace") { "subscribe($label)" } 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. - Log.d("BgRelayTrace") { "grace-start($label) — lifecycle=$current, waiting ${UNSUBSCRIBE_GRACE_MILLIS}ms" } - delay(UNSUBSCRIBE_GRACE_MILLIS) - Log.d("BgRelayTrace") { "unsubscribe($label) — grace elapsed while $current" } - unsubscribe() - subscribed = false + + Lifecycle.Event.ON_STOP -> { + graceJob?.cancel() + graceJob = + scope.launch { + if (UNSUBSCRIBE_GRACE_MILLIS > 0) delay(UNSUBSCRIBE_GRACE_MILLIS) + Log.d("BgRelayTrace") { "unsubscribe($label)" } + unsubscribe() + } + } + + else -> {} } } - } + + lifecycle.addObserver(observer) onDispose { + lifecycle.removeObserver(observer) scope.cancel() Log.d("BgRelayTrace") { "dispose-unsubscribe($label)" } // Idempotent: removing an absent key is a cheap no-op. Guarantees the From 5d2bbfe661287921df1168f03a0ab58eaa644d5b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 15:35:11 +0000 Subject: [PATCH 06/13] style(relay): spotless import ordering in BaseEoseManager Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- .../commons/relayClient/eoseManagers/BaseEoseManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt index 54ed7e8d98..3afee1c9c5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers import com.vitorpamplona.amethyst.commons.service.BundledUpdate import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO From 17073e7cfa6d1f54b9e15dc15dfec58cff0c3c11 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 15:53:59 +0000 Subject: [PATCH 07/13] debug(relay): trace the notification popup's connected-count updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistent notification's relay count is rendered by a collector on the service's Dispatchers.IO scope. If that collector is throttled while backgrounded — the same throttling that delayed the lifecycle teardown by 60s — the popup would show a stale count while the real pool (logged as flowConnected in updatePool) has already shrunk. Log every value the collector receives and every count it actually posts, so we can tell a stale popup from real connections. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- .../amethyst/service/notifications/NotificationRelayService.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index 93a556a01a..10dcadaf06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -255,9 +255,11 @@ class NotificationRelayService : Service() { launch { Amethyst.instance.client.connectedRelaysFlow().collectLatest { relays -> val count = relays.size + Log.d("BgRelayTrace") { "notif-collector received connectedRelays=$count (lastPosted=$connectedRelayCount)" } if (count != connectedRelayCount) { connectedRelayCount = count updateNotification(count) + Log.d("BgRelayTrace") { "notif-popup posted count=$count" } } } } From edae6d052005a0bf66dd18dc635123e4993aff5c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 16:30:06 +0000 Subject: [PATCH 08/13] feat(notif): distinguish foreground vs background relay count in popup The always-on notification now reads "Connected to N relays" while the app is foreground (the pool also holds feed/finder outbox relays) and "Connected to N inbox relays" once backgrounded (feeds torn down, only inbox + DM relays remain). The label is chosen from MainActivity.isResumed at each notification refresh; since foreground/background transitions always change the connected count, the existing count-driven re-post picks up the new wording. Both messages are now (relay/relays declines in many locales), converting the existing always_on_notif_connected across all 11 locales that had it (other-only; Crowdin fans out the remaining CLDR categories). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- .../notifications/NotificationRelayService.kt | 14 ++++++++++---- amethyst/src/main/res/values-cs-rCZ/strings.xml | 4 +++- amethyst/src/main/res/values-de-rDE/strings.xml | 4 +++- amethyst/src/main/res/values-hi-rIN/strings.xml | 4 +++- amethyst/src/main/res/values-hu-rHU/strings.xml | 4 +++- amethyst/src/main/res/values-nl-rNL/strings.xml | 4 +++- amethyst/src/main/res/values-pl-rPL/strings.xml | 4 +++- amethyst/src/main/res/values-pt-rBR/strings.xml | 4 +++- amethyst/src/main/res/values-sl-rSI/strings.xml | 4 +++- amethyst/src/main/res/values-sv-rSE/strings.xml | 4 +++- amethyst/src/main/res/values-zh-rCN/strings.xml | 4 +++- amethyst/src/main/res/values/strings.xml | 9 ++++++++- 12 files changed, 48 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index 10dcadaf06..02d1a1b4f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -39,6 +39,7 @@ import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -274,10 +275,15 @@ class NotificationRelayService : Service() { private fun buildNotification(connectedRelays: Int): Notification { val contentText = - if (connectedRelays > 0) { - getString(R.string.always_on_notif_connected, connectedRelays) - } else { - getString(R.string.always_on_notif_connecting) + when { + connectedRelays <= 0 -> getString(R.string.always_on_notif_connecting) + // Foreground: the pool also holds the feed/finder outbox relays, so the + // count reflects all connections, not just the inbox. Backgrounded, the + // feeds tear down and only inbox + DM relays remain. + MainActivity.isResumed -> + pluralStringRes(this, R.plurals.always_on_notif_connected_foreground, connectedRelays, connectedRelays) + else -> + pluralStringRes(this, R.plurals.always_on_notif_connected, connectedRelays, connectedRelays) } val openAppIntent = diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 3d2e2564e7..76896477df 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1341,7 +1341,9 @@ Pověření Udržuje připojení k vašim inbox relayím aktivní pro oznámení v reálném čase Amethyst oznámení aktivní - Připojeno k %1$d inbox relayím + + Připojeno k %1$d inbox relayím + Připojování k inbox relayím\u2026 Služba trvalých oznámení Udržuje trvalé připojení k vašim inbox relayím pro okamžité doručování oznámení. Zobrazuje průběžné oznámení. Spotřebovává více baterie, ale zajišťuje, že nezmeškáte žádnou zprávu. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 688d63940a..7862fd3944 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1325,7 +1325,9 @@ anz der Bedingungen ist erforderlich Anmeldeinformationen Hält Verbindungen zu deinen Inbox-Relays aktiv für Echtzeit-Benachrichtigungen Amethyst-Benachrichtigungen aktiv - Mit %1$d Inbox-Relays verbunden + + Mit %1$d Inbox-Relays verbunden + Verbinde mit Inbox-Relays\u2026 Dauerhafter Benachrichtigungsdienst Hält eine dauerhafte Verbindung zu deinen Inbox-Relays für sofortige Benachrichtigungen aufrecht. Zeigt eine fortlaufende Benachrichtigung an. Verbraucht mehr Akku, stellt aber sicher, dass du keine Nachricht verpasst. diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 2d3a1259c1..b823bdc436 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -1311,7 +1311,9 @@ योग्यता प्रमाण आपके आगतपेटिका पुनःप्रसारकों के साथ संयोजन सक्रिय रखता है तत्काल सूचनाओं के लिए अमेथिस्ट सूचनाएँ सक्रिय - संयोजित %1$d आगतपेटिका पुनःप्रसारकों के साथ + + संयोजित %1$d आगतपेटिका पुनःप्रसारकों के साथ + आगतपेटिका पुनःप्रसारकों के साथ संयोजन किया जा रहा है \u2026 सदैव सक्रिय सूचना सेवा अनवरत संयोजन बनाए रखता है आपके आगतपेटिका पुनःप्रसारकों के साथ तत्काल सूचना वितरण के लिए। एक स्थायी सूचना दिखाता है। विद्युत्कोष का अधिक उपयोग करता है पर निश्चित करता है कि आप कभी भी सन्देश नहीं खोएँगे। diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 08b1b94ec7..bc307b7d33 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -1327,7 +1327,9 @@ Hitelesítési adatok Aktív kapcsolatot tart fenn a beérkező üzenetek átjátszóival a valós idejű értesítések érdekében Amethyst értesítések aktíválva - Kapcsolódva %1$d beérkező üzenetátjátszóhoz + + Kapcsolódva %1$d beérkező üzenetátjátszóhoz + Kapcsolódás a beérkező üzenetátjátszókhoz\u2026 Folyamatos értesítési szolgáltatás Folyamatos kapcsolatot tart fenn a beérkező üzenetek átjátszóival az értesítések azonnali kézbesítése érdekében. Megjeleníti a folyamatban lévő értesítéseket. Több akkumulátort fogyaszt, de így biztosan nem marad le egyetlen üzenetről sem. diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 6f76a05408..91427cc4f9 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -1069,7 +1069,9 @@ Credential Houdt verbindingen met je inbox-relays actief voor realtime meldingen Amethyst-meldingen actief - Verbonden met %1$d inbox-relays + + Verbonden met %1$d inbox-relays + Verbinden met inbox-relays… Altijd-aan meldingsdienst Houdt een persistente verbinding met je inbox-relays voor directe melding. Toont een permanente notificatie. Gebruikt meer batterij maar zorgt dat je nooit een bericht mist. diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index b8fc236ea5..0773c2e692 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1345,7 +1345,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Uwierzytelnienie Utrzymuje aktywne połączenia z transmiterami skrzynki odbiorczej, umożliwiając otrzymywanie powiadomień w czasie rzeczywistym Powiadomienia Ametyst Aktywne - Połączono z %1$d transmiterami odbiorczymi + + Połączono z %1$d transmiterami odbiorczymi + Łączenie z transmiterami odbiorczymi\u2026 Usługa powiadomień zawsze włączona Utrzymuje stałe połączenie z transmiterami odbiorczymi, aby zapewnić natychmiastowe dostarczanie powiadomień. Wyświetla bieżące powiadomienia. Zużywa więcej baterii, ale gwarantuje, że nigdy nie przegapisz żadnej wiadomości. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 7cb94e3c62..f175be9395 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -1320,7 +1320,9 @@ Credencial Mantém as conexões com seus relays de caixa de entrada ativas para notificações em tempo real Notificações do Amethyst ativas - Conectado a %1$d relays de caixa de entrada + + Conectado a %1$d relays de caixa de entrada + Conectando aos relays de caixa de entrada\u2026 Serviço de notificações sempre ativo Mantém uma conexão persistente com seus relays de caixa de entrada para entrega instantânea de notificações. Mostra uma notificação contínua. Usa mais bateria, mas garante que você nunca perca uma mensagem. diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index baf791b6dd..3ce7496a75 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -1342,7 +1342,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Akreditivi Ohranja aktivne povezave z vašimi releji za obvestila v realnem času Amethyst obvestila so aktivna - Povezan z %1$d vhodnimi releji + + Povezan z %1$d vhodnimi releji + Povezovanje vhodnih relejev\u2026 Vedno aktivna obvestila Ohranja stalno povezavo z vašimi releji za takojšnjo dostavo obvestil. Prikazuje trajno obvestilo. Porabi več baterije, a zagotavlja, da ne zamudite nobenega sporočila. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index fa489d038c..68915dfb34 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1320,7 +1320,9 @@ Inloggningsuppgift Håller anslutningarna till dina inbox-relän aktiva för realtidsnotifieringar Amethyst-notifieringar aktiva - Ansluten till %1$d inbox-relän + + Ansluten till %1$d inbox-relän + Ansluter till inbox-relän\u2026 Alltid på-notifieringstjänst Upprätthåller en konstant anslutning till dina inbox-relän för omedelbar leverans av notifieringar. Visar en pågående notifiering. Använder mer batteri men säkerställer att du aldrig missar ett meddelande. diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 33ce27d79e..15d10f057b 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1307,7 +1307,9 @@ 凭证 保持与收件箱中继的连接以便接收实时通知 Amethyst 通知活跃 - 已连接到 %1$d 个收件箱中继 + + 已连接到 %1$d 个收件箱中继 + 正在连接到收件箱中继\u2026 “始终显示通知”服务 保持与收件箱中继的持续连接以便即时发送通知。 显示正在进行的通知。使用更多电量,但确保您永远不会错过消息。 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6c1ee7fc3f..07da4a218b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1438,7 +1438,14 @@ Relay Connection Service Keeps connections to your inbox relays active for real-time notifications Amethyst Notifications Active - Connected to %1$d inbox relays + + Connected to %1$d inbox relay + Connected to %1$d inbox relays + + + Connected to %1$d relay + Connected to %1$d relays + Connecting to inbox relays\u2026 Always-on notification service Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message. From dbfd2c4c43a1e26baf23b76c87860e6e971252f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 17:53:39 +0000 Subject: [PATCH 09/13] fix(notif): throttle relay-count updates to dodge Android's rate limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A device log showed the persistent notification stuck on a stale count (e.g. "44 inbox relays") while the pool had actually settled lower (flowConnected=8). Cause: the count collector posted the notification on every connectedRelaysFlow delta — ~90 updates during feed load, then ~22 in ~250ms during background teardown. Android rate-limits notification updates (~10/s) and silently drops the excess, so the last value the framework rendered (a mid-cascade 44) stuck instead of the final 8. Sample connectedRelaysFlow at 1s before updating the notification. That caps updates to ~1/s — comfortably under the limit — and the settled count always lands. Also drops the now-confirmed notif-collector/ notif-popup debug logging. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- .../notifications/NotificationRelayService.kt | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index 02d1a1b4f4..36acb126e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -43,10 +43,12 @@ import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch /** @@ -80,6 +82,10 @@ class NotificationRelayService : Service() { private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE" + // Throttle interval for refreshing the persistent notification's relay count. + // Keeps notification updates well under Android's rate limit (~10/s). + private const val NOTIFICATION_REFRESH_MS = 1000L + const val ACTION_AUTO_RESTART = "com.vitorpamplona.amethyst.AUTO_RESTART_NOTIFICATION_SERVICE" fun start(context: Context) { @@ -243,6 +249,7 @@ class NotificationRelayService : Service() { * drafts, and relay list changes. Since the service keeps the client connected, * those subscriptions remain active on the relays. */ + @OptIn(FlowPreview::class) private fun startRelayConnection() { relayServiceCollectorJob?.cancel() relayServiceCollectorJob = @@ -254,15 +261,22 @@ class NotificationRelayService : Service() { } launch { - Amethyst.instance.client.connectedRelaysFlow().collectLatest { relays -> - val count = relays.size - Log.d("BgRelayTrace") { "notif-collector received connectedRelays=$count (lastPosted=$connectedRelayCount)" } - if (count != connectedRelayCount) { - connectedRelayCount = count - updateNotification(count) - Log.d("BgRelayTrace") { "notif-popup posted count=$count" } + // sample() caps how often we touch the notification. During feed + // load/teardown connectedRelaysFlow churns dozens of times per second; + // posting on every delta blows past Android's notification rate limit + // (~10/s), which silently drops updates and leaves the visible count + // stuck on a stale intermediate value. One refresh per second stays + // well under the limit and always lands the settled count. + Amethyst.instance.client + .connectedRelaysFlow() + .sample(NOTIFICATION_REFRESH_MS) + .collectLatest { relays -> + val count = relays.size + if (count != connectedRelayCount) { + connectedRelayCount = count + updateNotification(count) + } } - } } } } From 738589fe2f82bc32d541ffd24d3b35411eb2193a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 18:15:32 +0000 Subject: [PATCH 10/13] chore(relay): remove diagnostic logging, restore 30s unsubscribe grace Strips the BgRelayTrace instrumentation added while diagnosing the background relay-count issues and restores the production grace period. - LifecycleAwareKeyDataSourceSubscription: UNSUBSCRIBE_GRACE_MILLIS back to 30s, drop the per-subscription label + logs, refresh the doc to describe the LifecycleEventObserver detection. - RelayPool: drop updatePool trace logs and the now-unused Log import; keep the _connectedRelays prune (with a trimmed comment). - BaseEoseManager: drop the per-assembler relay-count log + Log import. - SubscriptionController: drop activeRelays(), which only fed that log. The actual fixes stay: lifecycle-observer teardown detection, the connected-set prune, and the notification-count throttle + fg/bg wording. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- .../eoseManagers/BaseEoseManager.kt | 2 - ...LifecycleAwareKeyDataSourceSubscription.kt | 43 +++++-------------- .../nip01Core/relay/client/pool/RelayPool.kt | 13 +++--- .../subscriptions/SubscriptionController.kt | 7 --- 4 files changed, 15 insertions(+), 50 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt index 3afee1c9c5..a16d3734e7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.SubscriptionController -import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO @@ -54,7 +53,6 @@ abstract class BaseEoseManager( fun forceInvalidate() { updateSubscriptions(allKeys()) orchestrator.updateRelays() - Log.d("BgRelayTrace") { "${this::class.simpleName} — keys=${allKeys().size}, relays=${orchestrator.activeRelays().size}" } } override fun destroy() { 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 65deaa134e..cbddea997f 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 @@ -28,7 +28,6 @@ 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 com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -37,13 +36,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch -// DIAGNOSTIC: temporarily 0 to test whether unsubscribing the moment the app -// pauses actually disconnects the feed's outbox relays in the background. If the -// 30s grace's delay() was being starved on Dispatchers.Default once backgrounded -// (Doze/app-standby suspends timers), firing immediately on ON_STOP both proves it -// and fixes the leak. Restore to 30_000L (with a wakelock-/foreground-safe timer) -// once confirmed, to keep absorbing short app switches. -private const val UNSUBSCRIBE_GRACE_MILLIS = 0L +private const val UNSUBSCRIBE_GRACE_MILLIS = 30_000L /** * A lifecycle-aware version of [KeyDataSourceSubscription] that subscribes @@ -57,16 +50,14 @@ private const val UNSUBSCRIBE_GRACE_MILLIS = 0L * 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. + * Lifecycle transitions are observed with a main-thread [LifecycleEventObserver], + * which fires synchronously during `onStop`/`onStart`. Detecting the transition + * via a background-dispatched flow instead delivered `ON_STOP` up to ~60s late on + * a backgrounded device (the collector only resumed on the next relay keep-alive + * tick), leaving feeds connected long after the app was paused. Only the grace + * *delay* runs on a [Dispatchers.Default] scope, so it isn't gated by the UI + * frame clock (which stops ticking while backgrounded); returning to STARTED + * cancels the pending unsubscribe before it fires. * * 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 @@ -85,7 +76,6 @@ fun LifecycleAwareKeyDataSourceSubscription( key = state, subscribe = { dataSource.subscribe(state) }, unsubscribe = { dataSource.unsubscribe(state) }, - label = dataSource::class.simpleName ?: "?", ) } @@ -98,7 +88,6 @@ fun LifecycleAwareKeyDataSourceSubscription( key = states, subscribe = { dataSource.subscribe(states) }, unsubscribe = { dataSource.unsubscribe(states) }, - label = dataSource::class.simpleName ?: "?", ) } @@ -111,7 +100,6 @@ fun LifecycleAwareKeyDataSourceSubscription( key = state, subscribe = { dataSource.subscribe(state) }, unsubscribe = { dataSource.unsubscribe(state) }, - label = dataSource::class.simpleName ?: "?", ) } @@ -120,19 +108,11 @@ private fun LifecycleAwareSubscription( key: Any?, subscribe: () -> Unit, unsubscribe: () -> Unit, - label: String, ) { val lifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(key, lifecycle) { - // Detect lifecycle transitions with a main-thread LifecycleEventObserver, NOT by - // collecting currentStateFlow on a background dispatcher. On a real backgrounded - // device the latter delivered ON_STOP ~60s late — it only woke on the next - // NostrClient keep-alive tick — leaving heavy feeds (and ~150 relays) connected - // for that whole minute after the app was paused. A LifecycleEventObserver fires - // synchronously during onStop, so teardown starts immediately. - // - // The grace *delay* still runs on a background scope so it isn't gated by the UI + // Only the grace delay runs on a background scope so it isn't gated by the UI // frame clock, which stops ticking while the app is backgrounded. val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -147,7 +127,6 @@ private fun LifecycleAwareSubscription( Lifecycle.Event.ON_START -> { graceJob?.cancel() graceJob = null - Log.d("BgRelayTrace") { "subscribe($label)" } subscribe() } @@ -156,7 +135,6 @@ private fun LifecycleAwareSubscription( graceJob = scope.launch { if (UNSUBSCRIBE_GRACE_MILLIS > 0) delay(UNSUBSCRIBE_GRACE_MILLIS) - Log.d("BgRelayTrace") { "unsubscribe($label)" } unsubscribe() } } @@ -170,7 +148,6 @@ private fun LifecycleAwareSubscription( onDispose { lifecycle.removeObserver(observer) scope.cancel() - Log.d("BgRelayTrace") { "dispose-unsubscribe($label)" } // Idempotent: removing an absent key is a cheap no-op. Guarantees the // subscription is released even if the grace timer was still pending. unsubscribe() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index 0c1a9766ad..5bef670eca 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder -import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -157,7 +156,6 @@ class RelayPool( */ fun updatePool(newRelays: Set) { val toRemove = relays.keys() - newRelays - Log.d("BgRelayTrace") { "updatePool — desired=${newRelays.size}, inPool=${relays.size()}, toRemove=${toRemove.size}, connected=${connectedRelaysCount()}" } var atLeastOne = false newRelays.forEach { relay -> @@ -175,7 +173,6 @@ class RelayPool( if (atLeastOne) { _availableRelays.update { relays.keys() } } - Log.d("BgRelayTrace") { "updatePool done — cacheConnected=${connectedRelaysCount()}, flowConnected=${_connectedRelays.value.size}, inPool=${relays.size()}" } } fun addRelay(relay: NormalizedRelayUrl): IRelayClient { @@ -204,11 +201,11 @@ class RelayPool( relayInPool.disconnect() // Reflect the disconnect immediately. disconnect() uses OkHttp cancel(), // whose onClosed/onFailure callback — the only other path that prunes - // _connectedRelays — is async and, when cancelling hundreds of sockets at - // once in the background, frequently never arrives. That left the connected - // set (and the always-on notification's relay count) stale at ~110 while the - // pool itself had already shrunk to the desired ~20. The callback, if it does - // fire later, repeats this subtraction idempotently. + // _connectedRelays — is async and, when cancelling many sockets at once + // (e.g. a feed teardown when the app backgrounds), frequently never arrives. + // That leaves the connected set stale while the pool itself has already + // shrunk. The callback, if it does fire later, repeats this subtraction + // idempotently. _connectedRelays.update { it - relay } return true } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt index 1ad2f3fac9..6e0f4883e4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt @@ -51,13 +51,6 @@ class SubscriptionController( fun getSub(subId: String) = subscriptions.get(subId) - /** Distinct relays across all active subscriptions managed by this controller. */ - fun activeRelays(): Set { - val relays = mutableSetOf() - subscriptions.forEach { _, sub -> sub.filters()?.keys?.let { relays.addAll(it) } } - return relays - } - fun requestNewSubscription( subId: String, listener: SubscriptionListener, From 85e6abd17b63dc85f8cfcee0ae37387c91632098 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 18:32:45 +0000 Subject: [PATCH 11/13] chore(relay): drop speculative removeAllRelays connected-set clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit removeAllRelays() has no call sites — it's dead code — so clearing _connectedRelays there was never exercised. The live fix for the stale connected count is the prune in removeRelayInner (driven by updatePool), which keeps removeAllRelays untouched relative to main. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- .../quartz/nip01Core/relay/client/pool/RelayPool.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index 5bef670eca..a69ccbf9b8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -223,7 +223,6 @@ class RelayPool( disconnect() relays.clear() _availableRelays.update { emptySet() } - _connectedRelays.update { emptySet() } } } From 77834cfd92a138d8bb91ef8e993dab7f3ffc9f6a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 18:54:06 +0000 Subject: [PATCH 12/13] fix(relay): derive connected set from pool state instead of patching it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the incremental add/remove maintenance of _connectedRelays (including the removeRelayInner prune) with a recompute from the source of truth: a relay is connected iff it is in the pool AND its socket reports ready (isConnected()). refreshConnectedRelays() runs on connect, disconnect and pool-membership changes. The earlier prune patched the *readout* on the assumption that "removed from pool ⟹ disconnected", which is only incidentally true. A set that is hand-maintained per event drifts from reality whenever an event is missed — OkHttp's async cancel() callback being dropped under mass teardown, or a socket dying without an onDisconnected. Projecting the set from each pooled relay's actual isConnected() can't drift: removed relays are already disconnected so they fall out, and a silently-dead socket stops being counted on the next refresh. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- .../nip01Core/relay/client/pool/RelayPool.kt | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index a69ccbf9b8..9e46e81ba0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -172,6 +172,9 @@ class RelayPool( if (atLeastOne) { _availableRelays.update { relays.keys() } + // Removed relays were just disconnected; reflect that in the connected set + // now rather than waiting for their (possibly dropped) onDisconnected callback. + refreshConnectedRelays() } } @@ -199,14 +202,6 @@ class RelayPool( val relayInPool = relays.remove(relay) if (relayInPool != null) { relayInPool.disconnect() - // Reflect the disconnect immediately. disconnect() uses OkHttp cancel(), - // whose onClosed/onFailure callback — the only other path that prunes - // _connectedRelays — is async and, when cancelling many sockets at once - // (e.g. a feed teardown when the app backgrounds), frequently never arrives. - // That leaves the connected set stale while the pool itself has already - // shrunk. The callback, if it does fire later, repeats this subtraction - // idempotently. - _connectedRelays.update { it - relay } return true } return false @@ -215,6 +210,7 @@ class RelayPool( fun removeRelay(relay: NormalizedRelayUrl) { if (removeRelayInner(relay)) { _availableRelays.update { relays.keys() } + refreshConnectedRelays() } } @@ -223,6 +219,26 @@ class RelayPool( disconnect() relays.clear() _availableRelays.update { emptySet() } + refreshConnectedRelays() + } + } + + /** + * Recomputes [_connectedRelays] from the source of truth: a relay is connected iff it + * is currently in the pool AND its socket reports ready ([IRelayClient.isConnected]). + * + * This is a pure projection of the pool rather than a set we add to / remove from on + * each event, so it cannot drift from reality. An incrementally maintained set goes + * stale whenever a state change isn't observed — e.g. OkHttp's async cancel() callback + * is dropped under mass teardown, or a socket dies without an onDisconnected — and then + * reports relays as connected that no longer are. Relays removed from the pool have + * already been disconnected (isConnected() == false), so they fall out here naturally. + */ + private fun refreshConnectedRelays() { + val connected = mutableSetOf() + relays.forEach { url, relay -> if (relay.isConnected()) connected.add(url) } + if (_connectedRelays.value != connected) { + _connectedRelays.value = connected } } @@ -236,12 +252,12 @@ class RelayPool( pingMillis: Int, compressed: Boolean, ) { - _connectedRelays.update { it + relay.url } + refreshConnectedRelays() listener.onConnected(relay, pingMillis, compressed) } override fun onDisconnected(relay: IRelayClient) { - _connectedRelays.update { it - relay.url } + refreshConnectedRelays() listener.onDisconnected(relay) } From 400a06f62b3fad351e95f19dfd25b348fb3cd7af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 19:04:22 +0000 Subject: [PATCH 13/13] revert(relay): leave RelayPool's connected set to onDisconnected Drops the _connectedRelays changes (the removeRelayInner prune and the derived-projection refresh) and restores RelayPool to match main. The incremental onConnected/onDisconnected maintenance is sufficient; the user-visible background relay-count issues are addressed by the lifecycle teardown timing and the notification-update throttle, not here. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- .../nip01Core/relay/client/pool/RelayPool.kt | 28 ++----------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index 9e46e81ba0..b60eda98a6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -172,9 +172,6 @@ class RelayPool( if (atLeastOne) { _availableRelays.update { relays.keys() } - // Removed relays were just disconnected; reflect that in the connected set - // now rather than waiting for their (possibly dropped) onDisconnected callback. - refreshConnectedRelays() } } @@ -210,7 +207,6 @@ class RelayPool( fun removeRelay(relay: NormalizedRelayUrl) { if (removeRelayInner(relay)) { _availableRelays.update { relays.keys() } - refreshConnectedRelays() } } @@ -219,26 +215,6 @@ class RelayPool( disconnect() relays.clear() _availableRelays.update { emptySet() } - refreshConnectedRelays() - } - } - - /** - * Recomputes [_connectedRelays] from the source of truth: a relay is connected iff it - * is currently in the pool AND its socket reports ready ([IRelayClient.isConnected]). - * - * This is a pure projection of the pool rather than a set we add to / remove from on - * each event, so it cannot drift from reality. An incrementally maintained set goes - * stale whenever a state change isn't observed — e.g. OkHttp's async cancel() callback - * is dropped under mass teardown, or a socket dies without an onDisconnected — and then - * reports relays as connected that no longer are. Relays removed from the pool have - * already been disconnected (isConnected() == false), so they fall out here naturally. - */ - private fun refreshConnectedRelays() { - val connected = mutableSetOf() - relays.forEach { url, relay -> if (relay.isConnected()) connected.add(url) } - if (_connectedRelays.value != connected) { - _connectedRelays.value = connected } } @@ -252,12 +228,12 @@ class RelayPool( pingMillis: Int, compressed: Boolean, ) { - refreshConnectedRelays() + _connectedRelays.update { it + relay.url } listener.onConnected(relay, pingMillis, compressed) } override fun onDisconnected(relay: IRelayClient) { - refreshConnectedRelays() + _connectedRelays.update { it - relay.url } listener.onDisconnected(relay) }