Skip to content

Consider changing Signal class, possible memory leak #194

@1ForeverHD

Description

@1ForeverHD

I haven't explored in full, but if someone was able to investigate and confirm, that'd be much appreciated 🙏

Will consider changing Signal classes if true.

Details by Outgram Roblox:

"Hello. I'm writing to report a memory leak within the GoodSignal module that TopbarPlus has been published with. Specifically, the leak occurs within the freeRunnerThread implementation. Assuming the helper thread has yielded for a previous task, and a new one comes in, runEventHandlerInFreeThread creates a new instance of the helper thread, however, the previous coroutine is never properly cleaned up due to the non-terminating loop. This quickly comsumes memory, and with just 10 samples of relatively simple computations, ~400 KB of memory were never freed. As HDAdmin and TopbarPlus use this module, and likely make use of the freeRunnerThread often, this could quickly accumulate to several megabytes of memory usage.

For further clarification, I pasted the affected code into a script in this demonstration. It has been modified to track the state of every coroutine created in an array.

As you can see from the image, the threads are left in a suspended state, originating from runEventHandlerInFreeThread's while true do loop that is intended to pass input to acquireRunnerThreadAndCallEventHandler through the use of coroutine.yield() passing input from resumptions.

The problem is the mishandling of threads that are unable to close, and memory leakage as a byproduct. If the module inside TopbarPlus was operating correctly, all these threads would indicate a 'dead' state, with the exception being the last thread being reused. I should mention that this bug only occurs when the current thread is yielded and a new task triggers coroutine replacement. If they're all tasks that return immediately then everything operates normally."

-- The currently idle thread to run the next handler on
local freeRunnerThread = nil

-- The array we use to track coroutine states
local threadTrack = {}

-- Function which acquires the currently idle handler runner thread, runs the
-- function fn on it, and then releases the thread, returning it to being the
-- currently idle one.
-- If there was a currently idle runner thread already, that's okay, that old
-- one will just get thrown and eventually GCed.
local function acquireRunnerThreadAndCallEventHandler(fn, ...)
	local acquiredRunnerThread = freeRunnerThread
	freeRunnerThread = nil
	fn(...)
	-- The handler finished running, this runner thread is free again.
	freeRunnerThread = acquiredRunnerThread
end

-- Coroutine runner that we create coroutines of. The coroutine can be 
-- repeatedly resumed with functions to run followed by the argument to run
-- them with.
local function runEventHandlerInFreeThread()
	-- Note: We cannot use the initial set of arguments passed to
	-- runEventHandlerInFreeThread for a call to the handler, because those
	-- arguments would stay on the stack for the duration of the thread's
	-- existence, temporarily leaking references. Without access to raw bytecode
	-- there's no way for us to clear the "..." references from the stack.
	while true do
		acquireRunnerThreadAndCallEventHandler(coroutine.yield())
	end
end

local function runInFreeThread(Func:(any)->(), ...:any):nil
	if not freeRunnerThread then
		freeRunnerThread = coroutine.create(runEventHandlerInFreeThread)
		
		-- Get the freeRunnerThread to the first yield and ready to accept
		-- input from further resumptions.
		coroutine.resume(freeRunnerThread)
		
		-- Insert every new thread into an array so
		-- we can track its state.
		table.insert(threadTrack, freeRunnerThread)
	end
	
	task.spawn(freeRunnerThread, Func, ...)
	
	return nil
end

-- The coroutine leakage doesn't happen when tasks return immediately
-- so we must trigger replacement of the coroutines by intentionally
-- waiting. We run this loop 10 times.
for i = 1, 10 do
	runInFreeThread(function()
		-- Small busy work to ensure Thread is nil during execution
		task.wait()
	end)

	-- Immediately call again to trigger replacement
	runInFreeThread(function() end)
end

for Index, Value in threadTrack do
	print('Coroutine #', Index..':', coroutine.status(Value))
end
Image

DevForum post here: https://devforum.roblox.com/t/lua-signal-class-comparison-optimal-goodsignal-class/1387063/98?u=foreverhd

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions