From b1dbe3e40be64777ba7ac07297cee94f09530d7f Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Tue, 14 Jul 2026 11:06:00 -0700 Subject: [PATCH] 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 Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- .../agent_activity/observer_subscription.dart | 3 +- .../channels/channel_detail_page.dart | 3 +- .../channels/channel_detail_page/banners.dart | 37 +++++- .../lib/features/channels/channels_page.dart | 10 +- .../channels/channels_page/badges.dart | 114 +++++++++++++++++- .../features/channels/channels_page/body.dart | 40 ++++-- .../features/channels/channels_provider.dart | 18 ++- mobile/lib/shared/relay/relay_session.dart | 44 +++++-- mobile/lib/shared/relay/relay_socket.dart | 24 +++- .../features/channels/channels_page_test.dart | 111 +++++++++++++++++ .../channels/channels_provider_test.dart | 30 ++++- .../test/shared/relay/relay_session_test.dart | 106 +++++++++++++++- .../test/shared/relay/relay_socket_test.dart | 71 +++++++++++ 13 files changed, 570 insertions(+), 41 deletions(-) create mode 100644 mobile/test/shared/relay/relay_socket_test.dart diff --git a/mobile/lib/features/channels/agent_activity/observer_subscription.dart b/mobile/lib/features/channels/agent_activity/observer_subscription.dart index c15686c58..f32966deb 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 2f67c8474..377e156f4 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 d94fe5e5b..006705d3d 100644 --- a/mobile/lib/features/channels/channel_detail_page/banners.dart +++ b/mobile/lib/features/channels/channel_detail_page/banners.dart @@ -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, ), diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index a0fa2c742..a1aec7a17 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 9d697000c..09abc6a2e 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,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; diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 0639a13c2..ce7867da2 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,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, + ), ), ); } diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 74bbe640b..01f6f11d8 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -62,9 +62,22 @@ 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) { + ref.invalidateSelf(); + } else if (waitingForInitialConnection && !_hasLoaded && !connected.isCompleted) { connected.complete(); @@ -92,6 +105,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 615b75b9a..4c3df82fd 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,9 @@ class RelaySessionNotifier extends Notifier { /// Force a reconnect (e.g., returning from background). Future 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 { // 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 { ? SessionStatus.reconnecting : SessionStatus.connecting, reconnectAttempt: state.reconnectAttempt, + lastError: state.lastError, ); _socket?.dispose(); @@ -389,21 +400,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 +432,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 267b03039..acb9378ea 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; } diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 232cdeb91..011064ddf 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,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> 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 79be7a66f..0a10af2e7 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -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 memberships; List metadata; final List 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> 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. diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 6cd601f56..0706c9535 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 000000000..4efe4021f --- /dev/null +++ b/mobile/test/shared/relay/relay_socket_test.dart @@ -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(); + 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 _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); +}