Skip to content

Commit ec19c42

Browse files
committed
Move to another node when one cannot be reached, and only then
A node that is down was a dead end: the tunnel reported a failure and the user had to pick another node themselves, on a screen that did not say which of their nodes was working. The policy is deliberately one rule. **Only unreachability moves a connection.** A Portal answers a rejected authentication frame with silence rather than a close — on purpose, so that failure is not an oracle for active probing — which is the same shape on the wire as a Portal that is down. A client that failed over on it would spend the whole node list on one mistyped character, take several seconds doing it, and then report the last node's failure: the wrong node, the wrong message, and a user sent to check the wrong thing. So a refusal ends the attempt where it is, and a refused pin is a refusal rather than an outage because every other node will answer the same way to the same wrong pin. Health is a memory with the memory fading. Consecutive failures are counted, a success clears them, a refusal is a separate axis rather than a bigger failure count, and anything older than ten minutes stops counting — without decay the first list a user builds is the last one they can use, because every node that ever failed stays condemned and the ranking freezes. It is not persisted, for the same reason: what it measures is the device's network, and health carried across a restart would describe the coffee shop's Wi-Fi in the user's kitchen. Ranking puts health first and latency second, in that order. A node that answers a probe in 12 ms and then carries nothing is worse than one that answers in 200 ms and works, and sorting on latency would keep the fast broken one at the top forever. An untried node sorts between healthy and degraded, because the alternative is a list where a node can never earn its place back, and a refusing node is sorted last rather than removed — five nodes offering four, with no way to see why, is worse than five with one at the bottom. The user's own order is the final tiebreak, and automatic selection never overrides a node they picked: they chose it for a reason this client cannot see. `nodes.*` joins the coverage gate for the reason `routing.*` did. A failover that moved on the wrong kind of failure sends a user's traffic through a Portal they did not pick, and reports the wrong node's error while doing it. Writing the device case taught something worth keeping. `TunnelController.state` is process-wide and outlives the test that set it, so "wait until it is not Connecting" is true before the service has run and "wait until it is Failed" is true because the previous test failed. Both were written and both passed vacuously. The wait is now on the health itself, which `@Before` clears, so it waits for this attempt and no other.
1 parent 9bbc508 commit ec19c42

11 files changed

Lines changed: 714 additions & 5 deletions

File tree

