Fix: onion path rotation could never escape a bad guard node - #2217
Open
mpretty-cyro wants to merge 2 commits into
Open
mpretty-cyro wants to merge 2 commits into
mpretty-cyro wants to merge 2 commits into
Conversation
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
marked this pull request as ready for review
September 20, 2026 20:38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue #2216. Two halves of one defect — neither works alone.
The path test always targeted the same node
PathManager.testPathtook 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
PathErrorand 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 nextgetPath()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
getGuardSnodesdraws 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.Pathis still a bareList<Snode>.Tests
Four in
PathManagerTest, all on synthetic pools and paths. Each was verified to fail with only its own production change reverted:The escalation test asserts
getGuardSnodesis called withexistingGuards = 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
devplus these four).