4

I want to wait for a WebBrowser control to finish navigation. So i create an Event, and then i want to wait for it to be set:

procedure TContoso.NavigateToEmpty(WebBrowser: IWebBrowser2);
begin
   FEvent.ResetEvent;
   WebBrowser.Navigate2('about:blank'); //Event is signalled in the DocumentComplete event

   Self.WaitFor;
end;

And then i set the event in the DocumentComplete event:

procedure TContoso.DocumentComplete(ASender: TObject; const pDisp: IDispatch; const URL: OleVariant);
var
    doc: IHTMLDocument2;
begin
    if (pDisp <> FWebBrowser.DefaultInterface) then
    begin
       //This DocumentComplete event is for another frame
       Exit;
    end;

    //Set the event that it's complete
    FEvent.SetEvent;
end;

The problem comes in how to wait for this event to happen.

WaitFor it

First reaction would be to wait for the event to become triggered:

procedure TContoso.WaitFor;
begin
   FEvent.WaitFor;
end;

The problem with that is that the DocumentComplete event can never fire, because the application never goes idle enough to allow the COM event to get through.

Busy Sleep Wait

My first reaction was to do a busy sleep, waiting for a flag:

procedure TContoso.NavigateToEmpty(WebBrowser: IWebBrowser2);
begin
   FIsDocumentComplete := False;
   WebBrowser.Navigate2('about:blank'); //Flag is set in the DocumentComplete event
   Self.WaitFor;
end;

procedure TContoso.WaitFor;
var
   n: Iterations;
const
   MaxIterations = 25; //100ms each * 10 * 5 = 5 seconds
begin
   while n < MaxIterations do
   begin
      if FIsDocumentComplete then
         Exit;
      Inc(n);
      Sleep(100); //100ms
   end;
end;

The problem with a Sleep, is that it doesn't allow the application to do idle enough to allow the COM event messages to get through.

Use CoWaitForMultipleHandles

After research, it seems that COM folks created a function created exactly for this situation:

While a thread in a Single-Threaded Apartment (STA) blocks, we will pump certain messages for you. Message pumping during blocking is one of the black arts at Microsoft. Pumping too much can cause reentrancy that invalidates assumptions made by your application. Pumping too little causes deadlocks. Starting with Windows 2000, OLE32 exposes CoWaitForMultipleHandles so that you can pump “just the right amount.”

So i tried that:

procedure TContoso.WaitFor;
var
   hr: HRESULT;
   dwIndex: DWORD;
begin
   hr := CoWaitForMultipleHandles(0, 5000, 1, @FEvent.Handle, {out}dwIndex);
   OleCheck(hr);
end;

The problem is that just doesn't work; it doesn't allow the COM event to appear.

Use UseCOMWait wait

i could also try Delphi's own mostly secret feature of TEvent: UseCOMWait

Set UseCOMWait to True to ensure that when a thread is blocked and waiting for the object, any STA COM calls can be made back into this thread.

Excellent! Lets use that:

FEvent := TEvent.Create(True);

function TContoso.WaitFor: Boolean;
begin
   FEvent.WaitFor;
end;

Except that doesn't work; because the callback event never gets fired.

MsgWaitForMultipleBugs

So now i start to delve into the awful, awful, awful, awful, buggy, error-prone, re-entrancy inducing, sloppy, requires a mouse nudge, sometimes crashes world of MsgWaitForMultipleObjects:

function TContoso.WaitFor: Boolean;
var
//  hr: HRESULT;
//  dwIndex: DWORD;
//  msg: TMsg;
    dwRes: DWORD;
begin
//  hr := CoWaitForMultipleHandles(0, 5000, 1, @FEvent.Handle, {out}dwIndex);
//  OleCheck(hr);
//  Result := (hr = S_OK);

    Result := False;
    while (True) do
    begin
        dwRes := MsgWaitForMultipleObjects(1, @FEvent.Handle, False, 5000, QS_SENDMESSAGE);
        if (dwRes = WAIT_OBJECT_0) then
        begin
            //Our event signalled
            Result := True;
            Exit;
        end
        else if (dwRes = WAIT_TIMEOUT) then
        begin
            //We waited our five seconds; give up
            Exit;
        end
        else if (dwRes = WAIT_ABANDONED_0) then
        begin
            //Our event object was destroyed; something's wrong
            Exit;
        end
        else if (dwRes = (WAIT_OBJECT_0+1)) then
        begin
            GetMessage(msg, 0, 0, 0);
        if msg.message = WM_QUIT then
        begin
            {
                http://blogs.msdn.com/oldnewthing/archive/2005/02/22/378018.aspx

                PeekMessage will always return WM_QUIT. If we get it, we need to
                cancel what we're doing and "re-throw" the quit message.

                    The other important thing about modality is that a WM_QUIT message
                    always breaks the modal loop. Remember this in your own modal loops!
                    If ever you call the PeekMessage function or The GetMessage
                    function and get a WM_QUIT message, you must not only exit your
                    modal loop, but you must also re-generate the WM_QUIT message
                    (via the PostQuitMessage message) so the next outer layer will
                    see the WM_QUIT message and do its cleanup as well. If you fail
                    to propagate the message, the next outer layer will not know that
                    it needs to quit, and the program will seem to "get stuck" in its
                    shutdown code, forcing the user to terminate the process the hard way.
            }
            PostQuitMessage(msg.wParam);
            Exit;
        end;
        TranslateMessage(msg);
        DispatchMessage(msg);
    end;
