General purpose FromEvent method
Asked Answered
W

4

8

Using the new async/await model it's fairly straightforward to generate a Task that is completed when an event fires; you just need to follow this pattern:

public class MyClass
{
    public event Action OnCompletion;
}

public static Task FromEvent(MyClass obj)
{
    TaskCompletionSource<object> tcs = new TaskCompletionSource<object>();

    obj.OnCompletion += () =>
        {
            tcs.SetResult(null);
        };

    return tcs.Task;
}

This then allows:

await FromEvent(new MyClass());

The problem is that you need to create a new FromEvent method for every event in every class that you would like to await on. That could get really large really quick, and it's mostly just boilerplate code anyway.

Ideally I would like to be able to do something like this:

await FromEvent(new MyClass().OnCompletion);

Then I could re-use the same FromEvent method for any event on any instance. I've spent some time trying to create such a method, and there are a number of snags. For the code above it will generate the following error:

The event 'Namespace.MyClass.OnCompletion' can only appear on the left hand side of += or -=

As far as I can tell, there won't ever be a way of passing the event like this through code.

So, the next best thing seemed to be trying to pass the event name as a string:

await FromEvent(new MyClass(), "OnCompletion");

It's not as ideal; you don't get intellisense and would get a runtime error if the event doesn't exist for that type, but it could still be more useful than tons of FromEvent methods.

So it's easy enough to use reflection and GetEvent(eventName) to get the EventInfo object. The next problem is that the delegate of that event isn't known (and needs to be able to vary) at runtime. That makes adding an event handler hard, because we need to dynamically create a method at runtime, matching a given signature (but ignoring all parameters) that accesses a TaskCompletionSource that we already have and sets its result.

Fortunately I found this link which contains instructions on how to do [almost] exactly that via Reflection.Emit. Now the problem is that we need to emit IL, and I have no idea how to access the tcs instance that I have.

Below is the progress that I've made towards finishing this:

public static Task FromEvent<T>(this T obj, string eventName)
{
    var tcs = new TaskCompletionSource<object>();
    var eventInfo = obj.GetType().GetEvent(eventName);

    Type eventDelegate = eventInfo.EventHandlerType;

    Type[] parameterTypes = GetDelegateParameterTypes(eventDelegate);
    DynamicMethod handler = new DynamicMethod("unnamed", null, parameterTypes);

    ILGenerator ilgen = handler.GetILGenerator();

    //TODO ilgen.Emit calls go here

    Delegate dEmitted = handler.CreateDelegate(eventDelegate);

    eventInfo.AddEventHandler(obj, dEmitted);

    return tcs.Task;
}

What IL could I possibly emit that would allow me to set the result of the TaskCompletionSource? Or, alternatively, is there another approach to creating a method that returns a Task for any arbitrary event from an arbitrary type?

Witchcraft answered 12/10, 2012 at 19:31 Comment(2)
Note that the BCL has TaskFactory.FromAsync to easily translate from APM to TAP. There isn't an easy and generic way to translate from EAP to TAP, so I think that's why MS didn't include a solution like this. I find Rx (or TPL Dataflow) to be a closer match to "event" semantics anyway - and Rx does have a FromEvent kind of method.Sardinian
I also wanted to make a generic FromEvent<>, and this is is close as I could get to that without using reflection.Isothermal
C
25

Here you go:

internal class TaskCompletionSourceHolder
{
    private readonly TaskCompletionSource<object[]> m_tcs;

    internal object Target { get; set; }
    internal EventInfo EventInfo { get; set; }
    internal Delegate Delegate { get; set; }

    internal TaskCompletionSourceHolder(TaskCompletionSource<object[]> tsc)
    {
        m_tcs = tsc;
    }

    private void SetResult(params object[] args)
    {
        // this method will be called from emitted IL
        // so we can set result here, unsubscribe from the event
        // or do whatever we want.

        // object[] args will contain arguments
        // passed to the event handler
        m_tcs.SetResult(args);
        EventInfo.RemoveEventHandler(Target, Delegate);
    }
}

