From ca245959a28160d96ecf1c0b59a46f7ddc583e65 Mon Sep 17 00:00:00 2001 From: nils2525 Date: Mon, 31 Aug 2026 21:07:33 +0200 Subject: [PATCH 1/2] Fix subscription query completion for topic-filtered routes (#288) --- .../SocketRoutingTests/SubscriptionTests.cs | 130 ++++++++++++++++++ .../Sockets/Default/Routing/MessageRouter.cs | 13 +- .../Default/Routing/RouteCollection.cs | 9 ++ .../Sockets/Default/Subscription.cs | 6 +- 4 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 CryptoExchange.Net.UnitTests/SocketRoutingTests/SubscriptionTests.cs diff --git a/CryptoExchange.Net.UnitTests/SocketRoutingTests/SubscriptionTests.cs b/CryptoExchange.Net.UnitTests/SocketRoutingTests/SubscriptionTests.cs new file mode 100644 index 00000000..1d89c323 --- /dev/null +++ b/CryptoExchange.Net.UnitTests/SocketRoutingTests/SubscriptionTests.cs @@ -0,0 +1,130 @@ +using CryptoExchange.Net.Objects; +using CryptoExchange.Net.Sockets; +using CryptoExchange.Net.Sockets.Default; +using CryptoExchange.Net.Sockets.Default.Routing; +using CryptoExchange.Net.UnitTests.Implementations; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using System; + +namespace CryptoExchange.Net.UnitTests.SocketRoutingTests +{ + [TestFixture] + public class SubscriptionTests + { + [Test] + public void Handle_Should_OnlyCompleteSubscriptionQuery_ForMatchingTopic() + { + // arrange + var topicASubscription = new TopicSubscription("topic-a"); + var topicBSubscription = new TopicSubscription("topic-b"); + var topicAQuery = topicASubscription.CreateSubscriptionQuery(null!)!; + var topicBQuery = topicBSubscription.CreateSubscriptionQuery(null!)!; + + // act + var topicAHandled = topicASubscription.Handle("type", "topic-a", null!, DateTime.UtcNow, "original", "data"); + var topicBHandled = topicBSubscription.Handle("type", "topic-a", null!, DateTime.UtcNow, "original", "data"); + + // assert + Assert.That(topicAHandled, Is.True); + Assert.That(topicBHandled, Is.False); + Assert.That(topicAQuery.Completed, Is.True); + Assert.That(topicAQuery.Success, Is.True); + Assert.That(topicBQuery.Completed, Is.False); + } + + [Test] + public void Handle_Should_CompleteSubscriptionQuery_BeforeInvokingMatchingHandler() + { + // arrange + Query? query = null; + var queryCompletedWhenHandlerInvoked = false; + var subscription = new TopicSubscription("topic", () => queryCompletedWhenHandlerInvoked = query!.Completed); + query = subscription.CreateSubscriptionQuery(null!)!; + + // act + subscription.Handle("type", "topic", null!, DateTime.UtcNow, "original", "data"); + + // assert + Assert.That(queryCompletedWhenHandlerInvoked, Is.True); + } + + [TestCase(null)] + [TestCase("topic")] + public void Handle_Should_CompleteSubscriptionQuery_ForUnfilteredRoute(string? topicFilter) + { + // arrange + var subscription = new TopicSubscription(topic: null); + var query = subscription.CreateSubscriptionQuery(null!)!; + + // act + var handled = subscription.Handle("type", topicFilter, null!, DateTime.UtcNow, "original", "data"); + + // assert + Assert.That(handled, Is.True); + Assert.That(query.Completed, Is.True); + Assert.That(query.Success, Is.True); + } + + [Test] + public void Handle_Should_TreatEmptyTopicFilterAsUnfilteredRoute() + { + // arrange + var subscription = new TopicSubscription(string.Empty); + var query = subscription.CreateSubscriptionQuery(null!)!; + + // act + var handled = subscription.Handle("type", "topic", null!, DateTime.UtcNow, "original", "data"); + + // assert + Assert.That(handled, Is.True); + Assert.That(query.Completed, Is.True); + Assert.That(query.Success, Is.True); + } + + [Test] + public void Handle_Should_CompleteSubscriptionQuery_ForAnyMatchingTopic() + { + // arrange + var subscription = new TopicSubscription(["topic-a", "topic-b"]); + var query = subscription.CreateSubscriptionQuery(null!)!; + + // act + var handled = subscription.Handle("type", "topic-b", null!, DateTime.UtcNow, "original", "data"); + + // assert + Assert.That(handled, Is.True); + Assert.That(query.Completed, Is.True); + Assert.That(query.Success, Is.True); + } + + private sealed class TopicSubscription : Subscription + { + public TopicSubscription(string? topic, Action? handler = null) + : base(NullLogger.Instance, false) + { + MessageRouter = MessageRouter.CreateForEvent("type", topic, (_, _, _, _) => + { + handler?.Invoke(); + return CallResult.Ok(); + }); + } + + public TopicSubscription(string[] topics) + : base(NullLogger.Instance, false) + { + MessageRouter = MessageRouter.CreateForEvent("type", topics, (_, _, _, _) => CallResult.Ok()); + } + + protected override Query? GetSubQuery(SocketConnection connection) + { + return new TestQuery(new TestSocketMessage { Id = 1, Data = "Sub" }, false) + { + TimeoutBehavior = TimeoutBehavior.Succeed + }; + } + + protected override Query? GetUnsubQuery(SocketConnection connection) => null; + } + } +} diff --git a/CryptoExchange.Net/Sockets/Default/Routing/MessageRouter.cs b/CryptoExchange.Net/Sockets/Default/Routing/MessageRouter.cs index 01e5ee73..10332242 100644 --- a/CryptoExchange.Net/Sockets/Default/Routing/MessageRouter.cs +++ b/CryptoExchange.Net/Sockets/Default/Routing/MessageRouter.cs @@ -25,6 +25,9 @@ namespace CryptoExchange.Net.Sockets.Default.Routing Routes = routes; } + private RouteCollection? GetRoutes(string typeIdentifier) + => (_routingTable ?? throw new NullReferenceException("Routing table not build before handling")).GetRoutes(typeIdentifier); + /// /// Build the route mapping /// @@ -46,13 +49,21 @@ namespace CryptoExchange.Net.Sockets.Default.Routing /// public bool Handle(string typeIdentifier, string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object data, out CallResult? result) { - var routeCollection = (_routingTable ?? throw new NullReferenceException("Routing table not build before handling")).GetRoutes(typeIdentifier); + var routeCollection = GetRoutes(typeIdentifier); if (routeCollection == null) throw new InvalidOperationException($"No routes for {typeIdentifier} message type"); return routeCollection.Handle(topicFilter, connection, receiveTime, originalData, data, out result); } + /// + /// Whether the router has a matching route for the specified message type and topic + /// + public bool CanHandle(string typeIdentifier, string? topicFilter) + { + return GetRoutes(typeIdentifier)?.CanHandle(topicFilter) == true; + } + /// /// Create a void handler /// diff --git a/CryptoExchange.Net/Sockets/Default/Routing/RouteCollection.cs b/CryptoExchange.Net/Sockets/Default/Routing/RouteCollection.cs index 48df1979..90a7f3f4 100644 --- a/CryptoExchange.Net/Sockets/Default/Routing/RouteCollection.cs +++ b/CryptoExchange.Net/Sockets/Default/Routing/RouteCollection.cs @@ -60,6 +60,15 @@ namespace CryptoExchange.Net.Sockets.Default.Routing return matchingTopicRoutes; } + public bool CanHandle(string? topicFilter) + { + if (_routesWithoutTopicFilter.Count > 0) + return true; + + return topicFilter != null + && GetRoutesWithMatchingTopicFilter(topicFilter) != null; + } + public abstract bool Handle(string? topicFilter, SocketConnection connection, DateTime receiveTime, string? originalData, object data, out CallResult? result); } } diff --git a/CryptoExchange.Net/Sockets/Default/Subscription.cs b/CryptoExchange.Net/Sockets/Default/Subscription.cs index 7c025ea3..0ed13739 100644 --- a/CryptoExchange.Net/Sockets/Default/Subscription.cs +++ b/CryptoExchange.Net/Sockets/Default/Subscription.cs @@ -219,9 +219,13 @@ namespace CryptoExchange.Net.Sockets.Default ConnectionInvocations++; TotalInvocations++; - if (SubscriptionQuery != null && !SubscriptionQuery.Completed && SubscriptionQuery.TimeoutBehavior == TimeoutBehavior.Succeed) + if (SubscriptionQuery != null + && !SubscriptionQuery.Completed + && SubscriptionQuery.TimeoutBehavior == TimeoutBehavior.Succeed + && MessageRouter.CanHandle(typeIdentifier, topicFilter)) { // The subscription query is one where it is successful if there is no error returned + // The connection routes by type, so only a matching subscription route proves the query was successful // Since we've received a data update for the subscription we can assume the subscribe query was successful // Call timeout to complete SubscriptionQuery.Timeout(); From 4a4930d6587965e626831c19dfebfbc202057da0 Mon Sep 17 00:00:00 2001 From: Jkorf Date: Tue, 1 Sep 2026 09:03:50 +0200 Subject: [PATCH 2/2] Updated to version 12.5.1 --- CryptoExchange.Net/CryptoExchange.Net.csproj | 6 +++--- README.md | 6 ++++++ llms.txt | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CryptoExchange.Net/CryptoExchange.Net.csproj b/CryptoExchange.Net/CryptoExchange.Net.csproj index 74ec33f4..4f985559 100644 --- a/CryptoExchange.Net/CryptoExchange.Net.csproj +++ b/CryptoExchange.Net/CryptoExchange.Net.csproj @@ -6,9 +6,9 @@ CryptoExchange.Net JKorf CryptoExchange.Net is a base library which is used to implement different cryptocurrency (exchange) API's. It provides a standardized way of implementing different API's, which results in a very similar experience for users of the API implementations. - 12.5.0 - 12.5.0 - 12.5.0 + 12.5.1 + 12.5.1 + 12.5.1 false OKX;OKX.Net;Mexc;Mexc.Net;Kucoin;Kucoin.Net;Kraken;Kraken.Net;Huobi;Huobi.Net;CoinEx;CoinEx.Net;Bybit;Bybit.Net;Bitget;Bitget.Net;Bitfinex;Bitfinex.Net;Binance;Binance.Net;CryptoCurrency;CryptoCurrency Exchange;CryptoExchange.Net git diff --git a/README.md b/README.md index 9a1d84c3..a968e7ae 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,12 @@ Various: * PlatformInfo now required support environment names in the constructor ## Release notes +* Version 12.5.1 - 01 Sep 2026 + * Fixed KlineTracker reporting incorrect High/Low price on GetStats result + * Fixed caching issue for auth requests + * Fixed failed ISymbolOrderBook start potentially leaving a pending background task + * Fixed subscription query completion potentially reporting success too early for topic-filtered routes + * Version 12.5.0 - 21 Aug 2026 * Shared APIs * Added ISpotOrderManagementSocketClient for placing/canceling Spot orders via Shared websocket implementation diff --git a/llms.txt b/llms.txt index ebdb6d4a..c7e3b2ce 100644 --- a/llms.txt +++ b/llms.txt @@ -2,7 +2,7 @@ > Base C#/.NET library for cryptocurrency exchange API client implementations. Provides a standardized abstraction (REST, WebSocket, authentication, rate limiting, error handling, order book management, shared cross-exchange interfaces) that 28+ exchange-specific libraries are built on top of. -CryptoExchange.Net itself is not used directly — install one of the exchange-specific libraries (Binance.Net, Bybit.Net, OKX.Net, Kraken.Net, Coinbase.Net, etc.) or `CryptoClients.Net` to access all exchanges via a single bundle. The base library is what makes the entire ecosystem feel consistent: same `HttpResult` REST result pattern, same `WebSocketResult` websocket subscription pattern, same DI registration, same shared interfaces across all exchanges. Current version: 12.5.0. Targets netstandard2.0, netstandard2.1, net8.0, net9.0, net10.0. Native AOT supported. +CryptoExchange.Net itself is not used directly — install one of the exchange-specific libraries (Binance.Net, Bybit.Net, OKX.Net, Kraken.Net, Coinbase.Net, etc.) or `CryptoClients.Net` to access all exchanges via a single bundle. The base library is what makes the entire ecosystem feel consistent: same `HttpResult` REST result pattern, same `WebSocketResult` websocket subscription pattern, same DI registration, same shared interfaces across all exchanges. Current version: 12.5.1. Targets netstandard2.0, netstandard2.1, net8.0, net9.0, net10.0. Native AOT supported. The standout feature for cross-exchange code is `CryptoExchange.Net.SharedApis` — a set of interfaces (`ISpotTickerRestClient`, `ISpotOrderRestClient`, `IBalanceRestClient`, etc.) implemented by every exchange library. Same call signature works against any exchange.