Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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?

like image 879
SensorSmith Avatar asked May 15 '14 03:05

SensorSmith


1 Answers

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.

like image 50
noseratio Avatar answered Sep 20 '22 00:09

noseratio