Builtin Library
README.md
future
A future is a promise held as a value: store it in a table, return it from a
function, hand it to another task, and await it whenever the value is needed.
Every promise-returning call (http.get_json, delay,
asset.load, ...) already hands back one, a PromiseHandle; this module makes
new ones, runs work as one, chains them, waits for several at once, bounds one
in time and cancels them.
local future = require("@builtin::modules.future")
task.spawn(function()
-- Start both loads; they run at the same time.
local pending: { Future<any> } = {}
table.insert(pending, http.get_json("https://example.com/a.json"))
table.insert(pending, http.get_json("https://example.com/b.json"))
-- Collect them. Each await returns once its load has settled.
local results = {}
for i, f in pending do
results[i] = f:await()
end
end)
Members of a future
| Member | What it answers |
|---|---|
f:await() | Yields until the future settles and returns its value; a future that has already settled returns at once, with no yield. A rejection raises its error at this line. The same call as await(f) and task.await(f). |
f:state() | "pending", "resolved", "rejected" or "cancelled", read without taking the value. |
f:rejection() | Why the future was rejected: { message, origin?, stack? }, the reason as text, the chunk:line the rejection came from and, when Luau rejected it, the stack there. nil while it is pending or once it resolved. |
f:source() | The chunk:line whose call created the promise, for finding what a hung await is waiting on. |
f.handle | The promise's id string. tostring(f) reads the same. |
f:andThen(onResolve, onReject?) | A future of what the handler returns; a future it returns is awaited and its value delivered. Without onReject a rejection passes through. A handler that raises rejects it. |
f:catch(onReject) | A future of f's value, or of what onReject returns given the error. |
f:finally(fn) | A future settled the way f was, once fn() has run, whichever way f settled, cancelled included. |
f:tap(fn) | A future of f's value, once fn(value) has seen it. |
f:timeout(seconds, reason?) | A future of f's value that rejects with reason (a message naming the time by default) when seconds of real time pass first; f is then let go of. |
f:cancel() | Ends a pending f as cancelled: true, or false and the reason. |
The chaining methods come with the module, which every world script VM loads,
so a future an engine call hands back chains with no require. A handler runs in
a task (it may wait) inside a scopes.run: the chained future settles once
everything the handler started is over.
Every task that awaits a future receives its value: the ones waiting when it settles and any that await it later, for as long as the future is held.
Module functions
| Call | Result |
|---|---|
future.wrap(promise) | The future naming a promise, from its future or its id string. An id naming no promise the engine keeps raises. |
future.new(onCancel?) | A pending future plus resolve(value) and reject(error), which settle it once. error is any value but nil. onCancel() runs when the future is cancelled. |
future.resolved(value) | A future already resolved with value. |
future.rejected(err) | A future already rejected with err. |
future.after(seconds) | A future resolving with true after seconds of real time (the promise delay(seconds) hands back). |
future.spawn(fn, ...) | Runs fn(...) as a task; the future resolves with what it returns (awaiting a future it returns) and rejects with what it raises. |
future.all(futures) | Every value, in the order given. The first rejection rejects it, the first cancel cancels it, and the rest are let go of. {} for none. |
future.allSettled(futures) | { status, value?, error? } for each, in order, once all have settled. Never rejects. |
future.race(futures) | Settles the way the first to settle did; the rest are let go of. |
future.any(futures) | The first value resolved; the rest are let go of. When none resolves it rejects with { message, errors }. |
future.retry(fn, attempts, delaySeconds?) | Calls fn() until it succeeds, at most attempts times, with delaySeconds of real time between. |
future.fromEvent(event, predicate?) | The first value of the next fire of event (a Signal, a component event, anything offering :Connect(fn) / :connect(fn)) that predicate accepts. Disconnects once settled or cancelled, and is cancelled when what started it ends. |
local future = require("@builtin::modules.future")
task.spawn(function()
-- Both loads at once; the first failure fails the lot.
local maps = future.all({ http.get_json(urlA), http.get_json(urlB) }):await()
-- Whichever search answers first; the other is cancelled.
local nearest = future.race({ future.spawn(searchNear), future.spawn(searchFar) }):await()
-- No level within five seconds rejects, and the load is cancelled.
local ok, err = pcall(function()
return future.spawn(loadLevel, "forest"):timeout(5):await()
end)
end)
Cancellation
f:cancel() ends a pending future as cancelled. Awaiting it raises the error
task.isCancellation(err) recognises, and f:state() reads "cancelled". A
cancellation is never written to the error log.
- The work stops. The task running a
future.spawnor a handler is cancelled, and so is everything it started; each unwinds, so thepcallor cleanup around its wait runs. - Downstream. A future chained from a cancelled one (
andThen,catch,tap,timeout,all,race, ...) is cancelled too, without calling its handler;finallyruns its function first. - Upstream. A future counts the chains and combinators waiting on it. One
whose last such consumer is cancelled, or settled without it (a
raceanother input won, atimeoutthat ran out, anallanother input rejected), is cancelled. A directawaitis not a consumer. - What started it. The task running a handler belongs to what the calling code belongs to: a component instance, a scene load, a task scope. When that ends, the future is cancelled.
A future an engine call handed back (http.get_json, delay) runs to its end
whatever waits on it; its cancel() answers false and the reason.
Rejections
A rejection is kept apart from any value, so a future resolved with a table of
any shape delivers that table. Awaiting a rejected future raises its error: a
string at the await's line, any other value (an error table reject was
given) as itself. Catch it with pcall, and read where it came from on the
future:
local f, _, reject = future.new()
task.delay(1, function()
reject({ code = 404, message = "no such level" })
end)
local ok, err = pcall(f.await, f)
if not ok then
print(err.code) -- 404: the error value reject was given
local why = f:rejection()
print(why.origin) -- the line that called reject
end
A rejection nothing sees (no await receives it and nothing reads
rejection()) is written to the error log once the future is gone, so a
future you hold and await later reports nothing on its own. A chain or
combinator sees every rejection its inputs hold when it settles, so the second
rejection of an all, or a race input already rejected when another won,
writes nothing.
Identity
A VM holds one future object per promise. Every call that hands a promise to
the VM while its future is alive, future.wrap included, hands back that same
object: future.wrap(f) == f, rawequal(future.wrap(f.handle), f), and a
future is one table key.
Types
Future<T> is a future whose await delivers a T; PromiseHandle is a
future whose value has no stated type. They name the same value, so either
is accepted where the other is declared, and f:await() on a Future<T>
checks as a T. f:andThen(fn) checks as a Future of what fn returns (a
Future<V> it returns reads as V), f:catch(fn) as the value or what fn
returns, finally, tap and timeout as f's own type, and
future.all({ Future<T> }) as a Future<{ T }>.