Search code examples
c#wpfcom

IHTMLChangeSink UnregisterForDirtyRange throwing ComException HRESULT E_FAIL


We have a WPF with a tab control being the primary UI for displaying customer details, each open customer gets its own tab. Within the customer tabs we have another tab control that allows switching between various subsets of information, 2 of these use the Webbrowser control with IHTMLChangeSink functionality to monitor for hidden divs meant to trigger logic in the app.

Previously we were experiencing a very large memory leak when a Customer tab was closed, the cause of this was found to be the event handler created by RegisterForDirtyRange. To resolve the memory leak the Dispose methods were modified to call UnregisterForDirtyRange, using AutoIT to rapidly open and close customer tabs we were able to prove that the memory leak was fixed; this was done on a developer class machine.

Once this change was rolled out to testers we started seeing the application crash, in the event log we saw that the call to UnregisterForDirtyRange was throwing a ComException with HRESULT E_FAIL. Since we never saw this come up on the developer hardware and on the testers machines there was no guaranteed way to produce the crash I am thinking that there is some kind of race condition that is amplified when run on less powerful hardware.

Given this information my question is with regards to the internal workings of the Unregister call, can anyone think of what might be causing this exception?

My initial thought was that maybe the Notify method was running at the time of dispose so I tried introducing a lock between the dispose and notify but this didn't change anything.

Here is a stripped down version of the tab control that wraps the Web Browser:

public partial class BrowserTabWidget : BrowserWidget, IHTMLChangeSink
{
    private static Guid _markupContainer2Guid = typeof(IMarkupContainer2).GUID;
    private IMarkupContainer2 _container;
    private uint _cookie;

    public BrowserTabWidget()
    {
        InitializeComponent();
        if (!System.ComponentModel.DesignerProperties.GetIsInDesignMode(this))
        {
            Loaded += OnLoaded;
        }
    }

    protected override void DisposeControls()
    {
        if (_container != null)
        {
            _container.UnRegisterForDirtyRange(_cookie);
            Marshal.ReleaseComObject(_container);
        }
        WebBrowser.LoadCompleted -= OnWebBrowserLoadCompleted;
        WebBrowser.Dispose();
    }

    public override string CurrentUri
    {
        get { return (string)GetValue(CurrentUriProperty); }
        set
        {
            NavigateTo(value);
            SetValue(CurrentUriProperty, value);
        }
    }

    private void NavigateTo(string value)
    {
        WebBrowser.Navigate(new Uri(value));
    }

    public static readonly DependencyProperty CurrentUriProperty = DependencyProperty.Register("CurrentUri", typeof(string), typeof(BrowserTabWidget), new FrameworkPropertyMetadata(CurrentUriChanged));
    public static void CurrentUriChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var widget = (BrowserTabWidget)d;
        d.Dispatcher.BeginInvoke(
            DispatcherPriority.Normal,
            new Action(() => widget.NavigateTo(e.NewValue.ToString())));
    }

    private void InitializeWebBrowser()
    {
        WebBrowser.LoadCompleted += OnWebBrowserLoadCompleted;
        WebBrowser.Navigate(new Uri(viewModel.InitialUrl));
    }

    void OnWebBrowserLoadCompleted(object sender, System.Windows.Navigation.NavigationEventArgs e)
    {
        _container = GetMarkupContainer();
        _container.RegisterForDirtyRange(this, out _cookie);
    }

    private void OnLoaded(object sender, RoutedEventArgs e)
    {
        Loaded -= OnLoaded;
        Load();
    }

    private void Load()
    {
        InitializeWebBrowser();
    }

    private IMarkupContainer2 GetMarkupContainer()
    {
        var oDocument = WebBrowser.Document as IHTMLDocument2;
        var pDocument = Marshal.GetIUnknownForObject(oDocument);
        IntPtr pMarkupContainer;
        Marshal.QueryInterface(pDocument, ref _markupContainer2Guid, out pMarkupContainer);
        var oMarkupContainer = Marshal.GetUniqueObjectForIUnknown(pMarkupContainer);
        Marshal.Release(pDocument);
        Marshal.Release(pMarkupContainer);
        return (IMarkupContainer2)oMarkupContainer;
    }

    public void Notify()
    {
        var document = WebBrowser.Document as HTMLDocument;
        if (document != null)
        {
            //Parse Dom for hidden elements and trigger appropriate event handler
        }
    }
}

Solution

  • Hmya, E_FAIL, the curse of COM. Useless to ever diagnose anything, it is just a teacher's grade for the quality of the error reporting. I wrote the same code in Winforms to get something to testable, no repro. It is nevertheless very easy to force a repro. Given that the method takes only one argument, there's only one thing that can go wrong:

        if (_container != null)
        {
            _container.UnRegisterForDirtyRange(_cookie);
            _container.UnRegisterForDirtyRange(_cookie);    // Kaboom!!!
            Marshal.ReleaseComObject(_container);
        }
    

    Bad cookie. Criminal that they don't return E_INVALIDARG btw.

    I could not test your exact code of course, it does have problems. Most severe one I see and the repro case is that there is no protection against calling DisposeControls() more than once. In general it is never wrong to dispose objects more than once, I have no insight if that's a realistic failure mode in your project. Otherwise very simple to protect yourself against this. Including catch-and-swallow code:

    protected override void DisposeControls()
    {
        if (_container == null) return;
        try {
            _container.UnRegisterForDirtyRange(_cookie);
        }
        catch (System.Runtime.InteropServices.COMException ex) {
            if (ex.ErrorCode != unchecked((int)0x80004005)) throw;
            // Log mishap...
        }
        finally {
            Marshal.ReleaseComObject(_container);
            _container = null;
            _cookie = 0;
            WebBrowser.LoadCompleted -= OnWebBrowserLoadCompleted;
            WebBrowser.Dispose();
        }
    }
    

    Another thing I noticed in my test version of your code is that you don't appear to have any protection against the browser navigating to another page by any other means than WebBrowser.Navigate(). Or the LoadCompleted event firing more than once, it does for the stackoverflow.com home page for example. Or any web page that uses frames. That's a leak. Make it resilient by having your OnWebBrowserLoadCompleted() event handler also unregister the cookie if it is set.