refactor: simplify Tor-bootstrap reconnect fix to a transport-change check

Replaces the per-relay backoff-token approach (opaque WebsocketBuilder
config token + BasicRelayClient bookkeeping) with a much smaller check at
the source.

The connector already holds the two OkHttp clients, and those are rebuilt
only when something connection-relevant changes (Tor's SOCKS port appears,
wifi<->cellular switch). Everything else it wakes on — Tor bootstrap status
churn, connectivity blips, self-heal restarts — leaves the clients
untouched. So instead of threading a config token through quartz, the
connector now forces a backoff-skipping reconnect (ignoreRetryDelays=true)
only when an OkHttp client instance actually changed; otherwise it lets
each relay's exponential backoff decide. That stops the
reconnect-fail-reconnect loop while Tor boots, and still reconnects every
relay (Tor and clearnet alike) the instant the transport changes.

Reverts the quartz/OkHttpWebSocket changes from the previous commit and
wires the connector to take the StateFlows it actually consumes (the two
client flows + connectivity/tor status) instead of the manager objects,
which also decouples it from DualHttpClientManagerForRelays/
ConnectivityManager/TorManager.

https://claude.ai/code/session_01SCz8kdYs2FwesEyzbhmRPY
This commit is contained in:
Claude
2026-05-29 23:41:35 +00:00
parent e1a651e72e
commit 546007412d
6 changed files with 47 additions and 205 deletions
@@ -475,9 +475,10 @@ class AppModules(
val relayProxyClientConnector =
RelayProxyClientConnector(
torEvaluatorFlow.flow,
okHttpClientForRelays,
connManager,
torManager,
okHttpClientForRelays.defaultHttpClient,
okHttpClientForRelays.defaultHttpClientWithoutProxy,
connManager.status,
torManager.status,
client,
applicationIOScope,
)
@@ -34,30 +34,6 @@ import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.net.Proxy
/**
* Value-comparable fingerprint of the relevant parts of an [OkHttpClient] for a relay
* connection: the proxy (Tor SOCKS or none) and the timeouts. Two clients with the same
* fingerprint connect the same way, so a relay does not need to reconnect — and a forced
* reconnect should not skip the backoff — when the fingerprint is unchanged.
*/
private data class RelayTransportConfig(
val proxy: Proxy?,
val connectTimeoutMillis: Int,
val readTimeoutMillis: Int,
val writeTimeoutMillis: Int,
val callTimeoutMillis: Int,
)
private fun OkHttpClient.relayTransportConfig() =
RelayTransportConfig(
proxy = proxy,
connectTimeoutMillis = connectTimeoutMillis,
readTimeoutMillis = readTimeoutMillis,
writeTimeoutMillis = writeTimeoutMillis,
callTimeoutMillis = callTimeoutMillis,
)
class OkHttpWebSocket(
val url: NormalizedRelayUrl,
@@ -74,7 +50,21 @@ class OkHttpWebSocket(
val myUsingOkHttp = usingOkHttp ?: return true
return myUsingOkHttp.relayTransportConfig() != httpClient(url).relayTransportConfig()
val currentOkHttp = httpClient(url)
val usingProxy = myUsingOkHttp.proxy
val currentProxy = currentOkHttp.proxy
if (usingProxy != null && currentProxy != null && usingProxy != currentProxy) return true
if (usingProxy == null && currentProxy != null) return true
if (usingProxy != null && currentProxy == null) return true
if (currentOkHttp.readTimeoutMillis != myUsingOkHttp.readTimeoutMillis) return true
if (currentOkHttp.writeTimeoutMillis != myUsingOkHttp.writeTimeoutMillis) return true
if (currentOkHttp.connectTimeoutMillis != myUsingOkHttp.connectTimeoutMillis) return true
if (currentOkHttp.callTimeoutMillis != myUsingOkHttp.callTimeoutMillis) return true
return false
}
override fun connect() {
@@ -149,11 +139,6 @@ class OkHttpWebSocket(
url: NormalizedRelayUrl,
out: WebSocketListener,
) = OkHttpWebSocket(url, httpClient, out)
// Proxy + timeout fingerprint of the client this url would use right now.
// BasicRelayClient compares it against the last attempt to decide whether a
// forced reconnect may skip the exponential backoff.
override fun connectionConfig(url: NormalizedRelayUrl): Any = httpClient(url).relayTransportConfig()
}
override fun disconnect() {
@@ -21,10 +21,7 @@
package com.vitorpamplona.amethyst.service.relayClient
import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.utils.Log
@@ -44,9 +41,10 @@ import okhttp3.OkHttpClient
class RelayProxyClientConnector(
val torEvaluator: StateFlow<TorRelayEvaluation>,
val okHttpClients: DualHttpClientManagerForRelays,
val connManager: ConnectivityManager,
val torManager: TorManager,
val torConnection: StateFlow<OkHttpClient>,
val clearConnection: StateFlow<OkHttpClient>,
val connectivityStatus: StateFlow<ConnectivityStatus>,
val torStatus: StateFlow<TorServiceStatus>,
val client: INostrClient,
val scope: CoroutineScope,
) {
@@ -58,14 +56,21 @@ class RelayProxyClientConnector(
val torStatus: TorServiceStatus,
)
// The OkHttp clients in use the last time we forced a reconnect. These are only
// rebuilt when something connection-relevant changes (Tor's SOCKS port appears,
// wifi<->cellular switch), so comparing them tells us whether a wakeup is a real
// transport change or just noise (Tor bootstrap status churn, connectivity blips).
private var lastTorConnection: OkHttpClient? = null
private var lastClearConnection: OkHttpClient? = null
@OptIn(FlowPreview::class)
val relayServices =
combine(
torEvaluator,
okHttpClients.defaultHttpClient,
okHttpClients.defaultHttpClientWithoutProxy,
connManager.status,
torManager.status,
torConnection,
clearConnection,
connectivityStatus,
torStatus,
) { torSettings, torConnection, clearConnection, connectivity, torStatus ->
RelayServiceInfra(torSettings, torConnection, clearConnection, connectivity, torStatus)
}.debounce(100)
@@ -97,10 +102,19 @@ class RelayProxyClientConnector(
}
else -> {
Log.d("ManageRelayServices", "Relay Services have changed, reconnecting relays that need to")
// Only skip the per-relay exponential backoff when the actual HTTP
// transport changed. Otherwise (e.g. Tor still bootstrapping, the SOCKS
// port not yet listening) honor each relay's backoff so we don't
// reconnect-fail-reconnect on every unrelated infrastructure event.
val transportChanged =
it.torConnection !== lastTorConnection || it.clearConnection !== lastClearConnection
lastTorConnection = it.torConnection
lastClearConnection = it.clearConnection
Log.d("ManageRelayServices", "Relay Services have changed, reconnecting relays that need to (transportChanged=$transportChanged)")
client.reconnect(
onlyIfChanged = true,
ignoreRetryDelays = true,
ignoreRetryDelays = transportChanged,
)
}
}
@@ -74,11 +74,6 @@ open class BasicRelayClient(
private var lastConnectTentativeInSeconds: Long = 0L // the beginning of time.
private var delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
// The transport config (proxy, timeouts) used on the last connection attempt.
// Lets a forced reconnect tell whether the situation that caused the failures
// actually changed (e.g. Tor finally came up) before skipping the backoff.
private var lastAttemptConfig: Any? = null
// Makes sure only one socket is open for each url
private var connectingMutex = AtomicBoolean(false)
@@ -103,7 +98,6 @@ open class BasicRelayClient(
listener.onConnecting(this)
lastConnectTentativeInSeconds = TimeUtils.now()
lastAttemptConfig = socketBuilder.connectionConfig(url)
socket = socketBuilder.build(url, MyWebsocketListener())
socket?.connect()
@@ -219,31 +213,14 @@ open class BasicRelayClient(
override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) {
if (!isConnectionStarted() && !connectingMutex.load()) {
// A forced reconnect (ignoreRetryDelays) only skips the backoff when this
// relay's transport config actually changed since the last attempt.
// Otherwise we honor the exponential backoff, so a relay that keeps failing
// under the same config (e.g. a Tor relay while Tor is still booting and the
// SOCKS port is not yet listening) is not reconnected-failed-reconnected on
// every unrelated infrastructure event.
if ((ignoreRetryDelays && transportConfigChanged()) ||
TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds
) {
// waits 60 seconds to reconnect after disconnected.
if (ignoreRetryDelays || TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) {
upRelayDelayToConnect()
connect()
}
}
}
/**
* True when the transport config this relay would use now differs from the one used
* on the last attempt — or when the socket builder doesn't track configs (returns
* null), in which case a requested bypass is always honored (legacy behavior).
*/
private fun transportConfigChanged(): Boolean {
val current = socketBuilder.connectionConfig(url)
return current == null || current != lastAttemptConfig
}
fun upRelayDelayToConnect() {
if (delayToConnectInSeconds < TimeUtils.FIVE_MINUTES) {
delayToConnectInSeconds = delayToConnectInSeconds * 2
@@ -27,20 +27,4 @@ interface WebsocketBuilder {
url: NormalizedRelayUrl,
out: WebSocketListener,
): WebSocket
/**
* Returns an opaque, value-comparable token describing the transport
* configuration (proxy, timeouts, ...) this builder would currently use for
* [url]. [com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient]
* stores the token of its last connection attempt and, when a reconnect asks
* to ignore the backoff, only grants the bypass if this token changed since
* that attempt. That way a relay that keeps failing under the *same* config
* (e.g. a Tor relay while Tor is still bootstrapping and the SOCKS port is not
* yet listening) keeps honoring its exponential backoff instead of being
* hammered on every unrelated infrastructure event.
*
* The default returns `null`, which the client treats as "untracked" and
* therefore always honors the requested bypass (legacy behavior).
*/
fun connectionConfig(url: NormalizedRelayUrl): Any? = null
}
@@ -1,119 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.client.single.basic
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Regression tests for the per-relay backoff bypass in [BasicRelayClient].
*
* A forced reconnect (`ignoreRetryDelays = true`) must only skip the exponential
* backoff when the relay's transport config actually changed since the last failed
* attempt. This is what stops Tor relays from reconnect-fail-reconnecting on every
* infrastructure event while Tor is still bootstrapping (the SOCKS port is unchanged,
* so the config token is unchanged, so the backoff is honored).
*/
class BasicRelayClientBackoffTest {
private val url = NormalizedRelayUrl("wss://relay.test")
/**
* Fake transport: every [WebSocket.connect] immediately reports a Tor-style
* connection failure, returning the relay to the disconnected state. [config]
* is the value-comparable transport token the builder reports; tests mutate it
* to simulate Tor coming up / the proxy changing.
*/
private class FakeBuilder(
var config: Any?,
) : WebsocketBuilder {
var connectAttempts = 0
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
): WebSocket =
object : WebSocket {
override fun needsReconnect() = true
override fun connect() {
connectAttempts++
// Simulate a Tor SOCKS port that isn't listening yet. The "failed to
// connect to /127.0.0.1" message is the ignored-error path, so no long
// backoff is forced — only the normal doubling applies.
out.onFailure(RuntimeException("failed to connect to /127.0.0.1:9050"), null, null)
}
override fun disconnect() {}
override fun send(msg: String) = true
}
override fun connectionConfig(url: NormalizedRelayUrl): Any? = config
}
@Test
fun forcedReconnectHonorsBackoffWhenConfigUnchanged() {
val builder = FakeBuilder(config = "tor:9050:booting")
val relay = BasicRelayClient(url, builder, EmptyConnectionListener)
// First forced attempt: nothing has connected yet, so it fires and fails.
relay.connectAndSyncFiltersIfDisconnected(ignoreRetryDelays = true)
assertEquals(1, builder.connectAttempts)
// Tor is still booting: same config token. A second forced reconnect must NOT
// bypass the backoff (the failing situation hasn't changed), so no new attempt.
relay.connectAndSyncFiltersIfDisconnected(ignoreRetryDelays = true)
relay.connectAndSyncFiltersIfDisconnected(ignoreRetryDelays = true)
assertEquals(1, builder.connectAttempts)
}
@Test
fun forcedReconnectBypassesBackoffWhenConfigChanged() {
val builder = FakeBuilder(config = "tor:9050:booting")
val relay = BasicRelayClient(url, builder, EmptyConnectionListener)
relay.connectAndSyncFiltersIfDisconnected(ignoreRetryDelays = true)
assertEquals(1, builder.connectAttempts)
// Tor finished bootstrapping: the proxy this relay would use changed. A forced
// reconnect must now bypass the backoff and try immediately.
builder.config = "tor:17392:active"
relay.connectAndSyncFiltersIfDisconnected(ignoreRetryDelays = true)
assertEquals(2, builder.connectAttempts)
}
@Test
fun untrackedBuilderAlwaysHonorsForcedReconnect() {
// A builder that doesn't track configs (returns null) keeps the legacy behavior:
// a forced reconnect always tries immediately.
val builder = FakeBuilder(config = null)
val relay = BasicRelayClient(url, builder, EmptyConnectionListener)
relay.connectAndSyncFiltersIfDisconnected(ignoreRetryDelays = true)
relay.connectAndSyncFiltersIfDisconnected(ignoreRetryDelays = true)
assertEquals(2, builder.connectAttempts)
}
}