fix(quartz): snapshot LargeCache entries in forEach to prevent ConcurrentModificationException

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.
This commit is contained in:
M
2026-04-10 20:53:19 +10:00
parent 3294835b77
commit ed11d2cd45
2 changed files with 6 additions and 2 deletions
@@ -85,7 +85,10 @@ actual class LargeCache<K, V> : ICacheOperations<K, V> {
actual override fun size(): Int = concurrentMap.size
actual override fun forEach(consumer: ICacheBiConsumer<K, V>) {
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<K, V>): List<V> =
@@ -88,7 +88,8 @@ actual class LargeCache<K, V> : ICacheOperations<K, V> {
actual override fun size(): Int = withMap { it.size }
actual override fun forEach(consumer: ICacheBiConsumer<K, V>) {
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<K, V>): List<V> = withMap { map -> map.filter { consumer.filter(it.key, it.value) }.values.toList() }