fix(mobile): retry channel-sections startup sync when relay rate-limits cold start

On mobile cold start, ChannelsNotifier fires a burst of per-channel REQs
that exhausts the relay's per-connection rate-limit quota. The sections
manager then has BOTH its one-shot history fetch and its live
subscription rejected with 'rate-limited: quota exceeded'. Both errors
were silently swallowed with no retry, so the manager kept the local
(empty/default) store forever and desktop-created channel groups never
appeared on mobile. Restarts replay the same storm, so the failure is
sticky.

Fix: track whether the startup fetch and the live subscription have
succeeded, and retry _syncWithRelay with exponential backoff (2s base,
capped at 30s) until both land. The retry timer is cancelled on
dispose and previously swallowed errors are now logged.

Verified live on the Android emulator: cold-start logs show the
manager rate-limited, then a 2s retry succeeding and desktop-created
groups rendering. Regression tests cover retry-until-adopted, retry
stopping after success, and dispose cancelling pending retries.

Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-26 09:21:38 -07:00
parent 74b63e1846
commit c5f1d9a38b
2 changed files with 276 additions and 9 deletions
@@ -48,6 +48,12 @@ class ChannelSectionsManager {
void Function()? _unsubscribe;
bool _disposed = false;
/// Base delay for the startup-sync retry backoff. Overridable in tests.
final Duration _startupRetryBaseDelay;
Timer? _startupRetryTimer;
int _startupRetryAttempt = 0;
bool _startupFetchSucceeded = false;
ChannelSectionsManager({
required this.pubkey,
required SharedPreferences prefs,
@@ -56,12 +62,15 @@ class ChannelSectionsManager {
required SignedEventRelay? signedEventRelay,
required bool remoteEnabled,
required VoidCallback onChanged,
@visibleForTesting
Duration startupRetryBaseDelay = const Duration(seconds: 2),
}) : _storage = ChannelSectionsStorage(prefs),
_crypto = crypto,
_relaySession = relaySession,
_signedEventRelay = signedEventRelay,
_remoteEnabled = remoteEnabled,
_onChanged = onChanged,
_startupRetryBaseDelay = startupRetryBaseDelay,
_store = ChannelSectionsStorage(prefs).read(pubkey);
ChannelSectionStore get store => _store;
@@ -74,15 +83,57 @@ class ChannelSectionsManager {
return;
}
await _fetchAndMerge();
await _startLiveSubscription();
await _syncWithRelay();
_onChanged();
}
/// One startup-sync attempt: fetch the remote blob, then start the live
/// subscription. Either step can lose a transient race on cold start (the
/// relay rate-limits the burst of per-channel subscriptions and rejects
/// with `rate-limited: quota exceeded`) — retry with backoff instead of
/// silently giving up, which left desktop-created groups invisible until
/// an unrelated refetch.
Future<void> _syncWithRelay() async {
if (!_startupFetchSucceeded) {
_startupFetchSucceeded = await _fetchAndMerge();
}
final subscribed = _unsubscribe != null || await _startLiveSubscription();
if (!_startupFetchSucceeded || !subscribed) {
_scheduleStartupRetry();
}
}
void _scheduleStartupRetry() {
if (_disposed) return;
_startupRetryTimer?.cancel();
final delayMs = min(
_startupRetryBaseDelay.inMilliseconds << min(_startupRetryAttempt, 5),
30000,
);
_startupRetryAttempt++;
debugPrint(
'[ChannelSectionsManager] startup sync incomplete; '
'retrying in ${delayMs}ms (attempt $_startupRetryAttempt)',
);
_startupRetryTimer = Timer(Duration(milliseconds: delayMs), () {
_startupRetryTimer = null;
unawaited(
_syncWithRelay().then((_) {
if (!_disposed) _onChanged();
}),
);
});
}
void dispose({bool flushPending = true}) {
if (_disposed) return;
_disposed = true;
_startupRetryTimer?.cancel();
_startupRetryTimer = null;
final hadPending = _publishDebounce != null;
_publishDebounce?.cancel();
_publishDebounce = null;
@@ -201,8 +252,10 @@ class ChannelSectionsManager {
});
}
Future<void> _fetchAndMerge() async {
if (_relaySession == null) return;
/// Returns whether the fetch reached the relay (regardless of whether a
/// remote blob exists).
Future<bool> _fetchAndMerge() async {
if (_relaySession == null) return false;
try {
final events = await _relaySession.fetchHistory(
NostrFilter(
@@ -217,13 +270,17 @@ class ChannelSectionsManager {
_mergeEvents(events);
_persist();
if (!_disposed) _onChanged();
} catch (_) {
return true;
} catch (error) {
debugPrint('[ChannelSectionsManager] fetch failed: $error');
// Local state remains usable when relay is unavailable.
return false;
}
}
Future<void> _startLiveSubscription() async {
if (_relaySession == null) return;
/// Returns whether the live subscription was established.
Future<bool> _startLiveSubscription() async {
if (_relaySession == null) return false;
try {
_unsubscribe = await _relaySession.subscribe(
NostrFilter(
@@ -236,8 +293,12 @@ class ChannelSectionsManager {
),
_handleIncomingEvent,
);
} catch (_) {
// Non-fatal — local state and history still work.
return true;
} catch (error) {
debugPrint('[ChannelSectionsManager] live subscription failed: $error');
// Non-fatal — local state and history still work; retried by the
// startup-sync backoff.
return false;
}
}
@@ -0,0 +1,206 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:buzz/features/channels/channel_sections/channel_sections_manager.dart';
import 'package:buzz/shared/relay/relay.dart';
void main() {
late SharedPreferences prefs;
late nostr.Keys keychain;
late ChannelSectionsCrypto crypto;
Future<void> setUpEnv() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
keychain = nostr.Keys.generate();
crypto = ChannelSectionsCrypto(keychain.nsec, keychain.public);
}
NostrEvent sectionsEvent({
required List<Map<String, dynamic>> sections,
Map<String, String> assignments = const {},
required int createdAt,
String id = 'remote-event',
}) {
final payload = jsonEncode({
'version': 1,
'sections': sections,
'assignments': assignments,
});
return NostrEvent(
id: id,
pubkey: keychain.public,
createdAt: createdAt,
kind: EventKind.readState,
tags: const [
['d', 'channel-sections'],
['t', 'channel-sections'],
],
content: crypto.encrypt(payload),
sig: 'sig',
);
}
ChannelSectionsManager buildManager({
required RelaySessionNotifier relaySession,
Duration startupRetryBaseDelay = const Duration(milliseconds: 5),
}) {
return ChannelSectionsManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto,
relaySession: relaySession,
signedEventRelay: null,
remoteEnabled: true,
onChanged: () {},
startupRetryBaseDelay: startupRetryBaseDelay,
);
}
test('startup fetch rejected by relay rate limit retries until the remote '
'blob is adopted (cold-start regression)', () async {
await setUpEnv();
// Cold start: the relay rejects the first fetch AND the first live
// subscription with `rate-limited: quota exceeded` because the channel
// list fired dozens of REQs first. Pre-fix, both errors were swallowed
// and desktop-created groups never appeared until app data was cleared.
final relay = _RateLimitedRelaySession(
failuresBeforeSuccess: 2,
historyEvents: [
sectionsEvent(
sections: [
{'id': 's1', 'name': 'Desktop Group', 'order': 0},
],
createdAt: 100,
),
],
);
final manager = buildManager(
relaySession: relay,
startupRetryBaseDelay: const Duration(milliseconds: 10),
);
await manager.initialize();
expect(
manager.store.sections,
isEmpty,
reason: 'first fetch lost the rate-limit race',
);
// Wait for the backoff retries (10ms, 20ms, …) to win the race.
await _waitUntil(() => manager.store.sections.isNotEmpty);
expect(manager.store.sections.single.name, 'Desktop Group');
expect(
relay.subscribeCalls,
greaterThan(1),
reason: 'live subscription must be retried too',
);
manager.dispose(flushPending: false);
});
test(
'startup retry stops after fetch and subscription both succeed',
() async {
await setUpEnv();
final relay = _RateLimitedRelaySession(
failuresBeforeSuccess: 1,
historyEvents: [
sectionsEvent(
sections: [
{'id': 's1', 'name': 'Desktop Group', 'order': 0},
],
createdAt: 100,
),
],
);
final manager = buildManager(relaySession: relay);
await manager.initialize();
await _waitUntil(() => manager.store.sections.isNotEmpty);
final fetchCallsAfterSuccess = relay.fetchCalls;
final subscribeCallsAfterSuccess = relay.subscribeCalls;
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(relay.fetchCalls, fetchCallsAfterSuccess);
expect(relay.subscribeCalls, subscribeCallsAfterSuccess);
manager.dispose(flushPending: false);
},
);
test('disposing the manager cancels pending startup retries', () async {
await setUpEnv();
final relay = _RateLimitedRelaySession(failuresBeforeSuccess: 1000);
final manager = buildManager(relaySession: relay);
await manager.initialize();
manager.dispose(flushPending: false);
final fetchCallsAtDispose = relay.fetchCalls;
await Future<void>.delayed(const Duration(milliseconds: 60));
expect(
relay.fetchCalls,
fetchCallsAtDispose,
reason: 'no retries may fire after dispose',
);
});
}
Future<void> _waitUntil(
bool Function() condition, {
Duration timeout = const Duration(seconds: 2),
}) async {
final deadline = DateTime.now().add(timeout);
while (!condition()) {
if (DateTime.now().isAfter(deadline)) {
fail('condition not met within $timeout');
}
await Future<void>.delayed(const Duration(milliseconds: 10));
}
}
/// Rejects the first [failuresBeforeSuccess] fetch and subscribe calls with
/// the relay's rate-limit error, then succeeds — the exact failure mode seen
/// on Android cold start where the channel-list REQ burst exhausts the
/// relay's per-connection quota before the sections manager gets a turn.
class _RateLimitedRelaySession extends RelaySessionNotifier {
_RateLimitedRelaySession({
required this.failuresBeforeSuccess,
this.historyEvents = const [],
});
final int failuresBeforeSuccess;
final List<NostrEvent> historyEvents;
int fetchCalls = 0;
int subscribeCalls = 0;
final List<void Function(NostrEvent)> _listeners = [];
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
fetchCalls++;
if (fetchCalls <= failuresBeforeSuccess) {
throw Exception('rate-limited: quota exceeded; retry in 2s');
}
return historyEvents;
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
subscribeCalls++;
if (subscribeCalls <= failuresBeforeSuccess) {
throw Exception('rate-limited: quota exceeded; retry in 1s');
}
_listeners.add(onEvent);
return () => _listeners.remove(onEvent);
}
}