mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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) }
|
||||
|
||||
+7
-8
@@ -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,
|
||||
|
||||
-66
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user