From acdb264e657ea73b8afe30c2adf830271effa171 Mon Sep 17 00:00:00 2001 From: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 17:10:25 -0700 Subject: [PATCH] fix(mobile): stop stalled channel discovery Co-authored-by: Tom Brow Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- .../features/channels/channels_provider.dart | 32 +++++-- .../channels/channels_provider_test.dart | 83 ++++++++++++++++++- 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index d9c8c0448..4b8fa8031 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -19,6 +19,8 @@ import 'unread_badge/should_notify_for_event.dart'; const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2}; const _unreadCatchUpLimit = 1000; +const _channelDiscoveryPageSize = 500; +const _maxChannelDiscoveryIterations = 100; const _participatedRootIdsPrefix = 'buzz-thread-participation.v1'; const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; @@ -160,27 +162,45 @@ class ChannelsNotifier extends AsyncNotifier> { // Step 3: discover relay-visible open channels without fabricating // membership. The relay withholds private and DM metadata from this query, // while the client-side checks below keep that trust boundary explicit. - // Page with the relay's composite cursor so tied timestamps cannot skip - // channels and communities larger than one relay page remain complete. + // The WebSocket relay can ignore the composite cursor and repeat a full + // tied-timestamp page, so termination depends on seeing a new channel id. final discoverableMetas = []; { + final seenChannelIds = {}; int? until; String? beforeId; - const pageSize = 500; - while (true) { + for ( + var iteration = 0; + iteration < _maxChannelDiscoveryIterations; + iteration++ + ) { final page = await session.fetchHistory( NostrFilter( kinds: const [39000], - limit: pageSize, + limit: _channelDiscoveryPageSize, until: until, extensions: {'before_id': ?beforeId}, ), ); discoverableMetas.addAll(page); - if (page.length < pageSize) break; + var madeProgress = false; + for (final event in page) { + final channelId = event.getTagValue('d'); + if (channelId != null && seenChannelIds.add(channelId)) { + madeProgress = true; + } + } + if (!madeProgress || page.length < _channelDiscoveryPageSize) break; final last = page.last; until = last.createdAt; beforeId = last.id; + + if (iteration == _maxChannelDiscoveryIterations - 1) { + throw StateError( + 'Channel discovery exceeded ' + '$_maxChannelDiscoveryIterations iterations', + ); + } } } diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 991e513dc..8c50a5eaf 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -132,6 +132,65 @@ void main() { expect(discoveryFilters.last.extensions['before_id'], firstPage.last.id); }); + test('stops discovery when the relay repeats a full page', () async { + final repeatedPage = List.generate( + 500, + (index) => _meta( + id: 'repeated-channel-$index', + name: 'repeated-$index', + createdAt: 10, + ), + ); + final session = _FakeRelaySession( + memberships: const [], + metadataPages: [repeatedPage], + repeatLastMetadataPage: true, + maxMetadataPageRequests: 2, + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final channels = await container.read(channelsProvider.future); + + expect(channels, hasLength(500)); + final discoveryFilters = session.historyFilters + .where( + (filter) => + filter.kinds.length == 1 && + filter.kinds.single == 39000 && + !filter.tags.containsKey('#d'), + ) + .toList(); + expect(discoveryFilters, hasLength(2)); + }); + + test('fails loudly when discovery exceeds its iteration cap', () async { + final session = _FakeRelaySession( + memberships: const [], + metadataPageBuilder: (pageIndex) => List.generate( + 500, + (eventIndex) => _meta( + id: 'channel-$pageIndex-$eventIndex', + name: 'channel-$pageIndex-$eventIndex', + createdAt: 1000 - pageIndex, + ), + ), + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await expectLater( + container.read(channelsProvider.future), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Channel discovery exceeded'), + ), + ), + ); + }); + test('deduplicates joined channels from open-channel discovery', () async { final session = _FakeRelaySession( memberships: [_membership(_channelA, myPk)], @@ -536,6 +595,9 @@ class _FakeRelaySession extends RelaySessionNotifier { required this.memberships, this.metadata = const [], this.metadataPages, + this.metadataPageBuilder, + this.repeatLastMetadataPage = false, + this.maxMetadataPageRequests, this.hiddenDmEvents = const [], this.membershipFailures = 0, }); @@ -543,6 +605,9 @@ class _FakeRelaySession extends RelaySessionNotifier { List memberships; List metadata; final List>? metadataPages; + final List Function(int pageIndex)? metadataPageBuilder; + final bool repeatLastMetadataPage; + final int? maxMetadataPageRequests; final List hiddenDmEvents; int membershipFailures; int _metadataPageIndex = 0; @@ -585,10 +650,24 @@ class _FakeRelaySession extends RelaySessionNotifier { // fake to pre-filter them. final ids = filter.tags['#d']?.toSet(); if (ids == null) { + final requestIndex = _metadataPageIndex++; + final maxRequests = maxMetadataPageRequests; + if (maxRequests != null && requestIndex >= maxRequests) { + throw StateError( + 'Unexpected discovery page request ${requestIndex + 1}', + ); + } + final builder = metadataPageBuilder; + if (builder != null) return List.of(builder(requestIndex)); final pages = metadataPages; if (pages != null) { - if (_metadataPageIndex >= pages.length) return const []; - return List.of(pages[_metadataPageIndex++]); + if (requestIndex < pages.length) { + return List.of(pages[requestIndex]); + } + if (repeatLastMetadataPage && pages.isNotEmpty) { + return List.of(pages.last); + } + return const []; } return List.of(metadata); }