[EDITED] This appears to be a bug in the Framework's implementation of Application.DoEvents, which I've reported here. Restoring a wrong synchronization context on a UI thread may seriously affect component developers like me. The goal of the bounty is to draw more attention to this problem and to reward @MattSmith whose answer helped tracking it down.
I'm responsible for a .NET WinForms UserControl
-based component exposed as ActiveX to a legacy unmanaged app, via COM interop. The runtime requirement is .NET 4.0 + Microsoft.Bcl.Async.
The component gets instantiated and used on the app's main STA UI thread. Its implementation utilizes async/await
, so it expects that an instance of a serializing synchronization context has been installed on the current thread (i. e.,WindowsFormsSynchronizationContext
).
Usually, WindowsFormsSynchronizationContext
gets set up by Application.Run
, which is where the message loop of a managed app runs. Naturally, this is not the case for the unmanaged host app, and I have no control over this. Of course, the host app still has its own classic Windows message loop, so it should not be a problem to serialize await
continuation callbacks.
However, none of the solutions I've come up with so far is perfect, or even works properly. Here's an artificial example, where Test
method is invoked by the host app:
Task testTask;
public void Test()
{
this.testTask = TestAsync();
}
async Task TestAsync()
{
Debug.Print("thread before await: {0}", Thread.CurrentThread.ManagedThreadId);
var ctx1 = SynchronizationContext.Current;
Debug.Print("ctx1: {0}", ctx1 != null? ctx1.GetType().Name: null);
if (!(ctx1 is WindowsFormsSynchronizationContext))
SynchronizationContext.SetSynchronizationContext(new WindowsFormsSynchronizationContext());
var ctx2 = SynchronizationContext.Current;
Debug.Print("ctx2: {0}", ctx2.GetType().Name);
await TaskEx.Delay(1000);
Debug.WriteLine("thread after await: {0}", Thread.CurrentThread.ManagedThreadId);
var ctx3 = SynchronizationContext.Current;
Debug.Print("ctx3: {0}", ctx3 != null? ctx3.GetType().Name: null);
Debug.Print("ctx3 == ctx1: {0}, ctx3 == ctx2: {1}", ctx3 == ctx1, ctx3 == ctx2);
}
Debug output:
thread before await: 1 ctx1: SynchronizationContext ctx2: WindowsFormsSynchronizationContext thread after await: 1 ctx3: SynchronizationContext ctx3 == ctx1: True, ctx3 == ctx2: False
Although it continues on the same thread, the WindowsFormsSynchronizationContext
context I'm installing on the current thread before await
gets reset to the default SynchronizationContext
after it, for some reason.
Why does it get reset? I've verified my component is the only .NET component being used by that app. The app itself does call CoInitialize/OleInitialize
properly.
I've also tried setting up WindowsFormsSynchronizationContext
in the constructor of a static singleton object, so it gets installed on the thread when my managed assembly gets loaded. That didn't help: when Test
is later invoked on the same thread, the context has been already reset to the default one.
I'm considering using a custom awaiter to schedule await
callbacks via control.BeginInvoke
of my control, so the above would look like await TaskEx.Delay().WithContext(control)
. That should work for my own awaits
, as long as the host app keeps pumping messages, but not for awaits
inside any of the 3rd party assemblies my assembly may be referencing.
I'm still researching this. Any ideas on how to keep the correct thread affinity for await
in this scenario would be appreciated.
The WindowsFormsSynchronizationContext gets installed automatically when a control is created unless you've turned that off. So, unless someone else stomps over it after your control is created, there is nothing special to do.
You could set a breakpoint on WindowsFormsSynchronizationContext.InstalIifNeeded to see when that happens.
See:
(I realize this doesn't answer your question, but didn't want to put this in a comment)
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 whyDoEvents
is an evil. Unfortunately, I'm unable to avoid usingDoEvents
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 ofDoEvents
, 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
orForm.ShowDialog
), callingApplication.DoEvents
will replace the current synchronization context with the defaultSynchronizationContext
, providedWindowsFormsSynchronizationContext.AutoInstall
istrue
(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 withSynchronizationContext
in the first pass ofTest
and does not in the second pass.Debug output:
It took some investigation of the Framework's implementation of
Application.ThreadContext.RunMessageLoopInner
andWindowsFormsSynchronizationContext.InstalIifNeeded
/Uninstall
to understand why exactly it happens. The condition is that the thread doesn't currently execute anApplication
message loop, as mentioned above. The relevant piece fromRunMessageLoopInner
: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: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 replacedCoWaitForMultipleHandles
with a combination ofApplication.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 ofDoEvents
.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.