test: cover the long-running sync, and open the seams needed to do it

The six commits that built the live chat sync added no tests. Everything they
touch fails silently by nature — a filter that drops messages, a subscription
that stops being replayed, a group whose id never reaches the `#h` tag — so the
symptom is always "some messages didn't arrive", days later, on someone else's
phone. 46 tests, in four files.

**What is covered**

RelayPoolSubscriptionTest (13) — the pool's half of surviving a dropped socket.
A query is retained and replayed on reconnect; a closed one is forgotten and
stops the socket reconnecting for it; closing one of two leaves the other alone;
a negentropy exchange is never replayed (its rounds are stateful, so resuming
one reconciles against a conversation the relay is no longer having); an update
to a live subscription replaces what gets replayed, including when the send
itself fails; dropping a relay or closing the pool forgets what they carried;
replay is scoped to the relay that reconnected. Plus the semantic the whole
change rests on, asserted in both directions: a live subscription keeps
delivering after EOSE, a one-shot query still ends at it.

LiveSubscriptionReconcileTest (12) — the requirement this all exists for: the
group filter follows group membership with nobody calling a subscribe function.
Joining widens the filter *in place* rather than reopening (a reopen would drop
the live tail of every other group in that chunk); leaving drops one; leaving
everything closes the subscription; churn inside the debounce window collapses
to one update; a NIP-17 room never becomes a group subscription. Then the
collect loop: events stored against the relay they came from, an event after
EOSE still stored, a CLOSED reopened once the back-off elapses and not before,
and a rate-limited CLOSED waiting far longer — but still coming back.
Backgrounding closes and foregrounding rebuilds, reconnects, and queues the
catch-up.

LiveSubscriptionPlanTest (11) — the filter and planning rules, led by the one
most likely to be "tidied up" later: the gift wrap filter carries no `since`,
because NIP-59 randomizes created_at into the past and a `since` near the
present silently drops new messages.

RelayBackPressureTest (4) and ReconnectBackoffTest (6) — the two pure decisions.
Which CLOSED reasons mean "ease off", and the backoff arithmetic including the
exponent clamp: 2.0.pow(4000) is Infinity and Duration * Double throws on it, so
without it a socket failing long enough turned its reconnect loop into a crash
loop, at the point the network was least likely to recover unaided.

**Seams opened to get there**, each a readability win on its own terms:

  - NostrSocketClientFactory becomes an interface with DefaultNostrSocketClientFactory
    behind it, so the pool can be driven by a fake socket.
  - RelayPool takes its CoroutineScope, so the replay a reconnect triggers can be
    observed rather than raced.
  - LiveSubscriptionManager depends on a new LiveSubscriptionTransport (4
    methods) rather than RelaysSocketManager, which observes the active wallet in
    its init and cannot be stood up in a test at all.
  - Its pure planning helpers move to the companion as `internal`, and its
    launches inherit the caller's dispatcher instead of pinning Dispatchers.IO.
    SynchronizationViewModel already launches observe() on IO, so nothing moves —
    but a coroutine that picks its own dispatcher cannot be driven by a test
    scheduler.
  - reconnectDelay is extracted to ReconnectBackoff.kt with jitter as a
    parameter, so the arithmetic can be pinned without randomness.
  - endsLiveSubscription names the live-subscription termination rule next to
    isTerminalFor, which is the one-shot rule. Having both named makes the
    difference between them reviewable rather than implicit.

kotlinx-coroutines-test is added to commonTest: the pool's bookkeeping is all
suspend functions and there is no runBlocking in a common source set.

The tests were checked by mutation, not just by passing — reintroducing a
`since`, making EOSE terminal, dropping the leftGroupAt filter and removing
retention from query() each produce failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-05 23:31:11 +02:00
parent c8c962e4f4
commit f5eb744ca7
16 changed files with 1333 additions and 108 deletions

View File

