From f8040e5962d182865bc0e80debbe0d1d0a89dc5a Mon Sep 17 00:00:00 2001 From: "Dr. Tobias Baur" <1097224+believethehype@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:49:55 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20record=20DVM=20heartbeats=20in=20a=20str?= =?UTF-8?q?ong=20registry=20=E2=80=94=20beat=20notes=20are=20WeakReference?= =?UTF-8?q?-held?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beat notes live in LocalCache.addressables (WeakReference values) with no strong holder on the Discover screen: every GC sweep cleared them all at once, the freshness gate failed for every DVM simultaneously, and the list collapsed and rebuilt one beat at a time (worst on refresh — an allocation spike). The detail screen survived only because its own composable held the note. DvmHeartbeatRegistry (Address -> latest createdAt, strong, one entry per DVM) is now the freshness source: recorded on every beat consumption, read by the gate and the liveness composables. Stale entries are inert; the map is bounded by distinct DVM addresses. --- .../amethyst/model/DvmHeartbeat.kt | 11 +++- .../amethyst/model/DvmHeartbeatRegistry.kt | 58 +++++++++++++++++++ .../amethyst/model/LocalCache.kt | 8 ++- .../loggedIn/dvms/DvmHeartbeatObservation.kt | 10 +++- .../amethyst/model/DvmHeartbeatTest.kt | 12 ++++ 5 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt index 7a4fc9e8b4..aafb366746 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeat.kt @@ -30,11 +30,18 @@ import com.vitorpamplona.quartz.utils.TimeUtils /** The cache slot a DVM's heartbeat lives in: the announcement's own address, kind 11998. */ fun LocalCache.dvmHeartbeatOf(appDef: AppDefinitionEvent): DvmHeartbeatEvent? = getAddressableNoteIfExists(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag()))?.event as? DvmHeartbeatEvent -/** A DVM counts as alive only if its latest heartbeat is at most 420s old. */ +/** + * A DVM counts as alive only if its latest heartbeat is at most 900s old. The registry (not the + * WeakReference-held beat note) is the freshness source: beat notes have no strong holder on the + * Discover screen, and a GC sweep cleared them all at once, collapsing the list. + */ fun LocalCache.hasFreshDvmHeartbeat( appDef: AppDefinitionEvent, now: Long = TimeUtils.now(), -): Boolean = dvmHeartbeatOf(appDef)?.isFreshAt(now) == true +): Boolean = + DvmHeartbeatRegistry + .latestAt(Address(DvmHeartbeatEvent.KIND, appDef.pubKey, appDef.dTag())) + ?.let { it >= now - DvmHeartbeatEvent.MAX_AGE_SECONDS } == true /** * Every cached content-discovery announcement, WITHOUT the freshness gate — this is the source the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt new file mode 100644 index 0000000000..5f7ec90245 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/DvmHeartbeatRegistry.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.nip01Core.core.Address +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import java.util.concurrent.ConcurrentHashMap + +/** + * Strong, process-wide record of the latest heartbeat per DVM announcement address + * (`Address(11998, dvmPubKey, dTag) -> createdAt`). + * + * Beat Notes themselves live in `LocalCache.addressables`, a WeakReference store with no strong + * holder on the Discover screen — every GC sweep cleared them all at once and the freshness gate + * collapsed for every DVM simultaneously (the list emptied and rebuilt one beat at a time). This + * registry is the freshness source the gate and the liveness composables read: strong references, + * fed by every beat-arrival path (global REQ, outbox batches, per-surface fetches — all beat + * consumption routes through [record]). + * + * One entry per DVM address ever seen; timestamps only, so it stays tiny. `0` means "no beat". + */ +object DvmHeartbeatRegistry { + private val latestBeatCreatedAt = ConcurrentHashMap>() + + private fun flowFor(address: Address): MutableStateFlow = latestBeatCreatedAt.getOrPut(address) { MutableStateFlow(0L) } + + /** Observable latest-beat timestamp for this address; `0` means "no beat seen yet". */ + fun flowForPublic(address: Address): StateFlow = flowFor(address) + + /** Records a beat's createdAt; older beats never move the entry backwards. */ + fun record( + address: Address, + createdAt: Long, + ) = flowFor(address).update { current -> if (createdAt > current) createdAt else current } + + /** The latest recorded beat's createdAt for this address, or null when no beat was ever seen. */ + fun latestAt(address: Address): Long? = flowFor(address).value.takeIf { it > 0L } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 82e0d1d813..fba4ed1416 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -3744,8 +3744,14 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { is CommunityListEvent, is ContactListEvent, // DVM heartbeat (11998): stored per Address(11998, author, d) so liveness checks find the - // beat at the announcement's mirror address (amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md). + // beat at the announcement's mirror address (amethyst/plans/2026-09-10-dvm-heartbeat-liveness.md), + // AND recorded into the strong registry — beat notes are WeakReference-held with no strong + // holder on the Discover screen, so the gate must not depend on them surviving GC. is DvmHeartbeatEvent, + -> + consumeBaseReplaceable(event, relay, wasVerified).also { + DvmHeartbeatRegistry.record(event.address(), event.createdAt) + } is EmojiPackEvent, is EmojiPackSelectionEvent, is EphemeralChatListEvent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt index db80c0a3b7..97f28efee2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmHeartbeatObservation.kt @@ -39,8 +39,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.dvm_offline_banner +import com.vitorpamplona.amethyst.model.DvmHeartbeatRegistry import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -81,6 +83,11 @@ fun rememberDvmHeartbeatFresh( val observed = heartbeatNote?.let { observeNoteAndMap(it, accountViewModel) { it.event as? DvmHeartbeatEvent } } + // The registry is the eviction-proof freshness source (beat notes are WeakReference-held); + // the observed note is the secondary source and also drives the outbox fetch. + val registryBeat by + DvmHeartbeatRegistry.flowForPublic(appDefinitionAddress).collectAsStateWithLifecycle() + var now by remember(heartbeatNote) { mutableLongStateOf(TimeUtils.now()) } LaunchedEffect(heartbeatNote) { while (isActive) { @@ -90,7 +97,8 @@ fun rememberDvmHeartbeatFresh( } val beat = observed?.value - return rememberUpdatedState(beat != null && beat.isFreshAt(now)) + val bestBeat = maxOf(beat?.createdAt ?: 0L, registryBeat) + return rememberUpdatedState(bestBeat > 0L && bestBeat >= now - DvmHeartbeatEvent.MAX_AGE_SECONDS) } /** Floating "DVM is offline" banner, mirroring the Home status banner's card style. */ diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt index 886fe55cda..87e693af34 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/DvmHeartbeatTest.kt @@ -121,6 +121,18 @@ class DvmHeartbeatTest { assertFalse(LocalCache.hasFreshDvmHeartbeat(appDef("dvm-never"), TimeUtils.now())) } + @Test + fun theGateSurvivesWeakCacheEvictionOfTheBeatNote() { + // Beat notes live in LocalCache's WeakReference store with no strong holder on the + // Discover screen — a GC sweep clears them all at once and the list collapses. The + // freshness gate must therefore read the strong registry, not the evictable note. + val app = appDef("dvm-registry") + DvmHeartbeatRegistry.record(Address(DvmHeartbeatEvent.KIND, appDefPubKey, "dvm-registry"), 1_760_000_000L - 100) + + assertTrue("registry alone proves liveness", LocalCache.hasFreshDvmHeartbeat(app, 1_760_000_000L)) + assertTrue("no entry for dvm-never", DvmHeartbeatRegistry.latestAt(Address(DvmHeartbeatEvent.KIND, appDefPubKey, "dvm-never")) == null) + } + @Test fun theUngatedAnnouncementScanKeepsDvmsTheGateWouldHide() { // The outbox fetcher must source announcements from the cache, NOT from the gated feed