• 🏆 Texturing Contest #33 is OPEN! Contestants must re-texture a SD unit model found in-game (Warcraft 3 Classic), recreating the unit into a peaceful NPC version. 🔗Click here to enter!
  • It's time for the first HD Modeling Contest of 2024. Join the theme discussion for Hive's HD Modeling Contest #6! Click here to post your idea!

[Lua] DelayedAction

Level 6
Joined
Jun 30, 2017
Messages
41
A function that offers you the ability to run a function (with or without parameters) after a specified delay, without using polled waits or manually setting up timers.

Requires [Lua] TimerUtils

Lua:
-- Delayed Action v1.0.1 by Insanity_AI

--  requires https://www.hiveworkshop.com/threads/lua-timerutils.316957/

--[[Description:

    A function that offers you to run a function with arguments after some delay,
        without the use of polled waits, or manually setting up timers.
]]

--[[Use example:
        DelayedAction(3.00, RemoveUnit, GetTriggerUnit())
            will remove the triggering unit of some trigger after 3 seconds.
]]

function DelayedAction(delay, func, ...)

    if type(func) == "nil" or type(delay) == "nil" or delay < 0.00 then
        return
    end

    local timer = NewTimer({func,...})

    TimerStart(timer, delay, false, DelayedActionExecute)
  
    return timer
end

function DelayedActionExecute()
    local data = GetTimerData()

    local func = table.unpack(data,1)
    table.remove(data,1)

    ReleaseTimer(GetExpiredTimer())

    pcall(func,table.unpack(data))
end