‎app/build.gradle.kts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,11 @@ kover {
247247
// worst direction: traffic leaves the device somewhere the
248248
// user did not ask it to.
249249
"eu.nodepass.somewhere.routing.*",
250+
// Which node carries the traffic, for the same reason
251+
// again: a failover that moved on the wrong kind of failure
252+
// sends a user's traffic through a Portal they did not pick
253+
// and reports the wrong node's error while doing it.
254+
"eu.nodepass.somewhere.nodes.*",
250255
)
251256
}
252257
excludes {
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// Copyright (C) 2026 The Somewhere Authors
3+
4+
package eu.nodepass.somewhere.vpn
5+
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import eu.nodepass.somewhere.SomewhereApplication
8+
import eu.nodepass.somewhere.data.NodeStore
9+
import eu.nodepass.somewhere.nodes.Attempt
10+
import eu.nodepass.somewhere.nodes.Health
11+
import eu.nodepass.somewhere.protocol.DecodeResult
12+
import eu.nodepass.somewhere.protocol.url.NowhereUrl
13+
import org.junit.After
14+
import org.junit.Assert.assertEquals
15+
import org.junit.Assert.assertTrue
16+
import org.junit.Before
17+
import org.junit.Test
18+
import org.junit.runner.RunWith
19+
20+
/**
21+
* Failing over on a device, where the node list and the service are real.
22+
*
23+
* The policy itself is unit-tested and needs no device — what needs one is the
24+
* wiring: that the service records what happened to the node it was given, and
25+
* that an unreachable node leads to another being tried rather than to a
26+
* failure the user has to act on.
27+
*
28+
* **A node that cannot be resolved is the reachable-looking failure to stage.**
29+
* A refused connection needs something listening to refuse it; a name that does
30+
* not exist fails the same way from every network, on every device, in under a
31+
* second, and reaches exactly the code path a Portal that is down reaches.
32+
*/
33+
@RunWith(AndroidJUnit4::class)
34+
class FailoverOnDeviceTest {
35+
private val application get() = TunnelHarness.context.applicationContext as SomewhereApplication
36+
37+
private fun url(host: String): NowhereUrl =
38+
when (val parsed = NowhereUrl.parse("nowhere://key@$host:443?up=tcp&down=tcp")) {
39+
is DecodeResult.Ok -> parsed.value
40+
is DecodeResult.Invalid -> throw AssertionError("test URL does not parse: ${parsed.reason.detail}")
41+
}
42+
43+
private var saved: List<NodeStore.Entry> = emptyList()
44+
45+
@Before
46+
fun rememberTheUsersNodes() {
47+
E2eEnvironment.requireConsent(TunnelHarness.context)
48+
saved = application.nodes.nodes.value
49+
application.nodeHealth.clear()
50+
}
51+
52+
@After
53+
fun restore() {
54+
application.nodeHealth.clear()
55+
runCatching { TunnelHarness.stop() }
56+
}
57+
58+
/**
59+
* Waits for the service to have an opinion about [node].
60+
*
61+
* On the health rather than on the tunnel state, and that is the whole
62+
* lesson of writing this. `state` is process-wide and survives the test
63+
* that set it, so "wait until it is not Connecting" is true before the
64+
* service has run, and "wait until it is Failed" is true because the
65+
* *previous* test failed. Health is cleared in `@Before`, so waiting for it
66+
* to stop being Untried waits for this attempt and no other.
67+
*/
68+
private fun awaitOutcome(node: NowhereUrl) =
69+
TunnelHarness.await("the service to record an outcome for ${node.host}") {
70+
application.nodeHealth.health(node.toUrl()) != Health.Untried
71+
}
72+
73+
@Test
74+
fun anUnresolvableNodeIsRecordedAsUnreachableRatherThanRefused() {
75+
// The distinction the whole policy rests on. Unreachable moves to
76+
// another node; refused does not, because a Portal answers a bad key
77+
// with silence and failing over on that would walk the entire list on
78+
// one mistyped character.
79+
val node = url("no-such-host.invalid")
80+
SomewhereVpnService.start(TunnelHarness.context, node)
81+
awaitOutcome(node)
82+
83+
val health = application.nodeHealth.health(node.toUrl())
84+
assertTrue(
85+
"an unresolvable node should be degraded, not refusing; it is $health",
86+
health is Health.Degraded,
87+
)
88+
}
89+
90+
@Test
91+
fun aSucceedingNodeClearsWhatWentBeforeIt() {
92+
// Recorded through the same object the service writes to, so a
93+
// regression in either half shows up here.
94+
val node = url("no-such-host.invalid")
95+
application.nodeHealth.record(node.toUrl(), Attempt.Unreachable)
96+
application.nodeHealth.record(node.toUrl(), Attempt.Succeeded)
97+
assertEquals(Health.Healthy, application.nodeHealth.health(node.toUrl()))
98+
}
99+
100+
@Test
101+
fun theServiceRecordsHealthAgainstTheNodeItWasActuallyGiven() {
102+
// Keyed on the URL the service was started with, which is what the
103+
// node list holds. A key derived any other way — the display name, the
104+
// host alone — would collide between two nodes on one Portal and
105+
// condemn both when one failed.
106+
val first = url("no-such-host-one.invalid")
107+
val second = url("no-such-host-two.invalid")
108+
SomewhereVpnService.start(TunnelHarness.context, first)
109+
awaitOutcome(first)
110+
assertTrue(application.nodeHealth.health(first.toUrl()) is Health.Degraded)
111+
assertEquals(
112+
"an untouched node must not be marked by another node's failure",
113+
Health.Untried,
114+
application.nodeHealth.health(second.toUrl()),
115+
)
116+
}
117+
}

‎app/src/main/kotlin/eu/nodepass/somewhere/SomewhereApplication.kt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import eu.nodepass.somewhere.apps.PackageManagerApps
99
import eu.nodepass.somewhere.data.NodeRepository
1010
import eu.nodepass.somewhere.data.NodeStore
1111
import eu.nodepass.somewhere.data.SubscriptionStore
12+
import eu.nodepass.somewhere.nodes.NodeHealth
1213
import eu.nodepass.somewhere.routing.RoutingPreferences
1314
import eu.nodepass.somewhere.routing.RuleStore
1415
import eu.nodepass.somewhere.subscription.SubscriptionFetcher
@@ -64,4 +65,15 @@ class SomewhereApplication : Application() {
6465
val rules: RuleStore by lazy { RuleStore(File(filesDir, "routing/rules.txt")) }
6566

6667
val routing: RoutingPreferences by lazy { RoutingPreferences(File(filesDir, "routing/settings.txt")) }
68+
69+
/**
70+
* How nodes have behaved, this run.
71+
*
72+
* Deliberately not persisted. What it measures is the device's network, and
73+
* that changes when the device moves — health carried across a restart
74+
* would describe the coffee shop's Wi-Fi in the user's kitchen. Latency
75+
* from a probe is persisted by the repository, because a measurement is a
76+
* measurement; a verdict is not.
77+
*/
78+
val nodeHealth: NodeHealth by lazy { NodeHealth() }
6779
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// Copyright (C) 2026 The Somewhere Authors
3+
4+
package eu.nodepass.somewhere.nodes
5+
6+
/**
7+
* Whether to try another node, and which.
8+
*
9+
* Separate from [NodeHealth] because the two answer different questions and
10+
* only one of them is allowed to be clever. Health is a measurement; this is a
11+
* policy, and the policy is deliberately small.
12+
*
13+
* ## The rule that matters
14+
*
15+
* **Only unreachability moves a connection.** Everything else — a refused
16+
* authentication, a Portal that answered and said no, a node the user
17+
* configured wrongly — stays where it is and reports what happened. See
18+
* [Attempt] for why: a Portal answers a bad key with silence, so a client that
19+
* treated silence as unreachability would walk the whole list on one typo and
20+
* then blame the last node.
21+
*
22+
* ## The other rule
23+
*
24+
* **A node is tried once per attempt.** Without that, two nodes that each fail
25+
* over to the other spin until something else notices. The excluded set is the
26+
* caller's, so an attempt that spans a reconnect can carry it or drop it
27+
* deliberately rather than by accident.
28+
*/
29+
object Failover {
30+
/** Whether [attempt] is the kind of failure another node could fix. */
31+
fun shouldMove(attempt: Attempt): Boolean = attempt is Attempt.Unreachable
32+
33+
/**
34+
* The next node to try, or null when there is none worth trying.
35+
*
36+
* @param candidates every node the user has, in their own order.
37+
* @param tried nodes this attempt has already used, including the one that
38+
* just failed.
39+
*/
40+
fun next(
41+
candidates: List<String>,
42+
health: NodeHealth,
43+
tried: Set<String>,
44+
latencyMillis: (String) -> Int? = { null },
45+
): String? =
46+
health
47+
.ranked(candidates, latencyMillis)
48+
.firstOrNull { it !in tried && health.health(it).isUsable }
49+
50+
/**
51+
* The node to start on when nobody has chosen one.
52+
*
53+
* **Not used when the user has chosen.** An automatic selection that
54+
* overrode a person's pick would be a defect wearing a feature's clothes:
55+
* they chose that node for a reason this client cannot see — a region, a
56+
* subscription's quota, a Portal they administer — and the ranking here
57+
* knows none of it.
58+
*/
59+
fun preferred(
60+
candidates: List<String>,
61+
health: NodeHealth,
62+
latencyMillis: (String) -> Int? = { null },
63+
): String? = health.ranked(candidates, latencyMillis).firstOrNull { health.health(it).isUsable }
64+
}

0 commit comments

Comments
 (0)