James Telfer
Posts: 9
Joined: 2020-03-13
|
As has been noted in other places on the web and in this forum, using async/await with COM can be problematic, as the code does not resume execution on the UI thread. This causes cross-thread exception in WPF and other errors in Windows Forms.
When looking into this issue with the add-in I was developing, it appears to stem from the fact that the SynchronizationContext is not set on the thread when the fired event originates from a COM context.
To fix this, it is possible to set the value of SynchronizationContext.Current to a reasonable value directly; e.g.:
SynchronizationContext.Current = new WindowsFormsSynchronizationContext()
However the simplest strategy appears to be to wrap the event handler in a lambda that causes the fired event to be invoked on the UI thread. The side effect of this is that the SynchronizationContext is then set correctly, and await'ed calls will resume on the UI thread.
_loginWebBrowser.Navigating += (_, args) => Dispatcher.Invoke(() => LoginWebBrowser_Navigating());
The above registers an event handler that simply delegates the calling of the 'real' event handler method via the WPF dispatcher. A similar strategy works for Windows Forms. |
|
Andrei Smolin
Add-in Express team
Posts: 19012
Joined: 2006-05-11
|
Hello James,
You are free to use background threads. You must not call into any Office object model when on such a thread, though. For the code to not run on a background thread, you must not create background threads explicitly nor via ConfigureAwait(false) and Task.Run().
Andrei Smolin
Add-in Express Team Leader |
|
James Telfer
Posts: 9
Joined: 2020-03-13
|
Apologies for not being clear enough, let me explain better.
If you have a WPF app, and you register a Click handler, the handler code will fire on the UI thread. If you then await an async method, the code after the await will also execute on the UI thread:
public void HandleClick(object sender, EventArgs e)
{
// we start on the UI thread
textBox.Text = "UI change is OK here";
// may take a while, and may execute on a different thread
var text = await GetRemoteText();
// when it resumes after await, it's back on the UI thread
textBox.Text = $"Remote text: ${text}"
}
The above works fine in an add in. The way it works though uses a bit of magic: the SychronizationContext. Without a WPF-aware SynchronizationContext, the code after the await wouldn't necessarily execute on the UI thread.
But as I said, even in the context of a COM add-in, we're OK so far.
However, if the event comes not from a WPF or Windows Forms object, but comes instead from a COM object: that is a problem.
public void adxOutlookAppEvents_ExplorerSelectionChange(object sender, object explorer)
{
// we start on the UI thread
textBox.Text = "UI change is OK here";
// may take a while, and may execute on a different thread
var text = await GetRemoteText();
// when it resumes after await, UI code will most likely fail
textBox.Text = $"Remote text: ${text}"
}
The COM event is fired on the UI thread, but without a SychronizationContext.Current value. This means that the code after the await will not necessarily be executed on the UI thread and will then fail.
The way to fix this is to ensure that there's a context: either set an appropriate (WPF/Windows Forms) sync context or use Dispatch (as shown above).
It is possible to wrap every change to the UI in a COM event handler after the await with a Dispatch call, but this leads to quite ugly code. Setting the SynchronizationContext at the boundary (when the event is fired) allows for natural async/await code that acts as you would normally expect of a WinForms/WPF app.
This behaviour can be observed easily by watching the SynchronizationContext.Current value in the debugger when a WPF/WinForms event is fired vs a COM event. When it's a COM event, the value is null.
It would be quite neat if Add-in Express was able to set the context when the event fired. |
|
James Telfer
Posts: 9
Joined: 2020-03-13
|
|
Andrei Smolin
Add-in Express team
Posts: 19012
Joined: 2006-05-11
|
Hello James,
Thank you for the suggestion. We will consider implementing it: we would also need to support .NET 2.0 and previous async programming models.
You can use this class to work around this:
class MySynchronizationContext : IDisposable
{
private SynchronizationContext oldContext = null;
private SynchronizationContext newContext = null;
bool restoreContext = false;
public MySynchronizationContext(bool restoreContext= false)
{
this.restoreContext = restoreContext;
oldContext = SynchronizationContext.Current;
if (!(oldContext is WindowsFormsSynchronizationContext))
{
newContext = new WindowsFormsSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(newContext);
}
}
void IDisposable.Dispose()
{
if (restoreContext)
{
SynchronizationContext.SetSynchronizationContext(oldContext);
}
}
}
Here's how you use it:
private async void DoBreakfast(object sender, EventArgs e)
{
using (MySynchronizationContext synchronization = new MySynchronizationContext())
{
Log.Print(string.Format("DoBreakfast start"));
Coffee cup = PourCoffee();
Log.Print("coffee is ready");
Task<Egg> eggsTask = FryEggs(2);
Egg eggs = await eggsTask;
Log.Print("eggs are ready");
Task<Bacon> baconTask = FryBacon(3);
Bacon bacon = await baconTask;
Log.Print("bacon is ready");
Task<Toast> toastTask = ToastBread(2);
Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Log.Print("toast is ready");
Juice oj = PourOJ();
Log.Print("oj is ready");
Log.Print("Breakfast is ready!");
}
}
Andrei Smolin
Add-in Express Team Leader |
|