35

I have a blackbox object that exposes a method to kick of an async operation, and an event fires when the operation is complete. I have wrapped that into an Task<OpResult> BlackBoxOperationAysnc() method using TaskCompletionSource - that works well.

However, in that async wrapper I'd like to manage completing the async call with a timeout error if the event is not received after a given timeout. Currently I manage it with a timer as:

public Task<OpResult> BlackBoxOperationAysnc() {
    var tcs = new TaskCompletionSource<TestResult>();   
    const int timeoutMs = 20000;
    Timer timer = new Timer(_ => tcs.TrySetResult(OpResult.Timeout),
                            null, timeoutMs, Timeout.Infinite);

    EventHandler<EndOpEventArgs> eventHandler = (sender, args) => {
        ...
        tcs.TrySetResult(OpResult.BlarBlar);
    }
    blackBox.EndAsyncOpEvent += eventHandler;
    blackBox.StartAsyncOp();
    return tcs.Task;
}

Is that the only way to manage a timeout? Is there someway without setting up my own timer - I couldn't see anything timeout built into TaskCompletionSource?

roufamatic
  • 18,187
  • 7
  • 57
  • 86
Ricibob
  • 7,505
  • 5
  • 46
  • 65

2 Answers2

55

You could use CancellationTokenSource with timeout. Use it together with your TaskCompletionSource like this.

E.g.:

public Task<OpResult> BlackBoxOperationAysnc() {
    var tcs = new TaskCompletionSource<TestResult>();

    const int timeoutMs = 20000;
    var ct = new CancellationTokenSource(timeoutMs);
    ct.Token.Register(() => tcs.TrySetCanceled(), useSynchronizationContext: false);

    EventHandler<EndOpEventArgs> eventHandler = (sender, args) => {
        ...
        tcs.TrySetResult(OpResult.BlarBlar);
    }
    blackBox.EndAsyncOpEvent += eventHandler;
    blackBox.StartAsyncOp();
    return tcs.Task;
}

Updated, here's a complete functional example:

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

namespace ConsoleApplication
{
    public class Program
    {
        // .NET 4.5/C# 5.0: convert EAP pattern into TAP pattern with timeout
        public async Task<AsyncCompletedEventArgs> BlackBoxOperationAsync(
            object state,
            CancellationToken token,
            int timeout = Timeout.Infinite)
        {
            var tcs = new TaskCompletionSource<AsyncCompletedEventArgs>();
            using (var cts = CancellationTokenSource.CreateLinkedTokenSource(token))
            {
                // prepare the timeout
                if (timeout != Timeout.Infinite)
                {
                    cts.CancelAfter(timeout);
                }

                // handle completion
                AsyncCompletedEventHandler handler = (sender, args) =>
                {
                    if (args.Cancelled)
                        tcs.TrySetCanceled();
                    else if (args.Error != null)
                        tcs.SetException(args.Error);
                    else
                        tcs.SetResult(args);
                };

                this.BlackBoxOperationCompleted += handler;
                try
                {
                    using (cts.Token.Register(() => tcs.SetCanceled(), useSynchronizationContext: false))
                    {
                        this.StartBlackBoxOperation(null);
                        return await tcs.Task.ConfigureAwait(continueOnCapturedContext: false);
                    }
                }
                finally
                {
                    this.BlackBoxOperationCompleted -= handler;
                }
            }
        }

        // emulate async operation
        AsyncCompletedEventHandler BlackBoxOperationCompleted = delegate { };

        void StartBlackBoxOperation(object state)
        {
            ThreadPool.QueueUserWorkItem(s =>
            {
                Thread.Sleep(1000);
                this.BlackBoxOperationCompleted(this, new AsyncCompletedEventArgs(error: null, cancelled: false, userState: state));
            }, state);
        }

        // test
        static void Main()
        {
            try
            {
                new Program().BlackBoxOperationAsync(null, CancellationToken.None, 1200).Wait();
                Console.WriteLine("Completed.");
                new Program().BlackBoxOperationAsync(null, CancellationToken.None, 900).Wait();
            }
            catch (Exception ex)
            {
                while (ex is AggregateException)
                    ex = ex.InnerException;
                Console.WriteLine(ex.Message);
            }
            Console.ReadLine();
        }
    }
}

A .NET 4.0/C# 4.0 vesion can be found here, it takes advantage of the compiler-generated IEnumerator state machine.

noseratio
  • 59,932
  • 34
  • 208
  • 486
  • 1
    Thanks. Note: CancellationTokenSource only has timeout contructor in .NET4.5 onwards (not in .NET4.0). – Ricibob Sep 12 '13 at 12:13
  • 2
    It seems to me you are using CancellationTokenSource instead of just running a delayed task. I would prefer Task.Factory.StartNewDelayed(20000, () => tcs.TrySetCanceled()) for readability, in .NET 4.5. – angularsen Apr 01 '14 at 19:25
  • 2
    @AndreasLarsen, that'd require `ParallelExtensionsExtras`, but feel free to post your own answer if you like. – noseratio Apr 01 '14 at 19:57
  • 1
    @Noseratio, good point. I did not realize I was using 3rd party code for that. – angularsen Apr 01 '14 at 20:01
  • 1
    This code actually helped me add TimeOut functionality to a WebAPi call using TaskCompletionSource. Thanks! I actually didn't want to cancel the task, but throw an exception. So I added TrySetException after a cts.Dispose inside the Register method. I also found I needed to dispose of the CancellationTokenSource before calling TrySetResult on my TaskCompletionSource (using the Dispose method on the CancellationTokenSource) or the cancellation token would stay around and eventually time out anyway! – mike gold Aug 20 '15 at 01:37
  • 1
    this is brilliant! I had to use the .NET 4.0 version because we're still stuck in 4.0 with a project where we had the problem that the managed Oracle data provider [ignores](http://stackoverflow.com/questions/12692832/commandtimeout-for-oraclecommand) the `CommandTimeout` property, so we had to roll our own, and this did the trick. there is one slight correction, your code line 58 should be `this.StartBlackBoxOperation(state);` instead of `this.StartBlackBoxOperation(null);`, so workitems which actually use a non-null state object will receive it. – Cee McSharpface Sep 09 '16 at 19:59
  • 4
    Thanks for this code! However I had to replace all tcs.Set* with tcs.TrySet because there were many cases of race conditions with tcs being already set to a final state, leading to InvalidOperationException – Wizou Sep 19 '16 at 08:54
  • Since `CancellationTokenSource` is `IDisposable` you should implement `using` or call `Dispose` – Alexandre Feb 13 '19 at 13:11
  • A more general solution here: https://stackoverflow.com/a/25987969/2440 – Sire May 29 '19 at 13:11
0

You could use an extension for Task from here (https://stackoverflow.com/a/22078975/2680660) which also uses CancellationTokenSource.

With slight modification:

public static async Task<TResult> TimeoutAfter<TResult>(this Task<TResult> task, TimeSpan timeout)
{
    using (var timeoutCancellationTokenSource = new CancellationTokenSource())
    {
        var completedTask = await Task.WhenAny(task, Task.Delay(timeout, timeoutCancellationTokenSource.Token));
        if (completedTask == task)
        {
            timeoutCancellationTokenSource.Cancel();
            return await task;  // Very important in order to propagate exceptions
        }
        else
        {
            throw new TimeoutException($"{nameof(TimeoutAfter)}: The operation has timed out after {timeout:mm\\:ss}");
        }
    }
}

public Task<OpResult> BlackBoxOperationAysnc()
{
    var tcs = new TaskCompletionSource<TestResult>();   

    EventHandler<EndOpEventArgs> eventHandler = (sender, args) => {
        ...
        tcs.TrySetResult(OpResult.BlarBlar);
    }
    blackBox.EndAsyncOpEvent += eventHandler;
    blackBox.StartAsyncOp();
    return tcs.Task.TimeoutAfter(TimeSpan.FromSeconds(20));
}
Efreeto
  • 2,132
  • 1
  • 24
  • 25
  • 1
    Isn't this prone to `UnobservedTaskException` due to cancelling the timeout `Task` after the other task was completed, thus nobody ever awaits the result? – AyCe Mar 26 '21 at 05:53