From ed11d2cd45c0813d71ff6849f79fe688a2791dc8 Mon Sep 17 00:00:00 2001 From: M Date: Fri, 10 Apr 2026 20:53:19 +1000 Subject: [PATCH] fix(quartz): snapshot LargeCache entries in forEach to prevent ConcurrentModificationException MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Apple (iOS/macOS) and Linux targets, LargeCache.forEach() iterates the underlying map directly. When another coroutine modifies the map during iteration (e.g., NostrClient.syncFilters running while subscriptions are added), a ConcurrentModificationException is thrown. On JVM/Android this is not an issue because ConcurrentSkipListMap handles concurrent iteration safely. On Kotlin/Native (iOS), this exception is fatal — K/N calls abort() for unhandled exceptions, crashing the app immediately after account creation when relays connect and subscriptions start syncing. Fix: call .entries.toList() before iterating to create a snapshot, matching the JVM behavior where concurrent modifications during iteration are tolerated. --- .../com/vitorpamplona/quartz/utils/cache/LargeCache.apple.kt | 5 ++++- .../com/vitorpamplona/quartz/utils/cache/LargeCache.linux.kt | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.apple.kt b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.apple.kt index 9d29a6e2b..c64e575b3 100644 --- a/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.apple.kt +++ b/quartz/src/appleMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.apple.kt @@ -85,7 +85,10 @@ actual class LargeCache : ICacheOperations { actual override fun size(): Int = concurrentMap.size actual override fun forEach(consumer: ICacheBiConsumer) { - concurrentMap.forEach { consumer.accept(it.key, it.value) } + // Take a snapshot of entries to avoid ConcurrentModificationException + // when the map is modified during iteration (e.g., NostrClient.syncFilters + // iterating while subscriptions are added from another coroutine). + concurrentMap.entries.toList().forEach { consumer.accept(it.key, it.value) } } actual override fun filter(consumer: CacheCollectors.BiFilter): List = diff --git a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.linux.kt b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.linux.kt index 74861078f..2e49f22a5 100644 --- a/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.linux.kt +++ b/quartz/src/linuxMain/kotlin/com/vitorpamplona/quartz/utils/cache/LargeCache.linux.kt @@ -88,7 +88,8 @@ actual class LargeCache : ICacheOperations { actual override fun size(): Int = withMap { it.size } actual override fun forEach(consumer: ICacheBiConsumer) { - withMap { map -> map.forEach { consumer.accept(it.key, it.value) } } + // Snapshot entries to avoid ConcurrentModificationException + withMap { map -> map.entries.toList() }.forEach { consumer.accept(it.key, it.value) } } actual override fun filter(consumer: CacheCollectors.BiFilter): List = withMap { map -> map.filter { consumer.filter(it.key, it.value) }.values.toList() }