diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index 61329e8afd..558fba8865 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -40,6 +40,9 @@ class RelaySocket { @visibleForTesting static Duration debugPingInterval = pingInterval; + @visibleForTesting + static Duration debugConnectTimeout = relayWebSocketConnectTimeout; + final String _wsUrl; final String? _nsec; final void Function(List message) _onMessage; @@ -86,6 +89,7 @@ class RelaySocket { Uri.parse(_wsUrl), headers: headers, pingInterval: debugPingInterval, + connectTimeout: debugConnectTimeout, ), ); await _channel!.ready; diff --git a/mobile/lib/shared/relay/relay_websocket.dart b/mobile/lib/shared/relay/relay_websocket.dart index a91f19129a..0e4b91fc77 100644 --- a/mobile/lib/shared/relay/relay_websocket.dart +++ b/mobile/lib/shared/relay/relay_websocket.dart @@ -1,28 +1,34 @@ import 'dart:async'; import 'dart:io'; +/// Bounds how long a relay WebSocket may take to connect and upgrade. +/// +/// The OS gives up on a stalled TCP or TLS handshake only after minutes, and +/// reconnecting starts only once this attempt fails, so an unbounded attempt +/// leaves the app reconnecting for as long as the network stays stalled. +const relayWebSocketConnectTimeout = Duration(seconds: 10); + /// WebSocket's default HTTP client follows redirects, including custom headers. /// Keep key-bound assertions at their original origin and bound upgrade time. +/// +/// Each attempt gets its own HTTP client so a timed-out handshake can be +/// force-closed instead of left running until the OS gives up on it. Future openRelayWebSocket( Uri uri, { required Map headers, required Duration pingInterval, + Duration connectTimeout = relayWebSocketConnectTimeout, }) async { - if (headers.isEmpty) { - return WebSocket.connect( - uri.toString(), - ).then((socket) => socket..pingInterval = pingInterval); - } - final client = _UpgradeClient(); + final HttpClient client = headers.isEmpty ? HttpClient() : _UpgradeClient(); try { return await WebSocket.connect( uri.toString(), - headers: headers, + headers: headers.isEmpty ? null : headers, customClient: client, ) .then((socket) => socket..pingInterval = pingInterval) .timeout( - const Duration(seconds: 10), + connectTimeout, onTimeout: () { client.close(force: true); throw TimeoutException('Relay connection timed out'); diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index e810aa7cab..b213b925ff 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -871,6 +871,9 @@ void main() { ), ), inviteJoinRecoveryProvider.overrideWithValue((_) => recovery), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier(Future.value(const [])), + ), ], ); addTearDown(container.dispose); diff --git a/mobile/test/shared/agents/remote_agents_test.dart b/mobile/test/shared/agents/remote_agents_test.dart index 0469b2261a..cbae56a746 100644 --- a/mobile/test/shared/agents/remote_agents_test.dart +++ b/mobile/test/shared/agents/remote_agents_test.dart @@ -57,6 +57,9 @@ Future _controller(FakeRegistry registry) async { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + // Registrations go to a real loopback relay. The binding's mocked HTTP + // client would reject the WebSocket upgrade. + HttpOverrides.global = null; setUp(() { FlutterSecureStorage.setMockInitialValues({}); SharedPreferences.setMockInitialValues({}); diff --git a/mobile/test/shared/relay/relay_socket_liveness_test.dart b/mobile/test/shared/relay/relay_socket_liveness_test.dart index e3802f39b7..88ab3f9d0c 100644 --- a/mobile/test/shared/relay/relay_socket_liveness_test.dart +++ b/mobile/test/shared/relay/relay_socket_liveness_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:buzz/shared/relay/relay_socket.dart'; +import 'package:buzz/shared/relay/relay_websocket.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -40,17 +41,80 @@ Future _silentAfterHandshakeServer() async { return server; } +/// A server that accepts TCP connections and then never answers, like a +/// stalled TLS or upgrade handshake on a congested network. +/// Accepts TCP and never answers the upgrade. [closed] completes when the +/// client drops the connection. +Future<(ServerSocket server, Future closed)> +_stalledHandshakeServer() async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final closed = Completer(); + server.listen( + (client) => client.listen( + (_) {}, + onError: (_) {}, + onDone: () { + if (!closed.isCompleted) closed.complete(); + }, + ), + ); + return (server, closed.future); +} + void main() { const testPingInterval = Duration(milliseconds: 150); + const testConnectTimeout = Duration(milliseconds: 200); setUp(() { RelaySocket.debugPingInterval = testPingInterval; + RelaySocket.debugConnectTimeout = testConnectTimeout; }); tearDown(() { RelaySocket.debugPingInterval = RelaySocket.pingInterval; + RelaySocket.debugConnectTimeout = relayWebSocketConnectTimeout; }); + for (final scoped in [false, true]) { + test('gives up on a handshake that never completes ' + '(${scoped ? 'scoped headers' : 'no headers'})', () async { + final (server, clientClosed) = await _stalledHandshakeServer(); + + final disconnected = Completer(); + final socket = RelaySocket( + wsUrl: 'ws://127.0.0.1:${server.port}', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (error) { + if (!disconnected.isCompleted) disconnected.complete(error); + }, + ); + if (scoped) { + socket.headers = {'Nostr-Federated-Identity': 'Bearer synthetic'}; + } + unawaited(socket.connect()); + + final error = await disconnected.future.timeout( + testConnectTimeout * 10, + onTimeout: () => fail( + 'RelaySocket must fail a stalled handshake so the session can ' + 'schedule a reconnect', + ), + ); + expect(socket.state, SocketState.disconnected); + expect(error.toString(), contains('Relay connection timed out')); + // The abandoned handshake is closed, not left for the OS to time out. + await clientClosed.timeout( + testConnectTimeout * 10, + onTimeout: () => fail('the timed-out handshake was left open'), + ); + + socket.dispose(); + await server.close(); + }); + } + test('detects a peer that stops answering pings', () async { final server = await _silentAfterHandshakeServer();