#1808

Stop the Tor restart from blocking the main thread on resume

Draftjackjackbitswants to merge intofrom

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. TorManager is @MainActor, and restartArti() kept calling arti_stop() inline when Arti replaced C Tor (23d63ab4). shutdownCompletely() already calls it from Task.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 next arti_start. No Rust or xcframework change.

Change

-        _ = arti_stop()
+        _ = await Task.detached(priority: .userInitiated) { arti_stop() }.value

The 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.

mainthis PR
Where arti_stop() runsmain thread, 200-202 msoff main, 200-210 ms
Resumes with a main-thread stall over 50 ms84 / 84 (190-195 ms each)0 / 50 (worst 29 ms)
Tor ready after .active1.48-1.51 s1.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_start per restart, each preceded by a stop;
    • nothing stuck in restarting;
    • after every settle, Tor ready, relays reconnected, and "tor restarted" as the last message.
  • Lock/unlock can't be driven from simctl. It takes the same scenePhase .background → .active path 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 broken TorWillRestart / TorDidBecomeReady pairing.
  • 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_start then 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_arti ignores the shutdown signal until create_bootstrapped finishes.
  • Its late exit then clears IS_RUNNING / BOOTSTRAP_PROGRESS over 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_bootstrapped on 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.draftsAreIsolatedPerConversation flake (process-wide draft store reset by parallel panic tests; untouched here).
  • macOS app build: succeeded. SwiftLint --strict on changed files: clean.
  • iOS simulator suite: 2378/2378 passed (iPhone 17 Pro Max, iOS 27.0, dedicated simulator).

🤖 Generated with Claude Code

Activity

1

This pull request is a draft

Merging is unavailable until it is marked ready for review.