10

I am using async I/O to communicate with an HID device, and I would like to throw a catchable exception when there is a timeout. I've got the following read method:

public async Task<int> Read( byte[] buffer, int? size=null )
{
    size = size ?? buffer.Length;

    using( var cts = new CancellationTokenSource() )
    {
        cts.CancelAfter( 1000 );
        cts.Token.Register( () => { throw new TimeoutException( "read timeout" ); }, true );
        try
        {
            var t =  stream.ReadAsync( buffer, 0, size.Value, cts.Token );
            await t;
            return t.Result;
        }
        catch( Exception ex )
        {
            Debug.WriteLine( "exception" );
            return 0;
        }
    }
}

The exception thrown from the Token's callback is not caught by any try/catch blocks and I'm not sure why. I assumed it would be thrown at the await, but it is not. Is there a way to catch this exception (or make it catchable by the caller of Read())?

EDIT: So I re-read the doc at msdn, and it says "Any exception the delegate generates will be propagated out of this method call."

I'm not sure what it means by "propagated out of this method call", because even if I move the .Register() call into the try block the exception is still not caught.

bj0
  • 7,893
  • 5
  • 38
  • 49

3 Answers3

16

I personally prefer to wrap the Cancellation logic into it's own method.

For example, given an extension method like:

public static async Task<T> WithCancellation<T>(this Task<T> task, CancellationToken cancellationToken)
{
    var tcs = new TaskCompletionSource<bool>();
    using (cancellationToken.Register(s => ((TaskCompletionSource<bool>)s).TrySetResult(true), tcs))
    {
        if (task != await Task.WhenAny(task, tcs.Task))
        {
            throw new OperationCanceledException(cancellationToken);
        }
    }

    return task.Result;
}

You can simplify your method down to:

public async Task<int> Read( byte[] buffer, int? size=null )
{
    size = size ?? buffer.Length;

    using( var cts = new CancellationTokenSource() )
    {
        cts.CancelAfter( 1000 );
        try
        {
            return await stream.ReadAsync( buffer, 0, size.Value, cts.Token ).WithCancellation(cts.Token);
        }
        catch( OperationCanceledException cancel )
        {
            Debug.WriteLine( "cancelled" );
            return 0;
        }
        catch( Exception ex )
        {
            Debug.WriteLine( "exception" );
            return 0;
        }
    }
}

In this case, since your only goal is to perform a timeout, you can make this even simpler:

public static async Task<T> TimeoutAfter<T>(this Task<T> task, TimeSpan timeout)
{
    if (task != await Task.WhenAny(task, Task.Delay(timeout)))
    {
        throw new TimeoutException();
    }

    return task.Result; // Task is guaranteed completed (WhenAny), so this won't block
}

Then your method can be:

public async Task<int> Read( byte[] buffer, int? size=null )
{
    size = size ?? buffer.Length;

    try
    {
        return await stream.ReadAsync( buffer, 0, size.Value, cts.Token ).TimeoutAfter(TimeSpan.FromSeconds(1));
    }
    catch( TimeoutException timeout )
    {
        Debug.WriteLine( "Timed out" );
        return 0;
    }
    catch( Exception ex )
    {
        Debug.WriteLine( "exception" );
        return 0;
    }
}
Reed Copsey
  • 554,122
  • 78
  • 1,158
  • 1,373
  • Thanks for the response. Both of those are clever workarounds to the problem (I was originally using something similar to the second method with Task.Delay, but I switched to cts.CancelAfter() because it seemed cleaner as I didn't need to create an extra task on each read (which I cleaned up with a cancel anyway)). Is it not possible to catch the exception in the ```.Register``` callback? – bj0 May 12 '14 at 22:55
  • @bj0 The problem is the exception is async - you can catch it there, but then you have to do something with it, and you're in a different context/thread. – Reed Copsey May 13 '14 at 00:05
  • Yea, that makes sense. I was trying to think of a way to eliminate the extra Task/TCS created on each write, but I guess TCSs are pretty lightweight – bj0 May 13 '14 at 00:06
  • 1
    @bj0, if you don't want to create a dedicated TCS for cancellation, you can use `Task.Delay(Timeout.Infinite, token)`. – noseratio May 13 '14 at 00:13
  • @Noseratio, that's true, but Task.Delay creates an additional task instead of TCS. I may be wrong but I thought TCS was more 'lightweight'? – bj0 May 13 '14 at 00:21
  • 1
    I wish I could mark both these answers as "the answer" since they had good info, but I'll have to settle for up-voting. Marking @Noseratio's answer as correctly since he did answer the question about catching the exception. – bj0 May 13 '14 at 00:22
  • @bj0, it's .NET implementation specific, but currently I think `Task.Delay(Timeout.Infinite)` is more lightweight than TCS, check [this](http://stackoverflow.com/a/23473779/1768303) for some more details. – noseratio May 13 '14 at 00:29
  • @ReedCopsey There looks to be a race in the above situation where `stream.ReadAsync()` can linger after `Read()` has returned, and be allowed to consume bytes from a subsequent transmission, depending on how responsively it acknowledges `cts.Token`. Maybe wait for `task` to terminate before throwing `OperationCancelledException` in `WithCancellation()`? – antak Jun 01 '16 at 04:13
  • I am wondering why using is necessary in code using (cancellationToken.Register(s => ((TaskCompletionSource)s).TrySetResult(true), tcs))? what dos it change please? – Pipo Dec 09 '21 at 16:47
10

EDIT: So I re-read the doc at msdn, and it says "Any exception the delegate generates will be propagated out of this method call."

I'm not sure what it means by "propagated out of this method call", because even if I move the .Register() call into the try block the exception is still not caught.

What this means is that the caller of your cancellation callback (the code inside .NET Runtime) won't make an attempt to catch any exceptions you may throw there, so they will be propagated outside your callback, on whatever stack frame and synchronization context the callback was invoked on. This may crash the application, so you really should handle all non-fatal exceptions inside your callback. Think of it as of an event handler. After all, there may be multiple callbacks registered with ct.Register(), and each might throw. Which exception should have been propagated then?

So, such exception will not be captured and propagated into the "client" side of the token (i.e., to the code which calls CancellationToken.ThrowIfCancellationRequested).

Here's an alternative approach to throw TimeoutException, if you need to differentiate between user cancellation (e.g., a "Stop" button) and a timeout:

public async Task<int> Read( byte[] buffer, int? size=null, 
    CancellationToken userToken)
{
    size = size ?? buffer.Length;

    using( var cts = CancellationTokenSource.CreateLinkedTokenSource(userToken))
    {
        cts.CancelAfter( 1000 );
        try
        {
            var t =  stream.ReadAsync( buffer, 0, size.Value, cts.Token );
            try
            {
                await t;
            }
            catch (OperationCanceledException ex)
            {
                if (ex.CancellationToken == cts.Token)
                    throw new TimeoutException("read timeout", ex);
                throw;
            }
            return t.Result;
        }
        catch( Exception ex )
        {
            Debug.WriteLine( "exception" );
            return 0;
        }
    }
}
noseratio
  • 59,932
  • 34
  • 208
  • 486
  • 4
    +1. An important note is that `Register` is intended for interop with other cancellation systems. – Stephen Cleary May 12 '14 at 23:42
  • Hi @Noseratio! Rereading that paragraph, I think it's the combination of it with later sentences that puzzled me. Sorry for not being more clear. :-/ I agree that not throwing exceptions out of cancellation callbacks is a good idea. :-) However, at the technical level, if the callback is run synchronously by `Register` (the context of the quote) then only that callback is being run by `Register` and any exception raised is propagated out of `Register`. "After all, there may be multiple callbacks registered.... Which exception should have been propagated..." don't seem to apply in this context. – Ben Gribaudo Feb 13 '18 at 21:19
5

Exception handling for callbacks registered with CancellationToken.Register() is complex. :-)

Token Cancelled Before Callback Registration

If the cancellation token is cancelled before the cancellation callback is registered, the callback will be executed synchronously by CancellationToken.Register(). If the callback raises an exception, that exception will be propagated from Register() and so can be caught using a try...catch around it.

This propagation is what the statement you quoted refers to. For context, here's the full paragraph that that quote comes from.

If this token is already in the canceled state, the delegate will be run immediately and synchronously. Any exception the delegate generates will be propagated out of this method call.

"This method call" refers to the call to CancellationToken.Register(). (Don't feel bad about being confused by this paragraph. When I first read it a while back, I was puzzled, too.)

Token Cancelled After Callback Registration

Cancelled by Calling CancellationTokenSource.Cancel()

When the token is cancelled by calling this method, cancellation callbacks are executed synchronously by it. Depending on the overload of Cancel() that's used, either:

  • All cancellation callbacks will be run. Any exceptions raised will be combined into an AggregateException that is propagated out of Cancel().
  • All cancellation callbacks will be run unless and until one throws an exception. If a callback throws an exception, that exception will be propagated out of Cancel() (not wrapped in an AggregateException) and any unexecuted cancellation callbacks will be skipped.

In either case, like CancellationToken.Register(), a normal try...catch can be used to catch the exception.

Cancelled by CancellationTokenSource.CancelAfter()

This method starts a countdown timer then returns. When the timer reaches zero, the timer causes the cancellation process to run in the background.

Since CancelAfter() doesn't actually run the cancellation process, cancellation callback exceptions aren't propagated out of it. If you want to observer them, you'll need to revert to using some means of intercepting unhandled exceptions.

In your situation, since you're using CancelAfter(), intercepting the unhandled exception is your only option. try...catch won't work.

Recommendation

To avoid these complexities, when possible don't allow cancellation callbacks to throw exceptions.

Further Reading

Ben Gribaudo
  • 5,057
  • 1
  • 40
  • 75