test: pin Blossom notes across the consume window - LargeSoftCache evicts

Root cause of the recurring DesktopBlossomServerListTest CI failures,
finally caught by the diagnostic added last round (flow=[], getter=null,
no verification warning): DesktopLocalCache.addressableNotes holds notes
via SoftReference (LargeSoftCache). Under CI memory pressure a GC evicts
the consumed note between cache.consume() and the state's
getOrCreateAddressableNote(), which then mints a fresh EMPTY note - the
flow can never surface the servers. Production is immune because
BlossomServerListState pins blossomListNote as a field for its lifetime;
the tests just never held a strong reference across that window. All
three tests now pin the note before consuming, and the state test
asserts the event landed before construction so an eviction fails fast
at the source.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV
This commit is contained in:
Claude
2026-09-01 15:58:21 +00:00
parent 5b3eb140a6
commit 3f31c78242
@@ -63,9 +63,14 @@ class DesktopBlossomServerListTest {
val servers = listOf("https://blossom.example.com", "https://cdn.example.org")
val event = signedServerList(servers, signer)
// Pin the note across consume -> read: the cache holds notes via
// SoftReference (LargeSoftCache), so without a strong reference a GC
// between the two calls evicts the note and the re-fetch mints an
// empty one (seen on memory-tight CI runners).
val pinned = cache.getOrCreateAddressableNote(event.address())
cache.consume(event, relayUrl)
val stored = cache.getOrCreateAddressableNote(event.address()).event as? BlossomServersEvent
val stored = pinned.event as? BlossomServersEvent
assertNotNull(stored, "kind 10063 event must be stored in the addressable cache")
assertEquals(servers, stored.servers())
}
@@ -78,10 +83,11 @@ class DesktopBlossomServerListTest {
val newer = signedServerList(listOf("https://new.example.com"), signer, createdAt = 2_000)
val older = signedServerList(listOf("https://old.example.com"), signer, createdAt = 1_000)
val pinned = cache.getOrCreateAddressableNote(newer.address())
cache.consume(newer, relayUrl)
cache.consume(older, relayUrl)
val stored = cache.getOrCreateAddressableNote(newer.address()).event as? BlossomServersEvent
val stored = pinned.event as? BlossomServersEvent
assertEquals(listOf("https://new.example.com"), stored?.servers())
}
@@ -98,7 +104,16 @@ class DesktopBlossomServerListTest {
val signer = NostrSignerInternal(KeyPair())
val servers = listOf("https://blossom.example.com")
val event = signedServerList(servers, signer)
// Pin the note across the consume -> construct window: the cache holds
// notes via SoftReference (LargeSoftCache), and a GC under CI memory
// pressure evicted the consumed note before the state's
// getOrCreateAddressableNote re-fetched it, minting a fresh EMPTY note
// (observed as flow=[] / getter=null on the macOS runner). Production
// is immune the same way: BlossomServerListState pins blossomListNote
// as a field for its lifetime.
val pinned = cache.getOrCreateAddressableNote(event.address())
cache.consume(event, relayUrl)
assertNotNull(pinned.event, "consume must store the event before the state is built")
// Unconfined on purpose: the state's stateIn(Eagerly) collector then starts
// synchronously and resumes directly on the flowOn(IO) producer thread, so the