diff --git a/packages/ndk/lib/domain_layer/entities/request_state.dart b/packages/ndk/lib/domain_layer/entities/request_state.dart index 6aec8d1ec..c95d54ee5 100644 --- a/packages/ndk/lib/domain_layer/entities/request_state.dart +++ b/packages/ndk/lib/domain_layer/entities/request_state.dart @@ -409,6 +409,19 @@ class RequestState { addRequest(key, filters); } + /// 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. A send path that + /// is still working out its connection still counts, it may yet register one. + void closeIfNoRelays() { + if (requests.isNotEmpty || + pendingConnections > 0 || + 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 459f08dce..fef8a94b8 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 @@ -71,7 +71,7 @@ class JitEngine with Logger implements NetworkEngine { final cleanedExplicitRelays = cleanRelayUrls( requestState.request.explicitRelays!.toList(), ); - RelayJitRequestSpecificStrategy.handleRequest( + await RelayJitRequestSpecificStrategy.handleRequest( relayManager: relayManagerLight, requestState: requestState, filter: filter, @@ -143,6 +143,8 @@ class JitEngine with Logger implements NetworkEngine { closeOnEOSE: ndkRequest.closeOnEOSE, ); } + + requestState.closeIfNoRelays(); } /// broadcasts given event using inbox/outbox (gossip) if explicit relays are given they are used instead 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 8e7948b03..32ec1be2f 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 @@ -14,7 +14,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 750f1d4cf..3510d2b67 100644 --- a/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart +++ b/packages/ndk/lib/domain_layer/usecases/relay_sets_engine.dart @@ -238,6 +238,8 @@ class RelaySetsEngine implements NetworkEngine { } _globalState.inFlightRequests[state.id] = state; + state.closeIfNoRelays(); + for (MapEntry entry in state.requests.entries.toList()) { doRelayRequest(state.id, entry.value).then((sent) { @@ -282,6 +284,8 @@ class RelaySetsEngine implements NetworkEngine { } _globalState.inFlightRequests[state.id] = state; + state.closeIfNoRelays(); + for (MapEntry entry in state.requests.entries) { doRelayRequest(state.id, entry.value).then((sent) { diff --git a/packages/ndk/test/mocks/mock_relay.dart b/packages/ndk/test/mocks/mock_relay.dart index 0a5256374..475961e2b 100644 --- a/packages/ndk/test/mocks/mock_relay.dart +++ b/packages/ndk/test/mocks/mock_relay.dart @@ -106,6 +106,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; @@ -187,6 +191,7 @@ class MockRelay { this.closeRequestsMessage, this.silenceRequests = false, this.silenceFirstAuths = 0, + this.ignoreRequests = false, int? explicitPort, }) : _nip65s = nip65s, _explicitPort = explicitPort, @@ -508,6 +513,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/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); + }); + }); +} 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, );