diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt
index 976bec0c14..d4c235260c 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt
@@ -192,10 +192,10 @@ class AppModules(
val torManager = TorManager(torPrefs, 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 +203,7 @@ class AppModules(
.filterNotNull()
.distinctUntilChanged()
.drop(1)
- .collect { torManager.clearSessionBypass() }
+ .collect { torManager.onNetworkChange() }
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt
index 6d066d1d08..9a1551f77e 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/ArtiNative.kt
@@ -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
}
/**
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt
index 1b32e28241..aa66b5c478 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt
@@ -41,6 +41,7 @@ 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
/**
@@ -69,6 +70,18 @@ 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
+
init {
scope.launch(Dispatchers.IO) {
lastBypassApprovalMs = torPrefs.loadLastBypassApprovalMs()
@@ -95,7 +108,8 @@ class TorManager(
torPrefs.value.torType,
torPrefs.value.externalSocksPort,
sessionBypass,
- ) { torType, externalSocksPort, bypass ->
+ resetEpoch,
+ ) { torType, externalSocksPort, bypass, _ ->
Triple(torType, externalSocksPort, bypass)
}.transformLatest { (torType, externalSocksPort, bypass) ->
if (bypass) {
@@ -171,6 +185,40 @@ 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 almost certainly stuck —
+ // bad guards, broken circuits, expired consensus. Drop the TorClient, wipe
+ // on-disk state, and bump resetEpoch so the status combine re-fires and
+ // re-enters the INTERNAL branch — which calls service.start() and, because
+ // reset() flipped initialized=false, runs full Arti re-init with fresh
+ // bootstrap. Rate-limited so a permanently broken network doesn't loop us.
+ // Fires BEFORE the 60s connectionFailure dialog so most users never see it.
+ selfHealSignal
+ .onEach {
+ val now = System.currentTimeMillis()
+ if (now - lastSelfHealAtMs < SELF_HEAL_COOLDOWN_MS) return@onEach
+ lastSelfHealAtMs = now
+ Log.w("TorManager") { "Tor stuck Connecting >${SELF_HEAL_AFTER_MS}ms — self-healing (drop client + wipe state)" }
+ service.resetWithCleanState()
+ resetEpoch.update { it + 1 }
+ }.launchIn(scope)
+ }
+
fun rememberedApprovalActive(): Boolean {
val ts = lastBypassApprovalMs
return ts > 0 && (System.currentTimeMillis() - ts) < APPROVAL_REMEMBER_MS
@@ -187,12 +235,26 @@ class TorManager(
}
/**
- * 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 = System.currentTimeMillis()
+ scope.launch(Dispatchers.IO) {
+ torPrefs.saveLastBypassApprovalMs(0L)
+ service.reset()
+ resetEpoch.update { it + 1 }
+ }
}
fun isSocksReady() = status.value is TorServiceStatus.Active
@@ -202,5 +264,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
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt
index b74f567535..724cc34d59 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt
@@ -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.
+ */
+ 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.
+ */
+ suspend fun resetWithCleanState() {
+ reset()
+ withContext(Dispatchers.IO) {
+ clearAllArtiData()
+ }
+ }
}
diff --git a/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so b/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so
index 8fc5ef3339..6723547ecf 100755
Binary files a/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so and b/amethyst/src/main/jniLibs/arm64-v8a/libarti_android.so differ
diff --git a/amethyst/src/main/jniLibs/x86_64/libarti_android.so b/amethyst/src/main/jniLibs/x86_64/libarti_android.so
index 3ee9f24e99..efe7e633ac 100755
Binary files a/amethyst/src/main/jniLibs/x86_64/libarti_android.so and b/amethyst/src/main/jniLibs/x86_64/libarti_android.so differ
diff --git a/tools/arti-build/build-arti.sh b/tools/arti-build/build-arti.sh
index 71c3136194..7e403eb7c3 100755
--- a/tools/arti-build/build-arti.sh
+++ b/tools/arti-build/build-arti.sh
@@ -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
diff --git a/tools/arti-build/src/lib.rs b/tools/arti-build/src/lib.rs
index 20a7a43d11..b8fa2b9c24 100644
--- a/tools/arti-build/src/lib.rs
+++ b/tools/arti-build/src/lib.rs
@@ -20,6 +20,10 @@ static TOKIO_RUNTIME: Mutex