Skip to content

Add support for cancelling regular hub invocations from client - #64098

Merged
BrennanConroy merged 11 commits into
mainfrom
copilot/add-cancellation-to-hub-methods
Jun 23, 2026
Merged

Add support for cancelling regular hub invocations from client#64098
BrennanConroy merged 11 commits into
mainfrom
copilot/add-cancellation-to-hub-methods

Conversation

Copilot AI commented Oct 17, 2025

Copy link
Copy Markdown
Contributor

Add support for cancelling regular hub invocations from client

  • You've read the Contributor Guide and Code of Conduct.
  • You've included unit or integration tests for your change, where applicable.
  • You've included inline docs for your change, where applicable.
  • There's an open issue for the PR that you are making. If you'd like to propose a new feature or change, please open an issue to discuss the change or find an existing issue.

Description

This PR implements cancellation support for regular (non-streaming) hub method invocations from the client. Previously, only streaming invocations could be cancelled by the client. Now, when a client passes a CancellationToken to InvokeAsync and cancels it, a CancelInvocationMessage is sent to the server which cancels the hub method execution via a registered CancellationTokenSource.

Client-side (HubConnection.cs)

  • Added cancellation support to InvokeCoreAsyncCore: registers a callback to send CancelInvocationMessage when the cancellation token is triggered
  • Extracted shared cancellation logic into a CancelInvocationAsync private method, used by both regular invocations and streaming

Server-side (HubMethodDescriptor.cs)

  • Removed the restriction that limited CancellationToken synthetic arguments to streaming methods only — all hub methods can now accept CancellationToken

Server-side (DefaultHubDispatcher.cs)

  • Updated ExecuteInvocation to register the CancellationTokenSource in ActiveRequestCancellationSources so that CancelInvocationMessage can cancel regular invocations
  • Properly unregisters the CancellationTokenSource after the invocation completes

Logging (DefaultHubDispatcherLog.cs)

  • Added new log methods CancelInvocation (ID 29) and UnexpectedCancelWithId (ID 30) at the bottom of the file with generic messages covering both invocations and streams
  • Kept existing CancelStream (ID 10) and UnexpectedCancel (ID 11) methods for backward compatibility

Server-side (HubConnectionContext.cs)

  • Updated comment on ActiveRequestCancellationSources from "Currently used only for streaming methods" to "Used to cancel hub invocations and streaming methods"

Tests

  • Added client-side test CanCancelTokenDuringInvoke_SendsCancelInvocation to verify CancelInvocationMessage is sent for regular invocations
  • Added CancelableInvocation hub method to LongRunningHub for testing
  • Added server-side test InvokeHubMethodCanAcceptCancellationTokenAsArgument to verify CancellationToken works for regular hub method invocations
Original prompt

This section details on the original issue you should resolve

<issue_title>[SignalR] Possibility to cancel long running hub method from client</issue_title>
<issue_description>I´m not quit sure if this post is more of a question or a feature request.

I noticed that the InvokeAsync<TResult>(HubConnection, String, CancellationToken) method accepts a CancellationToken. As far as I understood this token only cancels the invocation (and waiting for a result, ...) on the client, while on the server side the method proceeds.
Is this correct?

If so wouldn´t it be a nice feature to allow such a cancellation? I saw that Google offers this possibility in their gRPC framework aswell:

Cancelling RPCs
Either the client or the server can cancel an RPC at any time. A cancellation terminates the RPC immediately so that no further work is done. It is not an “undo”: changes made before the cancellation will not be rolled back.

When my understanding is wrong please provide me an example on how to do a cancellation of long running hub methods from the client.</issue_description>

<agent_instructions>See comment in #11542 (comment)</agent_instructions>

Comments on the Issue (you are @copilot in this section)

