views:

451

answers:

5

i'm writing a delphi app that communicates with excel. one thing i noticed is that if i call the Save method on the Excel workbook object, it can appear to hang because excel has a dialog box open for the user. i'm using the late binding.

i'd like for my app to be able to notice when Save takes several seconds and then take some kind of action like show a dialog box telling this is what's happening.

i figured this'd be fairly easy. all i'd need to do is create a thread that calls Save and have that thread call Excel's Save routine. if it takes too long, i can take some action.

procedure TOfficeConnect.Save;
var
  Thread:TOfficeHangThread;
begin
  // spin off as thread so we can control timeout
  Thread:=TOfficeSaveThread.Create(m_vExcelWorkbook);

  if WaitForSingleObject(Thread.Handle, 5 {s} * 1000 {ms/s})=WAIT_TIMEOUT then
    begin
      Thread.FreeOnTerminate:=true;
      raise Exception.Create(_('The Office spreadsheet program seems to be busy.'));
    end;

  Thread.Free;
end;

  TOfficeSaveThread = class(TThread)
  private
    { Private declarations }
    m_vExcelWorkbook:variant;
  protected
    procedure Execute; override;
    procedure DoSave;
  public
    constructor Create(vExcelWorkbook:variant);
  end;

{ TOfficeSaveThread }

constructor TOfficeSaveThread.Create(vExcelWorkbook:variant);
begin
  inherited Create(true);

  m_vExcelWorkbook:=vExcelWorkbook;

  Resume;
end;

procedure TOfficeSaveThread.Execute;
begin
  m_vExcelWorkbook.Save;
end;

i understand this problem happens because the OLE object was created from another thread (absolutely).

how can i get around this problem? most likely i'll need to "re-marshall" for this call somehow...

any ideas?

thank you!

+1  A: 

Rather than accessing the COM object from two threads, just show the message dialog in the secondary thread. The VCL isn't thread-safe, but Windows is.

type
  TOfficeHungThread = class(TThread)
  private
    FTerminateEvent: TEvent;
  protected
    procedure Execute; override;
  public
   constructor Create;
   destructor Destroy; override;
   procedure Terminate; override;
  end;

...

constructor TOfficeHungThread.Create;
begin
  inherited Create(True);
  FTerminateEvent := TSimpleEvent.Create;
  Resume;
end;

destructor TOfficeHungThread.Destroy;
begin
  FTerminateEvent.Free;
  inherited;
end;

procedure TOfficeHungThread.Execute;
begin
  if FTerminateEvent.WaitFor(5000) = wrTimeout then
    MessageBox(Application.MainForm.Handle, 'The Office spreadsheet program seems to be busy.', nil, MB_OK);
end;

procedure TOfficeHungThread.Terminate;
begin
  FTerminateEvent.SetEvent;
end;

...

procedure TMainForm.Save;
var
  Thread: TOfficeHungThread;
begin
  Thread := TOfficeHungThread.Create;
  try
    m_vExcelWorkbook.Save;
    Thread.Terminate;
    Thread.WaitFor;
  finally
    Thread.Free;
  end;
end;
Craig Peterson
The problem seems to be that Excel has a Dialog open and is waiting for user input...
Remko
thank you for your answer; i hadn't thought of doing it that way. looks like i get pulled in a different direction today; i'll need to get back to this. i think i'm most comfortable with this solution.
X-Ray
in the end, i just put a msg in the thread that suggests you look at what Office is doing. works very nicely, thanks!
X-Ray
A: 

Try calling CoInitializeEx with COINIT_MULTITHREADED since MSDN states:

Multi-threading (also called free-threading) allows calls to methods of objects created by this thread to be run on any thread.

Remko
thank you for your answer; i didn't know about that! looks like i get pulled in a different direction today; i'll need to get back to this.
X-Ray
-1 Most COM objects are not free threaded
Bob Denny
@Bob: But certainly a multi threaded application can use (single threaded) com objects and as I read it this was exactly what was asked: a single threaded com object makes the calling application unresponsive and therefore a 2nd thread is needed.
Remko
+1  A: 

'Marshalling' an interface from one thread to another can be done by using CoMarshalInterThreadInterfaceInStream to put the interface into a stream, move the stream to the other thread and then use CoGetInterfaceAndReleaseStream to get the interface back from the stream. see here for an example in Delphi.

Lars Truijens
thank you for your help on marsalling! looks like i get pulled in a different direction today; i'll need to get back to this.
X-Ray
A: 

