From 7b34438b0494644ec239475b03d0f2b641d6248a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 15 Jun 2026 21:50:25 -0400 Subject: [PATCH] fix: gate Tor-routed relay dials until Tor's SOCKS port is ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../com/vitorpamplona/amethyst/AppModules.kt | 17 +++++++++++++---- .../amethyst/service/okhttp/OkHttpWebSocket.kt | 4 ++++ .../client/single/basic/BasicRelayClient.kt | 6 ++++++ .../nip01Core/relay/sockets/WebsocketBuilder.kt | 15 +++++++++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 733ec3a8ce..80466dcc26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt index 9f2ad864a6..c89de32554 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt @@ -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() { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 3ffaca6d92..dfa0863451 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt index aef749f774..6e5619cdcd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/sockets/WebsocketBuilder.kt @@ -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 }