Search code examples
c#asynchronoustask-parallel-librarytasktaskcompletionsource

Can one detect uncontrolled cancellation from .NET library code?


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?


Solution

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