Stop reporting Tor as connected once its circuits fail

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
This commit is contained in:
Taksh 2026-08-16 08:01:14 +05:30
parent 5156f7de89
commit 71dd9dfca3
3 changed files with 100 additions and 0 deletions

View File

@ -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

View File

@ -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
}
}

View File

@ -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 {