From 60f9e4ccfa3838c9a2175d66d774ecabdee223cb Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 5 Jul 2026 16:50:28 +0300 Subject: [PATCH] Bound the relay connect handshake so a fetch can't hang forever --- lib/src/nostr_fetch_actor.dart | 10 +++++++++- test/fake_relay_connection.dart | 4 ++++ test/nostr_fetch_actor_test.dart | 28 ++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/lib/src/nostr_fetch_actor.dart b/lib/src/nostr_fetch_actor.dart index a54ad49..823b1ab 100644 --- a/lib/src/nostr_fetch_actor.dart +++ b/lib/src/nostr_fetch_actor.dart @@ -31,15 +31,23 @@ final class EventsFetched extends FetchResult { class NostrFetchActor with Handler { final List _relayUrls; final Duration _eoseDeadline; + final Duration _connectDeadline; final RelayConnection Function(String url) _connectionFactory; NostrFetchActor({ required List relayUrls, Duration eoseDeadline = const Duration(seconds: 5), + // Bounds the WebSocket/TLS handshake. `connect()` awaits `channel.ready` + // with no deadline of its own, so a relay that accepts the socket but never + // completes the upgrade would hang the fetch forever — and, for a polling + // consumer, silently wedge its whole loop. On timeout the `finally` below + // closes the half-open socket and the relay is treated as down. + Duration connectDeadline = const Duration(seconds: 10), RelayConnection Function(String url) connectionFactory = WebSocketRelayConnection.new, }) : _relayUrls = relayUrls, _eoseDeadline = eoseDeadline, + _connectDeadline = connectDeadline, _connectionFactory = connectionFactory; @override @@ -78,7 +86,7 @@ class NostrFetchActor with Handler { final collected = []; try { - await conn.connect(); + await conn.connect().timeout(_connectDeadline); await conn.send(jsonEncode(['REQ', subId, jsonDecode(filter)])); try { await for (final raw in conn.messages.timeout(_eoseDeadline)) { diff --git a/test/fake_relay_connection.dart b/test/fake_relay_connection.dart index 5b46c4b..3517b32 100644 --- a/test/fake_relay_connection.dart +++ b/test/fake_relay_connection.dart @@ -9,11 +9,15 @@ final class FakeRelayConnection implements RelayConnection { bool isConnected = false; bool autoRespond = true; bool throwOnConnect = false; + // When set, connect() never completes — simulates a handshake that hangs + // (accepted socket, no WebSocket upgrade). + bool hangOnConnect = false; void Function(String subId)? onReq; @override Future connect() async { if (throwOnConnect) throw Exception('connection failed'); + if (hangOnConnect) await Completer().future; isConnected = true; } diff --git a/test/nostr_fetch_actor_test.dart b/test/nostr_fetch_actor_test.dart index ae4ff4c..0870c8d 100644 --- a/test/nostr_fetch_actor_test.dart +++ b/test/nostr_fetch_actor_test.dart @@ -120,6 +120,34 @@ void main() { expect((result as EventsFetched).events, hasLength(1)); }); + test('bounds a hung connect so the fetch cannot stall forever', () async { + final fakeHang = FakeRelayConnection()..hangOnConnect = true; + final fakeUp = FakeRelayConnection(); + addTearDown(fakeHang.dispose); + addTearDown(fakeUp.dispose); + + final event = _makeEvent('id1'); + fakeUp.onReq = (subId) { + fakeUp.push(jsonEncode(['EVENT', subId, event])); + fakeUp.push(jsonEncode(['EOSE', subId])); + }; + + final fakes = {'ws://hang': fakeHang, 'ws://up': fakeUp}; + final actor = NostrFetchActor( + relayUrls: ['ws://hang', 'ws://up'], + connectDeadline: const Duration(milliseconds: 50), + connectionFactory: (url) => fakes[url]!, + ); + + // The outer timeout is a guard: without the connect deadline this hangs. + final result = await actor + .handle(const FetchEvents(_filter)) + .timeout(const Duration(seconds: 2)); + + expect(result, isA()); + expect((result as EventsFetched).events, hasLength(1)); + }); + test('sends CLOSE after EOSE', () async { final fake = FakeRelayConnection(); addTearDown(fake.dispose);