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:
@@ -10,9 +10,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
@@ -30,10 +28,12 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
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.database.model.types.SynchronizationFilter
|
||||
import press.mantra.compose.network.relays.RelaysSocketManager
|
||||
import press.mantra.compose.network.relays.LiveSubscriptionTransport
|
||||
import press.mantra.compose.network.relays.isRelayBackPressure
|
||||
import press.mantra.compose.network.sockets.NostrIncomingMessage
|
||||
import press.mantra.compose.network.sockets.endsLiveSubscription
|
||||
import press.mantra.compose.nostr.Relays
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
@@ -63,7 +63,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||
* (commonly ~20 per connection), which they comfortably are.
|
||||
*/
|
||||
class LiveSubscriptionManager(
|
||||
private val relaysSocketManager: RelaysSocketManager,
|
||||
private val relaysSocketManager: LiveSubscriptionTransport,
|
||||
private val nostrRepository: NostrRepository,
|
||||
private val chatRepository: ChatRepository,
|
||||
private val isForeground: StateFlow<Boolean>,
|
||||
@@ -128,6 +128,69 @@ class LiveSubscriptionManager(
|
||||
* only survives into the plain-REQ fallback.
|
||||
*/
|
||||
private const val CATCH_UP_LIMIT = 50
|
||||
|
||||
/**
|
||||
* Every gift wrap addressed to us: direct messages, and the Marmot Welcome events
|
||||
* that make us a member of a group.
|
||||
*
|
||||
* There is deliberately no `since`. NIP-59 randomizes a wrap's `created_at` into the
|
||||
* past — our own outbound path stamps them with `TimeUtils.randomWithTwoDays()` — so
|
||||
* a wrap published right now can carry a timestamp two days old, and a `since`
|
||||
* anywhere near the present would silently drop it. "Some messages just never
|
||||
* arrive" is the worst failure mode to debug, and re-receiving a wrap costs one
|
||||
* no-op `storeNostrEvent`.
|
||||
*/
|
||||
internal fun giftWrapFilter(publicKey: HexKey) = Filter(
|
||||
kinds = listOf(GiftWrapEvent.KIND),
|
||||
tags = mapOf("p" to listOf(publicKey)),
|
||||
limit = INITIAL_HISTORY_LIMIT,
|
||||
)
|
||||
|
||||
/**
|
||||
* Marmot group messages for a chunk of the groups we belong to.
|
||||
*
|
||||
* Unlike gift wraps these carry honest timestamps (`MarmotOutboundDao` stamps them
|
||||
* with `TimeUtils.now()`), so a `since` watermark would be safe here. It is still not
|
||||
* used: `limit` already bounds the initial burst, and a watermark would have to be
|
||||
* recomputed every time the chunk's membership changed.
|
||||
*/
|
||||
internal fun groupFilter(groupIds: List<HexKey>) = Filter(
|
||||
kinds = listOf(GroupEvent.KIND),
|
||||
tags = mapOf("h" to groupIds),
|
||||
limit = INITIAL_GROUP_HISTORY_LIMIT,
|
||||
)
|
||||
|
||||
/**
|
||||
* The groups a live subscription should be watching.
|
||||
*
|
||||
* An MLS group is a room with group state; a NIP-17 room has none and is served by
|
||||
* the gift wrap subscription instead. A room we have left or deleted keeps its
|
||||
* history locally but must stop pulling new messages.
|
||||
*
|
||||
* Sorted so the same membership in a different row order is the same value, and a
|
||||
* re-emit that changes nothing costs nothing downstream.
|
||||
*/
|
||||
internal fun groupIdsFrom(rooms: List<LocalChatRoom>): List<HexKey> =
|
||||
rooms
|
||||
.filter { it.chatRoom.mlsGroupState != null }
|
||||
.filter { it.chatRoom.leftGroupAt == null && it.chatRoom.deletedAt == null }
|
||||
.map { it.chatRoom.id }
|
||||
.sorted()
|
||||
|
||||
internal fun groupChunks(groupIds: List<HexKey>): List<List<HexKey>> =
|
||||
groupIds.chunked(MAX_GROUPS_PER_SUBSCRIPTION)
|
||||
|
||||
internal fun groupSubId(index: Int) = "$GROUP_SUB_ID_PREFIX$index"
|
||||
|
||||
internal fun subscriptionKey(relayUrl: String, subId: String) = "$relayUrl|$subId"
|
||||
|
||||
/**
|
||||
* The chunk index a [subscriptionKey] refers to, or [Int.MAX_VALUE] for anything that
|
||||
* does not parse — which reads as "past the end", so an unrecognised key is closed
|
||||
* rather than kept open forever.
|
||||
*/
|
||||
internal fun subscriptionIndex(key: String) =
|
||||
key.substringAfterLast(GROUP_SUB_ID_PREFIX).toIntOrNull() ?: Int.MAX_VALUE
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,6 +223,11 @@ class LiveSubscriptionManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything below inherits the caller's dispatcher rather than pinning Dispatchers.IO.
|
||||
* `SynchronizationViewModel` already launches [observe] on IO, so nothing has moved — but
|
||||
* a coroutine that picks its own dispatcher cannot be driven by a test scheduler.
|
||||
*/
|
||||
private suspend fun runWhileForeground(publicKey: HexKey, keyPair: KeyPair): Unit = coroutineScope {
|
||||
logger.i("Foregrounded; opening live subscriptions for $publicKey")
|
||||
|
||||
@@ -171,10 +239,10 @@ class LiveSubscriptionManager(
|
||||
|
||||
// Live subscriptions cover the window we are online for; this covers the gap we were
|
||||
// not. Neither subsumes the other.
|
||||
launch(Dispatchers.IO) { queueCatchUpSynchronization(publicKey) }
|
||||
launch { queueCatchUpSynchronization(publicKey) }
|
||||
|
||||
Relays.DefaultDMRelayList.forEach { relay ->
|
||||
launch(Dispatchers.IO) {
|
||||
launch {
|
||||
runLiveSubscription(
|
||||
subId = GIFT_WRAP_SUB_ID,
|
||||
relayUrl = relay.url,
|
||||
@@ -184,7 +252,7 @@ class LiveSubscriptionManager(
|
||||
}
|
||||
}
|
||||
|
||||
launch(Dispatchers.IO) { followGroupMembership(publicKey = publicKey, keyPair = keyPair) }
|
||||
launch { followGroupMembership(publicKey = publicKey, keyPair = keyPair) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,37 +309,7 @@ class LiveSubscriptionManager(
|
||||
}
|
||||
|
||||
private suspend fun liveGroupIds(publicKey: HexKey): List<HexKey> =
|
||||
chatRepository.getChatRoomListByPublicKey(publicKey).toLiveGroupIds()
|
||||
|
||||
/**
|
||||
* Every gift wrap addressed to us: direct messages, and the Marmot Welcome events that
|
||||
* make us a member of a group.
|
||||
*
|
||||
* There is deliberately no `since`. NIP-59 randomizes a wrap's `created_at` into the past
|
||||
* — our own outbound path stamps them with `TimeUtils.randomWithTwoDays()` — so a wrap
|
||||
* published right now can carry a timestamp two days old, and a `since` anywhere near the
|
||||
* present would silently drop it. "Some messages just never arrive" is the worst failure
|
||||
* mode to debug, and re-receiving a wrap costs one no-op `storeNostrEvent`.
|
||||
*/
|
||||
private fun giftWrapFilter(publicKey: HexKey) = Filter(
|
||||
kinds = listOf(GiftWrapEvent.KIND),
|
||||
tags = mapOf("p" to listOf(publicKey)),
|
||||
limit = INITIAL_HISTORY_LIMIT,
|
||||
)
|
||||
|
||||
/**
|
||||
* Marmot group messages for a chunk of the groups we belong to.
|
||||
*
|
||||
* Unlike gift wraps these carry honest timestamps (`MarmotOutboundDao` stamps them with
|
||||
* `TimeUtils.now()`), so a `since` watermark would be safe here. It is still not used:
|
||||
* `limit` already bounds the initial burst, and a watermark would have to be recomputed
|
||||
* every time the chunk's membership changed.
|
||||
*/
|
||||
private fun groupFilter(groupIds: List<HexKey>) = Filter(
|
||||
kinds = listOf(GroupEvent.KIND),
|
||||
tags = mapOf("h" to groupIds),
|
||||
limit = INITIAL_GROUP_HISTORY_LIMIT,
|
||||
)
|
||||
groupIdsFrom(chatRepository.getChatRoomListByPublicKey(publicKey))
|
||||
|
||||
/**
|
||||
* Keeps the group subscriptions matching the groups we are actually in.
|
||||
@@ -291,7 +329,7 @@ class LiveSubscriptionManager(
|
||||
val subscriptions = mutableMapOf<String, Job>()
|
||||
|
||||
chatRepository.observeChatRoomListByPublicKey(publicKey)
|
||||
.map { rooms -> rooms.toLiveGroupIds() }
|
||||
.map { rooms -> groupIdsFrom(rooms) }
|
||||
.distinctUntilChanged()
|
||||
.debounce(GROUP_CHANGE_DEBOUNCE)
|
||||
.collect { groupIds ->
|
||||
@@ -318,7 +356,7 @@ class LiveSubscriptionManager(
|
||||
keyPair: KeyPair,
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
val chunks = groupIds.chunked(MAX_GROUPS_PER_SUBSCRIPTION)
|
||||
val chunks = groupChunks(groupIds)
|
||||
groupIdChunks = chunks
|
||||
|
||||
logger.i("Live group membership: ${groupIds.size} group(s) over ${chunks.size} subscription(s)")
|
||||
@@ -327,7 +365,7 @@ class LiveSubscriptionManager(
|
||||
val relayUrl = relay.url
|
||||
|
||||
chunks.forEachIndexed { index, chunk ->
|
||||
val subId = "$GROUP_SUB_ID_PREFIX$index"
|
||||
val subId = groupSubId(index)
|
||||
val key = subscriptionKey(relayUrl = relayUrl, subId = subId)
|
||||
|
||||
if (subscriptions[key]?.isActive == true) {
|
||||
@@ -336,7 +374,7 @@ class LiveSubscriptionManager(
|
||||
relayUrl = relayUrl,
|
||||
)
|
||||
} else {
|
||||
subscriptions[key] = scope.launch(Dispatchers.IO) {
|
||||
subscriptions[key] = scope.launch {
|
||||
runLiveSubscription(
|
||||
subId = subId,
|
||||
relayUrl = relayUrl,
|
||||
@@ -369,26 +407,6 @@ class LiveSubscriptionManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An MLS group is a room with group state; a NIP-17 room has none and is served by the
|
||||
* gift wrap subscription instead. A room we have left or deleted keeps its history
|
||||
* locally but must stop pulling new messages.
|
||||
*
|
||||
* Sorted so the same membership in a different row order is the same value, and a
|
||||
* re-emit that changes nothing costs nothing downstream.
|
||||
*/
|
||||
private fun List<press.mantra.compose.database.model.intermdiate.LocalChatRoom>.toLiveGroupIds() =
|
||||
this
|
||||
.filter { it.chatRoom.mlsGroupState != null }
|
||||
.filter { it.chatRoom.leftGroupAt == null && it.chatRoom.deletedAt == null }
|
||||
.map { it.chatRoom.id }
|
||||
.sorted()
|
||||
|
||||
private fun subscriptionKey(relayUrl: String, subId: String) = "$relayUrl|$subId"
|
||||
|
||||
private fun subscriptionIndex(key: String) =
|
||||
key.substringAfterLast(GROUP_SUB_ID_PREFIX).toIntOrNull() ?: Int.MAX_VALUE
|
||||
|
||||
/**
|
||||
* Keeps one subscription open on one relay, re-opening it if the relay ends it.
|
||||
*
|
||||
@@ -476,10 +494,7 @@ class LiveSubscriptionManager(
|
||||
relayUrl = relayUrl,
|
||||
).transformWhile { message ->
|
||||
emit(message)
|
||||
// CLOSED is the only message that ends a live subscription. EOSE emphatically
|
||||
// does not: it is the boundary between the stored history and the live tail,
|
||||
// and treating it as an end is exactly what makes a sync a poll.
|
||||
message !is NostrIncomingMessage.ClosedMessage
|
||||
!message.endsLiveSubscription()
|
||||
}.collect { message ->
|
||||
when (message) {
|
||||
is NostrIncomingMessage.EventMessage -> {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package press.mantra.compose.network.relays
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import press.mantra.compose.network.sockets.NostrIncomingMessage
|
||||
|
||||
/**
|
||||
* The slice of the relay layer a subscription that stays open needs.
|
||||
*
|
||||
* Narrower than [RelaysSocketManager] on purpose. That class observes the active wallet and
|
||||
* starts collecting relay lists in its `init`, so it cannot be stood up in a test at all —
|
||||
* which would otherwise leave the reconcile loop that keeps group subscriptions matching group
|
||||
* membership with no coverage but the pure helpers underneath it.
|
||||
*/
|
||||
interface LiveSubscriptionTransport {
|
||||
|
||||
/** @see RelayPool.openLiveSubscription */
|
||||
suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow<NostrIncomingMessage>
|
||||
|
||||
/** @see RelayPool.updateLiveSubscription */
|
||||
suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String)
|
||||
|
||||
/** @see RelayPool.closeLiveSubscription */
|
||||
suspend fun closeLiveSubscription(subId: String, relayUrl: String)
|
||||
|
||||
/** @see RelayPool.reconnectAll */
|
||||
suspend fun reconnectAll()
|
||||
}
|
||||
@@ -42,6 +42,12 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
class RelayPool(
|
||||
private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory,
|
||||
private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository,
|
||||
/**
|
||||
* Where this pool's own fire-and-forget work runs: status updates, the socket closes that
|
||||
* a relay-list change triggers, and the subscription replay a reconnect triggers.
|
||||
* Injectable so a test can drive that work deterministically rather than racing it.
|
||||
*/
|
||||
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
|
||||
) {
|
||||
val logger = Logger.withTag("RelayPool")
|
||||
|
||||
@@ -49,8 +55,6 @@ class RelayPool(
|
||||
const val PUBLISH_TIMEOUT = 30_000
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
val relays: MutableSet<press.mantra.compose.network.dto.RelayDTO> = mutableSetOf()
|
||||
|
||||
private val relayMutex = Mutex()
|
||||
|
||||
@@ -33,7 +33,7 @@ class RelaysSocketManager(
|
||||
private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory,
|
||||
private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository,
|
||||
private val relayRepository: press.mantra.compose.repository.RelayRepository,
|
||||
) {
|
||||
) : LiveSubscriptionTransport {
|
||||
val logger = Logger.withTag("RelaysSocketManager")
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
private val relayPoolsMutex = Mutex()
|
||||
@@ -126,8 +126,7 @@ class RelaysSocketManager(
|
||||
)
|
||||
}
|
||||
|
||||
/** @see RelayPool.reconnectAll */
|
||||
suspend fun reconnectAll() = relayPool.reconnectAll()
|
||||
override suspend fun reconnectAll() = relayPool.reconnectAll()
|
||||
|
||||
fun tryConnectingToAllRelays() {
|
||||
relayPool.relays.forEach {
|
||||
@@ -147,24 +146,21 @@ class RelaysSocketManager(
|
||||
}
|
||||
|
||||
|
||||
/** @see RelayPool.openLiveSubscription */
|
||||
suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow<press.mantra.compose.network.sockets.NostrIncomingMessage> {
|
||||
override suspend fun openLiveSubscription(reqCommand: ReqCmd, relayUrl: String): Flow<press.mantra.compose.network.sockets.NostrIncomingMessage> {
|
||||
return relayPool.openLiveSubscription(
|
||||
reqCommand = reqCommand,
|
||||
relayUrl = relayUrl
|
||||
)
|
||||
}
|
||||
|
||||
/** @see RelayPool.updateLiveSubscription */
|
||||
suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) {
|
||||
override suspend fun updateLiveSubscription(reqCommand: ReqCmd, relayUrl: String) {
|
||||
return relayPool.updateLiveSubscription(
|
||||
reqCommand = reqCommand,
|
||||
relayUrl = relayUrl
|
||||
)
|
||||
}
|
||||
|
||||
/** @see RelayPool.closeLiveSubscription */
|
||||
suspend fun closeLiveSubscription(subId: String, relayUrl: String) {
|
||||
override suspend fun closeLiveSubscription(subId: String, relayUrl: String) {
|
||||
return relayPool.closeLiveSubscription(
|
||||
subId = subId,
|
||||
relayUrl = relayUrl
|
||||
|
||||
@@ -28,6 +28,19 @@ fun NostrIncomingMessage.isTerminalFor(id: String): Boolean =
|
||||
(this is NostrIncomingMessage.ClosedMessage && subscriptionId == id) ||
|
||||
(this is NostrIncomingMessage.NegentropyError && subscriptionId == id)
|
||||
|
||||
/**
|
||||
* True for the message that ends a subscription meant to stay OPEN.
|
||||
*
|
||||
* Only CLOSED. EOSE emphatically does not end one: it is the boundary between the stored
|
||||
* history a relay had and the live tail it will now stream, and treating it as an end is
|
||||
* exactly what makes a subscription a poll. Compare [isTerminalFor], which is the rule for a
|
||||
* one-shot request, where EOSE is the whole point.
|
||||
*
|
||||
* No subscription-id argument, unlike [isTerminalFor]: a live collector is already filtered by
|
||||
* id before it gets here.
|
||||
*/
|
||||
fun NostrIncomingMessage.endsLiveSubscription(): Boolean = this is NostrIncomingMessage.ClosedMessage
|
||||
|
||||
/**
|
||||
* Completes the flow once the subscription is over, emitting the terminal
|
||||
* message first so callers still see the EOSE/CLOSED/NEG-ERR that ended it.
|
||||
|
||||
@@ -19,7 +19,25 @@ internal val defaultSocketsHttpClient by lazy {
|
||||
}
|
||||
|
||||
|
||||
object NostrSocketClientFactory {
|
||||
/**
|
||||
* How [press.mantra.compose.network.relays.RelayPool] gets a socket for a relay.
|
||||
*
|
||||
* An interface rather than the object directly, so the pool's subscription bookkeeping — what
|
||||
* it retains, when it replays, when it lets a socket stop reconnecting — can be exercised
|
||||
* against a fake instead of against a real websocket.
|
||||
*/
|
||||
interface NostrSocketClientFactory {
|
||||
|
||||
fun create(
|
||||
wssUrl: String,
|
||||
incomingCompressionEnabled: Boolean = false,
|
||||
onSocketConnectionOpened: SocketConnectionOpenedCallback? = null,
|
||||
onSocketConnectionClosed: SocketConnectionClosedCallback? = null,
|
||||
onSocketConnectionReopened: SocketConnectionReopenedCallback? = null,
|
||||
): NostrSocketClient
|
||||
}
|
||||
|
||||
object DefaultNostrSocketClientFactory : NostrSocketClientFactory {
|
||||
|
||||
fun create(
|
||||
wssUrl: String,
|
||||
@@ -39,12 +57,12 @@ object NostrSocketClientFactory {
|
||||
)
|
||||
}
|
||||
|
||||
fun create(
|
||||
override fun create(
|
||||
wssUrl: String,
|
||||
incomingCompressionEnabled: Boolean = false,
|
||||
onSocketConnectionOpened: SocketConnectionOpenedCallback? = null,
|
||||
onSocketConnectionClosed: SocketConnectionClosedCallback? = null,
|
||||
onSocketConnectionReopened: SocketConnectionReopenedCallback? = null,
|
||||
incomingCompressionEnabled: Boolean,
|
||||
onSocketConnectionOpened: SocketConnectionOpenedCallback?,
|
||||
onSocketConnectionClosed: SocketConnectionClosedCallback?,
|
||||
onSocketConnectionReopened: SocketConnectionReopenedCallback?,
|
||||
) = create(
|
||||
httpClient = defaultSocketsHttpClient,
|
||||
wssUrl = wssUrl,
|
||||
|
||||
@@ -32,10 +32,6 @@ import okio.buffer
|
||||
import okio.use
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
@@ -58,17 +54,7 @@ internal class NostrSocketClientImpl(
|
||||
|
||||
private val MAX_RECONNECT_DELAY = 60.seconds
|
||||
|
||||
/**
|
||||
* Bounds the exponent so a socket that has been failing for hours cannot overflow
|
||||
* the doubling into `Infinity`, which `Duration * Double` rejects outright.
|
||||
*/
|
||||
private const val MAX_RECONNECT_EXPONENT = 16
|
||||
|
||||
/**
|
||||
* Up to this fraction of the delay is added at random. Every relay in the pool
|
||||
* drops at once when the network does, and without jitter they would all come
|
||||
* back in lockstep for as long as the outage lasts.
|
||||
*/
|
||||
/** Fraction of the delay added at random. See [reconnectDelay]. */
|
||||
private const val RECONNECT_JITTER = 0.25
|
||||
|
||||
/**
|
||||
@@ -276,7 +262,12 @@ internal class NostrSocketClientImpl(
|
||||
reconnectJob = scope.launch {
|
||||
while (isActive && autoReconnect && !closedByClient) {
|
||||
val attempt = ++reconnectAttempts
|
||||
val wait = reconnectDelay(attempt)
|
||||
val wait = reconnectDelay(
|
||||
attempt = attempt,
|
||||
initialDelay = INITIAL_RECONNECT_DELAY,
|
||||
maxDelay = MAX_RECONNECT_DELAY,
|
||||
jitterFraction = RECONNECT_JITTER,
|
||||
)
|
||||
logger.i { "Reconnecting to $socketUrl in $wait (attempt $attempt)" }
|
||||
delay(wait)
|
||||
|
||||
@@ -291,13 +282,6 @@ internal class NostrSocketClientImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconnectDelay(attempt: Int): Duration {
|
||||
val doublings = 2.0.pow(min(attempt - 1, MAX_RECONNECT_EXPONENT))
|
||||
val backoff = minOf(INITIAL_RECONNECT_DELAY * doublings, MAX_RECONNECT_DELAY)
|
||||
|
||||
return backoff + backoff * RECONNECT_JITTER * Random.nextDouble()
|
||||
}
|
||||
|
||||
override suspend fun close() {
|
||||
val session = wsMutex.withLock {
|
||||
closedByClient = true
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package press.mantra.compose.network.sockets
|
||||
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Bounds the exponent so a socket that has been failing for hours cannot overflow the doubling
|
||||
* into `Infinity`, which `Duration * Double` rejects outright — turning a reconnect loop into a
|
||||
* crash loop at roughly the point the network is least likely to come back on its own.
|
||||
*/
|
||||
internal const val MAX_RECONNECT_EXPONENT = 16
|
||||
|
||||
/**
|
||||
* How long to wait before reconnect [attempt]: [initialDelay] doubled once per previous
|
||||
* failure, capped at [maxDelay], plus up to [jitterFraction] of that again at random.
|
||||
*
|
||||
* The jitter is not decoration. Every relay in the pool drops at the same moment when the
|
||||
* network does, so without it they all come back in lockstep for as long as the outage lasts,
|
||||
* and each round of that is a simultaneous burst of connection attempts.
|
||||
*
|
||||
* [jitter] is a parameter rather than an inline `Random.nextDouble()` so the arithmetic can be
|
||||
* pinned in a test; callers leave it defaulted.
|
||||
*/
|
||||
internal fun reconnectDelay(
|
||||
attempt: Int,
|
||||
initialDelay: Duration,
|
||||
maxDelay: Duration,
|
||||
jitterFraction: Double,
|
||||
jitter: Double = Random.nextDouble(),
|
||||
): Duration {
|
||||
val doublings = 2.0.pow(min(attempt - 1, MAX_RECONNECT_EXPONENT).coerceAtLeast(0))
|
||||
val backoff = minOf(initialDelay * doublings, maxDelay)
|
||||
|
||||
return backoff + backoff * jitterFraction * jitter
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import press.mantra.compose.network.relays.RelayPool
|
||||
import press.mantra.compose.network.relays.RelaysSocketManager
|
||||
import press.mantra.compose.network.relays.isRelayBackPressure
|
||||
import press.mantra.compose.network.sockets.NostrIncomingMessage
|
||||
import press.mantra.compose.network.sockets.NostrSocketClientFactory
|
||||
import press.mantra.compose.network.sockets.DefaultNostrSocketClientFactory
|
||||
import press.mantra.compose.repository.CachingImportRepository
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.NostrRepository
|
||||
@@ -68,7 +68,7 @@ class SynchronizationViewModel(
|
||||
|
||||
val relaysSocketManager = RelaysSocketManager(
|
||||
activeWalletStateFlow = activeWalletStateFlow,
|
||||
nostrSocketClientFactory = NostrSocketClientFactory,
|
||||
nostrSocketClientFactory = DefaultNostrSocketClientFactory,
|
||||
cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY,
|
||||
relayRepository = relayRepository,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user