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> = Mutex::new(None); static JAVA_VM: Mutex> = Mutex::new(None); static LOG_CALLBACK: Mutex> = Mutex::new(None); static SOCKS_TASK: Mutex>> = Mutex::new(None); +// Per-connection handler tasks. Tracked so destroy() can abort in-flight handlers +// — otherwise their Arc clones keep the client alive and the +// state file lock would not be released for the next initialize(). +static HANDLER_TASKS: Mutex>> = Mutex::new(Vec::new()); static INIT_ONCE: Once = Once::new(); // ============================================================================ @@ -243,11 +247,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); @@ -383,4 +390,49 @@ 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"); + + // Abort the listener task first so no new handlers spawn. + if let Some(handle) = SOCKS_TASK.lock().unwrap().take() { + handle.abort(); + } + + // Abort all in-flight handlers — each holds an Arc 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(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref() { + rt.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 } \ No newline at end of file