@davidfowl Today we only support cancellation for streams but there's nothing wrong with supporting this for other invocations. @BrennanConroy @anurse I don't think we do support that, but we structured the code in such a way that we could add support for it in the future. @ We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our [Triage Process](https://github.com/dotnet/aspnetcore/blob/master/docs/TriageProcess.md). @davidfowl @muzopraha What's the scenario you have with a long running hub method? @davidfowl @thorgeirk11 No updates, best to add your scenario so we have a record of what people want to use it for before we invest the time. @BrennanConroy File a new issue and show your code. @BrennanConroy CancellationToken in the hub method signature is only supported for Server to client streaming currently.

This issue is tracking adding it for other cases.</comment_new>
<comment_new>@BrennanConroy
Since this feature isn't implemented yet that method signature won't work. It's basically trying to receive a serialized CancellationToken from the client, which isn't going to work.

The cancellation token in InvokeAsync will cancel the client waiting for a server response, but it doesn't pass that info along to the server yet. That's what this issue is tracking adding support for.

public async Task UpdateChannelAsync(TPMChannels channel, CancellationToken cts = default) ->
public async Task UpdateChannelAsync(TPMChannels channel)</comment_new>
<comment_new>@davidfowl
I'll defer to @BrennanConroy here.</comment_new>
<comment_new>@BrennanConroy
Basically we would need to update the client(s) to send a CancelInvocationMessage when the cancellation token passed in to InvokeAsync is canceled.
We currently only send that message type when the client cancels a stream

await SendHubMessage(_state.CurrentConnectionStateUnsynchronized, new CancelInvocationMessage(irq.InvocationId), cancellationToken: default).ConfigureAwait(false);

The server is mostly setup to handle CancelInvocationMessage

case CancelInvocationMessage cancelInvocationMessage:
// Check if there is an associated active stream and cancel it if it exists.
// The cts will be removed when the streaming method completes executing
if (connection.ActiveRequestCancellationSources.TryGetValue(cancelInvocationMessage.InvocationId!, out var cts))
{
Log.CancelStream(_logger, cancelInvocationMessage.InvocationId!);
cts.Cancel();

The logs would likely need to be updated since it assumes streams are the only thing that can be canceled.

We'd also need to update the logic around which hub methods can have a synthetic argument (CancellationToken)

if (IsStreamResponse && p.ParameterType == typeof(CancellationToken))
{
HasSyntheticArguments = true;


A massive stretch goal would be to do the same thing for client results, which would require adding synthetic argument support on the client side and sending CancelInvocationMessage from the server. That work is tracked by #44831 and shouldn't restrict this issue from being worke...

Fixes #11542


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

@dotnet-policy-service

This comment was marked as outdated.

Copilot AI and others added 3 commits October 17, 2025 19:55
Co-authored-by: BrennanConroy <7574801+BrennanConroy@users.noreply.github.com>
Co-authored-by: BrennanConroy <7574801+BrennanConroy@users.noreply.github.com>
…fying comment

Co-authored-by: BrennanConroy <7574801+BrennanConroy@users.noreply.github.com>
Copilot AI changed the title [WIP] Add cancellation capability to long running hub methods Add support for cancelling regular hub invocations from client Oct 17, 2025
Copilot AI requested a review from BrennanConroy October 17, 2025 20:11

@BrennanConroy BrennanConroy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Update the comment on HubConnectionContext.ActiveRequestCancellationSources as well

Comment thread src/SignalR/clients/csharp/Client.Core/src/HubConnection.cs Outdated
Comment thread src/SignalR/server/Core/src/Internal/DefaultHubDispatcherLog.cs Outdated
Comment thread src/SignalR/server/Core/src/Internal/DefaultHubDispatcherLog.cs Outdated
…comment

Co-authored-by: BrennanConroy <7574801+BrennanConroy@users.noreply.github.com>

Copilot AI commented Oct 20, 2025

Copy link
Copy Markdown
Contributor Author

@copilot Update the comment on HubConnectionContext.ActiveRequestCancellationSources as well

Done in commit 0ad2858. Updated the comment from "Currently used only for streaming methods" to "Used to cancel hub invocations and streaming methods".

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • fakeurl.org
    • Triggering command: /home/REDACTED/work/aspnetcore/aspnetcore/.dotnet/dotnet exec --runtimeconfig /home/REDACTED/work/aspnetcore/aspnetcore/artifacts/bin/Microsoft.AspNetCore.SignalR.Client.Tests/Debug/net10.0/Microsoft.AspNetCore.SignalR.Client.Tests.runtimeconfig.json --depsfile /home/REDACTED/work/aspnetcore/aspnetcore/artifacts/bin/Microsoft.AspNetCore.SignalR.Client.Tests/Debug/net10.0/Microsoft.AspNetCore.SignalR.Client.Tests.deps.json /home/REDACTED/work/aspnetcore/aspnetcore/artifacts/bin/Microsoft.AspNetCore.SignalR.Client.Tests/Debug/net10.0/testhost.dll --port 34169 --endpoint 127.0.0.1:034169 --role client --parentprocessid 8506 --telemetryoptedin false (dns block)
    • Triggering command: /home/REDACTED/work/aspnetcore/aspnetcore/.dotnet/dotnet exec --runtimeconfig /home/REDACTED/work/aspnetcore/aspnetcore/artifacts/bin/Microsoft.AspNetCore.SignalR.Client.Tests/Debug/net10.0/Microsoft.AspNetCore.SignalR.Client.Tests.runtimeconfig.json --depsfile /home/REDACTED/work/aspnetcore/aspnetcore/artifacts/bin/Microsoft.AspNetCore.SignalR.Client.Tests/Debug/net10.0/Microsoft.AspNetCore.SignalR.Client.Tests.deps.json /home/REDACTED/work/aspnetcore/aspnetcore/artifacts/bin/Microsoft.AspNetCore.SignalR.Client.Tests/Debug/net10.0/testhost.dll --port 38457 --endpoint 127.0.0.1:038457 --role client --parentprocessid 10541 --telemetryoptedin false (dns block)
    • Triggering command: /home/REDACTED/work/aspnetcore/aspnetcore/.dotnet/dotnet exec --runtimeconfig /home/REDACTED/work/aspnetcore/aspnetcore/artifacts/bin/Microsoft.AspNetCore.SignalR.Client.Tests/Debug/net10.0/Microsoft.AspNetCore.SignalR.Client.Tests.runtimeconfig.json --depsfile /home/REDACTED/work/aspnetcore/aspnetcore/artifacts/bin/Microsoft.AspNetCore.SignalR.Client.Tests/Debug/net10.0/Microsoft.AspNetCore.SignalR.Client.Tests.deps.json /home/REDACTED/work/aspnetcore/aspnetcore/artifacts/bin/Microsoft.AspNetCore.SignalR.Client.Tests/Debug/net10.0/testhost.dll --port 34751 --endpoint 127.0.0.1:034751 --role client --parentprocessid 12687 --telemetryoptedin false (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot AI requested a review from BrennanConroy October 20, 2025 18:59
@github-actions github-actions Bot added the area-signalr Includes: SignalR clients and servers label Feb 10, 2026
Comment thread src/SignalR/server/Core/src/Internal/DefaultHubDispatcherLog.cs Outdated
Co-authored-by: BrennanConroy <7574801+BrennanConroy@users.noreply.github.com>
Comment thread src/SignalR/server/Core/src/Internal/DefaultHubDispatcherLog.cs Outdated
Comment thread src/SignalR/server/Core/src/Internal/DefaultHubDispatcherLog.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds end-to-end support for canceling regular (non-streaming) SignalR hub invocations from the client by flowing cancellation via CancelInvocationMessage and enabling CancellationToken as a synthetic hub method argument beyond streaming scenarios.

Changes:

  • Client: send CancelInvocationMessage when an InvokeAsync cancellation token is canceled; refactor shared cancellation logic for invoke/stream.
  • Server: allow CancellationToken synthetic arguments for all hub methods and register per-invocation cancellation sources so CancelInvocationMessage can cancel regular invocations.
  • Tests/logging: add coverage for cancellation messaging and hub method CancellationToken acceptance; introduce new dispatcher log events/messages.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/SignalR/server/SignalR/test/Microsoft.AspNetCore.SignalR.Tests/HubConnectionHandlerTests.cs Updates server-side test to validate CancellationToken works for non-streaming invocations and can be triggered via CancelInvocationMessage.
src/SignalR/server/SignalR/test/Microsoft.AspNetCore.SignalR.Tests/HubConnectionHandlerTestUtils/Hubs.cs Adds a cancelable non-streaming hub method used by tests.
src/SignalR/server/Core/src/Internal/HubMethodDescriptor.cs Treats CancellationToken parameters as synthetic for all hub methods (not just streams).
src/SignalR/server/Core/src/Internal/DefaultHubDispatcherLog.cs Adds new log events/messages for canceling invocations/streams by ID.
src/SignalR/server/Core/src/Internal/DefaultHubDispatcher.cs Registers invocation CTS in ActiveRequestCancellationSources for non-streaming invocations; updates cancel message handling and logging.
src/SignalR/server/Core/src/HubConnectionContext.cs Updates comment describing ActiveRequestCancellationSources usage.
src/SignalR/clients/csharp/Client/test/UnitTests/HubConnectionTests.cs Adds a unit test asserting CancelInvocationMessage is sent when canceling an InvokeAsync token; adjusts upload-stream cancellation expectations.
src/SignalR/clients/csharp/Client.Core/src/HubConnection.cs Implements/refactors client cancellation message sending; adjusts streaming-upload helper to return stream tasks for ordering.
Comments suppressed due to low confidence (1)

src/SignalR/server/Core/src/Internal/HubMethodDescriptor.cs:68

  • Now that CancellationToken parameters are treated as synthetic for all hub methods, methods with multiple CancellationToken parameters will result in multiple CTS instances being created in ReplaceArguments, but only the last one is returned/registered for cancellation. This leads to leaks and inconsistent cancellation behavior. Either validate that a hub method can only have a single CancellationToken parameter, or create a single CTS and reuse its Token for all CancellationToken parameters.
        // Take out synthetic arguments that will be provided by the server, this list will be given to the protocol parsers
        ParameterTypes = methodExecutor.MethodParameters.Where((p, index) =>
        {
            // CancellationTokens are synthetic arguments provided by the server
            if (p.ParameterType == typeof(CancellationToken))
            {
                HasSyntheticArguments = true;
                return false;
            }

Comment on lines +1073 to +1080
if (cancellationToken.CanBeCanceled)
{
cancellationToken.Register(state =>
{
var (irqLocal, tasks) = ((InvocationRequest, Task[]?))state!;
_ = CancelInvocationAsync(irqLocal, tasks);
}, (irq, streamTasks));
}

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CancellationToken.Register callback is not being disposed. If the caller supplies a long-lived token (e.g., RequestAborted), this registration will keep the InvocationRequest (and transitively the HubConnection) alive after the invocation completes, and can also send a CancelInvocationMessage after completion if the token is canceled later. Capture the CancellationTokenRegistration and dispose it when the invocation finishes (or move this registration into InvocationRequest so it’s disposed as part of irq.Dispose).

Copilot uses AI. Check for mistakes.
Comment on lines +725 to +729
cancellationToken.Register(state =>
{
var (irqLocal, tasks) = ((InvocationRequest, Task[]?))state!;
_ = CancelInvocationAsync(irqLocal, tasks);
}, (irq, streamTasks));

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as InvokeCoreAsyncCore: this CancellationToken.Register is never disposed, which can retain the InvocationRequest and related state after the stream completes and can trigger CancelInvocationMessage after completion if the token is canceled later. Store the CancellationTokenRegistration and dispose it when the stream invocation is completed/cleaned up (ideally via InvocationRequest disposal).

Suggested change
cancellationToken.Register(state =>
{
var (irqLocal, tasks) = ((InvocationRequest, Task[]?))state!;
_ = CancelInvocationAsync(irqLocal, tasks);
}, (irq, streamTasks));
var cancellationTokenRegistration = cancellationToken.Register(state =>
{
var (irqLocal, tasks) = ((InvocationRequest, Task[]?))state!;
_ = CancelInvocationAsync(irqLocal, tasks);
}, (irq, streamTasks));
_ = channel.Completion.ContinueWith(
static (t, state) =>
{
var registration = (CancellationTokenRegistration)state!;
registration.Dispose();
},
cancellationTokenRegistration,
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);

Copilot uses AI. Check for mistakes.
Comment on lines +410 to +420
// Register the CancellationTokenSource if present so CancelInvocationMessage can cancel it
if (cts != null && !string.IsNullOrEmpty(hubMethodInvocationMessage.InvocationId))
{
if (!connection.ActiveRequestCancellationSources.TryAdd(hubMethodInvocationMessage.InvocationId!, cts))
{
Log.InvocationIdInUse(logger, hubMethodInvocationMessage.InvocationId);
await SendInvocationError(hubMethodInvocationMessage.InvocationId, connection,
$"Invocation ID '{hubMethodInvocationMessage.InvocationId}' is already in use.");
return;
}
}

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CancellationTokenSource instances created for synthetic CancellationToken parameters are now added to ActiveRequestCancellationSources but never disposed for non-streaming invocations. This will leak registrations/resources; also, if TryAdd fails due to duplicate InvocationId, the CTS should be disposed before returning. Dispose the CTS when the invocation finishes (and on the TryAdd failure path), not just remove it from the dictionary.

Copilot uses AI. Check for mistakes.
Comment on lines 183 to 195
case CancelInvocationMessage cancelInvocationMessage:
// Check if there is an associated active stream and cancel it if it exists.
// The cts will be removed when the streaming method completes executing
// Check if there is an associated active invocation or stream and cancel it if it exists.
// The cts will be removed when the hub method completes executing
if (connection.ActiveRequestCancellationSources.TryGetValue(cancelInvocationMessage.InvocationId!, out var cts))
{
Log.CancelStream(_logger, cancelInvocationMessage.InvocationId!);
Log.CancelInvocation(_logger, cancelInvocationMessage.InvocationId!);
cts.Cancel();
}
else
{
// Stream can be canceled on the server while client is canceling stream.
Log.UnexpectedCancel(_logger);
// Invocation can be canceled on the server while client is canceling invocation.
Log.UnexpectedCancelWithId(_logger, cancelInvocationMessage.InvocationId!);
}

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DispatchMessageAsync now logs CancelInvocation/UnexpectedCancelWithId (EventIds 29/30) for CancelInvocationMessage, which changes the existing EventIds used for stream cancellation (10/11). This is a potentially breaking operational change for log/event-id based monitoring, and it also makes the “backward compatibility” methods unused. Consider keeping EventIds 10/11 for the existing stream-cancel path (or reusing them with updated message) and only using new EventIds for the new invocation-cancel scenario.

Copilot uses AI. Check for mistakes.
Comment on lines +446 to +450
// Remove the CancellationTokenSource from active requests
if (cts != null && !string.IsNullOrEmpty(hubMethodInvocationMessage.InvocationId))
{
connection.ActiveRequestCancellationSources.TryRemove(hubMethodInvocationMessage.InvocationId!, out _);
}

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This TryRemove runs whenever cts != null, even if the earlier TryAdd failed due to an InvocationId collision. In that failure case, this can remove the CTS for the existing invocation/stream that already owns the ID. Track whether the CTS was successfully registered and only remove (and dispose) it when registration succeeded.

Copilot uses AI. Check for mistakes.
@balazsmeszegeto

Copy link
Copy Markdown
Contributor

I'm following this issue, can I contribute or is there any other way I can aid the progress?

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Looks like this PR hasn't been active for some time and the codebase could have been changed in the meantime.
To make sure no conflicting changes have occurred, please rerun validation before merging. You can do this by leaving an /azp run comment here (requires commit rights), or by simply closing and reopening.

@dotnet-policy-service dotnet-policy-service Bot added the pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun label Mar 9, 2026
@balazsmeszegeto

Copy link
Copy Markdown
Contributor

@BrennanConroy can I assist in any capacity to get this done? Please let me know?

BrennanConroy and others added 2 commits May 13, 2026 15:21
…TryRemove on collision

- Client: store the new cancellationToken.Register registration on InvocationRequest
  so it is disposed with the irq, preventing a leak/post-completion CancelInvocationMessage
  for long-lived tokens (e.g. RequestAborted).
- Server (DefaultHubDispatcher): track whether the synthetic-arg CTS was successfully
  added to ActiveRequestCancellationSources; only TryRemove and Dispose it when registration
  succeeded, so an InvocationId collision does not evict another invocation's CTS. Also
  dispose the CTS for non-streaming invocations. Apply the same guard to the streaming path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When a hub method declared with a CancellationToken parameter is invoked via
SendAsync (no InvocationId), the synthetic CTS is created and linked to
ConnectionAborted but is never added to ActiveRequestCancellationSources.
Move the cts?.Dispose() outside the ctsRegistered guard so it runs in that
case as well, preventing per-call registration leaks on long-lived connections.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…vocations

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@BrennanConroy

Copy link
Copy Markdown
Member

@BrennanConroy can I assist in any capacity to get this done? Please let me know?

Feel free to review the PR and give your thoughts.

Glad to see you're looking forward to this change. 😄

davidfowl
davidfowl previously approved these changes May 21, 2026

@davidfowl davidfowl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@davidfowl
davidfowl dismissed their stale review May 21, 2026 03:23

agent

@balazsmeszegeto

Copy link
Copy Markdown
Contributor

Looks good for me, too 👍

private async Task CancelInvocationAsync(InvocationRequest irq, Task[]? streamTasks = null)
{
// Wait for stream completions to be sent before sending CancelInvocationMessage
if (streamTasks is not null)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment here to explain that streamTasks is only client side uploading and cancellationToken is linked to uploadStreamToken at LaunchStreams?

@BrennanConroy

Copy link
Copy Markdown
Member

/ba-g unrelated quarantine failure

@BrennanConroy
BrennanConroy merged commit 26881cc into main Jun 23, 2026
24 of 26 checks passed
@BrennanConroy
BrennanConroy deleted the copilot/add-cancellation-to-hub-methods branch June 23, 2026 20:48
@BrennanConroy BrennanConroy added this to the 11.0-preview6 milestone Jun 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-signalr Includes: SignalR clients and servers pending-ci-rerun When assigned to a PR indicates that the CI checks should be rerun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[SignalR] Possibility to cancel long running hub method from client

6 participants