Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

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

MemberWhat 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.handleThe 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

CallResult
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.spawn or a handler is cancelled, and so is everything it started; each unwinds, so the pcall or 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; finally runs 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 race another input won, a timeout that ran out, an all another input rejected), is cancelled. A direct await is 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 }>.