diff --git a/mobile/lib/features/channels/agent_activity/observer_subscription.dart b/mobile/lib/features/channels/agent_activity/observer_subscription.dart index c15686c582..f32966deb1 100644 --- a/mobile/lib/features/channels/agent_activity/observer_subscription.dart +++ b/mobile/lib/features/channels/agent_activity/observer_subscription.dart @@ -300,7 +300,8 @@ class ObserverRelayNotifier extends Notifier { : ObserverConnectionState.open, SessionStatus.connecting || SessionStatus.reconnecting => ObserverConnectionState.connecting, - SessionStatus.disconnected => ObserverConnectionState.idle, + SessionStatus.disconnected || + SessionStatus.failed => ObserverConnectionState.idle, }; } diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 2f67c84748..377e156f46 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -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), diff --git a/mobile/lib/features/channels/channel_detail_page/banners.dart b/mobile/lib/features/channels/channel_detail_page/banners.dart index d94fe5e5b5..4af232cc3a 100644 --- a/mobile/lib/features/channels/channel_detail_page/banners.dart +++ b/mobile/lib/features/channels/channel_detail_page/banners.dart @@ -55,17 +55,46 @@ 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( + state.lastError is RelayAuthRejectedException + ? 'Authentication rejected — Retry' + : '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 +115,7 @@ class _DetailConnectionBanner extends StatelessWidget { ), const SizedBox(width: Grid.xxs), Text( - 'Reconnecting…', + message, style: context.textTheme.labelSmall?.copyWith( color: context.colors.onSurfaceVariant, ), diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index a0fa2c742b..a1aec7a177 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -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 retryConnection() async { + await ref.read(relaySessionProvider.notifier).reconnect(); + if (context.mounted) ref.invalidate(channelsProvider); + } + Future 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, ), ); diff --git a/mobile/lib/features/channels/channels_page/badges.dart b/mobile/lib/features/channels/channels_page/badges.dart index 9d697000c8..36f129f7fc 100644 --- a/mobile/lib/features/channels/channels_page/badges.dart +++ b/mobile/lib/features/channels/channels_page/badges.dart @@ -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,105 @@ class _ConnectionBanner extends StatelessWidget { } } +class _ConnectionLostBanner extends StatelessWidget { + final Object? error; + final VoidCallback onRetry; + + const _ConnectionLostBanner({required this.error, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final message = error is RelayAuthRejectedException + ? 'Authentication rejected — Retry' + : 'Connection lost — Retry'; + return Material( + color: context.colors.surfaceContainerHighest, + child: InkWell( + onTap: onRetry, + child: SizedBox( + height: _kBannerHeight, + child: Center( + child: Text( + message, + 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 + ? 'Check your workspace access or credentials, then retry.' + : 'Check your network or VPN (e.g. Cloudflare WARP), then retry.'; + final detail = relayHost; + + 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; diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 0639a13c26..15cf693ca6 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -4,20 +4,24 @@ class _ChannelsBody extends StatelessWidget { final List? channels; final AsyncValue> channelsAsync; final bool showError; - final SessionStatus sessionStatus; + final SessionState sessionState; + final String relayHost; final bool showConnectionBanner; final String? currentPubkey; final Future Function() onRefresh; + final Future Function() onRetryConnection; final Future 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,30 @@ class _ChannelsBody extends StatelessWidget { top: barHeight, left: 0, right: 0, - child: showConnectionBanner - ? _ConnectionBanner(status: sessionStatus) + child: connectionLost + ? _ConnectionLostBanner( + error: sessionState.lastError, + 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 +94,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, + ), ), ); } diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 74bbe640bd..690d1d1a74 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -62,9 +62,25 @@ class ChannelsNotifier extends AsyncNotifier> { 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 || + (waitingForInitialConnection && + connected.isCompleted && + !_hasLoaded)) { + ref.invalidateSelf(); + } else if (waitingForInitialConnection && !_hasLoaded && !connected.isCompleted) { connected.complete(); @@ -92,6 +108,9 @@ class ChannelsNotifier extends AsyncNotifier> { 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; } diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 615b75b9ae..6b5e551f26 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -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 { 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 { @visibleForTesting void debugPauseNow() => _pauseNow(); + @visibleForTesting + bool get debugHasReconnectTimer => _reconnectTimer?.isActive ?? false; + @visibleForTesting void debugHandleSocketMessageForTest(List data) => _handleMessage(data); @@ -307,8 +316,10 @@ class RelaySessionNotifier extends Notifier { /// Force a reconnect (e.g., returning from background). Future reconnect() async { + _reconnectTimer?.cancel(); + ++_connectionGeneration; await _socket?.disconnect(); - _reconnectDelayMs = _baseReconnectDelayMs; + _resetReconnectBudget(); final config = ref.read(relayConfigProvider); await _connect(config); } @@ -341,7 +352,7 @@ class RelaySessionNotifier extends Notifier { // 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 +366,7 @@ class RelaySessionNotifier extends Notifier { ? SessionStatus.reconnecting : SessionStatus.connecting, reconnectAttempt: state.reconnectAttempt, + lastError: state.lastError, ); _socket?.dispose(); @@ -389,21 +401,31 @@ class RelaySessionNotifier extends Notifier { _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 +433,11 @@ class RelaySessionNotifier extends Notifier { }); } + 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() { diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index 267b030391..11a0b132a7 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -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 message) _onMessage; final void Function() _onConnected; final void Function(Object? error) _onDisconnected; + final RelayChannelFactory _channelFactory; + final Duration _connectTimeout; WebSocketChannel? _channel; StreamSubscription? _subscription; @@ -51,11 +56,21 @@ class RelaySocket { required void Function(List 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 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; } @@ -84,14 +102,18 @@ class RelaySocket { _subscription = _channel!.stream.listen( _handleRawMessage, onError: (Object error) { + final awaitingAuth = + _authCompleter != null && !_authCompleter!.isCompleted; _failAuth(error); _resetConnection(); - _onDisconnected(error); + if (!awaitingAuth) _onDisconnected(error); }, onDone: () { + final awaitingAuth = + _authCompleter != null && !_authCompleter!.isCompleted; _failAuth(null); _resetConnection(); - _onDisconnected(null); + if (!awaitingAuth) _onDisconnected(null); }, ); @@ -138,6 +160,7 @@ class RelaySocket { } void _resetConnection() { + _failAuth(null); _state = SocketState.disconnected; _subscription?.cancel(); _subscription = null; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 232cdeb912..228a9e73fc 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -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,92 @@ 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('Check your workspace access or credentials, then retry.'), + 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 +487,31 @@ class _ErrorNotifier extends ChannelsNotifier { Future> build() => Future.error('Connection refused'); } +class _PendingNotifier extends ChannelsNotifier { + @override + Future> build() => Completer>().future; +} + +class _FakeSessionNotifier extends RelaySessionNotifier { + final SessionState initialState; + int reconnectCount = 0; + + _FakeSessionNotifier(this.initialState); + + @override + SessionState build() => initialState; + + @override + Future 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 build() async => diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 79be7a66f7..a7d4710fc2 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -242,6 +242,76 @@ 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( + 'fails a pending initial build and fully reloads after recovery', + () async { + final error = Exception('relay unavailable'); + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + initialState: const SessionState(status: SessionStatus.connecting), + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final pending = container.read(channelsProvider.future); + await Future.delayed(Duration.zero); + session.setStatus(SessionStatus.failed, lastError: error); + await expectLater(pending, throwsA(same(error))); + + session.setStatus(SessionStatus.connected); + await Future.delayed(Duration.zero); + + final recovered = await container.read(channelsProvider.future); + expect(recovered.single.name, 'general'); + expect(session.subscribeFilters, hasLength(1)); + }, + ); + + test('fully reloads after being built in a failed state', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + initialState: SessionState( + status: SessionStatus.failed, + lastError: Exception('relay unavailable'), + ), + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await expectLater(container.read(channelsProvider.future), throwsException); + + session.setStatus(SessionStatus.connected); + await Future.delayed(Duration.zero); + + final recovered = await container.read(channelsProvider.future); + expect(recovered.single.name, 'general'); + expect(session.subscribeFilters, hasLength(1)); + }); + test( 'keeps cached channels and live subscriptions during reconnect', () async { @@ -431,8 +501,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 memberships; List metadata; final List hiddenDmEvents; @@ -444,7 +516,7 @@ class _FakeRelaySession extends RelaySessionNotifier { int unsubscribeCount = 0; @override - SessionState build() => const SessionState(status: SessionStatus.connected); + SessionState build() => initialState; @override Future> fetchHistory( @@ -492,8 +564,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. diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 6cd601f56b..0706c9535b 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -184,13 +184,115 @@ void main() { session.debugHandleDisconnected( const RelayAuthRejectedException('auth-required: verification failed'), ); - await Future.delayed(Duration.zero); - expect(session.state.status, SessionStatus.disconnected); + expect(session.state.status, SessionStatus.failed); + expect(session.state.lastError, isA()); 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(); diff --git a/mobile/test/shared/relay/relay_socket_test.dart b/mobile/test/shared/relay/relay_socket_test.dart new file mode 100644 index 0000000000..5cd25cc2fe --- /dev/null +++ b/mobile/test/shared/relay/relay_socket_test.dart @@ -0,0 +1,176 @@ +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('disconnect completes a pending authentication attempt', () async { + final channel = _ReadyWebSocketChannel(); + final socket = RelaySocket( + wsUrl: 'wss://relay.example', + nsec: null, + onMessage: (_) {}, + onConnected: () => fail('socket must not connect'), + onDisconnected: (_) {}, + channelFactory: (_) => channel, + ); + + final connecting = socket.connect(); + await Future.delayed(Duration.zero); + expect(socket.state, SocketState.authenticating); + + await socket.disconnect(); + await connecting.timeout(const Duration(seconds: 1)); + + expect(socket.state, SocketState.disconnected); + expect(channel.closeCount, 1); + }); + + test('auth-phase stream closure reports one disconnection', () async { + final channel = _ReadyWebSocketChannel(); + final disconnections = []; + final socket = RelaySocket( + wsUrl: 'wss://relay.example', + nsec: null, + onMessage: (_) {}, + onConnected: () => fail('socket must not connect'), + onDisconnected: disconnections.add, + channelFactory: (_) => channel, + ); + + final connecting = socket.connect(); + await Future.delayed(Duration.zero); + expect(socket.state, SocketState.authenticating); + + await channel.closeStream(); + await connecting.timeout(const Duration(seconds: 1)); + + expect(disconnections, hasLength(1)); + expect(disconnections.single, isA()); + expect(socket.state, SocketState.disconnected); + expect(channel.closeCount, 1); + }); + + test('auth-phase stream error reports one disconnection', () async { + final channel = _ReadyWebSocketChannel(); + final disconnections = []; + final socket = RelaySocket( + wsUrl: 'wss://relay.example', + nsec: null, + onMessage: (_) {}, + onConnected: () => fail('socket must not connect'), + onDisconnected: disconnections.add, + channelFactory: (_) => channel, + ); + + final connecting = socket.connect(); + await Future.delayed(Duration.zero); + expect(socket.state, SocketState.authenticating); + + channel.addStreamError(StateError('connection reset')); + await connecting.timeout(const Duration(seconds: 1)); + + expect(disconnections, hasLength(1)); + expect(disconnections.single, isA()); + expect(socket.state, SocketState.disconnected); + expect(channel.closeCount, 1); + }); + + test('hung handshake times out, closes, and reports disconnection', () async { + final channel = _HungWebSocketChannel(); + final disconnected = Completer(); + 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()); + expect(socket.state, SocketState.disconnected); + expect(channel.closeCount, 1); + }); +} + +class _ReadyWebSocketChannel implements WebSocketChannel { + final _controller = StreamController(); + int closeCount = 0; + + @override + Future get ready => Future.value(); + + @override + String? get protocol => null; + + @override + int? get closeCode => null; + + @override + String? get closeReason => null; + + @override + Stream get stream => _controller.stream; + + Future closeStream() => _controller.close(); + + void addStreamError(Object error) => _controller.addError(error); + + @override + late final WebSocketSink sink = _RecordingWebSocketSink( + _controller.sink, + () => closeCount++, + ); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _HungWebSocketChannel implements WebSocketChannel { + final _controller = StreamController(); + final _ready = Completer(); + int closeCount = 0; + + @override + Future get ready => _ready.future; + + @override + String? get protocol => null; + + @override + int? get closeCode => null; + + @override + String? get closeReason => null; + + @override + Stream 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 sink, this.onClose); + + @override + Future close([int? closeCode, String? closeReason]) async { + onClose(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +}