Event connections keeping references alive after being disconnected

Reproduction Steps
When the only strong reference to an object is in a function connected to an event, you’ll notice the object isn’t GC’ed after the event is disconnected:

local part = Instance.new("Part")
local tbl = setmetatable({}, { __mode = "k" })

do
    local obj = {}
    tbl[obj] = true

    local conn = part.Touched:Connect(function()
        local upv = obj -- only strong reference to obj beyond itself (which goes out of scope)
    end)

    conn:Disconnect() -- should cause there to be no more references to obj
end

part:Destroy() -- still a problem

while true do
    print(next(tbl))
    task.wait(1)
end

Expected Behavior
When a strong reference to an object is inside of a function connected to an event listener, the expectation is that the reference will be lost when the event is disconnected, letting the object be garbage collected if that was the only remaining reference.

Actual Behavior
Disconnecting an event does not allow the references it holds to be freed, leaving room for unexpected memory leaks.

Edit: Seems to function as expected if the connection and object are wrapped in a function instead of a do block, not sure the significance of that.

Issue Area: Engine
Issue Type: Other
Impact: High
Frequency: Constantly
Date First Experienced: 2021-12-22 00:12:00 (-05:00)

6 Likes

When wrapping it in a anonymous function, it does behave as expected. This is some strange behaviour.

local part = Instance.new("Part")
local tbl = setmetatable({}, { __mode = "k" });

(function() 
    local obj = {}
    tbl[obj] = true

    local conn = part.Touched:Connect(function()
        local upv = obj -- only strong reference to obj beyond itself (which goes out of scope)
    end)

    conn:Disconnect() -- should cause there to be no more references to obj    
end)()



part:Destroy()
part = nil

while true do
    print("should be empty: ", next(tbl))
    task.wait(1)
end

2 Likes

This is weird. Are you 100% sure the garbage collector runs in the first example? You could possibly be running into an issue where its allowed to be garbage collected, but the actual garbage collector doesn’t see a need to run at that time.

You can confirm this by forcing the garbage collector to run and seeing if the issue persists. Try and make a lot of garbage.

1 Like

Good point, I’ll try this and get back to you.

local part = Instance.new("Part")
local tbl = setmetatable({}, { __mode = "k" });

(function() 
    local obj = {}
    tbl[obj] = true
    local conn
    conn = part.Touched:Connect(function()
        local upv = obj -- only strong reference to obj beyond itself (which goes out of scope)
    end)

    conn:Disconnect() -- should cause there to be no more references to obj    
end)()



part:Destroy()
part = nil

while true do
    print("should be empty: ", next(tbl))
    task.wait(1)
end

maybe try this? Defined the con variable outside connection.

Tried making a lot of garbage, it seems to all get garbage collected properly while the object in the connected event still doesn’t, so garbage collection cycles are happening. Even tried making a lot of garbage every second:

local part = Instance.new("Part")
local tbl = setmetatable({}, { __mode = "k" })
local garbage = setmetatable({}, { __mode = "k" })

do
	local obj = {}
	tbl[obj] = true

	local conn = part.Touched:Connect(function()
		local upv = obj -- only strong reference to obj beyond itself (which goes out of scope)
	end)

	conn:Disconnect() -- should cause there to be no more references to obj
end

part:Destroy() -- still a problem

while true do
	print("Object: ", next(tbl))
	print(string.format("Garbage was collected: %s", next(garbage) and "false" or "true"))
	
	for i = 1, 10^6 do
		local a = {} -- Make more garbage
		garbage[a] = true
	end
	
	task.wait(1)
end

Output:
image

1 Like

That example works because it’s wrapped in a function rather than a do block, but with the do block it’s still not collecting. The issue isn’t that the event’s not being disconnected; it’s being disconnected just fine, but the references aren’t freed when that disconnection happens

That’s not an issue with the RBXScriptSignal engine.
It just seems as if using do blocks doesn’t mean that the vars are actually able to be collected.

The issue seems to be the fact that the thread never closes, because you have a loop there.

Try wrapping the loop in a task.spawn or task.defer.

1 Like

Notice that without the reference in the event handler, the variable inside the do block does get collected, despite the thread not dying, indicating that it is indeed the event that’s causing this problem. Expected behaviour is for references to be lost when the event is disconnected.

References to objects that go out of scope are meant to be lost, the thread they were declared in doesn’t have to end for that to happen.

1 Like

It doesn’t seem like it.

I have my own signal implementation, and I get the same result.

This is the code I’m using:

local FastSignal = require(
	game:GetService("ReplicatedStorage").FastSignal
)

local Event = FastSignal.new()

local weak = setmetatable({}, {__mode = 'kv'})

do
	local tbl = {}
	weak[1] = tbl
	
	local conn = Event:Connect(function()
		local upv = tbl
	end)
	
	conn:Disconnect()
	print(Event)
end

task.defer(function()
	while true do
		print(weak)
		task.wait(1)
	end
end)

I’m checking the Event object, but after disconnection, it does in fact get cleared from it.

This is probably some other GC quirk.


I’m gonna keep my computer running, and I’m gonna see if it ever gets cleaned up, it’s possible it just gets says to the GCer: “oh this is a much more complex object so we can take longer since it seems like it’s not gonna be freed anytime soon”

Result: Did not get cleaned.

1 Like

You’re right, it doesn’t seem to be an issue with event connections but with the functions passed to them. Seems functions aren’t being collected properly, looking into it now.

1 Like

I wonder if it’s possible <at all> for it to have anything to do with this.

2 Likes

I think that may explain it actually. That would explain why wrapping it in a function solves the issue; that would prevent this function from being shared as it contains an upvalue that isn’t top-level.

If that’s the case then I don’t think there’s any cause for concern, because any function that would be holding references that could cause a memory leak, wouldn’t qualify to be shared and would instead be unique functions that would get properly collected, I assume.

Edit: Yeah this definitely is the case. Changing the upvalue after initialization allows the function to be garbage collected:

local part = workspace:WaitForChild("TestPart")
local t = setmetatable({}, {__mode="k"})
local a = 1
a = 2

local f = function()
	local b = a
end
t[f] = true
f = nil


while true do
	task.wait(1)
	print(next(t))
end

Which makes sense considering:

Currently Luau compiler makes a decision to share the functions when the functions don’t have upvalues (that is, don’t refer to locals outside of their scope), or all upvalues are declared at the script level (aka top-level) and aren’t modified after initialization.

Thanks for pointing this out!

1 Like