@@ -0,0 +1,175 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GIFT_WRAP_SUB_ID
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GROUP_SUB_ID_PREFIX
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.giftWrapFilter
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupChunks
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupFilter
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupIdsFrom
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupSubId
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.subscriptionIndex
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.subscriptionKey
import press.mantra.compose.network.sockets.NostrIncomingMessage
import press.mantra.compose.network.sockets.endsLiveSubscription
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
class LiveSubscriptionPlanTest {
private val me = "a".repeat(64)
// --- filters ----------------------------------------------------------------------
/**
* The one that will look like an oversight to whoever reads it next.
*
* NIP-59 randomizes a gift wrap's `created_at` into the past, and our own outbound path
* stamps them with `TimeUtils.randomWithTwoDays()` — so a wrap published this second can
* carry a timestamp two days old. A `since` anywhere near the present silently drops a
* large share of genuinely new messages, and the symptom is "some DMs just never arrive":
* no error, no log, nothing to grep for.
*/
@Test
fun `the gift wrap filter carries no since`() {
assertNull(giftWrapFilter(me).since)
}
@Test
fun `the gift wrap filter asks for wraps addressed to us`() {
val filter = giftWrapFilter(me)
assertEquals(listOf(GiftWrapEvent.KIND), filter.kinds)
assertEquals(mapOf("p" to listOf(me)), filter.tags)
assertEquals(100, filter.limit)
}
/**
* `limit` is not a cap on the subscription — NIP-01 scopes it to the stored events a relay
* sends before EOSE, explicitly not to the stream after it. It bounds what a reconnect
* costs; it does not bound what arrives live.
*/
@Test
fun `the group filter asks for the given groups, and also carries no since`() {
val filter = groupFilter(listOf("group-a", "group-b"))
assertEquals(listOf(GroupEvent.KIND), filter.kinds)
assertEquals(mapOf("h" to listOf("group-a", "group-b")), filter.tags)
assertEquals(500, filter.limit)
assertNull(filter.since)
}
// --- which groups are watched -----------------------------------------------------
@Test
fun `only MLS rooms are watched`() {
val rooms = listOf(
room(id = "mls", mlsGroupState = "state"),
// A NIP-17 room has no group state; its messages arrive as gift wraps instead, so
// h-tag subscribing to it would ask for events that do not exist.
room(id = "nip17", mlsGroupState = null),
)
assertEquals(listOf("mls"), groupIdsFrom(rooms))
}
/**
* A room we left keeps its history locally and must stop pulling new messages. Getting
* this wrong is not merely wasteful: it means still receiving from a group we are no
* longer a member of.
*/
@Test
fun `rooms we have left or deleted are not watched`() {
val rooms = listOf(
room(id = "here", mlsGroupState = "state"),
room(id = "left", mlsGroupState = "state", leftGroupAt = Instant.fromEpochSeconds(10)),
room(id = "gone", mlsGroupState = "state", deletedAt = Instant.fromEpochSeconds(10)),
)
assertEquals(listOf("here"), groupIdsFrom(rooms))
}
/**
* Sorted, so the same membership in a different row order is the same value and the
* `distinctUntilChanged` upstream of the reconcile does not re-send a filter that has not
* actually changed.
*/
@Test
fun `group ids come out sorted`() {
val rooms = listOf("c", "a", "b").map { room(id = it, mlsGroupState = "state") }
assertEquals(listOf("a", "b", "c"), groupIdsFrom(rooms))
}
// --- chunking and subscription ids -------------------------------------------------
@Test
fun `groups are chunked to bound the size of one filter's tag array`() {
val chunks = groupChunks((1..250).map { "group-$it" })
assertEquals(3, chunks.size)
assertEquals(listOf(100, 100, 50), chunks.map { it.size })
}
@Test
fun `no groups means no subscriptions`() {
assertTrue(groupChunks(emptyList()).isEmpty())
}
@Test
fun `a subscription key round-trips back to its chunk index`() {
val key = subscriptionKey(relayUrl = "wss://relay.example.com", subId = groupSubId(3))
assertEquals("wss://relay.example.com|${GROUP_SUB_ID_PREFIX}3", key)
assertEquals(3, subscriptionIndex(key))
}
/**
* `subscriptionIndex` decides which subscriptions get closed when membership shrinks —
* anything at or past the new chunk count goes. Unparseable therefore has to read as "past
* the end", so a key nobody recognises is closed rather than kept open forever.
*/
@Test
fun `an unrecognisable key sorts past the end so it gets closed`() {
assertEquals(Int.MAX_VALUE, subscriptionIndex("wss://relay.example.com|$GIFT_WRAP_SUB_ID"))
assertEquals(Int.MAX_VALUE, subscriptionIndex("nonsense"))
}
// --- what ends a live subscription -------------------------------------------------
@Test
fun `EOSE does not end a live subscription, CLOSED does`() {
assertFalse(NostrIncomingMessage.EoseMessage(subscriptionId = "s").endsLiveSubscription())
assertFalse(NostrIncomingMessage.EventMessage(subscriptionId = "s").endsLiveSubscription())
assertFalse(NostrIncomingMessage.NoticeMessage(message = "hi").endsLiveSubscription())
assertTrue(
NostrIncomingMessage.ClosedMessage(subscriptionId = "s", message = "rate-limited")
.endsLiveSubscription()
)
}
private fun room(
id: String,
mlsGroupState: String?,
leftGroupAt: Instant? = null,
deletedAt: Instant? = null,
) = LocalChatRoom(
chatRoom = ChatRoom(
id = id,
userPublicKey = me,
subject = null,
description = null,
mlsGroupState = mlsGroupState,
leftGroupAt = leftGroupAt,
deletedAt = deletedAt,
)
)
}

View File