Use examples:
Code:
DelayedAction(1.00, RemoveUnit, GetTriggerUnit())   --will remove a unit after 1 second
DelayedAction(2.00, SomeFunction) -- will run "SomeFunction"(which doesn't require parameters) after 2 seconds

Edit (29.12.2019): Swapped the argument order in DelayedAction function as suggested by @Tasyen.
Edit (3.1.2020): Replaced DestroyTimer with ReleaseTimer. DelayedAction now returns the timer, in case said action needs to be paused or something.
 

Attachments

  • DelayedAction.w3x
    18.4 KB · Views: 95
Last edited:
Level 6
Joined
Jun 30, 2017
Messages
41
shouldn't check if type(func) ~= "nil" be check if type(func) ~= "function"
There is a __call metamethod in lua that can be applied to tables. Apparently if that method is defined, you can call a table as if it's a function.
Don't know if other types in lua can have metatables set to them, or if that's just exclusive to tables.
And because of that, I've just left it to just check that if the function provided is nil.
 

AGD

AGD

Level 16
Joined
Mar 29, 2016
Messages
688
I wanna share what I think is a better implementation for delayed actions.

- Uses only 1 timer created at init

- Callbacks that are expected to expire at the same time are called together at a single expiration of the timer
For example:
Lua:
ExecuteDelayed(2., A, ...)
ExecuteDelayed(1.2, function ()
    ExecuteDelayed(0.8, B)
end)
Since A and B are both expected to run at the same time, they are called together at a single expiration of the timer at the 2s mark, instead of from 2 separate expirations. This becomes significant the more parallel delayed callbacks there are present that would expire at the same time.

- Usage and pupose is the same to your library's.

delayedexecute.lua
Lua:
--[[ DelayedExecute v1.0.0 by AGD |

    Description:

        Small Lua script for delayed callbacks execution


    Requirements:

        - N/A

]]--
--[[

    API:

        function ExecuteDelayed(delay:number, callback:function, ...)
        - Executes <callback> after <delay> seconds with <...> as its arguments

]]--

do
    local TIMEOUT_OFFSET_TOLERANCE = 0.01

    local timer = CreateTimer()
    local elapsed = 0.
    local queue = {}
    local n = 0

    local function on_expire()
        elapsed = elapsed + TimerGetTimeout(timer)

        -- The first in the loop (i.e., the last in the queue) is guaranteed to
        -- be called. After that, it will continue to loop for callbacks whose
        -- expiration time is within <TIMEOUT_OFFSET_TOLERANCE> threshold and
        -- call them instead of unnecessarily re-running the timer with a very
        -- miniscule (or even 0.) timeout.
        -- This won't necessarily loop through all callbacks since the queue is
        -- sorted. It will stop at the first callback whose expiration time is
        -- greater than the threshold.
        while queue[n][1] - elapsed <= TIMEOUT_OFFSET_TOLERANCE do
            queue[n][2](table.unpack(queue[n][3], 1, queue[n][3].n))
            queue[n] = nil -- Pop called function
            n = n - 1
        end

        if n > 0 then
            TimerStart(timer, queue[n][1] - elapsed, false, on_expire)
        else
            elapsed = 0.
            PauseTimer(timer) -- This pausing of unused timer may not be necessary
        end
    end

    local function comp(a, b) return a[1] > b[1] end

    ---@param timeout number
    ---@param callback function or any callable object
    function ExecuteDelayed(timeout, callback, ...)
        n = n + 1
        queue[n] = {timeout + elapsed, callback, table.pack(...)}
        table.sort(queue, comp)

        if n == 1 then
            TimerStart(timer, timeout, false, on_expire)
        end
    end
end

EDIT: More up-to-date version below.
 
Last edited:
Level 20
Joined
Jul 10, 2009
Messages
474
I wanna share what I think is a better implementation for delayed actions.
Good Stuff, clever way of recycling a single timer!
Just a side question, have you benchmarked the performance of your implementation in contrast to both OP's solution and my naive approach? If it's obvious and doesn't need a benchmark, feel free to ignore my question :D
I do ask, because you are producing a bit of overhead by using table.pack, table.unpack and table.sort in your data structure on every call. Not sure on pack and unpack, but sorting a big array can definitely be performance-intensive. Also, not all sorting algorithms take advantage of arrays that are already nearly sorted, like yours. Is table.sort doing so? If not, it might be better to manually search the right queue spot i for inserting a new element (doable in a single loop) and replace table.sort(queue, comp) by table.insert(queue, i, {timeout + elapsed, callback, table.pack(...)}).
 

AGD

AGD

Level 16
Joined
Mar 29, 2016
Messages
688
Regarding the on_expire code (DelayedActionExecute in OP's case), both got unpack calls. OP got two, but it could be cut down to one so I'll be using this ideal version of OP's code (only 1 unpack) since we're primarily concerned with the designs of these two approaches. Mine has a table.pack(...) in the registration function while OP has none, but ideally his should have a table.pack() too in order for the all the arguments to be preserved including nils. Note that it is not rare to have nils in function arguments - sometimes optional parameters are found in the middle of the parameter list (tho ideally they should be listed last). So in short, both mine and OP's approach are even in terms of table.pack/unpack calls.

Regarding the expiration code, even if there is only 1 callback about to expire, the constant overhead in mine compared to OPs is VERY minimal. And when there are 2 (maybe 3) or more callbacks about to expire at the same time, mine becomes more efficient performance-wise. What mine does conceptually is like converting for example:
Lua:
for i = 1, 50 do
    TimerStart(CreateTimer(), 1., false, function ()
        print("Expired")
    end)
end
into
Lua:
TimerStart(CreateTimer(), 1., false, function ()
    for i = 1, 50 do
        print("Expired")
    end
end)
Both would print "Expired" 50 times but as you can see the latter is more efficient and would be more performant.

For the sorting (just checked, table.sort() uses quicksort: best-case O(nlogn), ave-case same, worst-case O(n^2)), you're totally right I should use insertion sort (best-case O(1), worst-case O(n)) since I'm sorting every registration.

In short, for mine to be overall more efficient than OPs, the benefits of my merged callbacks should be able to overcome the overhead of the sorting at registration. I think the saved overhead of multiple timer expiring (as well as running!) would easily overshadow the sorting but the only reliable way to test this is as you said, doing a benchmark. Maybe you could help benchmark this? :D

Btw, mine should also use pcall() for the callbacks.

EDIT: Updated script:
Lua:
--[[ DelayedExecute v1.0.1 by AGD |

    Description:

        Small Lua script for delayed callback execution


    Requirements:

        - N/A

]]--
--[[

    API:

        function ExecuteDelayed(delay:number, callback:function, ...)
        - Executes <callback> after <delay> seconds with <...> as its arguments

]]--

do
    local TIMEOUT_OFFSET_TOLERANCE = 0.01

    local timer = CreateTimer()
    local elapsed = 0.
    local queue = {}
    local n = 0

    local function on_expire()
        elapsed = elapsed + TimerGetTimeout(timer)

        -- The first in the loop (i.e., the last in the queue) is guaranteed to
        -- be called. After that, it will continue to loop for callbacks whose
        -- expiration time is within <TIMEOUT_OFFSET_TOLERANCE> threshold and
        -- call them instead of unnecessarily re-running the timer with a very
        -- miniscule (or even 0.) timeout.
        -- This won't necessarily loop through all callbacks since the queue is
        -- sorted. It will stop at the first callback whose expiration time is
        -- greater than the threshold.
        while queue[n][1] - elapsed <= TIMEOUT_OFFSET_TOLERANCE do
            pcall(queue[n][2], table.unpack(queue[n][3], 1, queue[n][3].n))
            queue[n] = nil -- Pop called function
            n = n - 1
        end

        if n > 0 then
            TimerStart(timer, queue[n][1] - elapsed, false, on_expire)
        else
            elapsed = 0.
            PauseTimer(timer) -- This pausing of timer may not be necessary
        end
    end

    ---@param timeout number
    ---@param callback function|table is table, must be callable
    function ExecuteDelayed(timeout, callback, ...)
        n = n + 1
        timeout = timeout + elapsed
        queue[n] = {timeout, callback, table.pack(...)}

        if n == 1 then
            TimerStart(timer, timeout, false, on_expire)
        else
            -- Sort timeouts in descending order
            local i = n
            while i > 1 and timeout > queue[i - 1][1] do
                queue[i], queue[i - 1] = queue[i - 1], queue[i]
                i = i - 1
            end
        end
    end
end

EDIT: More up-to-date script below
 
Last edited:
Level 20
Joined
Jul 10, 2009
Messages
474
That absolutely makes sense, thanks! As you have reduced the sorting to O(n) now, I think we can trust on your version being more performant and can skip the benchmark :)

I'm even thinking on implementing this logic into all my systems that use a flexible number of timers.

Btw., you might want to address the following bugs:
  1. You currently don't support delayed executions with a timeout smaller than queue[n][1]. It would require restarting the timer even in the n>1 case.
    Example:
    Lua:
    ExecuteDelayed(5., print, "bla")
    ExecuteDelayed(3.,print,"blabla")
    will show both prints aber 5 seconds.
  2. The system doesn't retain execution order, e.g.
    Lua:
    ExecuteDelayed(5., print, "A")
    ExecuteDelayed(5., print, "B")
    will first print B and afterwards A.
  3. The timeout of any ExecuteDelayed call is currently added to the point in time, where the last call happened, i.e.
    Lua:
    ExecuteDelayed(5., print, "A")
    <Wait 4 seconds>
    ExecuteDelayed(3., print, "B")
    will print both A and B at the same time, although there should be 2 seconds pause in between.
    I reckon timeout = timeout + elapsed + TimerGetElapsed() should fix that.
  4. The system shows weird behaviour after a few times of usage. I ran the following code, waited until it got printed, and ran again (and so on):
    Lua:
    ExecuteDelayed(2., print, "A")
    The third use would not print "A", while the fourth use showed two "A" at the same time...
 

AGD

AGD

Level 16
Joined
Mar 29, 2016
Messages
688
Hmm nice finds, thank you for pointing them out.

EDIT:
I was able to fix 1, 2, & 3 but 4 seems to be due to TimerGetElapsed() returning 0 shortly before it even expires (This is not always the case, as there are times TimerGetElapsed() returns the correct value at the moment (not before) of expiration). I'm curious if there're already any info/threads related to this as I can't seem to find any (This thread for example doesn't contain any info regarding this). I could probably fix this if I have enough info regarding this peculiar behaviour of timer natives.
 
