Errors and limits
Every fallible API call returns Result<_, RuntimeError>. Most failures are JavaScript exceptions, which stay pending on the context until the embedder takes them. This page covers exceptions, the other RuntimeError variants, and the limits a runtime enforces on call depth, host stack and heap memory, plus the collector settings and the memory report.
RuntimeError
| Variant | Meaning |
|---|---|
Exception |
JavaScript threw. The thrown value is the context's pending exception. |
Reentered |
An API call was made from inside a module loader callback instead of through its Scope. |
Unsupported(what) |
The source uses a construct the engine does not compile. |
Compile(Error) |
The front end failed with an internal or I/O error, not a JavaScript error. |
OutOfMemory |
The heap cap was reached: JavaScript did not catch its out-of-memory error, or an API call could not allocate. |
Invariant(what) |
The embedder broke one of the API's preconditions, such as passing a value from another runtime. |
Internal(message) |
The engine failed internally. |
RuntimeError implements std::error::Error and Display, and converts from the front end's Error.
Exceptions
When JavaScript throws, the call returns Err(RuntimeError::Exception) and the context keeps the thrown value:
Context::has_exception()tells whether one is pending.Context::take_exception()returns it asOption<Value>and clears it.Runtime::is_error_object(&object)tells whether an object is anErrorinstance.Runtime::print_value(&value)formats any value as QuickJS'sJS_PrintValuedoes, the output ofprintand of theqjscommand's uncaught-exception report. For an error that isName: messagefollowed by its stack. It runs no JavaScript: no getters, proxy traps ortoStringcalls.Runtime::qjs_print_value_bytes(&value)returns the same text as bytes, with a lone surrogate encoded as WTF-8 instead of U+FFFD.
let runtime = Runtime::new();
let mut context = runtime.context();
let result = context.eval("function f() { throw new TypeError('bad input') }\nf()");
assert_eq!(result, Err(RuntimeError::Exception));
assert!(context.has_exception().unwrap());
// Taking the exception clears it.
let Some(Value::Object(error)) = context.take_exception().unwrap() else {
panic!("expected an error object");
};
assert!(!context.has_exception().unwrap());
assert!(runtime.is_error_object(&error).unwrap());
// print_value formats it as QuickJS's print does: the error, then its stack.
let printed = runtime.print_value(&Value::Object(error)).unwrap();
assert!(printed.starts_with("TypeError: bad input\n at f (<input>:1"));A thrown value does not have to be an error object. throw 7 leaves Value::Int(7) pending.
Take the exception before the next call that might throw. Each new exception replaces the pending one.
Syntax errors
Compiling a script or module with a syntax error does not return RuntimeError::Compile. As with QuickJS's JS_Eval, the error is thrown: the call returns Err(RuntimeError::Exception) and a SyntaxError with the file, line and column in its stack is pending.
let runtime = Runtime::new();
let mut context = runtime.context();
// A syntax error is thrown, as QuickJS's JS_Eval throws it.
assert_eq!(
context.eval_with_filename("let x = ;", "app.js"),
Err(RuntimeError::Exception)
);
let exception = context.take_exception().unwrap().unwrap();
let printed = runtime.print_value(&exception).unwrap();
assert!(printed.starts_with("SyntaxError: unexpected token in expression: ';'"));
assert!(printed.contains("at app.js:1:9"));Creating errors
Context::new_native_error(kind, message) creates an error object without throwing it. A module loader creates one through its scope with Scope::new_native_error (Modules). NativeErrorKind lists QuickJS's native error types in their QuickJS order: Eval, Range, Reference, Syntax, Type, Uri, Internal and Aggregate. NativeErrorKind::name() gives the constructor name, such as "RangeError".
let runtime = Runtime::new();
let context = runtime.context();
let error = context
.new_native_error(NativeErrorKind::Range, "out of range")
.unwrap();
let Value::Object(object) = &error else {
panic!("expected an object")
};
assert!(runtime.is_error_object(object).unwrap());
assert_eq!(NativeErrorKind::Range.name(), "RangeError");Front-end errors
RuntimeError::Compile carries the front end's Error, with kind(), message() and span(). Its ErrorKind is one of Syntax, Type, Reference, Range, JsInternal, Internal, Io and Unsupported. Only Internal and Io reach the embedder as Compile. The JavaScript kinds are thrown as exceptions, and Unsupported becomes RuntimeError::Unsupported.
Call depth and the host stack
Deep recursion in JavaScript throws a catchable InternalError: stack overflow. Three limits produce it, all set through RuntimeOptions:
| Field | Limits | Default |
|---|---|---|
stack_limit |
registers in the interpreter's register file, which every JavaScript call uses | 1 << 20 |
native_stack_budget |
bytes of host stack used by nested re-entries into the interpreter | 1 MiB |
native_depth_limit |
the number of nested re-entries | u32::MAX (no limit) |
let runtime = Runtime::new();
let mut context = runtime.context();
let caught = context
.eval("function down(n) { return down(n + 1) + 1 }\ntry { down(0) } catch (e) { `${e.name}: ${e.message}` }")
.unwrap();
assert_eq!(text(&caught), "InternalError: stack overflow");A JavaScript call to a JavaScript function stays inside the interpreter and uses only registers, so stack_limit bounds ordinary recursion:
let depth = |options: RuntimeOptions| {
let runtime = Runtime::with_options(options);
let mut context = runtime.context();
let reached = context
.eval(
"let depth = 0; function down() { depth++; down() }\ntry { down() } catch {} depth",
)
.unwrap();
let Value::Int(reached) = reached else {
panic!("expected a number")
};
reached
};
let small = depth(RuntimeOptions {
stack_limit: 10_000,
..RuntimeOptions::default()
});
assert!(small < 10_000);
assert!(depth(RuntimeOptions::default()) > small);A re-entry happens when native code calls back into JavaScript, for example Array.prototype.map calling its callback, a getter, or a toString during a conversion. Each re-entry uses host stack. At its outermost entry the API sets the byte budget from the stack the current thread actually has, replacing the native_stack_budget value: most of the remaining stack on a thread declared with declare_thread_stack, and at most 1 MiB on any other thread.
native_depth_limit is for a host whose stack the byte measure cannot see, such as a WebAssembly engine's:
// A host whose stack the byte measure cannot see bounds nesting by depth:
// every native re-entry into the interpreter (here, each Array.prototype.map
// callback) counts as one level.
let runtime = Runtime::with_options(RuntimeOptions {
native_depth_limit: 64,
..RuntimeOptions::default()
});
let mut context = runtime.context();
let caught = context
.eval(
"function nest(n) { return n === 0 ? 0 : [n].map((m) => nest(m - 1))[0] + 1 }
try { nest(1000) } catch (e) { `${e.name}: ${e.message}` }",
)
.unwrap();
assert_eq!(text(&caught), "InternalError: stack overflow");
assert_eq!(context.eval("nest(32)").unwrap(), Value::Int(32));Declaring the thread's stack
The parser charges each nested construct against a budget modeled on QuickJS's nesting depths. It also checks the host stack, so deeply nested source becomes a catchable SyntaxError: stack overflow rather than a crash. On a thread other than the main thread, the engine assumes it has at most 1 MiB of stack unless told otherwise.
To give scripts more room, run the runtime on a thread with a large stack and call declare_thread_stack(size) first thing on that thread. The qjs command does this for its evaluation thread.
const STACK: usize = 64 << 20;
let worker = std::thread::Builder::new()
.stack_size(STACK)
.spawn(|| {
// First thing on the thread: the parser and the interpreter size
// their stack checks from this.
declare_thread_stack(STACK);
let runtime = Runtime::new();
let mut context = runtime.context();
let nested = |depth: usize| format!("{}1{}", "[".repeat(depth), "]".repeat(depth));
assert!(context.eval(&nested(500)).is_ok());
// Nesting past the parser's budget is a catchable SyntaxError.
assert_eq!(context.eval(&nested(5_000)), Err(RuntimeError::Exception));
let exception = context.take_exception().unwrap().unwrap();
let printed = runtime.print_value(&exception).unwrap();
assert!(printed.starts_with("SyntaxError: stack overflow"));
})
.unwrap();
worker.join().unwrap();The heap cap
RuntimeOptions::heap is a HeapLimits { max_bytes }. The default usize::MAX means no cap. RuntimeOptions::from(HeapLimits { .. }) builds options with only the cap set.
The cap applies to the bytes the heap reserves: the arenas holding objects, strings, shapes and the other records, the value heap, the tables beside them, code compiled at run time and regular-expression programs. Scripts compiled through Context::eval* and Context::compile* without the transient option keep their code in a separate code store that the cap does not count. MemoryReport::reserved_bytes() is the figure the cap is compared against.
When an allocation would cross the cap, the runtime collects first. If the heap would still cross it, JavaScript gets a catchable InternalError: out of memory, which the runtime creates up front so throwing it needs no memory. If the script does not catch it, the API call returns RuntimeError::OutOfMemory with no pending exception. An API call from Rust that cannot allocate returns RuntimeError::OutOfMemory too.
let runtime = Runtime::with_options(RuntimeOptions::from(HeapLimits {
max_bytes: 16 << 20,
}));
let mut context = runtime.context();
// Reaching the cap throws a catchable InternalError.
let caught = context
.eval(
"const chunks = [];
try { for (;;) chunks.push(new Array(1024).fill(0)); }
catch (e) { chunks.length = 0; `${e.name}: ${e.message}` }",
)
.unwrap();
assert_eq!(text(&caught), "InternalError: out of memory");
// The runtime stays usable once the memory is released.
assert_eq!(context.eval("1 + 1").unwrap(), Value::Int(2));
// Uncaught, the same error ends the call with RuntimeError::OutOfMemory.
assert_eq!(
context.eval("const kept = []; for (;;) kept.push(new Array(1024).fill(0));"),
Err(RuntimeError::OutOfMemory)
);
assert!(!context.has_exception().unwrap());
assert!(runtime.memory_report().unwrap().reserved_bytes() <= 16 << 20);The collector
The heap is collected by a mark-sweep collector. With the default GcPolicy::Automatic, a collection that leaves live bytes sets the budget for the next one:
budget = max(min_heap, live × (1 + growth))and the next collection runs when allocation reaches the budget. Two RuntimeOptions fields set it:
| Field | Meaning | Default |
|---|---|---|
min_heap |
the budget's floor, in bytes | 1 MiB |
growth |
how much the heap may grow past the live bytes between collections | 2.0 |
A larger min_heap or growth means fewer collections and more memory. With Runtime::set_gc_policy(GcPolicy::Manual) the runtime collects only at the cap and when Runtime::run_gc() is called.
Runtime::run_gc() runs a full collection and returns the collector's GcStats: collections, total_pause, max_pause, freed_records, freed_bytes and live_bytes. Runtime::gc_stats() returns them without collecting. After Runtime::set_record_gc_pauses(true), Runtime::gc_report() returns the line qjs --gc-stats prints, with pause percentiles.
The memory report
Runtime::memory_report() returns a MemoryReport of what each part of the runtime holds, read without a collection:
- one
ArenaReportper arena (objects,shapes,strings,symbols,bigints,cells,functions,bytecode,internals) withlive,capacity,used_bytesandreserved_bytes;arenas()lists them by name; values, the value heap's slots, free ranges and reserved bytes;code, the code units, instructions, position tables, source text and regular-expression programs;- the atom table, the register file's capacity and high-water mark, and the inline caches;
gc, the collector'sGcStats, withallocated_since_gc,threshold,budgetandcap.
MemoryReport implements Display as the table qjs --memory-report prints.
let runtime = Runtime::with_options(RuntimeOptions {
min_heap: 4 << 20, // collect no earlier than at 4 MiB reserved
growth: 1.5, // then at (1 + 1.5) x the live bytes
..RuntimeOptions::default()
});
runtime.set_gc_policy(GcPolicy::Manual).unwrap();
let mut context = runtime.context();
context
.eval("let garbage = []; for (let i = 0; i < 10000; i++) garbage.push({ i }); garbage = null;")
.unwrap();
let before = runtime.gc_stats().unwrap().collections;
let stats = runtime.run_gc().unwrap();
assert_eq!(stats.collections, before + 1);
assert!(stats.freed_records >= 10_000);
let report = runtime.memory_report().unwrap();
assert!(report.objects.live > 0);
assert_eq!(report.cap, usize::MAX);
// Display formats it as `qjs --memory-report` prints it.
assert!(report.to_string().contains("heap reserved"));The heap and the collector describes the arenas, the trigger and the cap in detail.
The complete program is examples/limits.rs:
cargo run --quiet --example limitsWhere to go next
- Host services: create a runtime with a host and options.
- Promises and jobs: exceptions thrown by jobs.
- Performance: what the collector settings cost and save.
- Architecture: how the heap, the interpreter and the API fit together.