fix: keep ArtiProxy alive across mode switches to avoid file lock conflicts

ArtiProxy holds an exclusive filesystem lock. Destroying it on OFF and
recreating on INTERNAL caused lock conflicts because the native layer
needs time to release the lock.

Instead, create ArtiProxy once and never destroy it. When Tor is OFF
or EXTERNAL, the proxy sits idle with no SOCKS connections — negligible
resource usage. This eliminates all file lock race conditions.

Also wrap start/stop in NonCancellable to prevent transformLatest
cancellation from leaking half-initialized proxy instances.

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
This commit is contained in:
Claude
2026-04-01 13:01:31 +00:00
parent c83256bac2
commit d94d8776eb
2 changed files with 67 additions and 59 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 {
@@ -25,6 +25,7 @@ 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.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -95,75 +96,84 @@ class TorService(
} }
suspend fun start() { suspend fun start() {
mutex.withLock { // NonCancellable ensures that if the calling coroutine is cancelled
if (artiProxy != null) { // (e.g., by transformLatest switching modes), we don't leak a
// Already running — just ensure status is up to date // half-started ArtiProxy with no reference to stop it.
if (bootstrapped.get()) { withContext(NonCancellable) {
_status.value = TorServiceStatus.Active(currentPort) mutex.withLock {
} else { if (artiProxy != null) {
_status.value = TorServiceStatus.Connecting // Already running — just ensure status is up to date
} if (bootstrapped.get()) {
return _status.value = TorServiceStatus.Active(currentPort)
} } else {
_status.value = TorServiceStatus.Connecting
_status.value = TorServiceStatus.Connecting
bootstrapped.set(false)
lastLogTime.set(System.currentTimeMillis())
withContext(Dispatchers.IO) {
var socksPort = DEFAULT_SOCKS_PORT
var lastError: Exception? = null
for (attempt in 0 until MAX_PORT_RETRIES) {
try {
val proxy =
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++
} }
return@withContext
} }
if (lastError != null) { _status.value = TorServiceStatus.Connecting
Log.e("TorService") { "Failed to start Arti after $MAX_PORT_RETRIES attempts" } bootstrapped.set(false)
_status.value = TorServiceStatus.Off lastLogTime.set(System.currentTimeMillis())
withContext(Dispatchers.IO) {
var socksPort = DEFAULT_SOCKS_PORT
var lastError: Exception? = null
for (attempt in 0 until MAX_PORT_RETRIES) {
try {
val proxy =
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
}
} }
} }
} }
} }
suspend fun stop() { suspend fun stop() {
mutex.withLock { // NonCancellable ensures stop() completes fully even if the caller
val proxy = artiProxy ?: return@withLock // is cancelled, preventing leaked ArtiProxy instances and file locks.
artiProxy = null withContext(NonCancellable) {
bootstrapped.set(false) mutex.withLock {
val proxy = artiProxy ?: return@withContext
artiProxy = null
bootstrapped.set(false)
Log.d("TorService", "Stopping Arti") Log.d("TorService", "Stopping Arti")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
proxy.stop() proxy.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}" }
}
// Give the native layer time to release file locks
delay(STOP_SETTLE_MS)
} }
// Give the native layer time to release file locks _status.value = TorServiceStatus.Off
delay(STOP_SETTLE_MS)
} }
_status.value = TorServiceStatus.Off
} }
} }
} }