@@ -0,0 +1,428 @@
package press.mantra.compose.managers
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import press.mantra.compose.database.model.ChatRoom
import press.mantra.compose.database.model.NegentropySynchronizeRequest
import press.mantra.compose.database.model.NostrEvent
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.GIFT_WRAP_SUB_ID
import press.mantra.compose.managers.LiveSubscriptionManager.Companion.groupSubId
import press.mantra.compose.network.relays.LiveSubscriptionTransport
import press.mantra.compose.network.sockets.NostrIncomingMessage
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.NostrRepository
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* The requirement this whole change exists to meet: the group subscription has to follow group
* membership, without anyone remembering to call a subscribe function when a group is joined.
*
* The failure this guards against is entirely silent. A group whose id never makes it into the
* `#h` filter is not an error anywhere — it is a conversation that simply never delivers, on a
* screen that looks exactly like an empty one.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class LiveSubscriptionReconcileTest {
private val keyPair = KeyPair()
private val me = keyPair.pubKey.toHexString()
@Test
fun `opens a group subscription for the groups we are in`() = runTest {
val transport = FakeTransport()
val rooms = MutableStateFlow(listOf(room("group-a"), room("group-b")))
val running = start(transport, rooms)
settle()
val opened = transport.opened.single { it.reqCommand.subId == groupSubId(0) }
assertEquals(
mapOf("h" to listOf("group-a", "group-b")),
opened.reqCommand.filters.single().tags,
)
running.cancelAndJoin()
}
/**
* The loop that closes: a Welcome arrives on the gift wrap subscription, a ChatRoom row is
* written, the room list re-emits, and the filter widens — with no chat screen involved.
*
* It has to widen *in place*. Closing and re-opening would drop the live tail of every
* group already in that chunk for as long as the round trip takes, so joining one group
* would briefly stop delivery on all the others.
*/
@Test
fun `joining a group widens the filter without reopening the subscription`() = runTest {
val transport = FakeTransport()
val rooms = MutableStateFlow(listOf(room("group-a")))
val running = start(transport, rooms)
settle()
val opensBefore = transport.opened.count { it.reqCommand.subId == groupSubId(0) }
rooms.value = listOf(room("group-a"), room("group-new"))
settle()
val updated = transport.updated.last { it.reqCommand.subId == groupSubId(0) }
assertEquals(
mapOf("h" to listOf("group-a", "group-new")),
updated.reqCommand.filters.single().tags,
)
assertEquals(
opensBefore,
transport.opened.count { it.reqCommand.subId == groupSubId(0) },
"widening must not re-open the subscription",
)
running.cancelAndJoin()
}
@Test
fun `leaving a group drops it from the filter`() = runTest {
val transport = FakeTransport()
val rooms = MutableStateFlow(listOf(room("group-a"), room("group-b")))
val running = start(transport, rooms)
settle()
rooms.value = listOf(room("group-a"), room("group-b", leftGroupAt = Instant.fromEpochSeconds(1)))
settle()
val updated = transport.updated.last { it.reqCommand.subId == groupSubId(0) }
assertEquals(mapOf("h" to listOf("group-a")), updated.reqCommand.filters.single().tags)
running.cancelAndJoin()
}
@Test
fun `leaving every group closes the subscription`() = runTest {
val transport = FakeTransport()
val rooms = MutableStateFlow(listOf(room("group-a")))
val running = start(transport, rooms)
settle()
rooms.value = emptyList()
settle()
assertTrue(transport.closed.any { it.subId == groupSubId(0) })
running.cancelAndJoin()
}
/**
* Membership churn inside the debounce window must collapse. Joining a group writes the
* room, its participants and placeholder profiles in quick succession, each of which
* re-emits the list — so without this a single join re-sends the filter several times.
*/
@Test
fun `rapid membership changes collapse into one update`() = runTest {
val transport = FakeTransport()
val rooms = MutableStateFlow(listOf(room("group-a")))
val running = start(transport, rooms)
settle()
val updatesBefore = transport.updated.size
rooms.value = listOf(room("group-a"), room("group-b"))
advanceTimeBy(100)
rooms.value = listOf(room("group-a"), room("group-b"), room("group-c"))
advanceTimeBy(100)
rooms.value = listOf(room("group-a"), room("group-b"), room("group-c"), room("group-d"))
settle()
assertEquals(1, transport.updated.size - updatesBefore, "expected one update, not three")
running.cancelAndJoin()
}
@Test
fun `a NIP-17 room never becomes a group subscription`() = runTest {
val transport = FakeTransport()
val rooms = MutableStateFlow(listOf(room("dm-room", mlsGroupState = null)))
val running = start(transport, rooms)
settle()
assertTrue(transport.opened.none { it.reqCommand.subId.startsWith("live-groups-") })
running.cancelAndJoin()
}
// --- what arrives on a subscription -------------------------------------------------
@Test
fun `events are stored against the relay they arrived from`() = runTest {
val transport = FakeTransport()
val nostr = RecordingNostrRepository()
val running = start(transport, MutableStateFlow(emptyList()), nostr)
settle()
val subscription = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }
subscription.messages.emit(
NostrIncomingMessage.EventMessage(subscriptionId = GIFT_WRAP_SUB_ID, nostrEvent = event("ev-1"))
)
settle()
val saved = nostr.saved.single()
assertEquals("ev-1", saved.first)
assertEquals(subscription.relayUrl, saved.second)
running.cancelAndJoin()
}
/** EOSE is a marker, not an end: what follows it is the whole point. */
@Test
fun `an event after EOSE is still stored`() = runTest {
val transport = FakeTransport()
val nostr = RecordingNostrRepository()
val running = start(transport, MutableStateFlow(emptyList()), nostr)
settle()
val subscription = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }
subscription.messages.emit(NostrIncomingMessage.EoseMessage(subscriptionId = GIFT_WRAP_SUB_ID))
subscription.messages.emit(
NostrIncomingMessage.EventMessage(subscriptionId = GIFT_WRAP_SUB_ID, nostrEvent = event("after-eose"))
)
settle()
assertEquals(listOf("after-eose"), nostr.saved.map { it.first })
running.cancelAndJoin()
}
@Test
fun `a relay that closes the subscription gets it re-opened`() = runTest {
val transport = FakeTransport()
val running = start(transport, MutableStateFlow(emptyList()))
settle()
val first = transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }
first.messages.emit(
NostrIncomingMessage.ClosedMessage(subscriptionId = GIFT_WRAP_SUB_ID, message = "shutting down")
)
settle(1.seconds)
assertEquals(
1,
transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID },
"should still be inside the back-off, not hammering the relay",
)
settle(30.seconds)
assertEquals(
2,
transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID },
"a CLOSED subscription should have been re-opened once the back-off elapsed",
)
running.cancelAndJoin()
}
/**
* Back-pressure is the one refusal that must NOT be answered promptly — opening another
* subscription is exactly what the relay just asked us to stop doing.
*/
@Test
fun `a rate-limited close waits far longer before re-opening`() = runTest {
val transport = FakeTransport()
val running = start(transport, MutableStateFlow(emptyList()))
settle()
transport.opened.single { it.reqCommand.subId == GIFT_WRAP_SUB_ID }.messages.emit(
NostrIncomingMessage.ClosedMessage(
subscriptionId = GIFT_WRAP_SUB_ID,
message = "rate-limited: too many concurrent REQs",
)
)
settle(30.seconds)
assertEquals(
1,
transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID },
"must not re-open within the window a plain failure would have",
)
// ...but it is a back-off, not a give-up.
settle(5.minutes)
assertEquals(
2,
transport.opened.count { it.reqCommand.subId == GIFT_WRAP_SUB_ID },
"the subscription should come back once the relay has had its breathing room",
)
running.cancelAndJoin()
}
// --- lifecycle -----------------------------------------------------------------------
@Test
fun `backgrounding closes the subscriptions and foregrounding rebuilds them`() = runTest {
val transport = FakeTransport()
val foreground = MutableStateFlow(true)
val running = start(transport, MutableStateFlow(emptyList()), isForeground = foreground)
settle()
val openedWhileForeground = transport.opened.size
assertTrue(openedWhileForeground > 0)
foreground.value = false
settle()
assertTrue(transport.closed.any { it.subId == GIFT_WRAP_SUB_ID }, "should have said goodbye")
foreground.value = true
settle()
assertTrue(transport.opened.size > openedWhileForeground, "should have re-subscribed")
running.cancelAndJoin()
}
/**
* A live subscription covers the window we are connected for; it cannot answer for the gap
* we were away. That is negentropy's job, and returning to the foreground is precisely
* when it needs asking.
*/
@Test
fun `foregrounding reconnects and queues a catch-up reconciliation`() = runTest {
val transport = FakeTransport()
val nostr = RecordingNostrRepository()
val running = start(transport, MutableStateFlow(listOf(room("group-a"))), nostr)
settle()
assertEquals(1, transport.reconnects, "the socket must not be trusted after a gap")
val purposes = nostr.queued.map { it.purpose }.toSet()
assertTrue("chat" in purposes, "gift wraps should be reconciled")
assertTrue("mlsMessages" in purposes, "group events should be reconciled")
running.cancelAndJoin()
}
// --- harness -------------------------------------------------------------------------
/**
* Advances virtual time by exactly [duration] and runs what that makes due.
*
* Deliberately not `advanceUntilIdle()`: that runs until nothing is scheduled at all,
* which means it fast-forwards through *any* pending delay — including the five-minute
* back-off this suite needs to assert has NOT elapsed. A test that cannot tell "waited"
* from "did not wait" cannot test a back-off at all.
*/
private fun kotlinx.coroutines.test.TestScope.settle(duration: Duration = 1.seconds) {
advanceTimeBy(duration)
runCurrent()
}
private fun kotlinx.coroutines.test.TestScope.start(
transport: FakeTransport,
rooms: MutableStateFlow<List<LocalChatRoom>>,
nostr: RecordingNostrRepository = RecordingNostrRepository(),
isForeground: MutableStateFlow<Boolean> = MutableStateFlow(true),
) = launch {
LiveSubscriptionManager(
relaysSocketManager = transport,
nostrRepository = nostr,
chatRepository = FakeChatRepository(rooms),
isForeground = isForeground,
).observe(keyPair)
}
private fun room(
id: String,
mlsGroupState: String? = "state",
leftGroupAt: Instant? = null,
) = LocalChatRoom(
chatRoom = ChatRoom(
id = id,
userPublicKey = me,
subject = null,
description = null,
mlsGroupState = mlsGroupState,
leftGroupAt = leftGroupAt,
)
)
private fun event(id: String) = NostrEvent(
id = id,
pubKey = me,
createdAt = Instant.fromEpochSeconds(1_000),
kind = 1059,
tags = emptyArray(),
content = "",
sig = "",
)
}
private class OpenedSubscription(val reqCommand: ReqCmd, val relayUrl: String) {
val messages = MutableSharedFlow<NostrIncomingMessage>(extraBufferCapacity = 32)
}
private class FakeTransport : LiveSubscriptionTransport {
val opened = mutableListOf<OpenedSubscription>()
val updated = mutableListOf<OpenedSubscription>()
val closed = mutableListOf<ClosedSubscription>()
var reconnects = 0
data class ClosedSubscription(val subId: String, val relayUrl: String)
override suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow<NostrIncomingMessage> =
OpenedSubscription(reqCommand, relayUrl).also { opened += it }.messages.asSharedFlow()
override suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) {
updated += OpenedSubscription(reqCommand, relayUrl)
}
override suspend fun closeLiveSubscription(subId: String, relayUrl: String) {
closed += ClosedSubscription(subId, relayUrl)
}
override suspend fun reconnectAll() {
reconnects++
}
}
private class FakeChatRepository(
private val rooms: StateFlow<List<LocalChatRoom>>,
) : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY {
override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow<List<LocalChatRoom>> = rooms
override suspend fun getChatRoomListByPublicKey(publicKey: String): List<LocalChatRoom> = rooms.value
}
private class RecordingNostrRepository : NostrRepository by NostrRepository.NO_OP_NOSTR_REPOSITORY {
/** (event id, relay it arrived from) */
val saved = mutableListOf<Pair<String, String>>()
val queued = mutableListOf<NegentropySynchronizeRequest>()
override suspend fun saveNostrEvent(
nostrEvent: NostrEvent,
relayURL: String,
synchronizationRelayURLs: List<String>,
level: Int,
activeKeyPair: KeyPair,
) {
saved += nostrEvent.id to relayURL
}
override suspend fun queueNegentropySynchronizeRequest(
negentropySynchronizeRequests: List<NegentropySynchronizeRequest>,
) {
queued += negentropySynchronizeRequests
}
}

View File

@@ -0,0 +1,58 @@
package press.mantra.compose.network.relays
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Pins which CLOSED reasons mean "ease off".
*
* The classification decides what happens next and is expensive to get wrong in both
* directions: a refusal read as a transient failure is answered by opening another
* subscription, which is how one "too many concurrent REQs" becomes a flood of them, while an
* unsupported filter read as back-pressure leaves a subscription shut for five minutes over a
* problem no amount of waiting fixes.
*/
class RelayBackPressureTest {
@Test
fun `recognises the NIP-01 machine-readable prefix`() {
assertTrue(isRelayBackPressure("rate-limited: slow down there chief"))
}
@Test
fun `recognises the free-text forms relays actually send`() {
listOf(
"ERROR: too many concurrent REQs",
"rate limit exceeded",
"Please slow down",
"TOO MANY SUBSCRIPTIONS",
"maximum concurrent subscriptions reached",
).forEach { reason ->
assertTrue(isRelayBackPressure(reason), "expected back-pressure: $reason")
}
}
@Test
fun `does not read a refusal we cannot wait out as back-pressure`() {
listOf(
"auth-required: we can't serve DMs to unauthenticated users",
"unsupported: filter contains unknown tag",
"invalid: filter is empty",
"error: negentropy disabled",
"blocked: you are not allowed to write here",
).forEach { reason ->
assertFalse(isRelayBackPressure(reason), "did not expect back-pressure: $reason")
}
}
/**
* A CLOSED with no reason at all is common. Treating it as back-pressure would mean the
* least informative refusal produced the longest possible outage.
*/
@Test
fun `a missing or empty reason is not back-pressure`() {
assertFalse(isRelayBackPressure(null))
assertFalse(isRelayBackPressure(""))
}
}

