From 1109b77bb62ab9cf81204173d5e343364336e328 Mon Sep 17 00:00:00 2001 From: Faith Sodipe Date: Tue, 26 May 2026 22:22:15 +0100 Subject: [PATCH 1/4] added Improvement in documentation for parallel streaming invocations limit --- aspnetcore/signalr/configuration.md | 7 +- .../configuration/includes/configuration5.md | 5 +- .../configuration/includes/configuration6.md | 5 +- .../configuration/includes/configuration7.md | 5 +- aspnetcore/signalr/hubs.md | 119 ++++++++++++++++++ 5 files changed, 136 insertions(+), 5 deletions(-) diff --git a/aspnetcore/signalr/configuration.md b/aspnetcore/signalr/configuration.md index 90d1698cf755..9e9bf951839d 100644 --- a/aspnetcore/signalr/configuration.md +++ b/aspnetcore/signalr/configuration.md @@ -73,8 +73,11 @@ The following table describes options for configuring SignalR hubs: | `EnableDetailedErrors` | `false` | If `true`, detailed exception messages are returned to clients when an exception is thrown in a Hub method. The default is `false` because these exception messages can contain sensitive information. | | `StreamBufferCapacity` | `10` | The maximum number of items that can be buffered for client upload streams. If this limit is reached, the processing of invocations is blocked until the server processes stream items.| | `MaximumReceiveMessageSize` | 32 KB | Maximum size of a single incoming hub message. Increasing the value might increase the risk of [Denial of service (DoS) attacks](https://developer.mozilla.org/docs/Glossary/DOS_attack). | -| `MaximumParallelInvocationsPerClient` | 1 | The maximum number of hub methods that each client can call in parallel before queueing. | -| `DisableImplicitFromServicesParameters` | `false` | Hub method arguments are resolved from DI if possible. | +| `MaximumParallelInvocationsPerClient` | 1 | The maximum number of hub methods that each client can call in parallel before queueing. This limit does not apply to streaming hub invocations. | +| `DisableImplicitFromServicesParameters` | `false` | Hub method arguments will be resolved from DI if possible. | + +> [!NOTE] +> `MaximumParallelInvocationsPerClient` does not apply to streaming hub invocations. Streaming invocations are expected to be long-running and can run concurrently. Use [hub filters](xref:signalr/hub-filters) to enforce per-connection streaming concurrency limits. Options can be configured for all hubs by providing an options delegate to the `AddSignalR` call in `Program.cs`. diff --git a/aspnetcore/signalr/configuration/includes/configuration5.md b/aspnetcore/signalr/configuration/includes/configuration5.md index 25336f2b2a2e..ac68e45984ab 100644 --- a/aspnetcore/signalr/configuration/includes/configuration5.md +++ b/aspnetcore/signalr/configuration/includes/configuration5.md @@ -57,7 +57,10 @@ The following table describes options for configuring SignalR hubs: | `EnableDetailedErrors` | `false` | If `true`, detailed exception messages are returned to clients when an exception is thrown in a Hub method. The default is `false` because these exception messages can contain sensitive information. | | `StreamBufferCapacity` | `10` | The maximum number of items that can be buffered for client upload streams. If this limit is reached, the processing of invocations is blocked until the server processes stream items.| | `MaximumReceiveMessageSize` | 32 KB | Maximum size of a single incoming hub message. Increasing the value may increase the risk of [Denial of service (DoS) attacks](https://developer.mozilla.org/docs/Glossary/DOS_attack). | -| `MaximumParallelInvocationsPerClient` | 1 | The maximum number of hub methods that each client can call in parallel before queueing. | +| `MaximumParallelInvocationsPerClient` | 1 | The maximum number of hub methods that each client can call in parallel before queueing. This limit does not apply to streaming hub invocations. | + +> [!NOTE] +> `MaximumParallelInvocationsPerClient` does not apply to streaming hub invocations. Streaming invocations are expected to be long-running and can run concurrently. Use [hub filters](xref:signalr/hub-filters) to enforce per-connection streaming concurrency limits. Options can be configured for all hubs by providing an options delegate to the `AddSignalR` call in `Startup.ConfigureServices`. diff --git a/aspnetcore/signalr/configuration/includes/configuration6.md b/aspnetcore/signalr/configuration/includes/configuration6.md index b794b551d1f5..718312e079c0 100644 --- a/aspnetcore/signalr/configuration/includes/configuration6.md +++ b/aspnetcore/signalr/configuration/includes/configuration6.md @@ -57,7 +57,10 @@ The following table describes options for configuring SignalR hubs: | `EnableDetailedErrors` | `false` | If `true`, detailed exception messages are returned to clients when an exception is thrown in a Hub method. The default is `false` because these exception messages can contain sensitive information. | | `StreamBufferCapacity` | `10` | The maximum number of items that can be buffered for client upload streams. If this limit is reached, the processing of invocations is blocked until the server processes stream items.| | `MaximumReceiveMessageSize` | 32 KB | Maximum size of a single incoming hub message. Increasing the value may increase the risk of [Denial of service (DoS) attacks](https://developer.mozilla.org/docs/Glossary/DOS_attack). | -| `MaximumParallelInvocationsPerClient` | 1 | The maximum number of hub methods that each client can call in parallel before queueing. | +| `MaximumParallelInvocationsPerClient` | 1 | The maximum number of hub methods that each client can call in parallel before queueing. This limit does not apply to streaming hub invocations. | + +> [!NOTE] +> `MaximumParallelInvocationsPerClient` does not apply to streaming hub invocations. Streaming invocations are expected to be long-running and can run concurrently. Use [hub filters](xref:signalr/hub-filters) to enforce per-connection streaming concurrency limits. Options can be configured for all hubs by providing an options delegate to the `AddSignalR` call in `Program.cs`. diff --git a/aspnetcore/signalr/configuration/includes/configuration7.md b/aspnetcore/signalr/configuration/includes/configuration7.md index b76087484bf9..8876e19de605 100644 --- a/aspnetcore/signalr/configuration/includes/configuration7.md +++ b/aspnetcore/signalr/configuration/includes/configuration7.md @@ -57,9 +57,12 @@ The following table describes options for configuring SignalR hubs: | `EnableDetailedErrors` | `false` | If `true`, detailed exception messages are returned to clients when an exception is thrown in a Hub method. The default is `false` because these exception messages can contain sensitive information. | | `StreamBufferCapacity` | `10` | The maximum number of items that can be buffered for client upload streams. If this limit is reached, the processing of invocations is blocked until the server processes stream items.| | `MaximumReceiveMessageSize` | 32 KB | Maximum size of a single incoming hub message. Increasing the value may increase the risk of [Denial of service (DoS) attacks](https://developer.mozilla.org/docs/Glossary/DOS_attack). | -| `MaximumParallelInvocationsPerClient` | 1 | The maximum number of hub methods that each client can call in parallel before queueing. | +| `MaximumParallelInvocationsPerClient` | 1 | The maximum number of hub methods that each client can call in parallel before queueing. This limit does not apply to streaming hub invocations. | | `DisableImplicitFromServicesParameters` | `false` | Hub method arguments will be resolved from DI if possible. | +> [!NOTE] +> `MaximumParallelInvocationsPerClient` does not apply to streaming hub invocations. Streaming invocations are expected to be long-running and can run concurrently. Use [hub filters](xref:signalr/hub-filters) to enforce per-connection streaming concurrency limits. + Options can be configured for all hubs by providing an options delegate to the `AddSignalR` call in `Program.cs`. ```csharp diff --git a/aspnetcore/signalr/hubs.md b/aspnetcore/signalr/hubs.md index 2c9d9fc2691d..edeff3d256f0 100644 --- a/aspnetcore/signalr/hubs.md +++ b/aspnetcore/signalr/hubs.md @@ -251,6 +251,125 @@ public class ChatHub : Hub > [!NOTE] > This feature makes use of , which is optionally implemented by DI implementations. If the app's DI container doesn't support this feature, injecting services into hub methods isn't supported. +## Limit per-connection streaming invocations + +`HubOptions.MaximumParallelInvocationsPerClient` controls non-streaming hub invocations only. It does not apply to streaming hub invocations. Streaming invocations are expected to be long-running and can run concurrently. To enforce a per-connection limit for streaming invocations, use a hub filter to track active stream-returning hub methods. + +```csharp +using System.Collections.Concurrent; + +public class StreamConcurrencyFilter : IHubFilter +{ + private readonly ConcurrentDictionary _activeStreams = new(); + private readonly int _maxConcurrentStreams; + + public StreamConcurrencyFilter(int maxConcurrentStreams = 1) + { + _maxConcurrentStreams = maxConcurrentStreams; + } + + public async ValueTask InvokeMethodAsync( + HubInvocationContext invocationContext, + Func> next) + { + if (!IsStreamingInvocation(invocationContext.HubMethod.ReturnType)) + { + return await next(invocationContext); + } + + var connectionId = invocationContext.Context.ConnectionId; + if (connectionId is null) + { + return await next(invocationContext); + } + + var activeStreams = _activeStreams.AddOrUpdate( + connectionId, + 1, + (_, current) => current + 1); + + if (activeStreams > _maxConcurrentStreams) + { + Decrement(connectionId); + throw new HubException($"The connection is limited to {_maxConcurrentStreams} concurrent streaming invocations."); + } + + try + { + return await next(invocationContext); + } + finally + { + Decrement(connectionId); + } + } + + private static bool IsStreamingInvocation(Type returnType) + { + if (returnType is null) + { + return false; + } + + if (returnType.IsGenericType) + { + var genericDefinition = returnType.GetGenericTypeDefinition(); + if (genericDefinition == typeof(IAsyncEnumerable<>) || + genericDefinition == typeof(ChannelReader<>)) + { + return true; + } + + if (genericDefinition == typeof(Task<>)) + { + return IsStreamingInvocation(returnType.GetGenericArguments()[0]); + } + } + + return false; + } + + private void Decrement(string connectionId) + { + while (true) + { + if (!_activeStreams.TryGetValue(connectionId, out var current)) + { + return; + } + + if (current <= 1) + { + if (_activeStreams.TryRemove(connectionId, out _)) + { + return; + } + + continue; + } + + if (_activeStreams.TryUpdate(connectionId, current - 1, current)) + { + return; + } + } + } +} +``` + +Register the filter as a singleton so the active-stream count is shared across hub invocations for the same connection: + +```csharp +builder.Services.AddSingleton(new StreamConcurrencyFilter(maxConcurrentStreams: 2)); +builder.Services.AddSignalR(options => +{ + options.AddFilter(); +}); +``` + +> [!NOTE] +> This filter applies only to methods that return `IAsyncEnumerable` or `ChannelReader` and does not change the behavior of non-streaming hub methods. + ### Keyed services support in Dependency Injection *Keyed services* refers to a mechanism for registering and retrieving Dependency Injection (DI) services using keys. A service is associated with a key by calling (or `AddKeyedScoped` or `AddKeyedTransient`) to register it. Access a registered service by specifying the key with the [`[FromKeyedServices]`](xref:Microsoft.Extensions.DependencyInjection.FromKeyedServicesAttribute) attribute. The following code shows how to use keyed services: From 89e978ea5e585f39f9649be49fdd4bb5c42758dd Mon Sep 17 00:00:00 2001 From: Faith Sodipe Date: Mon, 1 Jun 2026 09:17:10 +0100 Subject: [PATCH 2/4] updated docs section for keyed services --- aspnetcore/signalr/hubs.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/aspnetcore/signalr/hubs.md b/aspnetcore/signalr/hubs.md index e6d6833fcc52..5f8cbf54bd5c 100644 --- a/aspnetcore/signalr/hubs.md +++ b/aspnetcore/signalr/hubs.md @@ -258,6 +258,12 @@ public class ChatHub : Hub ### Keyed services support in dependency injection +The keyed services mechanism allows you to register and retrieve dependency injection services by using keys. A service is associated with a key by calling the method to register it. As an alternative, you can call the `AddKeyedScoped` or `AddKeyedTransient` method. + +You access a registered service by specifying the key with the [FromKeyedServices] attribute. The following code shows how to use keyed services: + +:::code language="csharp" source="~/../AspNetCore.Docs.Samples/signalr/hubs/KeyedSvsHub/Program.cs" highlight="5-6,34,39"::: + ## Limit per-connection streaming invocations `HubOptions.MaximumParallelInvocationsPerClient` controls non-streaming hub invocations only. It does not apply to streaming hub invocations. Streaming invocations are expected to be long-running and can run concurrently. To enforce a per-connection limit for streaming invocations, use a hub filter to track active stream-returning hub methods. From 41b68c668cb712c954e2994abe9fcf3ee5fa6173 Mon Sep 17 00:00:00 2001 From: Faith Sodipe Date: Mon, 1 Jun 2026 10:19:23 +0100 Subject: [PATCH 3/4] fixed broken reference --- aspnetcore/signalr/hubs.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aspnetcore/signalr/hubs.md b/aspnetcore/signalr/hubs.md index 5f8cbf54bd5c..7b4f805cd744 100644 --- a/aspnetcore/signalr/hubs.md +++ b/aspnetcore/signalr/hubs.md @@ -10,7 +10,7 @@ uid: signalr/hubs # customer intent: As an ASP.NET developer, I want to use hubs in ASP.NET Core SignalR, so I can enable real-time communication between connected clients and the server, and indirect client-to-client communication. --- -# Use hubs in SignalR for ASP.NET Core + Use hubs in SignalR for ASP.NET Core :::moniker range=">= aspnetcore-8.0" @@ -258,7 +258,7 @@ public class ChatHub : Hub ### Keyed services support in dependency injection -The keyed services mechanism allows you to register and retrieve dependency injection services by using keys. A service is associated with a key by calling the method to register it. As an alternative, you can call the `AddKeyedScoped` or `AddKeyedTransient` method. +The keyed services mechanism allows you to register and retrieve dependency injection services by using keys. A service is associated with a key by calling the xref:Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddKeyedSingleton%2A method to register it. As an alternative, you can call the `AddKeyedScoped` or `AddKeyedTransient` method. You access a registered service by specifying the key with the [FromKeyedServices] attribute. The following code shows how to use keyed services: From 7908545b073545c1056c692c27760e3a6f86f772 Mon Sep 17 00:00:00 2001 From: Faith Sodipe Date: Thu, 4 Jun 2026 20:38:37 +0100 Subject: [PATCH 4/4] updated code sample for streaming invocation --- aspnetcore/signalr/configuration.md | 2 +- .../configuration/includes/configuration7.md | 5 +- aspnetcore/signalr/hubs.md | 139 +++++++----------- 3 files changed, 55 insertions(+), 91 deletions(-) diff --git a/aspnetcore/signalr/configuration.md b/aspnetcore/signalr/configuration.md index e622cc8d2ac5..72358898293b 100644 --- a/aspnetcore/signalr/configuration.md +++ b/aspnetcore/signalr/configuration.md @@ -77,7 +77,7 @@ The following table describes options for configuring SignalR hubs: | `EnableDetailedErrors` | `false` | When this option is enabled (`true`), detailed exception messages are returned to clients when an exception is thrown in a hub method. The default is `false` because these exception messages can contain sensitive information. | | `StreamBufferCapacity` | 10 | The maximum number of items that can be buffered for client upload streams. When this limit is reached, the processing of invocations is blocked until the server processes stream items. | | `MaximumReceiveMessageSize` | 32 KB | The maximum size of a single incoming hub message. Increasing the value might increase the risk of [Denial of service (DoS) attacks](https://developer.mozilla.org/docs/Glossary/Denial_of_Service). | -| `MaximumParallelInvocationsPerClient` | 1 | The maximum number of hub methods that each client can call in parallel before queueing. | +| `MaximumParallelInvocationsPerClient` | 1 | The maximum number of hub methods that each client can call in parallel before queueing. Note that this limit doesn't apply to streaming hub invocations. For more information, see .| | `DisableImplicitFromServicesParameters` | `false` | Hub method arguments are resolved from dependency injection, if possible. | Options can be configured for all hubs by providing an options delegate to the `AddSignalR` call in the _Program.cs_ file. diff --git a/aspnetcore/signalr/configuration/includes/configuration7.md b/aspnetcore/signalr/configuration/includes/configuration7.md index 8876e19de605..03ec7c91616a 100644 --- a/aspnetcore/signalr/configuration/includes/configuration7.md +++ b/aspnetcore/signalr/configuration/includes/configuration7.md @@ -57,12 +57,9 @@ The following table describes options for configuring SignalR hubs: | `EnableDetailedErrors` | `false` | If `true`, detailed exception messages are returned to clients when an exception is thrown in a Hub method. The default is `false` because these exception messages can contain sensitive information. | | `StreamBufferCapacity` | `10` | The maximum number of items that can be buffered for client upload streams. If this limit is reached, the processing of invocations is blocked until the server processes stream items.| | `MaximumReceiveMessageSize` | 32 KB | Maximum size of a single incoming hub message. Increasing the value may increase the risk of [Denial of service (DoS) attacks](https://developer.mozilla.org/docs/Glossary/DOS_attack). | -| `MaximumParallelInvocationsPerClient` | 1 | The maximum number of hub methods that each client can call in parallel before queueing. This limit does not apply to streaming hub invocations. | +| `MaximumParallelInvocationsPerClient` | 1 | The maximum number of hub methods that each client can call in parallel before queueing. This limit does not apply to streaming hub invocations. Note that this limit doesn't apply to streaming hub invocations.| | `DisableImplicitFromServicesParameters` | `false` | Hub method arguments will be resolved from DI if possible. | -> [!NOTE] -> `MaximumParallelInvocationsPerClient` does not apply to streaming hub invocations. Streaming invocations are expected to be long-running and can run concurrently. Use [hub filters](xref:signalr/hub-filters) to enforce per-connection streaming concurrency limits. - Options can be configured for all hubs by providing an options delegate to the `AddSignalR` call in `Program.cs`. ```csharp diff --git a/aspnetcore/signalr/hubs.md b/aspnetcore/signalr/hubs.md index 7b4f805cd744..ecf5566ccec0 100644 --- a/aspnetcore/signalr/hubs.md +++ b/aspnetcore/signalr/hubs.md @@ -10,7 +10,7 @@ uid: signalr/hubs # customer intent: As an ASP.NET developer, I want to use hubs in ASP.NET Core SignalR, so I can enable real-time communication between connected clients and the server, and indirect client-to-client communication. --- - Use hubs in SignalR for ASP.NET Core +# Use hubs in ASP.NET Core SignalR :::moniker range=">= aspnetcore-8.0" @@ -266,122 +266,89 @@ You access a registered service by specifying the key with the [FromKeyedService ## Limit per-connection streaming invocations -`HubOptions.MaximumParallelInvocationsPerClient` controls non-streaming hub invocations only. It does not apply to streaming hub invocations. Streaming invocations are expected to be long-running and can run concurrently. To enforce a per-connection limit for streaming invocations, use a hub filter to track active stream-returning hub methods. +` controls the number of non-streaming hub method invocations a client can run in parallel before they are queued. It does **not** apply to streaming hub invocations. Streaming invocations are intentionally excluded because they are expected to be long-running and concurrent, so a client can start any number of concurrent streams regardless of that setting. + +To enforce a per-connection limit on streaming invocations, wrap the stream inside the hub method itself using a private helper that increments a counter before yielding items and decrements it in a `finally` block: ```csharp using System.Collections.Concurrent; +using System.Runtime.CompilerServices; -public class StreamConcurrencyFilter : IHubFilter +public class StreamingHub : Hub { - private readonly ConcurrentDictionary _activeStreams = new(); - private readonly int _maxConcurrentStreams; - - public StreamConcurrencyFilter(int maxConcurrentStreams = 1) + // Track the number of active streams per connection. + private static readonly ConcurrentDictionary _activeStreams = new(); + private const int MaxConcurrentStreams = 2; + + public IAsyncEnumerable Counter( + int count, + int delay, + CancellationToken cancellationToken) { - _maxConcurrentStreams = maxConcurrentStreams; + return WithLimit(GetCounter(count, delay, cancellationToken)); } - public async ValueTask InvokeMethodAsync( - HubInvocationContext invocationContext, - Func> next) + private async IAsyncEnumerable GetCounter( + int count, + int delay, + [EnumeratorCancellation] CancellationToken cancellationToken) { - if (!IsStreamingInvocation(invocationContext.HubMethod.ReturnType)) + for (var i = 0; i < count; i++) { - return await next(invocationContext); + cancellationToken.ThrowIfCancellationRequested(); + yield return i; + await Task.Delay(delay, cancellationToken); } + } - var connectionId = invocationContext.Context.ConnectionId; - if (connectionId is null) - { - return await next(invocationContext); - } + private async IAsyncEnumerable WithLimit( + IAsyncEnumerable stream, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var connectionId = Context.ConnectionId; - var activeStreams = _activeStreams.AddOrUpdate( + var current = _activeStreams.AddOrUpdate( connectionId, - 1, - (_, current) => current + 1); + addValue: 1, + updateValueFactory: (_, count) => count + 1); - if (activeStreams > _maxConcurrentStreams) + if (current > MaxConcurrentStreams) { - Decrement(connectionId); - throw new HubException($"The connection is limited to {_maxConcurrentStreams} concurrent streaming invocations."); - } + _activeStreams.AddOrUpdate( + connectionId, + addValue: 0, + updateValueFactory: (_, count) => Math.Max(0, count - 1)); - try - { - return await next(invocationContext); - } - finally - { - Decrement(connectionId); - } - } - - private static bool IsStreamingInvocation(Type returnType) - { - if (returnType is null) - { - return false; + throw new HubException( + $"The connection is limited to {MaxConcurrentStreams} concurrent streaming invocations."); } - if (returnType.IsGenericType) + try { - var genericDefinition = returnType.GetGenericTypeDefinition(); - if (genericDefinition == typeof(IAsyncEnumerable<>) || - genericDefinition == typeof(ChannelReader<>)) - { - return true; - } - - if (genericDefinition == typeof(Task<>)) + await foreach (var item in stream.WithCancellation(cancellationToken)) { - return IsStreamingInvocation(returnType.GetGenericArguments()[0]); + yield return item; } } - - return false; - } - - private void Decrement(string connectionId) - { - while (true) + finally { - if (!_activeStreams.TryGetValue(connectionId, out var current)) - { - return; - } - - if (current <= 1) - { - if (_activeStreams.TryRemove(connectionId, out _)) - { - return; - } - - continue; - } - - if (_activeStreams.TryUpdate(connectionId, current - 1, current)) - { - return; - } + _activeStreams.AddOrUpdate( + connectionId, + addValue: 0, + updateValueFactory: (_, count) => Math.Max(0, count - 1)); } } } ``` -Register the filter as a singleton so the active-stream count is shared across hub invocations for the same connection: - -```csharp -builder.Services.AddSingleton(new StreamConcurrencyFilter(maxConcurrentStreams: 2)); -builder.Services.AddSignalR(options => -{ - options.AddFilter(); -}); -``` +The key point is that `WithLimit` wraps the original `IAsyncEnumerable` and holds the counter elevated for the *full lifetime of the stream*, not just until the first item is yielded. +The `finally` block runs only when the client finishes consuming the stream, cancels it, or the connection drops. > [!NOTE] -> This filter applies only to methods that return `IAsyncEnumerable` or `ChannelReader` and does not change the behavior of non-streaming hub methods. +> The `_activeStreams` dictionary is `static` so it is shared across all hub instances for +a given connection. If you prefer DI-managed state, register a singleton service that owns +> the dictionary and inject it into the hub constructor. + ### Keyed services support in Dependency Injection