fix: gate Tor-routed relay dials until Tor's SOCKS port is ready

Before Tor finishes bootstrapping, the relay pool dialed every Tor-routed
relay against the not-yet-listening SOCKS proxy. On a cold start this was
~580 doomed dials (all "SOCKS: Connection refused") concentrated in the
seconds before Tor went Active, churning sockets/CPU and inflating each
relay's backoff. The cost scaled with bootstrap latency, and the same
storm recurred on every network switch (which resets and re-bootstraps Arti).

Add an optional WebsocketBuilder.canConnect(url) gate (defaults to true,
so other implementors are untouched), checked at the top of
BasicRelayClient.connect() before the mutex/onConnecting/build — so a
gated relay opens no socket, fires no listener events, and grows no
backoff. The Android builder gates Tor-routed relays on
torManager.isSocksReady(); RelayProxyClientConnector already reconnects
them with ignoreRetryDelays=true the instant Tor flips to Active, so they
dial as soon as the transport is usable.

Measured on-device: pre-ready doomed Tor dials 581 -> 0 across cold starts
and WiFi<->Mobile switches; clearnet connections stay untouched and Tor
relays self-heal once Tor is Active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-16 08:41:06 -04:00
co-authored by Claude Opus 4.8
parent 085e2466e6
commit 7b34438b04
4 changed files with 38 additions and 4 deletions
@@ -427,10 +427,19 @@ class AppModules(
// Connects the INostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
val useTor = torEvaluatorFlow.shouldUseTorForRelay(url)
okHttpClientForRelays.getHttpClient(useTor)
}
OkHttpWebSocket.Builder(
httpClient = { url ->
val useTor = torEvaluatorFlow.shouldUseTorForRelay(url)
okHttpClientForRelays.getHttpClient(useTor)
},
// Don't dial Tor-routed relays until Tor's SOCKS port is up. Otherwise the
// whole Tor-routed relay set is hammered with doomed dials against the dead
// proxy during bootstrap. RelayProxyClientConnector reconnects them (with
// ignoreRetryDelays=true) the instant Tor flips to Active.
canDial = { url ->
!torEvaluatorFlow.shouldUseTorForRelay(url) || torManager.isSocksReady()
},
)
// Caches all events in Memory
val cache: LocalCache = LocalCache
@@ -133,12 +133,16 @@ class OkHttpWebSocket(
class Builder(
val httpClient: (NormalizedRelayUrl) -> OkHttpClient,
val canDial: (NormalizedRelayUrl) -> Boolean = { true },
) : WebsocketBuilder {
// Called when connecting.
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
) = OkHttpWebSocket(url, httpClient, out)
// Gates the dial — false skips it (e.g. a Tor-routed relay before Tor is ready).
override fun canConnect(url: NormalizedRelayUrl) = canDial(url)
}
override fun disconnect() {
@@ -103,6 +103,12 @@ open class BasicRelayClient(
override fun needsToReconnect() = socket?.needsReconnect() ?: true
override fun connect() {
// Transport gate: skip the dial when the builder reports this relay's transport
// isn't ready (e.g. a Tor-routed relay while Tor's SOCKS port isn't up). Returning
// here before the mutex/socket/onConnecting means no doomed dial and no backoff
// growth; a later reconnect pass (fired when the transport becomes ready) will dial.
if (!socketBuilder.canConnect(url)) return
// If there is a connection, don't wait.
if (connectingMutex.exchange(true)) {
return
@@ -27,4 +27,19 @@ interface WebsocketBuilder {
url: NormalizedRelayUrl,
out: WebSocketListener,
): WebSocket
/**
* Whether the transport for [url] is ready to dial right now. Returning false makes
* [com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient.connect]
* skip the dial entirely — no socket, no backoff growth — until a later reconnect pass
* finds it ready.
*
* The motivating case: a Tor-routed relay while Tor's SOCKS proxy isn't up yet. Without
* this gate the pool hammers the dead proxy with doomed dials during the whole Tor
* bootstrap window. The caller is responsible for re-triggering a reconnect once the
* transport becomes ready (e.g. on the Tor status flipping to Active).
*
* Defaults to true so non-proxied builders need no change.
*/
fun canConnect(url: NormalizedRelayUrl): Boolean = true
}