Skip to content

Fix: onion path rotation could never escape a bad guard node - #2217

Open
mpretty-cyro wants to merge 2 commits into
session-foundation:devfrom
mpretty-cyro:fix/path-test-fixed-destination
Open

mpretty-cyro wants to merge 2 commits into
session-foundation:devfrom
mpretty-cyro:fix/path-test-fixed-destination

Conversation

@mpretty-cyro

Copy link
Copy Markdown
Collaborator

Issue #2216. Two halves of one defect — neither works alone.

The path test always targeted the same node

PathManager.testPath took the first eligible member of the snode pool as its destination, so every path test in a process talked to one node. A node that rejects onion payloads therefore failed every path test on that device for as long as it stayed in the pool, however healthy the candidate paths were. The field evidence on the issue is two disjoint candidate paths failing to one constant destination.

The destination is now picked with secureRandom() over the eligible members, matching what the rest of the file already does.

Rotation could never replace a bad guard

Rotation rebuilds its candidates around the existing guards, so when the first hop is the problem every candidate fails verification, rotation returns without committing, and nothing replaces the guard. Failures of that shape strike nothing — a reachable guard returning 400, or any code with no specific rule, maps to PathError and penalises neither node nor path, which is the agreed design — so there was no other route out of the loop.

Three consecutive rotations with no verified candidate now drop the paths via the existing clearPaths(). The next getPath() rebuilds with no reusable guards and draws fresh ones, replacing both guards rather than one. A successful commit resets the count, so a flaky network cannot walk a working client into repeated rebuilds.

It counts attempts rather than elapsed time because a failed rotation does not advance the rotation timestamp: a wedged client re-enters rotation on every getPath, so three failures is seconds apart, not thirty minutes.

Deliberate, not oversights

  • getGuardSnodes draws from the whole pool without excluding struck nodes, so the bad guard can be re-drawn by chance (roughly one in pool-size). A further escalation moves off it. Not fixed here.
  • Rebuild frequency changes only in the failure path — a healthy client never reaches the counter.
  • No new penalty rule for 400s, and no strike for a failed path test. Non-penalising defaults are the agreed paradigm, not a gap.
  • No persisted state and no schema change; Path is still a bare List<Snode>.

Tests

Four in PathManagerTest, all on synthetic pools and paths. Each was verified to fail with only its own production change reverted:

test reverting
path tests do not all target the same snode the random destination
rotation commits even though the first pool member rejects every request the random destination
rotation that never verifies drops the paths and rebuilds onto fresh guards the escalation
a rotation that verifies keeps a flaky client off the rebuild path the reset on commit

The escalation test asserts getGuardSnodes is called with existingGuards = emptySet(), so the assumption that clearing the paths actually reaches a guard-replacing rebuild is checked by CI rather than by reading.

Unit suite: 302 pass, 0 fail (298 on dev plus these four).

Issue session-foundation#2216. testPath took the first eligible member of the snode pool as its
destination, so every path test in a process talked to one node. A node that rejects
onion payloads therefore failed every path test on that device for as long as it
stayed in the pool: no candidate could ever be verified, so path rotation could never
commit, however healthy the candidate paths were. Field evidence was two disjoint
candidate paths failing to one constant destination.

The regression tests drive rotation, which is the only caller of the path test, and
need a foreground test scope - advanceUntilIdle() does not advance work launched into
runTest's backgroundScope.
Issue session-foundation#2216, the other half. Rotation rebuilds its candidates around the existing
guards, so when the first hop is the problem every candidate fails verification,
rotation returns without committing, and nothing ever replaces the guard. Failures
that do not strike anything - a reachable guard returning 400, or any code with no
specific rule - leave nothing else to break the loop either.

Three rotations in a row with no verified candidate now drop the paths via the
existing clearPaths(), so the next getPath() rebuilds with no reusable guards and
draws fresh ones. A successful commit resets the count, so a flaky network cannot
walk a working client into repeated rebuilds.

Counted in attempts rather than time because a failed rotation does not advance the
rotation timestamp: a wedged client re-enters rotation on every getPath, so three
failures is seconds, not thirty minutes.

Two things a reviewer should know rather than take as oversights: getGuardSnodes
draws from the whole pool without excluding struck nodes, so the bad guard can be
re-drawn by chance and a further escalation is what moves off it; and rebuild
frequency changes only in the failure path.
@mpretty-cyro mpretty-cyro self-assigned this Sep 20, 2026
@mpretty-cyro
mpretty-cyro marked this pull request as ready for review September 20, 2026 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant