From c07202944debb87ab7d43abac6c469ab87c9e0ee Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 5 Nov 2025 18:52:40 +0100 Subject: [PATCH 1/4] Adjusted subscription cleanup to avoid mutating the watcher map while iterating it, preventing the ConcurrentModificationException when accounts switch --- .../follows/AccountFollowsLoaderSubAssembler.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 36fc9474c..87c91c143 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -186,11 +186,11 @@ class AccountFollowsLoaderSubAssembler( } // removes accounts that are not being subscribed anymore. - accountUpdatesJobMap.forEach { - if (it.key !in uniqueSubscribedAccounts.keys) { - endWatcher(it.key) - } - } + // Cancel watchers for accounts no longer observed using a snapshot to avoid CME + accountUpdatesJobMap.keys + .toList() + .filter { it !in uniqueSubscribedAccounts.keys } + .forEach { endWatcher(it) } } private val accountUpdatesJobMap = mutableMapOf() From a82d6565fa58ea7ad82d5964718b2c5af851a3cf Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 5 Nov 2025 18:55:58 +0100 Subject: [PATCH 2/4] Hardened EOSEAccountFast against concurrent access so callers no longer iterate over live mutable maps --- .../amethyst/service/relays/EOSE.kt | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index 07ebfdd11..dfa5b2db4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -125,36 +125,46 @@ class EOSEAccountFast( cacheSize: Int = 20, ) { private val users: LruCache = LruCache(cacheSize) + private val lock = Any() fun addOrUpdate( user: T, relayUrl: NormalizedRelayUrl, time: Long, ) { - val relayList = users[user] - if (relayList == null) { - val newList = EOSERelayList() - users.put(user, newList) + synchronized(lock) { + val relayList = users[user] + if (relayList == null) { + val newList = EOSERelayList() + users.put(user, newList) - newList.addOrUpdate(relayUrl, time) - } else { - relayList.addOrUpdate(relayUrl, time) + newList.addOrUpdate(relayUrl, time) + } else { + relayList.addOrUpdate(relayUrl, time) + } } } fun removeEveryoneBut(list: Set) { - users.snapshot().forEach { - if (it.key !in list) { - users.remove(it.key) + synchronized(lock) { + users.snapshot().forEach { + if (it.key !in list) { + users.remove(it.key) + } } } } fun removeDataFor(user: T) { - users.remove(user) + synchronized(lock) { + users.remove(user) + } } - fun since(key: T) = users[key]?.relayList + fun since(key: T): SincePerRelayMap? = + synchronized(lock) { + users[key]?.relayList?.toMutableMap() + } fun newEose( user: T, From 545cd2ff6df93a57ba6e6ea7a4600243657ab9ad Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 5 Nov 2025 19:02:53 +0100 Subject: [PATCH 3/4] synchronize all cache mutations and supply sinceRelaySet so callers get snapshot copies rework groupByRelayPresence to build relay snapshots via sinceRelaySet, filtering with immutable lists to prevent ConcurrentModificationException when relays update mid-iteration --- .../user/watchers/UserReportsSubAssembler.kt | 27 +++++++++++++------ .../amethyst/service/relays/EOSE.kt | 5 ++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt index 651077a71..157187c06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserReportsSubAssembler.kt @@ -90,19 +90,30 @@ class UserReportsSubAssembler( users: Iterable, eoseCache: EOSEAccountFast, inRelays: Set, - ): Collection> = - users - .groupBy { - eoseCache - .since(it) - ?.keys - ?.intersect(inRelays) - ?.hashCode() + ): Collection> { + if (users.none()) return emptyList() + + val relaySnapshot = inRelays.toSet() + + return users + .groupBy { user -> + val relaysForUser = eoseCache.sinceRelaySet(user) + if (relaysForUser.isNullOrEmpty() || relaySnapshot.isEmpty()) { + null + } else { + val intersection = relaysForUser.filter { it in relaySnapshot }.sorted() + if (intersection.isEmpty()) { + null + } else { + intersection.hashCode() + } + } }.values .map { // important to keep in order otherwise the Relay thinks the filter has changed and we REQ again it.sortedBy { it.pubkeyHex } } + } fun findMinimumEOSEsForUsers( users: List, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index dfa5b2db4..9229450da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -166,6 +166,11 @@ class EOSEAccountFast( users[key]?.relayList?.toMutableMap() } + fun sinceRelaySet(key: T): Set? = + synchronized(lock) { + users[key]?.relayList?.keys?.toSet() + } + fun newEose( user: T, relayUrl: NormalizedRelayUrl, From 409b3b43f71e5ac52751e3f3361d0fa8ce5c5e5d Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 5 Nov 2025 19:06:18 +0100 Subject: [PATCH 4/4] optimise imports --- .../amethyst/model/nip51Lists/peopleList/FollowListsState.kt | 1 - .../main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt | 1 - .../amethyst/ui/screen/loggedIn/lists/display/PeopleListView.kt | 1 - .../amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt | 1 - 4 files changed, 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt index 3e3f76bd3..788272c28 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt @@ -46,7 +46,6 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update -import kotlin.collections.map /** * Maintains several stateflows for each step in processing PeopleLists diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 1f053cb2f..3fc5d2d3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing -import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListView.kt index d8069b5a6..edb1092f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListView.kt @@ -43,7 +43,6 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserComposeNoAction import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.PeopleListItem import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.HalfHalfHorzModifier diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt index f97b712e0..dd6b32788 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Lock