diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart index a99822ede5e..042ad4a6cec 100644 --- a/mobile/lib/features/channels/channel_directory.dart +++ b/mobile/lib/features/channels/channel_directory.dart @@ -485,3 +485,86 @@ extension _ObservedUnreadRecording on ChannelsNotifier { } } } + +/// Scope-owned live events received before the initial list is published. +class _BootstrapLiveEventBuffer { + _BootstrapLiveEventBuffer(this.fence); + + final _ChannelRefreshFence fence; + final Map events = {}; +} + +extension _ChannelBootstrapMerging on ChannelsNotifier { + void _cacheMemberSnapshots( + Iterable events, { + bool replaceAll = false, + }) { + final latestByChannelId = {}; + for (final event in events) { + final channelId = event.getTagValue('d'); + if (channelId == null) continue; + final current = latestByChannelId[channelId]; + if (current == null || event.createdAt > current.createdAt) { + latestByChannelId[channelId] = event; + } + } + + final snapshots = replaceAll + ? >{} + : Map>.of(_memberSnapshotsByChannelId); + snapshots.addAll({ + for (final entry in latestByChannelId.entries) + entry.key: List.unmodifiable([ + for (final member in membersFromEvent(entry.value)) + ChannelMember( + pubkey: member.pubkey, + role: member.role, + joinedAt: DateTime.fromMillisecondsSinceEpoch( + entry.value.createdAt * 1000, + isUtc: true, + ), + ), + ]), + }); + _memberSnapshotsByChannelId = Map.unmodifiable(snapshots); + } + + List _lastMessageFilters(List channels) => [ + for (final channel in channels) + NostrFilter( + kinds: EventKind.channelMessageEventKinds, + tags: { + '#h': [channel.id], + }, + limit: channel.isDm ? 1 : 20, + ), + ]; + + void _mergeLastMessageEvents( + Map lastMessageMap, + Iterable events, { + required Map channelById, + required String myPk, + required Set mutedChannelIds, + }) { + for (final event in events) { + final channelId = event.channelId; + if (channelId == null) continue; + final channel = channelById[channelId]; + if (channel == null) continue; + if (!channel.isDm && + !shouldNotifyForEvent( + event, + myPk, + mutedChannelIds: mutedChannelIds, + channelId: channelId, + )) { + continue; + } + final current = lastMessageMap[channelId]; + if (current == null || event.createdAt > current) { + lastMessageMap[channelId] = event.createdAt; + } + } + } +} diff --git a/mobile/lib/features/channels/channel_sync.dart b/mobile/lib/features/channels/channel_sync.dart new file mode 100644 index 00000000000..ac9591de31f --- /dev/null +++ b/mobile/lib/features/channels/channel_sync.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:math'; + +const channelQueryBatchSize = 100; +const liveSubscriptionMaxConcurrent = 4; +const liveSubscriptionStartInterval = Duration(milliseconds: 125); + +typedef TaskDelay = Future Function(Duration duration); + +List> chunkChannelQueryItems(List items) => [ + for (var start = 0; start < items.length; start += channelQueryBatchSize) + items.sublist(start, min(start + channelQueryBatchSize, items.length)), +]; + +/// Runs tasks through one globally serialized admission chain. +/// +/// The relay bills each REQ against a 50-per-five-second budget. A 125 ms +/// interval admits at most 40 requests in a half-open five-second window, while +/// the worker ceiling limits outstanding readiness waits when the relay stalls. +Future runPacedTasks( + List Function()> tasks, { + required int maxConcurrent, + required Duration startInterval, + required bool Function() isCancelled, + TaskDelay delay = defaultTaskDelay, + void Function(Object error)? onError, +}) async { + if (maxConcurrent < 1) throw ArgumentError.value(maxConcurrent); + var nextTask = 0; + var firstStart = true; + Future startPermit = Future.value(); + + Future acquireStartPermit() async { + if (firstStart) { + firstStart = false; + } else { + startPermit = startPermit.then((_) => delay(startInterval)); + await startPermit; + } + return !isCancelled(); + } + + Future worker() async { + while (true) { + final index = nextTask++; + if (index >= tasks.length) return; + if (!await acquireStartPermit()) return; + try { + await tasks[index](); + } catch (error) { + onError?.call(error); + } + } + } + + await Future.wait([ + for (var i = 0; i < min(maxConcurrent, tasks.length); i++) worker(), + ]); +} + +Future defaultTaskDelay(Duration duration) => + Future.delayed(duration); diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index d3bb29a0708..f644356fb0e 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -9,6 +9,7 @@ import '../../shared/relay/relay.dart'; import '../../shared/theme/theme_provider.dart'; import '../../shared/utils/string_utils.dart'; import 'channel.dart'; +import 'channel_sync.dart'; import 'channel_management_provider.dart' show ChannelMember, channelDetailsProvider; import 'channel_mutes/channel_mutes_provider.dart'; @@ -24,6 +25,7 @@ part 'channels_provider_lifecycle.dart'; const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2}; const _unreadCatchUpLimit = 1000; +const _latestMessageQueryDeadline = Duration(seconds: 8); const _participatedRootIdsPrefix = 'buzz-thread-participation.v1'; const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; @@ -46,12 +48,14 @@ class ChannelsNotifier extends AsyncNotifier> { int _subscriptionVersion = 0; String? _subscriptionRelayBaseUrl; Timer? _backstopTimer; + _BootstrapLiveEventBuffer? _bootstrapLiveEventBuffer; final Map _latestObservedByChannel = {}; final Map> _observedUnreadEventsByChannel = {}; Set _participatedRootIds = {}; Set _authoredRootIds = {}; String? _threadInterestPubkey; + String? _publishedChannelScope; bool _hasLoaded = false; String? _memberSnapshotRelayBaseUrl; String? _memberSnapshotPubkey; @@ -59,7 +63,6 @@ class ChannelsNotifier extends AsyncNotifier> { List _directoryMetas = const []; Set _hiddenDmIds = const {}; - /// Fences directory responses to the relay and identity that requested them. late final _ChannelRefreshCoordinator _refreshCoordinator = _ChannelRefreshCoordinator.forRef(ref); @@ -128,6 +131,7 @@ class ChannelsNotifier extends AsyncNotifier> { _observedUnreadEventsByChannel.clear(); _backstopTimer?.cancel(); _backstopTimer = null; + _bootstrapLiveEventBuffer = null; }); if (sessionState.status != SessionStatus.connected) { @@ -291,11 +295,17 @@ class ChannelsNotifier extends AsyncNotifier> { } } - // Step 3: fetch the most recent message per channel to populate lastMessageAt. - // kind:39000 metadata doesn't carry message timestamps, so channels load with - // lastMessageAt: null. Without this, unread detection and badge computation - // see every channel as having no messages. Skipped on backstop refreshes since - // live subscriptions keep lastMessageAt current after the initial load. + final bootstrapBuffer = subscribeLive && !_hasLoaded + ? _BootstrapLiveEventBuffer(fence) + : null; + if (bootstrapBuffer != null) { + _bootstrapLiveEventBuffer = bootstrapBuffer; + fence.ensureCurrent(); + unawaited(_subscribeLive(channels, fence)); + } + + // Fetch a bounded latest-message snapshot while initial live subscriptions + // are admitted in the background. Batches share one total startup deadline. if (fetchLastMessage) { final activeChannels = [ for (final channel in channels) @@ -304,29 +314,35 @@ class ChannelsNotifier extends AsyncNotifier> { final channelById = { for (final channel in activeChannels) channel.id: channel, }; - final events = await _fenced( - fence, - _fetchLastMessageEvents(session, activeChannels), - ); final lastMessageMap = {}; final mutedChannelIds = _mutedChannelIds(); - for (final event in events) { - final channelId = event.channelId; - if (channelId == null) continue; - final channel = channelById[channelId]; - if (channel == null) continue; - if (!channel.isDm && - !shouldNotifyForEvent( - event, - myPk, - mutedChannelIds: mutedChannelIds, - channelId: channelId, - )) { - continue; - } - final current = lastMessageMap[channelId]; - if (current == null || event.createdAt > current) { - lastMessageMap[channelId] = event.createdAt; + final stopwatch = Stopwatch()..start(); + for ( + var start = 0; + start < activeChannels.length; + start += channelQueryBatchSize + ) { + final remaining = _latestMessageQueryDeadline - stopwatch.elapsed; + if (remaining <= Duration.zero) break; + final end = min(start + channelQueryBatchSize, activeChannels.length); + try { + final events = await _fenced( + fence, + session.queryRelay( + _lastMessageFilters(activeChannels.sublist(start, end)), + timeout: remaining, + ), + ); + _mergeLastMessageEvents( + lastMessageMap, + events, + channelById: channelById, + myPk: myPk, + mutedChannelIds: mutedChannelIds, + ); + } catch (error) { + if (error is _StaleChannelRefresh) rethrow; + debugPrint('[ChannelsNotifier] last-message batch failed: $error'); } } @@ -362,15 +378,20 @@ class ChannelsNotifier extends AsyncNotifier> { // Scoped narrowly to the archived flip — broader metadata staleness // (renames, topic changes, etc.) is a separate, pre-existing concern that // already affects this provider for other reasons. - // Re-check before the first write that other providers can observe. Every - // await above is fenced, but the switch can also land in the synchronous - // gap, so the guard sits immediately before the write rather than only - // after the await. fence.ensureCurrent(); - final prevById = { - for (final c in state.value ?? const []) c.id: c, - }; + final prevById = _publishedChannelScope == fence.scope + ? { + for (final c in state.value ?? const []) c.id: c, + } + : const {}; + for (var i = 0; i < channels.length; i++) { + final previous = prevById[channels[i].id]?.lastMessageAt; + if (previous != null && + previous.isAfter(channels[i].lastMessageAt ?? DateTime(0))) { + channels[i] = channels[i].copyWith(lastMessageAt: previous); + } + } for (final channel in channels) { final prev = prevById[channel.id]; if (prev != null && prev.isArchived != channel.isArchived) { @@ -378,7 +399,18 @@ class ChannelsNotifier extends AsyncNotifier> { } } - if (subscribeLive) { + if (bootstrapBuffer != null) { + if (!identical(_bootstrapLiveEventBuffer, bootstrapBuffer)) { + throw const _StaleChannelRefresh(); + } + for (final event in bootstrapBuffer.events.values) { + _mergeLiveEventIntoChannels(channels, event); + } + // Publish while the buffer is still authoritative. No asynchronous + // callback can interleave between this write and the handoff below. + state = AsyncData(channels); + _bootstrapLiveEventBuffer = null; + } else if (subscribeLive) { // Subscriptions are shared relay state, so a retired refresh must not // install them even though its channel list is already built. fence.ensureCurrent(); @@ -387,80 +419,20 @@ class ChannelsNotifier extends AsyncNotifier> { // Guard the provider-state write in `retryDirectory` and `build`: the // caller assigns whatever this returns, so the last check belongs here. fence.ensureCurrent(); + _publishedChannelScope = fence.scope; return channels; } - void _cacheMemberSnapshots( - Iterable events, { - bool replaceAll = false, - }) { - final latestByChannelId = {}; - for (final event in events) { - final channelId = event.getTagValue('d'); - if (channelId == null) continue; - final current = latestByChannelId[channelId]; - if (current == null || event.createdAt > current.createdAt) { - latestByChannelId[channelId] = event; - } - } - - final snapshots = replaceAll - ? >{} - : Map>.of(_memberSnapshotsByChannelId); - snapshots.addAll({ - for (final entry in latestByChannelId.entries) - entry.key: List.unmodifiable([ - for (final member in membersFromEvent(entry.value)) - ChannelMember( - pubkey: member.pubkey, - role: member.role, - joinedAt: DateTime.fromMillisecondsSinceEpoch( - entry.value.createdAt * 1000, - isUtc: true, - ), - ), - ]), - }); - _memberSnapshotsByChannelId = Map.unmodifiable(snapshots); - } - - /// Fetches each channel's independent latest-message window in one HTTP - /// bridge request. The relay preserves NIP-01 per-filter limits while - /// executing the filters with bounded concurrency, avoiding an unbounded - /// burst of websocket REQs on communities with many channels. - Future> _fetchLastMessageEvents( - RelaySessionNotifier session, - List channels, - ) async { - if (channels.isEmpty) return const []; - - final filters = [ - for (final channel in channels) - NostrFilter( - kinds: EventKind.channelMessageEventKinds, - tags: { - '#h': [channel.id], - }, - limit: channel.isDm ? 1 : 20, - ), - ]; - - return _fetchChannelHistoryBatch( - session, - filters, - operation: 'latest-message query', - ); - } - Future> _fetchChannelHistoryBatch( RelaySessionNotifier session, List filters, { required String operation, + Duration timeout = const Duration(seconds: 8), }) async { if (filters.isEmpty) return const []; try { - return await session.queryRelay(filters); + return await session.queryRelay(filters, timeout: timeout); } catch (error) { debugPrint( '[ChannelsNotifier] batched $operation failed; ' @@ -577,7 +549,8 @@ class ChannelsNotifier extends AsyncNotifier> { _ChannelRefreshFence fence, ) async { fence.ensureCurrent(); - if (ref.read(relaySessionProvider).status != SessionStatus.connected) { + if (!ref.mounted || + ref.read(relaySessionProvider).status != SessionStatus.connected) { return; } @@ -604,50 +577,64 @@ class ChannelsNotifier extends AsyncNotifier> { entry.value(); } - for (final channelId in channelIds) { - if (ref.read(relaySessionProvider).status != SessionStatus.connected) { - return; - } - if (_unsubscribersByChannel.containsKey(channelId)) continue; - try { - final unsubscribe = await session.subscribe( - NostrFilter( - kinds: EventKind.channelEventKinds, - tags: { - '#h': [channelId], - }, - limit: 0, - ), - _handleLiveEvent, - ); - if (!fence.isCurrent) { - unsubscribe(); - throw const _StaleChannelRefresh(); - } - if (subscriptionVersion != _subscriptionVersion || - ref.read(relaySessionProvider).status != SessionStatus.connected || - !_desiredLiveChannelIds.contains(channelId) || - ref.read(relayConfigProvider).baseUrl != relayBaseUrl || - _subscriptionRelayBaseUrl != relayBaseUrl) { - unsubscribe(); - return; - } - final replaced = _unsubscribersByChannel[channelId]; - if (replaced != null) { - unsubscribe(); - continue; - } - _unsubscribersByChannel[channelId] = unsubscribe; - } on _StaleChannelRefresh { - rethrow; - } catch (error) { - debugPrint( - '[ChannelsNotifier] live subscription failed for $channelId: $error', - ); - } - } + final pendingChannelIds = [ + for (final channelId in channelIds) + if (!_unsubscribersByChannel.containsKey(channelId)) channelId, + ]; + await runPacedTasks( + [ + for (final channelId in pendingChannelIds) + () async { + if (subscriptionVersion != _subscriptionVersion || + !fence.isCurrent || + ref.read(relaySessionProvider).status != + SessionStatus.connected) { + return; + } + final unsubscribe = await session.subscribeWithStatus( + NostrFilter( + kinds: EventKind.channelEventKinds, + tags: { + '#h': [channelId], + }, + limit: 0, + ), + _handleLiveEvent, + onStatusChanged: (_) {}, + ); + if (subscriptionVersion != _subscriptionVersion || + !fence.isCurrent || + ref.read(relaySessionProvider).status != + SessionStatus.connected || + !_desiredLiveChannelIds.contains(channelId) || + ref.read(relayConfigProvider).baseUrl != relayBaseUrl || + _subscriptionRelayBaseUrl != relayBaseUrl) { + unsubscribe(); + return; + } + final replaced = _unsubscribersByChannel[channelId]; + if (replaced != null) { + unsubscribe(); + return; + } + _unsubscribersByChannel[channelId] = unsubscribe; + }, + ], + maxConcurrent: liveSubscriptionMaxConcurrent, + startInterval: liveSubscriptionStartInterval, + isCancelled: () => + subscriptionVersion != _subscriptionVersion || + !fence.isCurrent || + ref.read(relaySessionProvider).status != SessionStatus.connected || + ref.read(relayConfigProvider).baseUrl != relayBaseUrl, + delay: ref.read(channelsLiveSubscriptionDelayProvider), + onError: (error) { + debugPrint('[ChannelsNotifier] live subscription failed: $error'); + }, + ); - if (ref.read(relaySessionProvider).status != SessionStatus.connected) { + if (!ref.mounted || + ref.read(relaySessionProvider).status != SessionStatus.connected) { return; } @@ -725,11 +712,20 @@ class ChannelsNotifier extends AsyncNotifier> { ]; try { - final events = await _fetchChannelHistoryBatch( - session, - filters, - operation: 'unread catch-up', - ); + final events = []; + final stopwatch = Stopwatch()..start(); + for (final batch in chunkChannelQueryItems(filters)) { + final remaining = _latestMessageQueryDeadline - stopwatch.elapsed; + if (remaining <= Duration.zero) break; + events.addAll( + await _fetchChannelHistoryBatch( + session, + batch, + operation: 'unread catch-up', + timeout: remaining, + ), + ); + } // The relay round-trip above is the window Jed's probes park in: a newer // refresh, a community switch or an identity switch here means every // write below belongs to a channel list the user has left. @@ -778,50 +774,55 @@ class ChannelsNotifier extends AsyncNotifier> { } void _handleLiveEvent(NostrEvent event) { - final channelId = event.channelId; - if (channelId == null) return; - - final myPk = ref.read(myPubkeyProvider); - final mutedChannelIds = _mutedChannelIds(); + final bootstrapBuffer = _bootstrapLiveEventBuffer; + if (bootstrapBuffer != null) { + if (bootstrapBuffer.fence.isCurrent) { + bootstrapBuffer.events[event.id] = event; + } + return; + } + if (event.channelId == null) return; state = state.whenData((channels) { - final idx = channels.indexWhere((c) => c.id == channelId); - if (idx == -1) { - refresh(); - return channels; - } final updated = List.of(channels); - final channel = updated[idx]; - - if (myPk != null && event.pubkey.toLowerCase() == myPk.toLowerCase()) { - _recordSelfThreadInterest(event, myPk); - } - - if (myPk != null && - shouldNotifyForEvent( - event, - myPk, - participatedRootIds: _participatedRootIds, - followedRootIds: _followedRootIds(), - authoredRootIds: _authoredRootIds, - mutedChannelIds: mutedChannelIds, - channelId: channel.id, - )) { - _recordUnreadEvent(channel, event, myPk); - final eventTime = DateTime.fromMillisecondsSinceEpoch( - event.createdAt * 1000, - isUtc: true, - ); - if (channel.lastMessageAt == null || - eventTime.isAfter(channel.lastMessageAt!)) { - updated[idx] = channel.copyWith(lastMessageAt: eventTime); - } - } - + _mergeLiveEventIntoChannels(updated, event); return updated; }); } + void _mergeLiveEventIntoChannels(List channels, NostrEvent event) { + final channelId = event.channelId; + if (channelId == null) return; + final idx = channels.indexWhere((channel) => channel.id == channelId); + if (idx == -1) return; + final myPk = ref.read(myPubkeyProvider); + final channel = channels[idx]; + if (myPk != null && event.pubkey.toLowerCase() == myPk.toLowerCase()) { + _recordSelfThreadInterest(event, myPk); + } + if (myPk == null || + !shouldNotifyForEvent( + event, + myPk, + participatedRootIds: _participatedRootIds, + followedRootIds: _followedRootIds(), + authoredRootIds: _authoredRootIds, + mutedChannelIds: _mutedChannelIds(), + channelId: channel.id, + )) { + return; + } + _recordUnreadEvent(channel, event, myPk); + final eventTime = DateTime.fromMillisecondsSinceEpoch( + event.createdAt * 1000, + isUtc: true, + ); + if (channel.lastMessageAt == null || + eventTime.isAfter(channel.lastMessageAt!)) { + channels[idx] = channel.copyWith(lastMessageAt: eventTime); + } + } + Set _mutedChannelIds() => { for (final entry in ref.read(channelMutesProvider).store.channels.entries) if (entry.value.muted) entry.key, @@ -886,7 +887,6 @@ class ChannelsNotifier extends AsyncNotifier> { } } - /// Backstop refresh that preserves existing state on transient failure. Future _backstopRefresh() async { try { final sessionState = ref.read(relaySessionProvider); @@ -993,3 +993,7 @@ class ChannelsNotifier extends AsyncNotifier> { final channelsProvider = AsyncNotifierProvider>( ChannelsNotifier.new, ); + +final channelsLiveSubscriptionDelayProvider = Provider( + (_) => defaultTaskDelay, +); diff --git a/mobile/lib/shared/relay/relay_closed_policy.dart b/mobile/lib/shared/relay/relay_closed_policy.dart index d39084b1897..9c147e86c79 100644 --- a/mobile/lib/shared/relay/relay_closed_policy.dart +++ b/mobile/lib/shared/relay/relay_closed_policy.dart @@ -3,6 +3,9 @@ enum RelayClosedClass { /// A transient failure that may recover when the same REQ is retried. retryable, + /// Relay capacity exhaustion that may recover after another REQ closes. + capacity, + /// Relay back-pressure that must also arm the shared request gate. rateLimited, @@ -16,6 +19,9 @@ RelayClosedClass classifyRelayClosed(String message) { if (normalized.startsWith('rate-limited:')) { return RelayClosedClass.rateLimited; } + if (normalized.startsWith('error: too many subscriptions')) { + return RelayClosedClass.capacity; + } if (normalized.startsWith('restricted:') || normalized.startsWith('auth-required:') || normalized.startsWith('blocked:') || @@ -23,8 +29,7 @@ RelayClosedClass classifyRelayClosed(String message) { normalized.startsWith('pow:') || normalized.startsWith('duplicate:') || normalized.startsWith('unsupported:') || - normalized.startsWith('error: mixed search') || - normalized.startsWith('error: too many subscriptions')) { + normalized.startsWith('error: mixed search')) { return RelayClosedClass.terminal; } return RelayClosedClass.retryable; diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index b8d13ee34a4..f9207f02a60 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -154,6 +154,7 @@ class RelaySessionNotifier extends Notifier { List filters, { Duration timeout = const Duration(seconds: 8), }) async { + if (_rateLimitGate.isActive) await _rateLimitGate.wait(); final config = ref.read(relayConfigProvider); final url = Uri.parse(config.baseUrl).resolve('/query').toString(); final bodyBytes = utf8.encode( @@ -747,6 +748,8 @@ class RelaySessionNotifier extends Notifier { ? fallbackMs : _rateLimitGate.remainingMs(), ); + } else if (closedClass == RelayClosedClass.capacity) { + delayMs = max(backoffMs, RelayRateLimitGate.defaultRetrySeconds * 1000); } liveSub.closedRetryAttempt = attempt + 1; diff --git a/mobile/test/features/channels/channel_sync_test.dart b/mobile/test/features/channels/channel_sync_test.dart new file mode 100644 index 00000000000..7a9f37f245d --- /dev/null +++ b/mobile/test/features/channels/channel_sync_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel_sync.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('query chunks preserve exact 100 boundary', () { + expect( + chunkChannelQueryItems(List.generate(100, (i) => i)).map((b) => b.length), + [100], + ); + expect( + chunkChannelQueryItems(List.generate(129, (i) => i)).map((b) => b.length), + [100, 29], + ); + }); + + test('paced admission captures one shared 125 ms interval', () async { + final delays = []; + final starts = []; + await runPacedTasks( + [for (var i = 0; i < 6; i++) () async => starts.add(i)], + maxConcurrent: liveSubscriptionMaxConcurrent, + startInterval: liveSubscriptionStartInterval, + isCancelled: () => false, + delay: (duration) async => delays.add(duration), + ); + expect(starts.toSet(), {0, 1, 2, 3, 4, 5}); + expect(delays, List.filled(5, const Duration(milliseconds: 125))); + }); + + test('start permits are globally serialized across workers', () async { + final pendingDelays = >[]; + var activeDelays = 0; + var peakActiveDelays = 0; + final run = runPacedTasks( + [for (var i = 0; i < 6; i++) () async {}], + maxConcurrent: liveSubscriptionMaxConcurrent, + startInterval: liveSubscriptionStartInterval, + isCancelled: () => false, + delay: (_) { + activeDelays++; + peakActiveDelays = activeDelays > peakActiveDelays + ? activeDelays + : peakActiveDelays; + final pending = Completer(); + pendingDelays.add(pending); + return pending.future.whenComplete(() => activeDelays--); + }, + ); + + for (var completed = 0; completed < 5; completed++) { + await _waitUntil(() => pendingDelays.length == completed + 1); + expect(activeDelays, 1); + pendingDelays[completed].complete(); + } + await run; + expect(peakActiveDelays, 1); + }); + + test('slow tasks never exceed four in flight', () async { + var inFlight = 0; + var peakInFlight = 0; + final releases = >[]; + final run = runPacedTasks( + [ + for (var i = 0; i < 12; i++) + () async { + inFlight++; + if (inFlight > peakInFlight) peakInFlight = inFlight; + final release = Completer(); + releases.add(release); + await release.future; + inFlight--; + }, + ], + maxConcurrent: liveSubscriptionMaxConcurrent, + startInterval: liveSubscriptionStartInterval, + isCancelled: () => false, + delay: (_) async {}, + ); + await _waitUntil(() => releases.length >= 4); + expect(peakInFlight, 4); + while (releases.length < 12) { + final count = releases.length; + for (final release in releases.take(count).where((c) => !c.isCompleted)) { + release.complete(); + } + await _waitUntil(() => releases.length > count || releases.length == 12); + } + for (final release in releases.where((c) => !c.isCompleted)) { + release.complete(); + } + await run; + expect(peakInFlight, 4); + }); +} + +Future _waitUntil(bool Function() predicate) async { + for (var i = 0; i < 100; i++) { + if (predicate()) return; + await Future.delayed(Duration.zero); + } + fail('condition did not become true'); +} diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 04f7cd917ba..3d4aed4c237 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/features/channels/channel_sync.dart'; import 'package:buzz/shared/relay/relay.dart'; /// Tests for [ChannelsNotifier] in the pure-Nostr world. @@ -1593,6 +1594,7 @@ void main() { addTearDown(container.dispose); await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 2); // One subscription per joined, non-archived channel. expect(session.subscribeFilters, hasLength(2)); @@ -1643,6 +1645,11 @@ void main() { addTearDown(container.dispose); await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 2); + // Active fake subscriptions become visible just before the notifier + // records their unsubscribe handles. Let that synchronous handoff settle + // before testing an unchanged refresh. + await Future.delayed(const Duration(milliseconds: 10)); final initialSubscribeCount = session.totalSubscribeCount; await container.read(channelsProvider.notifier).refresh(); @@ -1650,6 +1657,35 @@ void main() { expect(session.totalSubscribeCount, initialSubscribeCount); expect(session.unsubscribeCount, 0); expect(session.subscribeFilters, hasLength(2)); + + session.emit( + const NostrEvent( + id: 'retained-live-event', + pubkey: 'alice', + createdAt: 20, + kind: EventKind.streamMessageV2, + tags: [ + ['h', _channelA], + ], + content: 'after unchanged refresh', + sig: 'sig', + ), + ); + + final channelA = container + .read(channelsProvider) + .requireValue + .firstWhere((channel) => channel.id == _channelA); + expect( + channelA.lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(20 * 1000, isUtc: true), + ); + expect( + container + .read(channelsProvider.notifier) + .observedUnreadEventsByChannel[_channelA], + contains('retained-live-event'), + ); }, ); @@ -1670,6 +1706,7 @@ void main() { addTearDown(container.dispose); await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 2); session.memberships = [ _membership(_channelB, myPk), _membership(_channelD, myPk), @@ -1709,6 +1746,7 @@ void main() { addTearDown(container.dispose); await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 2); session.memberships = []; session.metadata = []; @@ -1900,6 +1938,196 @@ void main() { }, ); + test('loaded refresh preserves a newer live timestamp', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + recentMessages: [ + NostrEvent( + id: 'snapshot', + pubkey: 'alice', + createdAt: 10, + kind: EventKind.streamMessageV2, + tags: const [ + ['h', _channelA], + ], + content: 'snapshot', + sig: 'sig', + ), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 1); + session.pauseNextLatestMessageQuery(); + final refresh = container.read(channelsProvider.notifier).refresh(); + await session.nextLatestMessageQueryStarted; + session.emit( + NostrEvent( + id: 'live', + pubkey: 'alice', + createdAt: 50, + kind: EventKind.streamMessageV2, + tags: const [ + ['h', _channelA], + ], + content: 'live', + sig: 'sig', + ), + ); + session.resumePausedLatestMessageQuery(); + await refresh; + + expect( + container.read(channelsProvider).value!.single.lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(50 * 1000, isUtc: true), + ); + }); + + test( + 'chunks latest-message and unread queries at exactly 100 channels', + () async { + final ids = [for (var i = 0; i < 100; i++) 'channel-$i']; + final session = _FakeRelaySession( + memberships: [for (final id in ids) _membership(id, myPk)], + metadata: [for (final id in ids) _meta(id: id, name: id)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await _waitUntil(() => session.queryBatches.length == 2); + + expect(session.queryBatches.map((batch) => batch.length), [100, 100]); + expect( + session.queryBatches.first.every((filter) => filter.since == null), + isTrue, + ); + expect( + session.queryBatches.last.every((filter) => filter.since != null), + isTrue, + ); + }, + ); + + test( + 'never-EOSE workers release and allow later subscriptions and catch-up', + () async { + final ids = [for (var i = 0; i < 5; i++) 'channel-$i']; + final session = _FakeRelaySession( + memberships: [for (final id in ids) _membership(id, myPk)], + metadata: [for (final id in ids) _meta(id: id, name: id)], + neverEoseSubscribeCount: 4, + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final stopwatch = Stopwatch()..start(); + await container.read(channelsProvider.future); + await _waitUntil(() => session.queryBatches.length == 2); + stopwatch.stop(); + + expect( + stopwatch.elapsed, + greaterThanOrEqualTo(const Duration(milliseconds: 450)), + ); + expect(stopwatch.elapsed, lessThan(const Duration(seconds: 2))); + expect(session.peakNeverEoseSubscriptions, 4); + expect(session.totalSubscribeCount, 5); + expect(session.activeChannels, ids.toSet()); + expect(session.queryBatches.last, hasLength(5)); + expect( + session.queryBatches.last.every((filter) => filter.since != null), + isTrue, + ); + }, + ); + + test( + 'chunks latest-message and unread queries at 100 for 129 channels', + () async { + final ids = [for (var i = 0; i < 129; i++) 'channel-$i']; + final session = _FakeRelaySession( + memberships: [for (final id in ids) _membership(id, myPk)], + metadata: [for (final id in ids) _meta(id: id, name: id)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await _waitUntil(() => session.queryBatches.length == 4); + + expect(session.queryBatches.map((batch) => batch.length), [ + 100, + 29, + 100, + 29, + ]); + expect( + session.historyFilters.where( + (filter) => filter.kinds.toSet().containsAll( + EventKind.channelMessageEventKinds, + ), + ), + isEmpty, + ); + }, + ); + + test('initial list retains a live event overlapping its snapshot', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + recentMessages: [ + NostrEvent( + id: 'snapshot', + pubkey: 'alice', + createdAt: 10, + kind: EventKind.streamMessageV2, + tags: const [ + ['h', _channelA], + ], + content: 'snapshot', + sig: 'sig', + ), + ], + )..pauseNextLatestMessageQuery(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final channelsFuture = container.read(channelsProvider.future); + await session.nextLatestMessageQueryStarted; + await _waitUntil(() => session.activeSubscriptionCount == 1); + final live = NostrEvent( + id: 'live', + pubkey: 'alice', + createdAt: 20, + kind: EventKind.streamMessageV2, + tags: const [ + ['h', _channelA], + ], + content: 'live', + sig: 'sig', + ); + session.emit(live); + session.emit(live); + session.resumePausedLatestMessageQuery(); + + final channels = await channelsFuture; + expect( + channels.single.lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(20 * 1000, isUtc: true), + ); + expect( + container + .read(channelsProvider.notifier) + .observedUnreadEventsByChannel[_channelA], + contains('live'), + ); + }); + test('ephemeral (TTL) channels appear in the list', () async { // Regression: previously the provider unconditionally dropped any channel // with a `ttl` tag, which made TTL channels invisible on iOS even when the @@ -2207,6 +2435,54 @@ void main() { }, ); + test( + 'same channel id does not inherit a timestamp across communities', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'community-a')], + recentMessages: [ + _message(id: 'a-message', channelId: _channelA, createdAt: 50), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect( + (await container.read( + channelsProvider.future, + )).single.lastMessageAt?.millisecondsSinceEpoch, + 50 * 1000, + ); + + session.setStatus(SessionStatus.disconnected); + session.metadata = [_meta(id: _channelA, name: 'community-b')]; + session.recentMessages = [ + _message(id: 'b-message', channelId: _channelA, createdAt: 10), + ]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://new-community.example'); + await Future.delayed(Duration.zero); + session.setStatus(SessionStatus.connected); + await _waitUntil( + () => + container.read(channelsProvider).value?.single.name == + 'community-b', + ); + + expect( + container + .read(channelsProvider) + .value! + .single + .lastMessageAt + ?.millisecondsSinceEpoch, + 10 * 1000, + ); + }, + ); + test( 'refreshes cached channels after a disconnected community switch', () async { @@ -2341,6 +2617,22 @@ NostrEvent _hiddenDms(List channelIds, {required String pubkey}) => sig: 'sig', ); +NostrEvent _message({ + required String id, + required String channelId, + required int createdAt, +}) => NostrEvent( + id: id, + pubkey: 'alice', + createdAt: createdAt, + kind: EventKind.streamMessageV2, + tags: [ + ['h', channelId], + ], + content: 'message', + sig: 'sig', +); + /// Build a kind:39000 channel metadata event. NostrEvent _meta({ required String id, @@ -2367,7 +2659,10 @@ NostrEvent _meta({ sig: 'sig', ); -ProviderContainer _buildContainer({required _FakeRelaySession session}) { +ProviderContainer _buildContainer({ + required _FakeRelaySession session, + TaskDelay liveSubscriptionDelay = _noDelay, +}) { return ProviderContainer( retry: (_, _) => null, overrides: [ @@ -2376,6 +2671,9 @@ ProviderContainer _buildContainer({required _FakeRelaySession session}) { // Route the pubkey through a mutable notifier so tests can switch the // signing identity mid-flight the way an account change does at runtime. myPubkeyProvider.overrideWith((ref) => ref.watch(_testPubkeyProvider)), + channelsLiveSubscriptionDelayProvider.overrideWithValue( + liveSubscriptionDelay, + ), ], ); } @@ -2402,7 +2700,7 @@ Future _settle() async { Future _waitUntil(bool Function() predicate) async { for (var i = 0; i < 100; i++) { if (predicate()) return; - await Future.delayed(Duration.zero); + await Future.delayed(const Duration(milliseconds: 5)); } fail('Timed out waiting for asynchronous provider work'); } @@ -2424,6 +2722,7 @@ class _FakeRelaySession extends RelaySessionNotifier { this.huddleStarts = const [], this.recentMessages = const [], this.membershipFailures = 0, + this.neverEoseSubscribeCount = 0, }); List memberships; @@ -2439,6 +2738,12 @@ class _FakeRelaySession extends RelaySessionNotifier { final List huddleStarts; List recentMessages; int membershipFailures; + final int neverEoseSubscribeCount; + int activeNeverEoseSubscriptions = 0; + int peakNeverEoseSubscriptions = 0; + int statusSubscribeAttempts = 0; + Completer? _pausedLatestMessageQuery; + Completer? _latestMessageQueryStarted; int directoryFailures = 0; bool failClaimedMemberCountQuery = false; bool failClaimedUnreadCatchUpQuery = false; @@ -2485,6 +2790,21 @@ class _FakeRelaySession extends RelaySessionNotifier { await started.future; } + Future get nextLatestMessageQueryStarted async { + final started = _latestMessageQueryStarted; + if (started == null) throw StateError('No latest-message query pending'); + await started.future; + } + + void pauseNextLatestMessageQuery() { + _pausedLatestMessageQuery = Completer(); + _latestMessageQueryStarted = Completer(); + } + + void resumePausedLatestMessageQuery() { + _pausedLatestMessageQuery!.complete(); + } + void pauseNextSubscribe() { if (_pausedSubscribe != null) { throw StateError('A subscription is already paused'); @@ -2793,6 +3113,16 @@ class _FakeRelaySession extends RelaySessionNotifier { return filter.until == null ? directorySnapshot : const []; } queryBatches.add(filters); + final isLatestMessage = + filters.isNotEmpty && filters.every((filter) => filter.since == null); + if (isLatestMessage && _pausedLatestMessageQuery != null) { + final messages = List.of(recentMessages); + _latestMessageQueryStarted!.complete(); + await _pausedLatestMessageQuery!.future; + _pausedLatestMessageQuery = null; + _latestMessageQueryStarted = null; + return _matchingMessages(filters, messages); + } // The unread catch-up is the only batch that carries `since` on every // filter; the latest-message batch leaves it null. Snapshot the messages at // request time so a parked response reflects the scope that asked for it. @@ -2814,7 +3144,14 @@ class _FakeRelaySession extends RelaySessionNotifier { } } } - return messageSnapshot.where((event) { + return _matchingMessages(filters, messageSnapshot); + } + + List _matchingMessages( + List filters, + List messages, + ) { + return messages.where((event) { return filters.any((filter) { if (!filter.kinds.contains(event.kind)) return false; for (final entry in filter.tags.entries) { @@ -2835,6 +3172,26 @@ class _FakeRelaySession extends RelaySessionNotifier { }).toList(); } + @override + Future subscribeWithStatus( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + required void Function(RelaySubscriptionStatus status) onStatusChanged, + }) async { + final shouldAwaitReadiness = + statusSubscribeAttempts++ < neverEoseSubscribeCount; + if (shouldAwaitReadiness) { + activeNeverEoseSubscriptions++; + if (activeNeverEoseSubscriptions > peakNeverEoseSubscriptions) { + peakNeverEoseSubscriptions = activeNeverEoseSubscriptions; + } + await Future.delayed(const Duration(milliseconds: 500)); + activeNeverEoseSubscriptions--; + } + return subscribe(filter, onEvent, onClosed: onClosed); + } + @override Future subscribe( NostrFilter filter, @@ -2876,3 +3233,5 @@ class _FakeAppLifecycleNotifier extends AppLifecycleNotifier { @override AppLifecycleState build() => AppLifecycleState.resumed; } + +Future _noDelay(Duration _) async {} diff --git a/mobile/test/shared/relay/relay_closed_policy_test.dart b/mobile/test/shared/relay/relay_closed_policy_test.dart index ff78ea89ef8..cd120e89c7a 100644 --- a/mobile/test/shared/relay/relay_closed_policy_test.dart +++ b/mobile/test/shared/relay/relay_closed_policy_test.dart @@ -15,7 +15,7 @@ void main() { 'duplicate: subscription already exists': RelayClosedClass.terminal, 'unsupported: filter extension': RelayClosedClass.terminal, 'error: mixed search and channel filter': RelayClosedClass.terminal, - 'error: too many subscriptions': RelayClosedClass.terminal, + 'error: too many subscriptions': RelayClosedClass.capacity, 'error: relay temporarily unavailable': RelayClosedClass.retryable, 'subscription closed by relay': RelayClosedClass.retryable, }; diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index c6ae6e9bd8a..ca44bcd7017 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -331,7 +331,7 @@ void main() { expect(gate.isActive, isFalse); }); - test('queryRelay does not wait for an active rate-limit gate', () async { + test('queryRelay waits for an active rate-limit gate', () async { final gate = RelayRateLimitGate( now: () => DateTime(2026), timerFactory: _ManualTimer.new, @@ -356,10 +356,10 @@ void main() { final query = harness.session.queryRelay(const []); await Future.delayed(Duration.zero); - expect(requestCount, 1); + expect(requestCount, 0); + gate.reset(); expect(await query, isEmpty); - // Still armed: the read must neither wait on the gate nor clear it. - expect(gate.isActive, isTrue); + expect(requestCount, 1); }); test( @@ -699,6 +699,26 @@ void main() { unsubscribe(); }); + test( + 'live subscribe without EOSE releases readiness after its bound', + () async { + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier(); + session.debugAttachSocketForTest(socket); + final stopwatch = Stopwatch()..start(); + + final unsubscribe = await session.subscribe(_channelFilter, (_) {}); + + expect( + stopwatch.elapsed, + greaterThanOrEqualTo(const Duration(milliseconds: 450)), + ); + expect(stopwatch.elapsed, lessThan(const Duration(seconds: 2))); + expect(_reqs(socket), hasLength(1)); + unsubscribe(); + }, + ); + test('terminal CLOSED fails a live subscribe before ready', () async { final session = RelaySessionNotifier(); const filter = NostrFilter(kinds: [EventKind.agentObserverFrame], limit: 0); @@ -998,6 +1018,44 @@ void main() { expect(disposeTimer.isActive, isFalse); }); + test( + 'capacity CLOSED retries after ten seconds without arming gate', + () async { + final retryTimers = <_ManualTimer>[]; + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + final session = RelaySessionNotifier( + rateLimitGate: gate, + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + retryTimers.add(timer); + return timer; + }, + ); + final socket = _RecordingRelaySocket(); + session.debugAttachSocketForTest(socket); + final subscribe = session.subscribe(_channelFilter, (_) {}); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + + session.debugHandleMessage([ + 'CLOSED', + 'l-1', + 'error: too many subscriptions', + ]); + + expect(retryTimers.single.duration, const Duration(seconds: 10)); + expect(gateTimers, isEmpty); + unsubscribe(); + }, + ); + test('rate-limited live CLOSED honours the gate floor', () async { final retryTimers = <_ManualTimer>[]; final gateTimers = <_ManualTimer>[];