The host boundary
rut exists to be embedded. The host — a Rust program — owns a VM,
declares its API once in rut source, binds the implementations with
typed Rust closures, and drives everything. The design rule for the
whole boundary: the type system does the checking, not your bridge
code. There is no argument re-parsing, no as number, no
“trust-me” coercion — a value crossing either direction is checked
against a reified runtime type, and a mismatch is a loud error naming
both sides.
The embedding model
#![allow(unused)]
fn main() {
let mut session = Session::new();
mount_std_core(&mut session);
session.register_module("app:gfx", lower_decl_module(GFX_DECL, "gfx.d.rut")?);
let mut hosts = HostRegistry::new();
hosts.register::<_, (i32, i32), Opaque<Canvas>, _>(
"app:gfx::newCanvas",
|_vm, w: i32, h: i32| Ok(Canvas::new(w, h)),
);
// the join: every declared row must have a binding, and vice versa —
// verified BEFORE any script runs
hosts.verify_against(&session.expected_host_fns())?;
let mut vm = Vm::new(program, &limits, hooks, hosts)?;
vm.call::<_, ()>("main", ())?; // an entry point
vm.run_ready()?; // drive async work to idle
}
The declaration file (a host package) spells the surface in rut:
pub host fn newCanvas(w: i32, h: i32) -> opaque;
pub host fn circle(h: opaque, x: f32, y: f32, r: f32) -> nil;
and the load-time join rejects three wiring bugs as panics — an embedder mistake, never a script diagnostic: a declared row with no binding, a binding with no declaration, and signature drift. “Mount what you bind”: an embedder that mounts a package declares its rows and must bind them.
The crossing set
What may cross is a compile-time property of the surface, not a runtime negotiation:
- Parameters: the primitives,
str,bytes, andopaque. - Returns: the same set plus the answer optionals
?str,?bytes,?opaque—Option<String>,Option<Vec<u8>>, and the opaque handles mint the nullable box, withNoneas the flatnil. - Tuples cross field by field, which makes the error convention —
(value, err)— a first-class entry answer. The two channels stay distinct by law: a returnederris data the host reads and acts on; a panic is drift and arrives on the trap channel, never filling an err field.
Everything else — user structs and classes, Vecs, trait-typed values,
closures — stays inside the VM. A declaration that violates the set is
a compile error at the declaration, not a failed call at 2 a.m. There
is no any: a polymorphic crossing seals its value in an erasure box
(opaque(v) at the call, opaque.downcast<T> after), checked, never
silent — see reified types.
The currency is typed Rust
On the Rust side the boundary speaks ordinary Rust types. Each type declares the rut type it binds against and converts checked:
- Borrow, don’t copy.
&strand&[u8]parameters read the VM’s buffers zero-copy. The borrow is call-scoped — the type system makes smuggling it past the return a compile error. - Copy on purpose. Owned
String/Vec<u8>parameters are the explicit “I keep this data” choice. Opaque<T>is the typed view of a host handle: an 8-byte store handle the host holds and passes back; the payload stays host-owned (Box<dyn Any>plus an optional finalize hook).
Borrow guards make the zero-copy views safe while script runs: a
borrowed object is flagged, and script-side mutation through a
re-entrant call traps with borrowed by host instead of racing. Guards
clear on return. This is sound forever because the heap never moves
objects (see memory).
Wrapping host state: the class-in-rut pattern
There is no “host class”. Native state crosses as an opaque handle
and a rut class wraps it — ordinary source the compiler can see and
optimize around:
class Canvas {
h: opaque;
fn circle(mut self, x: f32, y: f32, r: f32) -> nil {
canvas_circle(self.h, x, y, r);
}
fn hits(self) -> i32 { return canvas_hits(self.h); }
}
Every method is exactly one host call — the same crossing a magic host
class would have paid — but the wrapper can also carry impl blocks,
validation, and convenience the native side never needed to know about.
The payoff for keeping the boundary this small: host resources die
deterministically. When the wrapper’s last reference goes, the handle’s
refcount hits zero and the payload’s Rust Drop runs at that point —
sockets and textures close on script schedule, not at some future
collection.
Host functions are rows, dispatched by slot
Each declared row gets a stable slot id at compile time; calls compile to slot dispatch, so names are binding-time labels only — renaming a Rust-side binding target fails the join instead of silently rebinding. The surface grammar is small:
host fn name(params) -> T; // concrete signature; no generics —
// a generic has no shape to check
host struct Name { fields }; // flat record of crossing fields
builtin declarations (the engine’s own fns, classes, and traits —
str methods, Weak, Future) are engine surface: the embedder
cannot spell them, and users implement their traits with ordinary impl
blocks. Async rows (pub host async fn) are declared like any row and
expand into a small row family the driving loop uses — the embedder
binds them with one closure per future; see
the async model.
Re-entrancy, traps, budgets
- A native fn receives a context that may call back into rut
(
vm.call) — nested calls run under the same budget on a fresh frame stack. - Native code runs outside the op budget: the host is trusted to be
fast, or to hand back a future instead of blocking. A trap raised
inside a native fn propagates to the host as an
Err(Trap)with the native frame visible in the backtrace. - Traps are catchable only at the boundary — script never catches one. That is what keeps panics loud and state uncorrupted.
What was deliberately left out
No C ABI. There is no register_struct, no size_of, no pointer
mirroring of record layouts: records are slot arrays internal to the
VM, and a host sees them only through the checked crossing. The few
bytes you give up to the checked boundary buy the property that makes
embedding pleasant — every mismatch is a named, typed, load-time
diagnostic instead of a runtime mystery.
The full details live in the reference: embedding and native modules, the value boundary and borrows, host fns and declaration files, and the withdrawn C-struct experiment recorded at repr(C) interop.