An overcomplicated look into Bindable immutability

Warning: this resource delves into some pretty complex topics with Roblox.

Bindables can act strangely when you pass functions, or tables with metatables or functions. This guide aims to explain why this happens and what’s actually happening.

Luau VMs

Roblox isolates every script context into it’s own global state, in this resource I’m going to refer to them as ‘VM stacks’.

Edit Note

Most of this thread was written under the assumption that Roblox creates new VMs when it comes to script contexts, what’s actually happening is Roblox creates a global state for each context. I dont want to edit the entire thread so just go off this when I’m talking about VM stacks


Each of these stacks have an isolated memory, which means Luau itself cant fetch values from one stack and give it to another, this is the reason for bindables.

If we declared shared.Boop in a script, but then tried to read it from the CommandBar, we’d get nil, as Boop does not exist in the command bar context.

Roblox does this for better security isolation, we dont want a regular script being able to mutate a CoreScript variable thats vital for the game to be running (not that Roblox should be using shared anyway but whatever)

Bindables

The way you send data between stacks is with Bindables, this behaviour is intentional (and actually allows for some funny exploits in its own right).

If we want to send Boop to the CommandBar, we’d first hook up a BindableFunction from a script, then invoke it from the CommandBar.

We can now read the value of Boop!

The main meat of this post

Now its good an all knowing how bindables work, I’m pretty sure most people know about this behaviour, so whats this post actually about? We’re talking about how functions and tables behave when crossing VM stacks.

On their own, functions cannot be sent if the Bindable is being listened to from multiple contexts

- Attempt to load a function from a different Lua VM

This behaviour is only applied if you actually attempt to call the function, interestingly, if you keep the bindable isolated to the same context, you can call it normally.

Slightly different logic applies for tables, where it actually rebuilds the table, this will happen regardless of which context it’s being accessed from. This recreation will also strip metatables.

You will get the same function behaviour if it exists in a table where it errors if you try to load it cross-vm

local t1 = {
  bar = print
}

local Event = Instance.new("BindableEvent")
Event.Event:Connect(function(t2)
        print(t2.bar) --> function 0x00000000
	print(rawequal(t1, t2)) --> false
end)

Event:Fire(t1)
Keeping tables the same

If you can ensure that your events are only going to stay in one memory stack, it may be better to use a custom signal library to implement your events, as you can ensure the table will stay the same


Now, why does all this happen? Simple: if we have isolated memory, its impossible to access a function from one memory sandbox that would have to cross into another memory sandbox, since to that VM stack. The only memory that exists is it’s own sandbox and such does not exist.

The Attempt to load a function from a different Lua VM error is actually an error that warns you that a function cannot be read, if this safeguard did not exist, you could get very unintenional things happen.

There’s also a weird oddity of bindables where strings are recreated internally.

Side Effects of this restriction

SetCore requires you to pass BindableEvents or BindableFunctions through it instead of functions because the same cross-vm restriction applies here. The actual function call restriction is not isolated to bindables but exists in any piece of code that deals with going between VM stacks, again, tables are recreated and stripped before being sent over the stacks.

14 Likes

To elaborate on this: Lua(u)'s garbage collection is linked to one global state, what this means is that two global states cannot ever safely share any values because their garbage collectors are completely independent and have no way of communicating with each other to share values. This is desirable for many reasons, including the fact that you can have global states on different threads and Luau’s guarantees about VM isolation will basically ensure thread safety.

This also ensures, since no values (actually GCObjects) can ever be shared, code running in one global state will never be able to corrupt code running in a different state because it will never be able to obtain a reference that can mutate the other state.