From 905ccf5f1727c359a737483283c66c4e85206ddb Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 3 Sep 2025 02:22:27 +0200 Subject: [PATCH] better tor management (#373) --- .../com/bitchat/android/net/TorManager.kt | 125 +++++++++++++----- 1 file changed, 92 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/net/TorManager.kt b/app/src/main/java/com/bitchat/android/net/TorManager.kt index 7cfd6a5d..e04c2240 100644 --- a/app/src/main/java/com/bitchat/android/net/TorManager.kt +++ b/app/src/main/java/com/bitchat/android/net/TorManager.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.launch import kotlinx.coroutines.delay import kotlinx.coroutines.Job +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.CompletableDeferred import java.net.InetSocketAddress import java.util.concurrent.atomic.AtomicReference @@ -31,6 +33,7 @@ object TorManager { private const val RESTART_DELAY_MS = 2000L // 2 seconds between stop/start private const val INACTIVITY_TIMEOUT_MS = 5000L // 5 seconds of no activity before restart private const val MAX_RETRY_ATTEMPTS = 5 + private const val STOP_TIMEOUT_MS = 7000L private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -51,19 +54,24 @@ object TorManager { private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING } @Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED + enum class TorState { OFF, STARTING, BOOTSTRAPPING, RUNNING, STOPPING, ERROR } + data class TorStatus( val mode: TorMode = TorMode.OFF, val running: Boolean = false, - val bootstrapPercent: Int = 0, - val lastLogLine: String = "" + val bootstrapPercent: Int = 0, // kept for backwards compatibility with UI; 0 or 100 only + val lastLogLine: String = "", + val state: TorState = TorState.OFF ) private val _status = MutableStateFlow(TorStatus()) val statusFlow: StateFlow = _status.asStateFlow() + private val stateChangeDeferred = AtomicReference?>(null) + fun isProxyEnabled(): Boolean { val s = _status.value - return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null + return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState.RUNNING } fun init(application: Application) { @@ -104,9 +112,12 @@ object TorManager { TorMode.OFF -> { Log.i(TAG, "applyMode: OFF -> stopping tor") lifecycleState = LifecycleState.STOPPING - stopArti() + _status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.STOPPING) + stopArti() // non-suspending immediate request + // Best-effort wait for STOPPED before we declare OFF + waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS) socksAddr = null - _status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0) + _status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.OFF) currentSocksPort = DEFAULT_SOCKS_PORT bindRetryAttempts = 0 lifecycleState = LifecycleState.STOPPED @@ -124,8 +135,8 @@ object TorManager { } bindRetryAttempts = 0 lifecycleState = LifecycleState.STARTING + _status.value = _status.value.copy(mode = TorMode.ON, running = false, bootstrapPercent = 0, state = TorState.STARTING) startArti(application, useDelay = false) - _status.value = _status.value.copy(mode = TorMode.ON) // Defer enabling proxy until bootstrap completes appScope.launch { waitUntilBootstrapped() @@ -146,7 +157,8 @@ object TorManager { private suspend fun startArti(application: Application, useDelay: Boolean = false) { try { - stopArtiInternal() + // Ensure any previous instance is fully stopped before starting a new one + stopArtiAndWait() Log.i(TAG, "Starting Arti on port $currentSocksPort…") if (useDelay) { @@ -159,15 +171,7 @@ object TorManager { Log.i(TAG, "arti: $s") lastLogTime.set(System.currentTimeMillis()) _status.value = _status.value.copy(lastLogLine = s) - if ( - s.contains("Sufficiently bootstrapped", ignoreCase = true) || - s.contains("AMEx: state changed to Running", ignoreCase = true) - ) { - _status.value = _status.value.copy(bootstrapPercent = 100) - retryAttempts = 0 // Reset retry attempts on successful bootstrap - bindRetryAttempts = 0 // Reset bind retry attempts on successful bootstrap - startInactivityMonitoring() - } + handleArtiLogLine(s) } val proxy = ArtiProxy.Builder(application) @@ -180,12 +184,13 @@ object TorManager { proxy.start() lastLogTime.set(System.currentTimeMillis()) - _status.value = _status.value.copy(running = true, bootstrapPercent = 0) + _status.value = _status.value.copy(running = true, bootstrapPercent = 0, state = TorState.STARTING) lifecycleState = LifecycleState.RUNNING startInactivityMonitoring() } catch (e: Exception) { Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}") + _status.value = _status.value.copy(state = TorState.ERROR) // Check if this is a bind error val isBindError = isBindError(e) @@ -198,7 +203,7 @@ object TorManager { } else if (isBindError) { Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up") lifecycleState = LifecycleState.STOPPED - _status.value = _status.value.copy(running = false, bootstrapPercent = 0) + _status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.ERROR) } else { // For non-bind errors, use the existing retry mechanism scheduleRetry(application) @@ -235,12 +240,21 @@ object TorManager { private fun stopArti() { stopArtiInternal() socksAddr = null - _status.value = _status.value.copy(running = false, bootstrapPercent = 0) + _status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.STOPPING) + } + + private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) { + // Request stop + stopArtiInternal() + // Wait for confirmation via logs (Stopped) or timeout + waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs) + // Small grace period before relaunch to let file locks clear + delay(200) } private suspend fun restartArti(application: Application) { Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...") - stopArtiInternal() + stopArtiAndWait() delay(RESTART_DELAY_MS) startArti(application, useDelay = false) // Already delayed above } @@ -302,23 +316,68 @@ object TorManager { retryJob = null } - // Removed Tor resource installation: not needed for Arti - - /** - * Build an execution command that works on Android 10+ where app data dirs are mounted noexec. - * We invoke the platform dynamic linker and pass the PIE binary path as its first arg. - */ - // Removed exec command builder: not needed for Arti - private suspend fun waitUntilBootstrapped() { val current = _status.value if (!current.running) return - if (current.bootstrapPercent >= 100) return - // Suspend until we observe 100% at least once + if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return + // Suspend until we observe RUNNING at least once while (true) { - val s = statusFlow.first { it.bootstrapPercent >= 100 || !it.running } - if (!s.running) return - if (s.bootstrapPercent >= 100) return + val s = statusFlow.first { (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || !it.running || it.state == TorState.ERROR } + if (!s.running || s.state == TorState.ERROR) return + if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return + } + } + + private fun handleArtiLogLine(s: String) { + when { + s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> { + _status.value = _status.value.copy(state = TorState.STARTING) + completeWaitersIf(TorState.STARTING) + } + s.contains("AMEx: state changed to Starting", ignoreCase = true) -> { + _status.value = _status.value.copy(state = TorState.STARTING) + completeWaitersIf(TorState.STARTING) + } + s.contains("Sufficiently bootstrapped; system SOCKS now functional", ignoreCase = true) -> { + _status.value = _status.value.copy(bootstrapPercent = 100, state = TorState.BOOTSTRAPPING) + retryAttempts = 0 + bindRetryAttempts = 0 + startInactivityMonitoring() + } + s.contains("AMEx: state changed to Running", ignoreCase = true) -> { + // If we already saw Sufficiently bootstrapped, mark as RUNNING and ready. + val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready + _status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true) + completeWaitersIf(TorState.RUNNING) + } + s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> { + _status.value = _status.value.copy(state = TorState.STOPPING, running = false) + } + s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> { + _status.value = _status.value.copy(state = TorState.OFF, running = false, bootstrapPercent = 0) + completeWaitersIf(TorState.OFF) + } + s.contains("Another process has the lock on our state files", ignoreCase = true) -> { + // Signal error; we'll likely need to wait longer before restart + _status.value = _status.value.copy(state = TorState.ERROR) + } + } + } + + private fun completeWaitersIf(state: TorState) { + stateChangeDeferred.getAndSet(null)?.let { def -> + def.complete(state) + } + } + + private suspend fun waitForStateTransition(target: TorState, timeoutMs: Long): TorState? { + val def = CompletableDeferred() + stateChangeDeferred.getAndSet(def)?.cancel() + return withTimeoutOrNull(timeoutMs) { + // Fast-path: if we're already there + val cur = _status.value.state + if (cur == target) return@withTimeoutOrNull cur + def.await() } }