6

I've found that I can't distinguish controlled/cooperative from "uncontrolled" cancellation of Tasks/delegates without checking the source behind the specific Task or delegate.

Specifically, I've always assumed that when catching an OperationCanceledException thrown from a "lower level operation" that if the referenced token cannot be matched to the token for the current operation, then it should be interpreted as a failure/error. This is a statement from the "lower level operation" that it gave up (quit), but not because you asked it to do so.

Unfortunately, TaskCompletionSource cannot associate a CancellationToken as the reason for cancellation. So any Task not backed by the built in schedulers cannot communicate the reason for its cancellation and could misreport cooperative cancellation as an error.

UPDATE: As of .NET 4.6 TaskCompletionSource can associate a CancellationToken if the new overloads for SetCanceled or TrySetCanceled are used.

For instance the following

public Task ShouldHaveBeenAsynchronous(Action userDelegate, CancellationToken ct)
{
    var tcs = new TaskCompletionSource<object>();

    try
    {
      userDelegate();
      tcs.SetResult(null);   // Indicate completion
    }
    catch (OperationCanceledException ex)
    {
      if (ex.CancellationToken == ct)
        tcs.SetCanceled(); // Need to pass ct here, but can't
      else
        tcs.SetException(ex);
    }
    catch (Exception ex)
    {
      tcs.SetException(ex);
    }

    return tcs.Task;
}

private void OtherSide()
{
    var cts = new CancellationTokenSource();
    var ct = cts.Token;
    cts.Cancel();
    Task wrappedOperation = ShouldHaveBeenAsynchronous(
        () => { ct.ThrowIfCancellationRequested(); }, ct);

    try
    {
        wrappedOperation.Wait();
    }
    catch (AggregateException aex)
    {
        foreach (var ex in aex.InnerExceptions
                              .OfType<OperationCanceledException>())
        {
            if (ex.CancellationToken == ct)
                Console.WriteLine("OK: Normal Cancellation");
            else
                Console.WriteLine("ERROR: Unexpected cancellation");
        }
    }
}

will result in "ERROR: Unexpected cancellation" even though the cancellation was requested through a cancellation token distributed to all the components.

The core problem is that the TaskCompletionSource does not know about the CancellationToken, but if THE "go to" mechanism for wrapping asynchronous operations in Tasks can't track this then I don't think one can count on it ever being tracked across interface(library) boundaries.

In fact TaskCompletionSource CAN handle this, but the necessary TrySetCanceled overload is internal so only mscorlib components can use it.

So does anyone have a pattern that communicates that a cancellation has been "handled" across Task and Delegate boundaries?

SensorSmith
  • 1,129
  • 1
  • 12
  • 25

3 Answers3

2

I've found that I can't distinguish controlled from "uncontrolled" cancellation of Tasks/delegates without checking the details of how they are implemented.

Moreover, the fact that you have caught an OperationCanceledException exception while awaiting or waiting the task doesn't necessarily mean the task's Status is TaskStatus.Canceled. It may as well be TaskStatus.Faulted.

There are probably a few options to implement what you're after. I'd do it using ContinueWith and pass that continuation task to the client code, rather than the original TaskCompletionSource.Task:

using System;
using System.Threading;
using System.Threading.Tasks;

namespace ConsoleApplication
{
    public static class TaskExt
    {
        public static Task<TResult> TaskWithCancellation<TResult>(
            this TaskCompletionSource<TResult> @this,
            CancellationToken token)
        {
            var registration = token.Register(() => @this.TrySetCanceled());
            return @this.Task.ContinueWith(
                task => { registration.Dispose(); return task.Result; },
                token, 
                TaskContinuationOptions.LazyCancellation | 
                    TaskContinuationOptions.ExecuteSynchronously, 
                TaskScheduler.Default);
        }
    }

    class Program
    {
        static async Task OtherSideAsync(Task task, CancellationToken token)
        {
            try
            {
                await task;
            }
            catch (OperationCanceledException ex)
            {
                if (token != ex.CancellationToken)
                    throw;
                Console.WriteLine("Cancelled with the correct token");
            }
        }

        static void Main(string[] args)
        {
            var cts = new CancellationTokenSource(1000); // cancel in 1s
            var tcs = new TaskCompletionSource<object>();

            var taskWithCancellation = tcs.TaskWithCancellation(cts.Token);
            try
            {
                OtherSideAsync(taskWithCancellation, cts.Token).Wait();
            }
            catch (AggregateException ex)
            {
                Console.WriteLine(ex.InnerException.Message);
            }
            Console.ReadLine();
        }
    }
}

Note the use of TaskContinuationOptions.LazyCancellation, it's there to make sure the continuation task never gets completed before the tcs.Task task (when the cancellation has been requested via token).

Note also that if tcs.TrySetCanceled is called before the cancellation has been requested via token, the resulting task will be in faulted rather than cancelled state (taskWithCancellation.IsFaulted == true but taskWithCancellation.IsCancelled == false). If you want the cancellation status to be propagated for both implicit token and explicit tcs.TrySetCanceled cancellations, change the TaskWithCancellation extension like this:

