I was reading this answer to a question about how to schedule a TIMERPROC to run immediately. I was interested in this as I need to run a method immediately after the caller has finished executing its code synchronously.
So I came up with this skeleton:
Private Sub asyncProc(ByVal HWnd As LongPtr, ByVal message As WindowsMessage, ByVal timerID As LongPtr, ByVal tickCount As Long)
Debug.Print "asyncProc called (should be called second)"
End Sub
Private Sub syncProc()
Debug.Print "syncProc called (should be called first)"
End Sub
Sub test()
If tryScheduleProc(AddressOf asyncProc, New Collection) Then
syncProc
Else
Debug.Print "Unable to schedule proc"
End If
End Sub
... and of course the next step was to design the tryScheduleProc
function, which would setup a call to asyncProc
with a unique id provided by the address of the New Collection
Object (in practice this would be the callback's arguments).
Tests
My first approach was to copy the method set out in the linked answer; namely to post a WM_TIMER
message to the Application.HWnd
's message queue:
Private Function tryScheduleProc(byVal timerProc As LongPtr, ByVal arg As Object) As Boolean
Debug.Print "Scheduling..."
tryScheduleProc = PostMessage(Application.HWnd, WM_TIMER, objPtr(arg), timerProc)
End Function
However that prints
Scheduling...
syncProc called (should be called first)
i.e. the asyncProc
is never called.
Thinking there might be something wrong with the message I switched to the traditional way of generating timer messages, SetTimer
, only this time with a very short delay:
Private Function tryScheduleProc(ByVal timerProc As LongPtr, ByVal arg As Object) As Boolean
Debug.Print "Scheduling..."
Debug.Print "Create Timer:"; SetTimer(Application.HWnd, objPtr(arg), 0, timerProc)
tightLoopDelay 100 'wait 100ms for timer to have elapsed definitely
Dim tempMsg As tagMSG
tryScheduleProc = PeekMessage(tempMsg, Application.HWnd, WM_TIMER, WM_TIMER, PM_NOREMOVE) 'force message to be posted
KillTimer Application.HWnd, objPtr(arg)
End Function
Here, SetTimer
creates a flag on the queue once it elapses, and PeekMessage
forces this flag to be evaluated - this lets me destroy the timer, as KillTimer
does not remove existing messages (and I confirmed this was the case with another peek after the KillTimer
call - the message was there in the queue)
However once again the output in the immediate window shows the message has not caused the asyncProc
to run.
Scheduling...
Create Timer: 482245352
syncProc called (should be called first)
Where my confusion lies is in the following: if I put the call to KillTimer
in the callback proc instead then everything works fine:
Private Sub asyncProc(ByVal HWnd As LongPtr, ByVal message As WindowsMessage, ByVal timerID As LongPtr, ByVal tickCount As Long)
Debug.Print "asyncProc called (should be called second)"
KillTimer HWnd, timerID
End Sub
outputs
Scheduling...
Create Timer: 477790208
syncProc called (should be called first)
asyncProc called (should be called second)
Now as I understand it, the state of the thread's message queue should not change based on when I kill the timer (perhaps the timer will have elapsed again depending on how quickly my code executes, but there is no opportunity for other processes to alter the queue while the thread is busy is there?). Printing the queue confirms it contains one timer message as expected anyway.
So what I think is happening is that the WM_TIMER message is somehow being marked as invalid when the timerID it is associated with no-longer corresponds to a live timer. To confirm this I tried the original PostMessage approach, but with a temporary timer to validate the ID of the message:
Private Function tryScheduleProc(ByVal timerProc As LongPtr, ByVal arg As Object) As Boolean
Debug.Print "Scheduling..."
'make a validation timer - this won't expire for a long time
Debug.Print "Create a validation timer:"; SetTimer(Application.HWnd, objPtr(arg), &H7FFFFFFF, timerProc) 'max interval time
tryScheduleProc = PostMessage(Application.HWnd, WM_TIMER, objPtr(arg), timerProc)
End Function
This works as before
Scheduling...
Create a validation timer: 482246472
syncProc called (should be called first)
asyncProc called (should be called second)
...but it is not a great solution as I still need to remember to kill the timer in the callback proc, even though it's not being used to generate messages!
Conclusion
I assume my issue is with the implementation of the message loop in Excel, or the DispatchMessage
function. I don't know what either of these looks like internally or if they're documented anywhere - but I think Excel's implementation of the message loop is probably to blame because the Raymond Chen article on pretty much exactly this subject (orphaned timer messages) doesn't mention this issue. Also the MSDN documentation on DispatchMessage
says
The MSG structure must contain valid message values
but I don't think that is saying the timerID must still be around, only that the tagMSG.message must have a valid value (that may be an incorrect interpretation ofc). Also the answer to that other question would have to be wrong if DispatchMessage
was to blame. But perhaps the observed behaviour comes from somewhere else entirely - I am very new to WinApi & messages and so am not confident in the conclusions I've drawn.
Anyway, I'm wondering if anyone can shed some light on what's causing this behaviour, and indeed suggest some nice workaround for calling methods immediately after the code has run without too many limitations on what the async or sync methods must look like?