From 67e955366c15e620d4734994c072bb2679112d7f Mon Sep 17 00:00:00 2001 From: Nogringo Date: Sun, 6 Sep 2026 16:44:05 +0200 Subject: [PATCH 1/3] test: cover query sent to zero relays --- .../test/timeouts/no_relay_timeout_test.dart | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 packages/ndk/test/timeouts/no_relay_timeout_test.dart diff --git a/packages/ndk/test/timeouts/no_relay_timeout_test.dart b/packages/ndk/test/timeouts/no_relay_timeout_test.dart new file mode 100644 index 000000000..6c364ab23 --- /dev/null +++ b/packages/ndk/test/timeouts/no_relay_timeout_test.dart @@ -0,0 +1,65 @@ +import 'package:ndk/ndk.dart'; +import 'package:test/test.dart'; + +import '../mocks/mock_event_verifier.dart'; + +/// a request sent to zero relays has nothing to wait for and must not hang +/// until the query timeout +void main() { + const queryTimeout = Duration(seconds: 10); + const maxAcceptable = Duration(seconds: 5); + + Future expectFastEmptyQuery(Ndk ndk) async { + bool timeoutTriggered = false; + + final stopwatch = Stopwatch()..start(); + final response = ndk.requests.query( + filter: Filter(kinds: [Nip01Event.kTextNodeKind]), + timeout: queryTimeout, + timeoutCallback: () { + timeoutTriggered = true; + }, + ); + + final events = await response.future; + stopwatch.stop(); + + expect(events, isEmpty); + expect(timeoutTriggered, isFalse); + expect(stopwatch.elapsed, lessThan(maxAcceptable)); + } + + group('no relay - query does not wait for the timeout', () { + late Ndk ndk; + + tearDown(() async { + await ndk.destroy(); + }); + + test('relay sets engine', () async { + ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.RELAY_SETS, + bootstrapRelays: [], + ), + ); + + await expectFastEmptyQuery(ndk); + }); + + test('jit engine', () async { + ndk = Ndk( + NdkConfig( + eventVerifier: MockEventVerifier(), + cache: MemCacheManager(), + engine: NdkEngine.JIT, + bootstrapRelays: [], + ), + ); + + await expectFastEmptyQuery(ndk); + }); + }); +} From 876687cb4404345c280c6170da7d0a976969d0cd Mon Sep 17 00:00:00 2001 From: Nogringo Date: Sun, 6 Sep 2026 17:10:34 +0200 Subject: [PATCH 2/3] fix(requests): end a query that reached no relay A request dispatched to zero relays had nothing to answer it, so nothing closed its network stream and it only ended when the query timeout fired. With the default timeout that is a query hanging for seconds on an empty relay list. RequestState.closeIfNoRelays() mirrors BroadcastState.closeIfNoRelays(): no relay request registered means no answer is coming, so the network stream closes right away. RELAY_SETS calls it after dispatching, where relay entries are added synchronously. JIT registers its relays asynchronously, so its strategies are now awaited before the check, which is why the specific and pubkey strategies return futures and the pubkey strategy waits on the connection attempts it used to fire and forget. --- .../domain_layer/entities/request_state.dart | 8 ++ .../usecases/jit_engine/jit_engine.dart | 85 +++++++------ .../relay_jit_pubkey_strategy.dart | 116 +++++++++--------- .../relay_jit_specific_strategy.dart | 2 +- .../usecases/relay_sets_engine.dart | 4 + 5 files changed, 123 insertions(+), 92 deletions(-) diff --git a/packages/ndk/lib/domain_layer/entities/request_state.dart b/packages/ndk/lib/domain_layer/entities/request_state.dart index cf75fcf01..e51ca364b 100644 --- a/packages/ndk/lib/domain_layer/entities/request_state.dart +++ b/packages/ndk/lib/domain_layer/entities/request_state.dart @@ -144,6 +144,14 @@ class RequestState { } } + /// Closes the network stream if the request was not sent to any relay. + /// Engines call this once they are done dispatching: without a relay to + /// answer, nothing would end the request but its timeout. + void closeIfNoRelays() { + if (requests.isNotEmpty || networkController.isClosed) return; + networkController.close(); + } + /// closes all streams Future close() async { if (_timeout != null) { diff --git a/packages/ndk/lib/domain_layer/usecases/jit_engine/jit_engine.dart b/packages/ndk/lib/domain_layer/usecases/jit_engine/jit_engine.dart index 0b81df294..8eecbb567 100644 --- a/packages/ndk/lib/domain_layer/usecases/jit_engine/jit_engine.dart +++ b/packages/ndk/lib/domain_layer/usecases/jit_engine/jit_engine.dart @@ -61,6 +61,10 @@ class JitEngine with Logger implements NetworkEngine { //clean ignore relays List cleanIgnoreRelays = cleanRelayUrls(ignoreRelays); + /// strategies register their relays asynchronously, so the decision on + /// whether this request reached any relay has to wait for all of them + final List> strategies = []; + /// ["REQ", , , , ...] /// user can provide multiple filters for (final filter in requestState.unresolvedFilters) { @@ -71,52 +75,58 @@ class JitEngine with Logger implements NetworkEngine { final cleanedExplicitRelays = cleanRelayUrls( requestState.request.explicitRelays!.toList(), ); - RelayJitRequestSpecificStrategy.handleRequest( - relayManager: relayManagerLight, - requestState: requestState, - filter: filter, - closeOnEOSE: ndkRequest.closeOnEOSE, - specificRelays: cleanedExplicitRelays, + strategies.add( + RelayJitRequestSpecificStrategy.handleRequest( + relayManager: relayManagerLight, + requestState: requestState, + filter: filter, + closeOnEOSE: ndkRequest.closeOnEOSE, + specificRelays: cleanedExplicitRelays, + ), ); continue; } if ((filter.authors != null && filter.authors!.isNotEmpty)) { - RelayJitPubkeyStrategy.handleRequest( - globalState: globalState, - relayManager: relayManagerLight, - requestState: requestState, - cacheManager: cache, - filter: filter, - connectedRelays: relayManagerLight.connectedAnonymousRelays - .whereType>() - .toList(), - bootstrapRelays: bootstrapRelays, - desiredCoverage: ndkRequest.desiredCoverage, - closeOnEOSE: ndkRequest.closeOnEOSE, - direction: ReadWriteMarker - .writeOnly, // the author should write on the persons write relays - ignoreRelays: cleanIgnoreRelays, + strategies.add( + RelayJitPubkeyStrategy.handleRequest( + globalState: globalState, + relayManager: relayManagerLight, + requestState: requestState, + cacheManager: cache, + filter: filter, + connectedRelays: relayManagerLight.connectedAnonymousRelays + .whereType>() + .toList(), + bootstrapRelays: bootstrapRelays, + desiredCoverage: ndkRequest.desiredCoverage, + closeOnEOSE: ndkRequest.closeOnEOSE, + direction: ReadWriteMarker + .writeOnly, // the author should write on the persons write relays + ignoreRelays: cleanIgnoreRelays, + ), ); continue; } if (filter.pTags?.isNotEmpty != null && filter.pTags!.isNotEmpty) { - RelayJitPubkeyStrategy.handleRequest( - relayManager: relayManagerLight, - globalState: globalState, - requestState: requestState, - cacheManager: cache, - filter: filter, - connectedRelays: relayManagerLight.connectedAnonymousRelays - .whereType>() - .toList(), - bootstrapRelays: bootstrapRelays, - desiredCoverage: ndkRequest.desiredCoverage, - closeOnEOSE: ndkRequest.closeOnEOSE, - direction: ReadWriteMarker - .readOnly, // others should mention on the persons read relays - ignoreRelays: cleanIgnoreRelays, + strategies.add( + RelayJitPubkeyStrategy.handleRequest( + relayManager: relayManagerLight, + globalState: globalState, + requestState: requestState, + cacheManager: cache, + filter: filter, + connectedRelays: relayManagerLight.connectedAnonymousRelays + .whereType>() + .toList(), + bootstrapRelays: bootstrapRelays, + desiredCoverage: ndkRequest.desiredCoverage, + closeOnEOSE: ndkRequest.closeOnEOSE, + direction: ReadWriteMarker + .readOnly, // others should mention on the persons read relays + ignoreRelays: cleanIgnoreRelays, + ), ); continue; } @@ -144,6 +154,9 @@ class JitEngine with Logger implements NetworkEngine { ); } + await Future.wait(strategies); + requestState.closeIfNoRelays(); + // Late auth for subscriptions with authenticateAs if (ndkRequest.authenticateAs != null && ndkRequest.authenticateAs!.isNotEmpty) { diff --git a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_pubkey_strategy.dart b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_pubkey_strategy.dart index 03c9f68d0..d6c80da1c 100644 --- a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_pubkey_strategy.dart +++ b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_pubkey_strategy.dart @@ -35,7 +35,7 @@ import '../../user_relay_lists/user_relay_lists.dart'; /// class RelayJitPubkeyStrategy with Logger { - static void handleRequest({ + static Future handleRequest({ required RequestState requestState, required GlobalState globalState, @@ -52,7 +52,7 @@ class RelayJitPubkeyStrategy with Logger { required ReadWriteMarker direction, required List ignoreRelays, required RelayManager relayManager, - }) { + }) async { List combindedPubkeys = [ ...?filter.authors, ...?filter.pTags, @@ -109,7 +109,7 @@ class RelayJitPubkeyStrategy with Logger { return; } - _findRelaysForUnresolvedPubkeys( + await _findRelaysForUnresolvedPubkeys( requestState: requestState, globalState: globalState, relayManger: relayManager, @@ -148,7 +148,7 @@ class RelayJitPubkeyStrategy with Logger { // looks in nip65 data for not covered pubkeys // the result is relay candidates // connects to these candidates and sends out the request - static void _findRelaysForUnresolvedPubkeys({ + static Future _findRelaysForUnresolvedPubkeys({ required RelayManager relayManger, required RequestState requestState, required GlobalState globalState, @@ -184,6 +184,7 @@ class RelayJitPubkeyStrategy with Logger { coveragePubkeys = relayRanking.notCoveredPubkeys; // connect to the new found relays and send out the request + final List> connectFutures = []; for (final relayCandidate in relayRanking.ranking) { if (relayCandidate.score <= 0) { continue; @@ -194,57 +195,60 @@ class RelayJitPubkeyStrategy with Logger { ); if (!alreadyConnected) { - relayManger - .connectRelay( - dirtyUrl: relayCandidate.relayUrl, - connectionSource: ConnectionSource.pubkeyStrategy, - ) - .then((success) { - if (success.first) { - final myRelayConnectivity = - globalState.relays[RelayConnectionKey.anonymous( - relayCandidate.relayUrl, - )] as RelayConnectivity; - // add assigned pubkeys - myRelayConnectivity.specificEngineData!.addPubkeysToAssignedPubkeys( - relayCandidate.coveredPubkeys.map((e) => e.pubkey).toList(), - direction, - ); - - // send out the request - _sendRequestToSocket( - myRelayConnectivity, - requestState, - [ - _splitFilter( - filter, - relayCandidate.coveredPubkeys.map((e) => e.pubkey).toList(), - ), - ], - globalState, - relayManger, - ); - } - - if (!success.first) { - Logger.log.w( - () => - "Could not connect to relay: ${relayCandidate.relayUrl} - errorHandling", - ); - // _connectionErrorHandling( - // errorRelay: newRelay, - // requestState: requestState, - // filter: filter, - // connectedRelays: connectedRelays, - // cacheManager: cacheManager, - // desiredCoverage: desiredCoverage, - // direction: direction, - // ignoreRelays: ignoreRelays, - // closeOnEOSE: closeOnEOSE, - - // ); - } - }); + connectFutures.add( + relayManger + .connectRelay( + dirtyUrl: relayCandidate.relayUrl, + connectionSource: ConnectionSource.pubkeyStrategy, + ) + .then((success) { + if (success.first) { + final myRelayConnectivity = + globalState.relays[RelayConnectionKey.anonymous( + relayCandidate.relayUrl, + )] as RelayConnectivity; + // add assigned pubkeys + myRelayConnectivity.specificEngineData! + .addPubkeysToAssignedPubkeys( + relayCandidate.coveredPubkeys.map((e) => e.pubkey).toList(), + direction, + ); + + // send out the request + _sendRequestToSocket( + myRelayConnectivity, + requestState, + [ + _splitFilter( + filter, + relayCandidate.coveredPubkeys.map((e) => e.pubkey).toList(), + ), + ], + globalState, + relayManger, + ); + } + + if (!success.first) { + Logger.log.w( + () => + "Could not connect to relay: ${relayCandidate.relayUrl} - errorHandling", + ); + // _connectionErrorHandling( + // errorRelay: newRelay, + // requestState: requestState, + // filter: filter, + // connectedRelays: connectedRelays, + // cacheManager: cacheManager, + // desiredCoverage: desiredCoverage, + // direction: direction, + // ignoreRelays: ignoreRelays, + // closeOnEOSE: closeOnEOSE, + + // ); + } + }), + ); } if (alreadyConnected) { @@ -272,6 +276,8 @@ class RelayJitPubkeyStrategy with Logger { ); } } + + await Future.wait(connectFutures); } // adds the relay to ignoreRelays and retries the request for assigned pubkeys to this relay diff --git a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_specific_strategy.dart b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_specific_strategy.dart index 14a7678c0..72c8d3e1e 100644 --- a/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_specific_strategy.dart +++ b/packages/ndk/lib/domain_layer/usecases/jit_engine/relay_jit_request_strategies/relay_jit_specific_strategy.dart @@ -11,7 +11,7 @@ import '../../relay_manager.dart'; /// class RelayJitRequestSpecificStrategy { /// send out the request - static void handleRequest({ + static Future handleRequest({ required RequestState requestState, required Filter filter, required bool closeOnEOSE, diff --git a/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart b/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart index 0d82e1e1e..d9a5d6149 100644 --- a/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart +++ b/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart @@ -230,6 +230,8 @@ class RelaySetsEngine implements NetworkEngine { } } + state.closeIfNoRelays(); + for (MapEntry entry in state.requests.entries) { doRelayRequest(state.id, entry.value); @@ -278,6 +280,8 @@ class RelaySetsEngine implements NetworkEngine { } } + state.closeIfNoRelays(); + for (MapEntry entry in state.requests.entries) { doRelayRequest(state.id, entry.value).then((sent) { From 1c175766d53890750a60fcc3a0919cd48aded5f7 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Sun, 6 Sep 2026 17:11:57 +0200 Subject: [PATCH 3/3] test(timeouts): time out on a silent relay, not on an empty relay list The two tests asserting that a query times out were configured with bootstrapRelays: ["invalid"]. cleanRelayUrl drops any URL without a ws or wss scheme, so the list was silently emptied: they timed out because the request reached no relay at all, which is the behaviour just fixed, not because a relay failed to answer. They now point at a MockRelay that accepts the connection and the REQ but never answers it, the one case where expiring is the correct outcome. These carry the only assertions in the suite that a timeout does fire, and the only check that NdkConfig.defaultQueryTimeout is honoured, so they are kept rather than dropped. MockRelay gains the ignoreRequests flag for that. --- packages/ndk/test/mocks/mock_relay.dart | 10 ++++++++++ packages/ndk/test/timeouts/query_timeout_test.dart | 9 +++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/ndk/test/mocks/mock_relay.dart b/packages/ndk/test/mocks/mock_relay.dart index 1bee59b09..b43bc3dfc 100644 --- a/packages/ndk/test/mocks/mock_relay.dart +++ b/packages/ndk/test/mocks/mock_relay.dart @@ -99,6 +99,10 @@ class MockRelay { /// in the middle of an authentication does. The next ones are answered int silenceFirstAuths; + /// accept REQ messages but never answer them, neither with events nor with + /// an EOSE, the way a relay that is alive but stuck does + bool ignoreRequests; + // NIP-46 Remote Signer Support static const int kNip46Kind = BunkerRequest.kKind; @@ -178,6 +182,7 @@ class MockRelay { this.rejectFirstEventPublishes = 0, this.rejectEventMessage = 'rate-limited: retry later', this.silenceFirstAuths = 0, + this.ignoreRequests = false, int? explicitPort, }) : _nip65s = nip65s, _explicitPort = explicitPort, @@ -470,6 +475,11 @@ class MockRelay { return; } + if (ignoreRequests) { + log("MockRelay: ignoring REQ $requestId"); + return; + } + if (filters.isNotEmpty) { // Store the subscription for this client _clientSubscriptions[webSocket]?[requestId] = filters; diff --git a/packages/ndk/test/timeouts/query_timeout_test.dart b/packages/ndk/test/timeouts/query_timeout_test.dart index b9c13b9a0..a614a3925 100644 --- a/packages/ndk/test/timeouts/query_timeout_test.dart +++ b/packages/ndk/test/timeouts/query_timeout_test.dart @@ -20,6 +20,7 @@ void main() { group('Timeout - query', () { KeyPair key1 = Bip340.generatePrivateKey(); late MockRelay relay1; + late MockRelay silentRelay; late Ndk ndk; Map key1TextNotes = {key1: textNote(key1)}; @@ -28,11 +29,15 @@ void main() { relay1 = MockRelay(name: "relay 1"); await relay1.startServer(); relay1.textNotes = key1TextNotes; + + silentRelay = MockRelay(name: "silent relay", ignoreRequests: true); + await silentRelay.startServer(); }); tearDown(() async { await ndk.destroy(); await relay1.stopServer(); + await silentRelay.stopServer(); }); test('timeout does not trigger on normal request', () async { @@ -74,7 +79,7 @@ void main() { eventVerifier: MockEventVerifier(), cache: MemCacheManager(), engine: NdkEngine.RELAY_SETS, - bootstrapRelays: ["invalid"], + bootstrapRelays: [silentRelay.url], ); ndk = Ndk(config); @@ -111,7 +116,7 @@ void main() { eventVerifier: MockEventVerifier(), cache: MemCacheManager(), engine: NdkEngine.RELAY_SETS, - bootstrapRelays: ["invalid"], + bootstrapRelays: [silentRelay.url], defaultQueryTimeout: myTimeout, );