From 96eaefda9b33444f3d3d426e5aa280777d3342a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 23:35:25 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20tie=20rumor=20host=20lifetime=20to=20the?= =?UTF-8?q?=20Note=20=E2=80=94=20replace=20the=20RumorHosts=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A memory audit found the global strong-reference RumorHosts index could never be kept in sync with LocalCache.notes, which holds WeakReferences: seven prune paths (pruneExpiredEvents — rumors inherit the seal's expiration tag — hidden/old-message/replaceable/reaction/hidden-event prunes, and cleanMemory) plus silent GC eviction dropped rumor notes without clearing their entries, clear() had no callers (logout, account removal, memory trim), and orphaned stubs accumulated unbounded. The stub now lives on the Note (Note.rumorHost): whatever removes or garbage-collects the note frees the stub, closing every leak path by construction. Cost is one nullable reference per Note (~200-400 KB at a 50k-note steady state) versus the index's per-entry map overhead plus unbounded orphan growth. All consumers already held the Note: toNEvent, Account.broadcast, deleteEnvelopes, removeIfWrap, chat pruning, and the ingestion pipeline. RumorHosts is deleted. Also fixes the desktop regression the audit surfaced: the desktop gift-wrap handler now records the wrap on the rumor note, so desktop nevent citations of chat messages point at the wrap id again instead of exposing the private rumor id. https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo --- .../vitorpamplona/amethyst/model/Account.kt | 10 +-- .../amethyst/model/LocalCache.kt | 24 ++++--- .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../loggedIn/DecryptAndIndexProcessor.kt | 15 ++--- .../amethyst/commons/model/Note.kt | 22 ++++++- .../commons/model/nip59Giftwrap/RumorHosts.kt | 66 ------------------- .../vitorpamplona/amethyst/desktop/Main.kt | 3 + 7 files changed, 43 insertions(+), 99 deletions(-) delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip59Giftwrap/RumorHosts.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 267be80d8f..321c334aac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagL import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction -import com.vitorpamplona.amethyst.commons.model.nip59Giftwrap.RumorHosts import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult @@ -224,7 +223,6 @@ import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.HostStub import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent @@ -1331,15 +1329,9 @@ class Account( } } - /** - * The envelope (kind-1059 gift wrap, or bare kind-13 seal) that - * delivered [event], when [event] is a rumor. - */ - fun rumorHost(event: Event): HostStub? = RumorHosts.of(event) - suspend fun broadcast(note: Note) { note.event?.let { noteEvent -> - val host = rumorHost(noteEvent) + val host = note.rumorHost if (host != null) { // Rumors are rebroadcast as their delivering wrap: // download the wrap and send it. 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 e556b94fa9..991162980e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -33,7 +33,6 @@ import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.commons.model.nip59Giftwrap.RumorHosts import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtIdHexComparator import com.vitorpamplona.amethyst.commons.model.observables.EventListMatchingFilter import com.vitorpamplona.amethyst.commons.model.observables.NewEventMatchingFilter @@ -1383,7 +1382,7 @@ object LocalCache : ILocalCache, ICacheProvider { * Rumors additionally drop the envelope notes that delivered them. */ private fun deleteNote(deleteNote: Note) { - deleteNote.event?.let { deleteEnvelopes(it) } + deleteEnvelopes(deleteNote) deleteNote.detachFromChildren() @@ -1391,13 +1390,13 @@ object LocalCache : ILocalCache, ICacheProvider { } /** - * Removes the envelope notes that delivered [rumor]: the indexed host - * (normally the kind-1059 wrap; a bare kind-13 seal otherwise) and, - * when the host is a wrap, the seal layer it carried. Public events - * have no envelopes and are ignored. + * Removes the envelope notes that delivered [rumorNote]'s rumor: its + * host (normally the kind-1059 wrap; a bare kind-13 seal otherwise) + * and, when the host is a wrap, the seal layer it carried. Public + * events have no envelopes and are ignored. */ - fun deleteEnvelopes(rumor: Event) { - val host = RumorHosts.of(rumor) ?: return + fun deleteEnvelopes(rumorNote: Note) { + val host = rumorNote.rumorHost ?: return getNoteIfExists(host.id)?.let { hostNote -> (hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId -> @@ -1412,7 +1411,7 @@ object LocalCache : ILocalCache, ICacheProvider { } notes.remove(host.id) - RumorHosts.remove(rumor.id) + rumorNote.rumorHost = null } fun consume( @@ -2710,7 +2709,7 @@ object LocalCache : ILocalCache, ICacheProvider { when (val ev = note.event) { is BaseDMGroupEvent -> if (giftWrapFloor != null) { - val outerUntil = RumorHosts.get(ev.id)?.createdAt ?: ev.createdAt + val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) } } is PrivateDmEvent -> { @@ -2753,8 +2752,7 @@ object LocalCache : ILocalCache, ICacheProvider { } fun removeIfWrap(note: Note): List { - val noteEvent = note.event ?: return emptyList() - val host = RumorHosts.of(noteEvent) ?: return emptyList() + val host = note.rumorHost ?: return emptyList() val children = mutableListOf() getNoteIfExists(host.id)?.let { hostNote -> @@ -2767,7 +2765,7 @@ object LocalCache : ILocalCache, ICacheProvider { unlinkAndRemove(hostNote) children.addAll(hostNote.clearChildLinks()) } - RumorHosts.remove(noteEvent.id) + note.rumorHost = null return children } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 5e32335ddf..2ef48b7b55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1138,7 +1138,7 @@ class AccountViewModel( */ fun canBroadcast(note: Note): Boolean { val event = note.event ?: return false - return event.sig.isNotEmpty() || account.rumorHost(event) != null + return event.sig.isNotEmpty() || note.rumorHost != null } fun timestamp(note: Note) = launchSigner { account.otsState.timestamp(note) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 99194dfd1a..e05a9627a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.model.nip59Giftwrap.RumorHosts import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager import com.vitorpamplona.amethyst.model.Account @@ -417,7 +416,7 @@ class SealedRumorEventHandler( // broadcast can republish the wrap after a cache rebuild. // publicNote is the outermost event of this unwrap chain — the // kind-1059 wrap normally, the seal itself when it arrived bare. - publicNote.event?.let { envelope -> RumorHosts.put(rumorId, envelope) } + publicNote.event?.let { envelope -> cache.getOrCreateNote(rumorId).recordRumorHost(envelope) } processExistingSealedRumor(rumorId, publicNote) } } @@ -443,17 +442,17 @@ class SealedRumorEventHandler( eventNote.event = event.copyNoContent() - // Remember which envelope delivered this rumor (publicNote is the - // kind-1059 wrap normally, the seal itself when it arrived bare). - // Consumers cite/broadcast/prune/evict through this index — the - // unsigned rumor itself must never be referenced publicly. - publicNote.event?.let { envelope -> RumorHosts.put(innerRumor.id, envelope) } - cache.justConsume(innerRumor, null, true) cache.copyRelaysFromTo(publicNote, innerRumor) val innerRumorNote = cache.getOrCreateNote(innerRumor.id) + // Remember which envelope delivered this rumor (publicNote is the + // kind-1059 wrap normally, the seal itself when it arrived bare). + // Consumers cite/broadcast/prune/evict through this stub — the + // unsigned rumor itself must never be referenced publicly. + publicNote.event?.let { envelope -> innerRumorNote.recordRumorHost(envelope) } + // Marmot Welcome: GiftWrap → Seal → WelcomeEvent. The Seal handler // is the actual point at which we see the kind:444 inner. Route it // to the MLS flow for group joining in addition to caching — there's diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 1af10fbae6..3f0de80179 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.commons.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.model.nip59Giftwrap.RumorHosts import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread import com.vitorpamplona.amethyst.commons.util.KmpLock @@ -62,6 +61,7 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip59Giftwrap.HostStub import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.BigDecimal @@ -120,6 +120,24 @@ open class Note( var author: User? = null var replyTo: List? = null + /** + * The envelope that delivered this note when [event] is an unsealed + * rumor: normally the kind-1059 gift wrap, a bare kind-13 seal when + * one arrives unwrapped. Null for public events. + * + * Rumors are unsigned and must never be referenced or republished + * directly on public relays — consumers cite, broadcast, prune, and + * evict through this stub instead. Living on the Note (not on a + * global index, not on the quartz event) ties its lifetime to the + * note: whatever removes or garbage-collects the note frees the stub. + */ + var rumorHost: HostStub? = null + + /** Records the envelope that delivered this rumor. */ + fun recordRumorHost(envelope: Event) { + rumorHost = HostStub(envelope.id, envelope.pubKey, envelope.kind, envelope.createdAt) + } + var inGatherers: List? = null fun inGatherers() = inGatherers ?: listOf().also { inGatherers = it } @@ -240,7 +258,7 @@ open class Note( // Rumors are cited by the envelope that delivered them: the rumor id // resolves to nothing on public relays and exposing it would leak the // private event's identity. - val host = event?.let { RumorHosts.of(it) } + val host = rumorHost return if (host != null) { NEvent.create( host.id, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip59Giftwrap/RumorHosts.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip59Giftwrap/RumorHosts.kt deleted file mode 100644 index e648462f02..0000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip59Giftwrap/RumorHosts.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * 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.commons.model.nip59Giftwrap - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip59Giftwrap.HostStub -import com.vitorpamplona.quartz.utils.cache.LargeCache - -/** - * Rumor id → the envelope that delivered it (normally the kind-1059 gift - * wrap; a bare kind-13 seal when one arrives unwrapped). - * - * Unsealed rumors are unsigned and must never be referenced or republished - * directly on public relays. Consumers use this index to act on the - * delivering envelope instead: broadcast republishes the wrap, nevent - * citations point at the wrap id, chat pruning pages by the outer wrap - * time, and cache eviction removes the envelope notes alongside the rumor. - * - * Delivery metadata is deliberately kept OUT of the quartz event classes — - * any event kind can be a rumor without subclassing anything. Populated by - * each front end's gift-wrap ingestion pipeline. - */ -object RumorHosts { - private val index = LargeCache() - - fun put( - rumorId: HexKey, - host: HostStub, - ) = index.put(rumorId, host) - - /** Records [envelope] as the delivering event of [rumorId]. */ - fun put( - rumorId: HexKey, - envelope: Event, - ) = index.put(rumorId, HostStub(envelope.id, envelope.pubKey, envelope.kind, envelope.createdAt)) - - fun get(rumorId: HexKey): HostStub? = index.get(rumorId) - - /** The delivering envelope of [event], when [event] is a rumor. */ - fun of(event: Event): HostStub? = if (event.sig.isEmpty()) index.get(event.id) else null - - fun remove(rumorId: HexKey) { - index.remove(rumorId) - } - - fun clear() = index.clear() -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 2805abe27b..c178077a5b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1364,6 +1364,9 @@ fun MainContent( if (innerNote.event == null) { innerNote.loadEvent(innerEvent, innerAuthor, emptyList()) } + // Rumors are unsigned: citing or rebroadcasting them must + // go through the wrap that delivered them. + innerNote.recordRumorHost(event) iAccount.chatroomList.addMessage( innerEvent.chatroomKey(iAccount.pubKey), innerNote,