Promises and jobs

Promise reactions, await resumptions, dynamic import() and FinalizationRegistry callbacks do not run immediately. They are queued as jobs on the runtime's job queue, and the embedder decides when the queue runs. Nothing runs a job on its own: eval, call and execute_module return as soon as their synchronous part is done.

The job queue

A runtime has one first-in, first-out queue of jobs.

Method Effect
Runtime::is_job_pending() whether a job is queued
Runtime::run_pending_job() runs the oldest job; PendingJobOutcome::Executed, or PendingJobOutcome::NoJob when the queue was empty
Runtime::execute_pending_job() the same as run_pending_job
Runtime::run_jobs() runs jobs until the queue is empty or one throws

PendingJobOutcome::executed() returns true for Executed. A job can queue further jobs, which run later in the same drain.

examples/jobs.rsrust
let runtime = Runtime::new();
let mut context = runtime.context();

context
    .eval("globalThis.log = []; Promise.resolve().then(() => log.push('job')); log.push('script');")
    .unwrap();
// The script has finished; its reaction is still queued.
assert!(runtime.is_job_pending().unwrap());

assert_eq!(
    runtime.run_pending_job().unwrap(),
    PendingJobOutcome::Executed
);
assert_eq!(runtime.run_pending_job().unwrap(), PendingJobOutcome::NoJob);
assert_eq!(
    context.eval("log.join()").unwrap(),
    Value::String(JsString::from_static("script,job"))
);

When a job throws

Most JavaScript errors inside jobs do not reach the queue. A promise handler that throws rejects the promise its then returned, and an async function that throws rejects its own promise. A job throws to the queue only when nothing turns the error into a rejection, as with a FinalizationRegistry cleanup callback.

When that happens, run_pending_job returns Err(RuntimeError::Exception) and leaves the thrown value as the context's pending exception. The jobs after it stay queued. run_jobs stops at the first throw, so a loop that should keep going calls run_pending_job itself:

examples/jobs.rsrust
let runtime = Runtime::new();
let mut context = runtime.context();
// A FinalizationRegistry callback runs as a job after a collection.
context
    .eval(
        "globalThis.registry = new FinalizationRegistry((tag) => { throw new TypeError(tag) });
         registry.register({}, 'collected');
         Promise.resolve().then(() => { globalThis.after = true });",
    )
    .unwrap();
runtime.run_gc().unwrap();

// The job's exception is pending on the context; the queue keeps the
// jobs after it.
let mut thrown = Vec::new();
loop {
    match runtime.run_pending_job() {
        Ok(PendingJobOutcome::Executed) => {}
        Ok(PendingJobOutcome::NoJob) => break,
        Err(RuntimeError::Exception) => {
            let exception = context.take_exception().unwrap().unwrap();
            thrown.push(runtime.print_value(&exception).unwrap());
        }
        Err(other) => panic!("{other}"),
    }
}
assert_eq!(thrown.len(), 1);
assert!(thrown[0].starts_with("TypeError: collected"));
assert_eq!(context.eval("after").unwrap(), Value::Bool(true));

This is how the qjs command drains its queue: it prints each job's exception and continues.

Driving a promise to completion

Runtime::promise_snapshot(&promise) reads a promise's state without running JavaScript. It returns None when the object is not a promise, and otherwise a PromiseSnapshot with state() (PromiseState::Pending, Fulfilled or Rejected) and result(), the fulfillment value or rejection reason (undefined while pending).

To wait for one promise, run jobs until it settles:

examples/jobs.rsrust
/// Runs jobs until `promise` settles; its fulfillment value or rejection
/// reason.
fn settle(runtime: &Runtime, promise: &ObjectRef) -> Result<(PromiseState, Value), RuntimeError> {
    loop {
        let snapshot = runtime
            .promise_snapshot(promise)?
            .ok_or(RuntimeError::Invariant("not a promise"))?;
        if snapshot.state() != PromiseState::Pending {
            return Ok((snapshot.state(), snapshot.result().clone()));
        }
        if !runtime.run_pending_job()?.executed() {
            // Nothing left to run and the promise is still pending.
            return Err(RuntimeError::Invariant("the promise never settles"));
        }
    }
}

Calling an async function from Rust returns its promise, which this loop settles:

examples/jobs.rsrust
let runtime = Runtime::new();
let mut context = runtime.context();
let Value::Object(function) = context
    .eval("(async function (n) { let sum = 0; for (let i = 1; i <= n; i++) sum += await i; return sum; })")
    .unwrap()
else {
    panic!("expected a function");
};
let function = runtime.as_callable(&function).unwrap().unwrap();

let Value::Object(promise) = context
    .call(&function, Value::Undefined, &[Value::Int(10)])
    .unwrap()
else {
    panic!("expected a promise");
};
assert_eq!(
    settle(&runtime, &promise).unwrap(),
    (PromiseState::Fulfilled, Value::Int(55))
);

When the queue is empty and the promise is still pending, no job can settle it. The engine defines no timers or I/O, so only the embedder could settle it later, for example by calling a resolve function it kept. An embedder with nothing else to wait for treats this as an error. The same loop drives the promise execute_module returns (Modules).

Unhandled rejections

A rejected promise with no handler is easy to lose. The runtime tracks such promises when Runtime::set_track_rejections(true) is set. That follows QuickJS's js_std_promise_rejection_tracker:

A promise that gets its handler in a later job is not unhandled, so read the list after the queue has drained:

examples/jobs.rsrust
let runtime = Runtime::new();
runtime.set_track_rejections(true).unwrap();
let mut context = runtime.context();

context
    .eval(
        "Promise.reject(new Error('lost'));
         const caught = Promise.reject(new Error('caught'));
         caught.catch(() => {});",
    )
    .unwrap();
runtime.run_jobs().unwrap();

let unhandled = runtime.take_unhandled_rejections().unwrap();
assert_eq!(unhandled.len(), 1);
let (_promise, reason) = &unhandled[0];
assert!(
    runtime
        .print_value(reason)
        .unwrap()
        .starts_with("Error: lost")
);
// The list is emptied by the call.
assert!(runtime.take_unhandled_rejections().unwrap().is_empty());

The qjs command turns tracking on unless it is given --no-unhandled-rejection, and prints Possibly unhandled promise rejection: and the reason for each entry once the queue is empty.

Independently of tracking, the runtime reports every rejection event to its host through Host::promise_rejection (Host services).

Execution turns

Runtime::with_execution_turn(body) runs body as one synchronous run of JavaScript: an object a WeakRef points to stays alive until the outermost turn ends, as ECMAScript's ClearKeptObjects requires. Jobs run between turns. Inside a turn, run_pending_job returns Err(RuntimeError::Invariant(_)) and leaves the job queued.

The complete program is examples/jobs.rs:

shell
cargo run --quiet --example jobs

Where to go next