diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart index b9c00e904..775fea452 100644 --- a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart +++ b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart @@ -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 _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 _startLiveSubscription() async { - if (_relaySession == null) return; + /// Returns whether the live subscription was established. + Future _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; } } diff --git a/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart b/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart index eb3cd6ce3..70941a4f3 100644 --- a/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart +++ b/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart @@ -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 _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.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 historyEvents; + int fetchCalls = 0; + int subscribeCalls = 0; + final List _listeners = []; + + @override + Future> 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 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); + } +}