Coroutine.wrap function with full error stack traces

EJFace

I have been using and struggling with coroutine.wrap for several years now due to issues with stack traces from errors occurring within them. Sometimes you get no errors, incomplete stack traces, or the stack trace starting from the line the coroutine is started on rather than the line the error occurred on. You could use Spawn as an alternative to coroutines however it doesn’t run immediately.

This lead me to about 2 hours ago, when for the 3rd time I checked to see if yielding within xpcall had been enabled yet (it has) and set to work to see if it was possible to fix this (it was).

From my tests this method is as fast as directly using coroutine.wrap.

The functions

You can get/require the following model (It returns a table containing CoroutineWithStack and ResumeWithStack at named keys, access via require(CoroutineErrorHandling).CoroutineWithStack or .ResumeWithStack):

Note: If you want to require by ID and use it on the client you just need to require the ID from the server and then on the client do local CoroutineErrorHandling = require(game:GetService("ReplicatedStorage"):WaitForChild("CoroutineErrorHandling"))

The code is also up on github:

Simply pass the function and arguments you want to run in the coroutine to the CoroutineWithStack function and it’ll run it with support for full stack traces if an error occurs.

Drawbacks

Stack trace is in the error message

Due to how this works, there is a second stack trace included as you will see in blue in the images below. Unfortunately I can’t actually set the stack using error() so it includes the stack from that point with the actual stack in the error message (the red).

coroutine.resume(Thread) swallowing the error

coroutine.resume acts like pcall and returns the error instead of outputting it. You can either handle this resulting error yourself or if you still want it to output as an error you can use one of the following solutions:

  1. If you want the stack for errors to trace back to the last resume instead of the point the thread was initially created you can use ResumeWithStack instead of coroutine.resume.

  2. If you want the stack to trace back to when the thread first start just use coroutine.resume normally and if it errors just use error() to output it, like so

local Ran, Error = coroutine.resume(Thread)
if not Ran then
	error(Error, 0)
end

Examples

Example 1

Lets say I want to run the following code:

local CoroutineWithStack = require(4851605998)

local function Add(a, b)
	return a + b
end
local function Func(a, b)
	wait(2)
	return Add(a, b)
end
local function DoThing()
	coroutine.wrap(Func)(1, "b")
end
DoThing()

With normal coroutine.wrap we get the following error:
image
As you can see, it has missed the line which started the coroutine from the stack (line 11) and the line that called the DoThing() function (line 13)

If we replace coroutine.wrap(Func)(Args) with CoroutineWithStack(Func, Args) we get the following error

Modified code
local CoroutineWithStack = require(4851605998)

local function Add(a, b)
	return a + b
end
local function Func(a, b)
	wait(2)
	return Add(a, b)
end
local function DoThing()
	CoroutineWithStack(Func, 1, "b")
end
DoThing()

image
It now includes the full stack right up to line 13 (As I said before, the blue stack is the stack from the point I actually call error() and is unfortunately unavoidable as far as I can tell, just ignore it and use the red part).

Example 2

If we slightly edit the code so the yield is after the line that errors like so

local CoroutineWithStack = require(4851605998)

local function Add(a, b)
	return a + b
end
local function Func(a, b)
	local Num = Add(a, b)
	wait(2)
	return Num
end
local function DoThing()
	coroutine.wrap(Func)(1, "b")
end
DoThing()

With normal coroutine.wrap we get the following error:


As you can see, it has missed the line the error occured on (line 4) and the line where the Add(a, b) function ran (line 7)

If we replace the coroutine.wrap with CoroutineWithStack we get the following error

Modified code
local CoroutineWithStack = require(4851605998)

local function Add(a, b)
	return a + b
end
local function Func(a, b)
	local Num = Add(a, b)
	wait(2)
	return Num
end
local function DoThing()
	CoroutineWithStack(Func, 1, "b")
end
DoThing()

image
And once again, you can see it now correctly has the entire stack




You may be able to tell I’m both very excited and very tired (it’s past midnight) right now so I apologise for any mistakes/cringey parts in the post but I’m very happy I managed to get this working (almost gave up) and wanted to share this. If I’ve missed an easier way to do this let me know.

17 Likes

Seems useful for more complex functions in order to have precise information on errors that occur within a wrapped coroutine.

Thanks for sharing!

This seems un necessary to return the extra results when nothing is done with them when no error occurs.
Even if it did do something with the extra values, it wouldn’t correctly handle nils since those can signal the tables end. (table.pack should be used, with the n field)

You’re not supposed to rely on the format of debug.traceback:

