fix(mobile): bound relay reconnects and surface offline state

Fail fast when the relay handshake stalls, stop reconnecting after the bounded attempt budget, and provide actionable retry UI for terminal network and authentication failures.\n\nValidated with flutter analyze and the full mobile test suite.

Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e
2026-07-14 11:42:28 -07:00
parent 2ef0217a0a
commit b1dbe3e40b
13 changed files with 570 additions and 41 deletions
@@ -300,7 +300,8 @@ class ObserverRelayNotifier extends Notifier<ObserverRelayState> {
: ObserverConnectionState.open,
SessionStatus.connecting ||
SessionStatus.reconnecting => ObserverConnectionState.connecting,
SessionStatus.disconnected => ObserverConnectionState.idle,
SessionStatus.disconnected ||
SessionStatus.failed => ObserverConnectionState.idle,
};
}
@@ -283,7 +283,8 @@ class ChannelDetailPage extends HookConsumerWidget {
),
),
_DetailConnectionBanner(
status: ref.watch(relaySessionProvider).status,
state: ref.watch(relaySessionProvider),
onRetry: () => ref.read(relaySessionProvider.notifier).reconnect(),
),
if (!resolvedChannel.isForum && typingEntries.isNotEmpty)
_TypingIndicator(entries: typingEntries),
@@ -55,17 +55,44 @@ class _HeaderEphemeralBadge extends StatelessWidget {
}
class _DetailConnectionBanner extends StatelessWidget {
final SessionStatus status;
final SessionState state;
final VoidCallback onRetry;
const _DetailConnectionBanner({required this.status});
const _DetailConnectionBanner({required this.state, required this.onRetry});
@override
Widget build(BuildContext context) {
if (status == SessionStatus.connected ||
status == SessionStatus.disconnected) {
if (state.status == SessionStatus.connected ||
state.status == SessionStatus.disconnected) {
return const SizedBox.shrink();
}
if (state.status == SessionStatus.failed) {
return Material(
color: context.colors.surfaceContainerHighest,
child: InkWell(
onTap: onRetry,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.quarter + 2,
),
child: Center(
child: Text(
'Connection lost — Retry',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
),
),
);
}
final message = state.reconnectAttempt > 0
? 'Reconnecting… (attempt ${state.reconnectAttempt})'
: 'Reconnecting…';
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
@@ -86,7 +113,7 @@ class _DetailConnectionBanner extends StatelessWidget {
),
const SizedBox(width: Grid.xxs),
Text(
'Reconnecting…',
message,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
@@ -129,6 +129,7 @@ class ChannelsPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final channelsAsync = ref.watch(channelsProvider);
final sessionState = ref.watch(relaySessionProvider);
final relayHost = Uri.parse(ref.watch(relayConfigProvider).baseUrl).host;
final currentPubkey = ref
.watch(profileProvider)
.whenData((value) => value?.pubkey)
@@ -153,6 +154,11 @@ class ChannelsPage extends HookConsumerWidget {
}
final channels = cachedChannels.value;
Future<void> retryConnection() async {
await ref.read(relaySessionProvider.notifier).reconnect();
if (context.mounted) ref.invalidate(channelsProvider);
}
Future<void> openChannel(Channel channel) async {
if (!context.mounted) return;
await Navigator.of(context).push(
@@ -269,10 +275,12 @@ class ChannelsPage extends HookConsumerWidget {
channels: channels,
channelsAsync: channelsAsync,
showError: showError.value,
sessionStatus: sessionState.status,
sessionState: sessionState,
relayHost: relayHost,
showConnectionBanner: showConnectionBanner.value,
currentPubkey: currentPubkey,
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
onRetryConnection: retryConnection,
onSelectChannel: openChannel,
),
);
@@ -74,19 +74,24 @@ class _EphemeralBadge extends StatelessWidget {
}
class _ConnectionBanner extends StatelessWidget {
final SessionStatus status;
final SessionState state;
const _ConnectionBanner({required this.status});
const _ConnectionBanner({required this.state});
@override
Widget build(BuildContext context) {
if (status == SessionStatus.connected ||
status == SessionStatus.disconnected) {
if (state.status == SessionStatus.connected ||
state.status == SessionStatus.disconnected ||
state.status == SessionStatus.failed) {
return const SizedBox.shrink();
}
final isConnecting = status == SessionStatus.connecting;
final message = isConnecting ? 'Connecting…' : 'Reconnecting…';
final isConnecting = state.status == SessionStatus.connecting;
final message = isConnecting
? 'Connecting…'
: state.reconnectAttempt > 0
? 'Reconnecting… (attempt ${state.reconnectAttempt})'
: 'Reconnecting…';
return Container(
width: double.infinity,
@@ -119,6 +124,103 @@ class _ConnectionBanner extends StatelessWidget {
}
}
class _ConnectionLostBanner extends StatelessWidget {
final VoidCallback onRetry;
const _ConnectionLostBanner({required this.onRetry});
@override
Widget build(BuildContext context) {
return Material(
color: context.colors.surfaceContainerHighest,
child: InkWell(
onTap: onRetry,
child: SizedBox(
height: _kBannerHeight,
child: Center(
child: Text(
'Connection lost — Retry',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
),
),
);
}
}
class _OfflineView extends StatelessWidget {
final Object? error;
final String relayHost;
final VoidCallback onRetry;
const _OfflineView({
required this.error,
required this.relayHost,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
final authRejected = error is RelayAuthRejectedException;
final headline = authRejected
? 'Authentication rejected by the relay'
: "Can't reach your workspace";
final body = authRejected
? (error as RelayAuthRejectedException).message
: 'Check your network or VPN (e.g. Cloudflare WARP), then retry.';
final detail = authRejected
? relayHost
: '$relayHost\n${error ?? 'Connection failed'}';
return Center(
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.wifiOff,
size: Grid.xl,
color: context.colors.error,
),
const SizedBox(height: Grid.xs),
Text(
headline,
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: Grid.xxs),
Text(
body,
style: context.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: Grid.xxs),
Text(
detail,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: Grid.xs),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(LucideIcons.refreshCw),
label: const Text('Retry'),
),
],
),
),
);
}
}
class _ErrorView extends StatelessWidget {
final Object error;
final VoidCallback onRetry;
@@ -4,20 +4,24 @@ class _ChannelsBody extends StatelessWidget {
final List<Channel>? channels;
final AsyncValue<List<Channel>> channelsAsync;
final bool showError;
final SessionStatus sessionStatus;
final SessionState sessionState;
final String relayHost;
final bool showConnectionBanner;
final String? currentPubkey;
final Future<void> Function() onRefresh;
final Future<void> Function() onRetryConnection;
final Future<void> Function(Channel channel) onSelectChannel;
const _ChannelsBody({
required this.channels,
required this.channelsAsync,
required this.showError,
required this.sessionStatus,
required this.sessionState,
required this.relayHost,
required this.showConnectionBanner,
required this.currentPubkey,
required this.onRefresh,
required this.onRetryConnection,
required this.onSelectChannel,
});
@@ -26,6 +30,8 @@ class _ChannelsBody extends StatelessWidget {
final barHeight = frostedAppBarHeight(context);
if (channels != null) {
final connectionLost = sessionState.status == SessionStatus.failed;
final bannerVisible = connectionLost || showConnectionBanner;
return Stack(
children: [
RefreshIndicator(
@@ -34,8 +40,7 @@ class _ChannelsBody extends StatelessWidget {
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: SizedBox(height: barHeight)),
// Extra space for the connection banner when visible.
if (showConnectionBanner)
if (bannerVisible)
const SliverToBoxAdapter(
child: SizedBox(height: _kBannerHeight),
),
@@ -51,14 +56,27 @@ class _ChannelsBody extends StatelessWidget {
top: barHeight,
left: 0,
right: 0,
child: showConnectionBanner
? _ConnectionBanner(status: sessionStatus)
child: connectionLost
? _ConnectionLostBanner(onRetry: onRetryConnection)
: showConnectionBanner
? _ConnectionBanner(state: sessionState)
: const SizedBox.shrink(),
),
],
);
}
if (sessionState.status == SessionStatus.failed) {
return Padding(
padding: EdgeInsets.only(top: barHeight),
child: _OfflineView(
error: sessionState.lastError,
relayHost: relayHost,
onRetry: onRetryConnection,
),
);
}
// The error view is gated on a grace timer in the parent — see the
// useEffect in ChannelsPage. While the grace window is in flight we fall
// through to the connection banner so transient relay-cancellation errors
@@ -73,9 +91,13 @@ class _ChannelsBody extends StatelessWidget {
return Padding(
padding: EdgeInsets.only(top: barHeight),
child: _ConnectionBanner(
status: sessionStatus == SessionStatus.connected
? SessionStatus.connecting
: sessionStatus,
state: SessionState(
status: sessionState.status == SessionStatus.connected
? SessionStatus.connecting
: sessionState.status,
reconnectAttempt: sessionState.reconnectAttempt,
lastError: sessionState.lastError,
),
),
);
}
@@ -62,9 +62,22 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
final sessionState = ref.read(relaySessionProvider);
final waitingForInitialConnection =
sessionState.status != SessionStatus.connected;
final initiallyFailed = sessionState.status == SessionStatus.failed;
if (initiallyFailed) connected.future.ignore();
ref.listen(relaySessionProvider, (previous, next) {
if (next.status == SessionStatus.failed &&
waitingForInitialConnection &&
!_hasLoaded &&
!connected.isCompleted) {
connected.completeError(
next.lastError ?? Exception('Connection failed'),
);
return;
}
if (next.status != SessionStatus.connected) return;
if (waitingForInitialConnection &&
if (initiallyFailed) {
ref.invalidateSelf();
} else if (waitingForInitialConnection &&
!_hasLoaded &&
!connected.isCompleted) {
connected.complete();
@@ -92,6 +105,9 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
if (sessionState.status != SessionStatus.connected) {
// Keep the prior community's cache visible until the new relay connects.
if (_hasLoaded) return state.value ?? const [];
if (sessionState.status == SessionStatus.failed) {
throw sessionState.lastError ?? Exception('Connection failed');
}
await connected.future;
}
+35 -9
View File
@@ -16,14 +16,19 @@ import 'relay_client.dart';
import 'relay_provider.dart';
import 'relay_socket.dart';
enum SessionStatus { disconnected, connecting, connected, reconnecting }
enum SessionStatus { disconnected, connecting, connected, reconnecting, failed }
@immutable
class SessionState {
final SessionStatus status;
final int reconnectAttempt;
final Object? lastError;
const SessionState({required this.status, this.reconnectAttempt = 0});
const SessionState({
required this.status,
this.reconnectAttempt = 0,
this.lastError,
});
}
class _HistorySubscription {
@@ -86,6 +91,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
static const _baseReconnectDelayMs = 1000;
static const _maxReconnectDelayMs = 30000;
static const _maxReconnectAttempts = 5;
static const _eventBatchMs = 16;
static const _reconnectReplaySkewSeconds = 5;
static const _maxRecentDeliveryKeys = 5000;
@@ -294,6 +300,9 @@ class RelaySessionNotifier extends Notifier<SessionState> {
@visibleForTesting
void debugPauseNow() => _pauseNow();
@visibleForTesting
bool get debugHasReconnectTimer => _reconnectTimer?.isActive ?? false;
@visibleForTesting
void debugHandleSocketMessageForTest(List<dynamic> data) =>
_handleMessage(data);
@@ -307,8 +316,9 @@ class RelaySessionNotifier extends Notifier<SessionState> {
/// Force a reconnect (e.g., returning from background).
Future<void> reconnect() async {
_reconnectTimer?.cancel();
await _socket?.disconnect();
_reconnectDelayMs = _baseReconnectDelayMs;
_resetReconnectBudget();
final config = ref.read(relayConfigProvider);
await _connect(config);
}
@@ -341,7 +351,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
// Cancel any in-flight reconnect backoff timer so we reconnect immediately
// instead of waiting for the (possibly large) exponential delay.
_reconnectTimer?.cancel();
_reconnectDelayMs = _baseReconnectDelayMs;
_resetReconnectBudget();
final config = ref.read(relayConfigProvider);
_connect(config);
}
@@ -355,6 +365,7 @@ class RelaySessionNotifier extends Notifier<SessionState> {
? SessionStatus.reconnecting
: SessionStatus.connecting,
reconnectAttempt: state.reconnectAttempt,
lastError: state.lastError,
);
_socket?.dispose();
@@ -389,21 +400,31 @@ class RelaySessionNotifier extends Notifier<SessionState> {
_flushTimer = null;
if (error is RelayAuthRejectedException) {
_reconnectTimer?.cancel();
state = const SessionState(status: SessionStatus.disconnected);
state = SessionState(status: SessionStatus.failed, lastError: error);
return;
}
_scheduleReconnect();
_scheduleReconnect(error);
}
void _scheduleReconnect() {
void _scheduleReconnect(Object? error) {
if (_disposed || _paused) return;
final attempt = state.reconnectAttempt + 1;
_reconnectTimer?.cancel();
if (attempt > _maxReconnectAttempts) {
_reconnectTimer = null;
state = SessionState(
status: SessionStatus.failed,
reconnectAttempt: attempt,
lastError: error,
);
return;
}
state = SessionState(
status: SessionStatus.reconnecting,
reconnectAttempt: attempt,
lastError: error,
);
_reconnectTimer?.cancel();
_reconnectTimer = Timer(Duration(milliseconds: _reconnectDelayMs), () {
_reconnectDelayMs = min(_reconnectDelayMs * 2, _maxReconnectDelayMs);
final config = ref.read(relayConfigProvider);
@@ -411,6 +432,11 @@ class RelaySessionNotifier extends Notifier<SessionState> {
});
}
void _resetReconnectBudget() {
_reconnectDelayMs = _baseReconnectDelayMs;
state = SessionState(status: state.status, lastError: state.lastError);
}
/// Replay all live subscriptions after a reconnect, with a time skew to
/// catch events that occurred during the disconnect.
void _replayLiveSubscriptions() {
+21 -3
View File
@@ -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';
@@ -29,12 +30,16 @@ Exception classifyRelayAuthFailure(String message) {
return RelayAuthRejectedException(message);
}
typedef RelayChannelFactory = WebSocketChannel Function(Uri uri);
class RelaySocket {
final String _wsUrl;
final String? _nsec;
final void Function(List<dynamic> message) _onMessage;
final void Function() _onConnected;
final void Function(Object? error) _onDisconnected;
final RelayChannelFactory _channelFactory;
final Duration _connectTimeout;
WebSocketChannel? _channel;
StreamSubscription<dynamic>? _subscription;
@@ -51,11 +56,21 @@ class RelaySocket {
required void Function(List<dynamic> message) onMessage,
required void Function() onConnected,
required void Function(Object? error) onDisconnected,
RelayChannelFactory? channelFactory,
Duration connectTimeout = const Duration(seconds: 10),
}) : _wsUrl = wsUrl,
_nsec = nsec,
_onMessage = onMessage,
_onConnected = onConnected,
_onDisconnected = onDisconnected;
_onDisconnected = onDisconnected,
_channelFactory = channelFactory ?? _connectChannel,
_connectTimeout = connectTimeout;
static WebSocketChannel _connectChannel(Uri uri) =>
IOWebSocketChannel.connect(
uri,
pingInterval: const Duration(seconds: 30),
);
/// Connect to the relay and complete NIP-42 authentication.
Future<void> connect() async {
@@ -63,10 +78,13 @@ class RelaySocket {
_state = SocketState.connecting;
try {
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl));
await _channel!.ready;
_channel = _channelFactory(Uri.parse(_wsUrl));
await _channel!.ready.timeout(_connectTimeout);
} catch (e) {
final channel = _channel;
_channel = null;
_state = SocketState.disconnected;
if (channel != null) unawaited(channel.sink.close().catchError((_) {}));
_onDisconnected(e);
return;
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -9,6 +11,7 @@ import 'package:buzz/features/channels/read_state/read_state_provider.dart';
import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
void main() {
@@ -156,6 +159,89 @@ void main() {
expect(find.text('Retry'), findsOneWidget);
});
testWidgets('shows terminal offline view without a spinner and retries', (
tester,
) async {
final session = _FakeSessionNotifier(
SessionState(
status: SessionStatus.failed,
lastError: Exception('network unreachable'),
),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _PendingNotifier()),
relaySessionProvider.overrideWith(() => session),
],
),
);
await tester.pump();
expect(find.text("Can't reach your workspace"), findsOneWidget);
expect(find.textContaining('Cloudflare WARP'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsNothing);
await tester.tap(find.text('Retry'));
await tester.pump();
expect(session.reconnectCount, 1);
expect(session.state.status, SessionStatus.connecting);
});
testWidgets('shows relay authentication rejection copy', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _PendingNotifier()),
relaySessionProvider.overrideWith(
() => _FakeSessionNotifier(
const SessionState(
status: SessionStatus.failed,
lastError: RelayAuthRejectedException('access revoked'),
),
),
),
],
),
);
await tester.pump();
expect(find.text('Authentication rejected by the relay'), findsOneWidget);
expect(find.text('access revoked'), findsOneWidget);
expect(find.textContaining('Cloudflare WARP'), findsNothing);
expect(find.byType(CircularProgressIndicator), findsNothing);
});
testWidgets('shows static connection-lost banner over cached channels', (
tester,
) async {
final session = _FakeSessionNotifier(
SessionState(status: SessionStatus.connected),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(
() => _FakeNotifier([testChannels.first]),
),
relaySessionProvider.overrideWith(() => session),
],
),
);
await tester.pumpAndSettle();
session.fail(Exception('connection lost'));
await tester.pump();
expect(find.text('general'), findsOneWidget);
expect(find.text('Connection lost — Retry'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsNothing);
await tester.tap(find.text('Connection lost — Retry'));
await tester.pump();
expect(session.reconnectCount, 1);
});
testWidgets('renders and clears unread dot indicator', (tester) async {
final channels = [
Channel(
@@ -398,6 +484,31 @@ class _ErrorNotifier extends ChannelsNotifier {
Future<List<Channel>> build() => Future.error('Connection refused');
}
class _PendingNotifier extends ChannelsNotifier {
@override
Future<List<Channel>> build() => Completer<List<Channel>>().future;
}
class _FakeSessionNotifier extends RelaySessionNotifier {
final SessionState initialState;
int reconnectCount = 0;
_FakeSessionNotifier(this.initialState);
@override
SessionState build() => initialState;
@override
Future<void> reconnect() async {
reconnectCount++;
state = const SessionState(status: SessionStatus.connecting);
}
void fail(Object error) {
state = SessionState(status: SessionStatus.failed, lastError: error);
}
}
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
@@ -242,6 +242,28 @@ void main() {
},
);
test(
'fails immediately when built after the session already failed',
() async {
final error = Exception('relay unavailable');
final session = _FakeRelaySession(
memberships: [_membership(_channelA, myPk)],
metadata: [_meta(id: _channelA, name: 'general')],
initialState: SessionState(
status: SessionStatus.failed,
lastError: error,
),
);
final container = _buildContainer(session: session);
addTearDown(container.dispose);
await expectLater(
container.read(channelsProvider.future),
throwsA(same(error)),
);
},
);
test(
'keeps cached channels and live subscriptions during reconnect',
() async {
@@ -431,8 +453,10 @@ class _FakeRelaySession extends RelaySessionNotifier {
required this.metadata,
this.hiddenDmEvents = const [],
this.membershipFailures = 0,
this.initialState = const SessionState(status: SessionStatus.connected),
});
final SessionState initialState;
List<NostrEvent> memberships;
List<NostrEvent> metadata;
final List<NostrEvent> hiddenDmEvents;
@@ -444,7 +468,7 @@ class _FakeRelaySession extends RelaySessionNotifier {
int unsubscribeCount = 0;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
SessionState build() => initialState;
@override
Future<List<NostrEvent>> fetchHistory(
@@ -492,8 +516,8 @@ class _FakeRelaySession extends RelaySessionNotifier {
};
}
void setStatus(SessionStatus status) {
state = SessionState(status: status);
void setStatus(SessionStatus status, {Object? lastError}) {
state = SessionState(status: status, lastError: lastError);
}
/// Emit a live event to all subscribers.
@@ -184,13 +184,115 @@ void main() {
session.debugHandleDisconnected(
const RelayAuthRejectedException('auth-required: verification failed'),
);
await Future<void>.delayed(Duration.zero);
expect(session.state.status, SessionStatus.disconnected);
expect(session.state.status, SessionStatus.failed);
expect(session.state.lastError, isA<RelayAuthRejectedException>());
expect(auth.signOutCount, 0);
},
);
test('stops after five reconnect attempts with no timer running', () {
final session = RelaySessionNotifier();
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
container.read(relaySessionProvider);
for (var attempt = 1; attempt <= 5; attempt++) {
final error = Exception('connect failed $attempt');
session.debugHandleDisconnected(error);
expect(session.state.status, SessionStatus.reconnecting);
expect(session.state.reconnectAttempt, attempt);
expect(session.state.lastError, same(error));
}
final terminalError = Exception('connect failed 6');
session.debugHandleDisconnected(terminalError);
expect(session.state.status, SessionStatus.failed);
expect(session.state.reconnectAttempt, 6);
expect(session.state.lastError, same(terminalError));
expect(session.debugHasReconnectTimer, isFalse);
});
test('manual reconnect from failed resets the attempt budget', () async {
final sockets = <_ControlledRelaySocket>[];
final session = RelaySessionNotifier(
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)],
);
addTearDown(container.dispose);
container.read(relaySessionProvider);
for (var attempt = 0; attempt < 6; attempt++) {
session.debugHandleDisconnected(Exception('failed'));
}
expect(session.state.status, SessionStatus.failed);
await session.reconnect();
expect(session.state.status, SessionStatus.connecting);
expect(session.state.reconnectAttempt, 0);
sockets.single.connectSuccessfully();
expect(session.state.status, SessionStatus.connected);
});
test('app resume from failed starts fresh with a reset budget', () async {
final sockets = <_ControlledRelaySocket>[];
final session = RelaySessionNotifier(
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)],
);
addTearDown(container.dispose);
container.read(relaySessionProvider);
for (var attempt = 0; attempt < 6; attempt++) {
session.debugHandleDisconnected(Exception('failed'));
}
session.onAppResumed();
expect(session.state.status, SessionStatus.connecting);
expect(session.state.reconnectAttempt, 0);
expect(sockets, hasLength(1));
});
test('ignores callbacks from a socket replaced by a config change', () async {
final sockets = <_ControlledRelaySocket>[];
final keychain = nostr.Keys.generate();
@@ -0,0 +1,71 @@
import 'dart:async';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
void main() {
test('hung handshake times out, closes, and reports disconnection', () async {
final channel = _HungWebSocketChannel();
final disconnected = Completer<Object?>();
final socket = RelaySocket(
wsUrl: 'wss://relay.example',
nsec: null,
onMessage: (_) {},
onConnected: () => fail('socket must not connect'),
onDisconnected: disconnected.complete,
channelFactory: (_) => channel,
connectTimeout: const Duration(milliseconds: 1),
);
await socket.connect();
expect(await disconnected.future, isA<TimeoutException>());
expect(socket.state, SocketState.disconnected);
expect(channel.closeCount, 1);
});
}
class _HungWebSocketChannel implements WebSocketChannel {
final _controller = StreamController<dynamic>();
final _ready = Completer<void>();
int closeCount = 0;
@override
Future<void> get ready => _ready.future;
@override
String? get protocol => null;
@override
int? get closeCode => null;
@override
String? get closeReason => null;
@override
Stream<dynamic> get stream => _controller.stream;
@override
late final WebSocketSink sink = _RecordingWebSocketSink(
_controller.sink,
() => closeCount++,
);
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _RecordingWebSocketSink implements WebSocketSink {
final void Function() onClose;
_RecordingWebSocketSink(StreamSink<dynamic> sink, this.onClose);
@override
Future<void> close([int? closeCode, String? closeReason]) async {
onClose();
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}