fix(social): cap relay connect with a timeout so one hung relay can't stall the pool

This commit is contained in:
vjrj 2026-07-22 12:48:55 +02:00
parent 3ec6ed2329
commit 981cf10048
2 changed files with 50 additions and 4 deletions

View file

@ -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<RelayPool> connect(
List<String> relayUrls, {
required NostrIdentity identity,
Duration connectTimeout = const Duration(seconds: 10),
}) async {
final connections = <NostrConnection>[];
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.

View file

@ -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 = <Socket>[];
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(