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 🙂

Leave a Reply

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