Merge pull request #3053 from vitorpamplona/claude/tor-stops-working-1PIcU

Add Tor self-heal watchdog + integration tests + Arti v2.3.0
This commit is contained in:
Vitor Pamplona
2026-05-26 17:52:54 -04:00
committed by GitHub
19 changed files with 1582 additions and 45 deletions
+15
View File
@@ -269,6 +269,21 @@ android {
testOptions {
unitTests.isReturnDefaultValues = true
// Lets TorArtiNativeIntegrationTest's System.loadLibrary("arti_android")
// find the desktop-host build of our Arti JNI shim. The Android .so
// variants live in src/main/jniLibs/{arm64-v8a,x86_64}/ and are loaded
// on-device — this Linux x86_64 .so is just for JVM unit-test runs.
// -Pamethyst.arti.integration=true opts the (slow, network-dependent)
// tests in; see TorArtiNativeIntegrationTest.kdoc.
unitTests.all { test ->
test.systemProperty(
"java.library.path",
"${projectDir}/src/test/native-libs/x86_64-linux",
)
project
.findProperty("amethyst.arti.integration")
?.let { test.systemProperty("amethyst.arti.integration", it.toString()) }
}
}
}
@@ -0,0 +1,176 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.tor
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.ui.tor.TorService
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import java.net.InetSocketAddress
import java.net.Proxy
import java.util.concurrent.TimeUnit
import kotlin.system.measureTimeMillis
/**
* Real-Arti bootstrap + SOCKS round trip on-device. Verifies that the self-heal /
* destroy / re-init paths work end-to-end against the actual native lib.
*
* **This test is [Ignore]'d by default** because:
* - It needs network egress to the Tor network from the device/emulator. Many CI
* environments don't have it.
* - Bootstrap on a cold device can take 30-120s; the test costs real wall-clock time.
* - It depends on `check.torproject.org` being reachable.
*
* **To run manually:**
* 1. Connect a device or start an emulator that has internet egress to Tor.
* 2. Remove the `@Ignore` annotation below.
* 3. `./gradlew :amethyst:connectedPlayDebugAndroidTest -P android.testInstrumentationRunnerArguments.class=com.vitorpamplona.amethyst.tor.TorBootstrapInstrumentedTest`
*
* **What it covers that [TorManagerTest] does not:**
* - Real `ArtiNative.initialize` → `create_bootstrapped` → SOCKS listener bind.
* - Real rustls `CryptoProvider` install (regression check after the arti-v2.3.0 bump).
* - Real `destroy()` releasing the state file lock so a second `initialize()` succeeds.
* - OkHttp routing traffic through the SOCKS port and Arti exiting through the
* Tor network.
*
* **Companion fast tests:** `amethyst/src/test/.../tor/TorManagerTest.kt` covers the
* Kotlin-side self-heal logic (watchdog, cooldown, network change, status routing)
* with virtual time and in-memory fakes — no Arti required.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
@Ignore("Tier-3 integration test — requires on-device network access to Tor. See class kdoc to enable.")
class TorBootstrapInstrumentedTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val torService = TorService(context)
@After
fun tearDown() =
runBlocking {
// Drop the native client so this test's state file lock doesn't bleed into
// the next instrumented run on the same device.
torService.reset()
}
/**
* Cold-start bootstrap. The whole point of the custom Arti build is that this
* works at all — if create_bootstrapped panics (e.g., because we forgot to install
* a rustls CryptoProvider after an arti bump) the test catches it.
*/
@Test
fun `bootstraps to Active within 120s`() =
runBlocking(Dispatchers.IO) {
val elapsed =
measureTimeMillis {
torService.start()
val active =
withTimeout(BOOTSTRAP_TIMEOUT_MS) {
torService.status.first { it is TorServiceStatus.Active }
} as TorServiceStatus.Active
assertTrue("SOCKS port should be > 0", active.port > 0)
}
// Logged via assertEquals failure-on-too-slow; an actual `Log.i` would be invisible.
// Bootstrap should comfortably fit in 120s on a healthy network.
assertTrue("Bootstrap took ${elapsed}ms, expected < ${BOOTSTRAP_TIMEOUT_MS}ms", elapsed < BOOTSTRAP_TIMEOUT_MS)
}
/**
* SOCKS round-trip through Tor. Hits `check.torproject.org` which returns a JSON
* payload including `"IsTor":true` when the request actually exited via Tor.
* Catches regressions where the listener binds but no traffic flows (e.g., a
* broken handler-spawn race, or a crypto provider mismatch on the TLS handshake).
*/
@Test
fun `proxies HTTPS through Tor and reports IsTor true`() =
runBlocking(Dispatchers.IO) {
torService.start()
val active =
withTimeout(BOOTSTRAP_TIMEOUT_MS) {
torService.status.first { it is TorServiceStatus.Active }
} as TorServiceStatus.Active
val client =
OkHttpClient
.Builder()
.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", active.port)))
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
val request =
Request
.Builder()
.url("https://check.torproject.org/api/ip")
.build()
val body =
client.newCall(request).execute().use { resp ->
assertEquals("HTTP 200", 200, resp.code)
resp.body.string()
}
assertTrue(
"Response should report IsTor:true — actual body: $body",
body.contains("\"IsTor\":true"),
)
}
/**
* Verifies the destroy → re-init cycle that backs the self-heal path. After
* [TorService.reset], the next [TorService.start] must rebuild the TorClient and
* bring SOCKS back to Active — without a "state file already locked" error from
* the still-alive previous client.
*/
@Test
fun `reset then re-start brings SOCKS back to Active`() =
runBlocking(Dispatchers.IO) {
torService.start()
withTimeout(BOOTSTRAP_TIMEOUT_MS) {
torService.status.first { it is TorServiceStatus.Active }
}
torService.reset()
assertEquals(TorServiceStatus.Off, torService.status.value)
torService.start()
val second =
withTimeout(BOOTSTRAP_TIMEOUT_MS) {
torService.status.first { it is TorServiceStatus.Active }
} as TorServiceStatus.Active
assertTrue("Second bootstrap port valid", second.port > 0)
}
companion object {
private const val BOOTSTRAP_TIMEOUT_MS: Long = 120_000L
}
}
@@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorService
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
@@ -190,12 +191,12 @@ class AppModules(
UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope)
}
val torManager = TorManager(torPrefs, appContext, applicationIOScope)
val torManager = TorManager(torPrefs, TorService(appContext), applicationIOScope)
// Whenever the underlying network identity changes (wifi↔cellular, regained from
// offline, etc.) we clear any active Tor session bypass so the manager re-attempts
// bootstrap on the new network. The remembered-approval window is unaffected: if Tor
// stays stuck we will silently bypass again after the timeout fires.
// Network identity change (wifi↔cellular, regained from offline, captive portal
// cleared) — the old network's guards/circuits are dead, and Arti's in-memory
// client + on-disk state/ both need a fresh start. onNetworkChange drops the
// TorClient, clears the bypass + persisted approval, and triggers a full re-init.
init {
applicationIOScope.launch {
connManager.status
@@ -203,7 +204,7 @@ class AppModules(
.filterNotNull()
.distinctUntilChanged()
.drop(1)
.collect { torManager.clearSessionBypass() }
.collect { torManager.onNetworkChange() }
}
}
@@ -29,12 +29,14 @@ import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.ui.tor.TorPreferencesPort
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
@@ -48,10 +50,13 @@ class TorSharedPreferences(
prefs: TorSettings,
val context: Context,
val scope: CoroutineScope,
) {
) : TorPreferencesPort {
// Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs
val value = TorSettingsFlow.build(prefs)
override val torType: StateFlow<TorType> get() = value.torType
override val externalSocksPort: StateFlow<Int> get() = value.externalSocksPort
@OptIn(FlowPreview::class)
val saving =
value.propertyWatchFlow
@@ -66,9 +71,9 @@ class TorSharedPreferences(
value.toSettings(),
)
suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(context)
override suspend fun loadLastBypassApprovalMs(): Long = TorSharedPreferences.loadLastBypassApprovalMs(context)
suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, context)
override suspend fun saveLastBypassApprovalMs(value: Long) = TorSharedPreferences.saveLastBypassApprovalMs(value, context)
companion object {
// loads faster when individualized
@@ -62,6 +62,15 @@ object ArtiNative {
* @return 0 on success.
*/
external fun stopSocksProxy(): Int
/**
* Drop the in-process TorClient so the next [initialize] call rebuilds
* it from scratch (fresh bootstrap, new guards/circuits). Aborts the
* SOCKS listener and all in-flight connection handlers so the state
* file lock can be released.
* @return 0 on success.
*/
external fun destroy(): Int
}
/**
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.tor
import kotlinx.coroutines.flow.StateFlow
/**
* The slice of [TorService] that [TorManager] drives. Extracted so the manager
* can be unit-tested without booting Arti via JNI — production wires
* `TorService(context)`, tests wire an in-memory fake.
*/
interface TorBackend {
val status: StateFlow<TorServiceStatus>
suspend fun start()
suspend fun stop()
suspend fun reset()
suspend fun resetWithCleanState()
}
@@ -20,10 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.tor
import android.content.Context
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -41,20 +40,26 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* There should be only one instance of the Tor binding per app.
*
* Tor will connect as soon as status is listened to.
*
* [service] and [torPrefs] are constructor-injected so the manager can be unit-tested
* with in-memory fakes — see `TorManagerTest`. [ioDispatcher] is the dispatcher for
* background I/O (DataStore reads/writes, [TorBackend] calls); tests pass a
* `TestDispatcher` so virtual time controls scheduling.
*/
class TorManager(
private val torPrefs: TorSharedPreferences,
app: Context,
private val torPrefs: TorPreferencesPort,
val service: TorBackend,
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val nowMs: () -> Long = System::currentTimeMillis,
) {
val service = TorService(app)
/**
* In-memory only — when true, the manager emits [TorServiceStatus.Off] regardless of
* the persisted [TorType]. Cleared on process death, on network change, and on any
@@ -69,26 +74,56 @@ class TorManager(
*/
@Volatile private var lastBypassApprovalMs: Long = 0L
/**
* Bumped by self-heal paths ([onNetworkChange], stuck-Connecting watcher) so the
* [status] combine re-fires and re-enters the [TorType.INTERNAL] branch — which
* calls [TorService.start] again and, because [TorService.reset] flipped
* `initialized` back to false, runs full Arti re-initialization with a fresh
* bootstrap, new guards, new circuits.
*/
private val resetEpoch = MutableStateFlow(0)
/** Wall-clock of the last automatic self-heal — rate-limits the stuck-Connecting reset. */
@Volatile private var lastSelfHealAtMs: Long = 0L
/**
* Flipped the first time [status] reaches [TorServiceStatus.Active] in this process. Before
* that, the stuck-Connecting watchdog uses the gentler [TorService.reset] (drop client only)
* rather than [TorService.resetWithCleanState] — because on a slow legitimate first
* bootstrap there is no stale state to wipe, and wiping just forces an unnecessary
* re-bootstrap cycle. Once we've seen Tor work once, persisted `arti/state/` is fair game
* for the recovery to wipe.
*/
@Volatile private var hasEverBootstrapped: Boolean = false
init {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
lastBypassApprovalMs = torPrefs.loadLastBypassApprovalMs()
}
// Any user-initiated change to torType clears the in-memory bypass so the
// explicit user action wins over the implicit override.
torPrefs.value.torType
// Any user-initiated change to torType clears the in-memory bypass AND the
// remembered-approval window. Otherwise a single past "Use regular connection"
// traps the user in a silent-bypass loop: every Connecting span >60s
// auto-flips sessionBypass without showing the dialog, force-stop preserves
// the DataStore-backed approval, and toggling Tor off/on only clears the
// in-memory half — so wiping app data becomes the only recovery path.
torPrefs.torType
.drop(1)
.onEach { sessionBypass.value = false }
.launchIn(scope)
.onEach {
sessionBypass.value = false
lastBypassApprovalMs = 0L
torPrefs.saveLastBypassApprovalMs(0L)
}.launchIn(scope)
}
@OptIn(ExperimentalCoroutinesApi::class)
val status =
combine(
torPrefs.value.torType,
torPrefs.value.externalSocksPort,
torPrefs.torType,
torPrefs.externalSocksPort,
sessionBypass,
) { torType, externalSocksPort, bypass ->
resetEpoch,
) { torType, externalSocksPort, bypass, _ ->
Triple(torType, externalSocksPort, bypass)
}.transformLatest { (torType, externalSocksPort, bypass) ->
if (bypass) {
@@ -119,7 +154,7 @@ class TorManager(
}.catch { e ->
Log.e("TorManager") { "Tor service error: ${e.message}" }
emit(TorServiceStatus.Off)
}.flowOn(Dispatchers.IO)
}.flowOn(ioDispatcher)
.stateIn(
scope,
SharingStarted.WhileSubscribed(30000),
@@ -164,28 +199,92 @@ class TorManager(
false,
)
/**
* Fires once after [SELF_HEAL_AFTER_MS] of continuous [TorServiceStatus.Connecting].
* Drives the watchdog wired up below. `transformLatest` cancels the pending delay
* whenever the status changes, so a brief Connecting blip never fires.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private val selfHealSignal =
status.transformLatest { s ->
if (s is TorServiceStatus.Connecting) {
delay(SELF_HEAL_AFTER_MS)
emit(Unit)
}
}
init {
// Self-heal watchdog. When status sits at Connecting for longer than
// SELF_HEAL_AFTER_MS, the in-memory Arti state is likely stuck — bad guards,
// broken circuits, expired consensus. Drop the TorClient and bump resetEpoch
// so the status combine re-fires and re-enters the INTERNAL branch, which
// runs full Arti re-init. Rate-limited so a permanently broken network
// doesn't loop us. Fires BEFORE the 60s connectionFailure dialog so most
// users never see it.
//
// Pre-first-bootstrap: gentle reset (drop client, keep state). On a slow
// legitimate first bootstrap there's nothing on disk worth wiping, and
// wiping just costs another full bootstrap cycle.
// Post-first-bootstrap: full reset (drop client + wipe state). Once we've
// seen Tor work once, a stuck Connecting almost certainly means stale on-disk
// state from a different network needs to go.
status
.onEach {
if (it is TorServiceStatus.Active) hasEverBootstrapped = true
}.launchIn(scope)
selfHealSignal
.onEach {
val now = nowMs()
if (now - lastSelfHealAtMs < SELF_HEAL_COOLDOWN_MS) return@onEach
lastSelfHealAtMs = now
if (hasEverBootstrapped) {
Log.w("TorManager") { "Tor stuck Connecting >${SELF_HEAL_AFTER_MS}ms — self-healing (drop client + wipe state)" }
service.resetWithCleanState()
} else {
Log.w("TorManager") { "Tor stuck Connecting >${SELF_HEAL_AFTER_MS}ms on first bootstrap — self-healing (drop client only)" }
service.reset()
}
resetEpoch.update { it + 1 }
}.launchIn(scope)
}
fun rememberedApprovalActive(): Boolean {
val ts = lastBypassApprovalMs
return ts > 0 && (System.currentTimeMillis() - ts) < APPROVAL_REMEMBER_MS
return ts > 0 && (nowMs() - ts) < APPROVAL_REMEMBER_MS
}
/** Called when the user picks "Use regular connection". Starts a fresh 1-hour window. */
fun approveBypassForOneHour() {
val now = System.currentTimeMillis()
val now = nowMs()
lastBypassApprovalMs = now
sessionBypass.value = true
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
torPrefs.saveLastBypassApprovalMs(now)
}
}
/**
* Re-attempt Tor on this session — used on network change. Does not clear the
* remembered-approval window: if Tor stays stuck, we will silently bypass again
* after the timeout fires.
* Network identity changed (wifi↔cellular, captive portal cleared, regained from
* offline). The old network's guards and circuits are dead, but Arti's in-memory
* TorClient doesn't always notice — and even if it does, on-disk `state/` can hold
* unreachable guards that the next process load will pick up again. Drop the
* client, clear `sessionBypass`, clear the persisted approval, and bump
* [resetEpoch] so the status flow re-enters the INTERNAL branch with
* `initialized=false` — forcing a full Arti re-init with fresh bootstrap.
*/
fun clearSessionBypass() {
fun onNetworkChange() {
sessionBypass.value = false
lastBypassApprovalMs = 0L
// Prevent the stuck-Connecting watchdog from firing a second reset while the
// network-change bootstrap is still legitimately in progress (initial bootstrap
// on a new network can take ~1030s, sometimes longer).
lastSelfHealAtMs = nowMs()
scope.launch(ioDispatcher) {
torPrefs.saveLastBypassApprovalMs(0L)
service.reset()
resetEpoch.update { it + 1 }
}
}
fun isSocksReady() = status.value is TorServiceStatus.Active
@@ -195,5 +294,9 @@ class TorManager(
companion object {
const val BOOTSTRAP_TIMEOUT_MS: Long = 60_000L
const val APPROVAL_REMEMBER_MS: Long = 60L * 60L * 1000L
/** Self-heal kicks in BEFORE the 60s [BOOTSTRAP_TIMEOUT_MS] dialog so most users never see it. */
const val SELF_HEAL_AFTER_MS: Long = 45_000L
const val SELF_HEAL_COOLDOWN_MS: Long = 5L * 60L * 1000L
}
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.tor
import com.vitorpamplona.amethyst.commons.tor.TorType
import kotlinx.coroutines.flow.StateFlow
/**
* The slice of `TorSharedPreferences` that [TorManager] depends on. Extracted so the
* manager can be unit-tested without an Android `Context` (and without DataStore).
* Production wires `TorSharedPreferences`; tests wire an in-memory fake.
*/
interface TorPreferencesPort {
val torType: StateFlow<TorType>
val externalSocksPort: StateFlow<Int>
suspend fun loadLastBypassApprovalMs(): Long
suspend fun saveLastBypassApprovalMs(value: Long)
}
@@ -46,13 +46,13 @@ private const val MAX_PORT_RETRIES = 10
*/
class TorService(
val context: Context,
) {
) : TorBackend {
private var socksPort = DEFAULT_SOCKS_PORT
private val initialized = AtomicBoolean(false)
private val proxyRunning = AtomicBoolean(false)
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
private fun artiDataDir() = File(context.filesDir, "arti")
@@ -86,7 +86,7 @@ class TorService(
* Initialize the TorClient (once) and start the SOCKS proxy.
* Must be called from a coroutine on [Dispatchers.IO].
*/
suspend fun start() {
override suspend fun start() {
if (proxyRunning.get()) {
if (_status.value is TorServiceStatus.Active) return
_status.value = TorServiceStatus.Connecting
@@ -167,7 +167,7 @@ class TorService(
* Stop the SOCKS proxy and release the port.
* The TorClient stays alive — no file lock issues on restart.
*/
suspend fun stop() {
override suspend fun stop() {
if (!proxyRunning.compareAndSet(true, false)) return
withContext(Dispatchers.IO) {
@@ -177,4 +177,36 @@ class TorService(
_status.value = TorServiceStatus.Off
}
/**
* Drop the native TorClient so the next [start] runs full initialization
* with a fresh bootstrap, new guards, and new circuits. Used by self-heal
* paths in [TorManager] — network identity change, stuck-Connecting
* recovery — when the in-memory Arti state is suspected of being broken.
* The `arti/state/` directory on disk is preserved.
*/
override suspend fun reset() {
withContext(Dispatchers.IO) {
if (proxyRunning.compareAndSet(true, false)) {
ArtiNative.stopSocksProxy()
}
ArtiNative.destroy()
initialized.set(false)
Log.d("TorService") { "Tor service reset — next start will re-initialize" }
}
_status.value = TorServiceStatus.Off
}
/**
* Like [reset] but additionally wipes `arti/state/` so the next
* initialization rebuilds guard selection from scratch. Used when stale
* on-disk state (e.g. unreachable guards persisted from a previous
* network) is the suspected cause of a bootstrap that never completes.
*/
override suspend fun resetWithCleanState() {
reset()
withContext(Dispatchers.IO) {
clearAllArtiData()
}
}
}
Binary file not shown.
@@ -0,0 +1,489 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.tor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
import java.nio.file.Files
import java.util.concurrent.TimeUnit
import kotlin.system.measureTimeMillis
/**
* Tier-3 integration tests that drive the real Arti JNI shim on JVM, against the
* Linux x86_64 host build of the same wrapper crate that powers Android. The .so
* is checked in at `amethyst/src/test/native-libs/x86_64-linux/libarti_android.so`
* and the Gradle test task sets `java.library.path` to point at it.
*
* **Layered safety net for our Tor stack:**
* - [TorManagerTest] — fast unit tests, no Arti, virtual time. Covers Kotlin
* self-heal logic.
* - This file (smoke) — JNI bridge loads, version JNI call works. Always runs
* on Linux x86_64 hosts. ~10ms. Catches build/link regressions in the .so.
* - This file (integration) — opt-in via `-Pamethyst.arti.integration=true`.
* Real bootstrap + SOCKS round trips. Needs outbound TCP egress to arbitrary
* IPs/ports — works on most dev machines and Docker hosts with default
* networking; *will hang* on CI runners with restrictive egress lists.
* - `androidTest/.../tor/TorBootstrapInstrumentedTest` — same shape but against
* the Android .so on a connected device/emulator.
*
* **What the integration suite verifies that the unit tests cannot:**
*
* The original "Tor stops working until data-wipe" bug had four root causes that
* unit tests with a fake `TorBackend` can't exercise — they need the real Arti
* client, real circuits, real OS sockets:
*
* 1. The native `TorClient` getting stuck with bad guards / dead circuits /
* expired consensus, with no way to drop it in-process. Pre-fix there was
* no JNI `destroy()`. Verified by `destroy then re-initialize releases the
* state file lock cleanly`.
*
* 2. In-flight per-connection handlers each holding an `Arc<TorClient>` clone,
* pinning the state file lock past `destroy()`. Pre-fix the handler
* tracking was racy. Verified by `destroy aborts an in-flight SOCKS handler`.
*
* 3. `stopSocksProxy` deliberately not destroying the client (by design — for
* the legitimate stop/start reuse path), so a stuck client survived
* toggle-off-then-on. Verified by `stopSocksProxy then startSocksProxy
* reuses the running TorClient`.
*
* 4. State / fd / memory leaks accumulating across many destroy/init cycles
* (which the self-heal watchdog can drive at up to one per 5 minutes
* indefinitely). Verified by `survives multiple destroy then initialize
* cycles`.
*
* **Run the slow tests:**
* ```
* ./gradlew :amethyst:testPlayDebugUnitTest \
* --tests "com.vitorpamplona.amethyst.ui.tor.TorArtiNativeIntegrationTest" \
* -Pamethyst.arti.integration=true
* ```
*/
class TorArtiNativeIntegrationTest {
private var dataDir: File? = null
@After
fun tearDown() {
// Drop the in-process client between tests so the state file lock
// doesn't bleed across (and our tests stay independent). Idempotent —
// no-op if initialize never ran.
try {
ArtiNative.destroy()
} catch (_: Throwable) {
// Library may not have loaded if assumeArchAvailable skipped us.
}
dataDir?.deleteRecursively()
dataDir = null
}
// ---------------------------------------------------------------------
// Smoke — runs without -P. Catches build/link regressions.
// ---------------------------------------------------------------------
/**
* The host `.so` loads via `System.loadLibrary("arti_android")` and a trivial
* JNI function returns. If this fails, every other Tor test is moot — typical
* causes are a stale `.so` after an arti version bump, a missing rebuild on
* the test native-libs path, or a build that didn't export the expected JNI
* symbol. Always runs (no `-P` gate).
*/
@Test
fun `library loads and reports a version`() {
assumeArchAvailable()
val version = ArtiNative.getVersion()
assertTrue("Version string was: $version", version.startsWith("Arti "))
println("[arti] $version")
}
// ---------------------------------------------------------------------
// Bootstrap + round trip — the basic data plane.
// ---------------------------------------------------------------------
/**
* Real bootstrap + SOCKS round trip. Regression net for: rustls
* `CryptoProvider` install after the v2.3.0 bump, `fs-mistrust` host-trust
* override on JVM, and every `Java_..._ArtiNative_*` JNI export.
*/
@Test(timeout = BOOTSTRAP_TIMEOUT_MS + 60_000L)
fun `bootstraps and proxies an HTTPS request through Tor`() {
assumeFullIntegration()
val port = bootstrapAndStartSocks()
val exitIp = fetchExitIp(port)
println("[test] First-bootstrap exit IP: $exitIp")
assertNotNull(exitIp)
}
// ---------------------------------------------------------------------
// destroy + re-init releases state file lock — root cause #1.
// ---------------------------------------------------------------------
/**
* After `destroy`, the *same* on-disk data dir must be re-acquirable by a
* fresh `initialize` — no "state file already locked" error, no need to
* `clearAllArtiData`. This is the direct mirror of the self-heal recovery
* path that the watchdog drives on stuck-Connecting.
*/
@Test(timeout = (BOOTSTRAP_TIMEOUT_MS * 2) + 60_000L)
fun `destroy then re-initialize releases the state file lock cleanly`() {
assumeFullIntegration()
val firstPort = bootstrapAndStartSocks()
val firstIp = fetchExitIp(firstPort)
println("[test] Pre-destroy exit IP: $firstIp")
val destroyResult = ArtiNative.destroy()
assertEquals("destroy returned non-zero", 0, destroyResult)
// Re-init against the SAME data dir. Must NOT see "state file already locked".
assertEquals(
"re-initialize after destroy should succeed without clearing data",
0,
ArtiNative.initialize(dataDir!!.absolutePath),
)
val secondPort = pickPort()
assertEquals("post-destroy startSocksProxy", 0, ArtiNative.startSocksProxy(secondPort))
val secondIp = fetchExitIp(secondPort)
println("[test] Post-destroy exit IP: $secondIp (changed=${firstIp != secondIp})")
assertNotNull(secondIp)
}
// ---------------------------------------------------------------------
// In-flight handler abort — root cause #2.
// ---------------------------------------------------------------------
/**
* If a SOCKS connection is open at the moment we call [ArtiNative.destroy],
* the handler's `Arc<TorClient>` clone must be released — otherwise the
* `TorClient` stays alive past `destroy()`, the state file lock isn't
* released, and the next `initialize()` fails with "already locked".
*
* Pre-fix the handler-task tracking was racy: a connection accepted between
* `SOCKS_TASK.abort()` and the `HANDLER_TASKS` drain pinned an Arc. This
* test exercises that race window directly.
*/
@Test(timeout = BOOTSTRAP_TIMEOUT_MS + 60_000L)
fun `destroy aborts an in-flight SOCKS handler quickly`() {
assumeFullIntegration()
val port = bootstrapAndStartSocks()
// Open a long-lived SOCKS connection. We don't actually read the body —
// we just want a handler to be alive in tokio-land when destroy hits.
val socksClient = socksOkHttp(port, readTimeoutSeconds = 300L)
val inFlight =
Thread {
try {
// Hit a deliberately slow path. The fact that it never returns
// is fine — we're going to destroy() out from under it.
socksClient
.newCall(
Request
.Builder()
.url("https://check.torproject.org/api/ip")
.build(),
).execute()
.use { resp ->
@Suppress("UNUSED_VARIABLE")
val ignored = resp.body.string()
}
} catch (e: Throwable) {
println("[test] In-flight request aborted with: ${e.javaClass.simpleName}: ${e.message}")
}
}
inFlight.name = "in-flight-socks-request"
inFlight.start()
// Let the handler get into client.connect() or io::copy.
Thread.sleep(1_500)
// destroy() must return in roughly its budgeted time even with traffic
// in flight: ~1s wait for SOCKS_TASK termination + 500ms sleep for
// handler cleanup, plus some slack.
val destroyMs =
measureTimeMillis {
assertEquals(0, ArtiNative.destroy())
}
println("[test] destroy() with in-flight handler returned in ${destroyMs}ms")
assertTrue("destroy took ${destroyMs}ms, expected < 3000ms", destroyMs < 3_000)
// The in-flight thread should die promptly once its socket gets aborted.
inFlight.join(5_000)
assertTrue("In-flight request thread still alive after destroy", !inFlight.isAlive)
// The critical assertion: the state file lock was released, so a fresh
// initialize against the SAME data dir works. Pre-fix this would fail
// because the orphaned handler still held an Arc<TorClient>.
val reinitMs =
measureTimeMillis {
assertEquals(
"re-initialize after destroy-with-in-flight should succeed",
0,
ArtiNative.initialize(dataDir!!.absolutePath),
)
}
println("[test] re-initialize after in-flight-destroy completed in ${reinitMs}ms")
}
// ---------------------------------------------------------------------
// stop/start reuse — root cause #3 (negative test).
// ---------------------------------------------------------------------
/**
* The legitimate stop/start reuse path: stopSocksProxy releases the SOCKS
* port but keeps the TorClient alive, and startSocksProxy on a fresh port
* binds against the same client without re-bootstrapping. This is the
* pattern the user-facing toggle uses; we just verify it still works after
* our self-heal changes.
*/
@Test(timeout = BOOTSTRAP_TIMEOUT_MS + 60_000L)
fun `stopSocksProxy then startSocksProxy reuses the running TorClient`() {
assumeFullIntegration()
val firstPort = bootstrapAndStartSocks()
val firstIp = fetchExitIp(firstPort)
println("[test] Pre-stop exit IP: $firstIp")
assertEquals(0, ArtiNative.stopSocksProxy())
// No new bootstrap should happen — the second startSocksProxy reuses
// the client. So this round trip should be fast (no consensus download).
val secondPort = pickPort()
val secondStartMs =
measureTimeMillis {
assertEquals(0, ArtiNative.startSocksProxy(secondPort))
}
assertTrue(
"startSocksProxy on existing client took ${secondStartMs}ms — should be fast (no re-bootstrap)",
secondStartMs < 5_000,
)
val secondIp = fetchExitIp(secondPort)
println("[test] Post-stop/start exit IP: $secondIp (${secondStartMs}ms to re-bind)")
assertNotNull(secondIp)
}
// ---------------------------------------------------------------------
// Many cycles — root cause #4 (gradual degradation).
// ---------------------------------------------------------------------
/**
* The self-heal watchdog can drive `destroy → initialize` cycles up to once
* per 5 minutes for the entire app lifetime. Even at one per hour that's
* thousands of cycles before a user might restart the process. Verify we
* don't accumulate state corruption, file-descriptor leaks, or memory
* leaks across a handful of cycles.
*
* Bumped from 1 to [CYCLE_COUNT] cycles because 2 cycles (`destroy then
* re-initialize releases the state file lock cleanly`) is enough to catch
* the basic regression, but only 5+ catches gradual drift.
*/
@Test(timeout = (BOOTSTRAP_TIMEOUT_MS * CYCLE_COUNT) + 90_000L)
fun `survives multiple destroy then initialize cycles`() {
assumeFullIntegration()
dataDir = Files.createTempDirectory("arti-integ-cycles-").toFile()
ArtiNative.setLogCallback { line -> println("[arti] $line") }
val ips = mutableListOf<String>()
for (i in 1..CYCLE_COUNT) {
var ip: String? = null
val cycleMs =
measureTimeMillis {
assertEquals("cycle $i initialize", 0, ArtiNative.initialize(dataDir!!.absolutePath))
val port = pickPort()
assertEquals("cycle $i startSocksProxy", 0, ArtiNative.startSocksProxy(port))
ip = fetchExitIp(port)
ips += ip ?: "?"
assertEquals("cycle $i destroy", 0, ArtiNative.destroy())
}
println("[test] Cycle $i/$CYCLE_COUNT exit=$ip in ${cycleMs}ms")
}
// No hard assertion on IPs being different — Tor's exit selection isn't
// deterministic and small cycles can repeat exits. We just log them so
// the developer can see circuit variety.
println("[test] All ${CYCLE_COUNT} cycles completed. Exit IPs: $ips")
}
// ---------------------------------------------------------------------
// Concurrent SOCKS — accept loop + handler tracking under load.
// ---------------------------------------------------------------------
/**
* Verify that multiple in-flight SOCKS connections can coexist. This
* exercises:
* - the Rust accept loop pushing to `HANDLER_TASKS` (incl. its retain-on-push
* dedup of finished handles),
* - per-handler `Arc<TorClient>` clones being independent,
* - the listener handling several `accept().await` rounds back-to-back.
*/
@Test(timeout = BOOTSTRAP_TIMEOUT_MS + 120_000L)
fun `proxies concurrent SOCKS requests in parallel`() {
assumeFullIntegration()
val port = bootstrapAndStartSocks()
val concurrency = 5
val ips =
runBlocking {
(1..concurrency)
.map {
async(Dispatchers.IO) {
fetchExitIp(port)
}
}.awaitAll()
}
ips.forEachIndexed { i, ip ->
println("[test] Concurrent request ${i + 1}/$concurrency exit: $ip")
}
assertTrue("All concurrent requests should return an IP", ips.all { it != null })
}
// ---------------------------------------------------------------------
// destroy idempotency.
// ---------------------------------------------------------------------
/**
* Calling `destroy()` when the client is already destroyed (or was never
* initialized) should be a safe no-op. Pre-fix, a double-destroy could
* panic on the Rust side because of unwrap on an already-`None` Option.
* Now it's idempotent.
*/
@Test(timeout = BOOTSTRAP_TIMEOUT_MS + 60_000L)
fun `destroy is idempotent`() {
assumeFullIntegration()
// Destroy before any initialize — should be a no-op.
assertEquals("destroy on uninitialized client", 0, ArtiNative.destroy())
// Initialize, then destroy twice.
dataDir = Files.createTempDirectory("arti-integ-idem-").toFile()
ArtiNative.setLogCallback { line -> println("[arti] $line") }
assertEquals(0, ArtiNative.initialize(dataDir!!.absolutePath))
assertEquals("first destroy", 0, ArtiNative.destroy())
assertEquals("second destroy is a no-op", 0, ArtiNative.destroy())
// After two destroys, a fresh initialize still works.
assertEquals("initialize after double-destroy", 0, ArtiNative.initialize(dataDir!!.absolutePath))
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
private fun bootstrapAndStartSocks(): Int {
dataDir = Files.createTempDirectory("arti-integ-").toFile()
ArtiNative.setLogCallback { line -> println("[arti] $line") }
val bootstrapMs =
measureTimeMillis {
val initResult = ArtiNative.initialize(dataDir!!.absolutePath)
assertEquals("initialize returned $initResult", 0, initResult)
}
val port = pickPort()
val socksResult = ArtiNative.startSocksProxy(port)
assertEquals("startSocksProxy returned $socksResult", 0, socksResult)
println("[test] Bootstrap+startSocks complete in ${bootstrapMs}ms on port $port")
return port
}
/**
* Fetches `https://check.torproject.org/api/ip` through the given SOCKS port
* and returns the reported exit IP, or `null` if the response is malformed.
* Asserts the request succeeded — callers can `assertNotNull` if they care
* about the IP itself.
*/
private fun fetchExitIp(port: Int): String? {
val client = socksOkHttp(port)
val body =
client
.newCall(
Request
.Builder()
.url("https://check.torproject.org/api/ip")
.build(),
).execute()
.use { resp ->
assertEquals("HTTP 200 from check.torproject.org", 200, resp.code)
resp.body.string()
}
assertTrue(
"Response should report IsTor:true — body was: $body",
body.contains("\"IsTor\":true"),
)
return EXIT_IP_REGEX.find(body)?.groupValues?.getOrNull(1)
}
private fun socksOkHttp(
port: Int,
readTimeoutSeconds: Long = 60L,
): OkHttpClient =
OkHttpClient
.Builder()
.proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)))
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
.build()
private fun pickPort(): Int = (40_000..49_999).random()
/** Composite of `assumeArchAvailable` + `assumeIntegrationEnabled`. */
private fun assumeFullIntegration() {
assumeArchAvailable()
assumeIntegrationEnabled()
}
/**
* The checked-in test .so is built for Linux x86_64 only. Skip on other
* hosts rather than failing — a developer on macOS or aarch64 shouldn't see
* a build break just because they ran the full test suite.
*/
private fun assumeArchAvailable() {
val arch = System.getProperty("os.arch")?.lowercase().orEmpty()
val os = System.getProperty("os.name")?.lowercase().orEmpty()
assumeTrue(
"Test .so is provided only for Linux x86_64 (was: $os $arch). " +
"To run elsewhere, rebuild with `./tools/arti-build/build-arti-host.sh`.",
os.contains("linux") && (arch == "amd64" || arch == "x86_64"),
)
}
private fun assumeIntegrationEnabled() {
assumeTrue(
"Set -Pamethyst.arti.integration=true to enable. Needs network egress " +
"to arbitrary IPs/ports (Tor directory authorities + guards) — restrictive " +
"CI runners will hang in initialize().",
System.getProperty("amethyst.arti.integration") == "true",
)
}
companion object {
private const val BOOTSTRAP_TIMEOUT_MS: Long = 120_000L
private const val CYCLE_COUNT: Int = 5
private val EXIT_IP_REGEX = """"IP"\s*:\s*"([^"]+)"""".toRegex()
}
}
@@ -0,0 +1,447 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.tor
import com.vitorpamplona.amethyst.commons.tor.TorType
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for [TorManager]'s self-heal logic. Drives the manager with in-memory
* [TorBackend] + [TorPreferencesPort] fakes and a virtual clock so the 45s watchdog
* delay and 5-min cooldown can be exercised in milliseconds.
*
* Companion integration test in `amethyst/src/androidTest/.../tor/TorBootstrapInstrumentedTest.kt`
* covers the real-Arti bootstrap path on-device (currently @Ignore'd; see file for enable steps).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class TorManagerTest {
// ------------------------------------------------------------------
// construction + persisted state
// ------------------------------------------------------------------
@Test
fun `init loads persisted bypass approval`() =
runTest(UnconfinedTestDispatcher()) {
val recent = 1_000_000_000_000L
val prefs = FakeTorPreferences(initialApprovalMs = recent)
val manager = buildManager(prefs = prefs, clock = { recent + 1_000L })
advanceUntilIdle()
assertTrue(manager.rememberedApprovalActive())
}
@Test
fun `init does not flag approval when none persisted`() =
runTest(UnconfinedTestDispatcher()) {
val manager = buildManager()
advanceUntilIdle()
assertFalse(manager.rememberedApprovalActive())
}
@Test
fun `rememberedApprovalActive is false once outside the 1h window`() =
runTest(UnconfinedTestDispatcher()) {
val now = 1_000_000_000_000L
val tooOld = now - TorManager.APPROVAL_REMEMBER_MS - 1L
val prefs = FakeTorPreferences(initialApprovalMs = tooOld)
val manager = buildManager(prefs = prefs, clock = { now })
advanceUntilIdle()
assertFalse(manager.rememberedApprovalActive())
}
// ------------------------------------------------------------------
// torType change clears the bypass loop
// ------------------------------------------------------------------
@Test
fun `torType change clears in-memory bypass and persisted approval`() =
runTest(UnconfinedTestDispatcher()) {
val prefs = FakeTorPreferences(initialApprovalMs = 999L)
val manager = buildManager(prefs = prefs)
advanceUntilIdle()
manager.sessionBypass.value = true
prefs.setTorType(TorType.OFF)
advanceUntilIdle()
assertFalse(manager.sessionBypass.value)
assertEquals(0L, prefs.lastBypassApprovalMs)
}
@Test
fun `approveBypassForOneHour sets sessionBypass and persists timestamp`() =
runTest(UnconfinedTestDispatcher()) {
val now = 1_000_000_000_000L
val prefs = FakeTorPreferences()
val manager = buildManager(prefs = prefs, clock = { now })
advanceUntilIdle()
manager.approveBypassForOneHour()
advanceUntilIdle()
assertTrue(manager.sessionBypass.value)
assertEquals(now, prefs.lastBypassApprovalMs)
assertTrue(manager.rememberedApprovalActive())
}
// ------------------------------------------------------------------
// onNetworkChange — drops client + clears bypass + primes cooldown
// ------------------------------------------------------------------
@Test
fun `onNetworkChange clears bypass and persisted approval and resets backend`() =
runTest(UnconfinedTestDispatcher()) {
val prefs = FakeTorPreferences(initialApprovalMs = 12345L)
val backend = FakeTorBackend()
val manager = buildManager(prefs = prefs, backend = backend)
advanceUntilIdle()
manager.sessionBypass.value = true
manager.onNetworkChange()
advanceUntilIdle()
assertFalse(manager.sessionBypass.value)
assertEquals(0L, prefs.lastBypassApprovalMs)
assertTrue("onNetworkChange should reset backend at least once", backend.resetCount >= 1)
}
@Test
fun `onNetworkChange primes cooldown so the watchdog does not double-reset`() =
runTest(UnconfinedTestDispatcher()) {
val backend = FakeTorBackend()
// Constant clock — the only way self-heal would fire is if onNetworkChange
// failed to prime lastSelfHealAtMs.
val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L })
advanceUntilIdle()
val resetCountBefore = backend.resetCount
manager.onNetworkChange()
advanceUntilIdle()
// Status is back at Connecting after the network-change reset cycle.
// Advance past the 45s watchdog; cooldown must suppress a second reset.
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
runCurrent()
// Exactly one extra reset from onNetworkChange itself, none from the watchdog.
assertEquals(resetCountBefore + 1, backend.resetCount)
assertEquals(0, backend.resetWithCleanStateCount)
}
// ------------------------------------------------------------------
// stuck-Connecting watchdog
// ------------------------------------------------------------------
@Test
fun `watchdog uses gentle reset before first Active`() =
runTest(UnconfinedTestDispatcher()) {
val backend = FakeTorBackend()
// Big constant clock so (now - lastSelfHealAtMs=0) is well past cooldown.
val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L })
advanceUntilIdle()
assertEquals(TorServiceStatus.Connecting, manager.status.value)
assertEquals(0, backend.resetCount)
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
runCurrent()
assertEquals("gentle reset only — no state wipe before first Active", 1, backend.resetCount)
assertEquals(0, backend.resetWithCleanStateCount)
}
@Test
fun `watchdog uses full reset after first Active`() =
runTest(UnconfinedTestDispatcher()) {
val backend = FakeTorBackend()
val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L })
advanceUntilIdle()
// Drive backend to Active so hasEverBootstrapped flips.
backend.setActive(9050)
advanceUntilIdle()
assertTrue(manager.status.value is TorServiceStatus.Active)
// Back to Connecting — watchdog timer (re-)starts.
backend.setConnecting()
advanceUntilIdle()
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
runCurrent()
assertEquals(0, backend.resetCount)
assertEquals("after Active, watchdog wipes state too", 1, backend.resetWithCleanStateCount)
}
@Test
fun `watchdog cancels its delay when status leaves Connecting`() =
runTest(UnconfinedTestDispatcher()) {
val backend = FakeTorBackend()
val manager = buildManager(backend = backend, clock = { 1_000_000_000_000L })
advanceUntilIdle()
// Halfway through the watchdog delay, the bootstrap succeeds.
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS / 2)
backend.setActive(9050)
advanceUntilIdle()
// Past the original deadline — must NOT fire.
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS)
runCurrent()
assertEquals(0, backend.resetCount)
assertEquals(0, backend.resetWithCleanStateCount)
}
@Test
fun `watchdog cooldown blocks a second fire within the window`() =
runTest(UnconfinedTestDispatcher()) {
val backend = FakeTorBackend()
var clockNow = 1_000_000_000_000L
val manager = buildManager(backend = backend, clock = { clockNow })
advanceUntilIdle()
// First fire.
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
runCurrent()
assertEquals(1, backend.resetCount)
// Status returns to Connecting via the reset → re-start cycle. Advance another
// 45s of virtual time — clock has barely moved, so cooldown must block.
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
runCurrent()
assertEquals("cooldown should suppress the second fire", 1, backend.resetCount)
}
@Test
fun `watchdog can fire again once the cooldown elapses`() =
runTest(UnconfinedTestDispatcher()) {
val backend = FakeTorBackend()
var clockNow = 1_000_000_000_000L
val manager = buildManager(backend = backend, clock = { clockNow })
advanceUntilIdle()
// First fire.
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
runCurrent()
assertEquals(1, backend.resetCount)
// Move wall-clock past the cooldown window.
clockNow += TorManager.SELF_HEAL_COOLDOWN_MS + 1_000L
advanceTimeBy(TorManager.SELF_HEAL_AFTER_MS + 1_000L)
runCurrent()
assertEquals("after cooldown elapses, watchdog fires again", 2, backend.resetCount)
}
// ------------------------------------------------------------------
// top-level status routing
// ------------------------------------------------------------------
@Test
fun `status emits Off when torType is OFF`() =
runTest(UnconfinedTestDispatcher()) {
val prefs = FakeTorPreferences(initialTorType = TorType.OFF)
val backend = FakeTorBackend()
val manager = buildManager(prefs = prefs, backend = backend)
advanceUntilIdle()
assertEquals(TorServiceStatus.Off, manager.status.value)
assertEquals(0, backend.startCount)
}
@Test
fun `status emits Active(port) for EXTERNAL with valid port`() =
runTest(UnconfinedTestDispatcher()) {
val prefs = FakeTorPreferences(initialTorType = TorType.EXTERNAL, initialPort = 9150)
val manager = buildManager(prefs = prefs)
advanceUntilIdle()
val status = manager.status.value
assertTrue(status is TorServiceStatus.Active)
assertEquals(9150, (status as TorServiceStatus.Active).port)
}
@Test
fun `status emits Off for EXTERNAL when port is invalid`() =
runTest(UnconfinedTestDispatcher()) {
val prefs = FakeTorPreferences(initialTorType = TorType.EXTERNAL, initialPort = 0)
val manager = buildManager(prefs = prefs)
advanceUntilIdle()
assertEquals(TorServiceStatus.Off, manager.status.value)
}
@Test
fun `status follows backend status under INTERNAL`() =
runTest(UnconfinedTestDispatcher()) {
val backend = FakeTorBackend()
val manager = buildManager(backend = backend)
advanceUntilIdle()
assertEquals(TorServiceStatus.Connecting, manager.status.value)
backend.setActive(17392)
advanceUntilIdle()
assertEquals(TorServiceStatus.Active(17392), manager.status.value)
}
@Test
fun `activePortOrNull mirrors the Active port`() =
runTest(UnconfinedTestDispatcher()) {
val backend = FakeTorBackend()
val manager = buildManager(backend = backend)
// activePortOrNull is WhileSubscribed — give it a subscriber for the test.
val portJob = backgroundScope.launch { manager.activePortOrNull.collect {} }
advanceUntilIdle()
backend.setActive(17392)
advanceUntilIdle()
assertEquals(17392, manager.activePortOrNull.value)
portJob.cancel()
}
@Test
fun `sessionBypass forces Off even with torType INTERNAL`() =
runTest(UnconfinedTestDispatcher()) {
val backend = FakeTorBackend()
val manager = buildManager(backend = backend)
advanceUntilIdle()
backend.setActive(17392)
advanceUntilIdle()
assertNotEquals(TorServiceStatus.Off, manager.status.value)
manager.sessionBypass.value = true
advanceUntilIdle()
assertEquals(TorServiceStatus.Off, manager.status.value)
assertTrue(backend.stopCount >= 1)
}
// ------------------------------------------------------------------
// helpers
// ------------------------------------------------------------------
private fun TestScope.buildManager(
prefs: FakeTorPreferences = FakeTorPreferences(),
backend: FakeTorBackend = FakeTorBackend(),
clock: () -> Long = { 1_000_000_000_000L },
): TorManager =
TorManager(
torPrefs = prefs,
service = backend,
scope = backgroundScope,
// Unconfined so `MutableStateFlow.value = …` propagates through `flowOn`
// synchronously — otherwise advanceUntilIdle never settles the cross-dispatcher
// channel and `manager.status.value` is observed as the stateIn initial (Off).
ioDispatcher = UnconfinedTestDispatcher(testScheduler),
nowMs = clock,
)
}
/** In-memory [TorBackend] driven by tests. */
private class FakeTorBackend : TorBackend {
private val _status = MutableStateFlow<TorServiceStatus>(TorServiceStatus.Off)
override val status: StateFlow<TorServiceStatus> = _status.asStateFlow()
var startCount = 0
private set
var stopCount = 0
private set
var resetCount = 0
private set
var resetWithCleanStateCount = 0
private set
override suspend fun start() {
startCount++
_status.value = TorServiceStatus.Connecting
}
override suspend fun stop() {
stopCount++
_status.value = TorServiceStatus.Off
}
override suspend fun reset() {
resetCount++
_status.value = TorServiceStatus.Off
}
override suspend fun resetWithCleanState() {
resetWithCleanStateCount++
_status.value = TorServiceStatus.Off
}
fun setActive(port: Int) {
_status.value = TorServiceStatus.Active(port)
}
fun setConnecting() {
_status.value = TorServiceStatus.Connecting
}
}
/** In-memory [TorPreferencesPort] driven by tests. */
private class FakeTorPreferences(
initialTorType: TorType = TorType.INTERNAL,
initialPort: Int = 9050,
initialApprovalMs: Long = 0L,
) : TorPreferencesPort {
private val _torType = MutableStateFlow(initialTorType)
private val _externalSocksPort = MutableStateFlow(initialPort)
override val torType: StateFlow<TorType> = _torType.asStateFlow()
override val externalSocksPort: StateFlow<Int> = _externalSocksPort.asStateFlow()
var lastBypassApprovalMs: Long = initialApprovalMs
override suspend fun loadLastBypassApprovalMs(): Long = lastBypassApprovalMs
override suspend fun saveLastBypassApprovalMs(value: Long) {
lastBypassApprovalMs = value
}
fun setTorType(value: TorType) {
_torType.value = value
}
}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
arti-v2.2.0
arti-v2.3.0
+8 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "arti-android"
version = "2.2.0"
version = "2.3.0"
edition = "2021"
[lib]
@@ -9,14 +9,19 @@ crate-type = ["cdylib"]
[workspace]
[dependencies]
arti-client = { version = "0.41", default-features = false, features = [
arti-client = { version = "0.42", default-features = false, features = [
"tokio",
"rustls",
"compression",
"onion-service-client",
"static-sqlite",
] }
tor-rtcompat = { version = "0.41", default-features = false, features = ["tokio", "rustls"] }
tor-rtcompat = { version = "0.42", default-features = false, features = ["tokio", "rustls"] }
# Direct dep on rustls so we can install the `ring` crypto provider ourselves —
# arti-v2.3.0's tor-rtcompat no longer installs one implicitly. `ring` matches
# what arti-v2.2.0 effectively used and avoids the Android build pain of
# aws-lc-rs (which became Arti's default in 2.3.0).
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
jni = "0.21"
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "time", "macros"] }
anyhow = "1"
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
#
# Build the Arti JNI shim for the *host* (typically Linux x86_64) and stage it
# under amethyst/src/test/native-libs/<host-tag>/libarti_android.so so the JVM
# unit tests in TorArtiNativeIntegrationTest can `System.loadLibrary` it.
#
# Companion to build-arti.sh, which builds the *Android* targets for shipping
# in the APK. Same wrapper crate, same lib.rs — only the cargo target differs.
#
# Prerequisites:
# - Rust toolchain with the host target installed (default after `rustup install stable`).
# - The Arti source must already be cloned at .arti-source/ — run build-arti.sh
# once first if this is a fresh checkout.
#
# Usage:
# ./build-arti-host.sh
#
# Why this exists:
# Tier-3 JVM integration tests in amethyst/src/test/.../tor/TorArtiNativeIntegrationTest
# call the real Arti library. The checked-in .so under src/test/native-libs/x86_64-linux/
# covers the most common dev/CI host. If you bump ARTI_VERSION or touch
# tools/arti-build/src/lib.rs, regenerate the host .so with this script before
# running the integration tests; otherwise you'll be testing the previous shim.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
WRAPPER_DIR="$SCRIPT_DIR/.arti-source/arti-android-wrapper"
if [ ! -d "$WRAPPER_DIR" ]; then
echo "Arti source / wrapper not found at $WRAPPER_DIR."
echo "Run ./build-arti.sh first (clones .arti-source and sets up the wrapper)."
exit 1
fi
# Sync the latest wrapper sources into the .arti-source clone — build-arti.sh
# normally does this, but if you've only edited lib.rs the host build needs it too.
cp "$SCRIPT_DIR/src/lib.rs" "$WRAPPER_DIR/src/lib.rs"
HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')"
case "$HOST_TARGET" in
x86_64-unknown-linux-gnu) DEST_TAG="x86_64-linux" ;;
aarch64-unknown-linux-gnu) DEST_TAG="aarch64-linux" ;;
x86_64-apple-darwin) DEST_TAG="x86_64-macos" ;;
aarch64-apple-darwin) DEST_TAG="aarch64-macos" ;;
*)
echo "Unmapped host target $HOST_TARGET — add it to build-arti-host.sh."
exit 1
;;
esac
OUT_DIR="$PROJECT_ROOT/amethyst/src/test/native-libs/$DEST_TAG"
mkdir -p "$OUT_DIR"
echo "Building Arti shim for $HOST_TARGET$OUT_DIR/libarti_android.so"
cargo build --release \
--manifest-path "$WRAPPER_DIR/Cargo.toml" \
--target "$HOST_TARGET"
# macOS Rust toolchains produce .dylib, not .so. Rename so the existing
# System.loadLibrary("arti_android") path keeps working.
case "$HOST_TARGET" in
*-apple-darwin)
src="$WRAPPER_DIR/target/$HOST_TARGET/release/libarti_android.dylib"
;;
*)
src="$WRAPPER_DIR/target/$HOST_TARGET/release/libarti_android.so"
;;
esac
cp "$src" "$OUT_DIR/libarti_android.so"
size=$(du -h "$OUT_DIR/libarti_android.so" | cut -f1)
echo "Built $OUT_DIR/libarti_android.so ($size)"
echo ""
echo "Run the smoke test:"
echo " ./gradlew :amethyst:testPlayDebugUnitTest \\"
echo " --tests com.vitorpamplona.amethyst.ui.tor.TorArtiNativeIntegrationTest"
echo ""
echo "Run the full bootstrap tests (needs Tor network egress):"
echo " ./gradlew :amethyst:testPlayDebugUnitTest \\"
echo " --tests com.vitorpamplona.amethyst.ui.tor.TorArtiNativeIntegrationTest \\"
echo " -Pamethyst.arti.integration=true"
+1
View File
@@ -195,6 +195,7 @@ verify_jni_symbols() {
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize"
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksProxy"
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksProxy"
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_destroy"
)
for arch_dir in "$OUTPUT_DIR"/*/; do
+98 -5
View File
@@ -20,6 +20,10 @@ static TOKIO_RUNTIME: Mutex<Option<tokio::runtime::Runtime>> = Mutex::new(None);
static JAVA_VM: Mutex<Option<JavaVM>> = Mutex::new(None);
static LOG_CALLBACK: Mutex<Option<GlobalRef>> = Mutex::new(None);
static SOCKS_TASK: Mutex<Option<tokio::task::JoinHandle<()>>> = Mutex::new(None);
// Per-connection handler tasks. Tracked so destroy() can abort in-flight handlers
// — otherwise their Arc<TorClient> clones keep the client alive and the
// state file lock would not be released for the next initialize().
static HANDLER_TASKS: Mutex<Vec<tokio::task::JoinHandle<()>>> = Mutex::new(Vec::new());
static INIT_ONCE: Once = Once::new();
// ============================================================================
@@ -127,6 +131,12 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize(
log_info!("Initializing Arti with data directory: {}", data_dir_str);
INIT_ONCE.call_once(|| {
// arti-v2.3.0's tor-rtcompat no longer installs a rustls CryptoProvider
// implicitly — without this, TorClient::create_bootstrapped panics on
// first TLS handshake. install_default() returns Err if a provider is
// already installed, which is fine; we just want at-least-one.
let _ = rustls::crypto::ring::default_provider().install_default();
match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
@@ -160,8 +170,21 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize(
let result: Result<()> = runtime.block_on(async {
log_info!("Creating Arti client...");
let config = TorClientConfigBuilder::from_directories(state_dir, cache_dir)
.build()?;
let mut builder = TorClientConfigBuilder::from_directories(state_dir, cache_dir);
// Arti's fs-mistrust walks every parent of the state dir and rejects any
// that has an "unsafe" owner. On Android the app's private filesDir is
// sandboxed by the OS, so the default strict check is correct. On JVM
// host runs (TorArtiNativeIntegrationTest) the data dir lives under
// /tmp and the check trips on container-style ownership of `/` (UID 999
// etc.). Disable it for non-Android targets — these are the test/dev
// surface, not a user-facing binary.
#[cfg(not(target_os = "android"))]
{
builder.storage().permissions().dangerously_trust_everyone();
}
let config = builder.build()?;
let client = TorClient::create_bootstrapped(config).await?;
@@ -243,11 +266,14 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksPr
match listener.accept().await {
Ok((stream, _peer_addr)) => {
let client_clone = Arc::clone(&client);
tokio::spawn(async move {
let h = tokio::spawn(async move {
if let Err(e) = handle_socks_connection(stream, client_clone).await {
log_error!("SOCKS connection error: {:?}", e);
}
});
let mut handlers = HANDLER_TASKS.lock().unwrap();
handlers.retain(|h| !h.is_finished());
handlers.push(h);
}
Err(e) => {
log_error!("Failed to accept SOCKS connection: {:?}", e);
@@ -373,8 +399,13 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksPro
handle.abort();
}
if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref() {
rt.block_on(async {
let rt_handle = TOKIO_RUNTIME
.lock()
.unwrap()
.as_ref()
.map(|rt| rt.handle().clone());
if let Some(rh) = rt_handle {
rh.block_on(async {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
});
}
@@ -383,4 +414,66 @@ pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksPro
log_info!("SOCKS proxy stopped");
0
}
/// Destroy the TorClient — used by self-heal paths in Kotlin when Tor is
/// stuck and the in-memory state (guards, circuits) needs to be rebuilt
/// from scratch. Aborts the SOCKS listener and all in-flight per-connection
/// handlers, then drops the static Arc so Arti's state file lock can be
/// released. The next call to [initialize] will create a fresh TorClient
/// (and re-bootstrap).
#[no_mangle]
pub extern "C" fn Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_destroy(
_env: JNIEnv,
_class: JClass,
) -> jint {
log_info!("Destroying Arti client");
// Clone the runtime handle and release the TOKIO_RUNTIME mutex immediately —
// we will hold it for ~500ms below, and other JNI calls that need the runtime
// (e.g. a Kotlin start() racing with us) would otherwise block on this mutex.
let rt_handle = TOKIO_RUNTIME
.lock()
.unwrap()
.as_ref()
.map(|rt| rt.handle().clone());
// Abort the listener and wait for it to actually terminate before draining
// HANDLER_TASKS. The accept loop has no .await between `accept` and
// `HANDLER_TASKS.push(h)`, so abort() alone is racy: a handler can be spawned
// and pushed AFTER our drain. Awaiting the JoinHandle (with timeout) closes
// that window — no new handlers can be pushed once the listener task is gone.
let socks_handle = SOCKS_TASK.lock().unwrap().take();
if let (Some(h), Some(rh)) = (socks_handle, rt_handle.as_ref()) {
h.abort();
rh.block_on(async {
let _ = tokio::time::timeout(tokio::time::Duration::from_secs(1), h).await;
});
}
// Abort all in-flight handlers — each holds an Arc<TorClient> clone, and
// the client cannot drop (state file lock cannot release) while any clone
// is alive.
let handlers = std::mem::take(&mut *HANDLER_TASKS.lock().unwrap());
for h in &handlers {
h.abort();
}
drop(handlers);
// Give tokio a moment to actually cancel and drop the task frames so the
// handler Arcs are released before we drop our static one.
if let Some(rh) = rt_handle {
rh.block_on(async {
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
});
}
// Drop the static Arc. If any handler is still holding a clone, the
// TorClient stays alive until that handler finishes — in which case the
// next initialize() will fail and Kotlin's clearAllArtiData retry path
// will handle it.
let _ = ARTI_CLIENT.lock().unwrap().take();
log_info!("Arti client destroyed");
0
}