From 8cc198d1612257e05e31b33ca079e6854add111b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 02:03:23 +0000 Subject: [PATCH 1/2] fix: relay connectivity degradation with Tor by adding WebSocket pings, pool eviction, and reducing backoff Dead WebSocket connections through Tor were going undetected because no ping interval was set, leaving zombie connections that appeared connected but carried no traffic. Additionally, relay error backoff was set to ONE_DAY making recovery impossible without toggling Tor, and the shared OkHttp connection pool retained stale connections across proxy changes. - Add 120s WebSocket ping interval to detect dead Tor connections - Reduce dontTryAgainForALongTime from ONE_DAY to FIVE_MINUTES - Evict shared connection pool when proxy settings change https://claude.ai/code/session_01VFAypytKGzdmuoJAXrb72G --- .../amethyst/service/okhttp/OkHttpClientFactory.kt | 6 ++++++ .../service/okhttp/OkHttpClientFactoryForRelays.kt | 8 ++++++++ .../relay/client/single/basic/BasicRelayClient.kt | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index 73a7ff78b..0dcdbb60b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -46,10 +46,16 @@ class OkHttpClientFactory( .addNetworkInterceptor(keyDecryptor) .build() + private var lastProxy: Proxy? = null + fun buildHttpClient( proxy: Proxy?, timeoutSeconds: Int, ): OkHttpClient { + if (proxy != lastProxy) { + rootClient.connectionPool.evictAll() + lastProxy = proxy + } val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds return rootClient .newBuilder() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt index 1729027c3..556c91bf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt @@ -37,6 +37,7 @@ class OkHttpClientFactoryForRelays( const val DEFAULT_IS_MOBILE: Boolean = false const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10 const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30 + const val WEBSOCKET_PING_INTERVAL_SECS: Long = 120 private fun isEmulator(): Boolean = Build.FINGERPRINT.startsWith("generic") || @@ -76,10 +77,16 @@ class OkHttpClientFactoryForRelays( .addInterceptor(DefaultContentTypeInterceptor(userAgent)) .build() + private var lastProxy: Proxy? = null + fun buildHttpClient( proxy: Proxy?, timeoutSeconds: Int, ): OkHttpClient { + if (proxy != lastProxy) { + rootClient.connectionPool.evictAll() + lastProxy = proxy + } val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds return rootClient .newBuilder() @@ -87,6 +94,7 @@ class OkHttpClientFactoryForRelays( .connectTimeout(Duration.ofSeconds(seconds.toLong())) .readTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .writeTimeout(Duration.ofSeconds(seconds.toLong() * 3)) + .pingInterval(Duration.ofSeconds(WEBSOCKET_PING_INTERVAL_SECS)) .build() } 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 e83256988..33877f0f8 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 @@ -228,7 +228,7 @@ open class BasicRelayClient( } fun dontTryAgainForALongTime() { - delayToConnectInSeconds = TimeUtils.ONE_DAY + delayToConnectInSeconds = TimeUtils.FIVE_MINUTES } override fun sendOrConnectAndSync(cmd: Command) { From 7d12b5a1dd8d502c6fe59639853ca40dd093d0af Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 11:54:21 +0000 Subject: [PATCH 2/2] fix: clear stale Arti Tor cache on each fresh init, retry on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Arti data directory (/arti/) accumulates stale consensus and relay descriptors over time. When this cached data becomes invalid, TorClient::create_bootstrapped() either bootstraps with stale guards (leading to circuit failures) or fails outright. Since the native TorClient persists for the process lifetime, force-closing and reopening the app re-reads the same corrupted data. Only clearing all app data (which deletes the arti/ dir) would recover — matching the user report. - Clear arti/cache/ on each fresh initialization (new process) so Arti always downloads fresh consensus and relay descriptors - On initialize() failure, clear ALL arti data (state + cache) and retry once, recovering from corrupted state without requiring the user to manually clear app data https://claude.ai/code/session_01VFAypytKGzdmuoJAXrb72G --- .../amethyst/ui/tor/TorService.kt | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index 04540157a..b74f56753 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -54,6 +54,34 @@ class TorService( private val _status = MutableStateFlow(TorServiceStatus.Off) val status: StateFlow = _status.asStateFlow() + private fun artiDataDir() = File(context.filesDir, "arti") + + /** + * Clears the Arti cache directory (consensus, relay descriptors) while + * preserving the state directory (guard selection). This forces a fresh + * consensus download on the next bootstrap, preventing stale cached data + * from causing circuit failures. + */ + private fun clearArtiCache() { + val cacheDir = File(artiDataDir(), "cache") + if (cacheDir.exists()) { + cacheDir.deleteRecursively() + Log.d("TorService") { "Cleared Arti cache directory" } + } + } + + /** + * Clears all Arti persistent data (state + cache). Used as a last resort + * when initialization fails, to recover from corrupted state. + */ + private fun clearAllArtiData() { + val dataDir = artiDataDir() + if (dataDir.exists()) { + dataDir.deleteRecursively() + Log.d("TorService") { "Cleared all Arti data" } + } + } + /** * Initialize the TorClient (once) and start the SOCKS proxy. * Must be called from a coroutine on [Dispatchers.IO]. @@ -90,12 +118,21 @@ class TorService( } } - val dataDir = File(context.filesDir, "arti").absolutePath + // Clear cached consensus/descriptors so Arti bootstraps with + // fresh network data, preventing stale guards/circuits. + clearArtiCache() + + val dataDir = artiDataDir().absolutePath Log.d("TorService") { "Initializing Arti with data dir: $dataDir" } - val initResult = ArtiNative.initialize(dataDir) + var initResult = ArtiNative.initialize(dataDir) if (initResult != 0) { - Log.e("TorService") { "Failed to initialize Arti: error $initResult" } + Log.e("TorService") { "Failed to initialize Arti: error $initResult, clearing data and retrying" } + clearAllArtiData() + initResult = ArtiNative.initialize(dataDir) + } + if (initResult != 0) { + Log.e("TorService") { "Failed to initialize Arti on retry: error $initResult" } initialized.set(false) _status.value = TorServiceStatus.Off return@withContext