fix(mobile): stop stalled channel discovery

Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
This commit is contained in:
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp
2026-08-01 17:10:25 -07:00
co-authored by Tom Brow Codex
parent 4e7cefc4e6
commit acdb264e65
2 changed files with 107 additions and 8 deletions
@@ -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<List<Channel>> {
// 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 = <NostrEvent>[];
{
final seenChannelIds = <String>{};
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',
);
}
}
}
@@ -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<StateError>().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<NostrEvent> memberships;
List<NostrEvent> metadata;
final List<List<NostrEvent>>? metadataPages;
final List<NostrEvent> Function(int pageIndex)? metadataPageBuilder;
final bool repeatLastMetadataPage;
final int? maxMetadataPageRequests;
final List<NostrEvent> 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);
}