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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HwZzCdNMQoRWbgtZrp4SKH
This commit is contained in:
Claude
2026-06-19 21:11:10 +00:00
parent a99e67d8be
commit fa7bb365c4
2 changed files with 16 additions and 2 deletions
@@ -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] },
{
@@ -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] },
{