Top-level closures with upvalues cause memory leaks

From what seems to be Luau’s closure caching optimization, top-level functions with upvalues that are eligible for caching can cause memory leaks. This can be a very common issue with instances and can very easily be missed since this behavior is not something that the ordinary person is aware of. This was causing memory leaks in my game and making certain temporary instances created during loading/intermission phases to never be collected and build up over time. These instances persist even if they are destroyed via Destroy.

Reproduction:

-- The ScreenGui instance will never be collected since the closure it is referenced in is cached.
local weak_table = setmetatable({ Instance.new("ScreenGui") }, {__mode = 'v'})

do
	local reference = weak_table[1]
	local cached_function = function()
		-- Force an upvalue capture.
		local upvalue = reference
	end
	cached_function = nil
end

while task.wait(0.1) and #weak_table > 0 do
	warn('Waiting for collection...')
end

warn('Done waiting for collection.') -- Never reached.

However, in this case where we can force the optimization to not happen, the instance is collected:

for i = 1, 1 do

	local weak_table = setmetatable({ Instance.new("ScreenGui") }, {__mode = 'v'})

	do
		local reference = weak_table[1]
		local cached_function = function()
			-- Force an upvalue capture.
			local upvalue = reference
		end
		cached_function = nil
	end

	while task.wait(0.1) and #weak_table > 0 do
		warn('Waiting for collection...')
	end

	warn('Done waiting for collection.') -- Reached immediately.

end

As shown in the Unique References tab from the Developer Console (the first path is the weak table, and the second path is cached_function):

3 Likes

Thank you for the report.

It is a known issue that some strong references can remain through indirect places like function stack frames, upvalues and constants.
While unfortunate, destroying such an instance still clears most of the resources held by it and the amount of slots that can hold such references is limited, meaning that not much memory will be retained.

If this was a common issue like you say, we would have seen it more often. Luckily, this is just a corner case.
Like you were able to find out, this corner case can be diagnosed using the heap profiler we provide.

Does it not make sense for these cached functions/upvalues to be freed once the script has finished its execution rather than having them exist forever?

1 Like

Top function will usually be collected.
It might not be the case in Studio when script debugging is enabled because that will store a reference to every script in case breakpoint will be placed inside.