Await and SynchronizationContext in a managed component hosted by an unmanaged app

This is going to be a bit long. First of all, thanks Matt Smith and Hans Passant for your ideas, they have been very helpful.

The problem was caused by a good old friend, Application.DoEvents, although in a novelty way. Hans has an excellent post about why DoEvents is an evil. Unfortunately, I’m unable to avoid using DoEvents in this control, because of the synchronous API restrictions posed by the legacy unmanaged host app (more about it at the end). I’m well aware of the existing implications of DoEvents, but here I believe we have a new one:

On a thread without explicit WinForms message loop (i.e., any thread which hasn’t entered Application.Run or Form.ShowDialog), calling Application.DoEvents will replace the current synchronization context with the default SynchronizationContext, provided WindowsFormsSynchronizationContext.AutoInstall is true (which is so by default).

If it is not a bug, then it’s a very unpleasant undocumented behavior which may seriously affect some component developers.

Here is a simple console STA app reproducing the problem. Note how WindowsFormsSynchronizationContext gets (incorrectly) replaced with SynchronizationContext in the first pass of Test and does not in the second pass.

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace ConsoleApplication
{
    class Program
    {
        [STAThreadAttribute]
        static void Main(string[] args)
        {
            Debug.Print("ApartmentState: {0}", Thread.CurrentThread.ApartmentState.ToString());
            Debug.Print("*** Test 1 ***");
            Test();
            SynchronizationContext.SetSynchronizationContext(null);
            WindowsFormsSynchronizationContext.AutoInstall = false;
            Debug.Print("*** Test 2 ***");
            Test();
        }

        static void DumpSyncContext(string id, string message, object ctx)
        {
            Debug.Print("{0}: {1} ({2})", id, ctx != null ? ctx.GetType().Name : "null", message);
        }

        static void Test()
        {
            Debug.Print("WindowsFormsSynchronizationContext.AutoInstall: {0}", WindowsFormsSynchronizationContext.AutoInstall);
            var ctx1 = SynchronizationContext.Current;
            DumpSyncContext("ctx1", "before setting up the context", ctx1);

            if (!(ctx1 is WindowsFormsSynchronizationContext))
                SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext());

            var ctx2 = SynchronizationContext.Current;
            DumpSyncContext("ctx2", "before Application.DoEvents", ctx2);

            Application.DoEvents();

            var ctx3 = SynchronizationContext.Current;
            DumpSyncContext("ctx3", "after Application.DoEvents", ctx3);

            Debug.Print("ctx3 == ctx1: {0}, ctx3 == ctx2: {1}", ctx3 == ctx1, ctx3 == ctx2);
        }
    }
}

Debug output:

ApartmentState: STA
*** Test 1 ***
WindowsFormsSynchronizationContext.AutoInstall: True
ctx1: null (before setting up the context)
ctx2: WindowsFormsSynchronizationContext (before Application.DoEvents)
ctx3: SynchronizationContext (after Application.DoEvents)
ctx3 == ctx1: False, ctx3 == ctx2: False
*** Test 2 ***
WindowsFormsSynchronizationContext.AutoInstall: False
ctx1: null (before setting up the context)
ctx2: WindowsFormsSynchronizationContext (before Application.DoEvents)
ctx3: WindowsFormsSynchronizationContext (after Application.DoEvents)
ctx3 == ctx1: False, ctx3 == ctx2: True

It took some investigation of the Framework’s implementation of Application.ThreadContext.RunMessageLoopInner and WindowsFormsSynchronizationContext.InstalIifNeeded/Uninstall to understand why exactly it happens. The condition is that the thread doesn’t currently execute an Application message loop, as mentioned above. The relevant piece from RunMessageLoopInner:

if (this.messageLoopCount == 1)
{
    WindowsFormsSynchronizationContext.InstallIfNeeded();
}

Then the code inside WindowsFormsSynchronizationContext.InstallIfNeeded/Uninstall pair of methods doesn’t save/restore the thread’s existing synchronization context correctly. At this point, I’m not sure if it’s a bug or a design feature.

The solution is to disable WindowsFormsSynchronizationContext.AutoInstall, as simple as this:

struct SyncContextSetup
{
    public SyncContextSetup(bool autoInstall)
    {
        WindowsFormsSynchronizationContext.AutoInstall = autoInstall;
        SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext());
    }
}

static readonly SyncContextSetup _syncContextSetup =
    new SyncContextSetup(autoInstall: false);

A few words about why I use Application.DoEvents in the first place here. It’s a typical asynchronous-to-synchronous bridge code running on the UI thread, using a nested message loop. This is a bad practice, but the legacy host app expects all APIs to complete synchronously. The original problem is described here. At some later point, I replaced CoWaitForMultipleHandles with a combination of Application.DoEvents/MsgWaitForMultipleObjects, which now looks like this:

[EDITED] The most recent version of WaitWithDoEvents is here. [/EDITED]

The idea was to dispatch messages using .NET standard mechanism, rather than relying upon CoWaitForMultipleHandles to do so. That’s when I implicitly introduced the problem with the synchronization context, due to the described behavior of DoEvents.

The legacy app is currently being rewritten using modern technologies, and so is the control. The current implementation is aimed for existing customers with Windows XP who cannot upgrade for reasons beyond our control.

Finally, here’s the implementation of the custom awaiter which I mentioned in my question as an option to mitigate the problem. It was an interesting experience and it works, but it cannot be considered a proper solution.

/// <summary>
/// AwaitHelpers - custom awaiters
/// WithContext continues on the control's thread after await
/// E.g.: await TaskEx.Delay(1000).WithContext(this)
/// </summary>
public static class AwaitHelpers
{
    public static ContextAwaiter<T> WithContext<T>(this Task<T> task, Control control, bool alwaysAsync = false)
    {
        return new ContextAwaiter<T>(task, control, alwaysAsync);
    }

    // ContextAwaiter<T>
    public class ContextAwaiter<T> : INotifyCompletion
    {
        readonly Control _control;
        readonly TaskAwaiter<T> _awaiter;
        readonly bool _alwaysAsync;

        public ContextAwaiter(Task<T> task, Control control, bool alwaysAsync)
        {
            _awaiter = task.GetAwaiter();
            _control = control;
            _alwaysAsync = alwaysAsync;
        }

        public ContextAwaiter<T> GetAwaiter() { return this; }

        public bool IsCompleted { get { return !_alwaysAsync && _awaiter.IsCompleted; } }

        public void OnCompleted(Action continuation)
        {
            if (_alwaysAsync || _control.InvokeRequired)
            {
                Action<Action> callback = (c) => _awaiter.OnCompleted(c);
                _control.BeginInvoke(callback, continuation);
            }
            else
                _awaiter.OnCompleted(continuation);
        }

        public T GetResult()
        {
            return _awaiter.GetResult();
        }
    }
}

Leave a Comment