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.
await is not an error. You just get the promise instead of the result.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))
fail handler is reported as an unobserved rejection. Always handle failures, or the error will only show up in the log.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))
next and fail, not then and catch, because then and catch are reserved words in Lunaris.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.