Last edited:

AGD

AGD

Level 16
Joined
Mar 29, 2016
Messages
688
Oh, that sounds weird.

[Lua] Repaired Timer Natives might be worth a try, but it's based off the same information thread you mentioned, so low hopes.

Was TimerGetRemaining() returning the correct values in your test? If so, you could just calculate TimerGetTimeout() - TimerGetRemaining().
The bug still hasn't been fixed. I tried TimerGetTimeout() - TimerGetRemaining() but it still is problematic in some cases. Anyway, I'll just put the latest version here 'til I can fix it. Maybe you could also attempt to find a solution.

Lua:
--[[ DelayedExecute v1.0.2 by AGD |

    Description:

        Small Lua script for delayed callback execution


    Requirements:

        - N/A

]]--
--[[

    API:

        function ExecuteDelayed(delay:number, callback:function, ...)
        - Executes <callback> after <delay> seconds with <...> as its arguments

]]--
do
    local TIMEOUT_OFFSET_TOLERANCE = 0.01

    local timer = CreateTimer()
    local elapsed = 0.
    local queue = {}
    local n = 0
    local pack, unpack = table.pack, table.unpack

    local function on_expire()
        local new_elapsed = elapsed + TimerGetTimeout(timer)

        -- The first in the loop (i.e., the last in the queue) is guaranteed to
        -- be called. After that, it will continue to loop for callbacks whose
        -- expiration time is within <TIMEOUT_OFFSET_TOLERANCE> threshold and
        -- call them instead of unnecessarily re-running the timer with a very
        -- miniscule (or even 0.) timeout.
        -- This won't necessarily loop through all callbacks since the queue is
        -- sorted. It will stop at the first callback whose expiration time is
        -- greater than the threshold.
        while queue[n][1] - new_elapsed <= TIMEOUT_OFFSET_TOLERANCE do
            pcall(queue[n][2], unpack(queue[n][3], 1, queue[n][3].n))
            queue[n] = nil -- Pop executed callback
            n = n - 1
        end
        elapsed = new_elapsed

        if n > 0 then
            TimerStart(timer, queue[n][1] - elapsed, false, on_expire)
        else
            elapsed = 0.
            -- These two functions below may not be necessary
            TimerStart(timer, 0, false)
            PauseTimer(timer)
        end
    end

    ---@param timeout number
    ---@param callback function|table if table, must be callable
    function ExecuteDelayed(timeout, callback, ...)
        n = n + 1
        local queue_timeout = timeout + elapsed + TimerGetElapsed(timer)
        queue[n] = {queue_timeout, callback, pack(...)}

        if n == 1 then
            TimerStart(timer, timeout, false, on_expire)
        else
            -- Sort timeouts in descending order
            local i = n
            while i > 1 and queue_timeout >= queue[i - 1][1] do
                queue[i], queue[i - 1] = queue[i - 1], queue[i]
                i = i - 1
            end

            if i == n then
                -- No sorting happened which means this timeout is the next to
                -- expire - update timer timeout to this timeout.
                TimerStart(timer, timeout, false, on_expire)
            end
        end
    end
