diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt index 1f31ec79f0..c650fb2ac5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPager.kt @@ -147,7 +147,16 @@ class UntilLimitPager { if (c.pageCount == 0) { c.done = true } else { - c.reachedUntil = c.pageOldest + // The reached cursor must move strictly older every page (the next page asks `until = + // reached - 1`). A relay that returns events but none older than we already have — a + // misbehaving relay echoing the same newest events — would otherwise pin the cursor and the + // on-screen sentinel would re-request the same window forever. Treat that as the bottom. + val prev = c.reachedUntil + if (prev == null || c.pageOldest < prev) { + c.reachedUntil = c.pageOldest + } else { + c.done = true + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt index 53a1d0410d..ff05a9b22d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt @@ -185,6 +185,9 @@ class AccountGiftWrapsHistoryEoseManager( } private fun updateStatus(user: User) { + // The display flows are singletons shown for the foreground account; a background account's late + // EOSE must not overwrite them (its cursors still advance in the pager). + if (activeUser != user.pubkeyHex) return val relays = accounts[user.pubkeyHex]?.dmRelays?.flow?.value ?: emptySet() _relayCount.value = loadTracker.count() val start = startUntil() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt index eeb934dfc8..02f2b6922c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt @@ -162,6 +162,9 @@ class ChatroomListNip04HistorySubAssembler( } private fun updateStatus(user: User) { + // The display flows are singletons shown for the foreground account; a background account's late + // EOSE must not overwrite them (its cursors still advance in the pager). + if (activeUser != user.pubkeyHex) return val account = accounts[user.pubkeyHex] val relays = account?.let { allRelays(it) } ?: emptySet() _relayCount.value = loadTracker.count() diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerTest.kt new file mode 100644 index 0000000000..ce8b4fb083 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/UntilLimitPagerTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.eoseManagers + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UntilLimitPagerTest { + private val key = "acct" + private val relayA = RelayUrlNormalizer.normalizeOrNull("wss://a.relay")!! + private val relayB = RelayUrlNormalizer.normalizeOrNull("wss://b.relay")!! + private val start = 1_000L + + @Test + fun unarmedRelayIsNotRequestedButCountsAsActive() { + val pager = UntilLimitPager() + assertFalse(pager.isArmed(key, relayA)) + assertEquals(emptyList(), pager.armedRelays(key, listOf(relayA))) + // not done, so still "active" (there is history to ask for once advanced) + assertEquals(listOf(relayA), pager.activeRelays(key, listOf(relayA))) + // marker sits at the floor until it delivers + assertEquals(start, pager.reachedUntilFor(key, relayA, start)) + } + + @Test + fun firstAdvanceRequestsTheFloorThenSubsequentPagesStepBelowReached() { + val pager = UntilLimitPager() + + assertTrue(pager.advance(key, relayA, start)) + assertTrue(pager.isArmed(key, relayA)) + assertEquals(start, pager.requestedUntilFor(key, relayA)) + + // page returns events; oldest seen = 800 + pager.onEvent(key, relayA, 900) + pager.onEvent(key, relayA, 800) + pager.onEose(key, relayA) + assertEquals(800L, pager.reachedUntilFor(key, relayA, start)) + // EOSE does NOT move the requested cursor — the relay parks at the same filter + assertEquals(start, pager.requestedUntilFor(key, relayA)) + + // next advance steps to reached - 1 + assertTrue(pager.advance(key, relayA, start)) + assertEquals(799L, pager.requestedUntilFor(key, relayA)) + } + + @Test + fun emptyPageMarksRelayDoneAndBlocksFurtherAdvance() { + val pager = UntilLimitPager() + pager.advance(key, relayA, start) + pager.onEose(key, relayA) // no events + assertTrue(pager.isDone(key, relayA)) + assertFalse(pager.advance(key, relayA, start)) + assertEquals(emptyList(), pager.activeRelays(key, listOf(relayA))) + assertEquals(emptyList(), pager.armedRelays(key, listOf(relayA))) + } + + @Test + fun aPageThatDoesNotStepOlderEndsTheRelayInsteadOfLooping() { + val pager = UntilLimitPager() + pager.advance(key, relayA, start) + pager.onEvent(key, relayA, 800) + pager.onEose(key, relayA) + assertEquals(800L, pager.reachedUntilFor(key, relayA, start)) + + // misbehaving relay: next page echoes an event no older than what we already reached + pager.advance(key, relayA, start) // requested = 799 + pager.onEvent(key, relayA, 900) // newer than reached(800) — not strictly older + pager.onEose(key, relayA) + assertTrue("a non-advancing page should end the relay, not re-loop", pager.isDone(key, relayA)) + assertEquals(800L, pager.reachedUntilFor(key, relayA, start)) + } + + @Test + fun relaysAreTrackedIndependently() { + val pager = UntilLimitPager() + pager.advance(key, relayA, start) + pager.onEvent(key, relayA, 500) + pager.onEose(key, relayA) + // B never advanced + assertEquals(listOf(relayA), pager.armedRelays(key, listOf(relayA, relayB))) + assertEquals(500L, pager.reachedUntilFor(key, relayA, start)) + assertEquals(start, pager.reachedUntilFor(key, relayB, start)) + // deepest reached across both = A's 500 (B counts as the floor) + assertEquals(500L, pager.deepestReached(key, listOf(relayA, relayB), start)) + } + + @Test + fun deepestReachedIsNullWhenNoRelays() { + val pager = UntilLimitPager() + assertEquals(null, pager.deepestReached(key, emptyList(), start)) + } +}