feat(relays): reconnect dropped sockets and replay what they were carrying
Nothing in this app reconnected a websocket. NostrSocketClientImpl caught a
failure, called close(), and fired onSocketConnectionClosed — which only flips a
boolean in relayPoolStatus that nobody reads. A socket that failed stayed
failed, and the only reason that was survivable is that every subscription is
short: the next queued request opens a fresh socket on its way out through
ensureSocketConnectionOrThrow.
That stops being survivable the moment a subscription is meant to outlive the
socket, so this lands first, on its own. It is already a fix without any of
that: a REQ interrupted mid-download used to sit there until SUBSCRIPTION_TIMEOUT
gave up 120s later, having saved whatever partial set arrived before the drop.
Now the socket comes back and the REQ is re-sent.
The socket client:
- a supervised reconnect loop with exponential backoff (1s doubling to 60s)
plus up to 25% jitter, because every relay in the pool drops at once when
the network does and without jitter they all come back in lockstep. The
exponent is capped so a socket failing for hours cannot overflow the
doubling into Infinity, which Duration * Double rejects outright.
- `autoReconnect`, off by default and owned by the pool. Reconnecting a socket
nobody is subscribed on is battery spent on nothing, so the pool turns it on
for exactly as long as it retains a subscription for that relay.
- `closedByClient`, so closePool() is not answered by every socket in it
politely reconnecting. Cleared by the next caller-driven connect.
- onSessionLost() as the single exit point for a session that ended without
the client asking, replacing the close()-from-inside-the-receiver dance. It
identity-checks the session before clearing it, so a reconnect that already
installed a newer one is not torn down by its predecessor's cleanup, and
runs NonCancellable because the receiver job is cancelled as part of a
replacement connect.
- Frame.Close now breaks the receive loop rather than closing by hand. The
relay closing us is not the client closing us, so it earns a reconnect too.
- a new SocketConnectionReopenedCallback, fired only when a session is
established on a socket that had connected before. Kept separate from
"opened" because on a FIRST connect a replay would double-send the very REQ
whose sendMESSAGE opened the socket.
Two bugs fixed in passing, both of the silent kind:
- the compression REQ in the post-connect handshake was written to `wsSession`
before the new session was assigned to it, so it went to the previous
(usually null) session and was dropped. wsSession is now assigned first.
- sendMESSAGE used `wsSession?.send(...)`, so a send on a dropped socket was a
no-op and the caller waited forever for an answer to a message never sent.
It now warns.
The pool:
- retains the REQ text per (relay, subscription id), and replays it when that
relay's socket is re-established. A relay answers a repeated REQ on the same
subscription id by replacing the filter, so replay is a send rather than a
close-and-reopen, and the collector already attached to the socket's message
flow simply starts receiving again.
- retains on query() BEFORE the send, so a socket that dies between there and
the relay's first answer is still covered; releases on closeQuery(), which
every pump already calls from a NonCancellable finally.
- deliberately does NOT retain negentropy. NEG-OPEN carries a fingerprint of
the local set and each round depends on the last, so replaying one
mid-exchange would reconcile against a conversation the relay is no longer
having. An interrupted negentropy request is abandoned and re-queued.
- drops retained work for relays removed by changeRelays/removeRelays/
closePool, so a relay edit does not leave a socket reconnecting for
subscriptions nobody wants.
- collapses the five hand-rolled `socketClients.find { normalize... }` lookups
into socketClientFor(), now that there were about to be several more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
178ddd0181
commit
c3b0c8671b
5 files changed
+325
-27
No files matched your search
@@ -55,6 +55,20 @@ class RelayPool(
|
|||||||
|
|
||||||
private val relayMutex = Mutex()
|
private val relayMutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The REQ text of every subscription still believed to be open, keyed by normalized
|
||||||
|
* relay url and then by subscription id, so a socket that comes back can be handed
|
||||||
|
* its subscriptions again.
|
||||||
|
*
|
||||||
|
* Only plain REQs live here. A negentropy exchange is stateful — NEG-OPEN carries a
|
||||||
|
* fingerprint of the local set and each round depends on the last — so replaying one
|
||||||
|
* mid-exchange would reconcile against a conversation the relay is no longer having.
|
||||||
|
* An interrupted negentropy request is abandoned and re-queued instead.
|
||||||
|
*/
|
||||||
|
private val retainedRequests = mutableMapOf<String, MutableMap<String, String>>()
|
||||||
|
|
||||||
|
private val retainedRequestsMutex = Mutex()
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
var socketClients = setOf<press.mantra.compose.network.sockets.NostrSocketClient>()
|
var socketClients = setOf<press.mantra.compose.network.sockets.NostrSocketClient>()
|
||||||
|
|
||||||
@@ -75,6 +89,10 @@ class RelayPool(
|
|||||||
updateRelayStatus(url = url, connected = false)
|
updateRelayStatus(url = url, connected = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val onSocketConnectionReopenedCallback: press.mantra.compose.network.sockets.SocketConnectionReopenedCallback = { url ->
|
||||||
|
scope.launch { replayRetainedRequests(url) }
|
||||||
|
}
|
||||||
|
|
||||||
fun changeRelays(relays: List<press.mantra.compose.network.dto.RelayDTO>) {
|
fun changeRelays(relays: List<press.mantra.compose.network.dto.RelayDTO>) {
|
||||||
val existingRelayUrls = socketClients.map { it.socketUrl }
|
val existingRelayUrls = socketClients.map { it.socketUrl }
|
||||||
val newRelayUrls = relays.map { it.url }
|
val newRelayUrls = relays.map { it.url }
|
||||||
@@ -93,6 +111,7 @@ class RelayPool(
|
|||||||
updateRelayStatus(url = client.socketUrl, connected = false)
|
updateRelayStatus(url = client.socketUrl, connected = false)
|
||||||
scope.launch { client.close() }
|
scope.launch { client.close() }
|
||||||
}
|
}
|
||||||
|
forgetRetainedRequests(toRemoveSocketClients.map { it.socketUrl })
|
||||||
this.relays.clear()
|
this.relays.clear()
|
||||||
this.relays.addAll(relays)
|
this.relays.addAll(relays)
|
||||||
}
|
}
|
||||||
@@ -111,6 +130,7 @@ class RelayPool(
|
|||||||
updateRelayStatus(url = client.socketUrl, connected = false)
|
updateRelayStatus(url = client.socketUrl, connected = false)
|
||||||
scope.launch { client.close() }
|
scope.launch { client.close() }
|
||||||
}
|
}
|
||||||
|
forgetRetainedRequests(toRemoveSocketClients.map { it.socketUrl })
|
||||||
this.relays.removeAll(relays)
|
this.relays.removeAll(relays)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +159,7 @@ class RelayPool(
|
|||||||
updateRelayStatus(url = client.socketUrl, connected = false)
|
updateRelayStatus(url = client.socketUrl, connected = false)
|
||||||
scope.launch { client.close() }
|
scope.launch { client.close() }
|
||||||
}
|
}
|
||||||
|
forgetRetainedRequests(socketClients.map { it.socketUrl })
|
||||||
socketClients = emptySet()
|
socketClients = emptySet()
|
||||||
relays.clear()
|
relays.clear()
|
||||||
}
|
}
|
||||||
@@ -151,12 +172,92 @@ class RelayPool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun socketClientFor(relayUrl: String): press.mantra.compose.network.sockets.NostrSocketClient? {
|
||||||
|
val wanted = NormalizedRelayUrl(relayUrl).displayUrl()
|
||||||
|
|
||||||
|
return socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == wanted }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remembers a subscription so it can be re-sent if this relay's socket comes back,
|
||||||
|
* and tells the socket it is now worth reconnecting.
|
||||||
|
*/
|
||||||
|
private suspend fun retainRequest(relayUrl: String, subId: String, message: String) {
|
||||||
|
val key = NormalizedRelayUrl(relayUrl).displayUrl()
|
||||||
|
|
||||||
|
retainedRequestsMutex.withLock {
|
||||||
|
retainedRequests.getOrPut(key) { mutableMapOf() }[subId] = message
|
||||||
|
}
|
||||||
|
socketClientFor(relayUrl)?.autoReconnect = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forgets a subscription that has been closed. Once a relay is carrying none of ours,
|
||||||
|
* its socket stops reconnecting on its own: keeping a socket alive for a relay we have
|
||||||
|
* nothing open on is battery spent on nothing.
|
||||||
|
*/
|
||||||
|
private suspend fun releaseRequest(relayUrl: String, subId: String) {
|
||||||
|
val key = NormalizedRelayUrl(relayUrl).displayUrl()
|
||||||
|
|
||||||
|
val stillWanted = retainedRequestsMutex.withLock {
|
||||||
|
val forRelay = retainedRequests[key]
|
||||||
|
forRelay?.remove(subId)
|
||||||
|
if (forRelay.isNullOrEmpty()) {
|
||||||
|
retainedRequests.remove(key)
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stillWanted) socketClientFor(relayUrl)?.autoReconnect = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun forgetRetainedRequests(relayUrls: List<String>) {
|
||||||
|
if (relayUrls.isEmpty()) return
|
||||||
|
|
||||||
|
val keys = relayUrls.map { NormalizedRelayUrl(it).displayUrl() }
|
||||||
|
scope.launch {
|
||||||
|
retainedRequestsMutex.withLock {
|
||||||
|
keys.forEach { retainedRequests.remove(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands a relay back the subscriptions it was serving before its socket dropped.
|
||||||
|
*
|
||||||
|
* Only ever called for a RE-connection. A relay answers a repeated REQ on the same
|
||||||
|
* subscription id by replacing the filter, so this is a send rather than a
|
||||||
|
* close-and-reopen, and the collector already attached to the socket's message flow
|
||||||
|
* simply starts receiving again.
|
||||||
|
*/
|
||||||
|
private suspend fun replayRetainedRequests(relayUrl: String) {
|
||||||
|
val key = NormalizedRelayUrl(relayUrl).displayUrl()
|
||||||
|
val messages = retainedRequestsMutex.withLock { retainedRequests[key]?.toMap() }
|
||||||
|
|
||||||
|
if (messages.isNullOrEmpty()) return
|
||||||
|
|
||||||
|
val nostrSocketClient = socketClientFor(relayUrl)
|
||||||
|
if (nostrSocketClient == null) {
|
||||||
|
logger.w("Cannot replay ${messages.size} subscription(s): $relayUrl has no socket")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.i("Replaying ${messages.size} subscription(s) on $relayUrl: ${messages.keys}")
|
||||||
|
messages.forEach { (subId, message) ->
|
||||||
|
runCatching { nostrSocketClient.sendMESSAGE(message) }
|
||||||
|
.onFailure { logger.w(throwable = it) { "Failed to replay $subId on $relayUrl" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun List<press.mantra.compose.network.dto.RelayDTO>.mapAsNostrSocketClient() =
|
private fun List<press.mantra.compose.network.dto.RelayDTO>.mapAsNostrSocketClient() =
|
||||||
this.map {
|
this.map {
|
||||||
nostrSocketClientFactory.create(
|
nostrSocketClientFactory.create(
|
||||||
wssUrl = it.url,
|
wssUrl = it.url,
|
||||||
onSocketConnectionOpened = onSocketConnectionOpenedCallback,
|
onSocketConnectionOpened = onSocketConnectionOpenedCallback,
|
||||||
onSocketConnectionClosed = onSocketConnectionClosedCallback,
|
onSocketConnectionClosed = onSocketConnectionClosedCallback,
|
||||||
|
onSocketConnectionReopened = onSocketConnectionReopenedCallback,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,13 +287,19 @@ class RelayPool(
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.d("socketClients: ${socketClients.map { it.socketUrl }}")
|
logger.d("socketClients: ${socketClients.map { it.socketUrl }}")
|
||||||
val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() }
|
val nostrSocketClient = socketClientFor(relayUrl)
|
||||||
|
|
||||||
val filterRequest = OptimizedJsonMapper.toJson(reqCommand)
|
val filterRequest = OptimizedJsonMapper.toJson(reqCommand)
|
||||||
|
|
||||||
if (nostrSocketClient == null) {
|
if (nostrSocketClient == null) {
|
||||||
throw press.mantra.compose.exceptions.NetworkException("$relayUrl is not connected")
|
throw press.mantra.compose.exceptions.NetworkException("$relayUrl is not connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retained BEFORE the send, so a socket that dies between here and the relay's
|
||||||
|
// first answer still gets the REQ replayed when it comes back. Released by
|
||||||
|
// closeQuery, which every pump calls from a NonCancellable finally.
|
||||||
|
retainRequest(relayUrl = relayUrl, subId = reqCommand.subId, message = filterRequest)
|
||||||
|
|
||||||
return coroutineScope {
|
return coroutineScope {
|
||||||
val eventFlow = nostrSocketClient.queryAsFlow(reqCommand.subId)
|
val eventFlow = nostrSocketClient.queryAsFlow(reqCommand.subId)
|
||||||
with(nostrSocketClient) {
|
with(nostrSocketClient) {
|
||||||
@@ -210,7 +317,7 @@ class RelayPool(
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.d("socketClients: ${socketClients.map { it.socketUrl }}")
|
logger.d("socketClients: ${socketClients.map { it.socketUrl }}")
|
||||||
val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() }
|
val nostrSocketClient = socketClientFor(relayUrl)
|
||||||
|
|
||||||
val negentropySyncRequest = OptimizedJsonMapper.toJson(negOpenCmd)
|
val negentropySyncRequest = OptimizedJsonMapper.toJson(negOpenCmd)
|
||||||
|
|
||||||
@@ -232,20 +339,22 @@ class RelayPool(
|
|||||||
* [negentropySync] rather than opening a new one.
|
* [negentropySync] rather than opening a new one.
|
||||||
*/
|
*/
|
||||||
suspend fun sendNegentropyMessage(negMsgCmd: NegMsgCmd, relayUrl: String) {
|
suspend fun sendNegentropyMessage(negMsgCmd: NegMsgCmd, relayUrl: String) {
|
||||||
val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() }
|
val nostrSocketClient = socketClientFor(relayUrl)
|
||||||
?: throw press.mantra.compose.exceptions.NetworkException("$relayUrl is not connected")
|
?: throw press.mantra.compose.exceptions.NetworkException("$relayUrl is not connected")
|
||||||
|
|
||||||
nostrSocketClient.sendMESSAGE(OptimizedJsonMapper.toJson(negMsgCmd))
|
nostrSocketClient.sendMESSAGE(OptimizedJsonMapper.toJson(negMsgCmd))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun closeQuery(closeCmd: CloseCmd, relayUrl: String) {
|
suspend fun closeQuery(closeCmd: CloseCmd, relayUrl: String) {
|
||||||
|
releaseRequest(relayUrl = relayUrl, subId = closeCmd.subId)
|
||||||
|
|
||||||
addRelaysIfMissing(
|
addRelaysIfMissing(
|
||||||
setOf(
|
setOf(
|
||||||
NormalizedRelayUrl(relayUrl).url.toRelayDTO()
|
NormalizedRelayUrl(relayUrl).url.toRelayDTO()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() }
|
val nostrSocketClient = socketClientFor(relayUrl)
|
||||||
|
|
||||||
val closeSubscription = OptimizedJsonMapper.toJson(closeCmd)
|
val closeSubscription = OptimizedJsonMapper.toJson(closeCmd)
|
||||||
|
|
||||||
@@ -267,7 +376,7 @@ class RelayPool(
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.d("socketClients: ${socketClients.map { it.socketUrl }}")
|
logger.d("socketClients: ${socketClients.map { it.socketUrl }}")
|
||||||
val nostrSocketClient = socketClients.find { NormalizedRelayUrl(it.socketUrl).displayUrl() == NormalizedRelayUrl(relayUrl).displayUrl() }
|
val nostrSocketClient = socketClientFor(relayUrl)
|
||||||
|
|
||||||
val closeNegentropySubscription = OptimizedJsonMapper.toJson(negCloseCmd)
|
val closeNegentropySubscription = OptimizedJsonMapper.toJson(negCloseCmd)
|
||||||
|
|
||||||
|
|||||||
+9
@@ -9,6 +9,15 @@ interface NostrSocketClient {
|
|||||||
|
|
||||||
val incomingMessages: SharedFlow<NostrIncomingMessage>
|
val incomingMessages: SharedFlow<NostrIncomingMessage>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a socket that drops should be re-opened on its own, with backoff.
|
||||||
|
*
|
||||||
|
* Off by default, and owned by the pool rather than the socket: reconnecting a
|
||||||
|
* socket nobody is subscribed on is pure battery cost, so the pool turns this on
|
||||||
|
* for exactly as long as it is retaining at least one subscription for the relay.
|
||||||
|
*/
|
||||||
|
var autoReconnect: Boolean
|
||||||
|
|
||||||
suspend fun close()
|
suspend fun close()
|
||||||
|
|
||||||
@Throws(
|
@Throws(
|
||||||
|
|||||||
+4
@@ -27,6 +27,7 @@ object NostrSocketClientFactory {
|
|||||||
incomingCompressionEnabled: Boolean = false,
|
incomingCompressionEnabled: Boolean = false,
|
||||||
onSocketConnectionOpened: SocketConnectionOpenedCallback? = null,
|
onSocketConnectionOpened: SocketConnectionOpenedCallback? = null,
|
||||||
onSocketConnectionClosed: SocketConnectionClosedCallback? = null,
|
onSocketConnectionClosed: SocketConnectionClosedCallback? = null,
|
||||||
|
onSocketConnectionReopened: SocketConnectionReopenedCallback? = null,
|
||||||
): NostrSocketClient {
|
): NostrSocketClient {
|
||||||
return NostrSocketClientImpl(
|
return NostrSocketClientImpl(
|
||||||
httpClient = httpClient,
|
httpClient = httpClient,
|
||||||
@@ -34,6 +35,7 @@ object NostrSocketClientFactory {
|
|||||||
incomingCompressionEnabled = incomingCompressionEnabled,
|
incomingCompressionEnabled = incomingCompressionEnabled,
|
||||||
onSocketConnectionOpened = onSocketConnectionOpened,
|
onSocketConnectionOpened = onSocketConnectionOpened,
|
||||||
onSocketConnectionClosed = onSocketConnectionClosed,
|
onSocketConnectionClosed = onSocketConnectionClosed,
|
||||||
|
onSocketConnectionReopened = onSocketConnectionReopened,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,11 +44,13 @@ object NostrSocketClientFactory {
|
|||||||
incomingCompressionEnabled: Boolean = false,
|
incomingCompressionEnabled: Boolean = false,
|
||||||
onSocketConnectionOpened: SocketConnectionOpenedCallback? = null,
|
onSocketConnectionOpened: SocketConnectionOpenedCallback? = null,
|
||||||
onSocketConnectionClosed: SocketConnectionClosedCallback? = null,
|
onSocketConnectionClosed: SocketConnectionClosedCallback? = null,
|
||||||
|
onSocketConnectionReopened: SocketConnectionReopenedCallback? = null,
|
||||||
) = create(
|
) = create(
|
||||||
httpClient = defaultSocketsHttpClient,
|
httpClient = defaultSocketsHttpClient,
|
||||||
wssUrl = wssUrl,
|
wssUrl = wssUrl,
|
||||||
incomingCompressionEnabled = incomingCompressionEnabled,
|
incomingCompressionEnabled = incomingCompressionEnabled,
|
||||||
onSocketConnectionOpened = onSocketConnectionOpened,
|
onSocketConnectionOpened = onSocketConnectionOpened,
|
||||||
onSocketConnectionClosed = onSocketConnectionClosed,
|
onSocketConnectionClosed = onSocketConnectionClosed,
|
||||||
|
onSocketConnectionReopened = onSocketConnectionReopened,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+188
-22
@@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.IO
|
import kotlinx.coroutines.IO
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -21,6 +22,7 @@ import kotlinx.coroutines.isActive
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import okio.GzipSink
|
import okio.GzipSink
|
||||||
@@ -28,8 +30,14 @@ import okio.Inflater
|
|||||||
import okio.InflaterSource
|
import okio.InflaterSource
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import okio.use
|
import okio.use
|
||||||
|
import kotlin.concurrent.Volatile
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
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.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.uuid.ExperimentalUuidApi
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
@@ -40,15 +48,60 @@ internal class NostrSocketClientImpl(
|
|||||||
private val incomingCompressionEnabled: Boolean = false,
|
private val incomingCompressionEnabled: Boolean = false,
|
||||||
private val onSocketConnectionOpened: SocketConnectionOpenedCallback? = null,
|
private val onSocketConnectionOpened: SocketConnectionOpenedCallback? = null,
|
||||||
private val onSocketConnectionClosed: SocketConnectionClosedCallback? = null,
|
private val onSocketConnectionClosed: SocketConnectionClosedCallback? = null,
|
||||||
|
private val onSocketConnectionReopened: SocketConnectionReopenedCallback? = null,
|
||||||
) : NostrSocketClient {
|
) : NostrSocketClient {
|
||||||
|
|
||||||
val logger = Logger.withTag("NostrSocketClientImpl")
|
val logger = Logger.withTag("NostrSocketClientImpl")
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Wait before the first reconnect attempt; doubles per failure up to [MAX_RECONNECT_DELAY]. */
|
||||||
|
private val INITIAL_RECONNECT_DELAY = 1.seconds
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
private const val RECONNECT_JITTER = 0.25
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of a connect attempt, so the caller can tell a first connect from a repair. */
|
||||||
|
private enum class ConnectOutcome { ALREADY_CONNECTED, OPENED, REOPENED }
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
private val wsMutex = Mutex()
|
private val wsMutex = Mutex()
|
||||||
private var wsSession: WebSocketSession? = null
|
private var wsSession: WebSocketSession? = null
|
||||||
private var wsReceiverJob: Job? = null
|
private var wsReceiverJob: Job? = null
|
||||||
|
private var reconnectJob: Job? = null
|
||||||
|
|
||||||
|
/** Consecutive failed reconnect attempts; reset the moment a session is established. */
|
||||||
|
private var reconnectAttempts = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True once a session has been established, so the next one is a RE-connection and
|
||||||
|
* the pool owes it a subscription replay.
|
||||||
|
*/
|
||||||
|
private var hasConnected = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set by [close], cleared by the next caller-driven connect. A socket the client
|
||||||
|
* deliberately closed must stay closed — otherwise `closePool` would be answered by
|
||||||
|
* every socket in it politely reconnecting.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var closedByClient = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
override var autoReconnect: Boolean = false
|
||||||
|
|
||||||
private val _incomingMessages = MutableSharedFlow<NostrIncomingMessage>()
|
private val _incomingMessages = MutableSharedFlow<NostrIncomingMessage>()
|
||||||
override val incomingMessages = _incomingMessages.asSharedFlow()
|
override val incomingMessages = _incomingMessages.asSharedFlow()
|
||||||
@@ -56,20 +109,21 @@ internal class NostrSocketClientImpl(
|
|||||||
override val socketUrl = wssUrl.cleanWebSocketUrl()
|
override val socketUrl = wssUrl.cleanWebSocketUrl()
|
||||||
|
|
||||||
override suspend fun ensureSocketConnectionOrThrow() {
|
override suspend fun ensureSocketConnectionOrThrow() {
|
||||||
if (wsSession != null && wsSession?.isActive == true) return
|
if (isSessionActive()) return
|
||||||
|
|
||||||
wsMutex.withLock {
|
val outcome = wsMutex.withLock {
|
||||||
if (wsSession == null || wsSession?.isActive == false) {
|
// Asking for the socket is asking for it to stay up: clear the flag close()
|
||||||
wsSession = acquireWebSocketSession(socketUrl)
|
// set so a drop after this point reconnects again.
|
||||||
}
|
closedByClient = false
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun acquireWebSocketSession(url: String): WebSocketSession {
|
if (isSessionActive()) {
|
||||||
return try {
|
ConnectOutcome.ALREADY_CONNECTED
|
||||||
httpClient.webSocketSession(urlString = url).apply {
|
} else {
|
||||||
launchWebSocketReceiver()
|
wsSession = openSession()
|
||||||
onSocketConnectionOpened?.invoke(url)
|
// Assigned BEFORE the post-connect handshake below, because sendMESSAGE
|
||||||
|
// writes to `wsSession` rather than to the session it was handed — the
|
||||||
|
// compression REQ used to go to the previous (usually null) session and
|
||||||
|
// was silently dropped.
|
||||||
if (incomingCompressionEnabled) {
|
if (incomingCompressionEnabled) {
|
||||||
val id = Uuid.generateV4().toHexDashString()
|
val id = Uuid.generateV4().toHexDashString()
|
||||||
sendMESSAGE(
|
sendMESSAGE(
|
||||||
@@ -77,11 +131,35 @@ internal class NostrSocketClientImpl(
|
|||||||
ensureSessionBeforeSend = false,
|
ensureSessionBeforeSend = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val reopened = hasConnected
|
||||||
|
hasConnected = true
|
||||||
|
reconnectAttempts = 0
|
||||||
|
if (reopened) ConnectOutcome.REOPENED else ConnectOutcome.OPENED
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome == ConnectOutcome.ALREADY_CONNECTED) return
|
||||||
|
|
||||||
|
// Fired outside the lock. A callback that turns around and sends on this socket
|
||||||
|
// would otherwise queue behind a mutex the caller still holds.
|
||||||
|
onSocketConnectionOpened?.invoke(socketUrl)
|
||||||
|
if (outcome == ConnectOutcome.REOPENED) onSocketConnectionReopened?.invoke(socketUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSessionActive() = wsSession?.isActive == true
|
||||||
|
|
||||||
|
/** Opens a session and starts its receiver. Call with [wsMutex] held. */
|
||||||
|
private suspend fun openSession(): WebSocketSession {
|
||||||
|
return try {
|
||||||
|
httpClient.webSocketSession(urlString = socketUrl).apply { launchWebSocketReceiver() }
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
logger.w("NostrSocketClient::acquireWebSocketSession($socketUrl) failed.", error)
|
logger.w("NostrSocketClient::openSession($socketUrl) failed.", error)
|
||||||
close()
|
wsReceiverJob?.cancel()
|
||||||
|
wsReceiverJob = null
|
||||||
|
wsSession = null
|
||||||
onSocketConnectionClosed?.invoke(socketUrl, error)
|
onSocketConnectionClosed?.invoke(socketUrl, error)
|
||||||
|
scheduleReconnect()
|
||||||
throw press.mantra.compose.exceptions.NetworkException(cause = error)
|
throw press.mantra.compose.exceptions.NetworkException(cause = error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,6 +172,7 @@ internal class NostrSocketClientImpl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun WebSocketSession.receiveSocketMessages() {
|
private suspend fun WebSocketSession.receiveSocketMessages() {
|
||||||
|
var failure: Throwable? = null
|
||||||
try {
|
try {
|
||||||
for (frame in incoming) {
|
for (frame in incoming) {
|
||||||
when (frame) {
|
when (frame) {
|
||||||
@@ -112,8 +191,9 @@ internal class NostrSocketClientImpl(
|
|||||||
is Frame.Close -> {
|
is Frame.Close -> {
|
||||||
val closeReason = frame.readReason()
|
val closeReason = frame.readReason()
|
||||||
logger.w { "WS $socketUrl closed. [${closeReason?.code}, ${closeReason?.message}]" }
|
logger.w { "WS $socketUrl closed. [${closeReason?.code}, ${closeReason?.message}]" }
|
||||||
close()
|
// Leave the teardown to onSessionLost below. The relay closing us
|
||||||
onSocketConnectionClosed?.invoke(socketUrl, null)
|
// is not the client closing us, so this still earns a reconnect.
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> Unit
|
else -> Unit
|
||||||
@@ -124,23 +204,102 @@ internal class NostrSocketClientImpl(
|
|||||||
throw error
|
throw error
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
logger.w("NostrSocketClient::receiveSocketMessages() on $socketUrl failed.", error)
|
logger.w("NostrSocketClient::receiveSocketMessages() on $socketUrl failed.", error)
|
||||||
close()
|
failure = error
|
||||||
|
}
|
||||||
|
|
||||||
|
onSessionLost(session = this, error = failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single exit point for a session that ended without the client asking. Clears the
|
||||||
|
* session, tells the pool, and arms the reconnect.
|
||||||
|
*
|
||||||
|
* NonCancellable because the receiver job is cancelled as part of a replacement connect,
|
||||||
|
* and a cancelled cleanup would leave `wsSession` pointing at a dead socket.
|
||||||
|
*/
|
||||||
|
private suspend fun onSessionLost(session: WebSocketSession, error: Throwable?) =
|
||||||
|
withContext(NonCancellable) {
|
||||||
|
val wasCurrent = wsMutex.withLock {
|
||||||
|
// Identity check, not a null check: a reconnect may already have installed a
|
||||||
|
// newer session, and clearing that one would drop a healthy socket.
|
||||||
|
if (wsSession !== session) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
wsSession = null
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wasCurrent) return@withContext
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
session.close(
|
||||||
|
reason = CloseReason(
|
||||||
|
code = CloseReason.Codes.NORMAL,
|
||||||
|
message = "Session ended.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
onSocketConnectionClosed?.invoke(socketUrl, error)
|
onSocketConnectionClosed?.invoke(socketUrl, error)
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-opens a dropped socket with exponential backoff, for as long as the pool says it
|
||||||
|
* still wants one. Nothing in this app used to reconnect at all: a socket that failed
|
||||||
|
* stayed failed, and the only reason that was survivable is that every subscription was
|
||||||
|
* short and the next queued request opened a fresh socket on its way out.
|
||||||
|
*/
|
||||||
|
private fun scheduleReconnect() {
|
||||||
|
if (!autoReconnect || closedByClient) return
|
||||||
|
if (reconnectJob?.isActive == true) return
|
||||||
|
|
||||||
|
reconnectJob = scope.launch {
|
||||||
|
while (isActive && autoReconnect && !closedByClient) {
|
||||||
|
val attempt = ++reconnectAttempts
|
||||||
|
val wait = reconnectDelay(attempt)
|
||||||
|
logger.i { "Reconnecting to $socketUrl in $wait (attempt $attempt)" }
|
||||||
|
delay(wait)
|
||||||
|
|
||||||
|
if (!autoReconnect || closedByClient) return@launch
|
||||||
|
|
||||||
|
val reconnected = runCatching { ensureSocketConnectionOrThrow() }
|
||||||
|
if (reconnected.isSuccess) {
|
||||||
|
logger.i { "Reconnected to $socketUrl after $attempt attempt(s)" }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
override suspend fun close() {
|
||||||
wsReceiverJob?.cancel()
|
val session = wsMutex.withLock {
|
||||||
wsReceiverJob = null
|
closedByClient = true
|
||||||
|
val current = wsSession
|
||||||
|
wsSession = null
|
||||||
|
wsReceiverJob?.cancel()
|
||||||
|
wsReceiverJob = null
|
||||||
|
current
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectJob?.cancel()
|
||||||
|
reconnectJob = null
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
wsSession?.close(
|
session?.close(
|
||||||
reason = CloseReason(
|
reason = CloseReason(
|
||||||
code = CloseReason.Codes.NORMAL,
|
code = CloseReason.Codes.NORMAL,
|
||||||
message = "Closed by client.",
|
message = "Closed by client.",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
wsSession = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun processIncomingMessage(text: String) {
|
private fun processIncomingMessage(text: String) {
|
||||||
@@ -159,7 +318,14 @@ internal class NostrSocketClientImpl(
|
|||||||
ensureSocketConnectionOrThrow()
|
ensureSocketConnectionOrThrow()
|
||||||
}
|
}
|
||||||
logLargeText(text = text, url = socketUrl, incoming = false)
|
logLargeText(text = text, url = socketUrl, incoming = false)
|
||||||
wsSession?.send(Frame.Text(text = text))
|
val session = wsSession
|
||||||
|
if (session == null) {
|
||||||
|
// Used to be a silent `?.` no-op, which is how a dropped socket could swallow
|
||||||
|
// a send and leave the caller waiting on an answer to a message never sent.
|
||||||
|
logger.w { "Dropping a send to $socketUrl: no session" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.send(Frame.Text(text = text))
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendREQ(subscriptionId: String, data: JsonObject) {
|
override suspend fun sendREQ(subscriptionId: String, data: JsonObject) {
|
||||||
|
|||||||
+10
@@ -2,3 +2,13 @@ package press.mantra.compose.network.sockets
|
|||||||
|
|
||||||
typealias SocketConnectionOpenedCallback = (url: String) -> Unit
|
typealias SocketConnectionOpenedCallback = (url: String) -> Unit
|
||||||
typealias SocketConnectionClosedCallback = (url: String, error: Throwable?) -> Unit
|
typealias SocketConnectionClosedCallback = (url: String, error: Throwable?) -> Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired when a session is established on a socket that had already been connected
|
||||||
|
* once before — i.e. after a drop, not on the first connect.
|
||||||
|
*
|
||||||
|
* Deliberately separate from [SocketConnectionOpenedCallback]. The pool replays the
|
||||||
|
* subscriptions a socket was carrying when it comes back, and on a FIRST connect
|
||||||
|
* that replay would double-send the very REQ whose `sendMESSAGE` opened the socket.
|
||||||
|
*/
|
||||||
|
typealias SocketConnectionReopenedCallback = (url: String) -> Unit
|
||||||
Reference in new issue
Block a user