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

Applies the same startup-sync retry as PR #2995 does for
ChannelSectionsManager: on cold start the relay's per-connection quota
can be exhausted by the channel-list REQ burst, rejecting the sort
manager's history fetch and live subscription with 'rate-limited:
quota exceeded'. Both errors were swallowed with no retry, so the
custom channel order never loaded.

Retry _syncWithRelay with exponential backoff (2s base, 30s cap) until
the fetch and live subscription both succeed. A failed fetch still
never seed-publishes and the dirty flag is respected on every attempt.
Regression test mirrors the sections retry coverage.

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:26:31 -07:00
parent 0c397f5771
commit 83271da8d9
2 changed files with 150 additions and 8 deletions
@@ -51,6 +51,12 @@ class ChannelSortManager {
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;
/// Unix seconds of the oldest unpublished local edit; 0 when clean.
/// Persisted so unpublished edits survive manager teardown and restarts.
int _dirtySince;
@@ -67,12 +73,15 @@ class ChannelSortManager {
required SignedEventRelay? signedEventRelay,
required bool remoteEnabled,
required VoidCallback onChanged,
@visibleForTesting
Duration startupRetryBaseDelay = const Duration(seconds: 2),
}) : _storage = ChannelSortStorage(prefs),
_crypto = crypto,
_relaySession = relaySession,
_signedEventRelay = signedEventRelay,
_remoteEnabled = remoteEnabled,
_onChanged = onChanged,
_startupRetryBaseDelay = startupRetryBaseDelay,
_store = ChannelSortStorage(prefs).read(pubkey),
_dirtySince = ChannelSortStorage(prefs).readDirtySince(pubkey);
@@ -89,8 +98,20 @@ class ChannelSortManager {
return;
}
final foundRemote = await _fetchAndMerge();
await _startLiveSubscription();
await _syncWithRelay();
_onChanged();
}
/// One startup-sync attempt: fetch the remote blob, start the live
/// subscription, then reconcile dirty/seed state. 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.
Future<void> _syncWithRelay() async {
final foundRemote = _startupFetchSucceeded ? true : await _fetchAndMerge();
if (foundRemote != null) _startupFetchSucceeded = true;
final subscribed = _unsubscribe != null || await _startLiveSubscription();
// Publish-on-reconnect for unpublished local edits, and seed-publish when
// the relay confirmed it has no blob but local prefs exist. `foundRemote`
@@ -98,13 +119,41 @@ class ChannelSortManager {
if (_dirtySince > 0 || (foundRemote == false && _store.groups.isNotEmpty)) {
markDirty();
}
_onChanged();
if (!_startupFetchSucceeded || !subscribed) {
_scheduleStartupRetry();
}
}
void _scheduleStartupRetry() {
if (_disposed) return;
_startupRetryTimer?.cancel();
final delayMs = min(
_startupRetryBaseDelay.inMilliseconds << min(_startupRetryAttempt, 5),
30000,
);
_startupRetryAttempt++;
debugPrint(
'[ChannelSortManager] 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;
@@ -177,14 +226,16 @@ class ChannelSortManager {
return events.any(
(e) => e.pubkey == pubkey && e.getTagValue('d') == 'channel-sort',
);
} catch (_) {
} catch (error) {
debugPrint('[ChannelSortManager] fetch failed: $error');
// Local state remains usable when relay is unavailable.
return null;
}
}
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(
@@ -197,8 +248,12 @@ class ChannelSortManager {
),
_handleIncomingEvent,
);
} catch (_) {
// Non-fatal — local state and history still work.
return true;
} catch (error) {
debugPrint('[ChannelSortManager] live subscription failed: $error');
// Non-fatal — local state and history still work; retried by the
// startup-sync backoff.
return false;
}
}
@@ -149,6 +149,50 @@ void main() {
expect(manager.store.groups.keys, ['channels']);
manager.dispose(flushPending: false);
});
test('startup fetch rejected by relay rate limit retries until the remote '
'blob is adopted (cold-start regression)', () async {
await setUpEnv();
final relay = _RateLimitedRelaySession(
failuresBeforeSuccess: 2,
historyEvents: [
sortEvent(groups: {'channels': 'recent'}, createdAt: 100),
],
);
final manager = ChannelSortManager(
pubkey: keychain.public,
prefs: prefs,
crypto: crypto,
relaySession: relay,
signedEventRelay: _RecordingSignedEventRelay(),
remoteEnabled: true,
onChanged: () {},
startupRetryBaseDelay: const Duration(milliseconds: 10),
);
await manager.initialize();
expect(manager.sortModeFor('channels'), ChannelSortMode.alpha);
await _waitUntil(
() => manager.sortModeFor('channels') == ChannelSortMode.recent,
);
expect(relay.subscribeCalls, greaterThan(1));
manager.dispose(flushPending: false);
});
}
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));
}
}
class _SubmittedEvent {
@@ -211,3 +255,46 @@ class _FakeRelaySession extends RelaySessionNotifier {
void Function(String message)? onClosed,
}) async => () {};
}
/// Rejects the first [failuresBeforeSuccess] fetch and subscribe calls with
/// the relay's rate-limit error, then succeeds — the Android cold-start
/// failure mode where the channel-list REQ burst exhausts the relay's
/// per-connection quota before the sort 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);
}
}