[Beta] Deferred Lua Event Handling

Just because I got the monkey brain, if I dont understand any of this should I have to worry about updating my games or worry about stuff breaking? Can someone give me a laymen terms version of what’s actually changing here?

That’s not good. I work with UI and the ability to set elements to invisible or move them in the span of one frame is absolutely critical. @zeuxcg should take a look at this.

I’m glad this change has been clarified, though I wish it had been so sooner. I just hope we work out all the kinks like above first.

2 Likes

Basically, a lot of programmers have written code based on a lot of assumptions on how their code will function/what order things will run in.

Now, all of the sudden, roblox wants to change the order in which certain pieces of code run in order to optimize the engine in general, and make way for multithreaded code that runs on all processors in the future. However, as has been shown, a lot of existing (especially well-structured) code will all the suddenly break, and there are a lot of unpredictable bugs depending on how much you relied on the assumptions that are no longer true under this change.

Luckily, by default, this change is not enabled, and according to Arseny it won’t be enabled by default until it can be ensured that most games won’t completely break as a result. Right now there’s some bugs even with roblox’s own core scripts that control the camera and UI, so you really have to test it out yourself.

In workspace, the property called “SignalBehavior” controls whether these changes are enabled or not. If you set the SignalBehavior to Deferred, it will enable the new changes, so you can test out for yourself whether or not you notice any differences or bugs in your existing games. If there are issues, then you should set the SignalBehavior to Immediate, which will permanently disable these changes.

Right now, SignalBehavior is set to “Default” on all places, which right now will disable these new changes, but in the future they might be enabled if it’s left on “Default”.

4 Likes

I adjusted my game and plugins to support the deferred signal behavior this afternoon. I mainly needed to adjust my “safe call” implementation to use xpcall / coroutine.wrap instead of bindable events. I hadn’t noticed coroutine.wrap’s error tracing was fixed before now. Here’s what I ended up replacing it with:

Code
-- Silencing errors is bad. This error handler prints out clean errors using a bindable event.
-- It's unfortunate that we can't log the errors properly with red and blue text.
local bindable = Instance.new("BindableEvent")
bindable.Event:Connect(error)
local function errorHandler(msg)
	bindable:Fire(tostring(msg) .. "\nStack Begin\n" .. debug.traceback() .. "Stack End")
end


return {
	-- Immediately call f, but prevent it from interrupting the thread.
	SafeCall = function(f, ...)
		coroutine.wrap(xpcall)(f, errorHandler, ...)
	end,

	-- Immediately call f, but prevent it from interrupting the thread.
	-- It is assumed that 'f' doesn't yield. My debug implementation
	-- calls f during a __index metamethod.
	SafeCallNoYield = function(f, ...)
		xpcall(f, errorHandler, ...)
	end,

	-- This is what I use mostly. I only use it for "Async" Roblox APIs.
	FastSpawn = function(f, ...)--$inline_AssumeEllipsesSafe
		coroutine.wrap(f)(...)
	end,
}

This doesn’t seem ideal. Everything else is doable, but this is extremely tedious to fix for every case. If I disconnect a function from an event, I expect that it will no longer be called. This is likely to introduce rare edge cases in event-based code that otherwise works 99% of the time. My project uses 100% Lua-based signals for game logic, and I defer events until the end of the frame for a lot of things. When I disconnect an object (like a UI element or animation), I always make sure to remove anything it has added from the invocation queue.


I do it exactly like this at the end of my main RunService connections, except without table.remove for performance reasons.


