2

I have an Excel Ribbon via VSTO. When a button is clicked, some processing happens and rows are populated on current sheet. During this process, Excel locks - the user cannot keep working with their program. My workaround involves implementing an async solution as follows:

 // button1 click handler
 private async void button1_Click(object sender, RibbonControlEventArgs e)
 {
     await Task.Run(new Action(func));
 }

 // simple func
 void func()
 {
    var currSheet = (Worksheet) Globals.ThisAddIn.Application.ActiveSheet;
    int rowSize = 50;
    int colSize = 50;

    for (int i = 1; i <= rowSize ; i++)
        for (int j = 1; j <= colSize ; j++)
            ((Range) activeSheet.Cells[i, j]).Value2 = "sample";
 }

One big problem with this approach is that when a user is clicking, the following error pops up:

System.Runtime.InteropServices.COMException: 'Exception from HRESULT: 0x800AC472'

however, interactions with keyboard do not trigger such an event.

I am unsure how to debug this error, but it leads me into asking a few questions:

  • Am I following good practice in my technique for asynchronous interactions?
  • Are there some limitations with asynchronous interactions in the VSTO context? I know there were some discussions in the past, however, an updated discussion in 2018 would be worthwhile.
Zeruno
  • 1,391
  • 2
  • 20
  • 39
  • You **must use the same thread** that created the COM component for **all calls**. – Enigmativity Aug 07 '18 at 09:52
  • So you are saying that there is no way to have an asynchronous button click interaction? And if a button click starts a long process, Excel is locked for this whole period? – Zeruno Aug 07 '18 at 10:08
  • If Excel is created on the UI thread, then yes. Alternatively, if you create it on a background thread and you continue to use that thread for all future actions then the UI remains free. – Enigmativity Aug 07 '18 at 10:30
  • How can one select which thread to create their Excel on? Maybe I'm misunderstanding, but isn't that something which only Microsoft can control - and not the VSTO programmer? If you are referring to placing the lengthy processing on a background thread - that's exactly what the example in my question tries to do and it doesn't work out for some reason. – Zeruno Aug 07 '18 at 11:15
  • You could use Microsoft's Reactive Framework (NuGet "System.Reactive") and inside there there's a class called `System.Reactive.Concurrency.EventLoopScheduler` that can be used to create a thread that persists between calls. You can then call it like: `var els = new System.Reactive.Concurrency.EventLoopScheduler(); els.Schedule(() => { /* Do something */ });`. All calls to `.Schedule` will run on the same thread. – Enigmativity Aug 07 '18 at 12:36
  • @Enigmativity, I've had some time to use the Reactive Framework as you recommended, but the same error pops up under the same experiment. – Zeruno Aug 10 '18 at 12:16
  • You're using the `EventLoopScheduler` to both create the instance of Excel and to call any methods on it? – Enigmativity Aug 10 '18 at 13:08
  • What I've done is made the program behave in this way: EventLoopScheduler is instantiated in the VSTO Ribbon. On button click, schedule my logic (i.e. the logic which I don't want Excel to lock up while executing) on this EventLoopScheduler. – Zeruno Aug 10 '18 at 14:22
  • Sorry, you haven't answered my question: You're using the `EventLoopScheduler` to both **create the instance** of Excel and to **call any methods** on it? – Enigmativity Aug 11 '18 at 00:25

1 Answers1

0

It may be 2018 but the underlying architecture has not changed, multi-threading is still not recommended.

Now, despite that, there is a way. Here's is the best resource I know of with respect to doing it correctly... but it warns you up front:

First a warning: this is an advanced scenario, and you should not attempt to use this technique unless you’re sure you know what you’re doing. The reason for this warning is that while the technique described here is pretty simple, it’s also easy to get wrong in ways that could interfere significantly with the host application.

And the rest:

Problem description: you build an Office add-in that periodically makes calls back into the host object model. Sometimes the calls will fail, because the host is busy doing other things. Perhaps it is recalculating the worksheet; or (most commonly), perhaps it is showing a modal dialog and waiting for user input before it can continue.

