The 60s bootstrap timeout this branch adds is invisible to every JVM test — it only manifests against a real radio. Add a device-driven harness that drives the network transitions that have historically wedged Tor and asserts the lifecycle invariants from logcat, so the behavior can be re-verified whenever Arti is bumped or the Tor management path changes. tools/tor-network-tests/run.sh — adb-driven runner, one function per scenario, PASS/FAIL per check, restores a clean network state on exit: - cold_start Active reached + no pre-ready dial storm (the #3223 gate) - offline_bootstrap empty cache + airplane: asserts the bootstrap is bounded ('bootstrap timed out' logged), then recovers on restore. This is the regression test for this PR — it FAILS on a build without the timeout (create_bootstrapped blocks 95s+). - wifi_cellular WiFi->Cellular handover recovers to Active - airplane offline pauses relays cleanly; restore recovers - pause_resume backgrounding winds relays down (~30s); resume reconnects README.md documents each scenario's rationale, device setup, when to re-run (new Arti version, TorService/TorManager/dial-gate changes), and how the suite relates to TorManagerTest / TorCircuitHealthTrackerTest / the instrumented test. Verified on a Pixel emulator (WiFi + Cellular): all scenarios pass on this branch; offline_bootstrap fails on main (no timeout), confirming the suite discriminates fixed from broken. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6.3 KiB
Tor networking scenario suite
A repeatable, device-driven test harness for Amethyst's embedded Tor (Arti)
across the network transitions that have historically broken it. These checks
live outside the JVM/instrumented test tree on purpose: they manipulate the
device's network (airplane mode, WiFi/cellular, app lifecycle) and assert on
the real ArtiNative bootstrap — things a unit test can't reach.
When to run this
Run the suite whenever you touch anything in the Tor management path, and treat a green run as a release gate for those changes:
- Bumping Arti (
tools/arti-build, the rebuiltlibarti_android.so) — a new Arti version can change bootstrap timing, the SOCKS error mapping, or guard handling. This is the most important trigger. TorService.kt— lifecycle (start/stop/reset/resetWithCleanState), the bootstrap-timeout handling (ARTI_ERROR_BOOTSTRAP_TIMEOUT), or cache/state wiping.TorManager.kt— the self-heal watchdog,onNetworkChange, cooldowns, or status routing.- The dial gate —
WebsocketBuilder.canConnect/ thecanDialwiring inAppModules.ktthat holds Tor-routed relays until the SOCKS port is ready. - The connectivity layer —
ConnectivityManager/RelayProxyClientConnector(pause/resume, reconnect-on-change).
How it relates to the other tests
| Layer | Where | Covers |
|---|---|---|
| Pure logic (fast, deterministic) | amethyst/src/test/.../tor/TorManagerTest.kt |
watchdog, cooldown, onNetworkChange, status routing — virtual time, in-memory fakes, no Arti |
| Pure logic (fast) | amethyst/src/test/.../service/relayClient/TorCircuitHealthTrackerTest.kt |
the Active-but-failing discriminator (companion circuit-health PR) |
| Real Arti, single process | amethyst/src/androidTest/.../tor/TorBootstrapInstrumentedTest.kt (@Ignore'd) |
real initialize → create_bootstrapped → SOCKS round-trip + destroy |
| Real Arti + real network transitions | this suite | bootstrap bounding, network change, airplane, pause/resume — end to end |
The unit tests are the first line of defense (run in CI). This suite is the end-to-end backstop you run by hand (or in a device lab) before shipping a Tor change, because the failures it catches only appear with a real radio.
Device setup (once)
- Connect one device or emulator with internet egress to the Tor network.
An emulator that exposes both WiFi and Cellular (e.g. a stock Pixel AVD) is
ideal — the
wifi_cellularscenario needs a second transport to fail over to. - Install the debug build:
./gradlew :amethyst:installPlayDebug. - In the app, set Tor = INTERNAL and enable routing relays over Tor (Settings → Network/Tor). The suite does not change Tor settings; it only manipulates the network and the app lifecycle, so it tests the config the user actually runs.
Running
# full suite
tools/tor-network-tests/run.sh
# a single scenario
tools/tor-network-tests/run.sh offline_bootstrap
# override the package (e.g. a different flavor)
AMETHYST_PKG=com.vitorpamplona.amethyst.debug tools/tor-network-tests/run.sh
Exit code is 0 only if every selected scenario passes. The suite restores a
clean network state (airplane off, WiFi+data on) when it finishes.
Scenarios
Each scenario clears logcat, drives a transition, then asserts on Tor's lifecycle
log markers (SOCKS proxy active, bootstrap timed out, onNetworkChange,
Pausing/Resuming Relay Services, OnOpen).
cold_start
Force-restart on a healthy network. Asserts: Tor reaches Active, and the
number of Tor-routed dials issued before the SOCKS port was ready stays ~0
(the #3223 gate). A spike here means the gate regressed and the pool is hammering
a dead proxy during bootstrap.
offline_bootstrap — the headline regression test for this PR
Wipe Arti state (empty cache), enable airplane mode, then cold-start so
create_bootstrapped runs with no network and nothing cached. Asserts: the
native bootstrap is bounded — a bootstrap timed out line appears (the 60s
tokio::time::timeout releasing lifecycleMutex so the self-heal watchdog can
run), instead of blocking for many minutes. Then restore the network and assert
recovery to Active.
Without the timeout (
fix: bound Arti bootstrap with a 60s timeout, this PR),create_bootstrappedblocks indefinitely holding the lifecycle lock — observed wedging at 95s+ on an emulator — and this scenario fails with "no 'bootstrap timed out'". That regression is invisible to every JVM test, which is why this suite exists.
wifi_cellular
With Tor Active, disable WiFi (the default route fails over to cellular), wait,
re-enable. Asserts: the transport change drives onNetworkChange and Tor
returns to Active. Catches the "stale guards/circuits from the old network"
class of wedge.
airplane
Toggle airplane mode on (full offline) then off. Asserts: relays pause cleanly on connectivity loss (no Tor thrash while offline) and Tor + relays recover on restore.
pause_resume
Background the app (HOME), wait for the WhileSubscribed(30s) wind-down, then
foreground it. Asserts: relays pause (Pausing Relay Services) while
backgrounded and reconnect (OnOpen increases) on resume. The reconnection burst
on resume is the same shape as the post-Active warmup burst, so this also guards
against the circuit-health self-heal firing a false positive on resume.
Interpreting failures
The summary prints PASS/FAIL per check with a one-line reason. Common ones:
offline_bootstrapFAIL "no 'bootstrap timed out'" → the native timeout isn't in the installed.so(rebuildlibarti_android.sofromtools/arti-build, or you're on a branch without the fix).cold_startFAIL "pre-ready doomed dials >5" → thecanDial/canConnectgate regressed.wifi_cellular/airplaneFAIL "did not reach Active" → bootstrap is slow or wedged on the new network; pull the full log and look for repeatedExitTimeout/AllGuardsDown.
For a deeper look at any run, dump the lifecycle directly:
adb logcat -d | grep -E "TorService|TorManager|ManageRelayServices" \
| grep -iE "Initializing|bootstrap|proxy active|timed out|self-heal|reset|onNetworkChange|Pausing|Resuming"