end
 
Last edited:
Level 20
Joined
Jul 10, 2009
Messages
474
@AGD, I finally found time to test your script today.

It seems that on_expire wasn't able to properly reset the elapsed variable after executing queue[1], because you didn't include the n>0 check into the while-condition, which was consequently checking queue[0][1] - new_elapsed <= TIMEOUT_OFFSET_TOLERANCE and broke execution due to queue[0][1] being nil.

The following code solves that problem (a simple and condition is sufficient, because the and-operator is short-circuit in lua, i.e. the second part will not be evaluated, if the first is already false). However, I'm not sure, if I've properly solved the additional quirks you mentioned in your last post (so I'm assuming they just came from the mentioned bug, which is now fixed, but would you please run the same test as last time to see, if that's true).
Lua:
--[[ DelayedExecute v1.0.2 by AGD |
    Description:
        Small Lua script for delayed callback execution

    Requirements:
        - N/A
]]--
--[[
    API:
        function ExecuteDelayed(delay:number, callback:function, ...)
        - Executes <callback> after <delay> seconds with <...> as its arguments
]]--
do
    local TIMEOUT_OFFSET_TOLERANCE = 0.01
    local timer = CreateTimer()
    local elapsed = 0.
    local queue = {}
    local n = 0
    local pack, unpack = table.pack, table.unpack
    local function on_expire()
        elapsed = elapsed + TimerGetTimeout(timer)
        --print("start on_expire", "elapsed:", elapsed, "TimerTimeout:", TimerGetTimeout(timer), "new_elapsed:",new_elapsed, "queue[n][1]:", queue[n][1], "n", n)
        -- The first in the loop (i.e., the last in the queue) is guaranteed to
        -- be called. After that, it will continue to loop for callbacks whose
        -- expiration time is within <TIMEOUT_OFFSET_TOLERANCE> threshold and
        -- call them instead of unnecessarily re-running the timer with a very
        -- miniscule (or even 0.) timeout.
        -- This won't necessarily loop through all callbacks since the queue is
        -- sorted. It will stop at the first callback whose expiration time is
        -- greater than the threshold.
        while n > 0 and (queue[n][1] - elapsed <= TIMEOUT_OFFSET_TOLERANCE) do
            pcall(queue[n][2], unpack(queue[n][3], 1, queue[n][3].n))
            queue[n] = nil -- Pop executed callback
            n = n - 1
        end
        if n > 0 then
            TimerStart(timer, queue[n][1] - elapsed, false, on_expire)
        else
            elapsed = 0.
            -- These two functions below may not be necessary
            TimerStart(timer, 0, false)
            PauseTimer(timer)
        end
    end
    ---@param timeout number
    ---@param callback function|table if table, must be callable
    function ExecuteDelayed(timeout, callback, ...)
        n = n + 1
        local queue_timeout = timeout + elapsed + TimerGetElapsed(timer)
        queue[n] = {queue_timeout, callback, pack(...)}
        -- Sort timeouts in descending order
        local i = n
        while i > 1 and queue_timeout >= queue[i - 1][1] do
            queue[i], queue[i - 1] = queue[i - 1], queue[i]
            i = i - 1
        end
        if i == n then
            -- No sorting happened which means this timeout is the next to
            -- expire - update timer timeout to this timeout.
            TimerStart(timer, timeout, false, on_expire)
        end
    end