Lars' answer is along the right lines I think. An alternative to his suggestion is to use the GIT (Global Interface Table), which can be used as a cross-thread repository for interfaces.

See this SO thread here for code for interacting with the GIT, where I posted a Delphi unit that provides simple access to the GIT.

It should simply be a question of registering your Excel interface into the GIT from your main thread, and then getting a separate reference to the interface from within your TOfficeHangThread thread using the GetInterfaceFromGlobal method.

Conor Boyd
thank you for your answer; i think i'll go with the simplest and easiest solution since the project manager wants a quick solution.
X-Ray
+1  A: 

The real problem here is that Office applications aren't intended for multithreaded use. Because there can be any number of client applications issuing commands through COM, those commands are serialized to calls and processed one by one. But sometimes Office is in a state where it doesn't accept new calls (for example when it is displaying a modal dialog) and your call gets rejected (giving you the "Call was rejected by callee"-error). See also the answer of Geoff Darst in this thread.

What you need to do is implement a IMessageFilter and take care of your calls being rejected. I did it like this:

function TIMessageFilterImpl.HandleInComingCall(dwCallType: Integer;
  htaskCaller: HTASK; dwTickCount: Integer;
  lpInterfaceInfo: PInterfaceInfo): Integer;
begin
  Result := SERVERCALL_ISHANDLED;
end;

function TIMessageFilterImpl.MessagePending(htaskCallee: HTASK;
  dwTickCount, dwPendingType: Integer): Integer;
begin
  Result := PENDINGMSG_WAITDEFPROCESS;
end;

function ShouldCancel(aTask: HTASK; aWaitTime: Integer): Boolean;
var
  lBusy: tagOLEUIBUSYA;
begin
  FillChar(lBusy, SizeOf(tagOLEUIBUSYA), 0);
  lBusy.cbStruct := SizeOf(tagOLEUIBUSYA);
  lBusy.hWndOwner := Application.Handle;

  if aWaitTime < 20000 then //enable cancel button after 20 seconds
    lBusy.dwFlags := BZ_NOTRESPONDINGDIALOG;

  lBusy.task := aTask;
  Result := OleUIBusy(lBusy) = OLEUI_CANCEL;
end;

function TIMessageFilterImpl.RetryRejectedCall(htaskCallee: HTASK;
  dwTickCount, dwRejectType: Integer): Integer;
begin
  if dwRejectType = SERVERCALL_RETRYLATER then
  begin
    if dwTickCount > 10000 then //show Busy dialog after 10 seconds
    begin
      if ShouldCancel(htaskCallee, dwTickCount) then
        Result := -1
      else
        Result := 100;
    end
    else
      Result := 100; //value between 0 and 99 means 'try again immediatly', value >= 100 means wait this amount of milliseconds before trying again
  end
  else
  begin
    Result := -1; //cancel
  end;
end;

The messagefilter has to be registered on the same thread as the one issuing the COM calls. My messagefilter implementation will wait 10 seconds before displaying the standard OLEUiBusy dialog. This dialog gives you the option to retry the rejected call (in your case Save) or switch to the blocking application (Excel displaying the modal dialog). After 20 seconds of blocking, the cancel button will be enabled. Clicking the cancel button will cause your Save call to fail.

So forget messing around with threads and implement the messagefilter, which is the way to deal with these issues.

Edit: The above fixes "Call was rejected by callee" errors, but you have a Save that hangs. I suspect that Save brings up a popup that needs your attention (Does your workbook has a filename already?). If it is a popup that is in the way, try the following (not in a separate thread!):

{ Turn off Messageboxes etc. }
m_vExcelWorkbook.Application.DisplayAlerts := False;
try
  { Saves the workbook as a xls file with the name 'c:\test.xls' }
  m_vExcelWorkbook.SaveAs('c:\test.xls', xlWorkbookNormal);
finally
  { Turn on Messageboxes again }
  m_vExcelWorkbook.Application.DisplayAlerts := True;
end;

Also try to debug with Application.Visible := True; If there are any popups, there is a change you will see them and take actions to prevent them in the future.

The_Fox
wow--what a cool possibility; i didn't know about any of that! for now the project manager wants a quick solution but this shows me how much else is possible. thank you
X-Ray
@X-Ray: I have to admit that this may not be the solution for your problem now I read your post again. In the case of a "Call was rejected by callee" your method call fails immediately and does not hang. When you say Save hangs, I suspect there is a dialog that needs your attention. See my edited answer for more information.
The_Fox