Files
amethyst/amethyst
Vitor PamplonaandClaude Opus 5 e934d41bb2 fix(tor): self-heal a guard sample that is rotten at runtime, not just on disk
Tor wedged across app restarts with ~87% of relay connections failing, and
neither existing recovery fired. Captured from the device:

  guards.json  default: 60 guards, 59 disabled, 1 unlisted -> 1 usable
  Arti log     AllGuardsDown { n_accepted: 0, n_rejected: 60 }  x21,490
  sockets      36,007 failures vs 5,276 successful opens

Everything above Tor degraded with it. Concord was the visible casualty: its
control-plane sync drained exactly 12 wraps on every launch and never folded, so
the Messages tab showed zero Concord rows for two restarts running.

Two recoveries exist and this state slipped between both:

- `ArtiGuardState.hasNoUsableGuards` required `usable == 0`. Its own KDoc claimed
  "a single usable guard is enough to recover" — the device disproved it. One
  survivor that is merely *unreachable* is useless for recovery but sufficient to
  veto it, so the wipe never ran.
- `TorManager`'s watchdog only arms while status sits at Connecting. Tor had
  bootstrapped: the SOCKS proxy was bound, `hasEverBootstrapped` was true, ~13% of
  connections still worked, so status reached Active and the watchdog never fired.
  It is built for "Tor never came up"; this is "Tor came up and its guards rotted".

Nothing observed the steady-state failure rate, so all three gates evaluated the
same way on every launch and `guards.json` carried the wedge forward forever.

1. Proportional disk rule. A sample of at least MIN_SAMPLE_TO_JUDGE_RATIO whose
   usable guards fall under 1/USABLE_RATIO_DIVISOR of the total is wedged. Below
   that size only the strict `usable == 0` rule applies, so a young sample Arti is
   still filling is never wiped out from under a legitimate first bootstrap.

2. Runtime detector. `TorService` counts Arti's own AllGuardsDown log lines
   (GUARDS_DOWN_THRESHOLD within GUARDS_DOWN_WINDOW_MS) and exposes
   `TorBackend.guardsDownSignal`; `TorManager` routes it through the same
   rate-limited self-heal to `resetWithCleanState()`. This is the general fix: it
   believes Arti when it says every guard was rejected, so it fires even while
   `guards.json` still looks healthy and status is Active — precisely the blind
   spot between the two older heuristics. The count lives in the log callback
   because that is the only place Arti surfaces it; the reset stays in TorManager,
   which owns the cadence.

Verified on the wedged device. Next launch, unprompted:

  W TorService: No usable Arti guards left on disk — wiping state to rebuild the guard sample

  guard sample   60 total / 1 usable   ->  20 total / 20 usable / 0 disabled
  AllGuardsDown  21,569                ->  0
  sockets        36,007 fail / 5,276   ->  444 fail / 232 open
  Concord wraps  12, 12, 12 (pinned)   ->  12 -> 37 -> 258
  Concord rows   0                     ->  4 and climbing

Tests cover the 59-of-60 field case, a small-sample false-positive guard, and a
healthy-majority sample. The pre-existing 22-guard "poisoned but not wedged"
fixture still passes, so the ratio does not regress the earlier variant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:18:26 -04:00
..