fix: create ArtiProxy once and reuse — never recreate

The root cause of file lock conflicts was creating new ArtiProxy
objects on each start. Even after stop() confirmed, build() could
race with OS-level lock release.

Now ArtiProxy is created once in the TorService constructor and
reused for the app's lifetime. start() and stop() just toggle it
on/off on the same instance. No more file lock conflicts.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
This commit is contained in:
Claude
2026-04-01 13:25:28 +00:00
parent 7cac95726b
commit 14f8bf1245
@@ -37,30 +37,23 @@ 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 MAX_PORT_RETRIES = 3
private const val STOP_TIMEOUT_MS = 10_000L private const val STOP_TIMEOUT_MS = 10_000L
/** /**
* Manages a single ArtiProxy instance with explicit start/stop lifecycle. * Manages a single ArtiProxy instance with explicit start/stop lifecycle.
* *
* ArtiProxy holds an exclusive lock on state files in the filesystem. Unlike * ArtiProxy is created once and reused for the lifetime of the app.
* the old Android TorService (where bind/unbind was idempotent), we cannot * Only [start] and [stop] are called on it — never recreated — to
* create and destroy ArtiProxy instances on every flow collection cycle — * avoid state file lock conflicts in the native layer.
* the file lock from the old instance may not be released before the new
* one tries to acquire it.
*
* Instead, TorService owns a single ArtiProxy and exposes its state via
* a [StateFlow]. TorManager calls [start]/[stop] to control the lifecycle.
*/ */
class TorService( class TorService(
val context: Context, val context: Context,
) { ) {
private val mutex = Mutex() private val mutex = Mutex()
private var artiProxy: ArtiProxy? = null private val running = AtomicBoolean(false)
private var currentPort: Int = DEFAULT_SOCKS_PORT
private val bootstrapped = AtomicBoolean(false) private val bootstrapped = AtomicBoolean(false)
private val socksPort = DEFAULT_SOCKS_PORT
// Signalled by the log listener when Arti confirms it has stopped
@Volatile @Volatile
private var stoppedSignal: CompletableDeferred<Unit>? = null private var stoppedSignal: CompletableDeferred<Unit>? = null
@@ -76,13 +69,14 @@ class TorService(
text.contains("Sufficiently bootstrapped", ignoreCase = true) || text.contains("Sufficiently bootstrapped", ignoreCase = true) ||
text.contains("is usable", ignoreCase = true) -> { text.contains("is usable", ignoreCase = true) -> {
if (bootstrapped.compareAndSet(false, true)) { if (bootstrapped.compareAndSet(false, true)) {
_status.value = TorServiceStatus.Active(currentPort) _status.value = TorServiceStatus.Active(socksPort)
Log.d("TorService") { "Arti bootstrapped on port $currentPort" } Log.d("TorService") { "Arti bootstrapped on port $socksPort" }
} }
} }
text.contains("state changed to Stopped", ignoreCase = true) -> { text.contains("state changed to Stopped", ignoreCase = true) -> {
bootstrapped.set(false) bootstrapped.set(false)
running.set(false)
_status.value = TorServiceStatus.Off _status.value = TorServiceStatus.Off
stoppedSignal?.complete(Unit) stoppedSignal?.complete(Unit)
} }
@@ -98,16 +92,20 @@ class TorService(
} }
} }
private val artiProxy: ArtiProxy =
ArtiProxy
.Builder(context.applicationContext)
.setSocksPort(socksPort)
.setDnsPort(socksPort + 1)
.setLogListener(logListener)
.build()
suspend fun start() { suspend fun start() {
// NonCancellable ensures that if the calling coroutine is cancelled
// (e.g., by transformLatest switching modes), we don't leak a
// half-started ArtiProxy with no reference to stop it.
withContext(NonCancellable) { withContext(NonCancellable) {
mutex.withLock { mutex.withLock {
if (artiProxy != null) { if (running.get()) {
// Already running — just ensure status is up to date
if (bootstrapped.get()) { if (bootstrapped.get()) {
_status.value = TorServiceStatus.Active(currentPort) _status.value = TorServiceStatus.Active(socksPort)
} else { } else {
_status.value = TorServiceStatus.Connecting _status.value = TorServiceStatus.Connecting
} }
@@ -118,36 +116,12 @@ class TorService(
bootstrapped.set(false) bootstrapped.set(false)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
var socksPort = DEFAULT_SOCKS_PORT try {
var lastError: Exception? = null artiProxy.start()
running.set(true)
for (attempt in 0 until MAX_PORT_RETRIES) { Log.d("TorService") { "Arti started on port $socksPort" }
try { } catch (e: Exception) {
val proxy = Log.e("TorService") { "Failed to start Arti: ${e.message}" }
ArtiProxy
.Builder(context.applicationContext)
.setSocksPort(socksPort)
.setDnsPort(socksPort + 1)
.setLogListener(logListener)
.build()
proxy.start()
artiProxy = proxy
currentPort = socksPort
lastError = null
Log.d("TorService") { "Arti started on port $socksPort" }
break
} catch (e: Exception) {
lastError = e
Log.e("TorService") {
"Failed to start Arti on port $socksPort (attempt ${attempt + 1}): ${e.message}"
}
socksPort++
}
}
if (lastError != null) {
Log.e("TorService") { "Failed to start Arti after $MAX_PORT_RETRIES attempts" }
_status.value = TorServiceStatus.Off _status.value = TorServiceStatus.Off
} }
} }
@@ -156,27 +130,21 @@ class TorService(
} }
suspend fun stop() { suspend fun stop() {
// NonCancellable ensures stop() completes fully even if the caller
// is cancelled, preventing leaked ArtiProxy instances and file locks.
withContext(NonCancellable) { withContext(NonCancellable) {
mutex.withLock { mutex.withLock {
val proxy = artiProxy ?: return@withContext if (!running.get()) return@withContext
artiProxy = null
bootstrapped.set(false)
Log.d("TorService", "Stopping Arti") Log.d("TorService", "Stopping Arti")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// Set up a signal to wait for the "Stopped" log confirmation
val signal = CompletableDeferred<Unit>() val signal = CompletableDeferred<Unit>()
stoppedSignal = signal stoppedSignal = signal
try { try {
proxy.stop() artiProxy.stop()
} catch (e: Exception) { } catch (e: Exception) {
Log.d("TorService") { "Failed to stop Arti: ${e.message}" } Log.d("TorService") { "Failed to stop Arti: ${e.message}" }
} }
// Wait for the native layer to confirm stop and release file locks
val confirmed = withTimeoutOrNull(STOP_TIMEOUT_MS) { signal.await() } val confirmed = withTimeoutOrNull(STOP_TIMEOUT_MS) { signal.await() }
stoppedSignal = null stoppedSignal = null
@@ -184,6 +152,7 @@ class TorService(
Log.d("TorService") { "Arti confirmed stopped" } Log.d("TorService") { "Arti confirmed stopped" }
} else { } else {
Log.w("TorService") { "Arti stop timed out after ${STOP_TIMEOUT_MS / 1000}s" } Log.w("TorService") { "Arti stop timed out after ${STOP_TIMEOUT_MS / 1000}s" }
running.set(false)
} }
} }
_status.value = TorServiceStatus.Off _status.value = TorServiceStatus.Off