public static class ExtensionMethods
{
    private static Dictionary<Type, DynamicMethod> s_emittedHandlers =
        new Dictionary<Type, DynamicMethod>();

    private static void GetDelegateParameterAndReturnTypes(Type delegateType,
        out List<Type> parameterTypes, out Type returnType)
    {
        if (delegateType.BaseType != typeof(MulticastDelegate))
            throw new ArgumentException("delegateType is not a delegate");

        MethodInfo invoke = delegateType.GetMethod("Invoke");
        if (invoke == null)
            throw new ArgumentException("delegateType is not a delegate.");

        ParameterInfo[] parameters = invoke.GetParameters();
        parameterTypes = new List<Type>(parameters.Length);
        for (int i = 0; i < parameters.Length; i++)
            parameterTypes.Add(parameters[i].ParameterType);

        returnType = invoke.ReturnType;
    }

    public static Task<object[]> FromEvent<T>(this T obj, string eventName)
    {
        var tcs = new TaskCompletionSource<object[]>();
        var tcsh = new TaskCompletionSourceHolder(tcs);

        EventInfo eventInfo = obj.GetType().GetEvent(eventName);
        Type eventDelegateType = eventInfo.EventHandlerType;

        DynamicMethod handler;
        if (!s_emittedHandlers.TryGetValue(eventDelegateType, out handler))
        {
            Type returnType;
            List<Type> parameterTypes;
            GetDelegateParameterAndReturnTypes(eventDelegateType,
                out parameterTypes, out returnType);

            if (returnType != typeof(void))
                throw new NotSupportedException();

            Type tcshType = tcsh.GetType();
            MethodInfo setResultMethodInfo = tcshType.GetMethod(
                "SetResult", BindingFlags.NonPublic | BindingFlags.Instance);

            // I'm going to create an instance-like method
            // so, first argument must an instance itself
            // i.e. TaskCompletionSourceHolder *this*
            parameterTypes.Insert(0, tcshType);
            Type[] parameterTypesAr = parameterTypes.ToArray();

            handler = new DynamicMethod("unnamed",
                returnType, parameterTypesAr, tcshType);

            ILGenerator ilgen = handler.GetILGenerator();

            // declare local variable of type object[]
            LocalBuilder arr = ilgen.DeclareLocal(typeof(object[]));
            // push array's size onto the stack 
            ilgen.Emit(OpCodes.Ldc_I4, parameterTypesAr.Length - 1);
            // create an object array of the given size
            ilgen.Emit(OpCodes.Newarr, typeof(object));
            // and store it in the local variable
            ilgen.Emit(OpCodes.Stloc, arr);

            // iterate thru all arguments except the zero one (i.e. *this*)
            // and store them to the array
            for (int i = 1; i < parameterTypesAr.Length; i++)
            {
                // push the array onto the stack
                ilgen.Emit(OpCodes.Ldloc, arr);
                // push the argument's index onto the stack
                ilgen.Emit(OpCodes.Ldc_I4, i - 1);
                // push the argument onto the stack
                ilgen.Emit(OpCodes.Ldarg, i);

                // check if it is of a value type
                // and perform boxing if necessary
                if (parameterTypesAr[i].IsValueType)
                    ilgen.Emit(OpCodes.Box, parameterTypesAr[i]);

                // store the value to the argument's array
                ilgen.Emit(OpCodes.Stelem, typeof(object));
            }

            // load zero-argument (i.e. *this*) onto the stack
            ilgen.Emit(OpCodes.Ldarg_0);
            // load the array onto the stack
            ilgen.Emit(OpCodes.Ldloc, arr);
            // call this.SetResult(arr);
            ilgen.Emit(OpCodes.Call, setResultMethodInfo);
            // and return
            ilgen.Emit(OpCodes.Ret);

            s_emittedHandlers.Add(eventDelegateType, handler);
        }

        Delegate dEmitted = handler.CreateDelegate(eventDelegateType, tcsh);
        tcsh.Target = obj;
        tcsh.EventInfo = eventInfo;
        tcsh.Delegate = dEmitted;

        eventInfo.AddEventHandler(obj, dEmitted);
        return tcs.Task;
    }
}

