Merge pull request #3223 from vitorpamplona/fix/gate-tor-relays-until-ready

Gate Tor-routed relay dials until Tor is ready
This commit is contained in:
Vitor Pamplona
2026-06-16 09:44:53 -04:00
committed by GitHub
5 changed files with 46 additions and 5 deletions
@@ -427,10 +427,19 @@ class AppModules(
// Connects the INostrClient class with okHttp // Connects the INostrClient class with okHttp
val websocketBuilder = val websocketBuilder =
OkHttpWebSocket.Builder { url -> OkHttpWebSocket.Builder(
httpClient = { url ->
val useTor = torEvaluatorFlow.shouldUseTorForRelay(url) val useTor = torEvaluatorFlow.shouldUseTorForRelay(url)
okHttpClientForRelays.getHttpClient(useTor) 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 // Caches all events in Memory
val cache: LocalCache = LocalCache val cache: LocalCache = LocalCache
@@ -133,12 +133,16 @@ class OkHttpWebSocket(
class Builder( class Builder(
val httpClient: (NormalizedRelayUrl) -> OkHttpClient, val httpClient: (NormalizedRelayUrl) -> OkHttpClient,
val canDial: (NormalizedRelayUrl) -> Boolean = { true },
) : WebsocketBuilder { ) : WebsocketBuilder {
// Called when connecting. // Called when connecting.
override fun build( override fun build(
url: NormalizedRelayUrl, url: NormalizedRelayUrl,
out: WebSocketListener, out: WebSocketListener,
) = OkHttpWebSocket(url, httpClient, out) ) = 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() { override fun disconnect() {
@@ -27,7 +27,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.test.Test import kotlin.test.Test
@@ -87,7 +89,12 @@ class RelayHealthStoreCloseTest {
ioDispatcher = dispatcher, ioDispatcher = dispatcher,
) )
advanceUntilIdle() // NB: the store's init launches an always-on `while(true){ reclassify(); delay(60s) }`
// ticker on this shared test scheduler. advanceUntilIdle() would chase that periodic
// delay forever (livelock). Advance just past the persist debounce instead so init's
// debounced save fires while the ticker stays parked at its 60s mark.
advanceTimeBy(RelayHealthStore.PERSIST_DEBOUNCE_MS + 1)
runCurrent()
val baseline = persistence.saves val baseline = persistence.saves
store.close() store.close()
@@ -103,6 +103,12 @@ open class BasicRelayClient(
override fun needsToReconnect() = socket?.needsReconnect() ?: true override fun needsToReconnect() = socket?.needsReconnect() ?: true
override fun connect() { 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 there is a connection, don't wait.
if (connectingMutex.exchange(true)) { if (connectingMutex.exchange(true)) {
return return
@@ -27,4 +27,19 @@ interface WebsocketBuilder {
url: NormalizedRelayUrl, url: NormalizedRelayUrl,
out: WebSocketListener, out: WebSocketListener,
): WebSocket ): 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
} }