Async/Await in Script: How We Built a Modern Async Runtime on Top of Tokio
Script now has full async/await support, built on top of Tokio—Rust's production-grade async runtime. This post dives into how we implemented Promises, the await opcode, and bridged Rust's async world with Script's VM.
The Goal
JavaScript's async/await is one of its most important features. It makes asynchronous code readable and maintainable:
// Instead of callback hell:
fetchData((err, data) => {
if (err) return;
processData(data, (err, result) => {
if (err) return;
saveResult(result, () => {
console.log("Done!");
});
});
});
// We get clean async/await:
async function workflow() {
const data = await fetchData();
const result = await processData(data);
await saveResult(result);
console.log("Done!");
}
We wanted Script to have the same ergonomics, but with native performance.
Architecture Overview
Script's async system has three layers:
┌─────────────────────────────────────────┐
│ Script Code (async/await) │
└─────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ VM (Await opcode, Promise) │
└─────────────────┬───────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Tokio Runtime (Rust async) │
└─────────────────────────────────────────┘
- Script layer:
async functionsyntax,awaitexpressions - VM layer: Promise state machine,
Awaitopcode - Tokio layer: Actual async execution, I/O, timers
Promise Implementation
A Promise in Script is a state machine with three states:
// src/vm/value.rs
pub enum PromiseState {
Pending,
Fulfilled(JsValue),
Rejected(JsValue),
}
pub struct Promise {
state: Arc<Mutex<PromiseState>>,
handlers: Vec<Box<dyn FnOnce(JsValue) + Send>>,
}
Promise Lifecycle
// 1. Create a pending promise
const p = new Promise((resolve, reject) => {
// Promise starts in Pending state
});
// 2. Resolve it
resolve(42);
// → State: Fulfilled(42)
// 3. Or reject it
reject("error");
// → State: Rejected("error")
Promise.resolve() and Promise.reject()
These are convenience methods for creating already-resolved/rejected promises:
// Immediately resolved
const p1 = Promise.resolve(42);
// State: Fulfilled(42)
// Immediately rejected
const p2 = Promise.reject("error");
// State: Rejected("error")
Implementation:
// src/stdlib/mod.rs
pub fn native_promise_resolve(vm: &mut VM, args: Vec<JsValue>) -> JsValue {
let value = args.first().cloned().unwrap_or(JsValue::Undefined);
let promise = Promise::new_fulfilled(value);
JsValue::Promise(Arc::new(promise))
}
pub fn native_promise_reject(vm: &mut VM, args: Vec<JsValue>) -> JsValue {
let reason = args.first().cloned().unwrap_or(JsValue::Undefined);
let promise = Promise::new_rejected(reason);
JsValue::Promise(Arc::new(promise))
}
Promise.then() and Promise.catch()
These register handlers that run when the promise resolves or rejects:
const p = Promise.resolve(42);
p.then(value => {
console.log(value); // 42
return value * 2;
}).then(value => {
console.log(value); // 84
}).catch(error => {
console.error(error);
});
Implementation:
// src/vm/mod.rs
OpCode::CallMethod { name, arg_count } => {
if let JsValue::Promise(promise) = obj {
match name.as_str() {
"then" => {
let handler = args.first().cloned();
promise.add_fulfill_handler(handler);
// Return new promise for chaining
}
"catch" => {
let handler = args.first().cloned();
promise.add_reject_handler(handler);
}
// ...
}
}
}
Promise.all()
Waits for all promises to resolve:
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.resolve(3);
const all = Promise.all([p1, p2, p3]);
// Resolves to [1, 2, 3]
The Await Opcode
The await keyword compiles to an Await opcode:
// Source
async function test() {
const result = await Promise.resolve(42);
return result;
}
// Bytecode
[0] Push(Function { ... })
[1] Let("test")
[2] Jump(10)
[3] Push(String("Promise"))
[4] Load("Promise")
[5] GetProp("resolve")
[6] Push(Number(42.0))
[7] Call(1) // Promise.resolve(42)
[8] Await // ← await opcode
[9] Return
Await Implementation
// src/vm/mod.rs
OpCode::Await => {
let promise = self.stack.pop().expect("Await: no value on stack");
if let JsValue::Promise(promise) = promise {
let state = promise.state.lock().unwrap();
match &*state {
PromiseState::Fulfilled(value) => {
// Already resolved, push value and continue
self.stack.push(value.clone());
}
PromiseState::Rejected(reason) => {
// Already rejected, throw exception
self.throw_exception(reason.clone());
}
PromiseState::Pending => {
// Not ready yet, suspend execution
// (In future: integrate with Tokio runtime)
self.stack.push(JsValue::Undefined); // Placeholder
}
}
} else {
// Not a promise, wrap it
let promise = Promise::new_fulfilled(promise);
self.stack.push(JsValue::Promise(Arc::new(promise)));
}
}
Currently, await on a pending promise is a placeholder. In the future, we'll integrate with Tokio to actually suspend execution.
Async Function Syntax
When you write an async function, the compiler automatically wraps the return value in Promise.resolve():
// Source
async function getValue() {
return 42;
}
// What it compiles to
function getValue() {
const value = 42;
return Promise.resolve(value); // ← Automatic wrapping
}
Compiler Support
// src/compiler/mod.rs
fn gen_fn_decl(&mut self, fn_decl: &FunctionDecl) {
let is_async = fn_decl.is_async;
// ... function body ...
if is_async {
// Wrap return value in Promise.resolve()
self.instructions.push(OpCode::Push(JsValue::String("Promise".to_string())));
self.instructions.push(OpCode::Load("Promise".to_string()));
self.instructions.push(OpCode::GetProp("resolve".to_string()));
self.instructions.push(OpCode::Swap); // Swap promise and value
self.instructions.push(OpCode::Call(1));
}
}
Tokio Integration
Tokio is Rust's async runtime. We use it for:
- Async I/O: File reading, network requests
- Timers:
setTimeout,setInterval - Task scheduling: Executing async tasks
Initializing Tokio
// src/vm/mod.rs
impl VM {
pub fn init_async(&mut self) {
// Create Tokio runtime
let rt = tokio::runtime::Runtime::new().unwrap();
self.async_runtime = Some(rt);
}
}
Async File Reading (Future)
Here's how we'll implement async file reading:
// Future implementation
pub async fn native_fs_read_file_async(
path: &str
) -> Result<String, Error> {
tokio::fs::read_to_string(path).await
}
Then in Script:
async function readConfig() {
const content = await fs.readFile("config.json");
return JSON.parse(content);
}
Performance: Zero-Cost Abstractions
Script's async/await is designed for performance:
1. No Heap Allocation for Resolved Promises
If a promise is already resolved, await doesn't allocate:
PromiseState::Fulfilled(value) => {
// Just push the value, no allocation
self.stack.push(value.clone());
}