From 575fe952c648c1f0826a93e724b1d983ee799fd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 20:17:29 +0000 Subject: [PATCH 1/2] fix: auto-reconnect relays after server-initiated disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, when a relay closed the WebSocket (or the connection dropped), NostrClient.onDisconnected only updated state and notified listeners — it never tried to reconnect. The relay then stayed disconnected until the next subscribe/count/publish call (which triggers reconnect()) or an explicit reconnect() from the caller. Now, if the client is still active and the relay is still in the desired set (i.e. some sub/count/outbox still wants it), onDisconnected schedules a debounced reconnect via reconnectIfNeedsTo, which respects per-relay exponential backoff so we don't hammer dead relays. --- .../quartz/nip01Core/relay/client/NostrClient.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 91b1952d99..824158bdaf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -295,6 +295,18 @@ class NostrClient( override fun onDisconnected(relay: IRelayClient) { activeRequests.onDisconnected(relay.url) listeners.forEach { it.onDisconnected(relay) } + + // If the client is still active and the relay is still in the + // desired set (i.e. there are subscriptions, counts or pending + // outbox events that want it), reconnect it. Without this, a + // server-initiated close leaves the relay disconnected until a + // subscription change or an explicit reconnect() arrives. + // The reconnect path is debounced and goes through + // reconnectIfNeedsTo, which respects each relay's exponential + // backoff so we don't hammer dead relays. + if (isActive && relay.url in allRelays.value) { + reconnect(onlyIfChanged = true) + } } override fun onCannotConnect( From 709e254c15b837d8a6cb33f78d6b9c59c4fa99a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 20:28:02 +0000 Subject: [PATCH 2/2] fix: periodic keep-alive to revive relays in long backoff When a relay enters a long backoff (5 min, e.g. host unreachable or a server returned an HTTP error during handshake), the per-relay delayToConnectInSeconds blocks reconnect attempts for up to 5 minutes. Without a wakeup, nothing inside NostrClient revisits that relay until the next subscribe/count/publish. Add a keep-alive coroutine that calls reconnectIfNeedsTo(false) every 60s while the client is active. The per-relay backoff still gates the actual reconnect, so dead relays are not hammered, but a relay whose backoff window has elapsed is reconnected within ~60s of becoming eligible. The job lives in scope and is cancelled by close(). --- .../nip01Core/relay/client/NostrClient.kt | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 824158bdaf..84d2218067 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.IO import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -157,6 +158,27 @@ class NostrClient( false, ) + /** + * Periodically wakes up disconnected relays. Without this, a relay + * that hit a long backoff (5 min, e.g. host unreachable or a server + * error code) would stay disconnected forever in the absence of any + * subscription change. The per-relay [BasicRelayClient] backoff still + * gates the actual reconnect attempt, so dead relays are not hammered. + */ + private val keepAliveJob = + scope.launch { + while (true) { + delay(KEEP_ALIVE_INTERVAL_MS) + if (this@NostrClient.isActive) { + relayPool.reconnectIfNeedsTo(ignoreRetryDelays = false) + } + } + } + + companion object { + private const val KEEP_ALIVE_INTERVAL_MS = 60_000L + } + override fun reconnect( onlyIfChanged: Boolean, ignoreRetryDelays: Boolean,