Skip to content

Commit 905ccf5

Browse files
authored
better tor management (permissionlesstech#373)
1 parent 8b3dc71 commit 905ccf5

File tree

1 file changed

+92
-33
lines changed

1 file changed

+92
-33
lines changed

app/src/main/java/com/bitchat/android/net/TorManager.kt

Lines changed: 92 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import kotlinx.coroutines.sync.withLock
1616
import kotlinx.coroutines.launch
1717
import kotlinx.coroutines.delay
1818
import kotlinx.coroutines.Job
19+
import kotlinx.coroutines.withTimeoutOrNull
20+
import kotlinx.coroutines.CompletableDeferred
1921

2022
import java.net.InetSocketAddress
2123
import java.util.concurrent.atomic.AtomicReference
@@ -31,6 +33,7 @@ object TorManager {
3133
private const val RESTART_DELAY_MS = 2000L // 2 seconds between stop/start
3234
private const val INACTIVITY_TIMEOUT_MS = 5000L // 5 seconds of no activity before restart
3335
private const val MAX_RETRY_ATTEMPTS = 5
36+
private const val STOP_TIMEOUT_MS = 7000L
3437

3538
private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
3639

@@ -51,19 +54,24 @@ object TorManager {
5154
private enum class LifecycleState { STOPPED, STARTING, RUNNING, STOPPING }
5255
@Volatile private var lifecycleState: LifecycleState = LifecycleState.STOPPED
5356

57+
enum class TorState { OFF, STARTING, BOOTSTRAPPING, RUNNING, STOPPING, ERROR }
58+
5459
data class TorStatus(
5560
val mode: TorMode = TorMode.OFF,
5661
val running: Boolean = false,
57-
val bootstrapPercent: Int = 0,
58-
val lastLogLine: String = ""
62+
val bootstrapPercent: Int = 0, // kept for backwards compatibility with UI; 0 or 100 only
63+
val lastLogLine: String = "",
64+
val state: TorState = TorState.OFF
5965
)
6066

6167
private val _status = MutableStateFlow(TorStatus())
6268
val statusFlow: StateFlow<TorStatus> = _status.asStateFlow()
6369

70+
private val stateChangeDeferred = AtomicReference<CompletableDeferred<TorState>?>(null)
71+
6472
fun isProxyEnabled(): Boolean {
6573
val s = _status.value
66-
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null
74+
return s.mode != TorMode.OFF && s.running && s.bootstrapPercent >= 100 && socksAddr != null && s.state == TorState.RUNNING
6775
}
6876

6977
fun init(application: Application) {
@@ -104,9 +112,12 @@ object TorManager {
104112
TorMode.OFF -> {
105113
Log.i(TAG, "applyMode: OFF -> stopping tor")
106114
lifecycleState = LifecycleState.STOPPING
107-
stopArti()
115+
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.STOPPING)
116+
stopArti() // non-suspending immediate request
117+
// Best-effort wait for STOPPED before we declare OFF
118+
waitForStateTransition(target = TorState.OFF, timeoutMs = STOP_TIMEOUT_MS)
108119
socksAddr = null
109-
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0)
120+
_status.value = _status.value.copy(mode = TorMode.OFF, running = false, bootstrapPercent = 0, state = TorState.OFF)
110121
currentSocksPort = DEFAULT_SOCKS_PORT
111122
bindRetryAttempts = 0
112123
lifecycleState = LifecycleState.STOPPED
@@ -124,8 +135,8 @@ object TorManager {
124135
}
125136
bindRetryAttempts = 0
126137
lifecycleState = LifecycleState.STARTING
138+
_status.value = _status.value.copy(mode = TorMode.ON, running = false, bootstrapPercent = 0, state = TorState.STARTING)
127139
startArti(application, useDelay = false)
128-
_status.value = _status.value.copy(mode = TorMode.ON)
129140
// Defer enabling proxy until bootstrap completes
130141
appScope.launch {
131142
waitUntilBootstrapped()
@@ -146,7 +157,8 @@ object TorManager {
146157

147158
private suspend fun startArti(application: Application, useDelay: Boolean = false) {
148159
try {
149-
stopArtiInternal()
160+
// Ensure any previous instance is fully stopped before starting a new one
161+
stopArtiAndWait()
150162

151163
Log.i(TAG, "Starting Arti on port $currentSocksPort")
152164
if (useDelay) {
@@ -159,15 +171,7 @@ object TorManager {
159171
Log.i(TAG, "arti: $s")
160172
lastLogTime.set(System.currentTimeMillis())
161173
_status.value = _status.value.copy(lastLogLine = s)
162-
if (
163-
s.contains("Sufficiently bootstrapped", ignoreCase = true) ||
164-
s.contains("AMEx: state changed to Running", ignoreCase = true)
165-
) {
166-
_status.value = _status.value.copy(bootstrapPercent = 100)
167-
retryAttempts = 0 // Reset retry attempts on successful bootstrap
168-
bindRetryAttempts = 0 // Reset bind retry attempts on successful bootstrap
169-
startInactivityMonitoring()
170-
}
174+
handleArtiLogLine(s)
171175
}
172176

173177
val proxy = ArtiProxy.Builder(application)
@@ -180,12 +184,13 @@ object TorManager {
180184
proxy.start()
181185
lastLogTime.set(System.currentTimeMillis())
182186

183-
_status.value = _status.value.copy(running = true, bootstrapPercent = 0)
187+
_status.value = _status.value.copy(running = true, bootstrapPercent = 0, state = TorState.STARTING)
184188
lifecycleState = LifecycleState.RUNNING
185189
startInactivityMonitoring()
186190

187191
} catch (e: Exception) {
188192
Log.e(TAG, "Error starting Arti on port $currentSocksPort: ${e.message}")
193+
_status.value = _status.value.copy(state = TorState.ERROR)
189194

190195
// Check if this is a bind error
191196
val isBindError = isBindError(e)
@@ -198,7 +203,7 @@ object TorManager {
198203
} else if (isBindError) {
199204
Log.e(TAG, "Max bind retry attempts reached ($MAX_RETRY_ATTEMPTS), giving up")
200205
lifecycleState = LifecycleState.STOPPED
201-
_status.value = _status.value.copy(running = false, bootstrapPercent = 0)
206+
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.ERROR)
202207
} else {
203208
// For non-bind errors, use the existing retry mechanism
204209
scheduleRetry(application)
@@ -235,12 +240,21 @@ object TorManager {
235240
private fun stopArti() {
236241
stopArtiInternal()
237242
socksAddr = null
238-
_status.value = _status.value.copy(running = false, bootstrapPercent = 0)
243+
_status.value = _status.value.copy(running = false, bootstrapPercent = 0, state = TorState.STOPPING)
244+
}
245+
246+
private suspend fun stopArtiAndWait(timeoutMs: Long = STOP_TIMEOUT_MS) {
247+
// Request stop
248+
stopArtiInternal()
249+
// Wait for confirmation via logs (Stopped) or timeout
250+
waitForStateTransition(target = TorState.OFF, timeoutMs = timeoutMs)
251+
// Small grace period before relaunch to let file locks clear
252+
delay(200)
239253
}
240254

241255
private suspend fun restartArti(application: Application) {
242256
Log.i(TAG, "Restarting Arti (keeping SOCKS proxy enabled)...")
243-
stopArtiInternal()
257+
stopArtiAndWait()
244258
delay(RESTART_DELAY_MS)
245259
startArti(application, useDelay = false) // Already delayed above
246260
}
@@ -302,23 +316,68 @@ object TorManager {
302316
retryJob = null
303317
}
304318

305-
// Removed Tor resource installation: not needed for Arti
306-
307-
/**
308-
* Build an execution command that works on Android 10+ where app data dirs are mounted noexec.
309-
* We invoke the platform dynamic linker and pass the PIE binary path as its first arg.
310-
*/
311-
// Removed exec command builder: not needed for Arti
312-
313319
private suspend fun waitUntilBootstrapped() {
314320
val current = _status.value
315321
if (!current.running) return
316-
if (current.bootstrapPercent >= 100) return
317-
// Suspend until we observe 100% at least once
322+
if (current.bootstrapPercent >= 100 && current.state == TorState.RUNNING) return
323+
// Suspend until we observe RUNNING at least once
318324
while (true) {
319-
val s = statusFlow.first { it.bootstrapPercent >= 100 || !it.running }
320-
if (!s.running) return
321-
if (s.bootstrapPercent >= 100) return
325+
val s = statusFlow.first { (it.bootstrapPercent >= 100 && it.state == TorState.RUNNING) || !it.running || it.state == TorState.ERROR }
326+
if (!s.running || s.state == TorState.ERROR) return
327+
if (s.bootstrapPercent >= 100 && s.state == TorState.RUNNING) return
328+
}
329+
}
330+
331+
private fun handleArtiLogLine(s: String) {
332+
when {
333+
s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> {
334+
_status.value = _status.value.copy(state = TorState.STARTING)
335+
completeWaitersIf(TorState.STARTING)
336+
}
337+
s.contains("AMEx: state changed to Starting", ignoreCase = true) -> {
338+
_status.value = _status.value.copy(state = TorState.STARTING)
339+
completeWaitersIf(TorState.STARTING)
340+
}
341+
s.contains("Sufficiently bootstrapped; system SOCKS now functional", ignoreCase = true) -> {
342+
_status.value = _status.value.copy(bootstrapPercent = 100, state = TorState.BOOTSTRAPPING)
343+
retryAttempts = 0
344+
bindRetryAttempts = 0
345+
startInactivityMonitoring()
346+
}
347+
s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
348+
// If we already saw Sufficiently bootstrapped, mark as RUNNING and ready.
349+
val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready
350+
_status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true)
351+
completeWaitersIf(TorState.RUNNING)
352+
}
353+
s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> {
354+
_status.value = _status.value.copy(state = TorState.STOPPING, running = false)
355+
}
356+
s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> {
357+
_status.value = _status.value.copy(state = TorState.OFF, running = false, bootstrapPercent = 0)
358+
completeWaitersIf(TorState.OFF)
359+
}
360+
s.contains("Another process has the lock on our state files", ignoreCase = true) -> {
361+
// Signal error; we'll likely need to wait longer before restart
362+
_status.value = _status.value.copy(state = TorState.ERROR)
363+
}
364+
}
365+
}
366+
367+
private fun completeWaitersIf(state: TorState) {
368+
stateChangeDeferred.getAndSet(null)?.let { def ->
369+
def.complete(state)
370+
}
371+
}
372+
373+
private suspend fun waitForStateTransition(target: TorState, timeoutMs: Long): TorState? {
374+
val def = CompletableDeferred<TorState>()
375+
stateChangeDeferred.getAndSet(def)?.cancel()
376+
return withTimeoutOrNull(timeoutMs) {
377+
// Fast-path: if we're already there
378+
val cur = _status.value.state
379+
if (cur == target) return@withTimeoutOrNull cur
380+
def.await()
322381
}
323382
}
324383

0 commit comments

Comments
 (0)