mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Pinky is opening this PR on Wes’s behalf. ## Summary Reduce two separately measured mobile delays without changing the relay API or removing rich message rendering: - Publish the finite channel-list snapshot without waiting for live subscription setup. - Batch active channel-list subscriptions into sorted, deterministic chunks of at most 128 explicit channel IDs, retaining unchanged chunks. - Install replacement chunks before retiring old coverage. Retain old chunks across thrown replacement failures; filter callbacks to the current relay/identity and still-desired channels; clean up retired/in-flight work across disconnect and disposal. - Scope the custom-emoji Markdown matcher to known shortcodes actually referenced in the rendered content, rather than embedding the whole community palette in every message’s regex. Preserve unknown literals, shared-colon token boundaries, event-tag URL priority, content edits, and code literals. - Honor explicit zero retry hints without inventing a ten-second session-wide gate, while preserving the ordinary live-subscription retry backoff and any already-active gate. ## Matched performance results Medians of three before and three after process-cold launches, alternated on the same authenticated iPhone 17 Pro / iOS 26.5 simulator. Before is mobile source at `e76c81968b65b0755b83efdd59dc3375c59ddf40`; after is this production patch before two documentation-only comment fixes. First channel-list frame: 11.617s → 3.179s · 73% lower latency Live setup duration: 8.475s → 0.185s · 98% lower latency Channel-open first message-list frame: 2.754s → 1.230s · 55% lower latency Message data ready → first frame: 1.977s → 0.286s · 86% lower latency Channel-open reveal complete: 2.845s → 1.394s · 51% lower latency Channel-open data readiness: 0.770s → 0.944s · 23% higher latency The gain is client-side orchestration/rendering, not a claim that the relay became faster. First channel-list frame ranges were 10.835–11.788s before and 2.872–3.395s after; channel-open first-frame ranges were 1.560–2.906s before and 1.149–1.317s after. ### Measurement boundaries - Debug simulator builds, CPU sampling disabled, bounded timestamp probes enabled identically. These are not release/physical-device measurements. - Startup clock starts at Dart `main`; build/install/native pre-main time is excluded. Auth/preferences and OS/disk caches are retained between new processes. - Same account scale: 113 active channels. Latest-message events varied slightly with live activity (1543–1546). - Channel-open uses the same initial 50-row history window, 97 query events, and 67 provider events. The 2306-entry emoji palette is explicitly loaded before navigation on both sides; palette preparation is excluded from the channel-open clock and happens after the startup frame measurement. - Both diagnostic builds temporarily disabled unused avatar segmentation to work around the existing Google ML Kit arm64-simulator slice limitation. The workaround, dependency/native changes, auto-navigation, and all probes are excluded from this PR. ## Validation - Full mobile package suite: `flutter test` — 1890 passed. - `just mobile-check` — 506 files unchanged; analyzer clean. - `just file-size-check` — policy tests and all client ratchets passed. - `git diff --check` — passed. - New lifecycle regressions cover front-sorting insertion across a chunk boundary while replacement readiness is paused, failure retention/departed-channel filtering, retired generation + disconnect cleanup, disposal, chunk limits, unchanged-set reuse, and scope switches. - Emoji unit/widget coverage includes a 2500-unused-emoji palette, unknown tokens, case matching at the component level, shared-colon boundaries, rich text, event URL priority, and content edits. - Fresh-frame source review traced the subscription queue/fences, callback scopes, duplicate-event paths, matcher/wiring, and retry scheduling. - At committed/pushed head `13a83b628c8411c5885e6f76a250ba87accf6067`, all normal pre-push hooks passed: `mobile-checks` (formatter, analyzer, and the full 1890-test mobile suite), `file-size-check`, `branch-skew`, and `push-head-scope`. The commit hook formatted 506 files with no changes. Runtime measurements preceded only the two documentation-comment fixes; no runtime source changed afterward. ## Limits / follow-ups - `RelaySession.subscribe` still settles under its existing EOSE/fallback/retryable-CLOSED contract. “Setup completed” is not an unconditional EOSE or live-delivery guarantee. This PR does not add status-aware replacement ownership. - The channel-message provider still awaits subscribe before fetching history; that separate serialization is not removed here. - Oversized Huddle queries and the separate history batching path above 128 active channels remain follow-ups, as do pre-existing read-state initialization/size warnings. - Palette-only widget refresh and upstream Markdown uppercase-dispatch behavior are not changed. - A clean source build still has the existing Google ML Kit arm64-simulator issue; the profiling workaround is not a proposed product fix. Originating Buzz conversation: buzz://message?channel=793b0522-7995-4375-b1a6-fd94a96fa21d&id=6ba88afdec78ab2cfb6728afcd4a6d10f29e6aa33ff0f62f45d6750381e4d789 --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
977 lines
30 KiB
Dart
977 lines
30 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:pointycastle/digests/sha256.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../auth/auth.dart';
|
|
import 'nostr_models.dart';
|
|
import 'relay_client.dart';
|
|
import 'relay_closed_policy.dart';
|
|
import 'relay_http_query_client.dart';
|
|
import 'relay_provider.dart';
|
|
import 'relay_rate_limit_gate.dart';
|
|
import 'relay_session_types.dart';
|
|
import 'relay_socket.dart';
|
|
|
|
export 'relay_session_types.dart';
|
|
|
|
part 'relay_session_auth.dart';
|
|
|
|
class _HistorySubscription {
|
|
final List<NostrEvent> events = [];
|
|
final Completer<List<NostrEvent>> completer;
|
|
final Timer timeout;
|
|
|
|
_HistorySubscription({required this.completer, required this.timeout});
|
|
}
|
|
|
|
class _LiveSubscription {
|
|
final NostrFilter filter;
|
|
final void Function(NostrEvent) onEvent;
|
|
final void Function(String message)? onClosed;
|
|
final void Function(RelaySubscriptionStatus status)? onStatusChanged;
|
|
Completer<void>? readyCompleter;
|
|
int? lastSeenCreatedAt;
|
|
int closedRetryAttempt = 0;
|
|
Timer? closedRetryTimer;
|
|
|
|
_LiveSubscription({
|
|
required this.filter,
|
|
required this.onEvent,
|
|
this.onClosed,
|
|
this.onStatusChanged,
|
|
this.readyCompleter,
|
|
});
|
|
}
|
|
|
|
class _ClosedRetry {
|
|
final _LiveSubscription subscription;
|
|
final int generation;
|
|
_ClosedRetry({required this.subscription, required this.generation});
|
|
}
|
|
|
|
class _PendingEvent {
|
|
final Completer<NostrEvent> completer;
|
|
final Timer timeout;
|
|
|
|
_PendingEvent({required this.completer, required this.timeout});
|
|
}
|
|
|
|
class _BufferedEvent {
|
|
final String subId;
|
|
final NostrEvent event;
|
|
|
|
_BufferedEvent(this.subId, this.event);
|
|
}
|
|
|
|
class RelaySessionNotifier extends Notifier<SessionState> {
|
|
RelaySessionNotifier({
|
|
http.Client? httpClient,
|
|
http.Client Function()? httpClientFactory,
|
|
RelaySocketFactory socketFactory = RelaySocket.new,
|
|
DateTime Function()? now,
|
|
RelayRateLimitGate? rateLimitGate,
|
|
RelayTimerFactory retryTimerFactory = Timer.new,
|
|
Future<void> Function(Duration) replayDelay = Future.delayed,
|
|
}) : _httpQueryClient = RelayHttpQueryClient(
|
|
client: httpClient,
|
|
clientFactory: httpClientFactory,
|
|
),
|
|
_socketFactory = socketFactory,
|
|
_now = now ?? DateTime.now,
|
|
_rateLimitGate = rateLimitGate ?? RelayRateLimitGate(),
|
|
_retryTimerFactory = retryTimerFactory,
|
|
_replayDelay = replayDelay;
|
|
|
|
final RelayHttpQueryClient _httpQueryClient;
|
|
final RelaySocketFactory _socketFactory;
|
|
final DateTime Function() _now;
|
|
final RelayRateLimitGate _rateLimitGate;
|
|
final RelayTimerFactory _retryTimerFactory;
|
|
final Future<void> Function(Duration) _replayDelay;
|
|
|
|
static const _baseReconnectDelayMs = 1000;
|
|
static const _maxReconnectDelayMs = 30000;
|
|
static const _eventBatchMs = 16;
|
|
static const _reconnectReplaySkewSeconds = 5;
|
|
static const _replayBatchSize = 8;
|
|
static const _replayInterBatchDelay = Duration(milliseconds: 50);
|
|
static const _maxRecentDeliveryKeys = 5000;
|
|
static const _backgroundGraceDuration = Duration(seconds: 5);
|
|
|
|
RelaySocket? _socket;
|
|
final Map<String, _HistorySubscription> _historySubscriptions = {};
|
|
final Map<String, _LiveSubscription> _liveSubscriptions = {};
|
|
final Map<String, _ClosedRetry> _pendingClosedRetries = {};
|
|
final Map<String, _PendingEvent> _pendingEvents = {};
|
|
final List<_BufferedEvent> _eventBuffer = [];
|
|
final Set<String> _recentDeliveryKeys = {};
|
|
Timer? _reconnectTimer;
|
|
Timer? _flushTimer;
|
|
Timer? _backgroundGraceTimer;
|
|
DateTime? _backgroundedAt;
|
|
int _reconnectDelayMs = _baseReconnectDelayMs;
|
|
int _subIdCounter = 0;
|
|
bool _disposed = false;
|
|
bool _paused = false;
|
|
bool _hasConnectedOnce = false;
|
|
int _connectionGeneration = 0;
|
|
final Map<Object, String> _visibleChannelsByOwner = {};
|
|
final Map<Object, Future<void> Function()> _beforePauseCallbacks = {};
|
|
bool _socketConnected = false;
|
|
bool _closedRetryReplayScheduled = false;
|
|
|
|
@override
|
|
SessionState build() {
|
|
final config = ref.watch(relayConfigProvider);
|
|
final authState = ref.watch(authProvider);
|
|
|
|
// Reset disposed flag — build() may re-run on the same Notifier instance
|
|
// after a provider dependency changes (e.g. auth completing).
|
|
_disposed = false;
|
|
|
|
ref.onDispose(_dispose);
|
|
|
|
// Auto-connect when authenticated and we have a signing key (NIP-42 AUTH).
|
|
final isAuthenticated = authState.value?.status == AuthStatus.authenticated;
|
|
if (isAuthenticated && config.nsec != null) {
|
|
// Schedule connection after build completes.
|
|
Future.microtask(() => _connect(config));
|
|
}
|
|
|
|
return const SessionState(status: SessionStatus.disconnected);
|
|
}
|
|
|
|
/// Execute a one-shot query via the relay's HTTP bridge (`POST /query`).
|
|
Future<List<NostrEvent>> queryRelay(
|
|
List<NostrFilter> filters, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
final config = ref.read(relayConfigProvider);
|
|
final url = Uri.parse(config.baseUrl).resolve('/query').toString();
|
|
final bodyBytes = utf8.encode(
|
|
jsonEncode(filters.map((filter) => filter.toJson()).toList()),
|
|
);
|
|
// Reuse the session transport on success. A timeout rotates immediately
|
|
// for new queries, then closes the retired client after its peers finish.
|
|
final response = await _httpQueryClient.post(
|
|
Uri.parse(url),
|
|
headers: {
|
|
'Authorization': buildNip98AuthHeader(
|
|
method: 'POST',
|
|
url: url,
|
|
bodyBytes: bodyBytes,
|
|
nsec: config.nsec,
|
|
),
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: bodyBytes,
|
|
timeout: timeout,
|
|
);
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
_activateRateLimitGateFromHttpError(response.body);
|
|
throw RelayException(response.statusCode, response.body);
|
|
}
|
|
final decoded = jsonDecode(response.body);
|
|
if (decoded is! List) {
|
|
throw const FormatException('relay returned malformed query response');
|
|
}
|
|
try {
|
|
return [
|
|
for (final eventJson in decoded)
|
|
if (eventJson is Map<String, dynamic>)
|
|
NostrEvent.fromJson(eventJson)
|
|
else
|
|
throw const FormatException('relay returned malformed query event'),
|
|
];
|
|
} catch (error) {
|
|
if (error is FormatException) rethrow;
|
|
throw FormatException('relay returned malformed query event: $error');
|
|
}
|
|
}
|
|
|
|
void _activateRateLimitGateFromHttpError(String body) {
|
|
final dynamic decoded;
|
|
try {
|
|
decoded = jsonDecode(body);
|
|
} on FormatException {
|
|
return;
|
|
}
|
|
if (decoded is! Map<String, dynamic>) return;
|
|
final message = decoded['error'];
|
|
if (message is! String ||
|
|
classifyRelayClosed(message) != RelayClosedClass.rateLimited) {
|
|
return;
|
|
}
|
|
_rateLimitGate.activate(parseRateLimitRetrySeconds(message));
|
|
}
|
|
|
|
/// Fetch historical events matching [filter]. Sends REQ, collects events
|
|
/// until EOSE, then resolves. One-shot subscription.
|
|
Future<List<NostrEvent>> fetchHistory(
|
|
NostrFilter filter, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async {
|
|
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
|
|
if (_disposed) throw StateError('Relay session is disposed');
|
|
final subId = _nextSubId('h');
|
|
final completer = Completer<List<NostrEvent>>();
|
|
|
|
final timer = Timer(timeout, () {
|
|
final sub = _historySubscriptions.remove(subId);
|
|
if (sub != null && !sub.completer.isCompleted) {
|
|
sub.completer.completeError(
|
|
TimeoutException('Relay history request timed out after $timeout'),
|
|
);
|
|
}
|
|
_sendClose(subId);
|
|
});
|
|
|
|
_historySubscriptions[subId] = _HistorySubscription(
|
|
completer: completer,
|
|
timeout: timer,
|
|
);
|
|
|
|
_sendReq(subId, filter);
|
|
return completer.future;
|
|
}
|
|
|
|
Future<void Function()> subscribe(
|
|
NostrFilter filter,
|
|
void Function(NostrEvent) onEvent, {
|
|
void Function(String message)? onClosed,
|
|
}) => _subscribe(filter, onEvent, onClosed: onClosed);
|
|
|
|
/// Subscribe to a live stream and observe its recovery lifecycle.
|
|
///
|
|
/// The returned future completes after initial EOSE (or the existing
|
|
/// fallback timeout) and yields a cleanup callback. [onStatusChanged] emits
|
|
/// [RelaySubscriptionStatus.ready] at each EOSE after buffered replay events
|
|
/// have been delivered, and [RelaySubscriptionStatus.retrying] immediately
|
|
/// when a retryable or rate-limited CLOSED begins backoff. Terminal CLOSED
|
|
/// invokes [onClosed] and removes the subscription instead of retrying it.
|
|
/// Calling the cleanup callback cancels pending retries and sends CLOSE.
|
|
Future<void Function()> subscribeWithStatus(
|
|
NostrFilter filter,
|
|
void Function(NostrEvent) onEvent, {
|
|
void Function(String message)? onClosed,
|
|
required void Function(RelaySubscriptionStatus status) onStatusChanged,
|
|
}) => _subscribe(
|
|
filter,
|
|
onEvent,
|
|
onClosed: onClosed,
|
|
onStatusChanged: onStatusChanged,
|
|
);
|
|
|
|
Future<void Function()> _subscribe(
|
|
NostrFilter filter,
|
|
void Function(NostrEvent) onEvent, {
|
|
void Function(String message)? onClosed,
|
|
void Function(RelaySubscriptionStatus status)? onStatusChanged,
|
|
}) async {
|
|
if (_disposed) throw StateError('Relay session is disposed');
|
|
final subId = _nextSubId('l');
|
|
final readyCompleter = Completer<void>();
|
|
|
|
_liveSubscriptions[subId] = _LiveSubscription(
|
|
filter: filter,
|
|
onEvent: onEvent,
|
|
onClosed: onClosed,
|
|
onStatusChanged: onStatusChanged,
|
|
readyCompleter: readyCompleter,
|
|
);
|
|
|
|
_sendReq(subId, filter);
|
|
|
|
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);
|
|
}
|
|
|
|
Future<NostrEvent> publish(
|
|
NostrEvent event, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) {
|
|
final completer = Completer<NostrEvent>();
|
|
|
|
final timer = Timer(timeout, () {
|
|
final pending = _pendingEvents.remove(event.id);
|
|
if (pending != null && !pending.completer.isCompleted) {
|
|
pending.completer.completeError(
|
|
TimeoutException(
|
|
'Event ${event.id} not acknowledged within $timeout',
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
_pendingEvents[event.id] = _PendingEvent(
|
|
completer: completer,
|
|
timeout: timer,
|
|
);
|
|
|
|
_socket?.send(['EVENT', event.toJson()]);
|
|
return completer.future;
|
|
}
|
|
|
|
void sendRaw(List<dynamic> payload) {
|
|
_socket?.send(payload);
|
|
}
|
|
|
|
@visibleForTesting
|
|
void debugHandleMessage(List<dynamic> data) => _handleMessage(data);
|
|
|
|
@visibleForTesting
|
|
void debugFlushEventBuffer() => _flushEventBuffer();
|
|
|
|
@visibleForTesting
|
|
Future<void> debugHandleConnected() =>
|
|
_handleConnected(_connectionGeneration);
|
|
|
|
@visibleForTesting
|
|
Future<void> debugReplayLiveSubscriptions() =>
|
|
_replayLiveSubscriptions(_connectionGeneration);
|
|
|
|
@visibleForTesting
|
|
void debugDispose() => _dispose();
|
|
|
|
@visibleForTesting
|
|
void debugSupersedeConnection() => _connectionGeneration++;
|
|
|
|
@visibleForTesting
|
|
void debugHandleDisconnected([Object? error]) {
|
|
_socketConnected = false;
|
|
_handleDisconnected(_connectionGeneration, error);
|
|
}
|
|
|
|
@visibleForTesting
|
|
void debugResetClosedRetriesForDisconnect() {
|
|
_socketConnected = false;
|
|
_resetAllClosedRetries();
|
|
}
|
|
|
|
@visibleForTesting
|
|
void debugSetSessionStatus(SessionStatus status) {
|
|
_socketConnected = status == SessionStatus.connected;
|
|
}
|
|
|
|
@visibleForTesting
|
|
void debugPauseNow() => _pauseNow();
|
|
|
|
@visibleForTesting
|
|
void debugHandleSocketMessageForTest(List<dynamic> data) =>
|
|
_handleMessage(data);
|
|
|
|
@visibleForTesting
|
|
void debugAttachSocketForTest(RelaySocket socket) {
|
|
_socket?.dispose();
|
|
_socket = socket;
|
|
_socketConnected = true;
|
|
}
|
|
|
|
/// Registers a visible channel and returns an owner-scoped release callback.
|
|
/// The most recently registered owner is prioritized during reconnect replay.
|
|
void Function() registerVisibleChannel(String channelId) {
|
|
final owner = Object();
|
|
_visibleChannelsByOwner[owner] = channelId;
|
|
return () => _visibleChannelsByOwner.remove(owner);
|
|
}
|
|
|
|
/// Force a reconnect (e.g., returning from background).
|
|
Future<void> reconnect() async {
|
|
_socketConnected = false;
|
|
await _socket?.disconnect();
|
|
_reconnectDelayMs = _baseReconnectDelayMs;
|
|
final config = ref.read(relayConfigProvider);
|
|
await _connect(config);
|
|
}
|
|
|
|
/// Registers work that must settle before the background grace disconnect.
|
|
void Function() registerBeforePause(Future<void> Function() callback) {
|
|
final owner = Object();
|
|
_beforePauseCallbacks[owner] = callback;
|
|
return () => _beforePauseCallbacks.remove(owner);
|
|
}
|
|
|
|
/// Called by the app lifecycle provider when the app goes to background.
|
|
void onAppPaused() {
|
|
_backgroundedAt = _now();
|
|
_backgroundGraceTimer?.cancel();
|
|
_backgroundGraceTimer = Timer(_backgroundGraceDuration, () {
|
|
unawaited(_pauseAfterCallbacks());
|
|
});
|
|
}
|
|
|
|
Future<void> _pauseAfterCallbacks() async {
|
|
final callbacks = _beforePauseCallbacks.values.toList();
|
|
try {
|
|
await Future.wait(callbacks.map((callback) => callback()));
|
|
} catch (error) {
|
|
debugPrint('Background cleanup failed: $error');
|
|
}
|
|
if (_backgroundedAt != null) _pauseNow();
|
|
}
|
|
|
|
void _pauseNow() {
|
|
_paused = true;
|
|
_socketConnected = false;
|
|
_reconnectTimer?.cancel();
|
|
_cancelAllHistory(Exception('App moved to background'));
|
|
_rejectAllPending(Exception('App moved to background'));
|
|
_socket?.disconnect();
|
|
state = const SessionState(status: SessionStatus.disconnected);
|
|
}
|
|
|
|
/// Called by the app lifecycle provider when the app returns to foreground.
|
|
void onAppResumed() {
|
|
_paused = false;
|
|
final backgroundedAt = _backgroundedAt;
|
|
_backgroundedAt = null;
|
|
_backgroundGraceTimer?.cancel();
|
|
_backgroundGraceTimer = null;
|
|
|
|
final backgroundedLongEnoughToRequireReconnect =
|
|
backgroundedAt != null &&
|
|
_now().difference(backgroundedAt) >= _backgroundGraceDuration;
|
|
if (!backgroundedLongEnoughToRequireReconnect &&
|
|
state.status == SessionStatus.connected) {
|
|
return;
|
|
}
|
|
|
|
// Cancel any in-flight reconnect backoff timer so we reconnect immediately
|
|
// instead of waiting for the (possibly large) exponential delay.
|
|
_reconnectTimer?.cancel();
|
|
_reconnectDelayMs = _baseReconnectDelayMs;
|
|
final config = ref.read(relayConfigProvider);
|
|
_connect(config);
|
|
}
|
|
|
|
Future<void> _connect(RelayConfig config) async {
|
|
if (_disposed) return;
|
|
|
|
final generation = ++_connectionGeneration;
|
|
state = SessionState(
|
|
status: _hasConnectedOnce
|
|
? SessionStatus.reconnecting
|
|
: SessionStatus.connecting,
|
|
reconnectAttempt: state.reconnectAttempt,
|
|
);
|
|
|
|
_socket?.dispose();
|
|
final socket = _socketFactory(
|
|
wsUrl: config.wsUrl,
|
|
nsec: config.nsec,
|
|
onMessage: (message) {
|
|
if (generation == _connectionGeneration) _handleMessage(message);
|
|
},
|
|
onConnected: () => _handleConnected(generation),
|
|
onDisconnected: (error) => _handleDisconnected(generation, error),
|
|
);
|
|
_socket = socket;
|
|
|
|
await socket.connect();
|
|
}
|
|
|
|
Future<void> _handleConnected(int generation) async {
|
|
if (_disposed || generation != _connectionGeneration) return;
|
|
_socketConnected = true;
|
|
_hasConnectedOnce = true;
|
|
_reconnectDelayMs = _baseReconnectDelayMs;
|
|
state = const SessionState(status: SessionStatus.connected);
|
|
await _replayLiveSubscriptions(generation);
|
|
}
|
|
|
|
void _handleDisconnected(int generation, Object? error) {
|
|
if (_disposed || generation != _connectionGeneration) return;
|
|
_socketConnected = false;
|
|
_cancelAllHistory(error);
|
|
_rejectAllPending(error);
|
|
_resetAllClosedRetries();
|
|
_eventBuffer.clear();
|
|
_flushTimer?.cancel();
|
|
_flushTimer = null;
|
|
if (error is RelayAuthRejectedException) {
|
|
_reconnectTimer?.cancel();
|
|
state = const SessionState(status: SessionStatus.disconnected);
|
|
return;
|
|
}
|
|
_scheduleReconnect();
|
|
}
|
|
|
|
void _scheduleReconnect() {
|
|
if (_disposed || _paused) return;
|
|
final attempt = state.reconnectAttempt + 1;
|
|
state = SessionState(
|
|
status: SessionStatus.reconnecting,
|
|
reconnectAttempt: attempt,
|
|
);
|
|
|
|
_reconnectTimer?.cancel();
|
|
_reconnectTimer = Timer(Duration(milliseconds: _reconnectDelayMs), () {
|
|
_reconnectDelayMs = min(_reconnectDelayMs * 2, _maxReconnectDelayMs);
|
|
final config = ref.read(relayConfigProvider);
|
|
_connect(config);
|
|
});
|
|
}
|
|
|
|
/// Replay all live subscriptions after a reconnect, with a time skew to
|
|
/// catch events that occurred during the disconnect.
|
|
Future<void> _replayLiveSubscriptions(int generation) async {
|
|
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
|
|
if (!_isActiveConnection(generation)) return;
|
|
|
|
final entries = _liveSubscriptions.entries.toList();
|
|
final visibleChannelId = _visibleChannelsByOwner.isEmpty
|
|
? null
|
|
: _visibleChannelsByOwner.values.last;
|
|
if (visibleChannelId != null) {
|
|
entries.sort((left, right) {
|
|
final leftVisible =
|
|
left.value.filter.tags['#h']?.contains(visibleChannelId) ?? false;
|
|
final rightVisible =
|
|
right.value.filter.tags['#h']?.contains(visibleChannelId) ?? false;
|
|
if (leftVisible == rightVisible) return 0;
|
|
return leftVisible ? -1 : 1;
|
|
});
|
|
}
|
|
|
|
await _sendReplayBatches(entries, generation);
|
|
}
|
|
|
|
Future<void> _replayPendingClosedRetries(int generation) async {
|
|
if (!_isActiveConnection(generation)) return;
|
|
final entries = _pendingClosedRetries.entries
|
|
.where((entry) => entry.value.generation == generation)
|
|
.map(
|
|
(entry) => MapEntry<String, _LiveSubscription>(
|
|
entry.key,
|
|
entry.value.subscription,
|
|
),
|
|
)
|
|
.toList();
|
|
await _sendReplayBatches(entries, generation, pendingClosedRetries: true);
|
|
}
|
|
|
|
Future<void> _sendReplayBatches(
|
|
List<MapEntry<String, _LiveSubscription>> entries,
|
|
int generation, {
|
|
bool pendingClosedRetries = false,
|
|
}) async {
|
|
for (var i = 0; i < entries.length; i += _replayBatchSize) {
|
|
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
|
|
if (!_isActiveConnection(generation)) return;
|
|
final batch = entries.sublist(
|
|
i,
|
|
min(i + _replayBatchSize, entries.length),
|
|
);
|
|
for (final entry in batch) {
|
|
if (_liveSubscriptions[entry.key] != entry.value) continue;
|
|
if (pendingClosedRetries) {
|
|
final pendingRetry = _pendingClosedRetries[entry.key];
|
|
if (pendingRetry?.subscription != entry.value ||
|
|
pendingRetry?.generation != generation) {
|
|
continue;
|
|
}
|
|
_pendingClosedRetries.remove(entry.key);
|
|
}
|
|
_sendReq(entry.key, _replayFilter(entry.value));
|
|
}
|
|
if (i + _replayBatchSize < entries.length) {
|
|
await _replayDelay(_replayInterBatchDelay);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _isActiveConnection(int generation) =>
|
|
!_disposed && generation == _connectionGeneration;
|
|
|
|
NostrFilter _replayFilter(_LiveSubscription subscription) {
|
|
final since = subscription.lastSeenCreatedAt;
|
|
return since == null
|
|
? subscription.filter
|
|
: subscription.filter.copyWithSince(
|
|
max(0, since - _reconnectReplaySkewSeconds),
|
|
);
|
|
}
|
|
|
|
void _handleMessage(List<dynamic> data) {
|
|
if (data.isEmpty) return;
|
|
final type = data[0] as String;
|
|
|
|
switch (type) {
|
|
case 'EVENT':
|
|
_handleEvent(data);
|
|
case 'EOSE':
|
|
_handleEose(data);
|
|
case 'CLOSED':
|
|
_handleClosed(data);
|
|
case 'OK':
|
|
_handleOk(data);
|
|
}
|
|
}
|
|
|
|
void _handleEvent(List<dynamic> data) {
|
|
if (data.length < 3) return;
|
|
final subId = data[1] as String;
|
|
final eventJson = data[2] as Map<String, dynamic>;
|
|
final event = NostrEvent.fromJson(eventJson);
|
|
|
|
// History subscriptions accumulate immediately.
|
|
final historySub = _historySubscriptions[subId];
|
|
if (historySub != null) {
|
|
historySub.events.add(event);
|
|
return;
|
|
}
|
|
|
|
// Live subscriptions get batched. An EVENT proves the stream is active,
|
|
// but not that a retry replay is complete; only EOSE is that boundary.
|
|
final liveSub = _liveSubscriptions[subId];
|
|
if (liveSub != null) {
|
|
_resetClosedRetry(liveSub);
|
|
// Track last seen timestamp for reconnect replay.
|
|
if (liveSub.lastSeenCreatedAt == null ||
|
|
event.createdAt > liveSub.lastSeenCreatedAt!) {
|
|
liveSub.lastSeenCreatedAt = event.createdAt;
|
|
}
|
|
_eventBuffer.add(_BufferedEvent(subId, event));
|
|
_scheduleFlush();
|
|
}
|
|
}
|
|
|
|
void _handleEose(List<dynamic> data) {
|
|
if (data.length < 2) return;
|
|
final subId = data[1] as String;
|
|
|
|
// History subscription: resolve with collected events.
|
|
final historySub = _historySubscriptions.remove(subId);
|
|
if (historySub != null) {
|
|
historySub.timeout.cancel();
|
|
if (!historySub.completer.isCompleted) {
|
|
historySub.completer.complete(historySub.events);
|
|
}
|
|
_sendClose(subId);
|
|
return;
|
|
}
|
|
|
|
// Live subscription: flush replay callbacks before signaling ready. This
|
|
// ordering matters for retry replays, whose original ready completer has
|
|
// already been released.
|
|
final liveSub = _liveSubscriptions[subId];
|
|
if (liveSub != null) {
|
|
_resetClosedRetry(liveSub);
|
|
_flushBufferedEventsNow();
|
|
liveSub.onStatusChanged?.call(RelaySubscriptionStatus.ready);
|
|
}
|
|
if (liveSub != null &&
|
|
liveSub.readyCompleter != null &&
|
|
!liveSub.readyCompleter!.isCompleted) {
|
|
liveSub.readyCompleter!.complete();
|
|
liveSub.readyCompleter = null;
|
|
}
|
|
}
|
|
|
|
void _handleClosed(List<dynamic> data) {
|
|
if (data.length < 2) return;
|
|
final subId = data[1] as String;
|
|
final message = data.length >= 3 && data[2] is String
|
|
? data[2] as String
|
|
: 'subscription closed by relay';
|
|
final closedClass = classifyRelayClosed(message);
|
|
|
|
final historySub = _historySubscriptions.remove(subId);
|
|
if (historySub != null) {
|
|
if (closedClass == RelayClosedClass.rateLimited) {
|
|
_rateLimitGate.activate(parseRateLimitRetrySeconds(message));
|
|
}
|
|
historySub.timeout.cancel();
|
|
if (!historySub.completer.isCompleted) {
|
|
historySub.completer.completeError(Exception(message));
|
|
}
|
|
return;
|
|
}
|
|
|
|
final liveSub = _liveSubscriptions[subId];
|
|
if (liveSub == null) return;
|
|
final readyCompleter = liveSub.readyCompleter;
|
|
if (closedClass == RelayClosedClass.terminal) {
|
|
if (readyCompleter != null && !readyCompleter.isCompleted) {
|
|
readyCompleter.completeError(Exception(message));
|
|
}
|
|
liveSub.onClosed?.call(message);
|
|
_removeLiveSubscription(subId, liveSub);
|
|
return;
|
|
}
|
|
if (readyCompleter != null && !readyCompleter.isCompleted) {
|
|
readyCompleter.complete();
|
|
liveSub.readyCompleter = null;
|
|
}
|
|
liveSub.onStatusChanged?.call(RelaySubscriptionStatus.retrying);
|
|
if (liveSub.closedRetryTimer != null) return;
|
|
|
|
final attempt = liveSub.closedRetryAttempt;
|
|
final backoffMs = attempt >= 5
|
|
? _maxReconnectDelayMs
|
|
: _baseReconnectDelayMs * (1 << attempt);
|
|
var delayMs = backoffMs;
|
|
if (closedClass == RelayClosedClass.rateLimited) {
|
|
final retrySeconds = parseRateLimitRetrySeconds(message);
|
|
_rateLimitGate.activate(retrySeconds);
|
|
final fallbackMs =
|
|
(retrySeconds != null
|
|
? min(retrySeconds, RelayRateLimitGate.maxRetrySeconds)
|
|
: RelayRateLimitGate.defaultRetrySeconds) *
|
|
1000;
|
|
delayMs = max(
|
|
backoffMs,
|
|
_rateLimitGate.remainingMs() == 0
|
|
? fallbackMs
|
|
: _rateLimitGate.remainingMs(),
|
|
);
|
|
}
|
|
|
|
liveSub.closedRetryAttempt = attempt + 1;
|
|
final retryGeneration = _connectionGeneration;
|
|
liveSub.closedRetryTimer = _retryTimerFactory(
|
|
Duration(milliseconds: delayMs),
|
|
() async {
|
|
liveSub.closedRetryTimer = null;
|
|
if (!_isActiveConnection(retryGeneration) ||
|
|
_liveSubscriptions[subId] != liveSub) {
|
|
return;
|
|
}
|
|
if (_rateLimitGate.isActive) await _rateLimitGate.wait();
|
|
if (!_isActiveConnection(retryGeneration) ||
|
|
_liveSubscriptions[subId] != liveSub ||
|
|
!_socketConnected) {
|
|
return;
|
|
}
|
|
_pendingClosedRetries[subId] = _ClosedRetry(
|
|
subscription: liveSub,
|
|
generation: retryGeneration,
|
|
);
|
|
_scheduleClosedRetryReplay(retryGeneration);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _scheduleClosedRetryReplay(int generation) {
|
|
if (_closedRetryReplayScheduled) return;
|
|
_closedRetryReplayScheduled = true;
|
|
scheduleMicrotask(() async {
|
|
try {
|
|
await _replayPendingClosedRetries(generation);
|
|
} finally {
|
|
_closedRetryReplayScheduled = false;
|
|
_pendingClosedRetries.removeWhere(
|
|
(_, retry) => retry.generation != _connectionGeneration,
|
|
);
|
|
if (_pendingClosedRetries.values.any(
|
|
(retry) => retry.generation == _connectionGeneration,
|
|
)) {
|
|
_scheduleClosedRetryReplay(_connectionGeneration);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
void _handleOk(List<dynamic> data) {
|
|
if (data.length < 3) return;
|
|
final eventId = data[1] as String;
|
|
final accepted = data[2] as bool;
|
|
final message = data.length > 3 && data[3] is String
|
|
? data[3] as String
|
|
: '';
|
|
|
|
final pending = _pendingEvents.remove(eventId);
|
|
if (pending == null) return;
|
|
pending.timeout.cancel();
|
|
|
|
if (accepted) {
|
|
// We don't have the full event here; create a minimal placeholder.
|
|
// Command kinds (e.g. 41010, 30620, 46020) return "response:{...}" in
|
|
// the OK message — preserve it in `content` so callers can parse it.
|
|
if (!pending.completer.isCompleted) {
|
|
pending.completer.complete(
|
|
NostrEvent(
|
|
id: eventId,
|
|
pubkey: '',
|
|
createdAt: 0,
|
|
kind: 0,
|
|
tags: [],
|
|
content: message,
|
|
sig: '',
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
if (!pending.completer.isCompleted) {
|
|
pending.completer.completeError(
|
|
Exception(message.isNotEmpty ? message : 'Event rejected'),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _scheduleFlush() {
|
|
_flushTimer ??= Timer(
|
|
const Duration(milliseconds: _eventBatchMs),
|
|
_flushEventBuffer,
|
|
);
|
|
}
|
|
|
|
void _flushBufferedEventsNow() {
|
|
_flushTimer?.cancel();
|
|
_flushTimer = null;
|
|
_flushEventBuffer();
|
|
}
|
|
|
|
void _flushEventBuffer() {
|
|
_flushTimer = null;
|
|
if (_eventBuffer.isEmpty) return;
|
|
|
|
final batch = List<_BufferedEvent>.from(_eventBuffer);
|
|
_eventBuffer.clear();
|
|
|
|
for (final buffered in batch) {
|
|
final sub = _liveSubscriptions[buffered.subId];
|
|
if (sub == null) continue;
|
|
|
|
// Deduplicate per subscription. The same relay event can legitimately
|
|
// match multiple live subscriptions, e.g. the channel list unread listener
|
|
// and the open channel message listener.
|
|
final deliveryKey = '${buffered.subId}:${buffered.event.id}';
|
|
if (_recentDeliveryKeys.contains(deliveryKey)) continue;
|
|
|
|
// Cap the dedup set to prevent unbounded memory growth.
|
|
if (_recentDeliveryKeys.length >= _maxRecentDeliveryKeys) {
|
|
_recentDeliveryKeys.clear();
|
|
}
|
|
_recentDeliveryKeys.add(deliveryKey);
|
|
|
|
sub.onEvent(buffered.event);
|
|
}
|
|
}
|
|
|
|
String _nextSubId(String prefix) {
|
|
_subIdCounter++;
|
|
return '$prefix-$_subIdCounter';
|
|
}
|
|
|
|
void _sendReq(String subId, NostrFilter filter) {
|
|
_socket?.send(['REQ', subId, filter.toJson()]);
|
|
}
|
|
|
|
void _sendClose(String subId) {
|
|
_socket?.send(['CLOSE', subId]);
|
|
}
|
|
|
|
void _unsubscribe(String subId) {
|
|
final subscription = _liveSubscriptions[subId];
|
|
if (subscription != null) {
|
|
_removeLiveSubscription(subId, subscription);
|
|
}
|
|
_sendClose(subId);
|
|
}
|
|
|
|
void _removeLiveSubscription(String subId, _LiveSubscription subscription) {
|
|
if (_liveSubscriptions[subId] != subscription) return;
|
|
_liveSubscriptions.remove(subId);
|
|
_pendingClosedRetries.remove(subId);
|
|
subscription.closedRetryTimer?.cancel();
|
|
subscription.closedRetryTimer = null;
|
|
_recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:'));
|
|
}
|
|
|
|
void _resetClosedRetry(_LiveSubscription subscription) {
|
|
subscription.closedRetryAttempt = 0;
|
|
subscription.closedRetryTimer?.cancel();
|
|
subscription.closedRetryTimer = null;
|
|
}
|
|
|
|
void _cancelAllClosedRetries() {
|
|
_pendingClosedRetries.clear();
|
|
for (final subscription in _liveSubscriptions.values) {
|
|
subscription.closedRetryTimer?.cancel();
|
|
subscription.closedRetryTimer = null;
|
|
}
|
|
}
|
|
|
|
void _resetAllClosedRetries() {
|
|
_pendingClosedRetries.clear();
|
|
for (final subscription in _liveSubscriptions.values) {
|
|
_resetClosedRetry(subscription);
|
|
}
|
|
}
|
|
|
|
void _cancelAllHistory(Object? error) {
|
|
for (final entry in _historySubscriptions.values) {
|
|
entry.timeout.cancel();
|
|
if (!entry.completer.isCompleted) {
|
|
entry.completer.completeError(error ?? Exception('Connection lost'));
|
|
}
|
|
}
|
|
_historySubscriptions.clear();
|
|
}
|
|
|
|
void _rejectAllPending(Object? error) {
|
|
for (final entry in _pendingEvents.values) {
|
|
entry.timeout.cancel();
|
|
if (!entry.completer.isCompleted) {
|
|
entry.completer.completeError(error ?? Exception('Connection lost'));
|
|
}
|
|
}
|
|
_pendingEvents.clear();
|
|
}
|
|
|
|
void _dispose() {
|
|
_disposed = true;
|
|
_beforePauseCallbacks.clear();
|
|
_connectionGeneration++;
|
|
_reconnectTimer?.cancel();
|
|
_flushTimer?.cancel();
|
|
_backgroundGraceTimer?.cancel();
|
|
_backgroundedAt = null;
|
|
_cancelAllClosedRetries();
|
|
_rateLimitGate.reset();
|
|
_visibleChannelsByOwner.clear();
|
|
_socketConnected = false;
|
|
_cancelAllHistory(null);
|
|
_rejectAllPending(null);
|
|
final subscriptions = _liveSubscriptions.values.toList();
|
|
_liveSubscriptions.clear();
|
|
for (final subscription in subscriptions) {
|
|
subscription.closedRetryTimer?.cancel();
|
|
subscription.closedRetryTimer = null;
|
|
}
|
|
_recentDeliveryKeys.clear();
|
|
_socket?.dispose();
|
|
_socket = null;
|
|
_httpQueryClient.close();
|
|
}
|
|
}
|
|
|
|
final relaySessionProvider =
|
|
NotifierProvider<RelaySessionNotifier, SessionState>(
|
|
RelaySessionNotifier.new,
|
|
);
|