mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
fix(mobile): bound startup channel history load
Aggregate initial last-message discovery through bounded HTTP query chunks, and pace unread catch-up with cancellation while preserving per-channel live subscriptions. Add regression coverage for relay routing scope. Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -1544,6 +1544,60 @@ mod tests {
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracted_channel_scope_drives_live_fan_out() {
|
||||
use crate::subscription::SubscriptionRegistry;
|
||||
use buzz_core::StoredEvent;
|
||||
use chrono::Utc;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
fn stored_channel_event(channel_id: uuid::Uuid) -> StoredEvent {
|
||||
let event = EventBuilder::new(Kind::TextNote, "test")
|
||||
.tags([Tag::parse(["h", &channel_id.to_string()]).expect("valid h tag")])
|
||||
.sign_with_keys(&Keys::generate())
|
||||
.expect("sign");
|
||||
StoredEvent::with_received_at(event, Utc::now(), Some(channel_id), true)
|
||||
}
|
||||
|
||||
let channel_a = uuid::Uuid::new_v4();
|
||||
let channel_b = uuid::Uuid::new_v4();
|
||||
let channel_a_filter = filter_with_channel(channel_a).kind(Kind::TextNote);
|
||||
let registry = SubscriptionRegistry::new();
|
||||
let conn_id = uuid::Uuid::new_v4();
|
||||
registry.register(
|
||||
conn_id,
|
||||
"channel-a".into(),
|
||||
vec![channel_a_filter.clone()],
|
||||
extract_channel_id_from_filters(std::slice::from_ref(&channel_a_filter)),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
registry.fan_out(&stored_channel_event(channel_a)),
|
||||
vec![(conn_id, "channel-a".into())],
|
||||
);
|
||||
assert!(registry
|
||||
.fan_out(&stored_channel_event(channel_b))
|
||||
.is_empty());
|
||||
|
||||
let aggregate_filter = Filter::new().kind(Kind::TextNote).custom_tags(
|
||||
SingleLetterTag::lowercase(Alphabet::H),
|
||||
[channel_a.to_string(), channel_b.to_string()],
|
||||
);
|
||||
let aggregate_registry = SubscriptionRegistry::new();
|
||||
aggregate_registry.register(
|
||||
uuid::Uuid::new_v4(),
|
||||
"aggregate".into(),
|
||||
vec![aggregate_filter.clone()],
|
||||
extract_channel_id_from_filters(&[aggregate_filter]),
|
||||
);
|
||||
assert!(aggregate_registry
|
||||
.fan_out(&stored_channel_event(channel_a))
|
||||
.is_empty(),);
|
||||
assert!(aggregate_registry
|
||||
.fan_out(&stored_channel_event(channel_b))
|
||||
.is_empty(),);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_channel_id_single_channel() {
|
||||
let channel_id = uuid::Uuid::new_v4();
|
||||
|
||||
@@ -20,6 +20,10 @@ import 'unread_badge/should_notify_for_event.dart';
|
||||
|
||||
const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2};
|
||||
const _unreadCatchUpLimit = 1000;
|
||||
const _lastMessageQueryBatchSize = 100;
|
||||
const _lastMessageQueryTotalTimeout = Duration(seconds: 8);
|
||||
const _unreadCatchUpConcurrency = 4;
|
||||
const _unreadCatchUpStartInterval = Duration(milliseconds: 125);
|
||||
const _participatedRootIdsPrefix = 'buzz-thread-participation.v1';
|
||||
const _authoredRootIdsPrefix = 'buzz-thread-authored.v1';
|
||||
|
||||
@@ -36,13 +40,19 @@ const _authoredRootIdsPrefix = 'buzz-thread-authored.v1';
|
||||
class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
static const _backstopInterval = Duration(seconds: 60);
|
||||
|
||||
final Map<String, void Function()> _unsubscribersByChannel = {};
|
||||
final Map<String, ({Object attempt, void Function() unsubscribe})>
|
||||
_liveSubscriptionsByChannel = {};
|
||||
final Map<String, Completer<void>> _pendingSubscriptionCancellations = {};
|
||||
Future<void> _liveSubscriptionQueue = Future.value();
|
||||
List<Channel> _desiredLiveChannels = const [];
|
||||
Set<String> _desiredLiveChannelIds = const {};
|
||||
int _subscriptionVersion = 0;
|
||||
String? _subscriptionRelayBaseUrl;
|
||||
Timer? _backstopTimer;
|
||||
int _unreadCatchUpGeneration = 0;
|
||||
Set<String> _unreadCatchUpChannelIds = const {};
|
||||
bool _bufferingBootstrapLiveEvents = false;
|
||||
final List<NostrEvent> _bootstrapLiveEvents = [];
|
||||
final Map<String, int> _latestObservedByChannel = {};
|
||||
final Map<String, Map<String, ObservedUnreadEvent>>
|
||||
_observedUnreadEventsByChannel = {};
|
||||
@@ -86,7 +96,17 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
final waitingForInitialConnection =
|
||||
sessionState.status != SessionStatus.connected;
|
||||
ref.listen(relaySessionProvider, (previous, next) {
|
||||
if (next.status != SessionStatus.connected) return;
|
||||
if (next.status != SessionStatus.connected) {
|
||||
_cancelPendingLiveSubscriptions();
|
||||
if (previous?.status == SessionStatus.connected) {
|
||||
// In-flight history requests cannot always be aborted at the transport
|
||||
// boundary. Invalidate their generation so late responses are ignored,
|
||||
// and clear the completed-set marker so reconnect catch-up restarts.
|
||||
_unreadCatchUpGeneration++;
|
||||
_unreadCatchUpChannelIds = const {};
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (waitingForInitialConnection &&
|
||||
!_hasLoaded &&
|
||||
!connected.isCompleted) {
|
||||
@@ -110,6 +130,10 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
_observedUnreadEventsByChannel.clear();
|
||||
_backstopTimer?.cancel();
|
||||
_backstopTimer = null;
|
||||
_unreadCatchUpGeneration++;
|
||||
_unreadCatchUpChannelIds = const {};
|
||||
_bufferingBootstrapLiveEvents = false;
|
||||
_bootstrapLiveEvents.clear();
|
||||
});
|
||||
|
||||
if (sessionState.status != SessionStatus.connected) {
|
||||
@@ -273,6 +297,26 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Establish live callbacks before the bounded HTTP snapshot. During initial
|
||||
// build there is no published channel list to mutate, so buffer callbacks
|
||||
// and replay them onto the completed snapshot before returning it.
|
||||
final bufferBootstrapLiveEvents = subscribeLive && !_hasLoaded;
|
||||
if (bufferBootstrapLiveEvents) {
|
||||
_bootstrapLiveEvents.clear();
|
||||
_bufferingBootstrapLiveEvents = true;
|
||||
}
|
||||
Future<void>? bootstrapLiveSync;
|
||||
if (subscribeLive) {
|
||||
if (bufferBootstrapLiveEvents) {
|
||||
// Subscription admission is deliberately paced and may take longer than
|
||||
// the bounded snapshot. Run both concurrently; any not-yet-ready channel
|
||||
// is covered by unread catch-up after the live pass completes.
|
||||
bootstrapLiveSync = _subscribeLive(channels);
|
||||
} else {
|
||||
await _subscribeLive(channels);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -283,29 +327,38 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
for (final channel in channels)
|
||||
if (channel.isMember && !channel.isArchived) channel,
|
||||
];
|
||||
final channelById = {
|
||||
for (final channel in activeChannels) channel.id: channel,
|
||||
};
|
||||
final events = await _fetchLastMessageEvents(session, activeChannels);
|
||||
final lastMessageMap = <String, int>{};
|
||||
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;
|
||||
|
||||
// Keep the HTTP request bounded for very large accounts. Each batch still
|
||||
// replaces hundreds of simultaneous WebSocket history subscriptions with
|
||||
// one authenticated /query request, while a failed batch does not erase
|
||||
// successful results from the others. The whole sequence shares the same
|
||||
// eight-second startup budget as the former individual history requests,
|
||||
// so a slow relay cannot delay live subscription setup once per batch.
|
||||
final queryStopwatch = Stopwatch()..start();
|
||||
for (final batch in chunkChannelsForLastMessageQuery(activeChannels)) {
|
||||
final remaining =
|
||||
_lastMessageQueryTotalTimeout - queryStopwatch.elapsed;
|
||||
if (remaining <= Duration.zero) {
|
||||
debugPrint('[ChannelsNotifier] last-message query budget exhausted');
|
||||
break;
|
||||
}
|
||||
final current = lastMessageMap[channelId];
|
||||
if (current == null || event.createdAt > current) {
|
||||
lastMessageMap[channelId] = event.createdAt;
|
||||
try {
|
||||
final events = await session.queryRelay(
|
||||
buildChannelLastMessageFilters(batch),
|
||||
timeout: remaining,
|
||||
);
|
||||
lastMessageMap.addAll(
|
||||
resolveChannelLastMessages(
|
||||
batch,
|
||||
events,
|
||||
myPk: myPk,
|
||||
mutedChannelIds: mutedChannelIds,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
debugPrint('[ChannelsNotifier] last-message batch failed: $error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +375,21 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
}
|
||||
}
|
||||
|
||||
// Never let a refresh snapshot roll back a newer callback already applied
|
||||
// to the published list while the bounded HTTP batches were in flight.
|
||||
final publishedLastMessage = {
|
||||
for (final channel in state.value ?? const <Channel>[])
|
||||
if (channel.lastMessageAt != null) channel.id: channel.lastMessageAt!,
|
||||
};
|
||||
for (var i = 0; i < channels.length; i++) {
|
||||
final published = publishedLastMessage[channels[i].id];
|
||||
if (published != null &&
|
||||
(channels[i].lastMessageAt == null ||
|
||||
published.isAfter(channels[i].lastMessageAt!))) {
|
||||
channels[i] = channels[i].copyWith(lastMessageAt: published);
|
||||
}
|
||||
}
|
||||
|
||||
channels.sort((left, right) {
|
||||
final typeOrder =
|
||||
(_channelTypeOrder[left.channelType] ?? 99) -
|
||||
@@ -351,8 +419,22 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
}
|
||||
}
|
||||
|
||||
if (subscribeLive) {
|
||||
await _subscribeLive(channels);
|
||||
if (bufferBootstrapLiveEvents) {
|
||||
// Publish immediately after the bounded snapshot. Events from live REQs
|
||||
// already accepted are replayed now; later callbacks update published
|
||||
// state normally. The paced live pass continues in the background and
|
||||
// starts unread catch-up when complete, recovering the gap for channels
|
||||
// whose REQ was not accepted before their snapshot batch completed.
|
||||
_bufferingBootstrapLiveEvents = false;
|
||||
for (final event in _bootstrapLiveEvents) {
|
||||
_applyLiveEventToChannels(channels, event, myPk);
|
||||
}
|
||||
_bootstrapLiveEvents.clear();
|
||||
if (channels.length <= _lastMessageQueryBatchSize) {
|
||||
await bootstrapLiveSync;
|
||||
} else {
|
||||
unawaited(bootstrapLiveSync!);
|
||||
}
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
@@ -391,40 +473,13 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
_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<List<NostrEvent>> _fetchLastMessageEvents(
|
||||
RelaySessionNotifier session,
|
||||
List<Channel> 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<List<NostrEvent>> _fetchChannelHistoryBatch(
|
||||
RelaySessionNotifier session,
|
||||
List<NostrFilter> filters, {
|
||||
required String operation,
|
||||
required bool Function() isCancelled,
|
||||
}) async {
|
||||
if (filters.isEmpty) return const [];
|
||||
if (filters.isEmpty || isCancelled()) return const [];
|
||||
|
||||
try {
|
||||
return await session.queryRelay(filters);
|
||||
@@ -438,6 +493,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
const fallbackConcurrency = 4;
|
||||
final events = <NostrEvent>[];
|
||||
for (var start = 0; start < filters.length; start += fallbackConcurrency) {
|
||||
if (isCancelled()) break;
|
||||
final end = min(start + fallbackConcurrency, filters.length);
|
||||
final results = await Future.wait(
|
||||
filters.sublist(start, end).map((filter) async {
|
||||
@@ -448,6 +504,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (isCancelled()) break;
|
||||
for (final result in results) {
|
||||
events.addAll(result);
|
||||
}
|
||||
@@ -535,6 +592,16 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
if (channel.isMember && !channel.isArchived) channel.id,
|
||||
};
|
||||
final relayBaseUrl = ref.read(relayConfigProvider).baseUrl;
|
||||
if (!_sameStringSet(_unreadCatchUpChannelIds, channelIds)) {
|
||||
// Stop admitting work from the old channel set while its replacement
|
||||
// live subscriptions are being established.
|
||||
_unreadCatchUpGeneration++;
|
||||
}
|
||||
for (final entry in _pendingSubscriptionCancellations.entries.toList()) {
|
||||
if (channelIds.contains(entry.key)) continue;
|
||||
_pendingSubscriptionCancellations.remove(entry.key);
|
||||
if (!entry.value.isCompleted) entry.value.complete();
|
||||
}
|
||||
_desiredLiveChannels = channels;
|
||||
_desiredLiveChannelIds = channelIds;
|
||||
final subscriptionVersion = ++_subscriptionVersion;
|
||||
@@ -556,7 +623,8 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
int subscriptionVersion,
|
||||
List<Channel> channels,
|
||||
) async {
|
||||
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
|
||||
if (!ref.mounted ||
|
||||
ref.read(relaySessionProvider).status != SessionStatus.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -570,10 +638,14 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
}
|
||||
|
||||
if (_subscriptionRelayBaseUrl != relayBaseUrl) {
|
||||
for (final unsubscribe in _unsubscribersByChannel.values) {
|
||||
unsubscribe();
|
||||
for (final cancellation in _pendingSubscriptionCancellations.values) {
|
||||
if (!cancellation.isCompleted) cancellation.complete();
|
||||
}
|
||||
_unsubscribersByChannel.clear();
|
||||
_pendingSubscriptionCancellations.clear();
|
||||
for (final subscription in _liveSubscriptionsByChannel.values) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
_liveSubscriptionsByChannel.clear();
|
||||
_subscriptionRelayBaseUrl = relayBaseUrl;
|
||||
}
|
||||
if (ref.read(relayConfigProvider).baseUrl != relayBaseUrl) {
|
||||
@@ -582,63 +654,117 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final channelIds = _desiredLiveChannelIds;
|
||||
|
||||
for (final entry in _unsubscribersByChannel.entries.toList()) {
|
||||
for (final entry in _liveSubscriptionsByChannel.entries.toList()) {
|
||||
if (channelIds.contains(entry.key)) continue;
|
||||
_unsubscribersByChannel.remove(entry.key);
|
||||
entry.value();
|
||||
_liveSubscriptionsByChannel.remove(entry.key);
|
||||
entry.value.unsubscribe();
|
||||
}
|
||||
|
||||
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 (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;
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[ChannelsNotifier] live subscription failed for $channelId: $error',
|
||||
);
|
||||
}
|
||||
}
|
||||
final newChannelIds = [
|
||||
for (final channelId in channelIds)
|
||||
if (!_liveSubscriptionsByChannel.containsKey(channelId)) channelId,
|
||||
];
|
||||
final subscribeTasks = <Future<void> Function()>[
|
||||
for (final channelId in newChannelIds)
|
||||
() async {
|
||||
if (!ref.mounted ||
|
||||
ref.read(relaySessionProvider).status !=
|
||||
SessionStatus.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final cancellation = Completer<void>();
|
||||
final attempt = Object();
|
||||
var terminallyClosed = false;
|
||||
_pendingSubscriptionCancellations[channelId] = cancellation;
|
||||
final unsubscribe = await session.subscribeWhenReady(
|
||||
NostrFilter(
|
||||
kinds: EventKind.channelEventKinds,
|
||||
tags: {
|
||||
'#h': [channelId],
|
||||
},
|
||||
limit: 0,
|
||||
),
|
||||
_handleLiveEvent,
|
||||
onClosed: (_) {
|
||||
terminallyClosed = true;
|
||||
final current = _liveSubscriptionsByChannel[channelId];
|
||||
if (current?.attempt != attempt) return;
|
||||
_liveSubscriptionsByChannel.remove(channelId);
|
||||
_unreadCatchUpChannelIds = Set.unmodifiable(
|
||||
_unreadCatchUpChannelIds.difference({channelId}),
|
||||
);
|
||||
},
|
||||
cancelled: cancellation.future,
|
||||
);
|
||||
_pendingSubscriptionCancellations.remove(channelId);
|
||||
if (terminallyClosed) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
if (!ref.mounted) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
if (ref.read(relaySessionProvider).status !=
|
||||
SessionStatus.connected ||
|
||||
!_desiredLiveChannelIds.contains(channelId) ||
|
||||
ref.read(relayConfigProvider).baseUrl != relayBaseUrl ||
|
||||
_subscriptionRelayBaseUrl != relayBaseUrl) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
final replaced = _liveSubscriptionsByChannel[channelId];
|
||||
if (replaced != null) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
_liveSubscriptionsByChannel[channelId] = (
|
||||
attempt: attempt,
|
||||
unsubscribe: unsubscribe,
|
||||
);
|
||||
} on RelaySubscriptionCancelledException {
|
||||
// The desired channel set, relay, or provider lifetime changed.
|
||||
} catch (error) {
|
||||
debugPrint(
|
||||
'[ChannelsNotifier] live subscription failed for $channelId: $error',
|
||||
);
|
||||
} finally {
|
||||
_pendingSubscriptionCancellations.remove(channelId);
|
||||
}
|
||||
},
|
||||
];
|
||||
await runPacedTasks(
|
||||
subscribeTasks,
|
||||
maxConcurrent: _unreadCatchUpConcurrency,
|
||||
startInterval: _unreadCatchUpStartInterval,
|
||||
isCancelled: () =>
|
||||
!ref.mounted ||
|
||||
subscriptionVersion != _subscriptionVersion ||
|
||||
ref.read(relaySessionProvider).status != SessionStatus.connected ||
|
||||
ref.read(relayConfigProvider).baseUrl != relayBaseUrl,
|
||||
delay: ref.read(channelsLiveSubscriptionDelayProvider),
|
||||
);
|
||||
|
||||
if (ref.read(relaySessionProvider).status != SessionStatus.connected) {
|
||||
if (!ref.mounted ||
|
||||
ref.read(relaySessionProvider).status != SessionStatus.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscriptionVersion != _subscriptionVersion) {
|
||||
final desiredChannelIds = _desiredLiveChannelIds;
|
||||
for (final entry in _unsubscribersByChannel.entries.toList()) {
|
||||
for (final entry in _liveSubscriptionsByChannel.entries.toList()) {
|
||||
if (desiredChannelIds.contains(entry.key)) continue;
|
||||
_unsubscribersByChannel.remove(entry.key);
|
||||
entry.value();
|
||||
_liveSubscriptionsByChannel.remove(entry.key);
|
||||
entry.value.unsubscribe();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(_catchUpUnreadEvents(channels));
|
||||
_startUnreadCatchUpIfNeeded([
|
||||
for (final channel in channels)
|
||||
if (_liveSubscriptionsByChannel.containsKey(channel.id)) channel,
|
||||
]);
|
||||
|
||||
_backstopTimer?.cancel();
|
||||
_backstopTimer = Timer.periodic(
|
||||
@@ -647,11 +773,31 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _catchUpUnreadEvents(List<Channel> channels) async {
|
||||
void _startUnreadCatchUpIfNeeded(List<Channel> channels) {
|
||||
final catchUpChannelIds = {
|
||||
for (final channel in channels)
|
||||
if (channel.isMember && !channel.isArchived) channel.id,
|
||||
};
|
||||
if (_sameStringSet(_unreadCatchUpChannelIds, catchUpChannelIds)) return;
|
||||
_unreadCatchUpChannelIds = Set.unmodifiable(catchUpChannelIds);
|
||||
final catchUpGeneration = ++_unreadCatchUpGeneration;
|
||||
unawaited(_catchUpUnreadEvents(channels, catchUpGeneration));
|
||||
}
|
||||
|
||||
Future<void> _catchUpUnreadEvents(
|
||||
List<Channel> channels,
|
||||
int catchUpGeneration,
|
||||
) async {
|
||||
final myPk = ref.read(myPubkeyProvider);
|
||||
if (myPk == null) return;
|
||||
|
||||
final session = ref.read(relaySessionProvider.notifier);
|
||||
final relayBaseUrl = ref.read(relayConfigProvider).baseUrl;
|
||||
bool isCancelled() =>
|
||||
!ref.mounted ||
|
||||
catchUpGeneration != _unreadCatchUpGeneration ||
|
||||
ref.read(relaySessionProvider).status != SessionStatus.connected ||
|
||||
ref.read(relayConfigProvider).baseUrl != relayBaseUrl;
|
||||
final mutedChannelIds = _mutedChannelIds();
|
||||
final ReadStateState readState;
|
||||
try {
|
||||
@@ -688,7 +834,11 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
session,
|
||||
filters,
|
||||
operation: 'unread catch-up',
|
||||
isCancelled: isCancelled,
|
||||
);
|
||||
if (isCancelled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final event in events) {
|
||||
if (event.pubkey.toLowerCase() == myPk.toLowerCase()) {
|
||||
@@ -716,6 +866,7 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
continue;
|
||||
}
|
||||
_recordUnreadEvent(channel, event, myPk);
|
||||
_mergeLastMessageAt(channel.id, event.createdAt);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint('[ChannelsNotifier] unread catch-up failed: $error');
|
||||
@@ -724,51 +875,80 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
state = state.whenData((channels) => List<Channel>.of(channels));
|
||||
}
|
||||
|
||||
void _handleLiveEvent(NostrEvent event) {
|
||||
final channelId = event.channelId;
|
||||
if (channelId == null) return;
|
||||
|
||||
final myPk = ref.read(myPubkeyProvider);
|
||||
final mutedChannelIds = _mutedChannelIds();
|
||||
|
||||
void _mergeLastMessageAt(String channelId, int createdAt) {
|
||||
final eventTime = DateTime.fromMillisecondsSinceEpoch(
|
||||
createdAt * 1000,
|
||||
isUtc: true,
|
||||
);
|
||||
state = state.whenData((channels) {
|
||||
final idx = channels.indexWhere((c) => c.id == channelId);
|
||||
if (idx == -1) {
|
||||
refresh();
|
||||
final index = channels.indexWhere((channel) => channel.id == channelId);
|
||||
if (index == -1) return channels;
|
||||
final channel = channels[index];
|
||||
if (channel.lastMessageAt != null &&
|
||||
!eventTime.isAfter(channel.lastMessageAt!)) {
|
||||
return channels;
|
||||
}
|
||||
final updated = List<Channel>.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);
|
||||
}
|
||||
}
|
||||
|
||||
updated[index] = channel.copyWith(lastMessageAt: eventTime);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleLiveEvent(NostrEvent event) {
|
||||
if (_bufferingBootstrapLiveEvents) {
|
||||
_bootstrapLiveEvents.add(event);
|
||||
return;
|
||||
}
|
||||
final myPk = ref.read(myPubkeyProvider);
|
||||
|
||||
state = state.whenData((channels) {
|
||||
final updated = List<Channel>.of(channels);
|
||||
if (!_applyLiveEventToChannels(updated, event, myPk)) {
|
||||
refresh();
|
||||
return channels;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
bool _applyLiveEventToChannels(
|
||||
List<Channel> channels,
|
||||
NostrEvent event,
|
||||
String? myPk,
|
||||
) {
|
||||
final channelId = event.channelId;
|
||||
if (channelId == null) return true;
|
||||
final idx = channels.indexWhere((channel) => channel.id == channelId);
|
||||
if (idx == -1) return false;
|
||||
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,
|
||||
)) {
|
||||
_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);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Set<String> _mutedChannelIds() => {
|
||||
for (final entry in ref.read(channelMutesProvider).store.channels.entries)
|
||||
if (entry.value.muted) entry.key,
|
||||
@@ -894,17 +1074,27 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
state = await AsyncValue.guard(() => _fetch(subscribeLive: true));
|
||||
}
|
||||
|
||||
void _cancelPendingLiveSubscriptions() {
|
||||
for (final cancellation in _pendingSubscriptionCancellations.values) {
|
||||
if (!cancellation.isCompleted) cancellation.complete();
|
||||
}
|
||||
_pendingSubscriptionCancellations.clear();
|
||||
}
|
||||
|
||||
void _clearLiveSubscriptions() {
|
||||
_subscriptionVersion++;
|
||||
_desiredLiveChannels = const [];
|
||||
_desiredLiveChannelIds = const {};
|
||||
for (final unsubscribe in _unsubscribersByChannel.values) {
|
||||
unsubscribe();
|
||||
_cancelPendingLiveSubscriptions();
|
||||
for (final subscription in _liveSubscriptionsByChannel.values) {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
_unsubscribersByChannel.clear();
|
||||
_liveSubscriptionsByChannel.clear();
|
||||
_subscriptionRelayBaseUrl = null;
|
||||
_backstopTimer?.cancel();
|
||||
_backstopTimer = null;
|
||||
_unreadCatchUpGeneration++;
|
||||
_unreadCatchUpChannelIds = const {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,6 +1102,117 @@ final channelsProvider = AsyncNotifierProvider<ChannelsNotifier, List<Channel>>(
|
||||
ChannelsNotifier.new,
|
||||
);
|
||||
|
||||
final channelsLiveSubscriptionDelayProvider = Provider<TaskDelay>(
|
||||
(_) => _defaultTaskDelay,
|
||||
);
|
||||
|
||||
List<List<Channel>> chunkChannelsForLastMessageQuery(List<Channel> channels) =>
|
||||
[
|
||||
for (
|
||||
var start = 0;
|
||||
start < channels.length;
|
||||
start += _lastMessageQueryBatchSize
|
||||
)
|
||||
channels.sublist(
|
||||
start,
|
||||
min(start + _lastMessageQueryBatchSize, channels.length),
|
||||
),
|
||||
];
|
||||
|
||||
List<NostrFilter> buildChannelLastMessageFilters(List<Channel> channels) => [
|
||||
for (final channel in channels)
|
||||
NostrFilter(
|
||||
kinds: EventKind.channelMessageEventKinds,
|
||||
tags: {
|
||||
'#h': [channel.id],
|
||||
},
|
||||
limit: channel.isDm ? 1 : 20,
|
||||
),
|
||||
];
|
||||
|
||||
Map<String, int> resolveChannelLastMessages(
|
||||
List<Channel> channels,
|
||||
Iterable<NostrEvent> events, {
|
||||
required String myPk,
|
||||
Set<String> mutedChannelIds = const {},
|
||||
}) {
|
||||
final channelsById = {for (final channel in channels) channel.id: channel};
|
||||
final result = <String, int>{};
|
||||
for (final event in events) {
|
||||
final channelId = event.channelId;
|
||||
final channel = channelId == null ? null : channelsById[channelId];
|
||||
if (channel == null) continue;
|
||||
if (!channel.isDm &&
|
||||
!shouldNotifyForEvent(
|
||||
event,
|
||||
myPk,
|
||||
mutedChannelIds: mutedChannelIds,
|
||||
channelId: channelId,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
final current = result[channel.id];
|
||||
if (current == null || event.createdAt > current) {
|
||||
result[channel.id] = event.createdAt;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
typedef TaskDelay = Future<void> Function(Duration duration);
|
||||
typedef TaskErrorHandler = void Function(Object error);
|
||||
|
||||
Future<void> runPacedTasks(
|
||||
List<Future<void> Function()> tasks, {
|
||||
required int maxConcurrent,
|
||||
required Duration startInterval,
|
||||
required bool Function() isCancelled,
|
||||
TaskDelay delay = _defaultTaskDelay,
|
||||
TaskErrorHandler onError = _defaultTaskErrorHandler,
|
||||
}) async {
|
||||
if (maxConcurrent < 1) throw ArgumentError.value(maxConcurrent);
|
||||
var nextTask = 0;
|
||||
var firstStart = true;
|
||||
Future<void> startPermit = Future.value();
|
||||
|
||||
Future<bool> acquireStartPermit() async {
|
||||
if (firstStart) {
|
||||
firstStart = false;
|
||||
} else {
|
||||
startPermit = startPermit.then((_) => delay(startInterval));
|
||||
await startPermit;
|
||||
}
|
||||
return !isCancelled();
|
||||
}
|
||||
|
||||
Future<void> worker() async {
|
||||
while (true) {
|
||||
final index = nextTask++;
|
||||
if (index >= tasks.length) return;
|
||||
if (!await acquireStartPermit()) return;
|
||||
try {
|
||||
await tasks[index]();
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Future.wait([
|
||||
for (var i = 0; i < min(maxConcurrent, tasks.length); i++) worker(),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> _defaultTaskDelay(Duration duration) =>
|
||||
Future<void>.delayed(duration);
|
||||
|
||||
void _defaultTaskErrorHandler(Object error) {
|
||||
debugPrint('[ChannelsNotifier] paced task failed: $error');
|
||||
}
|
||||
|
||||
bool _sameStringSet(Set<String> left, Set<String> right) =>
|
||||
left.length == right.length && left.containsAll(right);
|
||||
|
||||
String? _observedUnreadRootId(NostrEvent event) =>
|
||||
_isBroadcastReply(event) ? null : event.threadReference.rootId;
|
||||
|
||||
|
||||
@@ -37,10 +37,18 @@ class _HistorySubscription {
|
||||
_HistorySubscription({required this.completer, required this.timeout});
|
||||
}
|
||||
|
||||
class RelaySubscriptionCancelledException implements Exception {
|
||||
const RelaySubscriptionCancelledException();
|
||||
|
||||
@override
|
||||
String toString() => 'Relay subscription cancelled before ready';
|
||||
}
|
||||
|
||||
class _LiveSubscription {
|
||||
final NostrFilter filter;
|
||||
final void Function(NostrEvent) onEvent;
|
||||
final void Function(String message)? onClosed;
|
||||
final bool waitForRelayReady;
|
||||
Completer<void>? readyCompleter;
|
||||
int? lastSeenCreatedAt;
|
||||
int closedRetryAttempt = 0;
|
||||
@@ -49,6 +57,7 @@ class _LiveSubscription {
|
||||
_LiveSubscription({
|
||||
required this.filter,
|
||||
required this.onEvent,
|
||||
required this.waitForRelayReady,
|
||||
this.onClosed,
|
||||
this.readyCompleter,
|
||||
});
|
||||
@@ -264,6 +273,30 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) =>
|
||||
_subscribe(filter, onEvent, onClosed: onClosed, waitForRelayReady: false);
|
||||
|
||||
/// Subscribe and resolve only after the relay confirms the REQ with EOSE.
|
||||
/// Retryable CLOSED responses keep this pending across the scheduled retry.
|
||||
Future<void Function()> subscribeWhenReady(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
Future<void>? cancelled,
|
||||
}) => _subscribe(
|
||||
filter,
|
||||
onEvent,
|
||||
onClosed: onClosed,
|
||||
waitForRelayReady: true,
|
||||
cancelled: cancelled,
|
||||
);
|
||||
|
||||
Future<void Function()> _subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
required bool waitForRelayReady,
|
||||
void Function(String message)? onClosed,
|
||||
Future<void>? cancelled,
|
||||
}) async {
|
||||
if (_disposed) throw StateError('Relay session is disposed');
|
||||
final subId = _nextSubId('l');
|
||||
@@ -272,26 +305,44 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
_liveSubscriptions[subId] = _LiveSubscription(
|
||||
filter: filter,
|
||||
onEvent: onEvent,
|
||||
waitForRelayReady: waitForRelayReady,
|
||||
onClosed: onClosed,
|
||||
readyCompleter: readyCompleter,
|
||||
);
|
||||
|
||||
_sendReq(subId, filter);
|
||||
|
||||
// Wait for EOSE or a short fallback timeout.
|
||||
try {
|
||||
await readyCompleter.future.timeout(
|
||||
const Duration(milliseconds: 500),
|
||||
onTimeout: () {},
|
||||
);
|
||||
} catch (_) {
|
||||
_liveSubscriptions.remove(subId);
|
||||
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
|
||||
rethrow;
|
||||
}
|
||||
final liveSub = _liveSubscriptions[subId];
|
||||
if (liveSub != null && liveSub.readyCompleter == readyCompleter) {
|
||||
liveSub.readyCompleter = null;
|
||||
if (waitForRelayReady) {
|
||||
if (cancelled == null) {
|
||||
await readyCompleter.future;
|
||||
} else {
|
||||
final cancelledBeforeReady = await Future.any([
|
||||
readyCompleter.future.then((_) => false),
|
||||
cancelled.then((_) => true),
|
||||
]);
|
||||
if (cancelledBeforeReady) {
|
||||
final liveSub = _liveSubscriptions[subId];
|
||||
if (liveSub != null) _removeLiveSubscription(subId, liveSub);
|
||||
_sendClose(subId);
|
||||
throw const RelaySubscriptionCancelledException();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Wait for EOSE or a short fallback timeout.
|
||||
try {
|
||||
await readyCompleter.future.timeout(
|
||||
const Duration(milliseconds: 500),
|
||||
onTimeout: () {},
|
||||
);
|
||||
} catch (_) {
|
||||
_liveSubscriptions.remove(subId);
|
||||
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
|
||||
rethrow;
|
||||
}
|
||||
final liveSub = _liveSubscriptions[subId];
|
||||
if (liveSub != null && liveSub.readyCompleter == readyCompleter) {
|
||||
liveSub.readyCompleter = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => _unsubscribe(subId);
|
||||
@@ -694,7 +745,9 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
_removeLiveSubscription(subId, liveSub);
|
||||
return;
|
||||
}
|
||||
if (readyCompleter != null && !readyCompleter.isCompleted) {
|
||||
if (!liveSub.waitForRelayReady &&
|
||||
readyCompleter != null &&
|
||||
!readyCompleter.isCompleted) {
|
||||
readyCompleter.complete();
|
||||
liveSub.readyCompleter = null;
|
||||
}
|
||||
@@ -931,6 +984,12 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
final subscriptions = _liveSubscriptions.values.toList();
|
||||
_liveSubscriptions.clear();
|
||||
for (final subscription in subscriptions) {
|
||||
final readyCompleter = subscription.readyCompleter;
|
||||
if (readyCompleter != null && !readyCompleter.isCompleted) {
|
||||
readyCompleter.completeError(
|
||||
const RelaySubscriptionCancelledException(),
|
||||
);
|
||||
}
|
||||
subscription.closedRetryTimer?.cancel();
|
||||
subscription.closedRetryTimer = null;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/features/channels/channels_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
@@ -21,6 +23,304 @@ import 'package:buzz/shared/relay/relay.dart';
|
||||
void main() {
|
||||
const myPk = 'me';
|
||||
|
||||
test('chunks 1,565 channels into sixteen bounded HTTP requests', () {
|
||||
final chunks = chunkChannelsForLastMessageQuery([
|
||||
for (var i = 0; i < 1565; i++) _channel(_generatedChannelId(i)),
|
||||
]);
|
||||
|
||||
expect(chunks, hasLength(16));
|
||||
expect(chunks.take(15).every((chunk) => chunk.length == 100), isTrue);
|
||||
expect(chunks.last, hasLength(65));
|
||||
});
|
||||
|
||||
test('chunks aggregate last-message queries and isolates failures', () async {
|
||||
final channelIds = List.generate(201, _generatedChannelId);
|
||||
final session =
|
||||
_FakeRelaySession(
|
||||
memberships: [for (final id in channelIds) _membership(id, myPk)],
|
||||
metadata: [for (final id in channelIds) _meta(id: id, name: id)],
|
||||
)
|
||||
..queryFailureCalls.add(2)
|
||||
..queryEvents = [
|
||||
_message(channelIds.first, createdAt: 10),
|
||||
_message(channelIds[100], createdAt: 20),
|
||||
_message(channelIds.last, createdAt: 30),
|
||||
];
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final channels = await container.read(channelsProvider.future);
|
||||
|
||||
expect(session.queryFilterBatches.map((batch) => batch.length), [
|
||||
100,
|
||||
100,
|
||||
1,
|
||||
]);
|
||||
expect(
|
||||
session.queryTimeouts.every(
|
||||
(timeout) => timeout <= const Duration(seconds: 8),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(session.queryFilterBatches.expand((batch) => batch), hasLength(201));
|
||||
expect(
|
||||
channels
|
||||
.firstWhere((channel) => channel.id == channelIds.first)
|
||||
.lastMessageAt,
|
||||
DateTime.fromMillisecondsSinceEpoch(10000, isUtc: true),
|
||||
);
|
||||
expect(
|
||||
channels
|
||||
.firstWhere((channel) => channel.id == channelIds[100])
|
||||
.lastMessageAt,
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
channels
|
||||
.firstWhere((channel) => channel.id == channelIds.last)
|
||||
.lastMessageAt,
|
||||
DateTime.fromMillisecondsSinceEpoch(30000, isUtc: true),
|
||||
);
|
||||
});
|
||||
|
||||
test('catch-up recovers event missed before live REQ readiness', () async {
|
||||
final channelIds = List.generate(101, _generatedChannelId);
|
||||
final session = _FakeRelaySession(
|
||||
memberships: [for (final id in channelIds) _membership(id, myPk)],
|
||||
metadata: [for (final id in channelIds) _meta(id: id, name: id)],
|
||||
);
|
||||
session.pauseNextSubscribe();
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final channels = await container.read(channelsProvider.future);
|
||||
expect(
|
||||
channels
|
||||
.firstWhere((channel) => channel.id == channelIds.first)
|
||||
.lastMessageAt,
|
||||
isNull,
|
||||
);
|
||||
expect(session.activeSubscriptionCount, lessThan(101));
|
||||
|
||||
final missedEvent = _message(channelIds.first, createdAt: 50);
|
||||
session.emit(missedEvent);
|
||||
session.unreadEvents = [missedEvent];
|
||||
session.resumePausedSubscribe();
|
||||
|
||||
await _waitUntil(() => session.unreadCatchUpQueryCount > 0);
|
||||
await _waitUntil(
|
||||
() =>
|
||||
container
|
||||
.read(channelsProvider.notifier)
|
||||
.observedUnreadEventsByChannel[channelIds.first]
|
||||
?.containsKey(missedEvent.id) ==
|
||||
true,
|
||||
attempts: 30000,
|
||||
);
|
||||
expect(
|
||||
container
|
||||
.read(channelsProvider)
|
||||
.value!
|
||||
.firstWhere((channel) => channel.id == channelIds.first)
|
||||
.lastMessageAt,
|
||||
DateTime.fromMillisecondsSinceEpoch(50000, isUtc: true),
|
||||
);
|
||||
});
|
||||
|
||||
test('loaded refresh preserves newer live timestamp', () async {
|
||||
final channelIds = List.generate(101, _generatedChannelId);
|
||||
final session = _FakeRelaySession(
|
||||
memberships: [for (final id in channelIds) _membership(id, myPk)],
|
||||
metadata: [for (final id in channelIds) _meta(id: id, name: id)],
|
||||
)..queryEvents = [_message(channelIds.first, createdAt: 10)];
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(channelsProvider.future);
|
||||
session.pauseQueryCall(4);
|
||||
final refresh = container.read(channelsProvider.notifier).refresh();
|
||||
await session.nextPausedQueryStarted;
|
||||
session.emit(_message(channelIds.first, createdAt: 50));
|
||||
session.resumePausedQuery();
|
||||
await refresh;
|
||||
|
||||
expect(
|
||||
container
|
||||
.read(channelsProvider)
|
||||
.value!
|
||||
.firstWhere((channel) => channel.id == channelIds.first)
|
||||
.lastMessageAt,
|
||||
DateTime.fromMillisecondsSinceEpoch(50000, isUtc: true),
|
||||
);
|
||||
});
|
||||
|
||||
test('stale unread fallback stops before its next wave', () async {
|
||||
final channelIds = List.generate(9, _generatedChannelId);
|
||||
final session = _FakeRelaySession(
|
||||
memberships: [for (final id in channelIds) _membership(id, myPk)],
|
||||
metadata: [for (final id in channelIds) _meta(id: id, name: id)],
|
||||
)..queryFailureCalls.add(2);
|
||||
session.pauseNextUnreadCatchUp();
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(channelsProvider.future);
|
||||
await session.nextUnreadCatchUpStarted;
|
||||
expect(session.unreadFallbackFetchCount, 4);
|
||||
|
||||
session.setStatus(SessionStatus.disconnected);
|
||||
session.resumePausedUnreadCatchUp();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(session.unreadFallbackFetchCount, 4);
|
||||
});
|
||||
|
||||
test('unchanged refresh does not restart unread catch-up', () async {
|
||||
final session = _FakeRelaySession(
|
||||
memberships: [_membership(_channelA, myPk)],
|
||||
metadata: [_meta(id: _channelA, name: 'general')],
|
||||
);
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(channelsProvider.future);
|
||||
await _waitUntil(() => session.unreadCatchUpQueryCount == 1);
|
||||
await container.read(channelsProvider.notifier).refresh();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(session.unreadCatchUpQueryCount, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'disconnect discards stale catch-up and reconnect restarts it',
|
||||
() async {
|
||||
final session = _FakeRelaySession(
|
||||
memberships: [_membership(_channelA, myPk)],
|
||||
metadata: [_meta(id: _channelA, name: 'general')],
|
||||
)..unreadEvents = [_message(_channelA, createdAt: 20)];
|
||||
session.pauseNextUnreadCatchUp();
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(channelsProvider.future);
|
||||
await session.nextUnreadCatchUpStarted;
|
||||
|
||||
session.setStatus(SessionStatus.disconnected);
|
||||
session.resumePausedUnreadCatchUp();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(
|
||||
container
|
||||
.read(channelsProvider.notifier)
|
||||
.observedUnreadEventsByChannel[_channelA],
|
||||
isNull,
|
||||
);
|
||||
|
||||
session.setStatus(SessionStatus.connected);
|
||||
await _waitUntil(() => session.unreadCatchUpQueryCount == 2);
|
||||
await _waitUntil(
|
||||
() =>
|
||||
container
|
||||
.read(channelsProvider.notifier)
|
||||
.observedUnreadEventsByChannel[_channelA]
|
||||
?.isNotEmpty ==
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('builds one bounded last-message filter per channel type', () {
|
||||
final filters = buildChannelLastMessageFilters([
|
||||
_channel(_channelA),
|
||||
_channel(_channelB, channelType: 'dm'),
|
||||
]);
|
||||
|
||||
expect(filters, hasLength(2));
|
||||
expect(filters[0].tags['#h'], [_channelA]);
|
||||
expect(filters[0].limit, 20);
|
||||
expect(filters[1].tags['#h'], [_channelB]);
|
||||
expect(filters[1].limit, 1);
|
||||
});
|
||||
|
||||
test('maps newest qualifying last messages and preserves DM semantics', () {
|
||||
final channels = [
|
||||
_channel(_channelA),
|
||||
_channel(_channelB, channelType: 'dm'),
|
||||
];
|
||||
final result = resolveChannelLastMessages(channels, [
|
||||
_message(_channelA, createdAt: 10, pubkey: myPk),
|
||||
_message(_channelA, createdAt: 20),
|
||||
_message(_channelA, createdAt: 15),
|
||||
_message(_channelB, createdAt: 30, pubkey: myPk),
|
||||
_message(_channelD, createdAt: 40),
|
||||
], myPk: myPk);
|
||||
|
||||
expect(result, {_channelA: 20, _channelB: 30});
|
||||
});
|
||||
|
||||
test(
|
||||
'paced tasks are lazy, bounded, failure-isolated, and cancellable',
|
||||
() async {
|
||||
var active = 0;
|
||||
var maxActive = 0;
|
||||
var created = 0;
|
||||
var errors = 0;
|
||||
var cancelled = false;
|
||||
final permits = <Completer<void>>[];
|
||||
final completions = List.generate(6, (_) => Completer<void>());
|
||||
final tasks = [
|
||||
for (var i = 0; i < completions.length; i++)
|
||||
() async {
|
||||
created++;
|
||||
active++;
|
||||
maxActive = max(maxActive, active);
|
||||
if (i == 1) {
|
||||
active--;
|
||||
throw Exception('isolated');
|
||||
}
|
||||
await completions[i].future;
|
||||
active--;
|
||||
},
|
||||
];
|
||||
|
||||
final run = runPacedTasks(
|
||||
tasks,
|
||||
maxConcurrent: 4,
|
||||
startInterval: const Duration(milliseconds: 125),
|
||||
isCancelled: () => cancelled,
|
||||
delay: (_) {
|
||||
if (cancelled) return Future.value();
|
||||
final permit = Completer<void>();
|
||||
permits.add(permit);
|
||||
return permit.future;
|
||||
},
|
||||
onError: (_) => errors++,
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(created, 1);
|
||||
expect(permits, hasLength(1));
|
||||
|
||||
for (var expectedCreated = 2; expectedCreated <= 4; expectedCreated++) {
|
||||
permits.removeAt(0).complete();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(created, expectedCreated);
|
||||
if (expectedCreated < 4) expect(permits, hasLength(1));
|
||||
}
|
||||
expect(maxActive, 3);
|
||||
|
||||
cancelled = true;
|
||||
for (final completion in completions) {
|
||||
if (!completion.isCompleted) completion.complete();
|
||||
}
|
||||
for (final permit in permits) {
|
||||
if (!permit.isCompleted) permit.complete();
|
||||
}
|
||||
await run;
|
||||
expect(created, 4);
|
||||
expect(errors, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'seeds members from the channel-list snapshot during reconnect',
|
||||
() async {
|
||||
@@ -136,6 +436,108 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'terminal live failure catches up only ready channels and retries gap',
|
||||
() async {
|
||||
final session = _FakeRelaySession(
|
||||
memberships: [
|
||||
_membership(_channelA, myPk),
|
||||
_membership(_channelB, myPk),
|
||||
],
|
||||
metadata: [
|
||||
_meta(id: _channelA, name: 'general'),
|
||||
_meta(id: _channelB, name: 'random'),
|
||||
],
|
||||
)..subscribeFailuresByChannel[_channelA] = 1;
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(channelsProvider.future);
|
||||
await _waitUntil(() => session.unreadCatchUpQueryCount == 1);
|
||||
expect(session.activeChannels, {_channelB});
|
||||
expect(
|
||||
session.queryFilterBatches
|
||||
.expand((filters) => filters)
|
||||
.where((filter) => filter.since != null)
|
||||
.map((filter) => filter.tags['#h']!.single),
|
||||
[_channelB],
|
||||
);
|
||||
|
||||
final missedEvent = _message(_channelA, createdAt: 50);
|
||||
session.unreadEvents = [missedEvent];
|
||||
await container.read(channelsProvider.notifier).refresh();
|
||||
|
||||
await _waitUntil(
|
||||
() => session.queryFilterBatches
|
||||
.expand((filters) => filters)
|
||||
.any(
|
||||
(filter) =>
|
||||
filter.since != null &&
|
||||
filter.tags['#h']?.single == _channelA,
|
||||
),
|
||||
attempts: 30000,
|
||||
);
|
||||
expect(session.activeChannels, {_channelA, _channelB});
|
||||
expect(
|
||||
container
|
||||
.read(channelsProvider.notifier)
|
||||
.observedUnreadEventsByChannel[_channelA]
|
||||
?.containsKey(missedEvent.id),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
container
|
||||
.read(channelsProvider)
|
||||
.value!
|
||||
.firstWhere((channel) => channel.id == _channelA)
|
||||
.lastMessageAt,
|
||||
DateTime.fromMillisecondsSinceEpoch(50000, isUtc: true),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'post-ready terminal close retries subscription and catches up gap',
|
||||
() async {
|
||||
final session = _FakeRelaySession(
|
||||
memberships: [_membership(_channelA, myPk)],
|
||||
metadata: [_meta(id: _channelA, name: 'general')],
|
||||
);
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(channelsProvider.future);
|
||||
await _waitUntil(() => session.unreadCatchUpQueryCount == 1);
|
||||
expect(session.activeChannels, {_channelA});
|
||||
|
||||
session.terminallyClose(_channelA);
|
||||
expect(session.activeChannels, isEmpty);
|
||||
final missedEvent = _message(_channelA, createdAt: 50);
|
||||
session.unreadEvents = [missedEvent];
|
||||
|
||||
await container.read(channelsProvider.notifier).refresh();
|
||||
|
||||
await _waitUntil(
|
||||
() =>
|
||||
session.activeChannels.contains(_channelA) &&
|
||||
container
|
||||
.read(channelsProvider.notifier)
|
||||
.observedUnreadEventsByChannel[_channelA]
|
||||
?.containsKey(missedEvent.id) ==
|
||||
true,
|
||||
attempts: 30000,
|
||||
);
|
||||
expect(
|
||||
container
|
||||
.read(channelsProvider)
|
||||
.value!
|
||||
.firstWhere((channel) => channel.id == _channelA)
|
||||
.lastMessageAt,
|
||||
DateTime.fromMillisecondsSinceEpoch(50000, isUtc: true),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'live subscription diff only removes and adds changed channels',
|
||||
() async {
|
||||
@@ -660,6 +1062,39 @@ const _channelA = '11111111-1111-4111-8111-111111111111';
|
||||
const _channelB = '22222222-2222-4222-8222-222222222222';
|
||||
const _channelD = '44444444-4444-4444-8444-444444444444';
|
||||
|
||||
String _generatedChannelId(int index) {
|
||||
final suffix = index.toRadixString(16).padLeft(12, '0');
|
||||
return 'aaaaaaaa-aaaa-4aaa-8aaa-$suffix';
|
||||
}
|
||||
|
||||
NostrEvent _message(
|
||||
String channelId, {
|
||||
required int createdAt,
|
||||
String pubkey = 'alice',
|
||||
}) => NostrEvent(
|
||||
id: 'message-$channelId-$createdAt',
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.streamMessageV2,
|
||||
tags: [
|
||||
['h', channelId],
|
||||
],
|
||||
content: 'message',
|
||||
sig: 'sig',
|
||||
);
|
||||
|
||||
Channel _channel(String id, {String channelType = 'stream'}) => Channel(
|
||||
id: id,
|
||||
name: id,
|
||||
channelType: channelType,
|
||||
visibility: 'open',
|
||||
description: '',
|
||||
createdBy: 'creator',
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(1000, isUtc: true),
|
||||
memberCount: 1,
|
||||
isMember: true,
|
||||
);
|
||||
|
||||
/// Build a kind:39002 membership event tagged with the channel id and member.
|
||||
NostrEvent _membership(
|
||||
String channelId,
|
||||
@@ -725,13 +1160,16 @@ ProviderContainer _buildContainer({required _FakeRelaySession session}) {
|
||||
overrides: [
|
||||
appLifecycleProvider.overrideWith(() => _FakeAppLifecycleNotifier()),
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
channelsLiveSubscriptionDelayProvider.overrideWithValue(
|
||||
(_) => Future<void>.value(),
|
||||
),
|
||||
myPubkeyProvider.overrideWithValue('me'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _waitUntil(bool Function() predicate) async {
|
||||
for (var i = 0; i < 100; i++) {
|
||||
Future<void> _waitUntil(bool Function() predicate, {int attempts = 100}) async {
|
||||
for (var i = 0; i < attempts; i++) {
|
||||
if (predicate()) return;
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
@@ -757,16 +1195,53 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
|
||||
final List<NostrFilter> historyFilters = [];
|
||||
final List<List<NostrFilter>> queryBatches = [];
|
||||
final List<List<NostrFilter>> queryFilterBatches = [];
|
||||
final List<Duration> queryTimeouts = [];
|
||||
final Set<int> queryFailureCalls = {};
|
||||
final Map<String, int> subscribeFailuresByChannel = {};
|
||||
List<NostrEvent> queryEvents = [];
|
||||
int? _pausedQueryCall;
|
||||
Completer<void>? _pausedQuery;
|
||||
Completer<void>? _pausedQueryStarted;
|
||||
final List<NostrFilter> subscribeFilters = [];
|
||||
final Map<int, (NostrFilter, void Function(NostrEvent))> _subscriptions = {};
|
||||
final Map<
|
||||
int,
|
||||
(NostrFilter, void Function(NostrEvent), void Function(String message)?)
|
||||
>
|
||||
_subscriptions = {};
|
||||
int _nextSubscriptionKey = 0;
|
||||
Completer<void>? _pausedSubscribe;
|
||||
Completer<void>? _subscribeStarted;
|
||||
Completer<void>? _pausedUnreadCatchUp;
|
||||
Completer<void>? _unreadCatchUpStarted;
|
||||
List<NostrEvent> unreadEvents = [];
|
||||
int unsubscribeCount = 0;
|
||||
int totalSubscribeCount = 0;
|
||||
|
||||
int get unreadFallbackFetchCount => historyFilters
|
||||
.where(
|
||||
(filter) =>
|
||||
filter.tags['#h']?.length == 1 &&
|
||||
filter.since != null &&
|
||||
filter.kinds.length == EventKind.channelMessageEventKinds.length &&
|
||||
filter.kinds.every(EventKind.channelMessageEventKinds.contains),
|
||||
)
|
||||
.length;
|
||||
|
||||
int get unreadCatchUpQueryCount => queryFilterBatches
|
||||
.expand((filters) => filters)
|
||||
.where(
|
||||
(filter) =>
|
||||
filter.tags['#h']?.length == 1 &&
|
||||
filter.since != null &&
|
||||
filter.kinds.length == EventKind.channelMessageEventKinds.length &&
|
||||
filter.kinds.every(EventKind.channelMessageEventKinds.contains),
|
||||
)
|
||||
.length;
|
||||
|
||||
Set<String> get activeChannels => {
|
||||
for (final (filter, _) in _subscriptions.values) ?filter.tags['#h']?.single,
|
||||
for (final (filter, _, _) in _subscriptions.values)
|
||||
?filter.tags['#h']?.single,
|
||||
};
|
||||
|
||||
int get activeSubscriptionCount => _subscriptions.length;
|
||||
@@ -793,6 +1268,46 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
paused.complete();
|
||||
}
|
||||
|
||||
Future<void> get nextUnreadCatchUpStarted async {
|
||||
final started = _unreadCatchUpStarted;
|
||||
if (started == null) {
|
||||
throw StateError('No paused unread catch-up is pending');
|
||||
}
|
||||
await started.future;
|
||||
}
|
||||
|
||||
void pauseNextUnreadCatchUp() {
|
||||
if (_pausedUnreadCatchUp != null) {
|
||||
throw StateError('An unread catch-up is already paused');
|
||||
}
|
||||
_pausedUnreadCatchUp = Completer<void>();
|
||||
_unreadCatchUpStarted = Completer<void>();
|
||||
}
|
||||
|
||||
void resumePausedUnreadCatchUp() {
|
||||
final paused = _pausedUnreadCatchUp;
|
||||
if (paused == null) throw StateError('No unread catch-up is paused');
|
||||
paused.complete();
|
||||
}
|
||||
|
||||
Future<void> get nextPausedQueryStarted async {
|
||||
final started = _pausedQueryStarted;
|
||||
if (started == null) throw StateError('No query is paused');
|
||||
await started.future;
|
||||
}
|
||||
|
||||
void pauseQueryCall(int call) {
|
||||
_pausedQueryCall = call;
|
||||
_pausedQuery = Completer<void>();
|
||||
_pausedQueryStarted = Completer<void>();
|
||||
}
|
||||
|
||||
void resumePausedQuery() {
|
||||
final paused = _pausedQuery;
|
||||
if (paused == null) throw StateError('No query is paused');
|
||||
paused.complete();
|
||||
}
|
||||
|
||||
@override
|
||||
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||||
|
||||
@@ -824,6 +1339,19 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
final ids = (filter.tags['#d'] ?? const <String>[]).toSet();
|
||||
return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList();
|
||||
}
|
||||
if (filter.since != null && filter.tags['#h']?.length == 1) {
|
||||
final paused = _pausedUnreadCatchUp;
|
||||
if (paused != null) {
|
||||
_unreadCatchUpStarted!.complete();
|
||||
await paused.future;
|
||||
_pausedUnreadCatchUp = null;
|
||||
_unreadCatchUpStarted = null;
|
||||
}
|
||||
final channelId = filter.tags['#h']!.single;
|
||||
return unreadEvents
|
||||
.where((event) => event.channelId == channelId)
|
||||
.toList();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
@@ -832,10 +1360,39 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
List<NostrFilter> filters, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
queryBatches.add(filters);
|
||||
return recentMessages.where((event) {
|
||||
queryBatches.add(List.of(filters));
|
||||
queryFilterBatches.add(List.of(filters));
|
||||
queryTimeouts.add(timeout);
|
||||
if (_pausedQueryCall == queryFilterBatches.length) {
|
||||
_pausedQueryStarted!.complete();
|
||||
await _pausedQuery!.future;
|
||||
_pausedQueryCall = null;
|
||||
_pausedQuery = null;
|
||||
_pausedQueryStarted = null;
|
||||
}
|
||||
if (queryFailureCalls.contains(queryFilterBatches.length)) {
|
||||
throw Exception('query failed');
|
||||
}
|
||||
if (filters.any((filter) => filter.since != null)) {
|
||||
final paused = _pausedUnreadCatchUp;
|
||||
if (paused != null) {
|
||||
_unreadCatchUpStarted!.complete();
|
||||
await paused.future;
|
||||
_pausedUnreadCatchUp = null;
|
||||
_unreadCatchUpStarted = null;
|
||||
}
|
||||
}
|
||||
final candidates = <NostrEvent>[
|
||||
...recentMessages,
|
||||
...queryEvents,
|
||||
...unreadEvents,
|
||||
];
|
||||
return candidates.where((event) {
|
||||
return filters.any((filter) {
|
||||
if (!filter.kinds.contains(event.kind)) return false;
|
||||
if (filter.since != null && event.createdAt < filter.since!) {
|
||||
return false;
|
||||
}
|
||||
for (final entry in filter.tags.entries) {
|
||||
final tagName = entry.key.startsWith('#')
|
||||
? entry.key.substring(1)
|
||||
@@ -854,23 +1411,45 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void Function()> subscribeWhenReady(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
Future<void>? cancelled,
|
||||
}) => _subscribeFake(filter, onEvent, onClosed: onClosed);
|
||||
|
||||
@override
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) => _subscribeFake(filter, onEvent, onClosed: onClosed);
|
||||
|
||||
Future<void Function()> _subscribeFake(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async {
|
||||
totalSubscribeCount++;
|
||||
subscribeFilters.add(filter);
|
||||
final channelId = filter.tags['#h']?.single;
|
||||
final remainingFailures = channelId == null
|
||||
? 0
|
||||
: subscribeFailuresByChannel[channelId] ?? 0;
|
||||
if (remainingFailures > 0) {
|
||||
subscribeFailuresByChannel[channelId!] = remainingFailures - 1;
|
||||
throw Exception('terminal subscription failure for $channelId');
|
||||
}
|
||||
final paused = _pausedSubscribe;
|
||||
if (paused != null) {
|
||||
if (paused != null && !_subscribeStarted!.isCompleted) {
|
||||
_subscribeStarted!.complete();
|
||||
await paused.future;
|
||||
_pausedSubscribe = null;
|
||||
_subscribeStarted = null;
|
||||
}
|
||||
final subscriptionKey = ++_nextSubscriptionKey;
|
||||
_subscriptions[subscriptionKey] = (filter, onEvent);
|
||||
_subscriptions[subscriptionKey] = (filter, onEvent, onClosed);
|
||||
return () {
|
||||
final subscription = _subscriptions.remove(subscriptionKey);
|
||||
if (subscription == null) return;
|
||||
@@ -883,9 +1462,18 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
state = SessionState(status: status);
|
||||
}
|
||||
|
||||
void terminallyClose(String channelId) {
|
||||
final entry = _subscriptions.entries.singleWhere(
|
||||
(entry) => entry.value.$1.tags['#h']?.single == channelId,
|
||||
);
|
||||
_subscriptions.remove(entry.key);
|
||||
subscribeFilters.remove(entry.value.$1);
|
||||
entry.value.$3?.call('restricted: access revoked');
|
||||
}
|
||||
|
||||
/// Emit a live event to all subscribers.
|
||||
void emit(NostrEvent event) {
|
||||
for (final (_, listener) in List.of(_subscriptions.values)) {
|
||||
for (final (_, listener, _) in List.of(_subscriptions.values)) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,6 +749,145 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'subscribeWhenReady stays pending across retryable CLOSED until retry EOSE',
|
||||
() async {
|
||||
final timers = <_ManualTimer>[];
|
||||
final socket = _RecordingRelaySocket();
|
||||
final session = RelaySessionNotifier(
|
||||
retryTimerFactory: (duration, callback) {
|
||||
final timer = _ManualTimer(duration, callback);
|
||||
timers.add(timer);
|
||||
return timer;
|
||||
},
|
||||
);
|
||||
session.debugAttachSocketForTest(socket);
|
||||
final events = <NostrEvent>[];
|
||||
var ready = false;
|
||||
final subscribe = session
|
||||
.subscribeWhenReady(_channelFilter, events.add)
|
||||
.whenComplete(() => ready = true);
|
||||
|
||||
session.debugHandleMessage(['CLOSED', 'l-1', 'error: relay overloaded']);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(ready, isFalse);
|
||||
|
||||
timers.single.fire();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(_reqs(socket).where((req) => req[1] == 'l-1'), hasLength(2));
|
||||
expect(ready, isFalse);
|
||||
|
||||
final replayEvent = _event(createdAt: 30);
|
||||
session.debugHandleMessage(['EVENT', 'l-1', replayEvent.toJson()]);
|
||||
expect(events, isEmpty);
|
||||
session.debugHandleMessage(['EOSE', 'l-1']);
|
||||
|
||||
final unsubscribe = await subscribe;
|
||||
expect(events, [replayEvent]);
|
||||
expect(ready, isTrue);
|
||||
unsubscribe();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'subscribeWhenReady stays pending across rate-limited CLOSED until retry EOSE',
|
||||
() async {
|
||||
final now = DateTime(2026);
|
||||
final gateTimers = <_ManualTimer>[];
|
||||
final retryTimers = <_ManualTimer>[];
|
||||
final gate = RelayRateLimitGate(
|
||||
now: () => now,
|
||||
timerFactory: (duration, callback) {
|
||||
final timer = _ManualTimer(duration, callback);
|
||||
gateTimers.add(timer);
|
||||
return timer;
|
||||
},
|
||||
);
|
||||
final socket = _RecordingRelaySocket();
|
||||
final session = RelaySessionNotifier(
|
||||
rateLimitGate: gate,
|
||||
retryTimerFactory: (duration, callback) {
|
||||
final timer = _ManualTimer(duration, callback);
|
||||
retryTimers.add(timer);
|
||||
return timer;
|
||||
},
|
||||
);
|
||||
session.debugAttachSocketForTest(socket);
|
||||
var ready = false;
|
||||
final subscribe = session
|
||||
.subscribeWhenReady(_channelFilter, (_) {})
|
||||
.whenComplete(() => ready = true);
|
||||
|
||||
session.debugHandleMessage([
|
||||
'CLOSED',
|
||||
'l-1',
|
||||
'rate-limited: quota exceeded; retry in 4s',
|
||||
]);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(ready, isFalse);
|
||||
|
||||
retryTimers.single.fire();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(ready, isFalse);
|
||||
gateTimers.single.fire();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(_reqs(socket).where((req) => req[1] == 'l-1'), hasLength(2));
|
||||
expect(ready, isFalse);
|
||||
|
||||
session.debugHandleMessage(['EOSE', 'l-1']);
|
||||
final unsubscribe = await subscribe;
|
||||
expect(ready, isTrue);
|
||||
unsubscribe();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'subscribeWhenReady cancellation removes the pending subscription',
|
||||
() async {
|
||||
final timers = <_ManualTimer>[];
|
||||
final socket = _RecordingRelaySocket();
|
||||
final session = RelaySessionNotifier(
|
||||
retryTimerFactory: (duration, callback) {
|
||||
final timer = _ManualTimer(duration, callback);
|
||||
timers.add(timer);
|
||||
return timer;
|
||||
},
|
||||
);
|
||||
session.debugAttachSocketForTest(socket);
|
||||
final cancelled = Completer<void>();
|
||||
final subscribe = session.subscribeWhenReady(
|
||||
_channelFilter,
|
||||
(_) {},
|
||||
cancelled: cancelled.future,
|
||||
);
|
||||
session.debugHandleMessage(['CLOSED', 'l-1', 'error: relay overloaded']);
|
||||
|
||||
cancelled.complete();
|
||||
await expectLater(
|
||||
subscribe,
|
||||
throwsA(isA<RelaySubscriptionCancelledException>()),
|
||||
);
|
||||
expect(timers.single.isActive, isFalse);
|
||||
expect(socket.messages, contains(equals(<dynamic>['CLOSE', 'l-1'])));
|
||||
|
||||
timers.single.fire();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(_reqs(socket).where((req) => req[1] == 'l-1'), hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test('dispose fails a pending subscribeWhenReady', () async {
|
||||
final session = RelaySessionNotifier();
|
||||
final subscribe = session.subscribeWhenReady(_channelFilter, (_) {});
|
||||
|
||||
session.debugDispose();
|
||||
|
||||
await expectLater(
|
||||
subscribe,
|
||||
throwsA(isA<RelaySubscriptionCancelledException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('CLOSED retries back off and reset after EOSE', () async {
|
||||
final timers = <_ManualTimer>[];
|
||||
final socket = _RecordingRelaySocket();
|
||||
|
||||
Reference in New Issue
Block a user