1

I am trying to do some trickery with the SetTimer api, and have finally been able to create a reproducible example for a problem I've been stuck with. I'm getting an error when I pass an instance of a custom class to a callback, but not for built-in/ library classes

Here's what I'm trying to do:

  1. Create a timer with a callback function using the SetTimer function
  2. Pass some data to the callback by setting the timerID (UINT_PTR nIDEvent in the docs) to be a pointer to an object which wraps the data
  3. Persist the argument object in memory over a state loss (hitting the stop button in the editor) using an mscorlib.AppDomain

To expand on those points a bit:

1. Creating the timer

No problems here; below are my api declarations, which I've put in a module called WinAPI

Public Declare Function SetTimer Lib "user32" ( _
                        ByVal hWnd As LongPtr, _
                        ByVal nIDEvent As LongPtr, _
                        ByVal uElapse As Long, _
                        ByVal lpTimerFunc As LongPtr) As LongPtr

Public Declare Function KillTimer Lib "user32" ( _
                        ByVal hWnd As LongPtr, _
                        ByVal nIDEvent As LongPtr) As Long

2. Pass data

I've defined a callback function signature which is compliant with the TIMERPROC definition

Private Sub timerProc(ByVal windowHandle As LongPtr, ByVal message As Long, ByVal timerObj As Object, ByVal tickCount As Long)

As you can see the third argument _In_ UINT_PTR idEvent, which is usually the plain id of the WinAPI timer, is here being used to pass a reference to some object in memory. In my actual code this is a strongly typed custom class, but for this example Object will suffice.

I then create the timer using

Dim timerParams As Object
'... initialise the object with the data to pass
SetTimer hWnd:=Application.hWnd, nIDEvent:=ObjPtr(timerParams), uElapse:=500, lpTimerFunc:=AddressOf timerProc

