mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
@@ -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()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+176
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -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 ~10–30s, 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.
Binary file not shown.
+489
@@ -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 @@
|
||||
arti-v2.2.0
|
||||
arti-v2.3.0
|
||||
@@ -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"
|
||||
|
||||
Executable
+83
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user