From b1dbe3e40be64777ba7ac07297cee94f09530d7f Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Tue, 14 Jul 2026 11:06:00 -0700 Subject: [PATCH 1/5] 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 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..006705d3df 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 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..09abc6a2e3 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 0639a13c26..ce7867da22 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 74bbe640bd..01f6f11d8a 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 615b75b9ae..4c3df82fd5 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 267b030391..acb9378ea9 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 232cdeb912..011064ddf5 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 79be7a66f7..0a10af2e7b 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 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..4efe4021ff --- /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); +} From 336a1a0b7a4e1ec534354b452124bbb3997f101e Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Tue, 14 Jul 2026 11:27:52 -0700 Subject: [PATCH 2/5] fix(mobile): address offline state review feedback Ensure interrupted authentication completes, fully reload channels after terminal recovery, avoid exposing raw connection errors, and distinguish auth rejection in cached banners. Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- .../channels/channel_detail_page/banners.dart | 4 +- .../channels/channels_page/badges.dart | 14 ++--- .../features/channels/channels_page/body.dart | 5 +- .../features/channels/channels_provider.dart | 5 +- mobile/lib/shared/relay/relay_socket.dart | 1 + .../features/channels/channels_page_test.dart | 5 +- .../channels/channels_provider_test.dart | 48 +++++++++++++++++ .../test/shared/relay/relay_socket_test.dart | 51 +++++++++++++++++++ 8 files changed, 123 insertions(+), 10 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page/banners.dart b/mobile/lib/features/channels/channel_detail_page/banners.dart index 006705d3df..4af232cc3a 100644 --- a/mobile/lib/features/channels/channel_detail_page/banners.dart +++ b/mobile/lib/features/channels/channel_detail_page/banners.dart @@ -79,7 +79,9 @@ class _DetailConnectionBanner extends StatelessWidget { ), child: Center( child: Text( - 'Connection lost — Retry', + state.lastError is RelayAuthRejectedException + ? 'Authentication rejected — Retry' + : 'Connection lost — Retry', style: context.textTheme.labelSmall?.copyWith( color: context.colors.onSurfaceVariant, ), diff --git a/mobile/lib/features/channels/channels_page/badges.dart b/mobile/lib/features/channels/channels_page/badges.dart index 09abc6a2e3..36f129f7fc 100644 --- a/mobile/lib/features/channels/channels_page/badges.dart +++ b/mobile/lib/features/channels/channels_page/badges.dart @@ -125,12 +125,16 @@ class _ConnectionBanner extends StatelessWidget { } class _ConnectionLostBanner extends StatelessWidget { + final Object? error; final VoidCallback onRetry; - const _ConnectionLostBanner({required this.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( @@ -139,7 +143,7 @@ class _ConnectionLostBanner extends StatelessWidget { height: _kBannerHeight, child: Center( child: Text( - 'Connection lost — Retry', + message, style: context.textTheme.labelSmall?.copyWith( color: context.colors.onSurfaceVariant, ), @@ -169,11 +173,9 @@ class _OfflineView extends StatelessWidget { ? 'Authentication rejected by the relay' : "Can't reach your workspace"; final body = authRejected - ? (error as RelayAuthRejectedException).message + ? 'Check your workspace access or credentials, then retry.' : 'Check your network or VPN (e.g. Cloudflare WARP), then retry.'; - final detail = authRejected - ? relayHost - : '$relayHost\n${error ?? 'Connection failed'}'; + final detail = relayHost; return Center( child: Padding( diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index ce7867da22..15cf693ca6 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -57,7 +57,10 @@ class _ChannelsBody extends StatelessWidget { left: 0, right: 0, child: connectionLost - ? _ConnectionLostBanner(onRetry: onRetryConnection) + ? _ConnectionLostBanner( + error: sessionState.lastError, + onRetry: onRetryConnection, + ) : showConnectionBanner ? _ConnectionBanner(state: sessionState) : const SizedBox.shrink(), diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 01f6f11d8a..690d1d1a74 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -75,7 +75,10 @@ class ChannelsNotifier extends AsyncNotifier> { return; } if (next.status != SessionStatus.connected) return; - if (initiallyFailed) { + if (initiallyFailed || + (waitingForInitialConnection && + connected.isCompleted && + !_hasLoaded)) { ref.invalidateSelf(); } else if (waitingForInitialConnection && !_hasLoaded && diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index acb9378ea9..d0fd8426ec 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -156,6 +156,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 011064ddf5..228a9e73fc 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -207,7 +207,10 @@ void main() { await tester.pump(); expect(find.text('Authentication rejected by the relay'), findsOneWidget); - expect(find.text('access revoked'), findsOneWidget); + expect( + find.text('Check your workspace access or credentials, then retry.'), + findsOneWidget, + ); expect(find.textContaining('Cloudflare WARP'), findsNothing); expect(find.byType(CircularProgressIndicator), findsNothing); }); diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 0a10af2e7b..a7d4710fc2 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -264,6 +264,54 @@ void main() { }, ); + 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 { diff --git a/mobile/test/shared/relay/relay_socket_test.dart b/mobile/test/shared/relay/relay_socket_test.dart index 4efe4021ff..5546a822b4 100644 --- a/mobile/test/shared/relay/relay_socket_test.dart +++ b/mobile/test/shared/relay/relay_socket_test.dart @@ -5,6 +5,28 @@ 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('hung handshake times out, closes, and reports disconnection', () async { final channel = _HungWebSocketChannel(); final disconnected = Completer(); @@ -26,6 +48,35 @@ void main() { }); } +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; + + @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(); From 024476edc463f6cbdd1330c90b257b1e4aa5c4e2 Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Tue, 14 Jul 2026 11:36:09 -0700 Subject: [PATCH 3/5] fix(mobile): stabilize relay retry lifecycle Avoid duplicate disconnect callbacks during authentication and invalidate deliberate reconnect callbacks before replacing the socket. Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- mobile/lib/shared/relay/relay_session.dart | 1 + mobile/lib/shared/relay/relay_socket.dart | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 4c3df82fd5..6b5e551f26 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -317,6 +317,7 @@ class RelaySessionNotifier extends Notifier { /// Force a reconnect (e.g., returning from background). Future reconnect() async { _reconnectTimer?.cancel(); + ++_connectionGeneration; await _socket?.disconnect(); _resetReconnectBudget(); final config = ref.read(relayConfigProvider); diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index d0fd8426ec..11a0b132a7 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -102,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); }, ); From 473986fe645a20486daf162d1f17fea893071cfa Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Tue, 14 Jul 2026 11:55:10 -0700 Subject: [PATCH 4/5] test(mobile): cover auth disconnect deduplication Assert that a stream closure during NIP-42 authentication produces exactly one disconnect callback. Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- .../test/shared/relay/relay_socket_test.dart | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/mobile/test/shared/relay/relay_socket_test.dart b/mobile/test/shared/relay/relay_socket_test.dart index 5546a822b4..e807c9bb12 100644 --- a/mobile/test/shared/relay/relay_socket_test.dart +++ b/mobile/test/shared/relay/relay_socket_test.dart @@ -27,6 +27,30 @@ void main() { 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; + + expect(disconnections, hasLength(1)); + expect(disconnections.single, isA()); + expect(socket.state, SocketState.disconnected); + }); + test('hung handshake times out, closes, and reports disconnection', () async { final channel = _HungWebSocketChannel(); final disconnected = Completer(); @@ -67,6 +91,8 @@ class _ReadyWebSocketChannel implements WebSocketChannel { @override Stream get stream => _controller.stream; + Future closeStream() => _controller.close(); + @override late final WebSocketSink sink = _RecordingWebSocketSink( _controller.sink, From 3852fdac3dfad833cfae6ce0afa00e5520bfd313 Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Tue, 14 Jul 2026 11:58:47 -0700 Subject: [PATCH 5/5] test(mobile): harden auth disconnect coverage Cover both clean stream closure and stream errors during authentication, with bounded waits and socket-close assertions. Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- .../test/shared/relay/relay_socket_test.dart | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/mobile/test/shared/relay/relay_socket_test.dart b/mobile/test/shared/relay/relay_socket_test.dart index e807c9bb12..5cd25cc2fe 100644 --- a/mobile/test/shared/relay/relay_socket_test.dart +++ b/mobile/test/shared/relay/relay_socket_test.dart @@ -44,11 +44,37 @@ void main() { expect(socket.state, SocketState.authenticating); await channel.closeStream(); - await connecting; + 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 { @@ -93,6 +119,8 @@ class _ReadyWebSocketChannel implements WebSocketChannel { Future closeStream() => _controller.close(); + void addStreamError(Object error) => _controller.addError(error); + @override late final WebSocketSink sink = _RecordingWebSocketSink( _controller.sink,