Most experiences on Roblox are just hacked together and fall apart easily as the project grows. These are my recommendations for semi-advanced scripters planning to develop a huge project:

  1. Design everything to be disconnectable. A maid class makes this easy. Every event:Connect(...) should have a corresponding connection:Disconnect() (unless you destroy the signal’s instance and know what you’re doing.)
  2. Avoid yielding / spawning (wait, spawn, delay, WaitForChild, etc.) Roblox APIs are the exception, but I don’t like it.
  3. Create a a custom Lua-based scheduler that is disconnectable. This is a solid alternative to wait. When a player leaves, you just disconnect their maid and you don’t need to worry about any threads or memory references hanging around due to wait or other yielding nonsense.
  4. Use table-based signals if you can. They are lightweight and more efficient than BindableEvents, but it’s important to watch out for edge cases where a method disconnects a second method that is yet to be called while the event is firing. Custom signals don’t even need to use coroutine.wrap when nothing yields! (see 2.)
  5. Use ModuleScripts with 1 Script and 1 LocalScript as entry points. This gives you complete control over when your code runs. Get WaitForChild and those pesky “this script ran before that one” edge cases out of your life.
  6. Don’t keep your guis in StarterGui. Create them programmatically or clone them from ReplicatedStorage. Otherwise you will need to WaitForChild for every single button and instance.
  7. The first thing you need to set up is server and client error reporting. After 5 years of full time development, my project is 150k lines of code and it’s easy to keep completely error-free because of this.
  8. Keep ReplicatedStorage as slim as possible. Store as much as you can in ServerStorage. I replicate instances (even ModuleScripts) privately to players by parenting it to their PlayerGui, firing a reference to them using a RemoteEvent, then immediately parenting it to nil. This can vastly improve join times, make your project many times more scalable, and use less memory.

I have developed dozens of buggy frameworks before this. These are the main reasons I’ve been able to work on this project for so long, and why it was easy for me to transition to deferred event handling.

49 Likes

By the way, if you want table-based signal behavior that acts identically to Roblox signals with respect to what happens when you connect / disconnect handlers in the middle of the event firing, this is the code you want. Pretty efficient too because it doesn’t use any tables other than the signal object itself and the connection objects (no list of handlers / invalidation state is needed):

Table based event code
	function Connection:Disconnect()
		assert(self._connected, "Can't disconnect a connection twice.", 2)
		self._connected = false

		if self._signal._handlerListHead == self then
			self._signal._handlerListHead = self._next
		else
			local prev = self._signal._handlerListHead
			while prev and prev._next ~= self do
				prev = prev._next
			end
			if prev then
				prev._next = self._next
			end
		end
	end

	function Signal:Connect(fn)
		local connection = setmetatable({
			_fn = fn,
			_next = self._handlerListHead,
		}, Connection)
		self._handlerListHead = connection
		return connection
	end

	function Signal:Fire(...)
		local item = self._handlerListHead
		while item do
			if item._connected then
				-- Or spawn a coroutine depending on the behavior you want
				item._fn(...)
			end
			item = item._next
		end
	end
33 Likes

After coding with this new system for a bit, I can definitely say I slightly overreacted. While it isn’t trivial, it hasn’t been a horrible pain rewriting broken sections of code.

Hey everyone,

Spent some time today refactoring Jailbreak to support deferred event behavior. Haven’t attacked it from every angle with hundreds of players, but from as many things as I tried, it seems good to go. I think you all will find that it’s actually not too difficult of a change to make to your games, unless you’re doing something really odd.

I ran into a few dominant things:

  1. I was assuming that calling CollectionService.AddTag would immediately, in same execution, trigger a call to CollectionService.GetInstanceAddedSignal. This is no longer the case with deferred execution. So, I switched the code to use a promise instead, and everything fell together fairly easily. In fact, some things ended up being more elegant solutions. (If you use @Quenty’s Binder, you will probably run into this).

  2. A few times I would fire a Signal (custom class using BindableEvent) and immediately destroy it, assuming that it would fire all connections before destroying. This is still safe to do for a BindableEvent, but not for the Signal implementation I was using which would clean up (by setting some variables to nil) the pass-by-reference objects. After raising this to @Quenty, he wrote a new drop-in-place Signal module for his previous which makes no assumptions about ordering.

  3. A few cases where RunService events were running an extra time than intended, because they were already queued before they got disconnected. Checking connection.Connected as the OP mentioned took care of that, maybe better solution later.

I’m excited for the future this opens up, sounds like some nice optimizations and engine sandboxing. Hopefully games that opt-in can begin to take advantage of those before the full switch years down the road? :slight_smile:

