Merge #6e30bc49: fix(profile-subscriptions): synchronize account map it…

fix(profile-subscriptions): synchronize account map iteration against concurrent removal

nostr:nevent1qqsxuv9uf8nvwg5cdfln3r82g6jjqx47kfvpgz7yxcstj4sxf5d3jvqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcql9j3h

PR-Author: greenart7c3
nostr:npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5

PR description:

Fixes the NoSuchElementException reported in nevent1qqszh6nmwv9nl6wt6zg8qm8azhqlhk76hemgn5us0pg4cx7twmayv6cgn4dga

updateFilters() snapshotted the accounts map (accounts.values.toList()) while closeSub() and the EOSE path concurrently removed entries. A weakly-consistent ConcurrentHashMap iterator can run off the end when the map shrinks mid-iteration, throwing NoSuchElementException out of updateFilters().

Fix: iteration and mutation of the accounts map are now mutually exclusive 342200224 updateFilters() copies the value list under synchronized(accounts) and iterates the snapshot outside the lock; all mutation sites (put in updateFilter, remove in closeSub(account), clear in closeSub()) take the same monitor. Single-key lookups stay lock-free.

Verified: ktlintCheck, lint, full test suite (all variants) green; ProfileSubscriptionTest concurrency tests 10/10 clean repeated runs.
This commit is contained in:
greenart7c3
2026-08-21 06:40:01 -03:00
@@ -68,6 +68,10 @@ class ProfileSubscription(
// hexKey -> the cached Account instance a live composable cares about. Updating the // hexKey -> the cached Account instance a live composable cares about. Updating the
// StateFlows on these instances is what reflects fresh metadata in the UI. // StateFlows on these instances is what reflects fresh metadata in the UI.
// Iteration (updateFilters) is wrapped in synchronized(this map) and every mutation
// (put/remove/clear) takes the same monitor, so the weakly-consistent CHM iterator
// can never run off the end and throw NoSuchElementException when the map shrinks
// mid-iteration. Single-key reads stay lock-free.
private val accounts = ConcurrentHashMap<String, Account>() private val accounts = ConcurrentHashMap<String, Account>()
init { init {
@@ -148,7 +152,7 @@ class ProfileSubscription(
val interval = Amber.instance.settings.profileFetchInterval val interval = Amber.instance.settings.profileFetchInterval
if (interval == ProfileFetchInterval.NEVER) return if (interval == ProfileFetchInterval.NEVER) return
accounts[account.hexKey] = account synchronized(accounts) { accounts[account.hexKey] = account }
val shouldFetch = if (interval == ProfileFetchInterval.ALWAYS) { val shouldFetch = if (interval == ProfileFetchInterval.ALWAYS) {
true true
@@ -228,14 +232,15 @@ class ProfileSubscription(
* Re-runs the fetch for every currently tracked account. Call when the relay list changes. * Re-runs the fetch for every currently tracked account. Call when the relay list changes.
*/ */
suspend fun updateFilters() { suspend fun updateFilters() {
accounts.values.toList().forEach { updateFilter(it) } val snapshot = synchronized(accounts) { accounts.values.toList() }
snapshot.forEach { updateFilter(it) }
} }
/** /**
* Stops updates for a single [account] (call when the composable leaves composition). * Stops updates for a single [account] (call when the composable leaves composition).
*/ */
fun closeSub(account: Account) { fun closeSub(account: Account) {
accounts.remove(account.hexKey) synchronized(accounts) { accounts.remove(account.hexKey) }
relayListSubIds.remove(account.hexKey)?.let { unsubscribe(it) } relayListSubIds.remove(account.hexKey)?.let { unsubscribe(it) }
subIds.remove(account.hexKey)?.let { unsubscribe(it) } subIds.remove(account.hexKey)?.let { unsubscribe(it) }
} }
@@ -252,7 +257,7 @@ class ProfileSubscription(
relaysPerSubId.clear() relaysPerSubId.clear()
subIds.clear() subIds.clear()
relayListSubIds.clear() relayListSubIds.clear()
accounts.clear() synchronized(accounts) { accounts.clear() }
} }
/** Default profile relays from the settings plus the user's own saved relay list. */ /** Default profile relays from the settings plus the user's own saved relay list. */