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.
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
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.
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
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
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.
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”
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.
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.