Get traceback from pcall

As a Roblox developer, it is currently impossible to get a full traceback from a yieldable pcall.

  • pcall returns the error message without a traceback
  • xpcall allows using debug.traceback in the error handler, but doesn’t allow yielding

There is no way to trace errors if an error was captured in a pcall.

If Roblox is able to address this issue, I would be able to more easily debug my game in cases where an error occurs inside a pcall.

13 Likes

Edit:

If you’re reading this now, this feature has been implemented! xpcall supports yielding and lets you get traceback.

For example:

local function warnWithTracebackOnError(func, ...)
    return xpcall(func, function(err)
        warn(tostring(err) .. "\n" .. debug.traceback())
        return false, err
    end, ...)
end
-- On success: returns true, ...results
-- On error: warns, then returns false, err

Another example:

local function getMessageAndTracebackOnError(func, ...) -- similar to "tpcall" below
    local traceback
    local result = table.pack(
        xpcall(func, function(err)
            traceback = debug.traceback()
            return false, err
        end, ...)
    )
    if result[1] then
        return unpack(result, 1, result.n)
    else
        return false, result[2], traceback
end
-- On success: returns true, ...results
-- On error: returns false, errMessage, traceback

Beyond that, I highly recommend using evaera’s Promise library – it has great support for error handling, making asynchronous code more clear, and it includes tracebacks with errors.

It lets you do things like:

Promise.try(function()
    -- this might error, oh no!
    error("some error")
end):catch(function(err)
    if Promise.Error.is(err) then
        warn("An error (" .. err.error .. ") occurred at " .. tostring(err.trace))
        -- or you can just do:
        warn(err)
        -- which prints the error and traceback
    end
end):expect()

With that said, if you want to see my old clever solution that you should absolutely not use anymore, you can find it below!


Original Post

I made a horrible work-around!

It uses ScriptContext.Error, GUID names for modules, and BindableEvents to get tracebacks from functions.

This shouldn’t be necessary, but it was a fun challenge.

Here is the module.

Edit: Given the recent attention, I’d like to note that the module should be updated, but the below code is not! The module is public, so feel free to check it out!

It’s gone through a few revisions. Requiring modules can actually be slow and cause fps spikes, so the most recent version uses a cache of modules and only creates new ones if all of the cached ones are in use.

Because this uses cloned modules, it won’t work in roblox script security contexts such as BuiltInPlugins. Roblox seems to error on using cloned modules here as a security measure. It works fine in all normal user-accessible contexts, such as plugins, the command bar, server scripts, and client scripts.

Here is the source

tpcall source

--[[
	Use:
	local success, error, traceback, fromScript = tpcall(func, args, ...)
--]]

local ScriptContext = game:GetService("ScriptContext")

local runnerBase = script.runner

return function(func, ...)
	local uniqueId = game:GetService("HttpService"):GenerateGUID()
	local runnerNew = runnerBase:Clone()
	runnerNew.Name = uniqueId
	local runner = require(runnerNew)
	local bindableIn = Instance.new("BindableEvent")
	local bindableOut = Instance.new("BindableEvent")
	local inArgs = {...}
	local success = true
	local outArgs
	bindableIn.Event:Connect(function()
		outArgs = {runner(func, unpack(inArgs))}
		bindableOut:Fire()
	end)
	local conn
	conn = ScriptContext.Error:Connect(function(err, trace, scr)
		if trace:find(uniqueId, 1, true) then
			success = false
			outArgs = {
				err,
				trace:match("^(.*)\n[^\n]+\n[^\n]+\n$"),
				scr
			}
			bindableOut:fire()
		end
	end)
	bindableIn:Fire()
	if outArgs then
		conn:disconnect()
		bindableIn:Destroy()
		bindableOut:Destroy()
		return success, unpack(outArgs)
	end
	bindableOut.Event:wait()
	conn:disconnect()
	bindableIn:Destroy()
	bindableOut:Destroy()
	return success, unpack(outArgs)
end

runner source

return function(func, ...)
	return func(...)
end

13 Likes

Long ago, when I was working on a Script Builder, I did something similar, except I created uniquely named functions using loadstring(), and on the client an array with (reusable) pre-named functions.

