fix: tie rumor host lifetime to the Note — replace the RumorHosts index

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
This commit is contained in:
Claude
2026-06-11 23:35:25 +00:00
parent 3e9fce1858
commit 96eaefda9b
7 changed files with 43 additions and 99 deletions
@@ -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.
@@ -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<Note> {
val noteEvent = note.event ?: return emptyList()
val host = RumorHosts.of(noteEvent) ?: return emptyList()
val host = note.rumorHost ?: return emptyList()
val children = mutableListOf<Note>()
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
}
@@ -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) }
@@ -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
@@ -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<Note>? = 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<NotesGatherer>? = null
fun inGatherers() = inGatherers ?: listOf<NotesGatherer>().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,
@@ -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<HexKey, HostStub>()
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()
}
@@ -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,