View File

@@ -0,0 +1,392 @@
package press.mantra.compose.network.relays
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonObject
import press.mantra.compose.network.dto.RelayDTO
import press.mantra.compose.network.sockets.NostrIncomingMessage
import press.mantra.compose.network.sockets.NostrSocketClient
import press.mantra.compose.network.sockets.NostrSocketClientFactory
import press.mantra.compose.network.sockets.SocketConnectionClosedCallback
import press.mantra.compose.network.sockets.SocketConnectionOpenedCallback
import press.mantra.compose.network.sockets.SocketConnectionReopenedCallback
import press.mantra.compose.repository.CachingImportRepository
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The pool's half of surviving a dropped socket.
*
* A subscription that outlives its socket only works if the pool remembers what that socket was
* carrying and hands it back on reconnect. None of that is observable from the outside — no
* return value changes, nothing throws — so a regression here looks like "messages stopped
* arriving after a tunnel", hours later, on someone else's phone.
*
* The other half is that a relay answers a repeated REQ on an existing subscription id by
* replacing that subscription's filter, which is what makes replay a send rather than a
* close-and-reopen.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class RelayPoolSubscriptionTest {
private val relayUrl = "wss://relay.example.com"
private val otherRelayUrl = "wss://other.example.com"
private fun req(subId: String, kind: Int = 1) =
ReqCmd(subId = subId, filters = listOf(Filter(kinds = listOf(kind))))
@Test
fun `a query is retained, and replayed when its socket comes back`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.query(req("sub-1"), relayUrl)
val socket = factory.only()
assertEquals(1, socket.sent.size, "the REQ should have gone out once")
assertTrue(socket.autoReconnect, "a relay carrying a subscription is worth reconnecting")
socket.sent.clear()
factory.reopen(relayUrl)
assertEquals(1, socket.sent.size, "the REQ should have been replayed")
assertTrue(socket.sent.single().contains("sub-1"))
}
@Test
fun `a closed query is forgotten, and the socket stops reconnecting for it`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.query(req("sub-1"), relayUrl)
pool.closeQuery(CloseCmd(subId = "sub-1"), relayUrl)
val socket = factory.only()
assertFalse(socket.autoReconnect, "nothing is open on this relay any more")
socket.sent.clear()
factory.reopen(relayUrl)
assertTrue(socket.sent.isEmpty(), "a closed subscription must not come back on reconnect")
}
/**
* Closing one of two must not drop the other — the bug this guards is a shared socket
* quietly losing its remaining subscription because a sibling finished first.
*/
@Test
fun `closing one subscription leaves the rest of that relay's alone`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.query(req("sub-1"), relayUrl)
pool.query(req("sub-2", kind = 7), relayUrl)
pool.closeQuery(CloseCmd(subId = "sub-1"), relayUrl)
val socket = factory.only()
assertTrue(socket.autoReconnect, "sub-2 is still open")
socket.sent.clear()
factory.reopen(relayUrl)
assertEquals(1, socket.sent.size)
assertTrue(socket.sent.single().contains("sub-2"))
}
/**
* Negentropy is stateful: NEG-OPEN carries a fingerprint of the local set and every round
* depends on the last. Replaying one mid-exchange would reconcile against a conversation
* the relay is no longer having, so an interrupted exchange is abandoned and re-queued
* instead.
*/
@Test
fun `a negentropy exchange is never replayed`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.negentropySync(
NegOpenCmd(subId = "neg-1", filter = Filter(kinds = listOf(1)), initialMessage = "6100"),
relayUrl,
)
val socket = factory.only()
assertEquals(1, socket.sent.size, "NEG-OPEN still goes out")
socket.sent.clear()
factory.reopen(relayUrl)
assertTrue(socket.sent.isEmpty(), "a half-finished reconciliation must not be resumed")
}
@Test
fun `a live subscription is retained like any other`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.openLiveSubscription(req("live-giftwrap"), relayUrl)
val socket = factory.only()
assertTrue(socket.autoReconnect)
socket.sent.clear()
factory.reopen(relayUrl)
assertTrue(socket.sent.single().contains("live-giftwrap"))
}
/**
* Widening a live subscription has to replace what gets replayed, not just what is on the
* wire now. Retaining the old filter would mean a reconnect quietly restored a
* subscription the caller had already moved on from — a group you just joined going
* silent the first time you walked through a tunnel.
*/
@Test
fun `updating a live subscription replaces what a reconnect replays`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.openLiveSubscription(req("live-groups-0", kind = 445), relayUrl)
pool.updateLiveSubscription(
ReqCmd(
subId = "live-groups-0",
filters = listOf(Filter(kinds = listOf(445), tags = mapOf("h" to listOf("group-b")))),
),
relayUrl,
)
val socket = factory.only()
socket.sent.clear()
factory.reopen(relayUrl)
val replayed = socket.sent.single()
assertTrue(replayed.contains("group-b"), "expected the current filter, got: $replayed")
}
/**
* An update that cannot be sent must still be recorded. Throwing instead would leave the
* previous filter retained, which is the one outcome worse than not sending at all.
*/
@Test
fun `an update whose send fails is still retained for the reconnect`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.openLiveSubscription(req("live-groups-0", kind = 445), relayUrl)
val socket = factory.only()
socket.failSends = true
pool.updateLiveSubscription(
ReqCmd(
subId = "live-groups-0",
filters = listOf(Filter(kinds = listOf(445), tags = mapOf("h" to listOf("group-b")))),
),
relayUrl,
)
socket.failSends = false
socket.sent.clear()
factory.reopen(relayUrl)
assertTrue(socket.sent.single().contains("group-b"))
}
@Test
fun `dropping a relay forgets what it was carrying`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.query(req("sub-1"), relayUrl)
pool.changeRelays(listOf(RelayDTO(url = otherRelayUrl, read = true, write = true)))
val dropped = factory.forUrl(relayUrl)
dropped.sent.clear()
factory.reopen(relayUrl)
assertTrue(dropped.sent.isEmpty(), "a relay we removed should not be re-subscribed")
}
@Test
fun `closing the pool forgets everything`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.query(req("sub-1"), relayUrl)
pool.closePool()
val socket = factory.only()
socket.sent.clear()
factory.reopen(relayUrl)
assertTrue(socket.sent.isEmpty())
}
/** Retention is per relay: one relay's reconnect must not re-send another's REQ. */
@Test
fun `replay is scoped to the relay that reconnected`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
pool.query(req("sub-here"), relayUrl)
pool.query(req("sub-there"), otherRelayUrl)
factory.forUrl(relayUrl).sent.clear()
factory.forUrl(otherRelayUrl).sent.clear()
factory.reopen(relayUrl)
assertTrue(factory.forUrl(relayUrl).sent.single().contains("sub-here"))
assertTrue(factory.forUrl(otherRelayUrl).sent.isEmpty())
}
/**
* The semantic the whole change rests on. For a one-shot request EOSE is the end; for a
* live one it is only the boundary between the history a relay had stored and the tail it
* will now stream. Ending there is precisely what made every chat sync a poll.
*/
@Test
fun `a live subscription keeps delivering after EOSE`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
val received = mutableListOf<NostrIncomingMessage>()
val flow = pool.openLiveSubscription(req("live-giftwrap"), relayUrl)
val collecting = launch(UnconfinedTestDispatcher(testScheduler)) {
flow.collect { received += it }
}
val socket = factory.only()
socket.deliver(NostrIncomingMessage.EoseMessage(subscriptionId = "live-giftwrap"))
socket.deliver(NostrIncomingMessage.EventMessage(subscriptionId = "live-giftwrap"))
assertEquals(2, received.size, "the event after EOSE should still have arrived")
assertTrue(collecting.isActive, "a live subscription ends when its collector stops, not at EOSE")
collecting.cancel()
}
/** The contrast, so the two rules cannot silently converge. */
@Test
fun `a one-shot query still ends at EOSE`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
val received = mutableListOf<NostrIncomingMessage>()
val flow = pool.query(req("sub-1"), relayUrl)
val collecting = launch(UnconfinedTestDispatcher(testScheduler)) {
flow.collect { received += it }
}
val socket = factory.only()
socket.deliver(NostrIncomingMessage.EoseMessage(subscriptionId = "sub-1"))
socket.deliver(NostrIncomingMessage.EventMessage(subscriptionId = "sub-1"))
assertEquals(1, received.size, "nothing should arrive after the EOSE that ended it")
assertFalse(collecting.isActive, "the collector should have completed")
}
/**
* A NOTICE carries no subscription id, so the socket hands it to every collector. It is
* admitted deliberately — it is the only signal some relays give for "negentropy disabled"
* — but it must stay advisory, never terminal, or one relay's complaint would tear down
* every unrelated subscription on that socket.
*/
@Test
fun `a NOTICE reaches a live subscription without ending it`() = runTest {
val factory = FakeSocketClientFactory()
val pool = pool(factory)
val received = mutableListOf<NostrIncomingMessage>()
val flow = pool.openLiveSubscription(req("live-giftwrap"), relayUrl)
val collecting = launch(UnconfinedTestDispatcher(testScheduler)) {
flow.collect { received += it }
}
factory.only().deliver(NostrIncomingMessage.NoticeMessage(message = "restricted: slow"))
assertEquals(1, received.size)
assertTrue(collecting.isActive)
collecting.cancel()
}
private fun kotlinx.coroutines.test.TestScope.pool(factory: FakeSocketClientFactory) =
RelayPool(
nostrSocketClientFactory = factory,
cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY,
// Unconfined so the pool's launched work — status updates, and the replay a
// reconnect triggers — has run by the time the call that started it returns.
scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)),
)
}
private class FakeSocketClientFactory : NostrSocketClientFactory {
private val clients = mutableMapOf<String, FakeNostrSocketClient>()
private val reopenCallbacks = mutableMapOf<String, SocketConnectionReopenedCallback?>()
override fun create(
wssUrl: String,
incomingCompressionEnabled: Boolean,
onSocketConnectionOpened: SocketConnectionOpenedCallback?,
onSocketConnectionClosed: SocketConnectionClosedCallback?,
onSocketConnectionReopened: SocketConnectionReopenedCallback?,
): NostrSocketClient {
reopenCallbacks[wssUrl] = onSocketConnectionReopened
return FakeNostrSocketClient(wssUrl).also { clients[wssUrl] = it }
}
fun only(): FakeNostrSocketClient = clients.values.single()
fun forUrl(url: String): FakeNostrSocketClient = clients.getValue(url)
/** Stands in for a socket that dropped and re-established its session. */
fun reopen(url: String) {
reopenCallbacks.getValue(url)?.invoke(url)
}
}
private class FakeNostrSocketClient(override val socketUrl: String) : NostrSocketClient {
val sent = mutableListOf<String>()
var failSends = false
private val _incomingMessages = MutableSharedFlow<NostrIncomingMessage>(extraBufferCapacity = 64)
override val incomingMessages: SharedFlow<NostrIncomingMessage> = _incomingMessages.asSharedFlow()
override var autoReconnect: Boolean = false
override suspend fun close() = Unit
override suspend fun ensureSocketConnectionOrThrow() = Unit
override suspend fun sendMESSAGE(text: String, ensureSessionBeforeSend: Boolean) {
if (failSends) throw IllegalStateException("socket is down")
sent += text
}
override suspend fun sendAUTH(signedEvent: JsonObject) = Unit
override suspend fun sendCLOSE(subscriptionId: String) = Unit
override suspend fun sendCOUNT(data: JsonObject): String = "unused"
override suspend fun sendEVENT(signedEvent: JsonObject) = Unit
override suspend fun sendREQ(subscriptionId: String, data: JsonObject) = Unit
/** Stands in for a message arriving on the wire. */
suspend fun deliver(message: NostrIncomingMessage) {
_incomingMessages.emit(message)
}
}

