ASP.NET MVC 5 CancellationToken fix

Hey everyone!

I got a neat little fix for you.

Problem: CancellationToken not being cancelled upon request abort.

We stumbled upon this small bug whilst implementing a straight foward, basic MVC 5 application.
My co-worker pointed out that the following code, although valid and correct was not working as expected in MVC 5:


[Route("tools/test")]
[HttpGet]
public async Task Test(CancellationToken token)
{
	var now = DateTime.Now;

	while (DateTime.Now < now.AddMinutes(1))
	{
		if (token.IsCancellationRequested)
		{
			return Json(false);
		}

		await BotherGoogle(token).ConfigureAwait(false);
	}

	return Json(true);
}

private async Task BotherGoogle( CancellationToken token)
{
	using (var httpClient = new HttpClient())
	{
		await httpClient.GetAsync("http://www.google.be", token).ConfigureAwait(false);
	}
}

I would call this action from JavaScript and abort the XHR request on the client after 5 seconds:


function startTest() {
	var xhr = $.get('@Url.Action("Test")', function(data) {
		toastr.info(data);
	});
	toastr.info('Test started');

	setTimeout(function() {
		xhr.abort();
		toastr.info('Test aborted');
	}, 5000);
}

As soon as the request had been aborted on the client, it would take around +- 40 seconds before the cancellation token on the server was marked as cancelled.

A solution

A solution, pointed out by Muhammad Rehan Saeed on David Paquette's blog here involved linking 2 cancellation tokens together so if one cancellation token gets cancelled, so would the linked ones:


[Route("tools/test")]
[HttpGet]
public async Task Test(CancellationToken token)
{
	var now = DateTime.Now;

	var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(token, Response.ClientDisconnectedToken).Token;

	while (DateTime.Now < now.AddMinutes(1))
	{
		if (linkedToken.IsCancellationRequested)
		{
			return Json(false);
		}

		await BotherGoogle(linkedToken).ConfigureAwait(false);
	}

	return Json(true);
}

With this solution in place, as soon as I cancelled the XHR request on the client, the server would pick up upon it immediatly and return the Json(false);

However, I didn't feel like having to modify all my async actions with this fix so I started looking into how MVC exactly gets the CancellationToken in the parameter to begin with.

Enter CancellationTokenModelBinder

After going through the ASP.NET MVC 5 sources i found that the model binder which makes the CancellationToken parameter work is nothing more than this:


// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. See License.txt in the project root for license information.

using System.Threading;

namespace System.Web.Mvc
{
    public class CancellationTokenModelBinder : IModelBinder
    {
        public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
        {
            return default(CancellationToken);
        }
    }
}

I had hoped on something more impressive but that it would be this simplistic I wouldn't have thought -.-'
Okay, only made my idea easier to implement.

Step 1: Throw away the default CancellationTokenModelBinder

In Global.asax


protected void Application_Start(object sender, EventArgs e)
{
	// ... application startup code

	ModelBinders.Binders.Remove(typeof(CancellationToken));
}

Step 2: Create a new CancellationTokenModelBinder


public class FixedCancellationTokenModelBinder : IModelBinder
{
	public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
	{
		var source = CancellationTokenSource.CreateLinkedTokenSource(default(CancellationToken),
				controllerContext.HttpContext.Response.ClientDisconnectedToken);

		return source.Token;
	}
}

Step 3: Register the new CancellationTokenModelBinder with your application

In Global.asax


protected void Application_Start(object sender, EventArgs e)
{
	// ... application startup code

	ModelBinders.Binders.Remove(typeof(CancellationToken));
	ModelBinders.Binders.Add(typeof(CancellationToken), new FixedCancellationTokenModelBinder());
}

We can now remove the patch code from the action and it will keep working 🙂

8 thoughts on “ASP.NET MVC 5 CancellationToken fix”

  1. Thx for guide.
    But with register FixedCancellationTokenModelBinder, [AsyncTimeout(xxx)] attribute not working.

  2. I am trying your solution and have found that it works great, but I see people online saying that CancellationTokenSource must be disposed. Some have had trouble with memory leaks when it is not disposed properly.

    Have you experienced any issues with memory leaks?
    Do you have any ideas for how to dispose of these linked token sources?

  3. Why do you have to create the LinkedTokenSource? Can’t you simply return the ClientDisconnectToken itself?

    1. Uhm, seeing as this is a 6 year old post and the last time I’ve dabbled with MVC is as equal long ago, my answer might not be 100% correct 😀

      But, I went over this post again and I think, in theory, you could return the ClientDisconnectToken. But that means you’d also have pass along that token to your other async methods being called in your async controller action instead of the CancellationToken from the parameters.

      The way I understand this, is that the CancellationToken from the parameters of the async controller action is not the token that gets cancelled upon request abortion.

      Hence the LinkedCancellationTokenSource…

      If the request is aborted by the client, the ClientDisconnectToken gets cancelled and the LinkedCancellationTokenSource will make sure that the CancellationToken from the parameters gets cancelled too 🙂

    2. And after re-reading my answer, returning the ClientDisconnectToken in the modelbinder will supply it as the token in the controller’s action parameter -.-‘

      So yea, looks like returning the ClientDisconnectToken in the modelbinder would be sufficient.

  4. Thanks for the swift reply, even for a 6 year old post 🙂

    Exactly what I was thinking… I’m implementing it like this on a project I’m working on… won’t push today though… that will be for monday 😉

    Have a good weekend!

Leave a Reply to Dirk Cancel reply

Your email address will not be published. Required fields are marked *

 

This site uses Akismet to reduce spam. Learn how your comment data is processed.