Skip to content

Commit fc84cbe

Browse files
atavismgarmr-ulfrjigar-fmyleshortonclaude
authored
Warn before pinning unreachable manual servers (#8833)
* manual server updates * Clarify manual server reachability warnings * refactor(servers): use SelectionHistory instead of URLTestResults (#8840) Map the radiance server JSON field renamed from urlTestResult to selection_history, replacing the UrlTestResult model with a SelectionHistory model mirroring lantern-box's TagHistory shape. Probe delay reads now key off lastSuccessDelayMs. * Group unavailable manual servers * Refresh server list when opening selection screen * Refresh reachability before listing manual servers * Avoid duplicate URL tests in server selection * code review updates * build: bump radiance for URL test timeout * ui: refine manual server reachability warnings * Treat untested servers as unknown * Revert "Treat untested servers as unknown" This reverts commit 00b6258. * server selection: distinguish "testing" from "unreachable" (#8873) * server selection: distinguish "testing" from "unreachable" During Smart Location convergence the url-test probe cycle takes several minutes to visit every server. Until a server is probed it has no selection history, so `Server.hasSuccessfulProbe` is false — and the UI treated "no successful probe" as a single state, bucketing every not-yet-probed server into the "Currently Unavailable" section with a "may be unreachable" warning. A mid-convergence snapshot therefore read as "many servers unreachable" when the probes were simply still running (Freshdesk #178551). Split the collapsed `!hasSuccessfulProbe` state into four, using the backend's `consecutive_failures` (a uint, unambiguous under omitempty, unlike the time.Time `last_outcome_at` zero value): - reachable lastSuccessDelayMs > 0 -> normal row - awaiting probe no verdict yet -> "testing" - flapping 1..2 failures, no success -> normal row - probed & failing >= 3 failures, no success -> warn The >= 3 threshold (consteq _manualSelectionFailureWarningThreshold) follows atavism's reverted "Treat untested servers as unknown" approach: a single transient probe failure shouldn't brand a server unreachable. `shouldWarnBeforeManualSelection` now means probed-and-sustained-failing only, so the section split, per-row warning icon, and pre-selection dialog all stop firing for untested servers automatically. Untested rows show a neutral "testing" spinner (new ServerTestingIndicator). The per-location representative prefers a still-testing server, then any not-yet-unreachable server, over a failed one — so a location reads as unavailable only once all of its servers have sustained failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Treat sustained probe failures as unreachable (#8874) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: atavism <atavism@users.noreply.github.com> --------- Co-authored-by: garmr-ulfr <104022054+garmr-ulfr@users.noreply.github.com> Co-authored-by: Jigar-f <jigar@getlantern.org> Co-authored-by: Myles Horton <afisk@getlantern.org> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 71fffd0 commit fc84cbe

11 files changed

Lines changed: 908 additions & 103 deletions

assets/locales/en.po

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ msgstr "Automatically chooses fastest location"
150150
msgid "pro_locations"
151151
msgstr "Pro locations:"
152152

153+
msgid "currently_unavailable"
154+
msgstr "Currently Unavailable:"
155+
153156
msgid "choose_your_location_with_lantern_pro"
154157
msgstr "Choose your location with Lantern Pro:"
155158

@@ -934,6 +937,24 @@ msgstr "Private Servers"
934937
msgid "fastest_server"
935938
msgstr "Fastest Server"
936939

940+
msgid "server_testing"
941+
msgstr "Testing…"
942+
943+
msgid "server_may_be_unreachable"
944+
msgstr "May be unreachable"
945+
946+
msgid "server_may_be_unreachable_title"
947+
msgstr "This Server may be unreachable"
948+
949+
msgid "server_may_be_unreachable_message"
950+
msgstr "This Server may not work from your current network. Use Smart Location to pick a working server automatically."
951+
952+
msgid "use_smart_location"
953+
msgstr "Use Smart Location"
954+
955+
msgid "try_anyway"
956+
msgstr "Try Anyway"
957+
937958
msgid "join_my_private_server"
938959
msgstr "Join My Lantern Private Server"
939960

lib/core/common/common.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export 'package:lantern/core/widgets/lantern_logo.dart';
6868
export 'package:lantern/core/widgets/platform_card.dart';
6969
export 'package:lantern/core/widgets/pro_banner.dart';
7070
export 'package:lantern/core/widgets/pro_button.dart';
71+
export 'package:lantern/core/widgets/server_reachability_warning_icon.dart';
72+
export 'package:lantern/core/widgets/server_testing_indicator.dart';
7173
export 'package:lantern/features/home/data_usage.dart';
7274

7375
export '../../core/widgets/divider_space.dart';

lib/core/models/available_servers.dart

Lines changed: 172 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,97 @@ class AvailableServers {
1111

1212
List<Server> get userServers => servers.where((s) => !s.isLantern).toList();
1313

14+
/// One representative Lantern server per country/city. If any server for the
15+
/// location has a successful probe, use the fastest one; otherwise prefer a
16+
/// server still awaiting its first probe (so the location reads as "testing")
17+
/// over one already probed-and-failing, falling back to a deterministic
18+
/// representative.
19+
List<Server> get lanternServerLocations {
20+
final grouped = <String, List<Server>>{};
21+
for (final server in lanternServers) {
22+
grouped.putIfAbsent(_locationKey(server), () => <Server>[]).add(server);
23+
}
24+
25+
return grouped.values.map(_bestServerForLocation).toList()
26+
..sort(_compareServersByLocation);
27+
}
28+
1429
bool get hasUserServers => servers.any((s) => !s.isLantern);
1530

16-
/// Lantern server with the lowest URL-test delay. Null when no server has
17-
/// a usable probe result — sing-box reports delay 0 for unreachable probes,
18-
/// so those are excluded.
31+
Server? serverByTag(String tag) {
32+
for (final server in servers) {
33+
if (server.tag == tag) return server;
34+
}
35+
return null;
36+
}
37+
38+
/// Lantern server with the lowest usable successful probe delay. Null when no
39+
/// Lantern server has a successful probe that is not currently hard-failing.
1940
Server? get fastestLanternServer {
20-
final ranked = lanternServers
21-
.where((s) => s.urlTestResult != null && s.urlTestResult!.delay > 0)
22-
.toList()
23-
..sort(
24-
(a, b) => a.urlTestResult!.delay.compareTo(b.urlTestResult!.delay),
25-
);
41+
final ranked =
42+
lanternServers
43+
.where((s) => s.hasSuccessfulProbe && !s.isProbedUnreachable)
44+
.toList()
45+
..sort(
46+
(a, b) => a.selectionHistory!.lastSuccessDelayMs.compareTo(
47+
b.selectionHistory!.lastSuccessDelayMs,
48+
),
49+
);
2650
return ranked.isEmpty ? null : ranked.first;
2751
}
2852
}
2953

54+
/// Consecutive failed probes before a server is treated as unreachable rather
55+
/// than merely uncertain. A single transient probe failure shouldn't brand a
56+
/// server "unavailable", so require sustained failures.
57+
const _manualSelectionFailureWarningThreshold = 3;
58+
59+
String _locationKey(Server server) {
60+
final location = server.location;
61+
return [
62+
location.countryCode.trim().toUpperCase(),
63+
location.country.trim().toLowerCase(),
64+
location.city.trim().toLowerCase(),
65+
].join('|');
66+
}
67+
68+
Server _bestServerForLocation(List<Server> servers) {
69+
final successful = servers
70+
.where((s) => s.hasSuccessfulProbe && !s.isProbedUnreachable)
71+
.toList();
72+
if (successful.isNotEmpty) {
73+
successful.sort((a, b) {
74+
final delay = a.selectionHistory!.lastSuccessDelayMs.compareTo(
75+
b.selectionHistory!.lastSuccessDelayMs,
76+
);
77+
if (delay != 0) return delay;
78+
return a.tag.compareTo(b.tag);
79+
});
80+
return successful.first;
81+
}
82+
83+
// No successful probe for this location. Prefer a server still awaiting its
84+
// first probe (so the location reads as "testing"), then any server not yet
85+
// ruled unreachable, falling back to the failed pool only when every server
86+
// has sustained enough failures — so a location is "unavailable" only once
87+
// all of its servers are.
88+
final awaiting = servers.where((s) => s.isAwaitingProbe).toList();
89+
final notUnreachable = servers.where((s) => !s.isProbedUnreachable).toList();
90+
final pool = awaiting.isNotEmpty
91+
? awaiting
92+
: (notUnreachable.isNotEmpty ? notUnreachable : servers);
93+
final sorted = [...pool]..sort(_compareServersByLocation);
94+
return sorted.first;
95+
}
96+
97+
int _compareServersByLocation(Server a, Server b) {
98+
final country = a.location.country.compareTo(b.location.country);
99+
if (country != 0) return country;
100+
final city = a.location.city.compareTo(b.location.city);
101+
if (city != 0) return city;
102+
return a.tag.compareTo(b.tag);
103+
}
104+
30105
class Server {
31106
final String tag;
32107
final String type;
@@ -35,7 +110,7 @@ class Server {
35110
final Map<String, dynamic>? endpoint;
36111
final GeoLocation location;
37112
final ServerCredential? credentials;
38-
final UrlTestResult? urlTestResult;
113+
final SelectionHistory? selectionHistory;
39114

40115
Server({
41116
required this.tag,
@@ -45,7 +120,7 @@ class Server {
45120
this.endpoint,
46121
required this.location,
47122
this.credentials,
48-
this.urlTestResult,
123+
this.selectionHistory,
49124
});
50125

51126
factory Server.fromJson(Map<String, dynamic> json) => Server(
@@ -60,14 +135,48 @@ class Server {
60135
credentials: json['credentials'] != null
61136
? ServerCredential.fromJson(json['credentials'] as Map<String, dynamic>)
62137
: null,
63-
urlTestResult: json["urlTestResult"] == null
138+
selectionHistory: json["selection_history"] == null
64139
? null
65-
: UrlTestResult.fromJson(json["urlTestResult"]),
140+
: SelectionHistory.fromJson(json["selection_history"]),
66141
);
67142

68143
/// IP address extracted from outbound or endpoint options.
69144
String get serverIP =>
70145
outbound?['server'] as String? ?? endpoint?['server'] as String? ?? '';
146+
147+
bool get hasSuccessfulProbe =>
148+
(selectionHistory?.lastSuccessDelayMs ?? 0) > 0;
149+
150+
/// True once a probe has returned a verdict for this server — either a
151+
/// success, or at least one recorded failure
152+
/// ([SelectionHistory.consecutiveFailures]). Until then the server is still
153+
/// awaiting its first probe and must not be presented as unreachable.
154+
bool get hasProbeVerdict =>
155+
hasSuccessfulProbe || (selectionHistory?.consecutiveFailures ?? 0) > 0;
156+
157+
/// Sustained probe failures past [_manualSelectionFailureWarningThreshold]
158+
/// enough current evidence to treat the server as unreachable rather than
159+
/// merely uncertain. One or two failures are still "unknown", not
160+
/// "unavailable". A prior success can still provide historical latency, but
161+
/// it does not override repeated current failures.
162+
bool get hasFailedProbeEvidence =>
163+
(selectionHistory?.consecutiveFailures ?? 0) >=
164+
_manualSelectionFailureWarningThreshold;
165+
166+
/// Still waiting for the first probe result. During the multi-minute Smart
167+
/// Location convergence most Lantern servers sit here briefly — shown as
168+
/// "testing", never as unavailable.
169+
bool get isAwaitingProbe => isLantern && !hasProbeVerdict;
170+
171+
/// Probed and sustained-failing: failures have passed the warning threshold.
172+
/// This is the only state that surfaces the unreachable warning; a server
173+
/// failing only once or twice is shown normally (uncertain, not unavailable).
174+
bool get isProbedUnreachable => isLantern && hasFailedProbeEvidence;
175+
176+
/// Manual mode pins traffic to exactly this server. Warn before pinning only
177+
/// once the server has sustained enough failed probes — never while it is
178+
/// still being tested ([isAwaitingProbe]) or after only a transient failure.
179+
bool get shouldWarnBeforeManualSelection => isProbedUnreachable;
71180
}
72181

73182
class GeoLocation {
@@ -113,17 +222,59 @@ class ServerCredential {
113222
);
114223
}
115224

116-
class UrlTestResult {
117-
int delay;
118-
DateTime time;
225+
/// SelectionHistory mirrors radiance's per-server selection history. Probe
226+
/// outcomes feed [lastSuccessDelayMs]/[consecutiveFailures]; real user-traffic
227+
/// failures feed [userFailures], kept separate so a censor that passes the
228+
/// probe URL while dropping user traffic is still visible.
229+
class SelectionHistory {
230+
/// Most recent successful probe RTT in milliseconds. 0 means no successful
231+
/// probe yet, used as the sentinel by [Server.hasSuccessfulProbe].
232+
final int lastSuccessDelayMs;
233+
234+
/// Timestamp of the most recent probe outcome, success or failure.
235+
final DateTime? lastOutcomeAt;
236+
237+
/// Probe failures since the last probe success; resets on success.
238+
final int consecutiveFailures;
239+
240+
/// Sliding window of user-traffic failure timestamps. Probe successes never
241+
/// enter this window.
242+
final List<DateTime> userFailures;
119243

120-
UrlTestResult({required this.delay, required this.time});
244+
final DateTime? updatedAt;
121245

122-
factory UrlTestResult.fromJson(Map<String, dynamic> json) =>
123-
UrlTestResult(delay: json["delay"], time: DateTime.parse(json["time"]));
246+
SelectionHistory({
247+
this.lastSuccessDelayMs = 0,
248+
this.lastOutcomeAt,
249+
this.consecutiveFailures = 0,
250+
this.userFailures = const [],
251+
this.updatedAt,
252+
});
253+
254+
factory SelectionHistory.fromJson(Map<String, dynamic> json) =>
255+
SelectionHistory(
256+
lastSuccessDelayMs: json["last_success_delay_ms"] ?? 0,
257+
lastOutcomeAt: json["last_outcome_at"] == null
258+
? null
259+
: DateTime.parse(json["last_outcome_at"]),
260+
consecutiveFailures: json["consecutive_failures"] ?? 0,
261+
userFailures:
262+
(json["user_failures"] as List<dynamic>?)
263+
?.map((e) => DateTime.parse(e as String))
264+
.toList() ??
265+
const [],
266+
updatedAt: json["updated_at"] == null
267+
? null
268+
: DateTime.parse(json["updated_at"]),
269+
);
124270

125271
Map<String, dynamic> toJson() => {
126-
"delay": delay,
127-
"time": time.toIso8601String(),
272+
"last_success_delay_ms": lastSuccessDelayMs,
273+
if (lastOutcomeAt != null)
274+
"last_outcome_at": lastOutcomeAt!.toIso8601String(),
275+
"consecutive_failures": consecutiveFailures,
276+
if (userFailures.isNotEmpty)
277+
"user_failures": userFailures.map((e) => e.toIso8601String()).toList(),
278+
if (updatedAt != null) "updated_at": updatedAt!.toIso8601String(),
128279
};
129280
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:lantern/core/common/app_asset.dart';
3+
import 'package:lantern/core/common/app_image_paths.dart';
4+
import 'package:lantern/core/common/app_semantic_colors.dart';
5+
import 'package:lantern/core/localization/i18n.dart';
6+
7+
/// Warning icon shown when a server may be unreachable.
8+
class ServerReachabilityWarningIcon extends StatelessWidget {
9+
const ServerReachabilityWarningIcon({super.key, this.size});
10+
11+
final double? size;
12+
13+
@override
14+
Widget build(BuildContext context) {
15+
final label = 'server_may_be_unreachable'.i18n;
16+
return Tooltip(
17+
message: label,
18+
child: Semantics(
19+
label: label,
20+
child: AppImage(
21+
path: AppImagePaths.info,
22+
height: size,
23+
width: size,
24+
color: context.statusWarningBgDot,
25+
),
26+
),
27+
);
28+
}
29+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:lantern/core/common/app_semantic_colors.dart';
3+
import 'package:lantern/core/localization/i18n.dart';
4+
5+
/// Spinner shown while a server is still awaiting its first url-test result.
6+
/// Distinct from the unreachable warning: a server is "testing" until a probe
7+
/// returns a verdict, and only "unreachable" once a probe has failed.
8+
class ServerTestingIndicator extends StatelessWidget {
9+
const ServerTestingIndicator({super.key, this.size});
10+
11+
final double? size;
12+
13+
@override
14+
Widget build(BuildContext context) {
15+
final label = 'server_testing'.i18n;
16+
final dimension = size ?? 16.0;
17+
return Tooltip(
18+
message: label,
19+
child: Semantics(
20+
label: label,
21+
child: SizedBox(
22+
width: dimension,
23+
height: dimension,
24+
child: CircularProgressIndicator(
25+
strokeWidth: 2,
26+
color: context.textTertiary,
27+
),
28+
),
29+
),
30+
);
31+
}
32+
}

lib/features/home/provider/app_event_notifier.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ class AppEventNotifier extends _$AppEventNotifier {
5252

5353
break;
5454
case 'server-location':
55+
// The selected server event is also our current Lantern-only signal
56+
// that Smart Location probe data may have changed.
57+
unawaited(
58+
ref
59+
.read(availableServersProvider.notifier)
60+
.forceFetchAvailableServers(),
61+
);
5562
// Only consume this event when the user is actually in auto mode.
5663
// Otherwise (custom server selected) ignore it — applying it would
5764
// silently flip the user's selection back to Smart Location on

0 commit comments

Comments
 (0)