Use xpcall, revamp error handling

This commit is contained in:
Eryn Lynn 2020-05-04 23:56:49 -04:00
parent 344c9759aa
commit 1ca3fff6f5
6 changed files with 380 additions and 145 deletions

View file

@ -1,3 +1,11 @@
# Next
- Runtime errors are now represented by objects. You must call tostring on rejection values before assuming they are strings (this was always good practice, but is required now).
- Errors now have much better stack traces due to using xpcall internally instead of pcall.
- Stack traces now be more direct and not include as many internal calls within the Promise library.
- Chained promises from resolve() or returning from andThen now have improved rejection messages for debugging.
- Yielding is now allowed in Promise.new and andThen executors.
- Improve test coverage for asynchronous and time-driven functions
# 2.5.1 # 2.5.1
- Fix issue with rejecting with non-string not propagating correctly. - Fix issue with rejecting with non-string not propagating correctly.

View file

@ -4,7 +4,7 @@ docs:
desc: A Promise is an object that represents a value that will exist in the future, but doesn't right now. Promises allow you to then attach callbacks that can run once the value becomes available (known as *resolving*), or if an error has occurred (known as *rejecting*). desc: A Promise is an object that represents a value that will exist in the future, but doesn't right now. Promises allow you to then attach callbacks that can run once the value becomes available (known as *resolving*), or if an error has occurred (known as *rejecting*).
types: types:
- name: PromiseStatus - name: Status
desc: An enum value used to represent the Promise's status. desc: An enum value used to represent the Promise's status.
kind: enum kind: enum
type: type:
@ -30,12 +30,10 @@ docs:
desc: | desc: |
Construct a new Promise that will be resolved or rejected with the given callbacks. Construct a new Promise that will be resolved or rejected with the given callbacks.
::: tip
If your Promise executor needs to yield, it is recommended to use [[Promise.async]] instead. You cannot directly yield inside the `executor` function of [[Promise.new]].
:::
If you `resolve` with a Promise, it will be chained onto. If you `resolve` with a Promise, it will be chained onto.
You can safely yield within the executor function and it will not block the creating thread.
You may register an optional cancellation hook by using the `onCancel` argument. You may register an optional cancellation hook by using the `onCancel` argument.
* This should be used to abort any ongoing operations leading up to the promise being settled. * This should be used to abort any ongoing operations leading up to the promise being settled.
* Call the `onCancel` function with a function callback as its only argument to set a hook which will in turn be called when/if the promise is cancelled. * Call the `onCancel` function with a function callback as its only argument to set a hook which will in turn be called when/if the promise is cancelled.
@ -73,20 +71,16 @@ docs:
- type: boolean - type: boolean
desc: "Returns `true` if the Promise was already cancelled at the time of calling `onCancel`." desc: "Returns `true` if the Promise was already cancelled at the time of calling `onCancel`."
returns: Promise returns: Promise
- name: async - name: defer
tags: [ 'constructor' ] tags: [ 'constructor' ]
desc: | desc: |
The same as [[Promise.new]], except it allows yielding. Use this if you want to yield inside your Promise body. The same as [[Promise.new]], except execution begins after the next `Heartbeat` event.
If your Promise body does not need to yield, such as when attaching `resolve` to an event listener, you should use [[Promise.new]] instead. This is a spiritual replacement for `spawn`, but it does not suffer from the same [issues](https://eryn.io/gist/3db84579866c099cdd5bb2ff37947cec) as `spawn`.
::: tip
Promises created with [[Promise.async]] don't begin executing until the next `RunService.Heartbeat` event, even if the executor function doesn't yield itself. This is to ensure that Promises produced from a function are either always synchronous or always asynchronous. <a href="/roblox-lua-promise/lib/Details.html#yielding-in-promise-executor">Learn more</a>
:::
```lua ```lua
local function waitForChild(instance, childName, timeout) local function waitForChild(instance, childName, timeout)
return Promise.async(function(resolve, reject) return Promise.defer(function(resolve, reject)
local child = instance:WaitForChild(childName, timeout) local child = instance:WaitForChild(childName, timeout)
;(child and resolve or reject)(child) ;(child and resolve or reject)(child)
@ -96,7 +90,7 @@ docs:
static: true static: true
params: params:
- name: asyncExecutor - name: deferExecutor
type: type:
kind: function kind: function
params: params:
@ -172,9 +166,9 @@ docs:
returns: Promise<...any> returns: Promise<...any>
- name: try - name: try
desc: | desc: |
Begins a Promise chain, calling a synchronous function and returning a Promise resolving with its return value. If the function errors, the returned Promise will be rejected with the error. Begins a Promise chain, calling a function and returning a Promise resolving with its return value. If the function errors, the returned Promise will be rejected with the error.
`Promise.try` is similar to [[Promise.promisify]], except the callback is invoked immediately instead of returning a new function, and unlike `promisify`, yielding is not allowed with `try`. `Promise.try` is similar to [[Promise.promisify]], except the callback is invoked immediately instead of returning a new function.
```lua ```lua
Promise.try(function() Promise.try(function()

View file

@ -2,8 +2,6 @@
An implementation of Promises similar to Promise/A+. An implementation of Promises similar to Promise/A+.
]] ]]
local ERROR_YIELD_NEW = "Yielding inside Promise.new is not allowed! Use Promise.async or create a new thread in the Promise executor!"
local ERROR_YIELD_THEN = "Yielding inside andThen/catch is not allowed! Instead, return a new Promise from andThen/catch."
local ERROR_NON_PROMISE_IN_LIST = "Non-promise value passed into %s at index %s" local ERROR_NON_PROMISE_IN_LIST = "Non-promise value passed into %s at index %s"
local ERROR_NON_LIST = "Please pass a list of promises to %s" local ERROR_NON_LIST = "Please pass a list of promises to %s"
local ERROR_NON_FUNCTION = "Please pass a handler function to %s!" local ERROR_NON_FUNCTION = "Please pass a handler function to %s!"
@ -14,6 +12,36 @@ local MODE_KEY_METATABLE = {
local RunService = game:GetService("RunService") local RunService = game:GetService("RunService")
--[[
An object to represent runtime errors that occur during execution.
Promises that experience an error like this will be rejected with
an instance of this object.
]]
local PromiseRuntimeError = {}
PromiseRuntimeError.__index = PromiseRuntimeError
function PromiseRuntimeError.new(errorString)
return setmetatable({
errorString = tostring(errorString) or "[This error has no error text.]"
}, PromiseRuntimeError)
end
function PromiseRuntimeError.is(anything)
if type(anything) == "table" then
return rawget(anything, "errorString") ~= nil
end
return false
end
function PromiseRuntimeError:extend(errorString)
return PromiseRuntimeError.new(("%s\n%s"):format(tostring(errorString), self.errorString))
end
function PromiseRuntimeError:__tostring()
return self.errorString
end
--[[ --[[
Packs a number of arguments into a table and returns its length. Packs a number of arguments into a table and returns its length.
@ -30,24 +58,18 @@ local function packResult(success, ...)
return success, select("#", ...), { ... } return success, select("#", ...), { ... }
end end
--[[
Calls a non-yielding function in a new coroutine.
Handles errors if they happen. local function makeErrorHandler(traceback)
]] return function(err)
local function runExecutor(yieldError, traceback, callback, ...) return debug.traceback(err, 2) .. "\nPromise created at:\n\n" .. traceback
-- Wrapped because C functions can't be passed to coroutine.create!
local co = coroutine.create(function(...)
return callback(...)
end)
local ok, len, result = packResult(coroutine.resume(co, ...))
if ok and coroutine.status(co) ~= "dead" then
error(yieldError .. "\n" .. traceback, 2)
end end
end
return ok, len, result --[[
Calls a Promise executor with error handling.
]]
local function runExecutor(traceback, callback, ...)
return packResult(xpcall(callback, makeErrorHandler(traceback), ...))
end end
--[[ --[[
@ -56,12 +78,12 @@ end
]] ]]
local function createAdvancer(traceback, callback, resolve, reject) local function createAdvancer(traceback, callback, resolve, reject)
return function(...) return function(...)
local ok, resultLength, result = runExecutor(ERROR_YIELD_THEN, traceback, callback, ...) local ok, resultLength, result = runExecutor(traceback, callback, ...)
if ok then if ok then
resolve(unpack(result, 1, resultLength)) resolve(unpack(result, 1, resultLength))
else else
reject(result[1] .. "\n" .. traceback) reject(PromiseRuntimeError.new(result[1]))
end end
end end
end end
@ -70,7 +92,10 @@ local function isEmpty(t)
return next(t) == nil return next(t) == nil
end end
local Promise = {} local Promise = {
_timeEvent = RunService.Heartbeat,
_getTime = tick
}
Promise.prototype = {} Promise.prototype = {}
Promise.__index = Promise.prototype Promise.__index = Promise.prototype
@ -97,20 +122,17 @@ Promise.Status = setmetatable({
Second parameter, parent, is used internally for tracking the "parent" in a Second parameter, parent, is used internally for tracking the "parent" in a
promise chain. External code shouldn't need to worry about this. promise chain. External code shouldn't need to worry about this.
]] ]]
function Promise.new(callback, parent) function Promise._new(traceback, callback, parent)
if parent ~= nil and not Promise.is(parent) then if parent ~= nil and not Promise.is(parent) then
error("Argument #2 to Promise.new must be a promise or nil", 2) error("Argument #2 to Promise.new must be a promise or nil", 2)
end end
local self = { local self = {
-- Used to locate where a promise was created -- Used to locate where a promise was created
_source = debug.traceback(), _source = traceback,
_status = Promise.Status.Started, _status = Promise.Status.Started,
-- Will be set to the Lua error string if it occurs while executing.
_error = nil,
-- A table containing a list of all results, whether success or failure. -- A table containing a list of all results, whether success or failure.
-- Only valid if _status is set to something besides Started -- Only valid if _status is set to something besides Started
_values = nil, _values = nil,
@ -131,9 +153,11 @@ function Promise.new(callback, parent)
_cancellationHook = nil, _cancellationHook = nil,
-- The "parent" of this promise in a promise chain. Required for -- The "parent" of this promise in a promise chain. Required for
-- cancellation propagation. -- cancellation propagation upstream.
_parent = parent, _parent = parent,
-- Consumers are Promises that have chained onto this one.
-- We track them for cancellation propagation downstream.
_consumers = setmetatable({}, MODE_KEY_METATABLE), _consumers = setmetatable({}, MODE_KEY_METATABLE),
} }
@ -163,8 +187,8 @@ function Promise.new(callback, parent)
return self._status == Promise.Status.Cancelled return self._status == Promise.Status.Cancelled
end end
coroutine.wrap(function()
local ok, _, result = runExecutor( local ok, _, result = runExecutor(
ERROR_YIELD_NEW,
self._source, self._source,
callback, callback,
resolve, resolve,
@ -173,47 +197,35 @@ function Promise.new(callback, parent)
) )
if not ok then if not ok then
self._error = result[1] or "error" reject(PromiseRuntimeError.new(result[1]))
reject((result[1] or "error") .. "\n" .. self._source)
end end
end)()
return self return self
end end
function Promise._newWithSelf(executor, ...) function Promise.new(executor)
local args return Promise._new(debug.traceback(nil, 2), executor)
local promise = Promise.new(function(...)
args = { ... }
end, ...)
-- we don't handle the length here since `args` will always be { resolve, reject, onCancelHook }
executor(promise, unpack(args))
return promise
end end
function Promise._new(traceback, executor, ...) function Promise:__tostring()
return Promise._newWithSelf(function(self, ...) return ("Promise(%s)"):format(self:getStatus())
self._source = traceback
executor(...)
end, ...)
end end
--[[ --[[
Promise.new, except pcall on a new thread is automatic. Promise.new, except pcall on a new thread is automatic.
]] ]]
function Promise.async(callback) function Promise.defer(callback)
local traceback = debug.traceback() local traceback = debug.traceback(nil, 2)
local promise local promise
promise = Promise._new(traceback, function(resolve, reject, onCancel) promise = Promise._new(traceback, function(resolve, reject, onCancel)
local connection local connection
connection = RunService.Heartbeat:Connect(function() connection = Promise._timeEvent:Connect(function()
connection:Disconnect() connection:Disconnect()
local ok, err = pcall(callback, resolve, reject, onCancel) local ok, _, result = runExecutor(traceback, callback, resolve, reject, onCancel)
if not ok then if not ok then
promise._error = err or "error" reject(PromiseRuntimeError.new(result))
reject(err .. "\n" .. traceback)
end end
end) end)
end) end)
@ -221,12 +233,15 @@ function Promise.async(callback)
return promise return promise
end end
-- Backwards compatibility
Promise.async = Promise.defer
--[[ --[[
Create a promise that represents the immediately resolved value. Create a promise that represents the immediately resolved value.
]] ]]
function Promise.resolve(...) function Promise.resolve(...)
local length, values = pack(...) local length, values = pack(...)
return Promise._new(debug.traceback(), function(resolve) return Promise._new(debug.traceback(nil, 2), function(resolve)
resolve(unpack(values, 1, length)) resolve(unpack(values, 1, length))
end) end)
end end
@ -236,16 +251,28 @@ end
]] ]]
function Promise.reject(...) function Promise.reject(...)
local length, values = pack(...) local length, values = pack(...)
return Promise._new(debug.traceback(), function(_, reject) return Promise._new(debug.traceback(nil, 2), function(_, reject)
reject(unpack(values, 1, length)) reject(unpack(values, 1, length))
end) end)
end end
--[[
Runs a non-promise-returning function as a Promise with the
given arguments.
]]
function Promise._try(traceback, callback, ...)
local valuesLength, values = pack(...)
return Promise._new(traceback, function(resolve, reject)
resolve(callback(unpack(values, 1, valuesLength)))
end)
end
--[[ --[[
Begins a Promise chain, turning synchronous errors into rejections. Begins a Promise chain, turning synchronous errors into rejections.
]] ]]
function Promise.try(...) function Promise.try(...)
return Promise.resolve():andThenCall(...) return Promise._try(debug.traceback(nil, 2), ...)
end end
--[[ --[[
@ -271,9 +298,7 @@ function Promise._all(traceback, promises, amount)
return Promise.resolve({}) return Promise.resolve({})
end end
return Promise._newWithSelf(function(self, resolve, reject, onCancel) return Promise._new(traceback, function(resolve, reject, onCancel)
self._source = traceback
-- An array to contain our resolved values from the given promises. -- An array to contain our resolved values from the given promises.
local resolvedValues = {} local resolvedValues = {}
local newPromises = {} local newPromises = {}
@ -343,17 +368,17 @@ function Promise._all(traceback, promises, amount)
end end
function Promise.all(promises) function Promise.all(promises)
return Promise._all(debug.traceback(), promises) return Promise._all(debug.traceback(nil, 2), promises)
end end
function Promise.some(promises, amount) function Promise.some(promises, amount)
assert(type(amount) == "number", "Bad argument #2 to Promise.some: must be a number") assert(type(amount) == "number", "Bad argument #2 to Promise.some: must be a number")
return Promise._all(debug.traceback(), promises, amount) return Promise._all(debug.traceback(nil, 2), promises, amount)
end end
function Promise.any(promises) function Promise.any(promises)
return Promise._all(debug.traceback(), promises, 1):andThen(function(values) return Promise._all(debug.traceback(nil, 2), promises, 1):andThen(function(values)
return values[1] return values[1]
end) end)
end end
@ -376,7 +401,7 @@ function Promise.allSettled(promises)
return Promise.resolve({}) return Promise.resolve({})
end end
return Promise._new(debug.traceback(), function(resolve, _, onCancel) return Promise._new(debug.traceback(nil, 2), function(resolve, _, onCancel)
-- An array to contain our resolved values from the given promises. -- An array to contain our resolved values from the given promises.
local fates = {} local fates = {}
local newPromises = {} local newPromises = {}
@ -428,7 +453,7 @@ function Promise.race(promises)
assert(Promise.is(promise), (ERROR_NON_PROMISE_IN_LIST):format("Promise.race", tostring(i))) assert(Promise.is(promise), (ERROR_NON_PROMISE_IN_LIST):format("Promise.race", tostring(i)))
end end
return Promise._new(debug.traceback(), function(resolve, reject, onCancel) return Promise._new(debug.traceback(nil, 2), function(resolve, reject, onCancel)
local newPromises = {} local newPromises = {}
local finished = false local finished = false
@ -471,7 +496,20 @@ function Promise.is(object)
return false return false
end end
local objectMetatable = getmetatable(object)
if objectMetatable == Promise then
-- The Promise came from this library.
return true
elseif objectMetatable == nil then
-- No metatable, but we should still chain onto tables with andThen methods
return type(object.andThen) == "function" return type(object.andThen) == "function"
elseif type(objectMetatable) == "table" and type(rawget(objectMetatable, "andThen")) == "function" then
-- Maybe this came from a different or older Promise library.
return true
end
return false
end end
--[[ --[[
@ -479,18 +517,7 @@ end
]] ]]
function Promise.promisify(callback) function Promise.promisify(callback)
return function(...) return function(...)
local traceback = debug.traceback() return Promise._try(debug.traceback(nil, 2), callback, ...)
local length, values = pack(...)
return Promise._new(traceback, function(resolve, reject)
coroutine.wrap(function()
local ok, resultLength, resultValues = packResult(pcall(callback, unpack(values, 1, length)))
if ok then
resolve(unpack(resultValues, 1, resultLength))
else
reject((resultValues[1] or "error") .. "\n" .. traceback)
end
end)()
end)
end end
end end
@ -512,8 +539,8 @@ do
seconds = 1 / 60 seconds = 1 / 60
end end
return Promise._new(debug.traceback(), function(resolve, _, onCancel) return Promise._new(debug.traceback(nil, 2), function(resolve, _, onCancel)
local startTime = tick() local startTime = Promise._getTime()
local endTime = startTime + seconds local endTime = startTime + seconds
local node = { local node = {
@ -524,8 +551,8 @@ do
if connection == nil then -- first is nil when connection is nil if connection == nil then -- first is nil when connection is nil
first = node first = node
connection = RunService.Heartbeat:Connect(function() connection = Promise._timeEvent:Connect(function()
local currentTime = tick() local currentTime = Promise._getTime()
while first.endTime <= currentTime do while first.endTime <= currentTime do
first.resolve(currentTime - first.startTime) first.resolve(currentTime - first.startTime)
@ -536,7 +563,7 @@ do
break break
end end
first.previous = nil first.previous = nil
currentTime = tick() currentTime = Promise._getTime()
end end
end) end)
else -- first is non-nil else -- first is non-nil
@ -670,7 +697,7 @@ function Promise.prototype:andThen(successHandler, failureHandler)
ERROR_NON_FUNCTION:format("Promise:andThen") ERROR_NON_FUNCTION:format("Promise:andThen")
) )
return self:_andThen(debug.traceback(), successHandler, failureHandler) return self:_andThen(debug.traceback(nil, 2), successHandler, failureHandler)
end end
--[[ --[[
@ -681,7 +708,7 @@ function Promise.prototype:catch(failureCallback)
failureCallback == nil or type(failureCallback) == "function", failureCallback == nil or type(failureCallback) == "function",
ERROR_NON_FUNCTION:format("Promise:catch") ERROR_NON_FUNCTION:format("Promise:catch")
) )
return self:_andThen(debug.traceback(), nil, failureCallback) return self:_andThen(debug.traceback(nil, 2), nil, failureCallback)
end end
--[[ --[[
@ -690,7 +717,7 @@ end
]] ]]
function Promise.prototype:tap(tapCallback) function Promise.prototype:tap(tapCallback)
assert(type(tapCallback) == "function", ERROR_NON_FUNCTION:format("Promise:tap")) assert(type(tapCallback) == "function", ERROR_NON_FUNCTION:format("Promise:tap"))
return self:_andThen(debug.traceback(), function(...) return self:_andThen(debug.traceback(nil, 2), function(...)
local callbackReturn = tapCallback(...) local callbackReturn = tapCallback(...)
if Promise.is(callbackReturn) then if Promise.is(callbackReturn) then
@ -710,7 +737,7 @@ end
function Promise.prototype:andThenCall(callback, ...) function Promise.prototype:andThenCall(callback, ...)
assert(type(callback) == "function", ERROR_NON_FUNCTION:format("Promise:andThenCall")) assert(type(callback) == "function", ERROR_NON_FUNCTION:format("Promise:andThenCall"))
local length, values = pack(...) local length, values = pack(...)
return self:_andThen(debug.traceback(), function() return self:_andThen(debug.traceback(nil, 2), function()
return callback(unpack(values, 1, length)) return callback(unpack(values, 1, length))
end) end)
end end
@ -720,7 +747,7 @@ end
]] ]]
function Promise.prototype:andThenReturn(...) function Promise.prototype:andThenReturn(...)
local length, values = pack(...) local length, values = pack(...)
return self:_andThen(debug.traceback(), function() return self:_andThen(debug.traceback(nil, 2), function()
return unpack(values, 1, length) return unpack(values, 1, length)
end) end)
end end
@ -814,7 +841,7 @@ function Promise.prototype:finally(finallyHandler)
finallyHandler == nil or type(finallyHandler) == "function", finallyHandler == nil or type(finallyHandler) == "function",
ERROR_NON_FUNCTION:format("Promise:finally") ERROR_NON_FUNCTION:format("Promise:finally")
) )
return self:_finally(debug.traceback(), finallyHandler) return self:_finally(debug.traceback(nil, 2), finallyHandler)
end end
--[[ --[[
@ -823,7 +850,7 @@ end
function Promise.prototype:finallyCall(callback, ...) function Promise.prototype:finallyCall(callback, ...)
assert(type(callback) == "function", ERROR_NON_FUNCTION:format("Promise:finallyCall")) assert(type(callback) == "function", ERROR_NON_FUNCTION:format("Promise:finallyCall"))
local length, values = pack(...) local length, values = pack(...)
return self:_finally(debug.traceback(), function() return self:_finally(debug.traceback(nil, 2), function()
return callback(unpack(values, 1, length)) return callback(unpack(values, 1, length))
end) end)
end end
@ -833,7 +860,7 @@ end
]] ]]
function Promise.prototype:finallyReturn(...) function Promise.prototype:finallyReturn(...)
local length, values = pack(...) local length, values = pack(...)
return self:_finally(debug.traceback(), function() return self:_finally(debug.traceback(nil, 2), function()
return unpack(values, 1, length) return unpack(values, 1, length)
end) end)
end end
@ -846,7 +873,7 @@ function Promise.prototype:done(finallyHandler)
finallyHandler == nil or type(finallyHandler) == "function", finallyHandler == nil or type(finallyHandler) == "function",
ERROR_NON_FUNCTION:format("Promise:finallyO") ERROR_NON_FUNCTION:format("Promise:finallyO")
) )
return self:_finally(debug.traceback(), finallyHandler, true) return self:_finally(debug.traceback(nil, 2), finallyHandler, true)
end end
--[[ --[[
@ -855,7 +882,7 @@ end
function Promise.prototype:doneCall(callback, ...) function Promise.prototype:doneCall(callback, ...)
assert(type(callback) == "function", ERROR_NON_FUNCTION:format("Promise:doneCall")) assert(type(callback) == "function", ERROR_NON_FUNCTION:format("Promise:doneCall"))
local length, values = pack(...) local length, values = pack(...)
return self:_finally(debug.traceback(), function() return self:_finally(debug.traceback(nil, 2), function()
return callback(unpack(values, 1, length)) return callback(unpack(values, 1, length))
end, true) end, true)
end end
@ -865,7 +892,7 @@ end
]] ]]
function Promise.prototype:doneReturn(...) function Promise.prototype:doneReturn(...)
local length, values = pack(...) local length, values = pack(...)
return self:_finally(debug.traceback(), function() return self:_finally(debug.traceback(nil, 2), function()
return unpack(values, 1, length) return unpack(values, 1, length)
end, true) end, true)
end end
@ -925,6 +952,7 @@ function Promise.prototype:expect(...)
return expectHelper(self:awaitStatus(...)) return expectHelper(self:awaitStatus(...))
end end
-- Backwards compatibility
Promise.prototype.awaitValue = Promise.prototype.expect Promise.prototype.awaitValue = Promise.prototype.expect
--[[ --[[
@ -972,10 +1000,21 @@ function Promise.prototype:_resolve(...)
self:_resolve(...) self:_resolve(...)
end, end,
function(...) function(...)
-- The handler errored. Replace the inner stack trace with our outer stack trace. local runtimeError = chainedPromise._values[1]
-- Backwards compatibility <v2
if chainedPromise._error then if chainedPromise._error then
return self:_reject((chainedPromise._error or "") .. "\n" .. self._source) runtimeError = PromiseRuntimeError.new(chainedPromise._error)
end end
if PromiseRuntimeError.is(runtimeError) then
return self:_reject(runtimeError:extend(
("The Promise at:\n\n%s\n...Rejected because it was chained to the following Promise, which encountered an error:\n"):format(
self._source
)
))
end
self:_reject(...) self:_reject(...)
end end
) )
@ -1025,7 +1064,7 @@ function Promise.prototype:_reject(...)
local err = tostring((...)) local err = tostring((...))
coroutine.wrap(function() coroutine.wrap(function()
RunService.Heartbeat:Wait() Promise._timeEvent:Wait()
-- Someone observed the error, hooray! -- Someone observed the error, hooray!
if not self._unhandledRejection then if not self._unhandledRejection then
@ -1033,15 +1072,16 @@ function Promise.prototype:_reject(...)
end end
-- Build a reasonable message -- Build a reasonable message
local message local message = ("Unhandled promise rejection:\n\n%s\n\n%s"):format(
if self._error then
message = ("Unhandled promise rejection:\n\n%s"):format(err)
else
message = ("Unhandled promise rejection:\n\n%s\n\n%s"):format(
err, err,
self._source self._source
) )
if Promise.TEST then
-- Don't spam output when we're running tests.
return
end end
warn(message) warn(message)
end)() end)()
end end
@ -1062,11 +1102,7 @@ function Promise.prototype:_finalize()
callback(self._status) callback(self._status)
end end
if self._parent and self._error == nil then -- Clear references to other Promises to allow gc
self._error = self._parent._error
end
-- Allow family to be buried
if not Promise.TEST then if not Promise.TEST then
self._parent = nil self._parent = nil
self._consumers = nil self._consumers = nil

View file

@ -2,6 +2,24 @@ return function()
local Promise = require(script.Parent) local Promise = require(script.Parent)
Promise.TEST = true Promise.TEST = true
local timeEvent = Instance.new("BindableEvent")
Promise._timeEvent = timeEvent.Event
local advanceTime do
local injectedPromiseTime = 0
Promise._getTime = function()
return injectedPromiseTime
end
function advanceTime(delta)
delta = delta or (1/60)
injectedPromiseTime = injectedPromiseTime + delta
timeEvent:Fire(delta)
end
end
local function pack(...) local function pack(...)
local len = select("#", ...) local len = select("#", ...)
@ -79,11 +97,95 @@ return function()
expect(promise).to.be.ok() expect(promise).to.be.ok()
expect(callCount).to.equal(1) expect(callCount).to.equal(1)
expect(promise:getStatus()).to.equal(Promise.Status.Rejected) expect(promise:getStatus()).to.equal(Promise.Status.Rejected)
expect(promise._values[1]:find("hahah")).to.be.ok() expect(tostring(promise._values[1]):find("hahah")).to.be.ok()
-- Loosely check for the pieces of the stack trace we expect -- Loosely check for the pieces of the stack trace we expect
expect(promise._values[1]:find("init.spec")).to.be.ok() expect(tostring(promise._values[1]):find("init.spec")).to.be.ok()
expect(promise._values[1]:find("new")).to.be.ok() expect(tostring(promise._values[1]):find("runExecutor")).to.be.ok()
end)
it("should work with C functions", function()
expect(function()
Promise.new(tick):andThen(tick)
end).to.never.throw()
end)
it("should have a nice tostring", function()
expect(tostring(Promise.resolve()):gmatch("Promise(Resolved)")).to.be.ok()
end)
it("should allow yielding", function()
local bindable = Instance.new("BindableEvent")
local promise = Promise.new(function(resolve)
bindable.Event:Wait()
resolve(5)
end)
expect(promise:getStatus()).to.equal(Promise.Status.Started)
bindable:Fire()
expect(promise:getStatus()).to.equal(Promise.Status.Resolved)
expect(promise._values[1]).to.equal(5)
end)
it("should preserve stack traces of resolve-chained promises", function()
local function nestedCall(text)
error(text)
end
local promise = Promise.new(function(resolve)
resolve(Promise.new(function()
nestedCall("sample text")
end))
end)
expect(promise:getStatus()).to.equal(Promise.Status.Rejected)
local trace = tostring(promise._values[1])
expect(trace:find("sample text")).to.be.ok()
expect(trace:find("nestedCall")).to.be.ok()
expect(trace:find("runExecutor")).to.be.ok()
expect(trace:find("runPlanNode")).to.be.ok()
expect(trace:find("...Rejected because it was chained to the following Promise, which encountered an error:")).to.be.ok()
end)
end)
describe("Promise.defer", function()
it("should execute after the time event", function()
local callCount = 0
local promise = Promise.defer(function(resolve, reject, onCancel, nothing)
expect(type(resolve)).to.equal("function")
expect(type(reject)).to.equal("function")
expect(type(onCancel)).to.equal("function")
expect(type(nothing)).to.equal("nil")
callCount = callCount + 1
resolve("foo")
end)
expect(callCount).to.equal(0)
expect(promise:getStatus()).to.equal(Promise.Status.Started)
advanceTime()
expect(callCount).to.equal(1)
expect(promise:getStatus()).to.equal(Promise.Status.Resolved)
advanceTime()
expect(callCount).to.equal(1)
end)
end)
describe("Promise.delay", function()
it("Should schedule promise resolution", function()
local promise = Promise.delay(1)
expect(promise:getStatus()).to.equal(Promise.Status.Started)
advanceTime()
expect(promise:getStatus()).to.equal(Promise.Status.Started)
advanceTime(1)
expect(promise:getStatus()).to.equal(Promise.Status.Resolved)
end) end)
end) end)
@ -132,6 +234,19 @@ return function()
end) end)
describe("Promise:andThen", function() describe("Promise:andThen", function()
it("should allow yielding", function()
local bindable = Instance.new("BindableEvent")
local promise = Promise.resolve():andThen(function()
bindable.Event:Wait()
return 5
end)
expect(promise:getStatus()).to.equal(Promise.Status.Started)
bindable:Fire()
expect(promise:getStatus()).to.equal(Promise.Status.Resolved)
expect(promise._values[1]).to.equal(5)
end)
it("should chain onto resolved promises", function() it("should chain onto resolved promises", function()
local args local args
local argsLength local argsLength
@ -200,6 +315,24 @@ return function()
expect(#chained._values).to.equal(0) expect(#chained._values).to.equal(0)
end) end)
it("should reject on error in callback", function()
local callCount = 0
local promise = Promise.resolve(1):andThen(function()
callCount = callCount + 1
error("hahah")
end)
expect(promise).to.be.ok()
expect(callCount).to.equal(1)
expect(promise:getStatus()).to.equal(Promise.Status.Rejected)
expect(tostring(promise._values[1]):find("hahah")).to.be.ok()
-- Loosely check for the pieces of the stack trace we expect
expect(tostring(promise._values[1]):find("init.spec")).to.be.ok()
expect(tostring(promise._values[1]):find("runExecutor")).to.be.ok()
end)
it("should chain onto asynchronously resolved promises", function() it("should chain onto asynchronously resolved promises", function()
local args local args
local argsLength local argsLength
@ -717,7 +850,7 @@ return function()
expect(promise:getStatus()).to.equal(Promise.Status.Started) expect(promise:getStatus()).to.equal(Promise.Status.Started)
bindable:Fire() bindable:Fire()
expect(promise:getStatus()).to.equal(Promise.Status.Rejected) expect(promise:getStatus()).to.equal(Promise.Status.Rejected)
expect(promise._values[1]:find("errortext")).to.be.ok() expect(tostring(promise._values[1]):find("errortext")).to.be.ok()
end) end)
end) end)
@ -770,11 +903,24 @@ return function()
Promise.try(function() Promise.try(function()
error('errortext') error('errortext')
end):catch(function(e) end):catch(function(e)
errorText = e errorText = tostring(e)
end) end)
expect(errorText:find("errortext")).to.be.ok() expect(errorText:find("errortext")).to.be.ok()
end) end)
it("should catch asynchronous errors", function()
local bindable = Instance.new("BindableEvent")
local promise = Promise.try(function()
bindable.Event:Wait()
error('errortext')
end)
expect(promise:getStatus()).to.equal(Promise.Status.Started)
bindable:Fire()
expect(promise:getStatus()).to.equal(Promise.Status.Rejected)
expect(tostring(promise._values[1]):find("errortext")).to.be.ok()
end)
end) end)
describe("Promise:andThenReturn", function() describe("Promise:andThenReturn", function()

65
package-lock.json generated
View file

@ -7552,9 +7552,9 @@
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==" "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="
}, },
"slugify": { "slugify": {
"version": "1.3.5", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.3.5.tgz", "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.4.0.tgz",
"integrity": "sha512-5VCnH7aS13b0UqWOs7Ef3E5rkhFe8Od+cp7wybFv5mv/sYSRkucZlJX0bamAJky7b2TTtGvrJBWVdpdEicsSrA==" "integrity": "sha512-FtLNsMGBSRB/0JOE2A0fxlqjI6fJsgHGS13iTuVT28kViI4JjUiNqp/vyis0ZXYcMnpR3fzGNkv+6vRlI2GwdQ=="
}, },
"snapdragon": { "snapdragon": {
"version": "0.8.2", "version": "0.8.2",
@ -8762,13 +8762,64 @@
} }
}, },
"vuepress-plugin-api-docs-generator": { "vuepress-plugin-api-docs-generator": {
"version": "1.0.16", "version": "1.0.18",
"resolved": "https://registry.npmjs.org/vuepress-plugin-api-docs-generator/-/vuepress-plugin-api-docs-generator-1.0.16.tgz", "resolved": "https://registry.npmjs.org/vuepress-plugin-api-docs-generator/-/vuepress-plugin-api-docs-generator-1.0.18.tgz",
"integrity": "sha512-6D1ZloMemPI3Iyx819ep/mE5+6y5zQ4zw2bM7y0PSUFs68HHbJbxAN9wOoxl9isTvmtTPM19L82E4MkSEzys/w==", "integrity": "sha512-fQauXyRcj5gAdQbAE3IIGHSZ9tsrLmykbjFgCq5Xd4LTZAsDOqWjWxfbFG5BChmePkCq+GjFlckEjGxAixAmBg==",
"requires": { "requires": {
"@vuepress/plugin-register-components": "^1.0.0-rc.1", "@vuepress/plugin-register-components": "^1.4.1",
"node-balanced": "0.0.14", "node-balanced": "0.0.14",
"slugify": "^1.3.4" "slugify": "^1.3.4"
},
"dependencies": {
"@vuepress/plugin-register-components": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@vuepress/plugin-register-components/-/plugin-register-components-1.4.1.tgz",
"integrity": "sha512-6yI4J/tMhOASSLmlP+5p4ccljlWuNBRsyYSKiD5jWAV181oMmN32LtuoCggXBhSvQUgn2grxyjmYw+tcSV5KGQ==",
"requires": {
"@vuepress/shared-utils": "1.4.1"
}
},
"@vuepress/shared-utils": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@vuepress/shared-utils/-/shared-utils-1.4.1.tgz",
"integrity": "sha512-FBUHFhvR7vk6glQy/qUntBz8bVeWiNYZ2/G16EKaerKKn15xAiD7tUFCQ3L/KjtQJ8TV38GK47UEXh7UTcRwQg==",
"requires": {
"chalk": "^2.3.2",
"diacritics": "^1.3.0",
"escape-html": "^1.0.3",
"fs-extra": "^7.0.1",
"globby": "^9.2.0",
"gray-matter": "^4.0.1",
"hash-sum": "^1.0.2",
"semver": "^6.0.0",
"upath": "^1.1.0"
}
},
"globby": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-9.2.0.tgz",
"integrity": "sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==",
"requires": {
"@types/glob": "^7.1.1",
"array-union": "^1.0.2",
"dir-glob": "^2.2.2",
"fast-glob": "^2.2.6",
"glob": "^7.1.3",
"ignore": "^4.0.3",
"pify": "^4.0.1",
"slash": "^2.0.0"
}
},
"pify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="
},
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
}
} }
}, },
"vuepress-plugin-container": { "vuepress-plugin-container": {

View file

@ -9,7 +9,7 @@
"dependencies": { "dependencies": {
"gh-pages": "^2.1.1", "gh-pages": "^2.1.1",
"vuepress": "^1.0.3", "vuepress": "^1.0.3",
"vuepress-plugin-api-docs-generator": "^1.0.16" "vuepress-plugin-api-docs-generator": "^1.0.18"
}, },
"devDependencies": {}, "devDependencies": {},
"scripts": { "scripts": {