Spcall: A niche module that fixes the behaviour of the timeout error
In short:
This module offers replacements that prevent the propagation of the timeout error.
Long:
This module aims to solve an issue with the behaviour of the timeout error.
When the timeout error is generated, it will cascade the error into any related thread.
In other words, the timeout error indirectly propagates.
This is due to to fact when the error is generated, the budget has been consumed but it does not reset the budget after it has been catched.
Thus any code that tries to call, even code that attemps to yield, will raise that error too.
But I found out, that it won’t cascade the error into threads that are suspended.
So what this module essestially does, is wrap the original thread in a suspended thread.
So this module makes sure the timeout error will behave like any other error.
Example of the issue with the timeout error
task.spawn(function()
coroutine.resume(coroutine.create(function()
task.spawn(function()
pcall(function()
while true do -- raise the timeout error
end
end)
print("This will raise another timeout error")
print("Never prints due to that this thread has errored")
end)
end))
print("Even this will error")
end)
print("As you can see it indirectly propegates the timeout error")
Use case
In most cases, the default behaviour of timeout is not that bad, it fails fast. Forcing you to the fix the issue. But when handling 3rd party code, e.g. code you have no control over. And you want to properly handle said code, including the timeout error. This would not be possible without this module.
An even more niche use case, is when the during the cascading of the error it generates that many errors and stacktraces that the root stacktrace (the code that actually caused it) is no longer accessible.
An example of this happening to me (link to gif)
In the gif you can see it only shows a portion of the 2000 errors that it had raised.
A more concrete use case
Without it:
For one of my other modules people write benchmarks → one errors with the timeout error → it cascades through out my whole module → whole module unresponsive → not debuggable in the slighest, because you get 2k errors (see gif above) → module completely unreponsive → unable to do the other benchmarks/profiling → can’t even close/mimize the gui.
Now with it:
People write benchmarks → one errors with the timeout error → catched → doesnt propegate → handle error → e.g. visual status informing benchmark X, errored, prints the error and the stack trace (1 error not 2k errors) → other benchmarks/profiling keep running → add hints how to fix the error → benchmarker keeps being completely useable → also doesn’t take 10 minutes for studio to become responsive again
Disclaimer
This is not a general replacement for task.spawn
and pcall
. It’s only for this use case.
You can but there is no point.
Code examples
It has identical API to the roblox counterparts, so you can use them as drop in replacements.
Fixed version of the example issue:
local module = require(...)
task.spawn(function()
coroutine.resume(coroutine.create(function()
task.spawn(function()
module.pcall(function()
while true do -- raise the timeout error
end
end)
print("This will raise another timeout error")
print("Never prints due to that this thread has errored")
end)
end))
print("Even this will error")
end)
print("As you can see it indirectly propegates the timeout error")
And now it will print all those prints
And here is the other replacement:
local module = require(...)
module.spawn(function()
while true do end
end)
print("This will print")
and the output, as you can see it prints the stacktrace but it doesnt propegate the error
API
The module offers two replacements: one for pcall and one for task.spawn.
They have an identical API.
Functions
void
module.spawn(funcThread: func | thread, …: any)
Behaves like task.spawn
and benefits of task lib (continuations, stacktraces), resumes within the same frame. But prevents the propagation of the timeout error.
(bool, str | any, ...any)
module.pcall(func: func, …: any)
Behaves almost exactly like pcall
, but doesn’t propagate the timeout error.
Few caveats though:
- Since I was unable to use
table.pack
, I wasn’t able to preserve holes when returning from pcall, so when that behaviour is wanted I suggest you pack the tuple before returning it. - As you can see from the code example for
module.pcall
, the order of the print statements isn’t exactly the same as it would ve been with pcall. This is due to fact that I can’t resume the the thread immediately because the budget has’t been reset yet. Instead it resumes it on the next step.
Source code
Show
-- @Author: VerdommeMan, see https://github.com/VerdommeMan/Spcall for more information
-- @Version: 1.0.0
-- This module aims to solve the issue with the behaviour of the timeout error.
-- When the timeout error is generated, it will cascade the error into any related thread.
-- In other words, the timeout error indirectly propagates
-- This is due to to fact when the error is generated, the budget has been consumed
-- but it does not reset the budget after it has been catched,
-- thus any code that tries to call, even code that attemps to yield, will raise that error too.
-- But I found out, that it wont cascade the error into threads that are suspended.
-- So what this module essentially does, is wrap the original thread in a suspended thread.
-- It doesn't look like much but it took me a long time to create this
-- It works in both SignalBehaviors (Deffered and Immediate) and supports continuations
-- It was a painful 20 hours in which I was slowly becoming insane but I did it :D
local Module = {}
-- Behaves like spawn, identical API, and benefits of task lib (continuations, stacktraces), resumes within the same frame
function Module.spawn(funcThread: (...any) -> () | thread, ...)
task.defer(funcThread, ...) -- order 1, func gets called at order 3
task.wait() -- order 2, suspends the current thread to prevent time out error from cascading into other threads
end
-- Pure black magic: abusing the mechanic that timeout doesnt error for indirectly calling metamethods (except __call)
-- (you can't yield in those though presumably why they are allowed to be indirectly called)
-- It indirectly calls task.delay({}, thread, args), luckily the first arg defaults to zero instead
-- Schedules the thread to be resumed on the next step
local ThreadScheduler = setmetatable({}, {__newindex = task.delay})
-- Behaves like pcall and has identical API and almost identical behaviour
-- When yielding within it, it yields the thread it has been called in (just like pcall),
-- doesnt print anything in the console, one note though, since im unable to use table.pack
-- I wasnt able to preserve holes when returning from it, so you will have to return the amount of args incase that behaviour is wanted
function Module.pcall(func: (any) -> (), ...): (boolean, ...any)
local curThread = coroutine.running()
task.defer(function(...)
ThreadScheduler[curThread] = {pcall(func, ...)} -- Absolute amazing piece of magic happening right here
end, ...)
return unpack(coroutine.yield())
end
return Module
-- Incase you are interested, here are some findings I had during this project:
-- timeout doesn't error (when the budget is consumed) as long as you aren't calling anything
-- The timeout error can't propegate into threads that have been suspended, this is how I encapsulate the error
-- Some of my previous iterations before the ThreadScheduler were: polling a variable (very costly),
-- Using a StringValue (could ve used any instance), listening to the Changed event, and change a property
-- But this was only effective for the Deffered SignalBehaviour since in that mode it calls the listeners after the budget has been reset
Links
If you have any questions, you can dm me or ask it in the comments below. If you find any issues you can make an issue on github or just comment it here. Ideas for more features are always welcome!
Feedback is always appreciated.