A reusable pattern to convert event into task
I have a (usage wise) much shorter Solution. I will show you the usage first and then give you the code that makes this happen (use it freely).
usage eg:
await button.EventAsync(nameof(button.Click));
or:
var specialEventArgs = await busniessObject.EventAsync(nameof(busniessObject.CustomerCreated));
or for Events that need to be triggered in some way:
var serviceResult = await service.EventAsync(()=> service.Start, nameof(service.Completed));
the magic that makes this happen (beware it's C# 7.1 syntax but can easily be converted back to lower language versions by adding a few lines):
using System;
using System.Threading;
using System.Threading.Tasks;
namespace SpacemonsterIndustries.Core
{
public static class EventExtensions
{
/// <summary>
/// Extension Method that converts a typical EventArgs Event into an awaitable Task
/// </summary>
/// <typeparam name="TEventArgs">The type of the EventArgs (must inherit from EventArgs)</typeparam>
/// <param name="objectWithEvent">the object that has the event</param>
/// <param name="trigger">optional Function that triggers the event</param>
/// <param name="eventName">the name of the event -> use nameof to be safe, e.g. nameof(button.Click) </param>
/// <param name="ct">an optional Cancellation Token</param>
/// <returns></returns>
public static async Task<TEventArgs> EventAsync<TEventArgs>(this object objectWithEvent, Action trigger, string eventName, CancellationToken ct = default)
where TEventArgs : EventArgs
{
var completionSource = new TaskCompletionSource<TEventArgs>(ct);
var eventInfo = objectWithEvent.GetType().GetEvent(eventName);
var delegateDef = new UniversalEventDelegate<TEventArgs>(Handler);
var handlerAsDelegate = Delegate.CreateDelegate(eventInfo.EventHandlerType, delegateDef.Target, delegateDef.Method);
eventInfo.AddEventHandler(objectWithEvent, handlerAsDelegate);
trigger?.Invoke();
var result = await completionSource.Task;
eventInfo.RemoveEventHandler(objectWithEvent, handlerAsDelegate);
return result;
void Handler(object sender, TEventArgs e) => completionSource.SetResult(e);
}
public static Task<TEventArgs> EventAsync<TEventArgs>(this object objectWithEvent, string eventName, CancellationToken ct = default) where TEventArgs : EventArgs
=> EventAsync<TEventArgs>(objectWithEvent, null, eventName, ct);
private delegate void UniversalEventDelegate<in TEventArgs>(object sender, TEventArgs e) where TEventArgs : EventArgs;
}
}
It is possible with a helper class and a fluent-like syntax:
public static class TaskExt
{
public static EAPTask<TEventArgs, EventHandler<TEventArgs>> FromEvent<TEventArgs>()
{
var tcs = new TaskCompletionSource<TEventArgs>();
var handler = new EventHandler<TEventArgs>((s, e) => tcs.TrySetResult(e));
return new EAPTask<TEventArgs, EventHandler<TEventArgs>>(tcs, handler);
}
}
public sealed class EAPTask<TEventArgs, TEventHandler>
where TEventHandler : class
{
private readonly TaskCompletionSource<TEventArgs> _completionSource;
private readonly TEventHandler _eventHandler;
public EAPTask(
TaskCompletionSource<TEventArgs> completionSource,
TEventHandler eventHandler)
{
_completionSource = completionSource;
_eventHandler = eventHandler;
}
public EAPTask<TEventArgs, TOtherEventHandler> WithHandlerConversion<TOtherEventHandler>(
Converter<TEventHandler, TOtherEventHandler> converter)
where TOtherEventHandler : class
{
return new EAPTask<TEventArgs, TOtherEventHandler>(
_completionSource, converter(_eventHandler));
}
public async Task<TEventArgs> Start(
Action<TEventHandler> subscribe,
Action action,
Action<TEventHandler> unsubscribe,
CancellationToken cancellationToken)
{
subscribe(_eventHandler);
try
{
using(cancellationToken.Register(() => _completionSource.SetCanceled()))
{
action();
return await _completionSource.Task;
}
}
finally
{
unsubscribe(_eventHandler);
}
}
}
Now you have a WithHandlerConversion
helper method, which can infer type parameter from converter argument, which means you need to write WebBrowserDocumentCompletedEventHandler
only one time.
Usage:
await TaskExt
.FromEvent<WebBrowserDocumentCompletedEventArgs>()
.WithHandlerConversion(handler => new WebBrowserDocumentCompletedEventHandler(handler))
.Start(
handler => this.webBrowser.DocumentCompleted += handler,
() => this.webBrowser.Navigate(@"about:blank"),
handler => this.webBrowser.DocumentCompleted -= handler,
CancellationToken.None);
I think the following version might be satisfactory enough. I did borrow the idea of preparing a correctly typed event handler from max's answer, but this implementation doesn't create any additional object explicitly.
As a positive side effect, it allows the caller to cancel or reject the result of the operation (with an exception), based upon the event's arguments (like AsyncCompletedEventArgs.Cancelled
, AsyncCompletedEventArgs.Error
).
The underlying TaskCompletionSource
is still completely hidden from the caller (so it could be replaced with something else, e.g. a custom awaiter or a custom promise):
private async void Form1_Load(object sender, EventArgs e)
{
await TaskExt.FromEvent<WebBrowserDocumentCompletedEventHandler, EventArgs>(
getHandler: (completeAction, cancelAction, rejectAction) =>
(eventSource, eventArgs) => completeAction(eventArgs),
subscribe: eventHandler =>
this.webBrowser.DocumentCompleted += eventHandler,
unsubscribe: eventHandler =>
this.webBrowser.DocumentCompleted -= eventHandler,
initiate: (completeAction, cancelAction, rejectAction) =>
this.webBrowser.Navigate("about:blank"),
token: CancellationToken.None);
this.webBrowser.Document.InvokeScript("setTimeout",
new[] { "document.body.style.backgroundColor = 'yellow'", "1" });
}
public static class TaskExt
{
public static async Task<TEventArgs> FromEvent<TEventHandler, TEventArgs>(
Func<Action<TEventArgs>, Action, Action<Exception>, TEventHandler> getHandler,
Action<TEventHandler> subscribe,
Action<TEventHandler> unsubscribe,
Action<Action<TEventArgs>, Action, Action<Exception>> initiate,
CancellationToken token = default) where TEventHandler : Delegate
{
var tcs = new TaskCompletionSource<TEventArgs>();
Action<TEventArgs> complete = args => tcs.TrySetResult(args);
Action cancel = () => tcs.TrySetCanceled();
Action<Exception> reject = ex => tcs.TrySetException(ex);
TEventHandler handler = getHandler(complete, cancel, reject);
subscribe(handler);
try
{
using (token.Register(() => tcs.TrySetCanceled(),
useSynchronizationContext: false))
{
initiate(complete, cancel, reject);
return await tcs.Task;
}
}
finally
{
unsubscribe(handler);
}
}
}
This actually can be used to await any callback, not just event handlers, e.g.:
var mre = new ManualResetEvent(false);
RegisteredWaitHandle rwh = null;
await TaskExt.FromEvent<WaitOrTimerCallback, bool>(
(complete, cancel, reject) =>
(state, timeout) => { if (!timeout) complete(true); else cancel(); },
callback =>
rwh = ThreadPool.RegisterWaitForSingleObject(mre, callback, null, 1000, true),
callback =>
rwh.Unregister(mre),
(complete, cancel, reject) =>
ThreadPool.QueueUserWorkItem(state => { Thread.Sleep(500); mre.Set(); }),
CancellationToken.None);
Updated, less boilerplate for a simple event case (I use this one more often these days):
public static async Task<TEventArgs> FromEvent<TEventHandler, TEventArgs>(
Action<TEventHandler> subscribe,
Action<TEventHandler> unsubscribe,
CancellationToken token = default,
bool runContinuationsAsynchronously = true)
where TEventHandler : Delegate
where TEventArgs: EventArgs
{
var tcs = new TaskCompletionSource<TEventArgs>(runContinuationsAsynchronously ?
TaskCreationOptions.RunContinuationsAsynchronously :
TaskCreationOptions.None);
var handler = new Action<object?, TEventArgs>((_, args) => tcs.TrySetResult(args));
var h = (TEventHandler)Delegate.CreateDelegate(typeof(TEventHandler), handler.Target, handler.Method);
subscribe(h);
try
{
using (token.Register(() => tcs.TrySetCanceled(), useSynchronizationContext: false))
{
return await tcs.Task;
}
}
finally
{
unsubscribe(h);
}
}
Usage:
await TaskExt.FromEvent<FormClosedEventHandler, FormClosedEventArgs>(
h => mainForm.FormClosed += h,
h => mainForm.FormClosed -= h,
token);