mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Tom Brow
Codex
parent
4e7cefc4e6
commit
acdb264e65
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user