fix: harden TorService with bootstrap timeout, thread safety, and error recovery

Address gaps found in code review:
- Use AtomicBoolean/AtomicLong for state accessed from native Arti
  log callback thread (was a data race)
- Add bootstrap timeout monitor (120s) that restarts Arti once if
  bootstrapping stalls, following BitChat's inactivity pattern
- Clean up failed ArtiProxy instances before port retry
- Detect "Another process has the lock" fatal error from Arti logs

https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
This commit is contained in:
Claude
2026-04-01 00:23:52 +00:00
parent 11af50e0ad
commit 293fd39a68
@@ -26,11 +26,17 @@ 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.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
private const val DEFAULT_SOCKS_PORT = 19050 private const val DEFAULT_SOCKS_PORT = 19050
private const val MAX_PORT_RETRIES = 3 private const val MAX_PORT_RETRIES = 3
private const val BOOTSTRAP_TIMEOUT_MS = 120_000L
private const val BOOTSTRAP_CHECK_INTERVAL_MS = 10_000L
class TorService( class TorService(
val context: Context, val context: Context,
@@ -42,25 +48,35 @@ class TorService(
var socksPort = DEFAULT_SOCKS_PORT var socksPort = DEFAULT_SOCKS_PORT
var artiProxy: ArtiProxy? = null var artiProxy: ArtiProxy? = null
var started = false val bootstrapped = AtomicBoolean(false)
val lastLogTime = AtomicLong(System.currentTimeMillis())
val logListener = val logListener =
ArtiLogListener { logLine -> ArtiLogListener { logLine ->
val text = logLine ?: return@ArtiLogListener val text = logLine ?: return@ArtiLogListener
Log.d("TorService") { "Arti: $text" } Log.d("TorService") { "Arti: $text" }
lastLogTime.set(System.currentTimeMillis())
when { when {
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 (!started) { if (bootstrapped.compareAndSet(false, true)) {
started = true
trySend(TorServiceStatus.Active(socksPort)) trySend(TorServiceStatus.Active(socksPort))
Log.d("TorService") { "Arti bootstrapped on port $socksPort" } Log.d("TorService") { "Arti bootstrapped on port $socksPort" }
} }
} }
text.contains("state changed to Stopped", ignoreCase = true) -> { text.contains("state changed to Stopped", ignoreCase = true) -> {
started = false bootstrapped.set(false)
trySend(TorServiceStatus.Off)
}
text.contains(
"Another process has the lock",
ignoreCase = true,
) -> {
Log.e("TorService") { "Arti state file lock conflict" }
bootstrapped.set(false)
trySend(TorServiceStatus.Off) trySend(TorServiceStatus.Off)
} }
} }
@@ -82,7 +98,15 @@ class TorService(
break break
} catch (e: Exception) { } catch (e: Exception) {
lastError = e lastError = e
Log.e("TorService") { "Failed to start Arti on port $socksPort (attempt ${attempt + 1}): ${e.message}" } Log.e("TorService") {
"Failed to start Arti on port $socksPort (attempt ${attempt + 1}): ${e.message}"
}
// Stop the failed instance before retrying
try {
artiProxy?.stop()
} catch (_: Exception) {
}
artiProxy = null
socksPort++ socksPort++
} }
} }
@@ -92,14 +116,61 @@ class TorService(
trySend(TorServiceStatus.Off) trySend(TorServiceStatus.Off)
} }
// Monitor for bootstrap stalls: if Arti stops producing logs
// during bootstrap, restart it.
val monitorJob =
launch {
val startTime = System.currentTimeMillis()
while (!bootstrapped.get()) {
delay(BOOTSTRAP_CHECK_INTERVAL_MS)
if (bootstrapped.get()) break
val elapsed = System.currentTimeMillis() - startTime
val timeSinceLastLog = System.currentTimeMillis() - lastLogTime.get()
if (elapsed > BOOTSTRAP_TIMEOUT_MS) {
Log.w("TorService") {
"Arti bootstrap timed out after ${elapsed / 1000}s"
}
// Stop and restart once
try {
artiProxy?.stop()
} catch (_: Exception) {
}
delay(1000)
try {
artiProxy =
ArtiProxy
.Builder(context.applicationContext)
.setSocksPort(socksPort)
.setDnsPort(socksPort + 1)
.setLogListener(logListener)
.build()
artiProxy!!.start()
lastLogTime.set(System.currentTimeMillis())
} catch (e: Exception) {
Log.e("TorService") { "Arti restart failed: ${e.message}" }
trySend(TorServiceStatus.Off)
}
break // Only retry once
} else if (timeSinceLastLog > BOOTSTRAP_CHECK_INTERVAL_MS * 3) {
Log.w("TorService") {
"Arti log stall detected (${timeSinceLastLog / 1000}s since last log)"
}
}
}
}
awaitClose { awaitClose {
Log.d("TorService", "Stopping Arti Tor Service") Log.d("TorService", "Stopping Arti Tor Service")
monitorJob.cancel()
try { try {
artiProxy?.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}" }
} }
trySend(TorServiceStatus.Off)
} }
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
} }