This code will work for almost all events that return void (regardless of the parameter list).

It can be improved to support any return values if necessary.

You can see the difference between Dax's and mine methods below:

static async void Run() {
    object[] result = await new MyClass().FromEvent("Fired");
    Console.WriteLine(string.Join(", ", result.Select(arg =>
        arg.ToString()).ToArray())); // 123, abcd
}

public class MyClass {
    public delegate void TwoThings(int x, string y);

    public MyClass() {
        new Thread(() => {
                Thread.Sleep(1000);
                Fired(123, "abcd");
            }).Start();
    }

    public event TwoThings Fired;
}

Briefly, my code supports really any kind of delegate type. You shouldn't (and don't need to) specify it explicitly like TaskFromEvent<int, string>.

Commiserate answered 12/10, 2012 at 22:21 Comment(5)
I just finished looking over your update and playing around with it a bit. I'm really liking it. The event handler is unsubscribed, which is a great touch. The various event handlers are cached, so IL isn't generated repeatedly for the same types, and, unlike other solutions, there is no need to specify the types of arguments to the event handler.Witchcraft
I could not make the code work on windows phone, do not know if it's a security issue. But not worked.. Exception: {"Attempt to access the method failed: System.Reflection.Emit.DynamicMethod..ctor(System.String, System.Type, System.Type[], System.Type)"}Lauderdale
@J.Lennon Unfortunately, I'm not able to test it on the Windows Phone. So I'll be really grateful if you could try to use this updated version and let me know if it helps. Thanks in advance.Commiserate
@J.Lennon I think Servy covered that in his subsequent comment. There are probably some performance differences too (no idea which would be faster in which scenarios without profiling it), but event subscribes or triggers are unlikely to be a bottleneck anyway.Nicholas
Do you know if it would be possible to simplify this with expression trees, or do you need the low-level ilgen?Nicholas
N
6

This will give you what you need without needing to do any ilgen, and way simpler. It works with any kind of event delegates; you just have to create a different handler for each number of parameters in your event delegate. Below are the handlers you'd need for 0..2, which should be the vast majority of your use cases. Extending to 3 and above is a simple copy and paste from the 2-parameter method.

This is also more powerful than the ilgen method because you can use any values created by the event in your async pattern.

// Empty events (Action style)
static Task TaskFromEvent(object target, string eventName) {
    var addMethod = target.GetType().GetEvent(eventName).GetAddMethod();
    var delegateType = addMethod.GetParameters()[0].ParameterType;
    var tcs = new TaskCompletionSource<object>();
    var resultSetter = (Action)(() => tcs.SetResult(null));
    var d = Delegate.CreateDelegate(delegateType, resultSetter, "Invoke");
    addMethod.Invoke(target, new object[] { d });
    return tcs.Task;
}

// One-value events (Action<T> style)
static Task<T> TaskFromEvent<T>(object target, string eventName) {
    var addMethod = target.GetType().GetEvent(eventName).GetAddMethod();
    var delegateType = addMethod.GetParameters()[0].ParameterType;
    var tcs = new TaskCompletionSource<T>();
    var resultSetter = (Action<T>)tcs.SetResult;
    var d = Delegate.CreateDelegate(delegateType, resultSetter, "Invoke");
    addMethod.Invoke(target, new object[] { d });
    return tcs.Task;
}

// Two-value events (Action<T1, T2> or EventHandler style)
static Task<Tuple<T1, T2>> TaskFromEvent<T1, T2>(object target, string eventName) {
    var addMethod = target.GetType().GetEvent(eventName).GetAddMethod();
    var delegateType = addMethod.GetParameters()[0].ParameterType;
    var tcs = new TaskCompletionSource<Tuple<T1, T2>>();
    var resultSetter = (Action<T1, T2>)((t1, t2) => tcs.SetResult(Tuple.Create(t1, t2)));
    var d = Delegate.CreateDelegate(delegateType, resultSetter, "Invoke");
    addMethod.Invoke(target, new object[] { d });
    return tcs.Task;
}

