mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
fix(mobile): recover stale relay sessions
Co-authored-by: Tom Brow <tomb@block.xyz> Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
parent
fc598f5f8d
commit
c193cd4dcb
@@ -78,17 +78,21 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
RelaySessionNotifier({
|
||||
http.Client? httpClient,
|
||||
RelaySocketFactory socketFactory = RelaySocket.new,
|
||||
DateTime Function()? now,
|
||||
}) : _httpClient = httpClient,
|
||||
_socketFactory = socketFactory;
|
||||
_socketFactory = socketFactory,
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
final http.Client? _httpClient;
|
||||
final RelaySocketFactory _socketFactory;
|
||||
final DateTime Function() _now;
|
||||
|
||||
static const _baseReconnectDelayMs = 1000;
|
||||
static const _maxReconnectDelayMs = 30000;
|
||||
static const _eventBatchMs = 16;
|
||||
static const _reconnectReplaySkewSeconds = 5;
|
||||
static const _maxRecentDeliveryKeys = 5000;
|
||||
static const _backgroundGraceDuration = Duration(seconds: 5);
|
||||
|
||||
RelaySocket? _socket;
|
||||
final Map<String, _HistorySubscription> _historySubscriptions = {};
|
||||
@@ -99,6 +103,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
Timer? _reconnectTimer;
|
||||
Timer? _flushTimer;
|
||||
Timer? _backgroundGraceTimer;
|
||||
DateTime? _backgroundedAt;
|
||||
int _reconnectDelayMs = _baseReconnectDelayMs;
|
||||
int _subIdCounter = 0;
|
||||
bool _disposed = false;
|
||||
@@ -315,8 +320,9 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
|
||||
/// Called by the app lifecycle provider when the app goes to background.
|
||||
void onAppPaused() {
|
||||
_backgroundedAt = _now();
|
||||
_backgroundGraceTimer?.cancel();
|
||||
_backgroundGraceTimer = Timer(const Duration(seconds: 5), _pauseNow);
|
||||
_backgroundGraceTimer = Timer(_backgroundGraceDuration, _pauseNow);
|
||||
}
|
||||
|
||||
void _pauseNow() {
|
||||
@@ -331,12 +337,18 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
/// 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;
|
||||
|
||||
// If still connected, nothing to do — the socket survived the background
|
||||
// grace window.
|
||||
if (state.status == SessionStatus.connected) return;
|
||||
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.
|
||||
@@ -650,6 +662,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
_reconnectTimer?.cancel();
|
||||
_flushTimer?.cancel();
|
||||
_backgroundGraceTimer?.cancel();
|
||||
_backgroundedAt = null;
|
||||
_cancelAllHistory(null);
|
||||
_rejectAllPending(null);
|
||||
_recentDeliveryKeys.clear();
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import 'nostr_models.dart';
|
||||
@@ -30,6 +31,12 @@ Exception classifyRelayAuthFailure(String message) {
|
||||
}
|
||||
|
||||
class RelaySocket {
|
||||
/// Interval for sending a ping and awaiting its pong before disconnecting.
|
||||
static const pingInterval = Duration(seconds: 30);
|
||||
|
||||
@visibleForTesting
|
||||
static Duration debugPingInterval = pingInterval;
|
||||
|
||||
final String _wsUrl;
|
||||
final String? _nsec;
|
||||
final void Function(List<dynamic> message) _onMessage;
|
||||
@@ -63,7 +70,10 @@ class RelaySocket {
|
||||
_state = SocketState.connecting;
|
||||
|
||||
try {
|
||||
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl));
|
||||
_channel = IOWebSocketChannel.connect(
|
||||
Uri.parse(_wsUrl),
|
||||
pingInterval: debugPingInterval,
|
||||
);
|
||||
await _channel!.ready;
|
||||
} catch (e) {
|
||||
_state = SocketState.disconnected;
|
||||
|
||||
@@ -274,7 +274,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.3.5+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
|
||||
@@ -47,6 +47,7 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
crypto: ^3.0.7
|
||||
custom_lint: ^0.8.0
|
||||
riverpod_lint: ^3.1.0
|
||||
mocktail: ^1.0.4
|
||||
|
||||
@@ -260,6 +260,120 @@ void main() {
|
||||
expect(session.state.status, SessionStatus.disconnected);
|
||||
});
|
||||
|
||||
test(
|
||||
'resume reconnects a stale connected session after a long pause',
|
||||
() async {
|
||||
final sockets = <_ControlledRelaySocket>[];
|
||||
final keychain = nostr.Keys.generate();
|
||||
var now = DateTime(2026, 8, 2, 12);
|
||||
final session = RelaySessionNotifier(
|
||||
now: () => now,
|
||||
socketFactory:
|
||||
({
|
||||
required wsUrl,
|
||||
required nsec,
|
||||
required onMessage,
|
||||
required onConnected,
|
||||
required onDisconnected,
|
||||
}) {
|
||||
final socket = _ControlledRelaySocket(
|
||||
wsUrl: wsUrl,
|
||||
nsec: nsec,
|
||||
onMessage: onMessage,
|
||||
onConnected: onConnected,
|
||||
onDisconnected: onDisconnected,
|
||||
);
|
||||
sockets.add(socket);
|
||||
return socket;
|
||||
},
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FakeRelayConfigNotifier(
|
||||
baseUrl: 'https://relay.example',
|
||||
nsec: keychain.nsec,
|
||||
),
|
||||
),
|
||||
authProvider.overrideWith(() => _AuthenticatedAuthNotifier()),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(authProvider.future);
|
||||
final subscription = container.listen(relaySessionProvider, (_, _) {});
|
||||
addTearDown(subscription.close);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
sockets.single.connectSuccessfully();
|
||||
|
||||
session.onAppPaused();
|
||||
now = now.add(const Duration(minutes: 5));
|
||||
session.onAppResumed();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(sockets, hasLength(2));
|
||||
expect(sockets.first.disposeCalls, 1);
|
||||
expect(session.state.status, SessionStatus.reconnecting);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'resume keeps a connected session within the background grace period',
|
||||
() async {
|
||||
final sockets = <_ControlledRelaySocket>[];
|
||||
final keychain = nostr.Keys.generate();
|
||||
var now = DateTime(2026, 8, 2, 12);
|
||||
final session = RelaySessionNotifier(
|
||||
now: () => now,
|
||||
socketFactory:
|
||||
({
|
||||
required wsUrl,
|
||||
required nsec,
|
||||
required onMessage,
|
||||
required onConnected,
|
||||
required onDisconnected,
|
||||
}) {
|
||||
final socket = _ControlledRelaySocket(
|
||||
wsUrl: wsUrl,
|
||||
nsec: nsec,
|
||||
onMessage: onMessage,
|
||||
onConnected: onConnected,
|
||||
onDisconnected: onDisconnected,
|
||||
);
|
||||
sockets.add(socket);
|
||||
return socket;
|
||||
},
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relaySessionProvider.overrideWith(() => session),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FakeRelayConfigNotifier(
|
||||
baseUrl: 'https://relay.example',
|
||||
nsec: keychain.nsec,
|
||||
),
|
||||
),
|
||||
authProvider.overrideWith(() => _AuthenticatedAuthNotifier()),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(authProvider.future);
|
||||
final subscription = container.listen(relaySessionProvider, (_, _) {});
|
||||
addTearDown(subscription.close);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
sockets.single.connectSuccessfully();
|
||||
|
||||
session.onAppPaused();
|
||||
now = now.add(const Duration(seconds: 4));
|
||||
session.onAppResumed();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(sockets, hasLength(1));
|
||||
expect(sockets.single.disposeCalls, 0);
|
||||
expect(session.state.status, SessionStatus.connected);
|
||||
},
|
||||
);
|
||||
|
||||
test('delivers the same live event to each matching subscription', () async {
|
||||
final session = RelaySessionNotifier();
|
||||
final firstEvents = <NostrEvent>[];
|
||||
@@ -402,6 +516,7 @@ class _AuthenticatedAuthNotifier extends AuthNotifier {
|
||||
class _ControlledRelaySocket extends RelaySocket {
|
||||
final void Function() _connected;
|
||||
final void Function(Object? error) _disconnected;
|
||||
int disposeCalls = 0;
|
||||
|
||||
_ControlledRelaySocket({
|
||||
required super.wsUrl,
|
||||
@@ -416,7 +531,9 @@ class _ControlledRelaySocket extends RelaySocket {
|
||||
Future<void> connect() async {}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
void dispose() {
|
||||
disposeCalls++;
|
||||
}
|
||||
|
||||
void connectSuccessfully() => _connected();
|
||||
|
||||
|
||||
111
mobile/test/shared/relay/relay_socket_liveness_test.dart
Normal file
111
mobile/test/shared/relay/relay_socket_liveness_test.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:buzz/shared/relay/relay_socket.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// A server that completes the WS handshake then never speaks again: no pongs,
|
||||
/// no close frame. Only a client-side ping timeout can notice.
|
||||
Future<ServerSocket> _silentAfterHandshakeServer() async {
|
||||
final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
server.listen((client) {
|
||||
client.listen(
|
||||
(data) {
|
||||
final match = RegExp(
|
||||
r'Sec-WebSocket-Key: (.*)\r\n',
|
||||
caseSensitive: false,
|
||||
).firstMatch(String.fromCharCodes(data));
|
||||
if (match == null) return;
|
||||
final accept = base64.encode(
|
||||
sha1
|
||||
.convert(
|
||||
utf8.encode(
|
||||
'${match.group(1)!.trim()}258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
|
||||
),
|
||||
)
|
||||
.bytes,
|
||||
);
|
||||
client.write(
|
||||
'HTTP/1.1 101 Switching Protocols\r\n'
|
||||
'Upgrade: websocket\r\nConnection: Upgrade\r\n'
|
||||
'Sec-WebSocket-Accept: $accept\r\n\r\n',
|
||||
);
|
||||
},
|
||||
onError: (_) {},
|
||||
onDone: () {},
|
||||
);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
void main() {
|
||||
const testPingInterval = Duration(milliseconds: 150);
|
||||
|
||||
setUp(() {
|
||||
RelaySocket.debugPingInterval = testPingInterval;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
RelaySocket.debugPingInterval = RelaySocket.pingInterval;
|
||||
});
|
||||
|
||||
test('detects a peer that stops answering pings', () async {
|
||||
final server = await _silentAfterHandshakeServer();
|
||||
|
||||
final disconnected = Completer<Object?>();
|
||||
final socket = RelaySocket(
|
||||
wsUrl: 'ws://127.0.0.1:${server.port}',
|
||||
nsec: null,
|
||||
onMessage: (_) {},
|
||||
onConnected: () {},
|
||||
onDisconnected: (error) {
|
||||
if (!disconnected.isCompleted) disconnected.complete(error);
|
||||
},
|
||||
);
|
||||
unawaited(socket.connect());
|
||||
|
||||
var detected = true;
|
||||
try {
|
||||
await disconnected.future.timeout(testPingInterval * 4);
|
||||
} on TimeoutException {
|
||||
detected = false;
|
||||
}
|
||||
|
||||
expect(
|
||||
detected,
|
||||
isTrue,
|
||||
reason:
|
||||
'RelaySocket must surface an unanswered ping through onDisconnected',
|
||||
);
|
||||
|
||||
socket.dispose();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test('keeps an idle but healthy peer connected', () async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
server.transform(WebSocketTransformer()).listen((ws) {
|
||||
// A healthy relay answers pings without sending application data.
|
||||
ws.listen((_) {}, onError: (_) {}, onDone: () {});
|
||||
});
|
||||
|
||||
var tornDown = false;
|
||||
final socket = RelaySocket(
|
||||
wsUrl: 'ws://127.0.0.1:${server.port}',
|
||||
nsec: null,
|
||||
onMessage: (_) {},
|
||||
onConnected: () {},
|
||||
onDisconnected: (_) => tornDown = true,
|
||||
);
|
||||
unawaited(socket.connect());
|
||||
|
||||
await Future<void>.delayed(testPingInterval * 4);
|
||||
|
||||
expect(tornDown, isFalse);
|
||||
|
||||
await socket.disconnect();
|
||||
await server.close(force: true);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user