(ok I don't use all the named arguments like that, but you get the idea;)

3. Persist Data

In my real code (sorry, not in this example), I already have some bits and pieces hooked up so that hitting the stop button will trigger the timer to be stopped, however it still gets one more tick before it is destroyed with KillTimer. Therefore it's crucial that my object gets persisted in memory even when I hit stop in the editor - if not then when the timerProc runs for the final time, the pointer it tries to dereference will be invalid.

Basically I always have to make sure that timerObj exists whenever timerProc is called. The WinAPI timers don't get destroyed when I press Stop in my VBA code, so my object mustn't be either. For that reason I'm using the approach suggested in this answer


The Issue

Right, putting all that together to create an MRE (or whatever the acronym is now):

Option Explicit

Private Declare Function SetTimer Lib "user32" ( _
                         ByVal hWnd As LongPtr, _
                         ByVal nIDEvent As LongPtr, _
                         ByVal uElapse As Long, _
                         ByVal lpTimerFunc As LongPtr) As LongPtr

Private Declare Function KillTimer Lib "user32" ( _
                         ByVal hWnd As LongPtr, _
                         ByVal nIDEvent As LongPtr) As Long

Private Function GetPersistentDictionary() As Object
    ' References:
    '  mscorlib.dll
    '  Common Language Runtime Execution Engine

    Const name = "weak-data"
    Static dict As Object

    If dict Is Nothing Then
        Dim host As New mscoree.CorRuntimeHost
        Dim domain As mscorlib.AppDomain
        host.Start
        host.GetDefaultDomain domain

        If IsObject(domain.GetData(name)) Then
            Set dict = domain.GetData(name)
        Else
            Set dict = CreateObject("Scripting.Dictionary")
            domain.SetData name, dict
        End If
    End If

    Set GetPersistentDictionary = dict
End Function

Private Sub timerProc(ByVal windowHandle As LongPtr, ByVal message As Long, ByVal timerObj As Object, ByVal tickCount As Long)

    Static i As Long 'this will go to zero after a state-loss
    i = i + 1
    Debug.Print i;
    Dim data As String
    data = timerObj.Item("myVal")
    Debug.Print data
    If i >= 10 Then
        KillTimer Application.hWnd, ObjPtr(timerObj)
        Debug.Print "Done"
        i = 0
    End If
End Sub

Private Sub setUpTimer()
    'create the data to pass to the callback function
    Dim testObj As Object
    Set testObj = New Dictionary
    testObj.Item("myVal") = "I'm the data you passed!"

    'store the data object in cache so its reference count never goes to zero
    Dim cache As Dictionary
    Set cache = GetPersistentDictionary()
    Set cache.Item("testObj") = testObj

    'create the timer, passing the data object as an argument
    SetTimer Application.hWnd, ObjPtr(testObj), 500, AddressOf timerProc
End Sub

And that actually works exactly as expected! The output is something like this:

 1 I'm the data you passed!
 2 I'm the data you passed!
 3 I'm the data you passed!
 4 I'm the data you passed!
 5 I'm the data you passed! '<- I pressed stop just after this, which restarted the static count, but didn't destroy the cached object
 1 I'm the data you passed!
 2 I'm the data you passed!
 3 I'm the data you passed!
 4 I'm the data you passed!
 5 I'm the data you passed!
 6 I'm the data you passed!
 7 I'm the data you passed!
 8 I'm the data you passed!
 9 I'm the data you passed!
 10 I'm the data you passed!
Done

However if I try this with a custom class instead of the Scripting.Dictionary as the data (save before attempting):

Private Sub setUpTimer()
    'create the data to pass to the callback function
    Dim testObj As Object
    Set testObj = New fakeDictionary '<-custom class, the only change
    testObj.Item("myVal") = "I'm the data you passed!"
    '...everything else the same

Where fakeDictionary is just this:

Option Explicit

Private dict As New Scripting.Dictionary

Public Property Get Item(ByVal key As String) As String
    Item = dict.Item(key)
End Property

Public Property Let Item(ByVal key As String, ByVal value As String)
    dict.Item(key) = value
End Property

Private Sub Class_Terminate()
    Debug.Print "I am made dead"
End Sub

I get this upon stopping the code:

Error image

And then Excel crashes when the next timer message comes in and runs the callback and the exception is unhandled.

The text reads

Run Time error -2147418105

Automation error

The callee (server [not server application]) is not available and disappeared; all connections are invalid. The call may have executed.

Community
  • 1
  • 1
Greedo
  • 4,967
  • 2
  • 30
  • 78
  • Ps. if you're having trouble using mscorlib like I was, it might be because of [this](https://stackoverflow.com/q/37074533/6609896), in which case either follow the workarounds there, or just use mscorlib v2 by installing/enabling .Net 3.5 like I did (following the instructions [here](https://learn.microsoft.com/en-us/dotnet/framework/install/dotnet-35-windows-10)) – Greedo Aug 19 '19 at 15:55
  • PPS If you want to be able to debug following the error, you need to route messages to a different window handle, it's not too hard, just create a new message window handle by doing [this](https://stackoverflow.com/a/4081383/6609896) in VBA - I'll post the code if necessary, but it's tangential to the error. – Greedo Aug 19 '19 at 15:59
  • @SMeaden Tried that:) - this code is forming part of an Addin so I've been messing around with instancing a lot already - to no avail:( – Greedo Aug 19 '19 at 16:00
  • @SMeaden had no idea that could be relevant! Windows 10 Home 64-bit (ideally any modern one should work) & Excel Office 365 v16.0... 32-bit. So later than Windows 7 – Greedo Aug 19 '19 at 16:11
  • @SMeaden The 3 numbers are 0, 3, 0 regardless of location (main or callback) or argument. Also I was under the impression that VBA runs exclusively in Excel's single UI thread - not sure if that's relevant – Greedo Aug 19 '19 at 16:28
  • You should *expect* things to blow up if you hit 'stop'... adjust the callback so that it bails out if it's invoked after some control variable reference is cleared. Get rid of the CLR cache, it's a toxic hack that's only making a complex situation worse. You are willfully creating a memory leak and causing VBA to lose track of what it needs to clean up. – Mathieu Guindon Aug 19 '19 at 16:35
  • @MathieuGuindon It's a tricky trade-off really; I'm not trying to write callback functions and timers right now, I'm working on some code to make it easier for other people to write these callbacks, where easier = fewer complete work-destroying crashes. So this kind of trick, while hacky I agree, will (I believe) help with the process of developing code. I'd like to see what you think when it's all put together. – Greedo Aug 19 '19 at 16:49
  • Very curious if having a conditional `End` instruction exactly where you would click 'stop' causes the same issue. I've been going with the assumption that 'stop' and `End` instructions are equivalent. – Mathieu Guindon Aug 19 '19 at 16:52
  • 1
    @MathieuGuindon `End` does nuke everything in a similar way but also means the `TIMERPROC` never returns which crashes Excel's message loop anyway (or the loop on that thread) - I think the stop button would do the same if it was evaluated part way through the callback's execution, but it only ever seems to land in-between calls to the `TIMERPROC` so it's not an issue. (This may be because the button press runs in the UI thread so is queued between `WM_TIMER` messages and therefore can never interrupt execution; same reason you need `DoEvents` scattered everywhere for the stop button to work) – Greedo Aug 19 '19 at 16:56
  • 1
    How about spawning a modeless, invisible userform and use that hWnd instead of Excel's? – Mathieu Guindon Aug 19 '19 at 16:59
  • 2
    You cannot safely do, what you are trying to do. The `nIDEvent` argument is meant to be a unique timer ID. It is pointer sized, so that you can pass the address of a dummy object as the ID. Every client that does this, will be guaranteed to get a unique ID. It is not meant to pass actual data. Thing is, if your timer procedure runs, how are you going to distinguish between those timers, that use any unique ID, and those timers, and those, where the ID is a pointer to an object? If you can, the timer procedure already knows the address of the object, so why pass it? – IInspectable Aug 19 '19 at 17:04
  • @IInspectable That's a really good point. I've been passing them directly mostly as a convenience and friendly API. As it stands I'm currently storing every object globally aren't I, so the timerProcs could just use their ID as a key in some global dictionary - would certainly get rid of the errors I'm seeing. But then I don't see why that's really any different; If I use a dummy object to generate the ID & Key, I still need to preserve they key:real-data mapping over a state loss - at which point I may as well just use real data as the ID. I know the callers will all be using real data – Greedo Aug 19 '19 at 17:18
  • 1
    Thanks to @IInspectable for their sanity check; its useful in resetting perspectives on this but I have changed the code to not use a interface pointer and use a 32-bit long (hashval of some text) instead but it still crashes because it is trying to call into your fakeDictionary class. Why does it crash? Because it is a VBA defined class and you tore it down. I believe that this goes away if you move your code to a binary compiled vehicle. In your own experiment the Scripting.Dictionary survived but your custom VBA class didn't. – S Meaden Aug 19 '19 at 17:26
  • @MathieuGuindon Oh I see what you mean, that's genius, thanks! – Greedo Aug 20 '19 at 08:22