public static Task<TResult> TaskWithCancellation<TResult>(
    this TaskCompletionSource<TResult> @this,
    CancellationToken token)
{
    var registration = token.Register(() => @this.TrySetCanceled());
    return @this.Task.ContinueWith(
        task => { registration.Dispose(); return task; },
        token,
        TaskContinuationOptions.LazyCancellation | 
            TaskContinuationOptions.ExecuteSynchronously,
        TaskScheduler.Default).Unwrap();
}

Updated to address comments:

A typical design of a Task-based library API is that the client code supplies a cancellation token to the API, and the API returns a Task, which is associated with the supplied token. The client code of the API can then do the token matching when catching cancelation exceptions.

The exact purpose of the TaskWithCancellation is to create such Task and return it to the client. The original TaskCompletionSource.Task is never exposed to the client. The cancelation happens because the token was passed to ContinueWith, that's how it gets associated with the continuation task. OTOH, token.Register, TrySetCanceled and TaskContinuationOptions.LazyCancellation are used just to make sure the things happen in the right order, including the registration clean-up.

noseratio
  • 59,932
  • 34
  • 208
  • 486
  • Is the following an accurate interpretation? Using 'OperationCanceledException' to signal controlled cancellation is weak because it may not have been caught by an intermediate lower layer that needed to do its own cleanup. For delegates/methods there is no better way, but if you have a 'Task' use its explicit 'Status'. If you really want to let consumers of your Tasks use the weaker exception pattern, I'd do the following... – SensorSmith May 15 '14 at 16:43
  • I think calling tcs.TrySetCanceled before cancellation is requested _should_ cause the returned task to be faulted. That condition is a statement of "You didn't ask me to stop, but I decided to anyway." So good catch, but I don't think it should be protected against. Also, I don't think the token registration fits in with general (re)use of the extension method since additional steps are/may be needed to cleanup/cancel the operations represented by the Task. Though an overload that takes an "onCancellationRequested" delegate would finish this off perfectly. – SensorSmith May 15 '14 at 17:08
  • @SensorSmith, in reply to the first comment, I think it depends, but usually handling `OperationCanceledException` is sufficient enough and it does logically mean cancelation, even if the awaited task is in faulted state, because it wasn't associated with this particular token. I.e., what else are you supposed to do when you catch this exception but cannot match the token? – noseratio May 15 '14 at 21:37
  • In reply to the second comment, the token callback registration clean-up is transparent to the consumer side of the task, and it will always correctly take place. So I don't think it's a problem. – noseratio May 15 '14 at 21:40
  • the reason behind the question is that I have assumed that if one catches an `OperationCanceledException` thrown from a "lower level operation" but it cannot be matched to the token for the current operation, that should be interpreted as a failed operation. Again it's a statement from the "lower level operation" that it gave up (quit). If you (the "higher level operation") weren't wanting/ready to give up(quit) then that is an error. I'll try to clarify the question. – SensorSmith May 15 '14 at 23:57
  • Regarding the token callback. I agree that hiding the token callback registration cleanup is nice, but it is wrong to cancel the task from inside the extension method because the user of the `TaskCompletionSource` may(should) have cleanup to perform associated with cancelling the task (or rather the operation that the `Task` represents). – SensorSmith May 16 '14 at 00:01
  • @SensorSmith, see my update to the answer. This is as much as I could help with this question; hopefully someone will provide a better solution. – noseratio May 16 '14 at 00:14
2

Just for the record: This has been fixed in .NET framework 4.6 and above TaskCompletionSource.TrySetCanceled Method (CancellationToken)

Ivan Stoev
  • 195,425
  • 15
  • 312
  • 343
  • Thanks for the update! However, that should read .NET framework **4.6** and above. – SensorSmith Sep 07 '15 at 21:36
  • Unfortunately, while that text marks the web page revision as being produced for 4.5 and 4.6, the Version Information at the bottom of the page says "Supported in: 4.6". Also, inspection of the IL on a machine with .net 4.5 installed shows that overload as being 'internal'. – SensorSmith Sep 07 '15 at 21:51
1

For the record: Yes, the API is/was broken in that TaskCompletionSource should accept a CancellationToken. The .NET runtimes fixed this for their own use, but did not expose the fix (overload of TrySetCanceled) prior to .NET 4.6.

As a Task consumer one has two basic options.

  1. Always check the Task.Status
  2. Simply check your own CancellationToken and ignore Task errors if cancellation was requested.

So something like:

object result;
try
{
    result = task.Result;
}
// catch (OperationCanceledException oce) // don't rely on oce.CancellationToken
catch (Exception ex)
{
    if (task.IsCancelled)
        return; // or otherwise handle cancellation

    // alternatively
    if (cancelSource.IsCancellationRequested)
        return; // or otherwise handle cancellation

    LogOrHandleError(ex);
}

The first counts on library writers to use TaskCompletionSource.TrySetCanceled rather than performing TrySetException with an OperationCanceledException supplying a matching token.

The second doesn't rely on library writers to do anything 'correctly' other than to do whatever is necessary to cope with exceptions their code. This might fail to log errors for troubleshooting, but one can't (reasonably) clean up operating state from inside external code anyway.