end
I also dared to simplify your code on two places: removed the new_elapsed local in on_expire and removed the n==1 case in ExecuteDelayed, as it would imply i==n anyway (which was starting the timer in the else part).

As a sidenote, considering any variable x (like a global integer variable, but can be a local as well), ExecuteDelayed(1., function() print(x) end) would print the value that x will have in 1 second, while ExecuteDelayed(1., print, x) would print the value that x has now, so the two ways of usage are not equivalent.
Not sure, if that's a problem, though. Seems more like a logical implication of variable references being evaluated at function runtime.
 
Last edited:

AGD

AGD

Level 16
Joined
Mar 29, 2016
Messages
688
It seems that on_expire wasn't able to properly reset the elapsed variable after executing queue[1], because you didn't include the n>0 check into the while-condition, which was consequently checking queue[0][1] - new_elapsed <= TIMEOUT_OFFSET_TOLERANCE and broke execution due to queue[0][1] being nil.
You're right, I was wrong about the TimerGetTimeout() being the problem. Shame on me for blaming it on the natives :grin:.

I also dared to simplify your code on two places: removed the new_elapsed local in on_expire and removed the n==1 case in ExecuteDelayed, as it would imply i==n anyway (which was starting the timer in the else part).
No problem but the removal of new_elapsed reintroduced the bug it was intended to solve which happens when calling ExecuteDelayed() inside a code that is run by ExecuteDelayed(). For example, in this test code:
Lua:
require('delayedexecute')

