Add Promise:now()

closes #23
This commit is contained in:
Eryn Lynn 2020-05-06 19:22:48 -04:00
parent ff345ea31b
commit faa4f73dd3
5 changed files with 171 additions and 57 deletions

View file

@ -1,6 +1,7 @@
# 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).
- New Promise Error class is exposed at `Promise.Error`, which includes helpful static methods like `Promise.Error.is`.
- Yielding is now allowed in Promise.new, andThen, and Promise.try executors.
- 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.
@ -10,6 +11,9 @@
- Improve test coverage for asynchronous and time-driven functions
- Change Promise.is to be safe when dealing with tables that have an `__index` metamethod that creates an error.
- Let Promise:expect() throw rejection objects
- Add Promise:now() (#23)
- Promise:timeout() now rejects with a `Promise.Error(Promise.Error.Kind.TimedOut)` object. (Formerly rejected with the string "Timed out")
- Attaching a handler to a cancelled Promise now rejects with a `Promise.Error(Promise.Error.Kind.AlreadyCancelled)`. (Formerly rejected with the string "Promise is cancelled")
# 2.5.1

View file

@ -512,16 +512,18 @@ docs:
returns: Promise<...any?>
- name: timeout
params: "seconds: number, rejectionValue: any?"
params: "seconds: number, rejectionValue: T?"
desc: |
Returns a new Promise that resolves if the chained Promise resolves within `seconds` seconds, or rejects if execution time exceeds `seconds`. The chained Promise will be cancelled if the timeout is reached.
Rejects with `rejectionValue` if it is non-nil. If a `rejectionValue` is not given, it will reject with a `Promise.Error(Promise.Error.Kind.TimedOut)`. This can be checked with [[Error.isKind]].
Sugar for:
```lua
Promise.race({
Promise.delay(seconds):andThen(function()
return Promise.reject(rejectionValue == nil and "Timed out" or rejectionValue)
return Promise.reject(rejectionValue == nil and Promise.Error.new({ kind = Promise.Error.Kind.TimedOut }) or rejectionValue)
end),
promise
})
@ -536,6 +538,22 @@ docs:
Promises will only be cancelled if all of their consumers are also cancelled. This is to say that if you call `andThen` twice on the same promise, and you cancel only one of the child promises, it will not cancel the parent promise until the other child promise is also cancelled.
- name: now
desc: |
Chains a Promise from this one that is resolved if this Promise is already resolved, and rejected if it is not resolved at the time of calling `:now()`. This can be used to ensure your `andThen` handler occurs on the same frame as the root Promise execution.
```lua
doSomething()
:now()
:andThen(function(value)
print("Got", value, "synchronously.")
end)
```
If this Promise is still running, Rejected, or Cancelled, the Promise returned from `:now()` will reject with the `rejectionValue` if passed, or a `Promise.Error(Promise.Error.Kind.NotResolvedInTime)`. This can be checked with [[Error.isKind]].
params: "rejectionValue: T?"
returns: Promise<T>
- name: await
tags: [ 'yields' ]
desc: |

View file

@ -152,7 +152,7 @@ It's good practice to add an `onCancel` hook to all of your asynchronous Promise
Even if you don't plan to directly cancel a particular Promise, chaining with other Promises can cause it to become automatically cancelled if no one cares about the value anymore.
:::
If you attach a `:andThen` or `:catch` handler to a Promise after it's been cancelled, the chained Promise will be instantly rejected with the error "Promise is cancelled". This also applies to Promises that you pass to `resolve`. However, `finally` does not have this constraint.
If you attach a `:andThen` or `:catch` handler to a Promise after it's been cancelled, the chained Promise will be instantly rejected with `Promise.Error(Promise.Error.Kind.AlreadyCancelled)`. This also applies to Promises that you pass to `resolve`. However, `finally` does not have this constraint.
::: warning
If you cancel a Promise immediately after creating it without yielding in between, the fate of the Promise is dependent on if the Promise handler yields or not. If the Promise handler resolves without yielding, then the Promise will already be settled by the time you are able to cancel it, thus any consumers of the Promise will have already been called and cancellation is not possible.

View file

@ -12,60 +12,105 @@ local MODE_KEY_METATABLE = {
local RunService = game:GetService("RunService")
--[[
Creates an enum dictionary with some metamethods to prevent common mistakes.
]]
local function makeEnum(enumName, members)
local enum = {}
for _, memberName in ipairs(members) do
enum[memberName] = memberName
end
return setmetatable(enum, {
__index = function(_, k)
error(("%s is not in %s!"):format(k, enumName), 2)
end,
__newindex = function()
error(("Creating new members in %s is not allowed!"):format(enumName), 2)
end
})
end
--[[
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 RuntimeError = {}
RuntimeError.__index = RuntimeError
local Error do
Error = {
Kind = makeEnum("Promise.Error.Kind", {
"ExecutionError",
"AlreadyCancelled",
"NotResolvedInTime",
"TimedOut"
})
}
Error.__index = Error
function RuntimeError.new(options, parent)
options = options or {}
return setmetatable({
error = tostring(options.error) or "[This error has no error text.]",
trace = options.trace,
context = options.context,
parent = parent,
createdTick = tick(),
createdTrace = debug.traceback()
}, RuntimeError)
end
function Error.new(options, parent)
options = options or {}
return setmetatable({
error = tostring(options.error) or "[This error has no error text.]",
trace = options.trace,
context = options.context,
kind = options.kind,
parent = parent,
createdTick = tick(),
createdTrace = debug.traceback()
}, Error)
end
function RuntimeError.is(anything)
if type(anything) == "table" then
local metatable = getmetatable(anything)
function Error.is(anything)
if type(anything) == "table" then
local metatable = getmetatable(anything)
if type(metatable) == "table" then
return rawget(anything, "error") ~= nil and type(rawget(metatable, "extend")) == "function"
if type(metatable) == "table" then
return rawget(anything, "error") ~= nil and type(rawget(metatable, "extend")) == "function"
end
end
return false
end
return false
end
function Error.isKind(anything, kind)
assert(kind ~= nil, "Argument #2 to Promise.Error.isKind must not be nil")
function RuntimeError:extend(options)
return RuntimeError.new(options, self)
end
function RuntimeError:getErrorChain()
local runtimeErrors = { self }
while runtimeErrors[#runtimeErrors].parent do
table.insert(runtimeErrors, runtimeErrors[#runtimeErrors].parent)
return Error.is(anything) and anything.kind == kind
end
return runtimeErrors
end
function Error:extend(options)
options = options or {}
function RuntimeError:__tostring()
local errorStrings = {}
options.kind = options.kind or self.kind
for _, runtimeError in ipairs(self:getErrorChain()) do
table.insert(errorStrings, table.concat({runtimeError.trace or runtimeError.error, runtimeError.context}, "\n"))
return Error.new(options, self)
end
return table.concat(errorStrings, "\n")
function Error:getErrorChain()
local runtimeErrors = { self }
while runtimeErrors[#runtimeErrors].parent do
table.insert(runtimeErrors, runtimeErrors[#runtimeErrors].parent)
end
return runtimeErrors
end
function Error:__tostring()
local errorStrings = {
("-- Promise.Error(%s) --"):format(self.kind or "?"),
}
for _, runtimeError in ipairs(self:getErrorChain()) do
table.insert(errorStrings, table.concat({
runtimeError.trace or runtimeError.error,
runtimeError.context
}, "\n"))
end
return table.concat(errorStrings, "\n")
end
end
--[[
@ -87,8 +132,9 @@ end
local function makeErrorHandler(traceback)
return function(err)
return RuntimeError.new({
return Error.new({
error = err,
kind = Error.Kind.ExecutionError,
trace = debug.traceback(err, 2),
context = "Promise created at:\n\n" .. traceback
})
@ -123,24 +169,14 @@ local function isEmpty(t)
end
local Promise = {
RuntimeError = RuntimeError,
Error = Error,
Status = makeEnum("Promise.Status", {"Started", "Resolved", "Rejected", "Cancelled"}),
_timeEvent = RunService.Heartbeat,
_getTime = tick,
}
Promise.prototype = {}
Promise.__index = Promise.prototype
Promise.Status = setmetatable({
Started = "Started",
Resolved = "Resolved",
Rejected = "Rejected",
Cancelled = "Cancelled",
}, {
__index = function(_, k)
error(("%s is not in Promise.Status!"):format(k), 2)
end
})
--[[
Constructs a new Promise with the given initializing callback.
@ -654,10 +690,19 @@ end
--[[
Rejects the promise after `seconds` seconds.
]]
function Promise.prototype:timeout(seconds, timeoutValue)
function Promise.prototype:timeout(seconds, rejectionValue)
local traceback = debug.traceback(nil, 2)
return Promise.race({
Promise.delay(seconds):andThen(function()
return Promise.reject(timeoutValue == nil and "Timed out" or timeoutValue)
return Promise.reject(rejectionValue == nil and Error.new({
kind = Error.Kind.TimedOut,
error = "Timed out",
context = ("Timeout of %d seconds exceeded.\n:timeout() called at:\n\n%s"):format(
seconds,
traceback
)
}) or rejectionValue)
end),
self
})
@ -713,7 +758,11 @@ function Promise.prototype:_andThen(traceback, successHandler, failureHandler)
elseif self._status == Promise.Status.Cancelled then
-- We don't want to call the success handler or the failure handler,
-- we just reject this promise outright.
reject("Promise is cancelled")
reject(Error.new({
error = "Promise is cancelled",
kind = Error.Kind.AlreadyCancelled,
context = "Promise created at\n\n" .. traceback
}))
end
end, self)
end
@ -1035,13 +1084,14 @@ function Promise.prototype:_resolve(...)
-- Backwards compatibility < v2
if chainedPromise._error then
maybeRuntimeError = RuntimeError.new({
maybeRuntimeError = Error.new({
error = chainedPromise._error,
kind = Error.Kind.ExecutionError,
context = "[No stack trace available as this Promise originated from an older version of the Promise library (< v2)]"
})
end
if RuntimeError.is(maybeRuntimeError) then
if Error.isKind(maybeRuntimeError, Error.Kind.ExecutionError) then
return self:_reject(maybeRuntimeError:extend({
error = "This Promise was chained to a Promise that errored.",
trace = "",
@ -1145,4 +1195,23 @@ function Promise.prototype:_finalize()
end
end
--[[
Chains a Promise from this one that is resolved if this Promise is
resolved, and rejected if it is not resolved.
]]
function Promise.prototype:now(rejectionValue)
local traceback = debug.traceback(nil, 2)
if self:getStatus() == Promise.Status.Resolved then
return self:_andThen(traceback, function(...)
return ...
end)
else
return Promise.reject(rejectionValue == nil and Error.new({
kind = Error.Kind.NotResolvedInTime,
error = "This Promise was not resolved in time for :now()",
context = ":now() was called at:\n\n" .. traceback
}) or rejectionValue)
end
end
return Promise

View file

@ -1203,4 +1203,27 @@ return function()
expect(value).to.equal(rejectionValue)
end)
end)
describe("Promise:now", function()
it("should resolve if the Promise is resolved", function()
local success, value = Promise.resolve("foo"):now():_unwrap()
expect(success).to.equal(true)
expect(value).to.equal("foo")
end)
it("should reject if the Promise is not resolved", function()
local success, value = Promise.new(function() end):now():_unwrap()
expect(success).to.equal(false)
expect(Promise.Error.isKind(value, "NotResolvedInTime")).to.equal(true)
end)
it("should reject with a custom rejection value", function()
local success, value = Promise.new(function() end):now("foo"):_unwrap()
expect(success).to.equal(false)
expect(value).to.equal("foo")
end)
end)
end