From 8385a4dbff75da4b795f89848e3de13a46aa5eb7 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 19 Oct 2026 12:44:47 -0600 Subject: [PATCH 1/4] Action Mode: show relayed data for direct sharing Advanced stats showed speed, session data and per-country bytes only for Unbounded; direct sharing (SmC) got a note saying data was unavailable. radiance now serves a direct-sharing relay snapshot at /peer/snapshot in the shape of Unbounded's, so lantern-core polls it every second as peer-snapshot and the share notifier folds it with the same relay code. Each snapshot source gets its own counter tracker and sample clock: both arrive every second with separate epochs, and a shared tracker would treat every alternation as a restart. Peer snapshots are folded in every mode, so bytes a session relays as it stops still count, but drive the speed only while SmC is active, and an idle Unbounded snapshot no longer zeroes SmC's speed. People still come from peer-connection events, so the snapshot's arrivals are not counted again. Bumps radiance to the snapshot commit (getlantern/radiance#689), with lantern-box and sing-box-minimal to match. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LQG2dRhaocR2UY5RPamm39 --- assets/locales/en.po | 8 +- lantern-core/core.go | 1 + lantern-core/unbounded_snapshot.go | 28 ++++-- lantern-core/unbounded_snapshot_test.go | 28 ++++++ .../action_mode/advanced_stats_sheet.dart | 58 +++++------- .../action_mode/provider/share_notifier.dart | 80 +++++++++++++--- .../action_mode/action_mode_widgets_test.dart | 14 +-- test/features/action_mode/snapshot_test.dart | 94 +++++++++++++++++++ 8 files changed, 242 insertions(+), 69 deletions(-) diff --git a/assets/locales/en.po b/assets/locales/en.po index 553229f11a..e8ec572853 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -2055,9 +2055,9 @@ msgstr "Tap Edit, then Add Widget." msgid "widget_step_3" msgstr "Search for Lantern, choose a size, and tap add widget." -# Advanced stats: low-emphasis link at the bottom of the Unbounded status +# Advanced stats: low-emphasis link at the bottom of the Action Mode status # card and the title of the bottom sheet it opens. "Data relayed" counts -# bytes this device forwarded for the people it helps over Unbounded. +# bytes this device forwarded for the people it helps, in either sharing mode. msgid "advanced_stats" msgstr "Advanced stats" @@ -2091,10 +2091,6 @@ msgstr "1 person" msgid "advanced_stats_people" msgstr "%s people" -# Shown instead of data figures while sharing via Share My Connection. -msgid "advanced_stats_smc_note" -msgstr "Data stats are available only when sharing via Unbounded." - # Sharing-mode names. "Direct connection" is Share My Connection, where people # connect straight to this device through a port on the user's router. # "WebRTC" is the Unbounded relay used when that port can't be opened; keep diff --git a/lantern-core/core.go b/lantern-core/core.go index 0dd395cf1a..5b6b203e4f 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -324,6 +324,7 @@ func (lc *LanternCore) initialize(opts *utils.Opts, eventEmitter utils.FlutterEv go lc.listenDataCapEvents() go lc.listenPeerConnectionEvents() go lc.listenUnboundedSnapshots() + go lc.listenPeerSnapshots() go lc.listenPeerStatusEvents() go lc.listenUserMessageAvailability() go lc.fetchUserDataIfNeeded() diff --git a/lantern-core/unbounded_snapshot.go b/lantern-core/unbounded_snapshot.go index 90db80d969..3169d3cf0d 100644 --- a/lantern-core/unbounded_snapshot.go +++ b/lantern-core/unbounded_snapshot.go @@ -15,20 +15,36 @@ func (lc *LanternCore) listenUnboundedSnapshots() { } func pollUnboundedSnapshots(ctx context.Context, ticks <-chan time.Time, read func(context.Context) (unbounded.Snapshot, error), notify func(string, string)) { - unavailable := false + pollSnapshots(ctx, ticks, read, notify, "unbounded-snapshot", "unbounded-unavailable") +} + +// listenPeerSnapshots polls the bytes direct sharing has relayed, which the +// app folds the same way as Unbounded's. Unbounded's poller already reports +// when the backend is unreachable, so this one sends no unavailable event. +func (lc *LanternCore) listenPeerSnapshots() { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + pollSnapshots(lc.ctx, ticker.C, lc.client.PeerRelaySnapshot, lc.notifyFlutter, "peer-snapshot", "") +} + +// pollSnapshots reads a snapshot on every tick and sends it to Flutter as +// event. After a failed read it sends unavailable once, when set, until a read +// succeeds again. +func pollSnapshots[T any](ctx context.Context, ticks <-chan time.Time, read func(context.Context) (T, error), notify func(string, string), event, unavailable string) { + reportedUnavailable := false for { requestCtx, cancel := context.WithTimeout(ctx, 3*time.Second) snapshot, err := read(requestCtx) cancel() if err == nil { - unavailable = false + reportedUnavailable = false if data, err := json.Marshal(snapshot); err == nil { - notify("unbounded-snapshot", string(data)) + notify(event, string(data)) } } - if err != nil && !unavailable && ctx.Err() == nil { - unavailable = true - notify("unbounded-unavailable", "{}") + if err != nil && unavailable != "" && !reportedUnavailable && ctx.Err() == nil { + reportedUnavailable = true + notify(unavailable, "{}") } select { case <-ctx.Done(): diff --git a/lantern-core/unbounded_snapshot_test.go b/lantern-core/unbounded_snapshot_test.go index ea97391a67..1ef1b3142a 100644 --- a/lantern-core/unbounded_snapshot_test.go +++ b/lantern-core/unbounded_snapshot_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/getlantern/radiance/peer" "github.com/getlantern/radiance/unbounded" ) @@ -40,6 +41,33 @@ func TestUnboundedUnavailableIsEmittedOncePerOutage(t *testing.T) { } } +// The peer poller has no unavailable event: failed reads are skipped, and +// each success is sent under the peer event. +func TestPeerSnapshotsSkipFailedReads(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ticks := make(chan time.Time, 2) + for range 2 { + ticks <- time.Now() + } + calls := 0 + var events []string + pollSnapshots(ctx, ticks, func(context.Context) (peer.Snapshot, error) { + calls++ + if calls == 2 { + return peer.Snapshot{Epoch: "run-1", BytesDown: 9}, nil + } + return peer.Snapshot{}, errors.New("offline") + }, func(name, data string) { + events = append(events, name+" "+data) + cancel() + }, "peer-snapshot", "") + want := []string{`peer-snapshot {"epoch":"run-1","running":false,"peers":null,"bytesDown":9,"bytesUp":0,"peerBytes":null}`} + if !reflect.DeepEqual(events, want) { + t.Fatalf("events = %v, want %v", events, want) + } +} + func TestUnboundedCancellationDoesNotReportOutage(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() diff --git a/lib/features/action_mode/advanced_stats_sheet.dart b/lib/features/action_mode/advanced_stats_sheet.dart index c30150e724..1674f24c34 100644 --- a/lib/features/action_mode/advanced_stats_sheet.dart +++ b/lib/features/action_mode/advanced_stats_sheet.dart @@ -4,7 +4,6 @@ import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/relay_stats.dart'; import 'package:lantern/core/models/share_state.dart'; -import 'package:lantern/core/widgets/info_row.dart'; import 'package:lantern/core/widgets/section_label.dart'; import 'package:lantern/features/action_mode/provider/share_notifier.dart'; @@ -24,8 +23,7 @@ void showAdvancedStatsSheet(BuildContext context) { ); } -/// Data-relay figures for Unbounded volunteers. Byte counters only exist for -/// Unbounded, so SmC sessions get a note in place of live figures. +/// Data-relay figures for volunteers, in either sharing mode. class AdvancedStatsView extends ConsumerWidget { const AdvancedStatsView({super.key, this.scrollController}); final ScrollController? scrollController; @@ -36,8 +34,7 @@ class AdvancedStatsView extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(shareProvider); final relay = state.relay; - final smc = state.mode == ShareMode.smc; - final live = !smc && relay.available; + final live = relay.available; final countries = relay.topCountries(topCountryCount); final divider = Divider(height: 1, color: context.borderDefault); @@ -46,13 +43,6 @@ class AdvancedStatsView extends ConsumerWidget { controller: scrollController, padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), children: [ - if (smc) ...[ - InfoRow( - key: const Key('advanced-stats.smc-note'), - text: 'advanced_stats_smc_note'.i18n, - ), - const SizedBox(height: 16), - ], AppCard( child: Column( children: [ @@ -69,29 +59,27 @@ class AdvancedStatsView extends ConsumerWidget { : null, ), divider, - if (!smc) ...[ - _StatRow( - label: 'advanced_stats_current_speed'.i18n, - value: live - ? _directions( - '${relay.speedDown.round().siBytes}/s', - '${relay.speedUp.round().siBytes}/s', - ) - : '—', - ), - divider, - _StatRow( - label: 'advanced_stats_session'.i18n, - value: live ? relay.sessionBytes.siBytes : '—', - detail: live - ? _directions( - relay.sessionDown.siBytes, - relay.sessionUp.siBytes, - ) - : null, - ), - divider, - ], + _StatRow( + label: 'advanced_stats_current_speed'.i18n, + value: live + ? _directions( + '${relay.speedDown.round().siBytes}/s', + '${relay.speedUp.round().siBytes}/s', + ) + : '—', + ), + divider, + _StatRow( + label: 'advanced_stats_session'.i18n, + value: live ? relay.sessionBytes.siBytes : '—', + detail: live + ? _directions( + relay.sessionDown.siBytes, + relay.sessionUp.siBytes, + ) + : null, + ), + divider, _StatRow( label: 'advanced_stats_all_time'.i18n, value: relay.lifetimeBytes.siBytes, diff --git a/lib/features/action_mode/provider/share_notifier.dart b/lib/features/action_mode/provider/share_notifier.dart index 8cf3120021..95d7fa0cf3 100644 --- a/lib/features/action_mode/provider/share_notifier.dart +++ b/lib/features/action_mode/provider/share_notifier.dart @@ -70,8 +70,11 @@ class ShareNotifier extends _$ShareNotifier { // Weight of the newest sample in the smoothed speed. static const _speedSmoothing = 0.5; - final _relayCounters = RelayCounterTracker(); - DateTime? _lastRelaySampleAt; + // Unbounded and SmC report counters in separate snapshots with their own + // epochs, so each needs its own tracker and sample clock. Only Unbounded's + // watermark is persisted. + final _unboundedFeed = _RelayFeed(); + final _peerFeed = _RelayFeed(); DateTime? _lastRelayPersistAt; Timer? _relayPersistTimer; bool _relayDirty = false; @@ -111,7 +114,7 @@ class ShareNotifier extends _$ShareNotifier { ref.read(appSettingProvider.notifier).setUnboundedAutoEnable(false), ); } - _relayCounters.restore( + _unboundedFeed.counters.restore( epoch: settings.unboundedRelayEpoch, down: settings.unboundedRelayMarkDown, up: settings.unboundedRelayMarkUp, @@ -372,8 +375,17 @@ class ShareNotifier extends _$ShareNotifier { // ─── Backend events ──────────────────────────────────────────────────────── - /// Unbounded snapshots arrive for the process lifetime, regardless of mode. + /// Unbounded and SmC snapshots arrive for the process lifetime, regardless + /// of mode. void _handleUnboundedEvent(AppEvent event) { + if (event.eventType == 'peer-snapshot') { + try { + _applyPeerSnapshot(jsonDecode(event.message) as Map); + } catch (e) { + debugPrint('share-my-connection: bad peer snapshot: $e'); + } + return; + } if (event.eventType == 'unbounded-unavailable') { if (!_changing && state.mode == ShareMode.unbounded) { _clearPeers(); @@ -471,7 +483,7 @@ class ShareNotifier extends _$ShareNotifier { ); ref.read(appSettingProvider.notifier).setUnboundedTotalHelped(newTotal); unawaited(_resolveAndEmit(ip, arc)); - // SmC has no byte counters, so the country row gains a person only. + // Bytes follow from SmC relay snapshots; this adds the person. unawaited(_attributeToCountry(ip, (down: 0, up: 0), arrived: true)); } else if (eventState == -1) { final entry = _peerArcs[ip]; @@ -500,9 +512,11 @@ class ShareNotifier extends _$ShareNotifier { state = state.copyWith( relay: _applyRelayCounters( snapshot, + feed: _unboundedFeed, epoch: snapshot['epoch'] as String, present: const {}, running: false, + updateSpeed: false, ), ); _persistRelay(force: true); @@ -551,6 +565,7 @@ class ShareNotifier extends _$ShareNotifier { totalCount: total, relay: _applyRelayCounters( snapshot, + feed: _unboundedFeed, epoch: epoch, present: counts.keys.toSet(), running: enabled && running, @@ -563,6 +578,28 @@ class ShareNotifier extends _$ShareNotifier { _persistRelay(force: !(enabled && running)); } + /// Folds an SmC relay snapshot's byte counters into the relay stats. Applied + /// in every mode, so bytes a session relays just before it stops still + /// count, but only drives the speed while SmC is the active mode. People + /// come from peer-connection events, so the snapshot's arrivals are not + /// counted again. + void _applyPeerSnapshot(Map snapshot) { + if (_changing) return; + final smc = state.mode == ShareMode.smc; + state = state.copyWith( + relay: _applyRelayCounters( + snapshot, + feed: _peerFeed, + epoch: (snapshot['epoch'] as String?) ?? '', + present: const {}, + running: smc && ((snapshot['running'] as bool?) ?? false), + updateSpeed: smc, + countArrivals: false, + ), + ); + _persistRelay(); + } + // ─── Relay stats ─────────────────────────────────────────────────────────── /// Folds a snapshot's byte counters into session and lifetime totals and @@ -570,9 +607,12 @@ class ShareNotifier extends _$ShareNotifier { /// peer's country is resolved. RelayStats _applyRelayCounters( Map snapshot, { + required _RelayFeed feed, required String epoch, required Set present, required bool running, + bool updateSpeed = true, + bool countArrivals = true, }) { final available = snapshot.containsKey('bytesDown'); final peerBytes = {}; @@ -587,8 +627,8 @@ class ShareNotifier extends _$ShareNotifier { ? counts : (down: prev.down + counts.down, up: prev.up + counts.up); }); - final markBefore = _relayCounters.watermark; - final delta = _relayCounters.apply( + final markBefore = feed.counters.watermark; + final delta = feed.counters.apply( epoch: epoch, down: (snapshot['bytesDown'] as num?)?.toInt() ?? 0, up: (snapshot['bytesUp'] as num?)?.toInt() ?? 0, @@ -597,12 +637,14 @@ class ShareNotifier extends _$ShareNotifier { ); final now = clock(); - final previousSampleAt = _lastRelaySampleAt; - _lastRelaySampleAt = now; + final previousSampleAt = feed.lastSampleAt; + feed.lastSampleAt = now; var relay = state.relay; var speedDown = relay.speedDown; var speedUp = relay.speedUp; - if (!running || !available) { + if (!updateSpeed) { + // Another mode owns the speed. + } else if (!running || !available) { speedDown = 0; speedUp = 0; } else if (!delta.reset && previousSampleAt != null) { @@ -613,7 +655,7 @@ class ShareNotifier extends _$ShareNotifier { } } relay = relay.copyWith( - available: available, + available: updateSpeed ? available : relay.available, sessionDown: relay.sessionDown + delta.down, sessionUp: relay.sessionUp + delta.up, lifetimeDown: relay.lifetimeDown + delta.down, @@ -625,16 +667,17 @@ class ShareNotifier extends _$ShareNotifier { // run resumes from it instead of taking another baseline. if (delta.down > 0 || delta.up > 0 || - _relayCounters.watermark != markBefore) { + feed.counters.watermark != markBefore) { _relayDirty = true; } - for (final ip in {...delta.peers.keys, ...delta.arrivals}) { + final arrivals = countArrivals ? delta.arrivals : const {}; + for (final ip in {...delta.peers.keys, ...arrivals}) { unawaited( _attributeToCountry( ip, delta.peers[ip] ?? (down: 0, up: 0), - arrived: delta.arrivals.contains(ip), + arrived: arrivals.contains(ip), ), ); } @@ -699,7 +742,7 @@ class ShareNotifier extends _$ShareNotifier { bytesDown: relay.lifetimeDown, bytesUp: relay.lifetimeUp, countries: relay.countries, - mark: _relayCounters.watermark, + mark: _unboundedFeed.counters.watermark, ); } @@ -777,3 +820,10 @@ class _PeerArc { // Resolved async after the first +1; no arc is emitted until then. PeerGeo? geo; } + +/// A snapshot source's counter tracker and the time of its last sample, from +/// which its speed is measured. +class _RelayFeed { + final counters = RelayCounterTracker(); + DateTime? lastSampleAt; +} diff --git a/test/features/action_mode/action_mode_widgets_test.dart b/test/features/action_mode/action_mode_widgets_test.dart index a9e6c07a71..7e3d02bd33 100644 --- a/test/features/action_mode/action_mode_widgets_test.dart +++ b/test/features/action_mode/action_mode_widgets_test.dart @@ -625,7 +625,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('advanced stats in SmC shows a note instead of zeros', ( + testWidgets('advanced stats in SmC shows the same figures as WebRTC', ( tester, ) async { await mount( @@ -638,15 +638,15 @@ void main() { relay: relay, ), ); - expect(find.byKey(const Key('advanced-stats.smc-note')), findsOneWidget); + expect(find.text('Direct connection'), findsOneWidget); + expect(find.text('↓ 1.3 MB/s ↑ 48 kB/s'), findsOneWidget); + expect(find.text('352 MB'), findsOneWidget); + expect(find.text('1.2 GB'), findsOneWidget); + expect(find.text('920 MB · 4 people'), findsOneWidget); expect( find.text('Data stats are available only when sharing via Unbounded.'), - findsOneWidget, + findsNothing, ); - expect(find.text('Current speed'), findsNothing); - expect(find.text('Data relayed this session'), findsNothing); - expect(find.text('Data relayed all time'), findsOneWidget); - expect(find.text('Direct connection'), findsOneWidget); expect(tester.takeException(), isNull); }); diff --git a/test/features/action_mode/snapshot_test.dart b/test/features/action_mode/snapshot_test.dart index 5a24dfe29e..e8d5a7911b 100644 --- a/test/features/action_mode/snapshot_test.dart +++ b/test/features/action_mode/snapshot_test.dart @@ -731,4 +731,98 @@ void main() { expect(saved.unboundedBytesDown, 1001500); expect(saved.unboundedRelayMarkDown, 1500); }); + + testWidgets('SmC relay snapshots drive speed, data, and countries', ( + tester, + ) async { + late WidgetRef widgetRef; + late BuildContext context; + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: Consumer( + builder: (ctx, ref, child) { + widgetRef = ref; + context = ctx; + return const SizedBox(); + }, + ), + ), + ); + final radiance = + container.read(radianceSettingsProvider.notifier) + as FakeRadianceSettings; + radiance.onSetPeerProxy = (_) async => right(unit); + final notifier = container.read(shareProvider.notifier); + var now = DateTime(2026, 10, 19); + notifier.clock = () => now; + Future peerSnapshot(int down, int up) async { + now = now.add(const Duration(seconds: 1)); + service.events.add( + AppEvent( + eventType: 'peer-snapshot', + message: jsonEncode({ + 'epoch': 'smc-1', + 'running': true, + 'peers': ['192.0.2.1:4000'], + 'bytesDown': down, + 'bytesUp': up, + 'peerBytes': [ + {'ip': '192.0.2.1', 'down': down, 'up': up}, + ], + }), + ), + ); + await tester.pump(); + } + + // Manual port (FakeService returns 1) routes the start to SmC. + await notifier.toggle(context, widgetRef); + expect(container.read(shareProvider).mode, ShareMode.smc); + service.events.add( + AppEvent( + eventType: 'peer-connection', + message: jsonEncode({'state': 1, 'source': '192.0.2.1:4000'}), + ), + ); + await tester.pump(); + expect(container.read(shareProvider).relay.countries['US']!.people, 3); + + await peerSnapshot(0, 0); // baseline + await peerSnapshot(4000, 400); + var relay = container.read(shareProvider).relay; + expect(relay.available, isTrue); + expect(relay.sessionDown, 4000); + expect(relay.sessionUp, 400); + expect(relay.lifetimeDown, 1004000); + expect(relay.speedDown, greaterThan(0)); + // Country attribution resolves the peer's location asynchronously. + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(); + relay = container.read(shareProvider).relay; + final us = relay.countries['US']!; + expect(us.down, 900 + 4000); + expect(us.people, 3, reason: 'a snapshot does not count people again'); + + // Unbounded keeps reporting while idle; it must not reset SmC's speed. + final speed = relay.speedDown; + service.events.add( + AppEvent( + eventType: 'unbounded-snapshot', + message: jsonEncode({ + 'epoch': 'run-1', + 'arrivals': 0, + 'enabled': false, + 'running': false, + 'peers': [], + 'bytesDown': 0, + 'bytesUp': 0, + 'peerBytes': [], + }), + ), + ); + await tester.pump(); + expect(container.read(shareProvider).relay.speedDown, speed); + expect(container.read(shareProvider).mode, ShareMode.smc); + }); } From 59f6ae38a7c8b0c5f75f8e962fb6e18e7a8cd366 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 19 Oct 2026 13:28:55 -0600 Subject: [PATCH 2/4] Action Mode: persist the direct-sharing relay watermark Without it, a restarted app's first SmC snapshot only set a baseline, so bytes relayed while the app was closed never reached the all-time total. The peer feed now saves and restores its own epoch and counters alongside Unbounded's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LQG2dRhaocR2UY5RPamm39 --- lib/core/models/app_setting.dart | 19 +++++++ .../action_mode/provider/share_notifier.dart | 10 +++- .../home/provider/app_setting_notifier.dart | 4 ++ test/features/action_mode/snapshot_test.dart | 49 +++++++++++++++++++ 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/lib/core/models/app_setting.dart b/lib/core/models/app_setting.dart index 6003c7a78f..7fe0fc0f03 100644 --- a/lib/core/models/app_setting.dart +++ b/lib/core/models/app_setting.dart @@ -35,6 +35,10 @@ class AppSetting { final String unboundedRelayEpoch; final int unboundedRelayMarkDown; final int unboundedRelayMarkUp; + // The same for direct sharing (SmC), whose snapshots carry their own epoch. + final String peerRelayEpoch; + final int peerRelayMarkDown; + final int peerRelayMarkUp; const AppSetting({ this.themeMode = 'system', @@ -56,6 +60,9 @@ class AppSetting { this.unboundedRelayEpoch = '', this.unboundedRelayMarkDown = 0, this.unboundedRelayMarkUp = 0, + this.peerRelayEpoch = '', + this.peerRelayMarkDown = 0, + this.peerRelayMarkUp = 0, }); /// Whether the app points at the staging backend. The notifier writes @@ -82,6 +89,9 @@ class AppSetting { String? unboundedRelayEpoch, int? unboundedRelayMarkDown, int? unboundedRelayMarkUp, + String? peerRelayEpoch, + int? peerRelayMarkDown, + int? peerRelayMarkUp, }) { return AppSetting( locale: newLocale ?? locale, @@ -106,6 +116,9 @@ class AppSetting { unboundedRelayMarkDown: unboundedRelayMarkDown ?? this.unboundedRelayMarkDown, unboundedRelayMarkUp: unboundedRelayMarkUp ?? this.unboundedRelayMarkUp, + peerRelayEpoch: peerRelayEpoch ?? this.peerRelayEpoch, + peerRelayMarkDown: peerRelayMarkDown ?? this.peerRelayMarkDown, + peerRelayMarkUp: peerRelayMarkUp ?? this.peerRelayMarkUp, ); } @@ -130,6 +143,9 @@ class AppSetting { 'unboundedRelayEpoch': unboundedRelayEpoch, 'unboundedRelayMarkDown': unboundedRelayMarkDown, 'unboundedRelayMarkUp': unboundedRelayMarkUp, + 'peerRelayEpoch': peerRelayEpoch, + 'peerRelayMarkDown': peerRelayMarkDown, + 'peerRelayMarkUp': peerRelayMarkUp, }; factory AppSetting.fromJson(Map json) => AppSetting( @@ -160,5 +176,8 @@ class AppSetting { (json['unboundedRelayMarkDown'] as num?)?.toInt() ?? 0, unboundedRelayMarkUp: (json['unboundedRelayMarkUp'] as num?)?.toInt() ?? 0, + peerRelayEpoch: (json['peerRelayEpoch'] ?? '').toString(), + peerRelayMarkDown: (json['peerRelayMarkDown'] as num?)?.toInt() ?? 0, + peerRelayMarkUp: (json['peerRelayMarkUp'] as num?)?.toInt() ?? 0, ); } diff --git a/lib/features/action_mode/provider/share_notifier.dart b/lib/features/action_mode/provider/share_notifier.dart index 95d7fa0cf3..864ec61f4d 100644 --- a/lib/features/action_mode/provider/share_notifier.dart +++ b/lib/features/action_mode/provider/share_notifier.dart @@ -71,8 +71,8 @@ class ShareNotifier extends _$ShareNotifier { static const _speedSmoothing = 0.5; // Unbounded and SmC report counters in separate snapshots with their own - // epochs, so each needs its own tracker and sample clock. Only Unbounded's - // watermark is persisted. + // epochs, so each needs its own tracker, sample clock and persisted + // watermark. final _unboundedFeed = _RelayFeed(); final _peerFeed = _RelayFeed(); DateTime? _lastRelayPersistAt; @@ -119,6 +119,11 @@ class ShareNotifier extends _$ShareNotifier { down: settings.unboundedRelayMarkDown, up: settings.unboundedRelayMarkUp, ); + _peerFeed.counters.restore( + epoch: settings.peerRelayEpoch, + down: settings.peerRelayMarkDown, + up: settings.peerRelayMarkUp, + ); return ShareState( totalCount: settings.unboundedTotalHelped, relay: RelayStats( @@ -743,6 +748,7 @@ class ShareNotifier extends _$ShareNotifier { bytesUp: relay.lifetimeUp, countries: relay.countries, mark: _unboundedFeed.counters.watermark, + peerMark: _peerFeed.counters.watermark, ); } diff --git a/lib/features/home/provider/app_setting_notifier.dart b/lib/features/home/provider/app_setting_notifier.dart index e603c4b1f6..af6a7ed7ca 100644 --- a/lib/features/home/provider/app_setting_notifier.dart +++ b/lib/features/home/provider/app_setting_notifier.dart @@ -124,6 +124,7 @@ class AppSettingNotifier extends _$AppSettingNotifier { required int bytesUp, required Map countries, ({String epoch, int down, int up})? mark, + ({String epoch, int down, int up})? peerMark, }) => update( state.copyWith( unboundedBytesDown: bytesDown, @@ -132,6 +133,9 @@ class AppSettingNotifier extends _$AppSettingNotifier { unboundedRelayEpoch: mark?.epoch, unboundedRelayMarkDown: mark?.down, unboundedRelayMarkUp: mark?.up, + peerRelayEpoch: peerMark?.epoch, + peerRelayMarkDown: peerMark?.down, + peerRelayMarkUp: peerMark?.up, ), ); diff --git a/test/features/action_mode/snapshot_test.dart b/test/features/action_mode/snapshot_test.dart index e8d5a7911b..b57109e661 100644 --- a/test/features/action_mode/snapshot_test.dart +++ b/test/features/action_mode/snapshot_test.dart @@ -78,6 +78,7 @@ class FakeSettings extends AppSettingNotifier { required int bytesUp, required Map countries, ({String epoch, int down, int up})? mark, + ({String epoch, int down, int up})? peerMark, }) { relayWrites++; state = state.copyWith( @@ -87,6 +88,9 @@ class FakeSettings extends AppSettingNotifier { unboundedRelayEpoch: mark?.epoch, unboundedRelayMarkDown: mark?.down, unboundedRelayMarkUp: mark?.up, + peerRelayEpoch: peerMark?.epoch, + peerRelayMarkDown: peerMark?.down, + peerRelayMarkUp: peerMark?.up, ); } } @@ -100,6 +104,15 @@ class ResumingSettings extends FakeSettings { ); } +class PeerResumingSettings extends FakeSettings { + @override + AppSetting build() => FakeSettings.seed.copyWith( + peerRelayEpoch: 'smc-1', + peerRelayMarkDown: 4000, + peerRelayMarkUp: 400, + ); +} + class FakeRadianceSettings extends RadianceSettings { final startResult = Completer>(); @override @@ -436,6 +449,42 @@ void main() { expect(relay.countries['US']!.down, 900); }); + test('resumes direct sharing from its persisted watermark', () async { + final resumed = ProviderContainer( + overrides: [ + lanternServiceProvider.overrideWithValue(service), + appSettingProvider.overrideWith(PeerResumingSettings.new), + radianceSettingsProvider.overrideWith(FakeRadianceSettings.new), + ], + ); + addTearDown(resumed.dispose); + resumed.read(shareProvider); + // The backend kept sharing while the app was closed. + service.events.add( + AppEvent( + eventType: 'peer-snapshot', + message: jsonEncode({ + 'epoch': 'smc-1', + 'running': true, + 'peers': ['192.0.2.1:4000'], + 'bytesDown': 6000, + 'bytesUp': 500, + 'peerBytes': [ + {'ip': '192.0.2.1', 'down': 6000, 'up': 500}, + ], + }), + ), + ); + await Future.delayed(Duration.zero); + final relay = resumed.read(shareProvider).relay; + expect(relay.lifetimeDown, 1000000 + 2000); + expect(relay.lifetimeUp, 2000 + 100); + // The first save is immediate, and it carries the new peer watermark. + final saved = resumed.read(appSettingProvider); + expect(saved.peerRelayEpoch, 'smc-1'); + expect(saved.peerRelayMarkDown, 6000); + }); + test( 'snapshots without byte counters (older radiance) are tolerated', () async { From e7f433560d4be91c346a9c16e07e4780c70f21f5 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 19 Oct 2026 13:32:35 -0600 Subject: [PATCH 3/4] Action Mode: zero SmC speed on outage, keep the save throttle When the backend is unreachable peer snapshots stop, but only Unbounded's outage reset the speed, so direct sharing kept showing its last figure. And the SmC branch of the Unbounded snapshot forced a save every second, which with SmC snapshots now marking the stats dirty bypassed the 30s throttle; it now forces only when Unbounded's counters moved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LQG2dRhaocR2UY5RPamm39 --- .../action_mode/provider/share_notifier.dart | 9 ++++- test/features/action_mode/snapshot_test.dart | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/lib/features/action_mode/provider/share_notifier.dart b/lib/features/action_mode/provider/share_notifier.dart index 864ec61f4d..f2c000a493 100644 --- a/lib/features/action_mode/provider/share_notifier.dart +++ b/lib/features/action_mode/provider/share_notifier.dart @@ -400,6 +400,9 @@ class ShareNotifier extends _$ShareNotifier { relay: _idleRelay(), ); _persistRelay(force: true); + } else if (!_changing && state.mode == ShareMode.smc) { + // Peer snapshots stop too, so the last speed would otherwise stay up. + state = state.copyWith(relay: _idleRelay()); } return; } @@ -514,6 +517,7 @@ class ShareNotifier extends _$ShareNotifier { if (state.mode == ShareMode.smc) { // Unbounded can still flush bytes from a session that stopped just // before SmC started. Count them without touching SmC's state. + final markBefore = _unboundedFeed.counters.watermark; state = state.copyWith( relay: _applyRelayCounters( snapshot, @@ -524,7 +528,10 @@ class ShareNotifier extends _$ShareNotifier { updateSpeed: false, ), ); - _persistRelay(force: true); + // Saved at once only when Unbounded's own counters moved: SmC's + // snapshots mark the stats dirty every second, and forcing on every + // idle Unbounded snapshot would bypass the throttle for them. + _persistRelay(force: _unboundedFeed.counters.watermark != markBefore); return; } final enabled = snapshot['enabled'] as bool; diff --git a/test/features/action_mode/snapshot_test.dart b/test/features/action_mode/snapshot_test.dart index b57109e661..0fcfe1b872 100644 --- a/test/features/action_mode/snapshot_test.dart +++ b/test/features/action_mode/snapshot_test.dart @@ -873,5 +873,38 @@ void main() { await tester.pump(); expect(container.read(shareProvider).relay.speedDown, speed); expect(container.read(shareProvider).mode, ShareMode.smc); + + // SmC traffic marks the stats dirty every second; an idle Unbounded + // snapshot must not force a save for it each time. + final settings = + container.read(appSettingProvider.notifier) as FakeSettings; + await peerSnapshot(8000, 800); + final writes = settings.relayWrites; + service.events.add( + AppEvent( + eventType: 'unbounded-snapshot', + message: jsonEncode({ + 'epoch': 'run-1', + 'arrivals': 0, + 'enabled': false, + 'running': false, + 'peers': [], + 'bytesDown': 0, + 'bytesUp': 0, + 'peerBytes': [], + }), + ), + ); + await tester.pump(); + expect(settings.relayWrites, writes); + + // When the backend is unreachable, peer snapshots stop too, so the + // speed must not stay up. + service.events.add( + AppEvent(eventType: 'unbounded-unavailable', message: '{}'), + ); + await tester.pump(); + expect(container.read(shareProvider).relay.speedDown, 0); + expect(container.read(shareProvider).mode, ShareMode.smc); }); } From 9e52ded9852420e55195f41be0ff822aafada3ec Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 19 Oct 2026 17:17:55 -0600 Subject: [PATCH 4/4] deps: pin lantern-box v0.0.166 and radiance 79f0a5bd8 lantern-box v0.0.166 carries peerconn.Counter (getlantern/lantern-box#371); radiance 79f0a5bd8 is main with the relay snapshot merged (getlantern/radiance#688, #689) and already contains a0b72470 from #9182. sing-box-minimal moves to the version lantern-box v0.0.166 requires. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KqE58aYaai3u8CFZe5nXdE --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 7393b164c2..fb522331e0 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ go 1.26.9 // replace github.com/sagernet/sing => github.com/getlantern/sing v0.7.18-lantern -replace github.com/sagernet/sing-box => github.com/getlantern/sing-box-minimal v1.13.20-0.20261017021932-5a256d757969 +replace github.com/sagernet/sing-box => github.com/getlantern/sing-box-minimal v1.13.20-0.20261019115333-624aa48e0252 // Backport gVisor reader shutdown fix (upstream be4ce8d320a5). replace github.com/sagernet/sing-tun => github.com/getlantern/sing-tun v0.0.0-20261017114032-5448fbd8b70b @@ -34,7 +34,7 @@ require ( github.com/alecthomas/assert/v2 v2.3.0 github.com/getlantern/common v1.2.1-0.20260910154003-08e030318f24 github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9 - github.com/getlantern/radiance v0.0.0-20261019190429-a0b724700121 + github.com/getlantern/radiance v0.0.0-20261019203946-79f0a5bd87ab github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb github.com/sagernet/sing-box v1.13.19 github.com/sagernet/sing-tun v0.8.12-0.20260810140523-7c73233bd0fb @@ -180,12 +180,12 @@ require ( github.com/getlantern/domainfront v0.0.0-20260915172349-4984689a8667 // indirect github.com/getlantern/keepcurrent v0.0.0-20261009194109-22f33eaeac6e // indirect github.com/getlantern/kindling v0.0.0-20261014210827-0f4a89dbe1ee // indirect - github.com/getlantern/lantern-box v0.0.154 // indirect + github.com/getlantern/lantern-box v0.0.166 // indirect github.com/getlantern/lantern-water v0.0.0-20260520145825-958775d51395 // indirect github.com/getlantern/osversion v0.0.0-20240418205916-2e84a4a4e175 // indirect github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 // indirect github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b // indirect - github.com/getlantern/samizdat v0.0.3-0.20260819153658-ebc74116a064 // indirect + github.com/getlantern/samizdat v0.0.3-0.20261019142234-18bd41c612f1 // indirect github.com/getlantern/semconv v0.0.0-20261011133221-f5e57ec9c1c5 // indirect github.com/getlantern/soar v0.2.3 // indirect github.com/getlantern/twiddle v0.0.0-20260914204124-f698a0d67d2f // indirect diff --git a/go.sum b/go.sum index e8b096eafd..61d05c7084 100644 --- a/go.sum +++ b/go.sum @@ -248,8 +248,8 @@ github.com/getlantern/keepcurrent v0.0.0-20261009194109-22f33eaeac6e h1:iJfNXnhg github.com/getlantern/keepcurrent v0.0.0-20261009194109-22f33eaeac6e/go.mod h1:ag5g9aWUw2FJcX5RVRpJ9EBQBy5yJuy2WXDouIn/m4w= github.com/getlantern/kindling v0.0.0-20261014210827-0f4a89dbe1ee h1:FXod6NjAD/KDl6Aj5mP1MVCLf2AAJv8qU4kmUrYcMZk= github.com/getlantern/kindling v0.0.0-20261014210827-0f4a89dbe1ee/go.mod h1:98jp1v8UyXJRGn8slpvzNz2tKnyzvSqtlVYkQ+kYapQ= -github.com/getlantern/lantern-box v0.0.154 h1:EPQjjBqNC1gbDb2SlpbWg1wo7pNM/TccZ/UV+yhVQjI= -github.com/getlantern/lantern-box v0.0.154/go.mod h1:gy3dGHMTQXaFi5DYIJJrMNT3WS2E7GOxuZl2jebcbuw= +github.com/getlantern/lantern-box v0.0.166 h1:Z2qy+5Yvd6s0pQBGlqP/bwRllkynK5Clf94HcHzy13Q= +github.com/getlantern/lantern-box v0.0.166/go.mod h1:RK7qWraZxaDGC6iJYJV6KEUyQvOy2njF5iEENZ1bl/k= github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9 h1:6seyD2f9tz2am0YQd/Qn+q7LFiiQgnmxgwWFnVceGZw= github.com/getlantern/lantern-server-provisioner v0.0.0-20251031121934-8ea031fccfa9/go.mod h1:s0VKrlJf/z+M0U8IKHFL2hfuflocRw3SINmMacrTlMA= github.com/getlantern/lantern-water v0.0.0-20260520145825-958775d51395 h1:grfGavAUp2E9w9ZoJuM3FyWyQ0sCJ64V4ZMKtZKRqTc= @@ -262,14 +262,14 @@ github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535 h1:rtDmW8YL github.com/getlantern/pluriconfig v0.0.0-20251126214241-8cc8bc561535/go.mod h1:WKJEdjMOD4IuTRYwjQHjT4bmqDl5J82RShMLxPAvi0Q= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b h1:gMYJzEhLrmIqQ+JnjiYNm+UyUDalK3WUmVyecFwmV5g= github.com/getlantern/publicip v0.0.0-20260328175246-2c460fe80c6b/go.mod h1:NpfXdK4ldEKkjQ4P1R+DBF4ua5VFOlxmgHROTnYrApg= -github.com/getlantern/radiance v0.0.0-20261019190429-a0b724700121 h1:N/s95WcdaC+//C8uL922P1VTp7RxXU1X9p74BlF2SN8= -github.com/getlantern/radiance v0.0.0-20261019190429-a0b724700121/go.mod h1:c0XV/0cz1Q6n/d5fYdl3z2QQE6ZeJa6VfZAkHHaFlSQ= -github.com/getlantern/samizdat v0.0.3-0.20260819153658-ebc74116a064 h1:NhCyQAWGEWjok/jg4gRD6fzCZvyOQy/4dVSFYhzU5xk= -github.com/getlantern/samizdat v0.0.3-0.20260819153658-ebc74116a064/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= +github.com/getlantern/radiance v0.0.0-20261019203946-79f0a5bd87ab h1:onIu3VaXT4+luEADvJIYcwEeeltpGLuiVP2RX60yLuI= +github.com/getlantern/radiance v0.0.0-20261019203946-79f0a5bd87ab/go.mod h1:5BAJjWE5MgXtMzxTlbl0Fcq6FQlTTUXb2bO7jbA8/e8= +github.com/getlantern/samizdat v0.0.3-0.20261019142234-18bd41c612f1 h1:Q1AHHHTd3eiHXn0HR0mltq6/4cGnv9cQFKYmoRuXWQo= +github.com/getlantern/samizdat v0.0.3-0.20261019142234-18bd41c612f1/go.mod h1:uEeykQSW2/6rTjfPlj3MTTo59poSHXfAHTGgzYDkbr0= github.com/getlantern/semconv v0.0.0-20261011133221-f5e57ec9c1c5 h1:oF5kQTI7RwMvjMnr4WhmQ1beAjSMl1zoQ4I7Cx5nm6I= github.com/getlantern/semconv v0.0.0-20261011133221-f5e57ec9c1c5/go.mod h1:GkPT5P9JoOTIRXRmFWxYgu1hhXgTFFTNc2hoG7WQc3g= -github.com/getlantern/sing-box-minimal v1.13.20-0.20261017021932-5a256d757969 h1:xDlY1aEy5FILPxlncY5ciHQQY5FdyiNNZ2RC86ACfsw= -github.com/getlantern/sing-box-minimal v1.13.20-0.20261017021932-5a256d757969/go.mod h1:4E8WvF7Vi60N1NtPw1CeG994J/yiJZZkty5dKKwG+V0= +github.com/getlantern/sing-box-minimal v1.13.20-0.20261019115333-624aa48e0252 h1:p6tbxiX1SHqo1FwO9l1uYerSSaMLt/R78QtLIScpP54= +github.com/getlantern/sing-box-minimal v1.13.20-0.20261019115333-624aa48e0252/go.mod h1:4E8WvF7Vi60N1NtPw1CeG994J/yiJZZkty5dKKwG+V0= github.com/getlantern/sing-tun v0.0.0-20261017114032-5448fbd8b70b h1:e5AdmeQNZZlDB0t7RtglDFZWBULfDCFzR7vkuPwEvbs= github.com/getlantern/sing-tun v0.0.0-20261017114032-5448fbd8b70b/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ= github.com/getlantern/soar v0.2.3 h1:BAahWy3b4J6wtrAJD7WgoO2O+x2Q3T6KN2r0E3ogNgs=