Having something like yxpcall(func,errhandler) (optionally with ,... to allow a vararg of arguments) would be nice. It would basically be xpcall that can yield, which allows us to use debug.traceback() in the handler.

2 Likes

We hit this issue internally recently!

I definitely want to make xpcall support yielding instead of introducing yxpcall.

3 Likes

If you’re gonna add that, it would also be nice if it passes on excess arguments:

pcall(print,1,2,3) --> 1 2 3
xpcall(print,errhandler,1,2,3) --> 1 2 3
-- (currently would print nothing, as arguments don't get passed on)

(in “vanilla” Lua pcall passes on arguments and xpcall doesn’t. maybe could’ve been tricky because of the error handler, idk)

1 Like

But it sounds cool! Anyway, allowing it to yield would be awesome.

LuaJIT does this too I think – I’d like to bring in more of the non-breaking changes that it makes to 5.1 that are quality-of-life improvements from 5.2.

1 Like

Any idea if this is still planned?

2 Likes

I took a look at the source with a couple other engineers here a few months back. I imagine there’s a way to get xpcall to support yielding, but I couldn’t figure out a simple solution. :frowning:

We definitely still want to, but there’s a couple caveats we’ve identified:

  • Supporting yielding will make xpcall slower (note how much faster than pcall it is right now)
  • Some games rely on xpcall not supporting yielding as a way to prevent certain kinds of bug

For now, it’s a backlog item that we’d love to have but haven’t gotten around to.

2 Likes

Doesn’t Roblox rely on it too as a hack at some points? I think I remember seeing it as part of some no yielding hack.

I am a bit confused about this. What would you try to prevent yielding that doesn’t yield by default?

1 Like

It can be used when you have large, complex Lua APIs where you need something to happen instantly where any yielding is probably a bug. For example:

  • when the game starts up for the first time, it probably needs to do a lot of instant set up without yielding. Yielding could cause weird bugs if you rely on executing before any physics frames.
  • when saving data, you want to serialize data instantly without leaving room for weird bugs from unexpected or infinite yielding. Erroring on yield is an easy way to prevent those bugs.
  • other things that should be instant but allow custom callbacks, such as updating gui layout in a gui framework.
5 Likes

I am rewriting my unit testing framework and need to be able to capture errors with a stack trace to cleanly present to a developer. Corecii’s solution works until you reach the point of trying to handle long stack traces (like Stack Overflow cases). My workaround is even more hacky than before. I would like to not have to do this, especially considering it will still flood the output when it doesn’t have to.

--[[
Corecii and TheNexusAvenger

Work-around to allow xpcall to be yieldable. Modified a bit
for use in Nexus Unit Testing (plugin).

Original: https://www.roblox.com/library/1070503396/tpcall-pcall-with-traceback
--]]

local LogService = game:GetService("LogService")
local HttpService = game:GetService("HttpService")

local CurrentErrorMessage
local CurrentErrorStackTrace
local ErrorThrownEvent = require(script.Parent:WaitForChild("NexusInstance"):WaitForChild("Event"):WaitForChild("LuaEvent")).new()
local RunnerBase = script:WaitForChild("Runner")
local MessageError,MessageInfo = Enum.MessageType.MessageError,Enum.MessageType.MessageInfo



--Set up the logging to get full stack traces.
--ScriptContext.Error caps the output, mainly for stack overflow.
LogService.MessageOut:Connect(function(Message,Type)
	if Type == MessageError then
		--Set the error message.
		CurrentErrorMessage = Message
	elseif Type == MessageInfo and CurrentErrorMessage ~= nil then
		--Add the output line.
		if CurrentErrorStackTrace == nil then
			CurrentErrorStackTrace = Message
		else
			CurrentErrorStackTrace = CurrentErrorStackTrace.."\n"..Message
		end
		
		--If "Stack End" is reached, signal the error.
		if Message == "Stack End" then
			ErrorThrownEvent:Fire(CurrentErrorMessage,CurrentErrorStackTrace)
			CurrentErrorMessage = nil
			CurrentErrorStackTrace = nil
		end
	end
end)



--[[
Custom implementation of a yieldable xpcall for Roblox.
--]]
local function yxpcall(Function,ErrorHandler,...)
	--Create the runner.
	local UniqueId = HttpService:GenerateGUID()
	local NewRunner = RunnerBase:Clone()
	NewRunner.Name = UniqueId
	
	--Set up the runner.
	local Runner = require(NewRunner)
	local BindableIn = Instance.new("BindableEvent")
	local BindableOut = Instance.new("BindableEvent")
	local InArgumentss = {...}
	local Success = true
	local OutArgumentss
	BindableIn.Event:Connect(function()
		--Get the output from running the script.
		OutArgumentss = {Runner(Function,unpack(InArgumentss))}
		
		--Signal the function is done (successful).
		BindableOut:Fire()
	end)
	
	--Set up error handling.
	local ErrorConnection
	ErrorConnection = ErrorThrownEvent:Connect(function(ErrorMessage,Traceback)
		--If the unique id is in the traceback, set it as failing.
		if Traceback:find(UniqueId,1,true) then
			--Set the 
			Success = false
			OutArgumentss = {
				ErrorMessage,
				Traceback
			}
			
			--Signal the function is done (errored).
			BindableOut:fire()
		end
	end)
	
	--Start the input.
	BindableIn:Fire()
	
	--[[
	Disconnects the events.
	--]]
	local function DisconnectEvents()
		ErrorConnection:Disconnect()
		BindableIn:Destroy()
		BindableOut:Destroy()
	end
	
	--[[
	Runs the error handler if there was an error.
	--]]
	local function RunErrorHandler()
		if not Success then
			ErrorHandler(unpack(OutArgumentss))
		end
	end
	
	--If it finishes before a wait can be setup, return the output.
	if OutArgumentss then
		DisconnectEvents()
		RunErrorHandler()
		return Success,unpack(OutArgumentss)
	end
	
	--Wait for the function to complete.
	BindableOut.Event:wait()
	
	--Return the output.
	DisconnectEvents()
	RunErrorHandler()
	return Success,unpack(OutArgumentss)
end



return yxpcall

As an example of “flooding” the output, this is what happens when I run the unit tests. If the developer tries to debug the error, they will have dozens of errors in the output, which uses resources that don’t need to be spent. Notice the 16 seconds to test the stack overflow case. A lot of that is Roblox Studio trying to display the output.
image

Here is a test with xpcall and the stack overflow case:

--Method that throws stack overflow.
local function StackOverflow()
	StackOverflow()
end

--Run the test.
local Start = tick()
xpcall(StackOverflow,function(Error)
	print("Stack overflow! Took "..tostring(tick() - Start).." seconds!") --<0.002 seconds
	print("Stack trace length: "..tostring(#debug.traceback())) --1114018 characters or so; stack trace is complete
end)
1 Like

This is a neat improvement! You might want to take a look at the changes between the posted source here and the module on Roblox. I think the most significant change between the two is re-using existing runner module clones because require is slow. I was running into performance issues before that change.

Keep in mind that MessageOut has some quirks too:

  • messages are shared between edit, server, and client in Studio. This is not an issue due to the guid names.
  • it does not work on clients online, or at least cannot be relied on to work online

I think the best pattern for using this in a live game is to use a tpcall-like in Studio and a pcall-like online and in testing. This could also be a yxpcall-like in Studio and a xpcall-like that uses pcall internally online.


I’m still hoping for an official way to do this. These methods are not reliable as stated by Roblox because we have been told to not rely on the stack trace format for anything in-game. We have no way of knowing that Roblox won’t do something like truncate the script name or truncate the stack trace entirely. That’s what ScriptContext.Error is doing, right?

I just want to make sure it’s clear for those evaluating this feature request: I don’t want to use the stack trace in my code at all. I just want it present from something reliable like pcall in all contexts so I can read it and use it for debugging. This is becoming increasingly important as Roblox games grow more complex and use more error-catching patterns in fundamental utilities. Right now, we either have to guess where the error is occurring or temporarily turn off error-catching to get a stack trace.

1 Like