Can one detect uncontrolled cancellation from .NET library code?

1.4k Views Asked by At

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?

3

There are 3 best solutions below

1
On BEST ANSWER

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);
    }
}
7
On

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.

2
On

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