Summary
On iOS, every return to the foreground restarts Arti: app switches, and by the same code path unlocks. The restart called the Rust arti_stop() inline from TorManager.restartArti(), which runs on the main actor. arti_stop() sleeps 200 ms while holding Arti's state lock, so the UI froze for about 200 ms on every resume.
This PR awaits that one call on a detached task. Nothing else changes:
- the full restart on every foreground;
- the 200 ms sleep;
- the stop-before-start order;
- the
TorWillRestart/ flag resets / wait loop /startIfNeeded()sequence; - AppRuntime's URLSession rebuild and relay reset.
Draft until a device pass (checklist below). Tor restarts on lock and background have been fragile.
Root cause
- The restart itself is deliberate. It dates from #564 ("iOS can suspend Tor harshly; restart every time we become active") and was device-verified in the v1.7.1 pass (#1580). This PR does not touch it.
- The main-thread placement is inherited.
TorManageris@MainActor, andrestartArti()kept callingarti_stop()inline when Arti replaced C Tor (23d63ab4).shutdownCompletely()already calls it fromTask.detached. - The 200 ms sleep stays. It is in
arti_stop()(localPackages/Arti/arti-bitchat/src/lib.rs:150) and came with the first Arti commit. It orders the old instance's exit before the nextarti_start. No Rust or xcframework change.
Change
- _ = arti_stop()
+ _ = await Task.detached(priority: .userInitiated) { arti_stop() }.valueThe PR also adds a comment, and TorManagerTests: a source guard that fails if arti_stop() is called anywhere outside a Task.detached closure. Pointed at the old source, it flags the inline call.
Evidence
iOS 27.0 simulator, Debug, iPhone 17 Pro Max. Both builds carry identical temporary instrumentation (not committed), and a probe pings the main queue every 10 ms after .active.
| main | this PR | |
|---|---|---|
Where arti_stop() runs | main thread, 200-202 ms | off main, 200-210 ms |
| Resumes with a main-thread stall over 50 ms | 84 / 84 (190-195 ms each) | 0 / 50 (worst 29 ms) |
Tor ready after .active | 1.48-1.51 s | 1.48-1.58 s |
- Stress, per build: 36 rapid background/foreground cycles (re-background 0-300 ms after foreground) plus 6 settle periods. Both builds had identical outcomes:
- one
arti_startper restart, each preceded by a stop; - nothing stuck in restarting;
- after every settle, Tor ready, relays reconnected, and "tor restarted" as the last message.
- one
- Lock/unlock can't be driven from
simctl. It takes the same scenePhase.background→.activepath as an app switch (AppRuntime.handleScenePhaseChange).
What can interleave now (reviewed)
The old restartArti() was not atomic: await MainActor.run yields even on the main actor. So events that arrived during the old 200 ms freeze already ran between the stop and the restart's startIfNeeded(), with the same Swift flags they see now. Two things change: they now run while the stop is still in progress, and the Rust-side state differs at that moment.
Harness: the real TorManager.swift (before and after), compiled against a fake FFI that mirrors lib.rs, 169 event-injection runs per version.
- In neither version: Tor down in the foreground, a stale
isReady, or a brokenTorWillRestart/TorDidBecomeReadypairing. - Main-thread stall: median 204 ms → 5 ms.
One residual edge, this PR only: a panic wipe, or a Tor policy off→on, landing within ~200 ms of a resume restart.
arti_startthen waits on the main thread for the rest of the stop (20-197 ms in the harness).- The restart's wait loop can spin its 4 s.
- The end state is the same, and the wait is no longer than the stall every resume paid before.
- A small guard could remove it, but it would add a flag to
startIfNeeded(), which every start goes through. It is left out of this minimal change.
Pre-existing issue found (not changed here)
Main and this PR both reach the same desync. It happens when the app backgrounds inside a restart, or while Tor is still bootstrapping:
run_artiignores the shutdown signal untilcreate_bootstrappedfinishes.- Its late exit then clears
IS_RUNNING/BOOTSTRAP_PROGRESSover the newer instance.
From then on, for the rest of the process:
arti_stop()is a no-op;arti_start()runs without a stop;- old
TorClients keep handles on Arti's lock and cache files.
Tor still came up, but on main the relays did not reconnect within 24-30 s in 2 of 7 checks. This likely explains a good part of the historical restart flakiness.
Proposed follow-up, with its own device pass:
- a restart-in-flight guard in
startIfNeeded(); - in Rust, cancel
create_bootstrappedon shutdown, and only clear the globals for the current instance.
Device checklist
- App-switch resume ×10: no hitch; "tor restarting…" / "tor restarted" pair up; relays reconnect
- Lock → unlock ×10
- Unlock and re-lock within ~1 s, then unlock again: Tor recovers
- A 5+ minute lock, and an overnight lock
- Toggle Tor off/on, and airplane mode, while "tor restarting…" is showing
- Wi-Fi ↔ cellular while locked
- Panic wipe right after a resume
Tests
- SwiftPM, CI's command, 3 runs:
- XCTest: 199/199 in every run, including the new guard.
- Swift Testing: 2160/2160 in 2 runs. One run hit the known
ComposerDraftStoreTests.draftsAreIsolatedPerConversationflake (process-wide draft store reset by parallel panic tests; untouched here).
- macOS app build: succeeded. SwiftLint
--stricton changed files: clean. - iOS simulator suite: 2378/2378 passed (iPhone 17 Pro Max, iOS 27.0, dedicated simulator).
🤖 Generated with Claude Code
Activity
1This pull request is a draft
Merging is unavailable until it is marked ready for review.