1 Like

Thanks, I fixed your first point. I was initially going in a different direction with this function (An error handler) and forgot it no longer returns anything.

As for your second point, I was considering mentioning it does rely on a certain format for debug.traceback() but because it’s just removing the last line I thought it would mostly be alright. You can remove that line if you don’t mind having the line the CoroutineWithStack function runs on included in the stack trace.

For anyone that wants it, replace the ErrorHandler function with this to no longer rely on the debug.traceback() format being the same:

local function ErrorHandler(Error)
	return {Error, debug.traceback(nil, 2)}
end

Results in the extra trace you can see below (CoroutineWithStack:12)
image

As described in this topic, debug.traceback can be used to get a stack trace from an errored thread. There’s really no point in all the indirection with pcall, xpcall, and coroutine.wrap when you just create and resume a thread directly:

local function CoroutineWithStack(Func, ...)
	local Thread = coroutine.create(Func)
	local Ran, Error = coroutine.resume(Thread, ...)
	if not Ran then
		local Trace = debug.traceback(Thread)
		error(Error .. "\n" .. Trace)
	end
end

As far as I can see that doesn’t work if the function errors after a yield?

EDIT: I can however replace the pcall with the coroutine.resume returning the Error, while still having the xpcall, to presumably make it faster

EDIT2: With that change it’s now as fast as just using coroutine.wrap from the brief testing I’ve done

It works the same as yours. The function returned by coroutine.wrap basically calls coroutine.resume, and throws the resulting error if one occurs. If the thread yields, then it will be resumed on the next call to the wrapping function, or by whatever else might be managing the thread.

As an aside, the implementation of coroutine.wrap is effectively the following:

function coroutine.wrap(func)
	local thread = coroutine.create(func)
	return function(...)
		local results = table.pack(coroutine.resume(thread, ...))
		if not results[1] then
			error(results[2], 2)
		end
		return table.unpack(results, 2, results.n)
	end
end

That is, it discards the stack of the thread, instead emitting a stack trace of the error. The implementation needs to be modified only a bit to include the stack of the thread in the error message:

 function coroutine.wrap(func)
 	local thread = coroutine.create(func)
 	return function(...)
 		local results = table.pack(coroutine.resume(thread, ...))
 		if not results[1] then
+			local trace = debug.traceback(thread)
+			error(results[2] .. "\n" .. trace, 2)
-			error(results[2], 2)
 		end
 		return table.unpack(results, 2, results.n)
 	end
 end

Anyway, your implementation does not handle the wrapping function beyond the first call either. This is perfectly fine: usually, it is assumed that the thread would be yielded by wait or some other means, and therefore resumed later by Roblox’s task scheduler. If coroutine.yield were called, then the thread would never be resumed, but that’s also how the scheduler deals with it.

If an error occurs after a yield, it would be handled by whatever had resumed it. For example, if wait(0) yielded the thread, then the scheduler will resume it. When the error occurs, the scheduler emits the error message along with a stack trace of the thread (rather than an error call, as with coroutine.wrap). This might explain the discrepancy with stack traces that you’ve observed.

1 Like

Sorry, I meant after yielding due to wait() or other roblox scheduler resumes (You’re right it doesn’t work when using coroutine.yield() and being resumed manually elsewhere)

For comparison after a wait:
Your function
image
(Missing the stack from before the coroutine starting)

My function
image

Code for reference
local CoroutineWithStack = require(script.CoroutineWithStack)

local function Add(a, b)
	return a + b
end
local function Func(a, b)
	wait(2)
	local Num = Add(a, b)
	return Num
end
local function DoThing()
	CoroutineWithStack(Func, 1, "b")
end

DoThing()
1 Like

I see. My function only handles an error that occurs in the first resume. Your function has set an error handler, so any error will be handled within the xpcall, even between yields. An error handler can’t be set for a thread, so xpcall is the only way to go.

1 Like

FYI, if anyone still wants the full stack trace after using coroutine.resume() to resume the Thread I’ve added information under Drawbacks on how to do this.

EDIT: Have updated the OP with ResumeWithStack to handle resuming threads with correct stack traces (Check under Drawbacks for info) and have made CoroutineWithStack and ResumeWithStack functions both return whatever the coroutine returns if it doesn’t error for sake of completeness

EDIT 2: Have made it a module and put it up on github, if you want to use the module on the client side you can require the ID from the server and then on the client do local CoroutineErrorHandling = require(game:GetService("ReplicatedStorage"):WaitForChild("CoroutineErrorHandling"))