(PS: @Tomarty’s recommendations are good. Especially #1, #2, and #6 has worked well for Jailbreak.)

47 Likes

Yeah we’re planning to fix this; we discovered this too late for this fix to be included in the initial release.

10 Likes

Hey would you mind looking into the UI bug posted by @DarthChadius ? (Assuming staff have not noticed it yet, so apologies if it’s being investigated)

Haven’t tested myself yet but I do a lot of UI work and this would undoubtedly break much of my ui. I rely a lot on being able to make changes to an element within the same frame without flickering.

1 Like

Is this just a customizable delay to events firing?

Great response, I have a better idea of why Roblox would like to implement this change, but

I have a question regrading the use of BinableEvents.

The reason why I use them are the following:

  1. They exist and provide visuals for debugging in Studio
  2. I can use WaitForChild to make sure they exist
  3. Up until now they were the only option to run code quickly without sacrificing stack traces
  4. They don’t cause issues with cyclical dependency when two modules need to act upon one another
  5. They can communicate across VMs

Now that “Deferred” will become the new behavior there are certain issues that have to be dealt with.

  1. Requiring an event to run immediately after it’s fired is no longer possible. For example
    • Reading a value that is expected to change after an event is fired is no longer possible

These issues might seem small but they have a huge impact for many of us.

We would like our events to be handled immediately when it’s fired, that is and always have been expected behavior for us.

While there are merits to the changes, I don’t necessarily understand what they are and how they would work nor do I have a perfect solution for this but I’d like to state the way I’m using BindableEvents in hopes of getting some sort of middle ground that we can work on.


I also use Attributes and CollectionService but unfortunately I wasn’t able to get through to the code that uses that yet since I’m still fixing the problems that occur with BindableEvents

3 Likes

The biggest issue for me are BindableEvents, like @RuizuKun_Dev said. They are currently widely used as signals by many, and suddenly making their dispatching deferred kills of a very legitimate use case, unlike the other events which are fired off either by internal Roblox processes or indirectly (the current issues with those sound more like implementation issues).

Yes, custom Lua based implementations are faster and use less memory, but BindableEvent has the benefit of using the Roblox data model as source of truth, allowing Instances to carry their events around and disconnect everything when they are gone. Now if I want to use an immediate signal pattern I’m forced to use a custom or 3rd party implementation since BindableEvents changed from being multi delegates to acting more like message dispatchers.

Also, what of BindableFunctions? Are they going to yield like RemoteFunctions now, or are they still immediate?

3 Likes

Really good question! With the current implementation the event handler will still be called, any variables that the handler has access to won’t be collected and the parameters it is called with will be as they were when the event was triggered.

However, we’re discussing changing the behavior of Disconnect so that subsequent queued handlers aren’t called if the first handler disconnects the event. This should affect instances that get destroyed to, any additional event handlers would not be called.

Which behavior makes the most sense to you?
  • If Disconnect is called no future event handlers are added to the queue, all event handlers currently in the queue are removed and never called
  • If Disconnect is called no future event handlers are added to the queue, event handlers already in the queue are still called at the first invocation point

0 voters

3 Likes

Thanks for sharing this. As a part of this feature we also defer when scripts start running. This has inadvertently resulted in scripts sometimes starting after PlayerAdded is called in Studio. We will be addressing this in a future update, for now you can manually resolve it by calling your handler for any player already in the game:

for _, player in ipairs(Players:GetPlayers()) do
  onPlayerAdded(player)
end

I wouldn’t expect this to work any differently with deferred event handling enabled. Your handler will still be called before we start rendering the next frame.

1 Like

If I’m understanding this correctly, does that mean if you connect AncestryChanged to an instance and then destroy it, AncestryChanged won’t actually fire since it’ll be deferred and then cleared? That seems like very unideal behavior.

Would it not be possible to try to run all the existing handlers when an instance is destroyed, before disconnecting them?

2 Likes

This is intended. With the existing behavior, calling Fire immediately resumes any waiting threads. This results in ‘a’ being printed and the thread being yielded for the next event which ends up being ‘b’.

Meanwhile, when deferred event handling is enabled you wait for the bindable to fire, the bindable is fired 5 times back to back, and then your waiting thread is resumed. This means the second iteration of the loop has nothing to wait for and will yield forever.

1 Like

At this point in time there is no change in behavior. Every property change will still result an event handler being called immediately or deferred.

1 Like

I’m sure both of them have valid use cases, but if you look at Javascript for example, it’s really common that you might want to call event.stopPropagation() which prevents future handlers from being called. Disconnect could do a similar thing, or maybe it should be its own method and keep current Disconnect functionality so you don’t break existing code. I’m not sure how this would work when parallel lua is involved, since iirc there’s a way to connect handlers that run in parallel?

4 Likes

Thank you for the report. I suspect I know what’s going on here, it should be pretty straight-forward to fix.

1 Like