feat(tor): self-heal — drop & rebuild Arti on network change and stuck Connecting

When Arti's in-memory TorClient gets into a broken state (bad guards from a
previous network, dead circuits, expired consensus held in memory), nothing
short of a process restart used to recover it: the JNI exposed initialize /
startSocksProxy / stopSocksProxy but no way to drop the TorClient, and the
Kotlin side gated initialize behind a one-shot AtomicBoolean. force-stop
preserved the on-disk arti/state/, toggle-off-then-on only re-bound the SOCKS
listener on the same broken client, and wiping app data was the only way out.

Rust side
- New JNI Java_..._ArtiNative_destroy: aborts the SOCKS listener task, aborts
  all in-flight per-connection handlers (each holds an Arc<TorClient> clone
  that would otherwise pin the state file lock), waits 500ms, drops the static
  ARTI_CLIENT. Next initialize() call creates a fresh client and re-bootstraps.
- Track handler JoinHandles in HANDLER_TASKS so destroy can abort them; cull
  finished ones on each accept to keep the Vec bounded.

Kotlin side
- TorService.reset() / resetWithCleanState() — drop the native client, flip
  initialized=false. The second variant also wipes arti/state/ on disk to
  rebuild guard selection from scratch.
- TorManager.resetEpoch StateFlow is now part of the status combine; bumping
  it re-fires the INTERNAL branch which calls service.start() and runs full
  Arti re-init.
- onNetworkChange (wired from ConnectivityManager.networkId distinctUntilChanged)
  now calls service.reset() + clears the persisted bypass approval + bumps the
  epoch. Replaces the previous clearSessionBypass() which only touched the
  in-memory bypass half.
- Self-heal watchdog: when status sits at Connecting for >45s (before the 60s
  connectionFailure dialog), calls resetWithCleanState. Rate-limited to one
  per 5 minutes so a permanently broken network doesn't loop us. onNetworkChange
  primes lastSelfHealAtMs so a slow legitimate post-network-change bootstrap
  doesn't get a second reset on top of itself.

Rebuilds: libarti_android.so for arm64-v8a + x86_64 (NDK 27, 16KB-page aligned).
This commit is contained in:
Claude
2026-05-26 19:09:54 +00:00
parent 321adebfe6
commit db378a105c
8 changed files with 171 additions and 11 deletions
@@ -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() }
}
}
@@ -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
}
/**
@@ -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 ~1030s, 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
}
}
@@ -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()
}
}
}
Binary file not shown.
+1
View File
@@ -195,6 +195,7 @@ verify_jni_symbols() {
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_initialize"
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksProxy"
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksProxy"
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_destroy"
)
for arch_dir in "$OUTPUT_DIR"/*/; do
+53 -1
View File
@@ -20,6 +20,10 @@ static TOKIO_RUNTIME: Mutex<Option<tokio::runtime::Runtime>> = Mutex::new(None);
static JAVA_VM: Mutex<Option<JavaVM>> = Mutex::new(None);
static LOG_CALLBACK: Mutex<Option<GlobalRef>> = Mutex::new(None);
static SOCKS_TASK: Mutex<Option<tokio::task::JoinHandle<()>>> = Mutex::new(None);
// Per-connection handler tasks. Tracked so destroy() can abort in-flight handlers
// — otherwise their Arc<TorClient> clones keep the client alive and the
// state file lock would not be released for the next initialize().
static HANDLER_TASKS: Mutex<Vec<tokio::task::JoinHandle<()>>> = Mutex::new(Vec::new());
static INIT_ONCE: Once = Once::new();
// ============================================================================
@@ -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<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(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
}