diff --git a/packages/commons_core/lib/src/social/nostr/relay_pool.dart b/packages/commons_core/lib/src/social/nostr/relay_pool.dart index eac038d..aad754d 100644 --- a/packages/commons_core/lib/src/social/nostr/relay_pool.dart +++ b/packages/commons_core/lib/src/social/nostr/relay_pool.dart @@ -26,19 +26,32 @@ class RelayPool implements NostrChannel { /// Number of relays currently connected (for diagnostics/tests). int get relayCount => _connections.length; - /// Connects to each of [relayUrls], skipping any that fail. Throws - /// [StateError] only when NONE are reachable, so the caller can treat that as - /// "offline". + /// Connects to each of [relayUrls], skipping any that fail or take longer + /// than [connectTimeout] — a relay on a silently-filtering network would + /// otherwise hang the whole pool for minutes. Throws [StateError] only when + /// NONE are reachable, so the caller can treat that as "offline". static Future connect( List relayUrls, { required NostrIdentity identity, + Duration connectTimeout = const Duration(seconds: 10), }) async { final connections = []; await Future.wait( relayUrls.map((url) async { try { + final attempt = NostrConnection.connect(url, identity: identity); connections.add( - await NostrConnection.connect(url, identity: identity), + await attempt.timeout( + connectTimeout, + onTimeout: () { + // Abandon the hung attempt; if it ever completes, close it so + // the socket doesn't leak. + unawaited( + attempt.then((c) => c.close()).catchError((_) {}), + ); + throw TimeoutException('relay connect timed out', connectTimeout); + }, + ), ); } catch (_) { // Unreachable relay — skip it, keep the rest. diff --git a/packages/commons_core/test/social/relay_pool_test.dart b/packages/commons_core/test/social/relay_pool_test.dart index 52de12f..cdb41bc 100644 --- a/packages/commons_core/test/social/relay_pool_test.dart +++ b/packages/commons_core/test/social/relay_pool_test.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:typed_data'; import 'package:commons_core/commons_core.dart'; @@ -58,6 +59,38 @@ void main() { await r1.stop(); }); + test('a hung relay cannot stall the pool past the connect timeout', () async { + // A server that accepts TCP but never answers the WebSocket handshake — + // the shape of a silently-filtered network, where connect hangs for minutes. + final hang = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final held = []; + hang.listen(held.add); + final r1 = await MiniRelay.start(); + final alice = await idFor(5); + + final sw = Stopwatch()..start(); + final pool = await RelayPool.connect( + [r1.url, 'ws://127.0.0.1:${hang.port}'], + identity: alice, + connectTimeout: const Duration(milliseconds: 500), + ); + sw.stop(); + + expect(pool.relayCount, 1, reason: 'the live relay is kept'); + expect( + sw.elapsed, + lessThan(const Duration(seconds: 5)), + reason: 'the hung relay must not stall the whole pool', + ); + + await pool.close(); + for (final s in held) { + s.destroy(); + } + await hang.close(); + await r1.stop(); + }); + test('throws when no relay is reachable (caller treats as offline)', () async { final alice = await idFor(4); expect(