mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +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)
|
val torManager = TorManager(torPrefs, appContext, applicationIOScope)
|
||||||
|
|
||||||
// Whenever the underlying network identity changes (wifi↔cellular, regained from
|
// Network identity change (wifi↔cellular, regained from offline, captive portal
|
||||||
// offline, etc.) we clear any active Tor session bypass so the manager re-attempts
|
// cleared) — the old network's guards/circuits are dead, and Arti's in-memory
|
||||||
// bootstrap on the new network. The remembered-approval window is unaffected: if Tor
|
// client + on-disk state/ both need a fresh start. onNetworkChange drops the
|
||||||
// stays stuck we will silently bypass again after the timeout fires.
|
// TorClient, clears the bypass + persisted approval, and triggers a full re-init.
|
||||||
init {
|
init {
|
||||||
applicationIOScope.launch {
|
applicationIOScope.launch {
|
||||||
connManager.status
|
connManager.status
|
||||||
@@ -203,7 +203,7 @@ class AppModules(
|
|||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.drop(1)
|
.drop(1)
|
||||||
.collect { torManager.clearSessionBypass() }
|
.collect { torManager.onNetworkChange() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ object ArtiNative {
|
|||||||
* @return 0 on success.
|
* @return 0 on success.
|
||||||
*/
|
*/
|
||||||
external fun stopSocksProxy(): Int
|
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.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.transformLatest
|
import kotlinx.coroutines.flow.transformLatest
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +70,18 @@ class TorManager(
|
|||||||
*/
|
*/
|
||||||
@Volatile private var lastBypassApprovalMs: Long = 0L
|
@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 {
|
init {
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
lastBypassApprovalMs = torPrefs.loadLastBypassApprovalMs()
|
lastBypassApprovalMs = torPrefs.loadLastBypassApprovalMs()
|
||||||
@@ -95,7 +108,8 @@ class TorManager(
|
|||||||
torPrefs.value.torType,
|
torPrefs.value.torType,
|
||||||
torPrefs.value.externalSocksPort,
|
torPrefs.value.externalSocksPort,
|
||||||
sessionBypass,
|
sessionBypass,
|
||||||
) { torType, externalSocksPort, bypass ->
|
resetEpoch,
|
||||||
|
) { torType, externalSocksPort, bypass, _ ->
|
||||||
Triple(torType, externalSocksPort, bypass)
|
Triple(torType, externalSocksPort, bypass)
|
||||||
}.transformLatest { (torType, externalSocksPort, bypass) ->
|
}.transformLatest { (torType, externalSocksPort, bypass) ->
|
||||||
if (bypass) {
|
if (bypass) {
|
||||||
@@ -171,6 +185,40 @@ class TorManager(
|
|||||||
false,
|
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 {
|
fun rememberedApprovalActive(): Boolean {
|
||||||
val ts = lastBypassApprovalMs
|
val ts = lastBypassApprovalMs
|
||||||
return ts > 0 && (System.currentTimeMillis() - ts) < APPROVAL_REMEMBER_MS
|
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
|
* Network identity changed (wifi↔cellular, captive portal cleared, regained from
|
||||||
* remembered-approval window: if Tor stays stuck, we will silently bypass again
|
* offline). The old network's guards and circuits are dead, but Arti's in-memory
|
||||||
* after the timeout fires.
|
* 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
|
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
|
fun isSocksReady() = status.value is TorServiceStatus.Active
|
||||||
@@ -202,5 +264,9 @@ class TorManager(
|
|||||||
companion object {
|
companion object {
|
||||||
const val BOOTSTRAP_TIMEOUT_MS: Long = 60_000L
|
const val BOOTSTRAP_TIMEOUT_MS: Long = 60_000L
|
||||||
const val APPROVAL_REMEMBER_MS: Long = 60L * 60L * 1000L
|
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
|
_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_initialize"
|
||||||
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksProxy"
|
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_startSocksProxy"
|
||||||
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksProxy"
|
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_stopSocksProxy"
|
||||||
|
"Java_com_vitorpamplona_amethyst_ui_tor_ArtiNative_destroy"
|
||||||
)
|
)
|
||||||
|
|
||||||
for arch_dir in "$OUTPUT_DIR"/*/; do
|
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 JAVA_VM: Mutex<Option<JavaVM>> = Mutex::new(None);
|
||||||
static LOG_CALLBACK: Mutex<Option<GlobalRef>> = Mutex::new(None);
|
static LOG_CALLBACK: Mutex<Option<GlobalRef>> = Mutex::new(None);
|
||||||
static SOCKS_TASK: Mutex<Option<tokio::task::JoinHandle<()>>> = 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();
|
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 {
|
match listener.accept().await {
|
||||||
Ok((stream, _peer_addr)) => {
|
Ok((stream, _peer_addr)) => {
|
||||||
let client_clone = Arc::clone(&client);
|
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 {
|
if let Err(e) = handle_socks_connection(stream, client_clone).await {
|
||||||
log_error!("SOCKS connection error: {:?}", e);
|
log_error!("SOCKS connection error: {:?}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let mut handlers = HANDLER_TASKS.lock().unwrap();
|
||||||
|
handlers.retain(|h| !h.is_finished());
|
||||||
|
handlers.push(h);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_error!("Failed to accept SOCKS connection: {:?}", 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");
|
log_info!("SOCKS proxy stopped");
|
||||||
0
|
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