do
    local function callback(msg)
        print(msg)
    end

    ExecuteDelayed(1.00, callback, "A:1.00")
    ExecuteDelayed(1.20, callback, "B:1.20")
    ExecuteDelayed(1.30, callback, "C:1.30")
    ExecuteDelayed(1.40, callback, "D:1.40")
    ExecuteDelayed(2.00, callback, "E:2.00")
    ExecuteDelayed(1.50, callback, "F:1.50")
    ExecuteDelayed(0.90, callback, "G:0.90")
    ExecuteDelayed(0.90, callback, "H:0.90")
    ExecuteDelayed(0.90, callback, "I:0.90")
    ExecuteDelayed(0.90, callback, "J:0.90")
    ExecuteDelayed(0.95, callback, "K:0.95")

    -- delay queue registration for 1.95s and execute 0.3s after registration
    ExecuteDelayed(1.95, function ()
        ExecuteDelayed(0.30, callback, "L:2.25")
    end)

    -- delay queue registration for 3s and execute 0.3s after registration
    ExecuteDelayed(3.00, function ()
        ExecuteDelayed(0.30, callback, "M:3.30")
    end)

    TimerStart(CreateTimer(), 3.00, false, function ()
        ExecuteDelayed(0.30, callback, "N:3.30")
    end)

    local i = 0
    TimerStart(CreateTimer(), 2, true, function ()
        i = i + 1
        if i < 7 then
            ExecuteDelayed(2, print, "O")
        else
            PauseTimer(GetExpiredTimer())
        end
    end)

    local elapsed = 0.
    TimerStart(CreateTimer(), 1/5, true, function ()
        elapsed = elapsed + TimerGetTimeout(GetExpiredTimer())
        print("Elapsed Time: " .. tostring(math.floor(elapsed*100 + 0.5)/100))
    end)
end
the message "L:2.25" happens at the 2.70s mark instead of at 2.25s, so I had to add new_elapsed back.

As a sidenote, considering any variable x (like a global integer variable, but can be a local as well), ExecuteDelayed(1., function() print(x) end) would print the value that x will have in 1 second, while ExecuteDelayed(1., print, x) would print the value that x has now, so the two ways of usage are not equivalent.
Not sure, if that's a problem, though. Seems more like a logical implication of variable references being evaluated at function runtime.
Yeah I think that's just really two statements that have different meaning, though it's something that might trip up the user.

Again, thanks for solving that last bug and for your other suggestions and feedback =). From my tests at least, all seems to behave as intended now.

Here's the latest code:
delayedexecute.lua
Lua:
--[[ DelayedExecute v1.0.3 by AGD |

    Description:

        Small Lua script for delayed callback execution


    Requirements:

        - N/A


    Credits:

        - Eikonium (Bug-fixes, feedback & improvements)

]]--
--[[

    API:

        function ExecuteDelayed(delay:number, callback:function, ...)
        - Executes <callback> after <delay> seconds with <...> as its arguments

]]--

