Non-deterministic event loss during recursive registration #15


  • Defect
Open
  • jaliborc created this issue Jan 10, 2026

    First, thank you for maintaining this project; it has been a cornerstone of the ecosystem for a long time.

     

    I’ve unfortunately identified an issue in CallbackHandler that causes events to be silently dropped. In environments where this library is shared across multiple independent addons (such as through AceAddon), this creates unpredictable behavior that is extremely difficult to debug.

    The Problem

    The current logic uses an insertQueue to prevent table mutation during iteration, which is an excellent idea. However, because the queue is only processed afterwords (when oldrecurse == 0), any events fired recursively (while a dispatch is already in progress) will not be seen by callbacks currently sitting in the queue, and the messages are lost.

     

    This is a minimal example for non-determenistic behavior:

    function example()
      MyCallbacks:RegisterMessage('SOME_EVENT', function() print('Event received!') end)
      MyCallbacks:FireMessage('SOME_EVENT')
    end

    Whether "Event received!" prints depends entirely on whether example() call results indirectly or not from another Fire execution:

    • If example() is called independently, it works as expected.

    • If example() is triggered by a different callback (recursion), the new registration stays in insertQueue until the top-level dispatch finishes, causing the FireMessage to be "lost".

    Notice that this is still true if Addon A registers an event and Addon B fires it, this is just the simplest example.

     

    Why this is a Critical Flaw

    Because CallbackHandler is a shared dependency, the recursion is often "global" across the entire UI:

     

    • Unpredictability: A developer cannot know if their code is being executed due to a Fire call from a completely unrelated addon.

    • Silent Failure: If Addon A triggers a callback that causes Addon B to initialize and fire an event (for example it initializes a new frame which needs to register events), Addon B will fail to receive its own events, with no error thrown.

    • Non-determinism: The behavior changes based on which addon happened to trigger which execution chain of events, making it nearly impossible to reproduce consistently.

    Possible Solutions

    There are likely several ways to approach a fix, here's my suggestion to help with it: during recursion, treat tables as immutable, and do a table swap instead of a queue, framebuffers style. This idea would likely be more efficient if recursion was tracked per-event as well, instead of globally.

    recurse = {}<br />target[RegisterName] = function(self, eventname, method, ...) 
    	-- ... the current type check, regfunc closure creation code
    
    	if (recurse[eventname] or 0) &gt; 0 then
    		-- Create a new version of the table so existing iterators aren't disturbed
    		events[eventname] = CopyTable(events[eventname])
    	end
    
    	events[eventname][self] = regfunc
    	if registry.OnUsed and first then
    		registry.OnUsed(registry, target, eventname)
    	end<br />end
    function registry:Fire(eventname, ...)
    	if not rawget(events, eventname) or not next(events[eventname]) then return end
    	local oldrecurse = recurse[eventname] or 0
    	recurse[eventname] = oldrecurse + 1
    	Dispatch(events[eventname], eventname, ...) -- dispatch will use the current version of the table, which is immutable (and thus safe)
    	recurse[eventname] = oldrecurse
    
    	 -- insertQueue logic is now gone
    end

     

     

     

     

     

     

     

  • jaliborc added a tag Defect Jan 10, 2026

To post a comment, please login or register a new account.