mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
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:
@@ -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 ~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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
Binary file not shown.
@@ -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();
|
||||
|
||||
// ============================================================================
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user