do
    local TIMEOUT_OFFSET_TOLERANCE = 0.01

    local timer = CreateTimer()
    local elapsed = 0.
    local queue = {}
    local n = 0
    local pack, unpack = table.pack, table.unpack

    local function on_expire()
        local new_elapsed = elapsed + TimerGetTimeout(timer)

        -- The first in the loop (i.e., the last in the queue) is guaranteed to
        -- be called. After that, it will continue to loop for callbacks whose
        -- expiration time is within <TIMEOUT_OFFSET_TOLERANCE> threshold and
        -- call them instead of unnecessarily re-running the timer with a very
        -- miniscule (or even 0.) timeout.
        -- This won't necessarily loop through all callbacks since the queue is
        -- sorted. It will stop at the first callback whose expiration time is
        -- greater than the threshold.
        while n > 0 and (queue[n][1] - new_elapsed <= TIMEOUT_OFFSET_TOLERANCE) do
            pcall(queue[n][2], unpack(queue[n][3], 1, queue[n][3].n))
            queue[n] = nil -- Pop executed callback
            n = n - 1
        end
        elapsed = new_elapsed

        if n > 0 then
            TimerStart(timer, queue[n][1] - elapsed, false, on_expire)
        else
            elapsed = 0.
            -- These two functions below may not be necessary
            TimerStart(timer, 0, false)
            PauseTimer(timer)
        end
    end

    ---@param timeout number
    ---@param callback function|table if table, must be callable
    function ExecuteDelayed(timeout, callback, ...)
        n = n + 1
        local queue_timeout = timeout + elapsed + TimerGetElapsed(timer)
        queue[n] = {queue_timeout, callback, pack(...)}

        -- Sort timeouts in descending order
        local i = n
        while i > 1 and queue_timeout >= queue[i - 1][1] do
            queue[i], queue[i - 1] = queue[i - 1], queue[i]
            i = i - 1
        end

        if i == n then
            -- No sorting happened which means this timeout is the next to
            -- expire - update timer timeout to this timeout.
            TimerStart(timer, timeout, false, on_expire)
        end
    end
end
 
Level 20
Joined
Jul 10, 2009
Messages
474
Again, thanks for solving that last bug and for your other suggestions and feedback =).
Yw :)
No problem but the removal of new_elapsed reintroduced the bug it was intended to solve which happens when calling ExecuteDelayed() inside a code that is run by ExecuteDelayed().
Right, I hadn't thought about nested executions!

Now where I think about it, aren't nested calls still an issue in combination with the TIMEOUT_OFFSET_TOLERANCE thing?
on_execute is looping through queue[n] in descending order, while each loop step could potentially call a nested ExecuteDelayed and thus insertion-sort the array, shuffling the current index away.
Lua:
--The loop is using an upvalue as loop index, not a local
while n > 0 and (queue[n][1] - new_elapsed <= TIMEOUT_OFFSET_TOLERANCE) do
    pcall(queue[n][2], unpack(queue[n][3], 1, queue[n][3].n)) --if queue[n][2] is another ExecuteDelayed, n will be increased.
    queue[n] = nil -- n doesn't necessarily have the same value as in the line above
    n = n - 1
end
Fortunately, this does indeed behave as intended precisely because you haven't used a local to loop. n is being increased by 1 from outside and the queue[n] is being swapped to queue[n+1] at the same time (as all newcomers to the stack are future executions), so queue[n+1] needs to be nilled, which is exactly what happens.
Still, outside interferences to a while loop counter are really hard to judge on their correctness and can produce bugs in very weird scenarios.

In our case, the above fortunate upvalue index shift can be screwed up by the following scenario:
  • Lua:
    function Nested()
        ExecuteDelayed(0.002, print, "B")
    end
    
    ExecuteDelayed(1., print, "A")
    ExecuteDelayed(1.005, Nested) --immediate followup call due to timeout difference < TIMEOUT_OFFSET_TOLERANCE
    Executing the code will freeze Warcraft, because immediate followup calls creating another immediate followup call with even smaller timeout will sort the queue in the "wrong" way and lead to the new entry being nilled, while the old is kept and immediately called again, leading to an endless loop.
  • Even if we set TIMEOUT_OFFSET_TOLERANCE to 0, we can still reproduce this freeze by nesting another Execute Delayed with a negative timeout, i.e.
    Lua:
    function Nested()
        ExecuteDelayed(-1., print, "B")
    end
    
    ExecuteDelayed(1., print, "A")
    ExecuteDelayed(1., Nested)

One potential fix would be to do:
Lua:
local q
while n > 0 and (queue[n][1] - new_elapsed <= TIMEOUT_OFFSET_TOLERANCE) do
    q = queue[n]
    queue[n] = nil
    n = n-1
    pcall(q[2], unpack(q[3], 1, q[3].n))