end;

The above code is wrong because:

  • i don't know what kind of message to wake up for (are com events sent?)
  • i don't know i don't want to call GetMessage, because that gets messages; i only want to get the COM message (see point one)
  • i might should be using PeekMessage (see point 2)
  • i don't know if i have to call GetMessage in a loop until it returns false (see Old New Thing)

I've been programming long enough to run away, far away, if i'm going to pump my own messages.

The questions

So i have four questions. All related. This post is one of the four:

  • How to make WebBrower.Navigate2 synchronous?
  • How to pump COM messages?
  • Does pumping COM messages cause COM events to callback?
  • How to use CoWaitForMultipleHandles

I am writing in, and using Delphi. But obviously any native code would work (C, C++, Assembly, Machine code).

See also

Community
  • 1
  • 1
Ian Boyd
  • 246,734
  • 253
  • 869
  • 1,219
  • I think I'm missing something in your description - why is your application not idle enough to deal with loading a single (very small) document? When I was using `WebBrowser`, it was a trivial setup to load a document and handle the event. (I used VB6 for this but the COM side should be the same in Delphi.) – xxbbcc Dec 22 '15 at 17:09
  • 1
    Why are you trying to make `Navigate2` synchronous? What are you trying to achieve by this? This almost sounds like an X/Y problem to me (I may be missing some detail, though). – xxbbcc Dec 22 '15 at 17:10
  • @xxbbcc: You have to wait for the browser's `Document` to be fully loaded, even when navigating to `about:blank`, before you can manipulate it safely. It is not uncommon to navigate to `about:blank`, wait for ready, and then stream customized HTML into the browser dynamically. – Remy Lebeau Dec 22 '15 at 17:38
  • I was going to say *"cause i said so"*, but Remy knew what i was doing. For everyone else: [MSDN: Loading HTML content from a Stream](http://msdn.microsoft.com/en-us/library/aa752047.aspx). As usual: don't confuse the example with the question. The question is not how to load HTML into a web-browser, or how to make Navigate2 synchronous - it's how to pump COM messages. My same question would apply if i was using ADO, or any other COM technology. – Ian Boyd Dec 22 '15 at 18:05
  • @RemyLebeau Yes, I know that. that's exactly why I asked. It doesn't imply any of the details in the question, though. – xxbbcc Dec 22 '15 at 19:37
  • 1
    @IanBoyd There's already an answer but you simply need a message pump. I still don't get why you need that - most of `WebBrowser` is designed to run asynchronously so you seem to be trying something non-standard. – xxbbcc Dec 22 '15 at 19:41
  • @xxbbcc It's standard enough that it's a common question. And common enough that most answers out there mention a busy-wait on readystate. And standard enough that the original version of the MSDN article used a busy-sleep-wait on readystate (which was fine when busy-wait on readystate worked). – Ian Boyd Dec 22 '15 at 19:46
  • @IanBoyd Hmm, I don't remember ever having to do that with `WebBrowser`... I don't have all that old code in front of me but I remember handling document loading fairly straightforward. (I only ever loaded documents from files, not from streams - I don't know if that makes a difference.) – xxbbcc Dec 22 '15 at 19:48
  • @xxbbcc In my case it's a UI, the HTML for which is generated entirely in code. We create an HGLOBAL, and then copy the HTML into it, then use the handy shell function `SHCreateStreamOnHGlobal` to turn the memory behind the HGLOBAL into an IStream. Then you can instruct the document to load content from the IStream using the document's IPersistStream interface. But that all requires *having* a document in the web-browser, which doesn't happen until you navigate the browser somewhere - such as `about:blank`. – Ian Boyd Dec 22 '15 at 20:13
  • @IanBoyd, FYI on `CoWaitForMultipleHandles` , check [this](http://stackoverflow.com/a/21371891/1768303) and [this](http://stackoverflow.com/a/21573637). – noseratio Dec 25 '15 at 06:10

1 Answers1

5

The short and long of it is that you have to pump ALL messages normally, you can't just single out COM messages by themselves (and besides, there is no documented messages that you can peek/pump by themselves, they are known only to COM's internals).

How to make WebBrower.Navigate2 synchronous?

You can't. But you don't have to wait for the OnDocumentComplete event, either. You can busy-loop inside of NavigateToEmpty() itself until the WebBrowser's ReadyState property is READYSTATE_COMPLETE, pumping the message queue when messages are waiting to be processed:

procedure TContoso.NavigateToEmpty(WebBrowser: IWebBrowser2);
begin
  WebBrowser.Navigate2('about:blank');
  while (WebBrowser.ReadyState <> READYSTATE_COMPLETE) and (not Application.Terminated) do
  begin
    // if MsgWaitForMultipleObjects(0, Pointer(nil)^, False, 5000, QS_ALLINPUT) = WAIT_OBJECT_0 then
    // if GetQueueStatus(QS_ALLINPUT) <> 0 then
      Application.ProcessMessages;
  end;
end;

How to pump COM messages?

You can't, not by themselves anyway. Pump everything, and be prepared to handle any reentry issues that result from that.

Does pumping COM messages cause COM events to callback?

Yes.

How to use CoWaitForMultipleHandles

Try something like this:

procedure TContoso.NavigateToEmpty(WebBrowser: IWebBrowser2);
var
  hEvent: THandle;
  dwIndex: DWORD;
  hr: HRESULT;
begin
  // when UseCOMWait() is true, TEvent.WaitFor() does not wait for, or
  // notify, when messages are pending in the queue, so use
  // CoWaitForMultipleHandles() directly instead.  But you have to still
  // use a waitable object, just don't signal it...
  hEvent := CreateEvent(nil, True, False, nil);
  if hEvent = 0 then RaiseLastOSError;
  try
    WebBrowser.Navigate2('about:blank');
    while (WebBrowser.ReadyState <> READYSTATE_COMPLETE) and (not Application.Terminated) do
    begin
      hr := CoWaitForMultipleHandles(COWAIT_INPUTAVAILABLE, 5000, 1, hEvent, dwIndex);
      case hr of
        S_OK: Application.ProcessMessages;
        RPC_S_CALLPENDING, RPC_E_TIMEOUT: begin end;
      else
        RaiseLastOSError(hr);
      end;
    end;
  finally
    CloseHandle(hEvent);
  end;
end;
Remy Lebeau
  • 555,201
  • 31
  • 458
  • 770
  • The problem, of course, with `ProcessMessages` is that it will cause re-entrancy problems - where none existed before. The current implementation is to a) call `Navigate2` b) Busy sleep for 5 seconds c) see of **WebBrowser.Document** is there, if it is - barrel ahead and start using it. Does it work? Yes. Has it worked for 8 years? Yes. Is it right? No. I can't break existing code in the name of introducing bugs. – Ian Boyd Dec 22 '15 at 18:09
  • Oh, and it turns out that nowadays, forwhatever reason, the `ReadyState` never becomes **Complete**. – Ian Boyd Dec 22 '15 at 18:10
  • I am aware of the reentry issues (I mentioned that in my answer). You have to process messages in order for the `ReadyState` to progress states, and trigger the `OnDocumentComplete` event. You could just busy sleep without processing messages until `Document` is not nil, but that does not guarantee that the `Document` is ready to be used, as it becomes assigned during the `Loaded`/`Interactive` state before the `Complete` state is reached. – Remy Lebeau Dec 22 '15 at 18:26
  • The *correct* solution is to simply not block your code to begin with. `Navigate/2()` is asynchronous, so treat it as such. Call it and return control to the main message loop without blocking your code, and then wait for the `OnDocumentComplete` event to trigger before moving on with your logic. – Remy Lebeau Dec 22 '15 at 18:27
  • I wish i knew how ADO turns asynchronous database calls, and callbacks, into synchronous calls. It's a problem that has been solved in the Windows COM world before. I just don't know how. – Ian Boyd Dec 22 '15 at 18:33
  • Is your statement true (ReadyState never reaches complete) for any arbitrary (even trivial) html page, or is that statement specific to the content/sites YOU happen to be loading? – Warren P Dec 22 '15 at 19:32
  • @RemyLebeau _The correct solution is to simply not block your code to begin with. Navigate/2() is asynchronous, so treat it as such_ - this is exactly why I asked my comments under the question. – xxbbcc Dec 22 '15 at 19:40
  • @WarrenP It's true when navigating to `about:blank`. It used to be that you could sit in a busy-loop spinning on `while (WebBrowser.ReadyState <> READYSTATE_COMPLETE) do` after calling `WebBrowser.Navigate2('about:blank')`. At the very least it worked 2/27/2001, when it was first copied from MSDN and added to our source control. My god, has it been that long. Back then we worst thing was *"strategery"* and arguing about Pell Grants. – Ian Boyd Dec 22 '15 at 20:00
  • Watching the message that, when `Dispatched` triggers the call to my callback, i realize it's not a COM message at all. The Browser control posts a custom WM_APP+2 message to an internal hidden window. When received by the IE internal message window, it turns around and fires the attached `IDispatch` event listeners. That's why CWFMH doesn't work - it's not a COM message. And while i know what the message code is, and i *might* be able to find the intended hwnd recipient, so i can pump it, i cannot count on anything so fragile. Your example of CWFMH is likely correct and answers the question. – Ian Boyd Dec 22 '15 at 20:20