Async and promises
A plugin waits the way JavaScript does: async functions, await,
Promise. While one function waits - for a web request, for a few seconds -
the server and the rest of the plugin go on.
server.addCommand("/weather", async ({ player }) => {
print(player, "asking...");
const response = await fetch("https://wttr.in/?format=3");
if (!response.ok) return;
print(player, await response.text());
});
async function countdown(player: Player) {
for (let i = 3; i > 0; i--) {
print(player, `${i}`);
await sleep(1000);
}
print(player, "Go!");
}
async and await
- An
asyncfunction returns aPromiseat once and runs up to its firstawait. What it returns is what the promise is fulfilled with:async function count() { return 3; }gives aPromise<number>. - A call without
awaitdoes not wait:countdown(player)runs on its own, and the line after the call runs straight away. await promisegives the promise's value once there is one. The rest of the function runs later - on the frame the value comes in - while the server gets on with everything else. Even a promise that has settled already lets the running code finish first, as in JavaScript.awaitworks inasyncfunctions, arrows and methods.- An async arrow uses the variables around it before and after an
await, as any closure does.
sleep
await sleep(1000); // one second
await sleep(5000, { signal: AbortSignal.timeout(2000) }); // rejected after 2 s
A promise fulfilled after that many milliseconds - setTimeout for an
async function. Like every AMX Mod X timer, it waits at least 0.1 second
and fires on a server frame: sleep(10) takes about 100 ms.
Async listeners and commands
An async function goes wherever a listener goes: server.addCommand,
server.addEventListener, game.addEventListener, Forward.subscribe and a
cvar's "change". What it tells the game counts only before its first
await: the answer a game listener returns, event.preventDefault(). Once
it waits, the game has moved on; the function simply goes on later.
game.addEventListener("fallDamage", async (event) => {
if (event.player.isBot) return 0; // counts: no fall damage for bots
await sleep(100);
return 0; // too late - the damage is done
});
Promise
The global Promise works as in JavaScript: then, catch, finally,
Promise.resolve, Promise.reject, Promise.all, Promise.allSettled,
Promise.race and Promise.any.
async function compare(a: string, b: string) {
const [first, second] = await Promise.all([fetch(a), fetch(b)]);
console.log(`${first.status} ${second.status}`);
}
fetch("https://example.com/")
.then((response) => console.log(`${response.status}`))
.catch((error) => console.error(error.message));
A .then callback returns a plain value. To follow one request with
another, await them one after the other.
Making a promise
new Promise((resolve, reject) => ...) turns something that calls back into
a promise. The function given to it runs at once, and what it sets up calls
resolve or reject later:
function delay(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), ms);
});
}
Where the answer comes from somewhere else - a menu the player picks from, a
chat line - keep resolve until then:
let answer: ((value: string) => void) | null = null;
function askName(player: Player) {
print(player, "Type your clan name in chat");
return new Promise<string>((resolve) => {
answer = resolve;
});
}
function onChatLine(text: string) {
const resolve = answer;
if (resolve == null) return;
answer = null;
resolve(text);
}
Promise.all and allSettled
Promises of different types give a tuple - each value with its own type - as in TypeScript:
const url = "https://example.com/stats";
const [response, _] = await Promise.all([fetch(url), sleep(1000)]); // Response, void
const [count, name] = await Promise.all([countAsync(), nameAsync()]); // number, string
const [page, rank] = await Promise.allSettled([fetch(a), rankAsync()]);
if (page.status == "fulfilled") print(player, await page.value.text());
if (rank.status == "rejected") console.error(rank.reason.message);
- Promises of one type, or a list in a variable, give an array:
(await Promise.all(list)).length. - A tuple is read by destructuring, or by an index written as a number:
results[0]. An index past its end is an error, as in TypeScript.for...of,.mapand.lengthare for arrays. - One call takes up to 8 promises of different types.
- A plain value is taken as a promise settled with it:
Promise.all([load(), 5]), andPromise.all(numbers)of anumber[]. allSettledis fulfilled once every promise has settled, either way. Each result has astatus:"fulfilled"withvalue, or"rejected"withreason, anError.
Promise.race and any
race settles like the first promise to settle. any takes the first value
and passes rejections by; when every promise is rejected, it rejects with an
AggregateError whose errors hold each reason.
const url = "https://example.com/stats";
const response = await Promise.race([fetch(url), sleep(3000)]); // Response | null
if (!response) return; // the sleep won
print(player, await response.text());
const fastest = await Promise.any([fetch(mirrorA), fetch(mirrorB)]);
The result has one type, so the promises need one:
- the same type - the result is of that type;
- a
Promise<void>(asleep, an async function that returns nothing) beside an object or a string - the result is that type ornull: check it withif (!response); - classes with a common base class - the result is of that class
(
Promise.race([cat(), dog()])is anAnimal).
Anything else - a number and a string, a number and a sleep - does not
build: Promise.race of 'number' and 'string': their values share no type.
Await them one by one instead.
Errors
An error in an async function - a throw, or one from what it calls, an
index out of range included - rejects its promise with that error, as in
JavaScript. A rejected promise carries the error:
try/catcharound theawaitgets it:consturl= "https://example.com/stats"; try { constresponse= awaitfetch(url);print(player, awaitresponse.text()); } catch (error) {console.log(`the request failed: ${error.message}`); }.catchgets it.awaitof a rejected promise outside atryends the awaiting function there, and that function's own promise is rejected with the same error - up to whoever catches it.- Nobody handling it prints one line in the server console:
Unhandled promise rejection: <message>. A cancellation - an error namedAbortErrororTimeoutError- is expected and is not reported.
Outside an async function, reject with reject(new Error("message")) in a
new Promise, or Promise.reject(new Error("message")). error.message and
error.name read it back.
A crash that is not an error - a stack overflow - ends the async function:
the console names the plugin and the crash, and the plugin and the server run
on. Whoever awaits it gets an error named AbortError with the message
the async function crashed.
Cancelling: AbortController and AbortSignal
As in the browser:
const url = "https://example.com/stats";
const controller = new AbortController();
fetch(url, { signal: controller.signal }).catch((error) => console.log(error.name)); // "AbortError"
controller.abort();
controller.signal.aborted; // true
controller.signal.reason; // the Error it was aborted with
controller.signal.addEventListener("abort", () => console.log("aborted"));
await fetch(url, { signal: AbortSignal.timeout(3000) }); // "TimeoutError" after 3 s
fetch(url, { signal })cancels the request and rejects with the signal's reason - an Error namedAbortError, unlessabort()was given another.sleep(ms, { signal })stops waiting and rejects the same way.AbortSignal.timeout(ms)aborts by itself, with an Error namedTimeoutError;AbortSignal.abort()is aborted already;AbortSignal.any([a, b])aborts with whichever aborts first.
When the player leaves
player.signal aborts when that player leaves the server; the next player in
the slot gets a new one:
const url = "https://example.com/stats";
const stats = await fetch(url, { signal: player.signal });
An async command handler, or a listener of an event about a player
(putInServer, connect, authorized and the others with an
event.player), does not even need that: it runs under that player's
signal. Every await in it - a request, a sleep, any promise - ends quietly
when the player leaves, with nothing in the console and nothing to clean up:
server.addCommand("/rank", async ({ player }) => {
const rank = await fetch(`https://example.com/rank/${player.steamId}`); // cancelled if he leaves
print(player, await rank.text()); // never runs then
});
Async functions called from such a handler run under the same signal, and a
signal passed by hand works alongside it: whichever aborts first. Game
event listeners and the events of a player leaving do not get one.
After an await
A function that awaited resumes on a later frame, and much may have changed:
the round ended, the player died - or left. Under a player's signal his
leaving ends the function for you; anywhere else, check
player.isConnected after an await before you use the player again.
Limits
- Async generators (
async function*) do not build;for awaitwalks a list of promises or values. - An async function keeps up to 4 KB of its own state while it waits; one that needs more is stopped, with a message in the console naming the plugin.
- Code that can reach an
awaitruns somewhat slower in a tight loop. A plugin withoutasyncfunctions pays nothing.
What else amxts cannot do yet is on Limitations.
Forwards
A forward is an event one plugin raises and other plugins hear - TypeScript and Pawn alike. It is how a plugin tells the rest of the server that a round of its game mode started, that a player bought something, that a setting changed.
Performance
A plugin is compiled to machine code before the server loads it, so the work it does on its own - loops, arithmetic, arrays, text - is fast. What costs time is crossing between the plugin and the game - a call into AMX Mod X, an event on its way to a listener - and memory a busy function asks for again and again. This page says where the time goes and gives the habits that follow.