32
votes

Given the following code:

var cts = new CancellationTokenSource();

try 
{
    // get a "hot" task
    var task = new HttpClient().GetAsync("http://www.google.com", cts.Token);

    // request cancellation
    cts.Cancel();

    await task;

    // pass:
    Assert.Fail("expected TaskCanceledException to be thrown");
}
catch (TaskCanceledException ex) 
{
    // pass:
    Assert.IsTrue(cts.Token.IsCancellationRequested,
        "expected cancellation requested on original token");

    // fail:
    Assert.IsTrue(ex.CancellationToken.IsCancellationRequested,
        "expected cancellation requested on token attached to exception");
}

I would expect ex.CancellationToken.IsCancellationRequested to be true inside the catch block, but it is not. Am I misunderstanding something?

3
Is the ex.CancelationToken instance equal (ReferenceEqual) to cts? Documentation states: "If the token is associated with a canceled operation, the CancellationToken.IsCancellationRequested property of the token returns true".Alex
@Alex: CancellationToken is a struct, so ReferenceEquals() will always return false.Peter Duniho
@AndreasNiedermair: no. I mean that if you have CancellationToken token = cts.Token; and evaluate object.ReferenceEquals(token, token) (i.e. compare the CancellationToken value to itself), even that will return false, because value types have to be boxed before being passed as an object reference, and so the boxed objects will always be different, even if they were obtained from the same value.Peter Duniho
@usr: the code he posted works. I copy/pasted it into a blank console app (in its own method, since Main() can't be async) and it behaves exactly as reported.Peter Duniho
Note that this code has a race condition. The operation might be cancelled or it might just finish normally before it gets cancelled. You should cancel the token before starting the actual work (or ensure that the work can't possibly finish before the token is cancelled) to ensure that this doesn't happen.Servy

3 Answers

48
votes

That's the case because HttpClient internally (in SendAsync) is using a TaskCompletionSource to represent the async operation. It returns TaskCompletionSource.Task and that's the task you await on.

It then calls base.SendAsync and registers a continuation on the returned task that cancels/completes/faults the TaskCompletionSource's task accordingly.

In the case of cancellation it uses TaskCompletionSource.TrySetCanceled which associates the canceled task with a new CancellationToken (default(CancellationToken)).

You can see that by looking at the TaskCanceledException. On top of ex.CancellationToken.IsCancellationRequested being false ex.CancellationToken.CanBeCanceled is also false, meaning that this CancellationToken can never be canceled as it wasn't created using a CancellationTokenSource.


IMO it should be using TaskCompletionSource.TrySetCanceled(CancellationToken) instead. That way the TaskCompletionSource will be associated with the CancellationToken passed in by the consumer and not simply the default CancellationToken. I think it's a bug (though a minor one) and I submitted an issue on connect about it.

3
votes

@Bengie This didn't work for me. I had to alter it a little. IsCancellationRequested always returned true so i couldn't rely on that.

This worked for me:

using (CancellationTokenSource cancelAfterDelay = new CancellationTokenSource(TimeSpan.FromSeconds(timeout)))
{
    DateTime startedTime = DateTime.Now;

    try
    {
        response = await request.ExecuteAsync(cancelAfterDelay.Token);
    }
    catch (TaskCanceledException e)
    {
        DateTime cancelledTime = DateTime.Now;
        if (startedTime.AddSeconds(timeout-1) <= cancelledTime)
        {
            throw new TimeoutException($"An HTTP request to {request.Url} timed out ({timeout} seconds)");
        }
        else
            throw;
    }
}
return response;
0
votes

I set the timeout to infinite to disable it, then I pass in my own cancellation token.

using(CancellationTokenSource cancelAfterDelay = new CancellationTokenSource(timespan/timeout))
...
catch(OperationCanceledException e)
{
if(!cancelAfterDelay.Token.IsCancellationRequested)
throw new TimeoutException($"An HTTP request to {request.Uri} timed out ({(int)requestTimeout.TotalSeconds} seconds)");
else
throw;
}