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();
}
}
}