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 -> {
service.stop()
emit(TorServiceStatus.Off)
}
TorType.EXTERNAL -> {
service.stop()
if (externalSocksPort > 0) {
emit(TorServiceStatus.Active(externalSocksPort))
} else {
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.utils.Log
import info.guardianproject.arti.ArtiLogListener
import info.guardianproject.arti.ArtiProxy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -95,75 +96,84 @@ class TorService(
}
suspend fun start() {
mutex.withLock {
if (artiProxy != null) {
// Already running — just ensure status is up to date
if (bootstrapped.get()) {
_status.value = TorServiceStatus.Active(currentPort)
} else {
_status.value = TorServiceStatus.Connecting
}
return
}
_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++
// 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) {
mutex.withLock {
if (artiProxy != null) {
// Already running — just ensure status is up to date
if (bootstrapped.get()) {
_status.value = TorServiceStatus.Active(currentPort)
} else {
_status.value = TorServiceStatus.Connecting
}
return@withContext
}
if (lastError != null) {
Log.e("TorService") { "Failed to start Arti after $MAX_PORT_RETRIES attempts" }
_status.value = TorServiceStatus.Off
_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++
}
}
if (lastError != null) {
Log.e("TorService") { "Failed to start Arti after $MAX_PORT_RETRIES attempts" }
_status.value = TorServiceStatus.Off
}
}
}
}
}
suspend fun stop() {
mutex.withLock {
val proxy = artiProxy ?: return@withLock
artiProxy = null
bootstrapped.set(false)
// NonCancellable ensures stop() completes fully even if the caller
// is cancelled, preventing leaked ArtiProxy instances and file locks.
withContext(NonCancellable) {
mutex.withLock {
val proxy = artiProxy ?: return@withContext
artiProxy = null
bootstrapped.set(false)
Log.d("TorService", "Stopping Arti")
withContext(Dispatchers.IO) {
try {
proxy.stop()
} catch (e: Exception) {
Log.d("TorService") { "Failed to stop Arti: ${e.message}" }
Log.d("TorService", "Stopping Arti")
withContext(Dispatchers.IO) {
try {
proxy.stop()
} catch (e: Exception) {
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
delay(STOP_SETTLE_MS)
_status.value = TorServiceStatus.Off
}
_status.value = TorServiceStatus.Off
}
}
}