View File

@@ -0,0 +1,73 @@
package press.mantra.compose.network.sockets
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
class ReconnectBackoffTest {
private val initial = 1.seconds
private val max = 60.seconds
private fun delay(attempt: Int, jitter: Double = 0.0) =
reconnectDelay(
attempt = attempt,
initialDelay = initial,
maxDelay = max,
jitterFraction = 0.25,
jitter = jitter,
)
@Test
fun `doubles once per previous failure`() {
assertEquals(1.seconds, delay(attempt = 1))
assertEquals(2.seconds, delay(attempt = 2))
assertEquals(4.seconds, delay(attempt = 3))
assertEquals(8.seconds, delay(attempt = 4))
}
@Test
fun `stops growing at the cap`() {
assertEquals(max, delay(attempt = 7))
assertEquals(max, delay(attempt = 50))
}
/**
* The reason [MAX_RECONNECT_EXPONENT] exists. `2.0.pow(4000)` is `Infinity`, and
* `Duration * Double` throws on it — so without the clamp a socket that had been failing
* for long enough turned its own reconnect loop into a crash loop, at roughly the moment
* the network was least likely to recover unaided.
*/
@Test
fun `survives an attempt count large enough to overflow the doubling`() {
assertEquals(max, delay(attempt = 4_000))
assertEquals(max, delay(attempt = Int.MAX_VALUE))
}
/** Defensive: a caller that starts counting at zero should still get a usable delay. */
@Test
fun `treats a non-positive attempt as the first one`() {
assertEquals(initial, delay(attempt = 0))
assertEquals(initial, delay(attempt = -3))
}
@Test
fun `adds at most the jitter fraction on top, never subtracts`() {
assertEquals(4.seconds, delay(attempt = 3, jitter = 0.0))
assertEquals(5.seconds, delay(attempt = 3, jitter = 1.0))
val midway = delay(attempt = 3, jitter = 0.5)
assertTrue(midway > 4.seconds && midway < 5.seconds, "expected 4s..5s, got $midway")
}
/**
* Jitter is applied after the cap, so a capped delay still spreads: relays all drop at the
* same moment when the network does, and identical waits would bring them back in lockstep
* for as long as the outage lasted.
*/
@Test
fun `jitter still spreads a capped delay`() {
assertTrue(delay(attempt = 50, jitter = 1.0) > max)
}
}