Use would be like this. As you can see, even though the event is defined in a custom delegate, it still works. And you can capture the evented values as a tuple.

static async void Run() {
    var result = await TaskFromEvent<int, string>(new MyClass(), "Fired");
    Console.WriteLine(result); // (123, "abcd")
}

public class MyClass {
    public delegate void TwoThings(int x, string y);

    public MyClass() {
        new Thread(() => {
            Thread.Sleep(1000);
            Fired(123, "abcd");
        }).Start();
    }

    public event TwoThings Fired;
}

Here's a helper function that'll allow you to write the TaskFromEvent functions in just one line each, if the above three methods are too much copy-and-paste for your preferences. Credit has to be given to max for simplifying what I had originally.

Nicholas answered 12/10, 2012 at 19:31 Comment(1)
Thansk a lot!!! For windows phone, this line has to be modified: var parameters = methodInfo.GetParameters().Select(a => System.Linq.Expressions.Expression.Parameter(a.ParameterType, a.Name)).ToArray();Lauderdale
S
2

If you're willing to have one method per delegate type, you can do something like:

Task FromEvent(Action<Action> add)
{
    var tcs = new TaskCompletionSource<bool>();

    add(() => tcs.SetResult(true));

    return tcs.Task;
}

You would use it like:

await FromEvent(x => new MyClass().OnCompletion += x);

Be aware that this way you never unsubscribe from the event, that may or may not be a problem for you.

If you're using generic delegates, one method per each generic type is enough, you don't need one for each concrete type:

Task<T> FromEvent<T>(Action<Action<T>> add)
{
    var tcs = new TaskCompletionSource<T>();

    add(x => tcs.SetResult(x));

    return tcs.Task;
}

Although type inference doesn't work with that, you have to explicitly specify the type parameter (assuming the type of OnCompletion is Action<string> here):

string s = await FromEvent<string>(x => c.OnCompletion += x);
Supercilious answered 12/10, 2012 at 20:27 Comment(2)
The main problem there is that so many of the UI frameworks create their own delegate types for each event (rather than using Action<T>/EventHandler<T>), and that's where something like this would be most useful, so creating a FromEvent method for each delegate type would be better, but still not perfect. That said, you could just have the first method you made and use: await FromEvent(x => new MyClass().OnCompletion += (a,b)=> x()); on any event. It's sort of a half-way-there solution.Witchcraft
@Witchcraft Yeah, I though about doing it that way too, but I didn't mention it because I think it's ugly (i.e. too much boilerplate).Supercilious
L
-1

I faced this problem by trying to write GetAwaiter extension method for System.Action, forgetting that System.Action is immutable and by passing it as an argument you make a copy. However, you do not make a copy if you pass it with ref keyword, thus:

public static class AwaitExtensions
{ 
    public static Task FromEvent(ref Action action)
    {
        TaskCompletionSource<object> tcs = new TaskCompletionSource<object>();
        action += () => tcs.SetResult(null);
        return tcs.Task;
    }
}

Usage:

await AwaitExtensions.FromEvent(ref OnActionFinished);

Note: TCS listener remain subscribed

Launder answered 24/3, 2020 at 19:12 Comment(2)
This method cannot by used for awaiting events of other classes. It can only be used from within the current class. Otherwise you'll get a compile-time error: The event 'MyClass.OnActionFinished' can only appear on the left hand side of += or -= (except when used from within the type 'MyClass')Hemialgia
Unfortunately so. Adding a line of code otherClass.OnAction += () => OnActionFinished?.Invoke(); before await was convenient in my case.Launder

© 2022 - 2024 — McMap. All rights reserved.