If you don’t create any background threads in your add-in, and therefore make all OM calls on the same thread your add-in was created on, your call won’t fail, it simply won’t be invoked until the host is unblocked. Then, it will be processed in sequence. This is the normal case, and it is recommended that this is how you design your Office solutions in most scenarios – that is, without creating any new threads.

However, if you do create additional threads, and attempt to make OM calls on any of those threads, then the calls will simply fail if the host is blocked. You’ll get a COMException, typically something like this: System.Runtime.InteropServices.COMException, Exception from HRESULT: 0x800AC472.

To fix this, you could implement IMessageFilter in your add-in, and register the message filter on your additional thread. If you do this, and Excel is busy when you make a call on that thread, then COM will call back on your implementation of IMessageFilter.RetryRejectedCall. This gives you an opportunity to handle the failed call – either by retrying it, and/or by taking some other mitigating action, such as displaying a message box to tell the user to close any open dialogs if they want your operation to continue.

Note that there are 2 IMessageFilter interfaces commonly defined. One is in System.Windows.Forms – you don’t want that one. Instead, you want the one defined in objidl.h, which you’ll need to import like this:

[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct INTERFACEINFO
{
    [MarshalAs(UnmanagedType.IUnknown)]
    public object punk;
    public Guid iid;
    public ushort wMethod;
}

[ComImport, ComConversionLoss, InterfaceType((short)1), Guid("00000016-0000-0000-C000-000000000046")]
public interface IMessageFilter
{
    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall,
        MethodCodeType = MethodCodeType.Runtime)]
    int HandleInComingCall(
        [In] uint dwCallType,
        [In] IntPtr htaskCaller,
        [In] uint dwTickCount,
        [In, MarshalAs(UnmanagedType.LPArray)] INTERFACEINFO[] lpInterfaceInfo);

    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall,
        MethodCodeType = MethodCodeType.Runtime)]
    int RetryRejectedCall(
        [In] IntPtr htaskCallee,
        [In] uint dwTickCount,
        [In] uint dwRejectType);

    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall,
        MethodCodeType = MethodCodeType.Runtime)]
    int MessagePending(
        [In] IntPtr htaskCallee,
        [In] uint dwTickCount,
        [In] uint dwPendingType);
}

Then, implement this interface in your ThisAddIn class. Note that IMessageFilter is also implemented on the server (that is, in Excel, in our example), and that the IMessageFilter.HandleInComingCall call is only made on the server. The other 2 methods will be called on the client (that is, our add-in, in this example). We’ll get MessagePending calls after an application has made a COM method call and a Windows message occurs before the call has returned. The important method is RetryRejectedCall. In the implementation below, we display a message box asking the user whether or not they want to retry the operation. If they say “Yes”, we return 1, otherwise -1. COM expects the following return values from this call:

  • -1: the call should be canceled. COM then returns RPC_E_CALL_REJECTED from the original method call.
  • Value >= 0 and <100: the call is to be retried immediately.
  • Value >= 100: COM will wait for this many milliseconds and then retry the call.
public int HandleInComingCall([In] uint dwCallType, [In] IntPtr htaskCaller, [In] uint dwTickCount,
    [In, MarshalAs(UnmanagedType.LPArray)] INTERFACEINFO[] lpInterfaceInfo)
{
    Debug("HandleInComingCall");
    return 1;
}

public int RetryRejectedCall([In] IntPtr htaskCallee, [In] uint dwTickCount, [In] uint dwRejectType)
{
    int retVal = -1;
    Debug.WriteLine("RetryRejectedCall");
    if (MessageBox.Show("retry?", "Alert", MessageBoxButtons.YesNo) == DialogResult.Yes)
    {
        retVal = 1;
    }
    return retVal;
}

