From 0d3bb4ad7358eecb3f4d71e3daf90e741cb53cb7 Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 5 Oct 2026 16:16:06 +0530 Subject: [PATCH] Stop reporting Tor as connected once its circuits fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arti's bootstrap milestones only fire on the way up. "We have found that guard [scrubbed] is usable." latches the session to RUNNING at 100% and calls stopInactivityMonitoring(), and from there nothing downgrades the status except a deliberate stop or a state-file lock conflict. So when the network goes away after bootstrap, Arti logs a continuous stream of "Failed to connect through Tor" / "SOCKS connection error" while the header keeps showing the connected colour, which is gated on running && bootstrapPercent >= 100. isProxyEnabled() agrees, so requests keep being routed into a SOCKS port that cannot reach an exit. Track circuit failures after bootstrap and drop the published status below 100% once they both accumulate and persist. Individual failures are a normal part of Tor, so a burst inside one instant — which is what the report shows, three threads failing in the same millisecond — is ignored; it takes four failures still going twenty seconds later. Downgrading to BOOTSTRAPPING rather than ERROR keeps running = true, so callers fail closed through awaitSelectedRoute instead of leaking to clearnet, the inactivity watchdog re-arms now that bootstrapPercent is below 100, and the existing backoff retry gets to re-establish the session. A successful re-bootstrap clears the tally and restores RUNNING. The policy is kept free of Android and coroutine dependencies so the thresholds are directly testable. Refs #610 --- .../com/bitchat/android/net/ArtiTorManager.kt | 28 ++++++++ .../android/net/TorCircuitHealthPolicy.kt | 64 +++++++++++++++++++ .../com/bitchat/android/util/AppConstants.kt | 8 +++ 3 files changed, 100 insertions(+) create mode 100644 app/src/main/java/com/bitchat/android/net/TorCircuitHealthPolicy.kt diff --git a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt index b1eed65e..1a93e8d7 100644 --- a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt +++ b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt @@ -93,6 +93,7 @@ class ArtiTorManager private constructor() { private var inactivityJob: Job? = null private var retryJob: Job? = null private var currentApplication: Application? = null + private val circuitHealth = TorCircuitHealthPolicy() private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING } @@ -484,6 +485,7 @@ class ArtiTorManager private constructor() { } retryAttempts = 0 bindRetryAttempts = 0 + circuitHealth.reset() startInactivityMonitoring() } @@ -498,10 +500,36 @@ class ArtiTorManager private constructor() { running = true ) } + circuitHealth.reset() stopInactivityMonitoring() completeWaitersIf(TorState.RUNNING) } + // Bootstrap milestones only fire on the way up, so a session that loses every exit + // circuit after reaching RUNNING would otherwise stay pinned at 100% while every + // request through it fails. + circuitHealth.isCircuitFailure(s) -> { + if (currentState != TorState.RUNNING || currentLifecycle != LifecycleState.RUNNING) { + return + } + if (!circuitHealth.onCircuitFailure(System.currentTimeMillis())) { + return + } + circuitHealth.reset() + Log.w(TAG, "Tor circuits failing after bootstrap; no longer reporting connected") + // Drop below 100% rather than to ERROR: callers fail closed instead of leaking + // to clearnet, the indicator stops claiming a working Tor, and the existing + // watchdog plus retry path get another chance to re-establish the session. + _statusFlow.update { + it.copy( + state = TorState.BOOTSTRAPPING, + bootstrapPercent = 75 + ) + } + startInactivityMonitoring() + currentApplication?.let { scheduleRetry(it) } + } + s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> { if (currentLifecycle != LifecycleState.STOPPING) { return diff --git a/app/src/main/java/com/bitchat/android/net/TorCircuitHealthPolicy.kt b/app/src/main/java/com/bitchat/android/net/TorCircuitHealthPolicy.kt new file mode 100644 index 00000000..5a79be2a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/net/TorCircuitHealthPolicy.kt @@ -0,0 +1,64 @@ +package com.bitchat.android.net + +import com.bitchat.android.util.AppConstants + +/** + * Decides when a Tor session that already finished bootstrapping should stop being reported as + * connected. + * + * Arti's bootstrap milestones only fire on the way up. Once a guard is usable the session is + * latched to 100%, and losing every exit circuit afterwards produces a stream of SOCKS errors + * that no bootstrap milestone contradicts — so the indicator keeps claiming a working Tor. + * + * Circuit failures are a normal part of Tor operation, so a single burst must not move the + * indicator. A downgrade requires [failureThreshold] failures that also span at least + * [minSpanMs], which separates "three parallel attempts lost a flaky circuit" from "still + * failing twenty seconds later". Progress on the wire clears the tally. + * + * Kept free of Android and coroutine dependencies so the policy is directly testable. + */ +internal class TorCircuitHealthPolicy( + private val failureThreshold: Int = AppConstants.Tor.CIRCUIT_FAILURE_THRESHOLD, + private val minSpanMs: Long = AppConstants.Tor.CIRCUIT_FAILURE_MIN_SPAN_MS, + private val windowMs: Long = AppConstants.Tor.CIRCUIT_FAILURE_WINDOW_MS +) { + private companion object { + // Arti emits these once it cannot build or use an exit circuit. Matched as substrings in + // the same style as the bootstrap milestones in ArtiTorManager. + val FAILURE_MARKERS = listOf( + "Failed to connect through Tor", + "SOCKS connection error", + "Failed to obtain exit circuit" + ) + } + + private var failureCount = 0 + private var windowStartedAtMs = 0L + + fun isCircuitFailure(line: String): Boolean = + FAILURE_MARKERS.any { line.contains(it, ignoreCase = true) } + + /** + * Records one circuit failure at [nowMs] and reports whether the session has now failed for + * long enough, and often enough, to stop being advertised as connected. + */ + @Synchronized + fun onCircuitFailure(nowMs: Long): Boolean { + val elapsed = nowMs - windowStartedAtMs + // A stale window, or a clock that moved backwards, starts counting again. + if (failureCount == 0 || elapsed > windowMs || elapsed < 0L) { + windowStartedAtMs = nowMs + failureCount = 1 + return false + } + + failureCount++ + return failureCount >= failureThreshold && elapsed >= minSpanMs + } + + @Synchronized + fun reset() { + failureCount = 0 + windowStartedAtMs = 0L + } +} diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt index b1822806..2b214cce 100644 --- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt +++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt @@ -115,6 +115,14 @@ object AppConstants { const val INACTIVITY_TIMEOUT_MS: Long = 5_000L const val MAX_RETRY_ATTEMPTS: Int = 5 const val STOP_TIMEOUT_MS: Long = 7_000L + + // Post-bootstrap circuit health. A Tor session that has bootstrapped can still lose every + // exit circuit — the guard stays usable, so nothing in the bootstrap path notices. + // Individual circuit failures are normal and must not move the indicator, so a downgrade + // needs both enough failures and enough elapsed time to rule out a momentary blip. + const val CIRCUIT_FAILURE_THRESHOLD: Int = 4 + const val CIRCUIT_FAILURE_MIN_SPAN_MS: Long = 20_000L + const val CIRCUIT_FAILURE_WINDOW_MS: Long = 120_000L } object UI {