Async & Await

This page was generated with the assistance of AI, it needs a cleanup

Some things take time. Reading from a server database, waiting on a network reply, or any other work that cannot answer immediately. Lunaris handles these with async and await, so waiting code still reads top to bottom.

Why async?

Without await, code that waits has to be written as nested callbacks. Every extra step adds another layer:

db.Query(query1, "GetUser" {
    db.Query(query2(user.Id), "GetAchievements" {
        db.Query(query3(achievements), "GetStats" {
            print(stats)
        })
    })
})

Each layer needs its own error handling, try cannot wrap the whole thing, and return no longer means anything useful. The same work with async and await:

async function GetFullPlayerProfile() {
    local user = await db.Query("<Get Current User>")
    local achievements = await db.Query(query2(user.Id))
    return await db.Query(query3(achievements))
}

Async functions

Put async in front of function to declare an async function.

async function LoadName(id) {
    local row = await db.Query("SELECT name FROM players WHERE id = ?", id)
    return row.Name
}

An async function always returns a promise, never the value directly. Calling it does not wait:

local p = LoadName(1)
print(p:status()) // pending

To get the value, await it from inside another async function, or attach a callback with next.

Async functions start immediately

An async function runs straight away, up to its first await. Everything before that await has already happened by the time the call returns.

async function Save(player) {
    print("saving...")           // runs immediately
    await db.Store(player)
    print("saved")               // runs later, once the store completes
}

Save(player)
print("continuing")

// Output:
// saving...
// continuing
// saved

Other forms

async works on every function form.

async function Named() { return await Work() }        // named
local async function Helper() { return await Work() } // local
local f = async function() { return await Work() }    // anonymous
local h = async (x) => await Work() + x               // arrow

Await

await waits for a promise and gives back its value.

async function Total() {
    local a = await Work()
    local b = await Work()
    return a + b
}

await can be used anywhere a value can, including inside loops and conditions:

async function SumAll(ids) {
    local sum = 0
    for (local i = 1; i <= #ids; i++) {
        sum += await LoadScore(ids[i])
    }
    return sum
}

Where await is allowed

await may only appear inside an async function. Using it in an ordinary function is an error, because an ordinary function has no way to pause.

function Broken() {
    return await Work() // error: 'await' is only valid inside an async function
}

This includes a plain function nested inside an async one: only the innermost function counts:

async function Outer() {
    local inner = function() {
        return await Work() // still an error, 'inner' is not async
    }
    return inner()
}

Mark the inner function async and await its result instead:

async function Outer() {
    local inner = async function() { return await Work() }
    return await inner()
}

Awaiting a plain value

Awaiting something that is not a promise simply gives back the value. This means a function can return either a value or a promise and the caller does not have to care.

async function f() {
    return await 5 // 5
}

Await and operators

await binds tighter than operators like +, and looser than . and calls. So:

Written Means
await a + b (await a) + b
await a.b await (a.b)
await f() await (f())

Use parentheses when you need the other grouping:

local name = (await LoadPlayer(id)).Name

Handling failures

When an awaited promise fails, the error is thrown at the await. Ordinary try / catch / finally works:

async function LoadSafely(id) {
    try {
        return await db.Query("SELECT * FROM players WHERE id = ?", id)
    } catch (err) {
        print("lookup failed: " + err.Message)
        return nil
    } finally {
        print("done")
    }
}

attempt works too, when the failure can simply be ignored:

async function Maybe(id) {
    local row = attempt await db.Query("SELECT * FROM players WHERE id = ?", id)
    if (row is nil) print("no data")
}

If an async function throws and nothing catches it, the promise it returned is rejected instead. Use fail to observe that:

LoadSafely(1):fail(|err| print("rejected: " + err.Message))

Promises

A promise represents a value that is not ready yet. Most of the time you get one from an async function or a host API and just await it, but the promise module lets you create and combine them directly.

Creating

promise.resolve(5)              // already-resolved promise
promise.reject("failed")        // already-rejected promise

promise.new(|resolve, reject| { // built from a callback-style API
    SomeCallbackApi(|value| resolve(value))
})

Inspecting

local p = LoadName(1)
print(p:status()) // "pending", "resolved" or "rejected"
print(p:isdone()) // false

Reacting without await

Outside an async function, use next and fail:

LoadName(1)
    :next(|name| print("Hello " + name))
    :fail(|err| print("Failed: " + err.Message))

next returns a new promise, so calls can be chained. A handler that itself returns a promise is adopted, so chains stay flat:

LoadName(1)
    :next(|name| LoadScore(name))
    :next(|score| print(score))

Waiting on several at once

promise.all waits for every promise and resolves to an array of results, in the same order. It rejects as soon as any one of them rejects.

async function LoadAll(ids) {
    local names = await promise.all({ LoadName(ids[1]), LoadName(ids[2]), LoadName(ids[3]) })
    print(names[1] + ", " + names[2] + ", " + names[3])
}

Because async functions start immediately, the three lookups above run at the same time rather than one after another. Which is the main reason to use promise.all over three separate awaits.

promise.any resolves to the first success, and only rejects if all of them reject.

local fastest = await promise.any({ LoadFromCache(id), LoadFromDatabase(id) })

When continuations run

An async function resumes on the game’s normal update, not the instant the underlying work finishes. That means your script never runs halfway through someone else’s frame, and you never have to worry about two parts of your mod running at the same time.

The practical consequence is that a small delay between “the work finished” and “my code continues” is normal and expected.

Async event handlers

Some events accept async handlers, registered with AddAsyncEventHandler instead of AddEventHandler:

Server.OnPlayerJoin.AddAsyncEventHandler(async |player| {
    local row = await db.Query("SELECT title FROM players WHERE id = ?", player.Id)
    player.SetTitle(row.Title)
})

The two are different on purpose:

Registration Handler runs Ordering
AddEventHandler One at a time, before the event finishes Registration order is guaranteed
AddAsyncEventHandler Started and left to finish on its own No ordering guarantee

Use AddEventHandler when order matters and the handler is quick. Use AddAsyncEventHandler when the handler needs to await. Registering an async handler with AddEventHandler is an error, because a handler that pauses cannot keep the ordering promise.

Coroutines

async and await are separate from coroutine. An async function cannot be used as a coroutine body, and coroutine.yield cannot be used inside one. Prefer async for anything that waits on outside work; coroutines remain useful for writing your own iterators and step-by-step sequences.

Categories:

Updated: