TaskMonitor: Free Yourself From async void and MOAR

I first wanted to call this "yawtodwav" (yet another way to deal with async void), but I finally went for "Free Yourself From async void", which is a shameless and lazy way to surf on my previous adventure.

https://github.com/roubachof/Sharpnado.TaskMonitor

The TaskMonitor was inspired by Stephen Cleary's NotifyTask. It's a task wrapper component dealing with non-awaited, or fire and forget if you prefer, Task, implementing async/await best practices for safe execution.

The difference is that NotifyTask is made for UI scenarios (MVVM), where you want to bind the result or the state of the task to a view property. For this it implements INotifyPropertyChanged.
See this: https://docs.microsoft.com/en-us/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming

Whereas TaskMonitor is designed to be used in any kind of scenarios (server, business layer, UI, etc...).

It offers:

  • Safe execution of all your async tasks: taylor-made for async void scenarios and non awaited Task
  • Callbacks for any states (canceled, success, completed, failure)
  • Default or custom error handler
  • Default or custom task statistics logger

Free yourself from async void

Now let's say you have an evil async void in your code:

public async void InitializeAsync()  
{
    try
    {
        await InitializeMonkeyAsync();
    }
    catch (Exception exception)
    {
        // handle error
    }
}

private async Task InitializeMonkeyAsync()  
{
    Monkey = await _monkeyService.GetMonkeyAsync();
}

You can get rid of async void and simply use the TaskMonitor:

public void InitializeAsync()  
{
    TaskMonitor.Create(InitializeMonkeyAsync);
}

If an error occurs, it will call the default error handler which will Trace the exception, so that it won't crash (async void) or fail silently (non awaited task).

TaskMonitor|ERROR|013|Error in wrapped task  
Exception:System.Exception: Fault  
   at Sharpnado.Tasks.Tests.TaskMonitorTest.DelayFaultAsync() in D:\Dev\Sharpnado\src\TaskMonitor\Sharpnado.TaskMonitor.Tests\TaskMonitorTest.cs:line 243
   at Sharpnado.Tasks.TaskMonitorBase.MonitorTaskAsync(Task task) in D:\Dev\Sharpnado\src\TaskMonitor\Sharpnado.TaskMonitor\TaskMonitorBase.cs:line 186

But you can also setup globally your own error handler with the TaskMonitorConfiguration static class:

TaskMonitorConfiguration.ErrorHandler = (message, exception) =>  
    {
        // Do custom stuff for exception handling;
    };

Now careful: if you are in a MVVM scenario, I strongly encourage you to read my Free Yourself From IsBusy post. The ViewModelLoader or the NotifyTask would be better to handle the ViewModel loading states.

Good also with events or messages

If you are subscribing to an event/message and want to make async stuff when the event is raised, it will also be a perfect candidate.

public Constructor(IMonkeyService monkeyService)  
{
    monkeyService.MonkeyChanged += OnMonkeyChanged;
    // same as messageService.Subscribe("MonkeyChangedMessage", OnMonkeyChanged)
}

private void OnMonkeyChanged(MonkeyChangedEventArgs eventArgs)  
{
    TaskMonitor.Create(() => DoSomethingAsync(eventArgs.Monkey));
}

private Task DoSomethingAsync(Monkey monkey)  
{
    await CrazyAsyncStuff(monkey);
    await SomeOtherCrazyAction();
}

Good with non awaited task and ContinueWith

Previously you maybe used the ContinueWith task method to create a new task and deal with fire and forget scenarios.

public void DoSomethingAsync()  
{
    // Task will not be awaited and you are still handling the exception: hooray!
    Task.Run(() => InitializeMonkey())
        .ContinueWith(
            t => HandleException(t.InnerException),
            TaskContinuationOptions.OnlyOnFaulted);
}

private void InitializeMonkey()  
{
    ...
}

You can achieve the same behaviour with the TaskMonitor in a cleaner way:

public void DoSomethingAsync()  
{
    TaskMonitor.Create(Task.Run(() => InitializeMonkey()));
}

Can be used as a simple decorator for statistics and error handling

You can specify global error handler and statistics logger:

TaskMonitorConfiguration.LogStatistics = true;  
TaskMonitorConfiguration.StatisticsHandler = (taskMonitor, timeSpan) =>  
{
    // My global statistics logger
};

TaskMonitorConfiguration.ErrorHandler = (taskMonitor, message, exception) =>  
{
    // My global error logger
};

Then use TaskMonitor as a simple task logging decorator:

try  
{
    await TaskMonitor.Create(DelayFaultAsync, name: "UseMonitorAsDecoratedFaultTest").Task;
}
catch(Exception exception)  
{
    // handle exception
}

But you can also let the default handlers Trace the errors and the statistics.
Output with default handlers:

TaskMonitor|ERROR|013|Error in wrapped task  
Exception:System.Exception: Fault  
   at Sharpnado.Tasks.Tests.TaskMonitorTest.DelayFaultAsync() in D:\Dev\Sharpnado\src\TaskMonitor\Sharpnado.TaskMonitor.Tests\TaskMonitorTest.cs:line 262
   at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) in C:\Dev\xunit\xunit\src\xunit.assert\Asserts\Record.cs:line 82
TaskMonitor|STATS|013|UseMonitorAsDecoratedFaultTest => Status: IsFaulted, Executed in 334,27070000000003 ms  

Features summary

Delegates for all states of the ran task:

// Here task is "hot", it runs as soon as Create is called
TaskMonitor.Create(  
    () => DoSomethingAsync(cts.Token), 
    t => isCompleted = true, 
    t => isFaulted = true,
    t => isSuccessfullyCompleted = true);

Builder for more elegant construction and deferred execution:

var monitor = new TaskMonitor.Builder(() => DoSomethingAsync(cts.Token))  
    .WithWhenCanceled(t => isCanceled = true)
    .WithWhenFaulted(t => isFaulted = true)
    .WithWhenSuccessfullyCompleted(t => isSuccess = true)
    .Build();

// explicit task start
monitor.Start();  

Support for task with result, Task<T>:

var monitor = TaskMonitor<List<string>>.Create(  
    DelayListAsync, 
    t => isCompleted = true, 
    t => isFaulted = true,
    (task, result) =>
    {
        isSuccessfullyCompleted = true;
        Assert.Equal(3, result.Count);
    });

private async Task<List<string>> DelayListAsync()  
{
    await Task.Delay(200);
    return new List<string> {"1", "2", "3"};
}

Default handling of errors and statistics, and naming of the monitor:

TaskMonitorConfiguration.LogStatistics = true;  
TaskMonitor.Create(  
    DelayFaultAsync,
    name: "NominalFaultTestTask");

Output:

TaskMonitor|ERROR|013|Error in wrapped task  
Exception:System.Exception: Fault  
   at Sharpnado.Tasks.Tests.TaskMonitorTest.DelayFaultAsync() in D:\Dev\Sharpnado\src\TaskMonitor\Sharpnado.TaskMonitor.Tests\TaskMonitorTest.cs:line 262
   at Sharpnado.Tasks.TaskMonitorBase.MonitorTaskAsync(Task task) in D:\Dev\Sharpnado\src\TaskMonitor\Sharpnado.TaskMonitor\TaskMonitorBase.cs:line 186
TaskMonitor|STATS|013|NominalFaultTestTask => Status: IsFaulted, Executed in 246,55870000000002 ms  

Global configuration for statistics and errors handlers:

TaskMonitorConfiguration.LogStatistics = true;  
TaskMonitorConfiguration.StatisticsHandler = (taskMonitor, timeSpan) =>  
{
    statsHandlerCalled = true;
    Assert.True(timeSpan.TotalMilliseconds > 0);
};

TaskMonitorConfiguration.ErrorHandler = (taskMonitor, message, exception) =>  
{
    errorHandlerCalled = true;
};

Run the wrapped Task in a new Task:

int threadId = Thread.CurrentThread.ManagedThreadId;  
var monitor = TaskMonitor<int>.Create(  
    DelayThreadIdAsync,
    inNewTask: true);

await monitor.TaskCompleted;

Assert.NotEqual(threadId, monitor.Result);  

Await the task monitor without failures. Awaiting on the TaskCompleted property will never fail:

// Will always succeed wether the task is canceled, successful or faulted
await monitor.TaskCompleted;  

Consider globally or locally the cancel state as faulted to simplify your workflow:

// Local configuration
var cts = new CancellationTokenSource();  
bool isFaulted = false;  
bool isCanceled = false;

var monitor = new TaskMonitor.Builder(() => DelayCanceledAsync(cts.Token))  
    .WithWhenCanceled(t => isCanceled = true)
    .WithWhenFaulted(t => isFaulted = true)
    .WithConsiderCanceledAsFaulted(true)
    .Build();

cts.Cancel();  
monitor.Start();

await monitor.TaskCompleted;

Assert.True(!isCanceled && monitor.IsCanceled);  
Assert.True(isFaulted && monitor.IsFaulted);  
// Global configuration
var cts = new CancellationTokenSource();  
TaskMonitorConfiguration.ConsiderCanceledAsFaulted = true;  
bool isFaulted = false;  
bool isCanceled = false;  
bool isSuccess = false;

var monitor = new TaskMonitor.Builder(() => DelayCanceledAsync(cts.Token))  
    .WithWhenCanceled(t => isCanceled = true)
    .WithWhenFaulted(t => isFaulted = true)
    .Build();

cts.Cancel();  
monitor.Start();

await monitor.TaskCompleted;

Assert.True(!isCanceled && monitor.IsCanceled);  
Assert.True(isFaulted && monitor.IsFaulted);