From 77834cfd92a138d8bb91ef8e993dab7f3ffc9f6a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 18:54:06 +0000 Subject: [PATCH] fix(relay): derive connected set from pool state instead of patching it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the incremental add/remove maintenance of _connectedRelays (including the removeRelayInner prune) with a recompute from the source of truth: a relay is connected iff it is in the pool AND its socket reports ready (isConnected()). refreshConnectedRelays() runs on connect, disconnect and pool-membership changes. The earlier prune patched the *readout* on the assumption that "removed from pool ⟹ disconnected", which is only incidentally true. A set that is hand-maintained per event drifts from reality whenever an event is missed — OkHttp's async cancel() callback being dropped under mass teardown, or a socket dying without an onDisconnected. Projecting the set from each pooled relay's actual isConnected() can't drift: removed relays are already disconnected so they fall out, and a silently-dead socket stops being counted on the next refresh. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ukw6FJPFh3JKGXL532p3ae --- .../nip01Core/relay/client/pool/RelayPool.kt | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index a69ccbf9b8..9e46e81ba0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -172,6 +172,9 @@ class RelayPool( if (atLeastOne) { _availableRelays.update { relays.keys() } + // Removed relays were just disconnected; reflect that in the connected set + // now rather than waiting for their (possibly dropped) onDisconnected callback. + refreshConnectedRelays() } } @@ -199,14 +202,6 @@ class RelayPool( val relayInPool = relays.remove(relay) if (relayInPool != null) { relayInPool.disconnect() - // Reflect the disconnect immediately. disconnect() uses OkHttp cancel(), - // whose onClosed/onFailure callback — the only other path that prunes - // _connectedRelays — is async and, when cancelling many sockets at once - // (e.g. a feed teardown when the app backgrounds), frequently never arrives. - // That leaves the connected set stale while the pool itself has already - // shrunk. The callback, if it does fire later, repeats this subtraction - // idempotently. - _connectedRelays.update { it - relay } return true } return false @@ -215,6 +210,7 @@ class RelayPool( fun removeRelay(relay: NormalizedRelayUrl) { if (removeRelayInner(relay)) { _availableRelays.update { relays.keys() } + refreshConnectedRelays() } } @@ -223,6 +219,26 @@ class RelayPool( disconnect() relays.clear() _availableRelays.update { emptySet() } + refreshConnectedRelays() + } + } + + /** + * Recomputes [_connectedRelays] from the source of truth: a relay is connected iff it + * is currently in the pool AND its socket reports ready ([IRelayClient.isConnected]). + * + * This is a pure projection of the pool rather than a set we add to / remove from on + * each event, so it cannot drift from reality. An incrementally maintained set goes + * stale whenever a state change isn't observed — e.g. OkHttp's async cancel() callback + * is dropped under mass teardown, or a socket dies without an onDisconnected — and then + * reports relays as connected that no longer are. Relays removed from the pool have + * already been disconnected (isConnected() == false), so they fall out here naturally. + */ + private fun refreshConnectedRelays() { + val connected = mutableSetOf() + relays.forEach { url, relay -> if (relay.isConnected()) connected.add(url) } + if (_connectedRelays.value != connected) { + _connectedRelays.value = connected } } @@ -236,12 +252,12 @@ class RelayPool( pingMillis: Int, compressed: Boolean, ) { - _connectedRelays.update { it + relay.url } + refreshConnectedRelays() listener.onConnected(relay, pingMillis, compressed) } override fun onDisconnected(relay: IRelayClient) { - _connectedRelays.update { it - relay.url } + refreshConnectedRelays() listener.onDisconnected(relay) }