For Task producers one can

  1. Try to honor the OperationCanceledException.CancellationToken contract by using reflection to associate the token with the Task cancellation.
  2. Use a Continuation to associate the token with the returned task.

The later is simple, but like Consumer option 2 may ignore task errors (or even mark the Task completed long before the execution sequence stops).

A full implementation of both (including cached delegate to avoid reflection)...

UPDATE: For .NET 4.6 and above simply call the newly public overload of TaskCompletionSource.TrySetCanceled that accepts a CancellationToken. Code using the extension method below will automatically switch to that overload when linked against .NET 4.6 (if the calls were made using the extension method syntax).

static class TaskCompletionSourceExtensions
{
    /// <summary>
    /// APPROXIMATION of properly associating a CancellationToken with a TCS
    /// so that access to Task.Result following cancellation of the TCS Task 
    /// throws an OperationCanceledException with the proper CancellationToken.
    /// </summary>
    /// <remarks>
    /// If the TCS Task 'RanToCompletion' or Faulted before/despite a 
    /// cancellation request, this may still report TaskStatus.Canceled.
    /// </remarks>
    /// <param name="this">The 'TCS' to 'fix'</param>
    /// <param name="token">The associated CancellationToken</param>
    /// <param name="LazyCancellation">
    /// true to let the 'owner/runner' of the TCS complete the Task
    /// (and stop executing), false to mark the returned Task as Canceled
    /// while that code may still be executing.
    /// </param>
    public static Task<TResult> TaskWithCancellation<TResult>(
        this TaskCompletionSource<TResult> @this,
        CancellationToken token,
        bool lazyCancellation)
    {
        if (lazyCancellation)
        {
            return @this.Task.ContinueWith(
                (task) => task,
                token,
                TaskContinuationOptions.LazyCancellation |
                    TaskContinuationOptions.ExecuteSynchronously,
                TaskScheduler.Default).Unwrap();
        }

        return @this.Task.ContinueWith((task) => task, token).Unwrap();
        // Yep that was a one liner!
        // However, LazyCancellation (or not) should be explicitly chosen!
    }


    /// <summary>
    /// Attempts to transition the underlying Task into the Canceled state
    /// and set the CancellationToken member of the associated 
    /// OperationCanceledException.
    /// </summary>
    public static bool TrySetCanceled<TResult>(
        this TaskCompletionSource<TResult> @this,
        CancellationToken token)
    {
        return TrySetCanceledCaller<TResult>.MakeCall(@this, token);
    }

    private static class TrySetCanceledCaller<TResult>
    {
        public delegate bool MethodCallerType(TaskCompletionSource<TResult> inst, CancellationToken token);

        public static readonly MethodCallerType MakeCall;

        static TrySetCanceledCaller()
        {
            var type = typeof(TaskCompletionSource<TResult>);

            var method = type.GetMethod(
                "TrySetCanceled",
                System.Reflection.BindingFlags.Instance |
                System.Reflection.BindingFlags.NonPublic,
                null,
                new Type[] { typeof(CancellationToken) },
                null);

            MakeCall = (MethodCallerType)
                Delegate.CreateDelegate(typeof(MethodCallerType), method);
        }
    }
}

and test program...

class Program
{
    static void Main(string[] args)
    {
        //var cts = new CancellationTokenSource(6000); // To let the operation complete
        var cts = new CancellationTokenSource(1000);
        var ct = cts.Token;
        Task<string> task = ShouldHaveBeenAsynchronous(cts.Token);

        try
        {
            Console.WriteLine(task.Result);
        }
        catch (AggregateException aex)
        {
            foreach (var ex in aex.Flatten().InnerExceptions)
            {
                var oce = ex as OperationCanceledException;
                if (oce != null)
                {
                    if (oce.CancellationToken == ct)
                        Console.WriteLine("OK: Normal Cancellation");
                    else
                        Console.WriteLine("ERROR: Unexpected cancellation");
                }
                else
                {
                    Console.WriteLine("ERROR: " + ex.Message);
                }
            }
        }

        Console.Write("Press Enter to Exit:");
        Console.ReadLine();
    }

    static Task<string> ShouldHaveBeenAsynchronous(CancellationToken ct)
    {
        var tcs = new TaskCompletionSource<string>();

        try
        {
            //throw new NotImplementedException();

            ct.WaitHandle.WaitOne(5000);
            ct.ThrowIfCancellationRequested();
            tcs.TrySetResult("this is the result");
        }
        catch (OperationCanceledException ex)
        {
            if (ex.CancellationToken == ct)
                tcs.TrySetCanceled(ct);
            else
                tcs.TrySetException(ex);
        }
        catch (Exception ex)
        {
            tcs.TrySetException(ex);
        }

        return tcs.Task;
        //return tcs.TaskWithCancellation(ct, false);
    }
}
SensorSmith
  • 1,129
  • 1
  • 12
  • 25
  • Kudos to @Noseratio for mentioning Task.Status, suggesting use of a Continuation, and posting any use of LazyCancellation though it needed tweaking. – SensorSmith Aug 25 '14 at 02:50