embedding a javascript runtime in rust, part 3: object handles
previously we looked at how to call javascript functions in rust, and found a relatively efficient way to export rust structs to javascript.
how do we access a javascript object from rust later, without keeping it on the value stack the whole time? we could store it in a js variable, and look it up later:
src/main.rs > unsafe {}
unsafe {}let ctx = duk_create_heap(None, None, None, null_mut(), None);
duk_push_c_function(ctx, Some(print), DUK_VARARGS);
duk_put_global_string(ctx, c"print".as_ptr());
duk_push_array(ctx);
duk_push_int(ctx, 0*0);
duk_put_prop_index(ctx, -2, 0);
duk_push_int(ctx, 1*1);
duk_put_prop_index(ctx, -2, 1);
duk_push_int(ctx, 2*2);
duk_put_prop_index(ctx, -2, 2);
duk_push_int(ctx, 3*3);
duk_put_prop_index(ctx, -2, 3);
// done with the array for now!
duk_put_global_string(ctx, c"squares".as_ptr());
assert_eq!(duk_get_top(ctx), 0);
// TODO: some other work
assert_eq!(duk_get_top(ctx), 0);
duk_get_global_string(ctx, c"print".as_ptr());
duk_get_global_string(ctx, c"squares".as_ptr());
duk_call(ctx, 1);
duk_pop(ctx);
println!();
duk_destroy_heap(ctx);
$ cargo run
0,1,4,9
this works, but property lookup can be slow. and for js objects that matter to rust, we really want to be able to interact with them as rust values, and tie their lifetimes to other values that depend on them.
6. js values must outlive their rust usage
((o) duktape has a garbage-collected heap, which makes allocations for strings, buffers, and (notably) objects. duktape also never moves the top-level allocations for those, so it would seem as though we could grab the heap pointer for the array, and push it back onto the stack later when we need it again. but in reality, we crash.
src/main.rs > unsafe {}
unsafe {}// done with the array for now!
let squares = duk_get_heapptr(ctx, -1);
duk_pop(ctx);
assert_eq!(duk_get_top(ctx), 0);
// TODO: some other work
assert_eq!(duk_get_top(ctx), 0);
duk_get_global_string(ctx, c"print".as_ptr());
duk_push_heapptr(ctx, squares);
duk_call(ctx, 1);
duk_pop(ctx);
println!();
duk_destroy_heap(ctx);
$ cargo run
Segmentation fault
we no longer store the object in a variable, so it’s vulnerable to garbage collection as soon as we duk_pop(). what we need is a rust value that:
- can access a javascript value at any time, and
- keeps that javascript value alive for as long as it lives.
that’s a handle! we can achieve those two things by:
saving the heap pointer in the handle with
duk_get_heapptr(), then restoring it later withduk_push_heapptr()whenever we need to access it.- creating a strong reference to the object in javascript, by setting a property of a global variable, then deleting that property when the handle is dropped.
src/main.rs > unsafe {}
unsafe {}duk_eval_string(ctx, cr#"
var HANDLES = {};
var NEXT_HANDLE_KEY = 0;
"#.as_ptr());
duk_pop(ctx);
/// rooting reference to a javascript object.
#[derive(Debug)]
struct Handle {
/// heap pointer of the object, to access it in [`Handle::push()`]
ptr: *mut c_void,
/// handle key, to release the handle in [`Drop`]
ctx: *mut duk_context,
key: duk_uint_t,
}
/// unroot the object, allowing it to be garbage collected.
impl Drop for Handle {
fn drop(&mut self) {
unsafe {
// `delete HANDLES[rust self.key]`
duk_get_global_string(self.ctx, c"HANDLES".as_ptr());
duk_del_prop_index(self.ctx, -1, self.key);
duk_pop(self.ctx);
}
}
}
impl Handle {
/// create a handle for the object at `index` on the stack,
/// rooting it to prevent garbage collection.
fn new_from_stack(ctx: *mut duk_context, index: duk_idx_t) -> Handle {
unsafe {
// `HANDLES[NEXT_HANDLE_KEY++] = stack[rust index]`
let index = duk_normalize_index(ctx, index);
duk_get_global_string(ctx, c"HANDLES".as_ptr());
duk_eval_string(ctx, c"NEXT_HANDLE_KEY++".as_ptr());
let key = duk_get_uint(ctx, -1);
duk_dup(ctx, index);
duk_put_prop(ctx, -3);
duk_pop(ctx);
let ptr = duk_get_heapptr(ctx, index);
Handle { ptr, ctx, key }
}
}
/// push the object onto the value stack.
fn push(&self) {
unsafe {
duk_push_heapptr(self.ctx, self.ptr);
}
}
}
these handles work a bit better, but the Drop impl causes a crash if it happens after duk_destroy_heap(), which is true for let bindings in the same scope.
src/main.rs > unsafe {}
unsafe {}// done with the array for now!
let squares = Handle::new_from_stack(ctx, -1);
duk_pop(ctx);
assert_eq!(duk_get_top(ctx), 0);
// TODO: some other work
assert_eq!(duk_get_top(ctx), 0);
duk_get_global_string(ctx, c"print".as_ptr());
squares.push();
duk_call(ctx, 1);
duk_pop(ctx);
println!();
duk_destroy_heap(ctx);
$ cargo run
0,1,4,9
Segmentation fault
7. the js heap must outlive all handles
we need to ensure that the duktape heap and context aren’t destroyed before we run the Drop impl of any Handle, because dropping a handle requires running instructions in the context. but ctx is a raw pointer (*mut duk_context), which has no lifetime.
to give the context a lifetime, we need to use references. but raw pointers are Copy, which means we can trivially sneak them out of any reference, so we’ll need to create a non-Copy wrapper struct…
src/context.rs
use std::{ffi::{CStr, c_char, c_void}, ptr::null_mut};
use crate::sys::*;
#[derive(Debug)]
pub struct Context {
ctx: *mut duk_context,
}
impl Drop for Context {
fn drop(&mut self) {
unsafe {
duk_destroy_heap(self.ctx);
}
}
}
impl Context {
pub fn create() -> Self {
unsafe {
let ctx = duk_create_heap(None, None, None, null_mut(), Some(fatal));
Self { ctx }
}
}
}
macro_rules! fn_wrapper {
($call_name:ident, $method_name:ident(ctx: *mut duk_context $(, $arg_name:ident: $arg_ty:ty)* $(,)?) $(-> $ret_ty:ty)?) => {
#[allow(unused)]
pub unsafe fn $method_name(&self $(, $arg_name: $arg_ty)*) $(-> $ret_ty)? {
unsafe {
$call_name(self.ctx $(, $arg_name)*)
}
}
};
}
impl Context {
// wrappers for functions not supported by bindgen
fn_wrapper!(duk_eval_string, eval_string(ctx: *mut duk_context, src: *const c_char));
// wrappers mechanically derived from bindgen output for `duktape.h`
fn_wrapper!(duk_push_undefined, push_undefined(ctx: *mut duk_context));
fn_wrapper!(duk_push_null, push_null(ctx: *mut duk_context));
fn_wrapper!(duk_push_boolean, push_boolean(ctx: *mut duk_context, val: duk_bool_t));
fn_wrapper!(duk_push_true, push_true(ctx: *mut duk_context));
fn_wrapper!(duk_push_false, push_false(ctx: *mut duk_context));
fn_wrapper!(duk_push_number, push_number(ctx: *mut duk_context, val: duk_double_t));
fn_wrapper!(duk_push_nan, push_nan(ctx: *mut duk_context));
fn_wrapper!(duk_push_int, push_int(ctx: *mut duk_context, val: duk_int_t));
fn_wrapper!(duk_push_uint, push_uint(ctx: *mut duk_context, val: duk_uint_t));
fn_wrapper!(duk_push_string, push_string(ctx: *mut duk_context, str_: *const ::std::os::raw::c_char, ) -> *const ::std::os::raw::c_char);
fn_wrapper!(duk_push_lstring, push_lstring(ctx: *mut duk_context, str_: *const ::std::os::raw::c_char, len: duk_size_t, ) -> *const ::std::os::raw::c_char);
fn_wrapper!(duk_push_pointer, push_pointer(ctx: *mut duk_context, p: *mut ::std::os::raw::c_void));
// ...
}
extern "C" fn fatal(_: *mut c_void, message: *const c_char) {
unsafe {
panic!("{:?}", CStr::from_ptr(message));
}
}
…so we can borrow it in Handle. unfortunately (for my brevity) this also means we’ll have to rewrite all of the duktape calls from duk_foo(ctx, ...) to ctx.foo(...).
src/main.rs > unsafe {}
unsafe {}let ctx = Context::create();
ctx.push_c_function(Some(print), DUK_VARARGS);
ctx.put_global_string(c"print".as_ptr());
ctx.push_array();
ctx.push_int(0*0);
ctx.put_prop_index(-2, 0);
ctx.push_int(1*1);
ctx.put_prop_index(-2, 1);
ctx.push_int(2*2);
ctx.put_prop_index(-2, 2);
ctx.push_int(3*3);
ctx.put_prop_index(-2, 3);
ctx.eval_string(cr#"
var HANDLES = {};
var NEXT_HANDLE_KEY = 0;
"#.as_ptr());
ctx.pop();
/// rooting reference to a javascript object.
#[derive(Debug)]
struct Handle<'ctx> {
/// heap pointer of the object, to access it in [`Handle::push()`]
ptr: *mut c_void,
/// handle key, to release the handle in [`Drop`]
ctx: &'ctx Context,
key: duk_uint_t,
}
/// unroot the object, allowing it to be garbage collected.
impl Drop for Handle<'_> {
fn drop(&mut self) {
unsafe {
// `delete HANDLES[rust self.key]`
self.ctx.get_global_string(c"HANDLES".as_ptr());
self.ctx.del_prop_index(-1, self.key);
self.ctx.pop();
}
}
}
impl Handle<'_> {
/// create a handle for the object at `index` on the stack,
/// rooting it to prevent garbage collection.
fn new_from_stack(ctx: &Context, index: duk_idx_t) -> Handle<'_> {
unsafe {
// `HANDLES[NEXT_HANDLE_KEY++] = stack[rust index]`
let index = ctx.normalize_index(index);
ctx.get_global_string(c"HANDLES".as_ptr());
ctx.eval_string(c"NEXT_HANDLE_KEY++".as_ptr());
let key = ctx.get_uint(-1);
ctx.dup(index);
ctx.put_prop(-3);
ctx.pop();
let ptr = ctx.get_heapptr(index);
Handle { ptr, ctx, key }
}
}
/// push the object onto the value stack.
fn push(&self) {
unsafe {
self.ctx.push_heapptr(self.ptr);
}
}
}
// done with the array for now!
let squares = Handle::new_from_stack(&ctx, -1);
ctx.pop();
assert_eq!(ctx.get_top(), 0);
// TODO: some other work
assert_eq!(ctx.get_top(), 0);
ctx.get_global_string(c"print".as_ptr());
squares.push();
ctx.call(1);
ctx.pop();
println!();
// impl Drop for Handle will run first
// then impl Drop for Context will call duk_destroy_heap()
with the Handle borrowing Context, rust ensures that the Context is dropped after the Handle when they both go out of scope. voilà!
$ cargo run
0,1,4,9
stay tuned!
the Handle impl shown above is very inefficient, taking over 1500ns to create and destroy a single handle. next time, we’ll optimise it, down to less than 80ns.