Task.defer errors with re-entrancy depth exceeded, but the equivalent task.spawn does not

Reproduction Steps
task.defer example:

local thread
thread = coroutine.create(function()
	for _ = 1, 30 do
		task.defer(thread)

		coroutine.yield()
	end

	print("done!")
end)

task.spawn(thread)

Equivalent task.spawn example:

local thread
thread = coroutine.create(function()
	for _ = 1, 30 do
		task.spawn(function()
			task.wait()
			task.spawn(thread)
		end)

		coroutine.yield()
	end

	print("done!")
end)

task.spawn(thread)

Expected Behavior
task.defer should behave the same as task.spawn(function(c, ...) task.wait() task.spawn(c, ...) end)

Actual Behavior
task.defer errors with Maximum re-entrancy depth (10) exceeded while task.spawn completes successfully and prints "done!"

Workaround
Using task.spawn instead of task.defer

Issue Area: Engine
Issue Type: Other
Impact: Moderate
Frequency: Very Rarely
Date First Experienced: 2022-03-02 23:03:00 (-06:00)
Date Last Experienced: 2022-03-02 23:03:00 (-06:00)


Side-note: why am I doing something so weird?

First of all, this is not the actual code I’m using, this is a minimal reproduction of the issue.

I have an object set up to handle some precise and critical ordering of tasks. The tasks are only ran when triggered, which involves a task.defer call to schedule task processing. It’s conceivable that a task will spawn off a new coroutine that ends up triggering task processing again. This does happen – I ran into it while writing tests for this object.

A common alternative would be something like:

while true do
    if self._shouldProcessTasks then
        self:_processTasks()
    end
    task.wait()
end
function Object:_triggerProcessTasks()
    self._shouldProcessTasks = true
end

Not weird at all, right!

But I was avoiding that busy loop by using the task library:

function Object:_triggerProcessTasks()
    if not self._processingTasks then
        task.defer(self._processTasks)
    end
end

I have since switched to task.spawn which doesn’t have this issue, oddly enough:

function Object:_triggerProcessTasks()
    if not self._processingTasks then
        task.spawn(function()
            task.wait()
            self:_processTasks()
        end)
    end
end
5 Likes

This is intended behavior. Since defers from within a resumption queue resume at the end of the same queue, allowing things to defer endlessly would bypass the execution timeout and freeze Roblox forever, which is usually not what you want.

Ah yes, defer is different behavior from wait + spawn.

I feel like relying on the normal script execution timeout is better than this confusing error and limit. Roblox will not potentially freeze and run forever because it kills whichever script is running around the 10 second mark.

I don’t feel safe designing code around defer if it can error like this with such a low limit (10). I’d much rather let my code run with some sub-optimal behavior I can improve than have it break — sometimes unpredictably — at such a low limit. The game will not freeze at 10 defer calls. Maybe 10000!

As a last note, this is the same error that shows when re-triggering an event from itself. I’m not convinced defer is intended to have the same behavior. An event triggering itself at all is arguably a bug in the developer’s code, so 10 is a reasonable limit. defer on the other hand is meant to be called by the developer and there are cases where it makes sense to defer the same coroutine 10 times or more.

1 Like

This only works when the task scheduler is not allowed to run. During the resumption period, the task scheduler is running just fine. I haven’t tested this, and have no way to with the re-entrancy limit, but there’s about a 50/50 chance that deferring repeatedly would bypass the timeout mechanism, depending on how it was implemented.

10 is a relatively high limit considering what you’re supposed to use defer for - “not quite right now” scheduling of short tasks (or at least, short before yielding).

I think this is the case that I don’t recognize. This is also the issue that you’re running into, so may I ask why you need to defer the same coroutine 10 times or more in a row?

1 Like

Following up, as @qwertyexpert said.
This is an intended behavior. As documented for task.defer in the Dev Hub page This function should be used when a similar behavior to task.spawn is desirable, but the thread does not need to run immediately.
Task.spawn works in this case because is the best option for your script.

Closing this thread since this is not a bug!

2 Likes