From fa7bb365c43f8ed3bd4629831687ffd4c1cf2af3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 21:11:10 +0000 Subject: [PATCH] fix: snapshot live-stream status order before sorting to avoid TimSort contract violation LiveStreamsFeedFilter.sort and DiscoverLiveFeedFilter.sort computed the primary sort key, convertStatusToOrder(it.event), lazily inside the comparator. That key reads OnlineChecker.isCachedAndOffline(url), which depends on a moving five-minute window and on the checkOnlineCache LruCache. A background online check can mutate that cache while the sort is running, so the same note could compare as LIVE (order 2) in one pairwise comparison and offline (order 0) in another. The resulting unstable ordering makes TimSort throw "Comparison method violates its general contract!". Snapshot the status order once per item before sorting (matching how participantCounts/allParticipants are already precomputed) so the comparator reads stable values. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HwZzCdNMQoRWbgtZrp4SKH --- .../nip53LiveActivities/DiscoverLiveFeedFilter.kt | 9 ++++++++- .../loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt index 500a6cddd5..b77ddd849f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt @@ -106,10 +106,17 @@ open class DiscoverLiveFeedFilter( val allParticipants = items.associate { it to counter.countFollowsThatParticipateOn(it, null) } + // Snapshots the status order once per item. convertStatusToOrder reads the + // OnlineChecker cache and the moving five-minute window, both of which can change + // mid-sort (a background online check can update the cache). Reading it lazily inside + // the comparator makes the ordering unstable and TimSort throws + // "Comparison method violates its general contract!". + val statusOrders = items.associateWith { convertStatusToOrder(it.event) } + return items .sortedWith( compareBy( - { convertStatusToOrder(it.event) }, + { statusOrders[it] }, { participantCounts[it] }, { allParticipants[it] }, { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt index 12608632b1..c8ed90f0bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/livestreams/dal/LiveStreamsFeedFilter.kt @@ -106,10 +106,17 @@ class LiveStreamsFeedFilter( val allParticipants = items.associate { it to counter.countFollowsThatParticipateOn(it, null) } + // Snapshots the status order once per item. convertStatusToOrder reads the + // OnlineChecker cache and the moving five-minute window, both of which can change + // mid-sort (a background online check can update the cache). Reading it lazily inside + // the comparator makes the ordering unstable and TimSort throws + // "Comparison method violates its general contract!". + val statusOrders = items.associateWith { convertStatusToOrder(it.event) } + return items .sortedWith( compareBy( - { convertStatusToOrder(it.event) }, + { statusOrders[it] }, { participantCounts[it] }, { allParticipants[it] }, {