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

@@ -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 -> {

View File

@@ -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()
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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

View File

@@ -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
}

View File

@@ -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,
)