From 3f31c782429e7f3307627ac07a3d2f6bd64f481b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 15:58:21 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_011S1vbFWVVAMFDT8PTgdibV --- .../cache/DesktopBlossomServerListTest.kt | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt index b309e6befd..5816e4e085 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopBlossomServerListTest.kt @@ -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