public int MessagePending([In] IntPtr htaskCallee, [In] uint dwTickCount, [In] uint dwPendingType)
{
    Debug("MessagePending");
    return 1;
}

Finally, register your message filter with COM, using CoRegisterMessageFilter. Message filters are per-thread, so you must register the filter on the background thread that you create to make the OM call. In the example below, the add-in provides a method InvokeAsyncCallToExcel, which will be invoked from a Ribbon Button. In this method, we create a new thread and make sure this is an STA thread. In my example, the thread procedure, RegisterFilter, does the work of registering the filter – and it then sleeps for 3 seconds to give the user a chance to do something that will block – such as pop up a dialog in Excel. This is clearly just for demo purposes, so that you can see what happens when Excel blocks just before a background thread call is made. The CallExcel method makes the call on Excel’s OM.

[DllImport("ole32.dll")]
static extern int CoRegisterMessageFilter(IMessageFilter lpMessageFilter, out IMessageFilter lplpMessageFilter);

private IMessageFilter oldMessageFilter;
internal void InvokeAsyncCallToExcel()
{
    Thread t = new Thread(this.RegisterFilter);
    t.SetApartmentState(ApartmentState.STA);
    t.Start();
}

private void RegisterFilter()
{
    CoRegisterMessageFilter(this, out oldMessageFilter);
    Thread.Sleep(3000);
    CallExcel();
}

private void CallExcel()
{
    try
    {
        this.Application.ActiveCell.Value2 = DateTime.Now.ToShortTimeString();
    }
    catch (Exception ex)
    {
        Debug.WriteLine(ex.ToString());
    }
}

Note I've changed the return types from uint to int as the original code did not compile. I've tried this in Word and it does work, but I have not included it in my software, mainly because I'm not sure of the ways in which this can blow up. The author doesn't say.

Chris
  • 3,400
  • 1
  • 27
  • 41
  • "multi-threading is still not recommended" - No, it's not allowed. That's the memory model for COM calls to Excel. – Enigmativity Aug 07 '18 at 09:52
  • 1
    @Enigmativity I don't understand your comment. The example works, so it is allowed, unless we're using different definitions of allow. I would love more to hear more about why this should be avoided. – Chris Aug 07 '18 at 20:31
  • @Chris, I am understanding that the MessageFilter lets you intercept when there is an operation which was blocked, and then lets you retry it or handle it gracefully. But there does not seem to be an option to continue whatever task was interrupted? If I have a call to a web service which takes some time to give me back a response, and it gets blocked, retrying it rather than being able to handle the response, is not ideal. – Zeruno Aug 13 '18 at 14:39
  • I think returning a value that is >100 in `RetryRejectedCall` would be the closest thing to that behavior that you could do. – Chris Aug 13 '18 at 15:53
  • I've followed the MessageFilter implementation in some detail and it seems that my API call is not trigerring an error while on another thread. But trying to interact with with Excel (e.g. creating a new sheet) while Excel is populating cells (an instruction from another thread) is giving me the same problems as described in the original question. The MessageFilter does not seem to be handling such kind of errors despite the error code being the same. Honestly, I'm a bit lost at the moment and it seems hopeless. – Zeruno Aug 14 '18 at 12:16
  • Well, maybe there are some complexities the author did not mention. I would not be surprised. The example he gives is a really simple test case. – Chris Aug 14 '18 at 16:55
  • https://learn.microsoft.com/en-us/visualstudio/vsto/threading-support-in-office?view=vs-2017 "in the case of solutions created by using the Office development tools in Visual Studio, COM interop converts all rejected calls to a COMException" I am suspecting that this must be why I could not make the MessageFilter work - all calls were being rejected as the exception highlighted in my question; what do you think? If all calls are being rejected, then is the MessageFilter redundant in a VSTO project? – Zeruno Sep 05 '18 at 18:05
  • I doubt it, it also says this at the end of that paragraph: "However, you can also create the background thread as STA and then register a message filter for that thread to handle this case." – Chris Sep 06 '18 at 13:55