Files
buzz/mobile/lib/shared/relay/relay_session.dart
ce56e34411 fix(mobile): recover stale relay sessions (#4372)
### Summary

Fixes [this
issue](buzz://message?channel=e62570dd-33ad-42c5-b92b-75f2689f9694&id=b726c366abfe62429ee3cdcd34d0c0fb98c33c3ea053480585bed71745412b56):
> I often don’t see my bot responses until after I post. they’re usually
time stamped correctly so I think it’s just a refresh issue?

### What changed?

Buzz Mobile now reconnects relay sessions after the app has remained
backgrounded beyond the existing 5-second grace period, even when the
session still reports a stale `connected` state. This makes resume
recovery independent of whether iOS runs the grace timer before or after
delivering `resumed`.

Reconnection is now based on elapsed background time rather than a
direct socket-health probe.
- If the app was backgrounded for at least the 5-second grace period,
the socket is presumed dead and the session reconnects regardless of
reported status.
- If it was backgrounded for less than that, a reported `connected`
status is still trusted.

In the sub-5-second window the socket is either genuinely alive, which
is the common case for a momentary background, or it is dead and the
client ping detects it within the two-interval worst case described
below. That is now a degraded-latency path, not a silent-forever path.

The mobile relay socket now uses `IOWebSocketChannel.connect` with a
30-second `pingInterval`. An unanswered ping closes the Dart socket
through the existing disconnect and reconnect path.

Detection takes up to two ping intervals, so about 60 seconds worst
case, not 30. One interval of idleness elapses and a ping is sent, then
a second interval elapses with no pong and the socket closes. Any
inbound pong restarts the first stage, so the clock measures idleness
rather than running on a fixed cadence.

### Why?

Buzz iOS can sometimes stop showing new bot or agent responses after a
phone has been locked for 5 to 10 minutes. When the user later posts a
message, the missing responses can appear all at once. iOS may suspend
Buzz before the short delayed cleanup that would normally close its
connection has a chance to run. Before this change, Buzz trusted the
resulting stale healthy status on resume and skipped reconnecting, so
the missing responses stayed hidden until a later post exposed the dead
connection.

A state-machine test with a stubbed connection reproduced this reported
pattern and showed that it matches this failure mode: the failed post
triggered a reconnect that fetched the missing messages. The same test
also checked the other candidate explanation, the bug tracked in
[#3053](https://github.com/block/buzz/pull/3053), where the relay has
closed the app's subscription. That state does not produce the pattern.
Posting succeeds and the user's own message appears, but nothing looks
for the missed messages, so they stay hidden. The test confirmed that
the missed messages were still available to fetch in that state, so the
missing step was a trigger to fetch them. This was not an end-to-end
reproduction on an iOS device or a live relay.

The new resume check covers the normal lock and unlock path. If the app
was backgrounded for less than the 5-second grace period, it still
trusts a connection marked as healthy. A dead connection in that window
is instead detected by the ping check, which can take up to about 60
seconds but prevents the app from remaining silently stuck. The ping
only runs while iOS is running the app, so it does not detect a
connection that died during suspension; the resume check owns the lock
and unlock path.

A pre-existing path also runs the same resume handling when network
connectivity returns while the app is already in the foreground. Because
the app was not backgrounded, this change does not alter that path,
which still trusts a connection marked as healthy and relies on the
slower ping check.

Recovery from a subscription that the relay explicitly closes remains in
[#3053](https://github.com/block/buzz/pull/3053), and the two changes
overlap in one file. Changes to how missed messages are backfilled or
replayed are out of scope.

### How is it tested?

Full mobile suite at base and head. Both runs have the same known
macOS-host-only failure in `ChannelDetailPage keeps follow mode off
while a tall newest message stays visible` at line 1053:

- Base: 1,021 passed, 1 skipped, 1 failed
- Head: 1,025 passed, 1 skipped, 1 failed

Added tests:

-
[`relay_session_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/shared/relay/relay_session_test.dart):
long-background resume reconnect and within-grace control
-
[`relay_socket_liveness_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/shared/relay/relay_socket_liveness_test.dart):
silent-peer disconnect and idle-but-healthy control

Mutation checks confirm that removing elapsed-background resume recovery
fails with one socket instead of two, and removing `pingInterval` leaves
the silent peer connected. Restored production code passes both
mutations' regression tests and the healthy idle control.

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
2026-08-03 12:28:50 -07:00

979 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_provider.dart';
import 'relay_rate_limit_gate.dart';
import 'relay_socket.dart';
enum SessionStatus { disconnected, connecting, connected, reconnecting }
@immutable
class SessionState {
final SessionStatus status;
final int reconnectAttempt;
const SessionState({required this.status, this.reconnectAttempt = 0});
}
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;
Completer<void>? readyCompleter;
int? lastSeenCreatedAt;
int closedRetryAttempt = 0;
Timer? closedRetryTimer;
_LiveSubscription({
required this.filter,
required this.onEvent,
this.onClosed,
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);
}
/// Manages websocket subscriptions, event batching, reconnection with replay,
/// and pending event tracking. Equivalent to the desktop's RelayClientSession.
typedef RelaySocketFactory =
RelaySocket Function({
required String wsUrl,
required String? nsec,
required void Function(List<dynamic> message) onMessage,
required void Function() onConnected,
required void Function(Object? error) onDisconnected,
});
class RelaySessionNotifier extends Notifier<SessionState> {
RelaySessionNotifier({
http.Client? httpClient,
RelaySocketFactory socketFactory = RelaySocket.new,
DateTime Function()? now,
RelayRateLimitGate? rateLimitGate,
RelayTimerFactory retryTimerFactory = Timer.new,
Future<void> Function(Duration) replayDelay = Future.delayed,
}) : _httpClient = httpClient,
_socketFactory = socketFactory,
_now = now ?? DateTime.now,
_rateLimitGate = rateLimitGate ?? RelayRateLimitGate(),
_retryTimerFactory = retryTimerFactory,
_replayDelay = replayDelay;
final http.Client? _httpClient;
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 = {};
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()),
);
final client = _httpClient ?? http.Client();
final shouldCloseClient = _httpClient == null;
final response = await client
.post(
Uri.parse(url),
headers: {
'Authorization': buildNip98AuthHeader(
method: 'POST',
url: url,
bodyBytes: bodyBytes,
nsec: config.nsec,
),
'Content-Type': 'application/json',
},
body: bodyBytes,
)
.timeout(timeout)
.whenComplete(() {
if (shouldCloseClient) client.close();
});
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;
}
/// Subscribe to live events matching [filter]. Returns an unsubscribe
/// function. Live subscriptions survive reconnects — they are replayed with
/// `since: lastSeenCreatedAt - 5s` on reconnect.
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) 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,
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;
}
return () => _unsubscribe(subId);
}
/// Publish an event and wait for the relay's OK confirmation.
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;
}
/// Send a raw message over the WebSocket without waiting for acknowledgement.
/// Used for ephemeral events like typing indicators.
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);
}
/// Called by the app lifecycle provider when the app goes to background.
void onAppPaused() {
_backgroundedAt = _now();
_backgroundGraceTimer?.cancel();
_backgroundGraceTimer = Timer(_backgroundGraceDuration, _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.
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: signal ready.
final liveSub = _liveSubscriptions[subId];
if (liveSub != null) {
_resetClosedRetry(liveSub);
}
if (liveSub != null &&
liveSub.readyCompleter != null &&
!liveSub.readyCompleter!.isCompleted) {
// EOSE is the boundary between replay and live delivery. Flush any
// replay events before resolving subscribe(), so callers that begin a
// one-shot query immediately afterwards cannot classify a delayed batch
// callback as having arrived during that query.
_flushBufferedEventsNow();
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;
}
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 && retrySeconds > 0
? 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;
_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;
_httpClient?.close();
}
}
final relaySessionProvider =
NotifierProvider<RelaySessionNotifier, SessionState>(
RelaySessionNotifier.new,
);
String buildNip98AuthHeader({
required String method,
required String url,
required List<int> bodyBytes,
required String? nsec,
}) {
if (nsec == null || nsec.isEmpty) {
throw Exception('Cannot query relay: no signing key available');
}
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final payloadHash = SHA256Digest()
.process(Uint8List.fromList(bodyBytes))
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final event = nostr.Event.from(
kind: 27235,
content: '',
tags: [
['u', url],
['method', method.toUpperCase()],
['payload', payloadHash],
['nonce', const Uuid().v4()],
],
secretKey: privkeyHex,
verify: false,
);
return 'Nostr ${base64.encode(utf8.encode(event.toJson()))}';
}