fix: never stop ArtiProxy — Arti's lock is tied to object lifetime

Arti's state file lock is released when the TorClient object is
garbage collected, not when stop() is called. Calling stop()+start()
on the same ArtiProxy creates a new internal TorClient that conflicts
with the old lock that hasn't been GC'd yet.

Solution: start ArtiProxy once, let it run for the process lifetime.
When the user turns Tor OFF or EXTERNAL, TorManager simply stops
emitting Active status — OkHttp stops routing through the proxy.
The idle proxy uses negligible resources and drops circuits when no
SOCKS connections are active.

This removes stop(), Mutex, NonCancellable, CompletableDeferred — all
the complexity that was trying to work around a fundamental Arti
design constraint.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
This commit is contained in:
Claude
2026-04-01 13:29:22 +00:00
parent 14f8bf1245
commit 4f92878146
2 changed files with 28 additions and 76 deletions
@@ -63,12 +63,10 @@ class TorManager(
} }
TorType.OFF -> { TorType.OFF -> {
service.stop()
emit(TorServiceStatus.Off) emit(TorServiceStatus.Off)
} }
TorType.EXTERNAL -> { TorType.EXTERNAL -> {
service.stop()
if (externalSocksPort > 0) { if (externalSocksPort > 0) {
emit(TorServiceStatus.Active(externalSocksPort)) emit(TorServiceStatus.Active(externalSocksPort))
} else { } else {
@@ -24,38 +24,36 @@ import android.content.Context
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import info.guardianproject.arti.ArtiLogListener import info.guardianproject.arti.ArtiLogListener
import info.guardianproject.arti.ArtiProxy import info.guardianproject.arti.ArtiProxy
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
private const val DEFAULT_SOCKS_PORT = 19050 private const val DEFAULT_SOCKS_PORT = 19050
private const val STOP_TIMEOUT_MS = 10_000L
/** /**
* Manages a single ArtiProxy instance with explicit start/stop lifecycle. * Manages a single ArtiProxy instance for the app's lifetime.
* *
* ArtiProxy is created once and reused for the lifetime of the app. * Arti's state file lock is tied to the TorClient object's lifetime
* Only [start] and [stop] are called on it never recreated to * it is only released when the object is garbage collected, not when
* avoid state file lock conflicts in the native layer. * stop() is called. Calling stop()+start() on ArtiProxy creates a new
* internal TorClient that conflicts with the old lock.
*
* Therefore, ArtiProxy is created and started once. It runs for the
* entire process lifetime. When the user turns Tor "off", TorManager
* simply stops emitting Active status no traffic is routed through
* the proxy, but the proxy itself stays alive. This is safe because
* an idle Arti uses negligible resources and maintains no circuits
* when no SOCKS connections are made.
*/ */
class TorService( class TorService(
val context: Context, val context: Context,
) { ) {
private val mutex = Mutex()
private val running = AtomicBoolean(false)
private val bootstrapped = AtomicBoolean(false)
private val socksPort = DEFAULT_SOCKS_PORT private val socksPort = DEFAULT_SOCKS_PORT
private val bootstrapped = AtomicBoolean(false)
@Volatile private val started = AtomicBoolean(false)
private var stoppedSignal: CompletableDeferred<Unit>? = null
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off) private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
val status: StateFlow<TorServiceStatus> = _status.asStateFlow() val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
@@ -74,20 +72,11 @@ class TorService(
} }
} }
text.contains("state changed to Stopped", ignoreCase = true) -> {
bootstrapped.set(false)
running.set(false)
_status.value = TorServiceStatus.Off
stoppedSignal?.complete(Unit)
}
text.contains( text.contains(
"Another process has the lock", "Another process has the lock",
ignoreCase = true, ignoreCase = true,
) -> { ) -> {
Log.e("TorService") { "Arti state file lock conflict" } Log.e("TorService") { "Arti state file lock conflict" }
bootstrapped.set(false)
_status.value = TorServiceStatus.Off
} }
} }
} }
@@ -101,60 +90,25 @@ class TorService(
.build() .build()
suspend fun start() { suspend fun start() {
withContext(NonCancellable) { if (started.get()) {
mutex.withLock { // Already started — just emit current state
if (running.get()) { if (bootstrapped.get()) {
if (bootstrapped.get()) { _status.value = TorServiceStatus.Active(socksPort)
_status.value = TorServiceStatus.Active(socksPort) } else {
} else {
_status.value = TorServiceStatus.Connecting
}
return@withContext
}
_status.value = TorServiceStatus.Connecting _status.value = TorServiceStatus.Connecting
bootstrapped.set(false)
withContext(Dispatchers.IO) {
try {
artiProxy.start()
running.set(true)
Log.d("TorService") { "Arti started on port $socksPort" }
} catch (e: Exception) {
Log.e("TorService") { "Failed to start Arti: ${e.message}" }
_status.value = TorServiceStatus.Off
}
}
} }
return
} }
}
suspend fun stop() { _status.value = TorServiceStatus.Connecting
withContext(NonCancellable) {
mutex.withLock {
if (!running.get()) return@withContext
Log.d("TorService", "Stopping Arti") withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { try {
val signal = CompletableDeferred<Unit>() artiProxy.start()
stoppedSignal = signal started.set(true)
Log.d("TorService") { "Arti started on port $socksPort" }
try { } catch (e: Exception) {
artiProxy.stop() Log.e("TorService") { "Failed to start Arti: ${e.message}" }
} catch (e: Exception) {
Log.d("TorService") { "Failed to stop Arti: ${e.message}" }
}
val confirmed = withTimeoutOrNull(STOP_TIMEOUT_MS) { signal.await() }
stoppedSignal = null
if (confirmed != null) {
Log.d("TorService") { "Arti confirmed stopped" }
} else {
Log.w("TorService") { "Arti stop timed out after ${STOP_TIMEOUT_MS / 1000}s" }
running.set(false)
}
}
_status.value = TorServiceStatus.Off _status.value = TorServiceStatus.Off
} }
} }