end
But honestly, the safest approach IMO would be to remove the immediate followup calls (thus not needing the upvalue based while loop anymore) and maximize negative timeout inputs with zero. That's up to you, though.

Another thing: A nested call might even trigger both the TimerStart in the n>0 case in on_expire and the TimerStart in the i == n case in ExecuteDelayed. Not sure, if that's a problem, though.

Sorry for the wall of text. Not sure, why that happens to me so quickly all the time :D
And honestly, when we started our discussion, I wasn't anticipating the whole thing to become this tricky :p

Edit: Another bug:
Lua:
ExecuteDelayed(5., print, "A")
<Wait 3 seconds>
ExecuteDelayed(1., print, "B")
will print "A" after more than 7 seconds.

Edit2: the bug was because elapsed needs to be incremented by TimerGetElapsed() in the i==n case of ExecuteDelayed (before restarting the timer).

I've attached a fixed version of the code, which also removed the upvalue-based while loop and shuffling side effects, including TIMEOUT_OFFSET_TOLERANCE. The immediate followup calls were a really nice idea, but I think it's cleaner this way and much less bug-prone :). All changes to the queue (like decrementing counter and pop action) are now made before pcall to prevent any potential side effects of nested calls.

Lua:
--[[ DelayedExecute v1.0.4 by AGD |
    Description:
        Small Lua script for delayed callback execution

    Requirements:
        - N/A

    Credits:
        - Eikonium (Bug-fixes, feedback & improvements)
]]--
--[[
    API:
        function ExecuteDelayed(delay:number, callback:function, ...)
        - Executes <callback> after <delay> seconds with <...> as its arguments
]]--
do
    local timer = CreateTimer()
    local elapsed = 0.
    local queue = {}
    local n = 0
    local pack, unpack = table.pack, table.unpack
    local function on_expire()
        local topOfQueue = queue[n]
        queue[n] = nil
        n = n - 1
        elapsed = topOfQueue[1]
        if n > 0 then
            TimerStart(timer, queue[n][1] - elapsed, false, on_expire)
        else
            elapsed = 0.
            -- These two functions below may not be necessary
            TimerStart(timer, 0, false)
            PauseTimer(timer)
        end
        pcall(topOfQueue[2], unpack(topOfQueue[3], 1, topOfQueue[3].n))
    end
    ---@param timeout number
    ---@param callback function|table if table, must be callable
    ---@vararg any arguments of the callback function
    function ExecuteDelayed(timeout, callback, ...)
        n = n + 1
        timeout = math.max(timeout, 0.)
        local queue_timeout = timeout + elapsed + math.max(TimerGetElapsed(timer), 0.) --TimerGetElapsed() can return negative values sometimes, not sure why.
        queue[n] = {queue_timeout, callback, pack(...)}
        -- Sort timeouts in descending order
        local i = n
        while i > 1 and queue_timeout >= queue[i - 1][1] do
            queue[i], queue[i - 1] = queue[i - 1], queue[i]
            i = i - 1
        end
        if i == n then
            -- No sorting happened which means this timeout is the next to
            -- expire - update timer timeout to this timeout.
            elapsed = queue_timeout - timeout
            TimerStart(timer, timeout, false, on_expire)
        end
     
    end
end
 
Last edited:

Bribe

Code Moderator
Level 50
Joined
Sep 26, 2009
Messages
9,456
So I'm getting back into Lua, and have improved TimerUtils a little after reviewing your code.

With [Lua] - TimerUtils 2.0, your code can comfortably be shortened to:

Lua:
   function DelayedAction(delay, func, ...)
      if type(func) == "nil" or type(delay) == "nil" or delay < 0.00 then
        return nil
      end
      
      return NewTimer(function() pcall(func, table.unpack({...})) end, delay)
   end

Edit: Aside from the masterpiece included earlier in this thread (DelayedExecute by @AGD and @Eikonium ), this resource is redundant. TimerUtils 2.1 even allows the arguments to be swapped, so one can just do:

Lua:
NewTimer(69, function()
   print("nice")
end)
 
Last edited:
Top