1 Answers1

1

Well, if you press Stop then I do not think it reasonable to expect pointers to objects created from your VBA defined classes to continue to be valid. For a really durable class you'll need to write it in C# (or C++ or Python, or even VB6).

S Meaden
  • 8,050
  • 3
  • 34
  • 65
  • 2
    This. Forcing objects to stick around after 'stop' is pressed (i.e. after execution context is nuked), amounts to willfully creating a memory leak. This cannot end well. – Mathieu Guindon Aug 19 '19 at 16:31
  • 1
    FWIW, if you opt to get [`vbWatchDog`](https://www.everythingaccess.com/vbwatchdog.asp) (not free), you will be able to stop without resetting the global state. Seems more cleaner than trying to create a quasi-out-of-process store. Otherwise, just don't click that Stop button. Treat it like a [SCRAM button](https://en.wikipedia.org/wiki/Scram). – this Aug 19 '19 at 16:35
  • IDK I'm not convinced... VBA classes are like any other COM object and use reference counting to stay alive right? The way it was explained in [the other post by @FlorentB.](https://stackoverflow.com/a/38956839/6609896) is that the cache approach I'm using is simply adding a reference to the object and storing it in a place which has the lifetime of the Excel process. It's equivalent to adding something to the data store when you subclass a window. I think you *should* expect pointers that are still referenced to remain valid, and Microsoft is at fault if they do a sneaky tablecloth pull! – Greedo Aug 19 '19 at 16:42
  • 3
    @Greedo the point is that by hitting 'stop', *you* are the one doing the sneaky tablecloth pull – Mathieu Guindon Aug 19 '19 at 16:47
  • 1
    Keep in mind this is a developer-only problem as well. End users will not experience this problem because they won't be habit of opening up VBIDE to press stop button. – this Aug 19 '19 at 16:52
  • @this Precisely, I'm trying to get the behaviour that pressing stop -> code stops running gracefully rather than Excel crashes - purely as a convenience to developers – Greedo Aug 19 '19 at 17:22