A self-heal reset()/resetWithCleanState() (and the lifecycle serialization added
in the previous commit) can only recover Tor if initialize() actually returns.
ArtiNative.initialize() calls TorClient::create_bootstrapped, which on a hostile
network (unreachable guards, wiped consensus) retries internally for many
minutes. While it blocks it holds lifecycleMutex, so the watchdog's reset can
never run — Tor stays wedged at Connecting.
Wrap create_bootstrapped in a 60s tokio::time::timeout. On timeout the future is
dropped (tearing down the half-built client) and initialize() returns -4; the
JNI ABI is unchanged (still one String arg), so the checked-in CI host .so and
TorArtiNativeIntegrationTest keep working without a rebuild. TorService treats
-4 specially: drop the init flag and leave status Connecting (don't wipe+retry
inline under the lock, don't go Off) so TorManager's self-heal watchdog resets
and re-inits on its own cadence, and connectionFailure can still surface the
"use regular connection" dialog.
Rebuilt libarti_android.so for arm64-v8a + x86_64.
Verified on device: a no-network cold-start bootstrap timed out at exactly 60s
(previously hung 7+ min), released the lock, and on network restore the watchdog
re-init'd and Tor reached Active. Addresses #3225.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the test gap below the tier-1 unit tests by running the real Arti
JNI shim end-to-end on JVM. Cheaper than an emulator + connectedAndroidTest,
and exercises the exact Rust + JNI code path the Android .so does.
Three tests in TorArtiNativeIntegrationTest:
1. `library loads and reports a version` — always-on smoke check. Loads
libarti_android.so via System.loadLibrary and calls ArtiNative.getVersion.
~10ms. Catches build/link regressions (e.g. a stale .so after an ARTI
bump, a missing JNI symbol export, a forgotten rebuild on this path).
Skipped on non-Linux-x86_64 hosts with a clear message pointing at the
build-arti-host.sh rebuild step.
2. `bootstraps and proxies an HTTPS request through Tor` — opt-in via
-Pamethyst.arti.integration=true. ArtiNative.initialize → startSocksProxy
→ OkHttp-via-SOCKS → check.torproject.org/api/ip. Asserts "IsTor":true.
Regression net for the rustls CryptoProvider install we added after the
v2.3.0 bump and for the destroy/handler-abort fixes in the Rust shim.
3. `destroy then re-initialize releases the state file lock cleanly` — opt-in.
The direct unit-test mirror of the self-heal path: bootstrap, destroy, hit
the SAME data dir with initialize again, verify it succeeds without a
"state file already locked" error and that traffic still flows.
Wiring:
- New tools/arti-build/build-arti-host.sh — companion to build-arti.sh.
Cargo-builds the wrapper crate for the host target (x86_64-linux on most
dev machines, but the script maps macOS / arm64-linux too) and copies to
amethyst/src/test/native-libs/<host-tag>/libarti_android.so.
- amethyst/build.gradle.kts testOptions.unitTests.all configures
-Djava.library.path so System.loadLibrary("arti_android") finds the
checked-in host .so. Also forwards -Pamethyst.arti.integration so the
opt-in gate works from a Gradle invocation.
- Checked-in src/test/native-libs/x86_64-linux/libarti_android.so for the
most common dev/CI host (~6 MB).
Wrapper change to make the JVM path actually run:
- lib.rs: on #[cfg(not(target_os = "android"))], call
builder.storage().permissions().dangerously_trust_everyone() so Arti's
fs-mistrust check doesn't reject /tmp data dirs on hosts where parent
directories have unusual UIDs (typical in containers). Android keeps its
strict default — the app's private filesDir is already sandboxed by the OS.
Compiled-out on Android, so the shipped Android .so is functionally
unchanged.
Verified in this session:
- Smoke test passes without -P (3 tests, 1 ran, 2 skipped).
- Full unit test suite still passes.
- With -P the bootstrap tests get past Arti's permissions check; they hang
on actual relay I/O in this container because outbound TCP egress is
restricted to a CDN allow-list, not Tor relays. Tests succeed on hosts
with unrestricted outbound — see the test kdoc.
Wins: reduced GeoIP memory usage (moved off heap), CircuitClosed→NotConnected
error change (affects our handler error paths), DATA-cells-on-closed-streams
fix, and a flow-control sidechannel mitigation bug fix. Nothing here directly
addresses the stuck-Tor recovery work in the prior commits, but it's a clean
overdue bump while we're in this code.
Wrapper changes required by the bump:
- arti-client + tor-rtcompat: 0.41 → 0.42 to match the new crate versions
shipped with arti-v2.3.0.
- arti-v2.3.0's tor-rtcompat no longer installs a rustls CryptoProvider
implicitly (changelog: "if the application fails to install a rustls
CryptoProvider, tor-rtcompat no longer installs one itself"). Add a direct
`rustls = "0.23"` dep with the `ring` feature and `install_default()` it
inside INIT_ONCE before runtime creation — otherwise create_bootstrapped
panics on the first TLS handshake. Keeping `ring` (same as 2.2.0
effectively used) rather than 2.3.0's new default `aws-lc-rs`, which is
heavier on Android and has known build.rs pain on aarch64-linux-android.
Heads-up for the next bump: arti-v2.4.0 will explicitly wrap TorClient in
Arc rather than implicitly having Arc-like semantics. We already wrap
explicitly so the migration is a no-op aside from potential Arc<Arc<...>>
cleanup.
Rebuilds: libarti_android.so for arm64-v8a + x86_64.
Audit of db378a1 surfaced three issues; this commit addresses them.
1) First-bootstrap self-heal storm (TorManager). On a fresh install with a
slow network the legitimate first bootstrap takes 30–60s. The 45s
stuck-Connecting watchdog used to fire resetWithCleanState, wiping an
empty state dir and adding a full bootstrap cycle of delay for no gain.
Now: track hasEverBootstrapped (flipped when status reaches Active);
pre-first-bootstrap self-heals use the gentler reset (drop client only,
keep state), post-first-bootstrap use resetWithCleanState. Wiping stale
on-disk guards only matters once we know Arti can actually work.
2) Rust destroy() race (lib.rs). The accept loop in startSocksProxy has no
.await between accept() returning and HANDLER_TASKS.push(h), so an
abort() alone is racy — a new handler can be spawned and pushed AFTER
our drain runs, which then holds an Arc<TorClient> past destroy() and
keeps the state file lock alive. Now: after abort(), await the SOCKS
JoinHandle with a 1s timeout so the listener fully terminates before
we drain HANDLER_TASKS. No new handlers can be added once the listener
is gone.
3) TOKIO_RUNTIME mutex held during block_on(sleep). The previous
`if let Some(rt) = TOKIO_RUNTIME.lock().unwrap().as_ref()` kept the
mutex held for the full sleep duration, blocking any other JNI caller
that needs the runtime. Now: clone the runtime Handle and release the
mutex immediately. Same fix applied to stopSocksProxy.
Rebuilds: libarti_android.so for arm64-v8a + x86_64.
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).
All log messages go through send_log_to_java() → Kotlin ArtiLogCallback
→ Log.d("TorService"), which already writes to logcat. The android_logger
module was a second FFI call to __android_log_write that duplicated
every line.
https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
All features and APIs verified present in 0.41:
- tokio, rustls, compression, onion-service-client, static-sqlite
- TorClient::create_bootstrapped, from_directories, connect()
https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
- Set default-features = false on arti-client and tor-rtcompat
- Removed bridge-client (UI doesn't expose bridge config yet)
- Narrowed tokio features from "full" to only what the SOCKS proxy
needs: rt-multi-thread, net, io-util, time, macros
Kept: tokio, rustls, compression, onion-service-client, static-sqlite
(all required for Amethyst's .onion relay support)
https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu
The Guardian Project's arti-mobile-ex AAR has three problems:
1. No 16KB page-aligned binaries (required for Google Play)
2. ArtiProxy's stop()+start() causes state file lock conflicts
(lock is tied to TorClient object lifetime, released only on GC)
3. ~140MB AAR size
Replace with a custom JNI bridge built from Arti source, following
BitChat's proven approach:
Build tooling (tools/arti-build/):
- build-arti.sh: Clones official Arti, compiles with cargo-ndk
for ARM64 + x86_64, NDK 25+ for 16KB page alignment
- Cargo.toml: Minimal deps with size-optimized release profile
- src/lib.rs: Custom SOCKS5 proxy with proper lifecycle:
- initialize() creates TorClient once (holds state lock forever)
- startSocksProxy() binds port and accepts connections
- stopSocksProxy() aborts listener only (TorClient stays alive)
This cleanly separates "stop routing traffic" from "destroy client"
Kotlin side:
- ArtiNative.kt: JNI declarations + ArtiLogCallback interface
- TorService.kt: Uses ArtiNative directly, start() initializes +
starts proxy, stop() only stops proxy (no lock issues)
- TorManager.kt: Restored stop() calls for OFF/EXTERNAL modes
since our native stop is now safe
Removed: arti-mobile-ex dependency from build.gradle and version catalog
Native libraries must be built separately:
cd tools/arti-build && ./build-arti.sh
https://claude.ai/code/session_01BApgDd5udqBzMqysSRMpZu