Loading and the embed loop
Loading verifies, links, and mounts a program — it executes nothing. The host owns time, I/O, and lifetime: the VM never reads the filesystem or a clock on its own, and the host decides when (and whether) any rut code runs.
From path to machine
rut run app/ # a directory with rut.toml
rut run plugin/plugin.rutbundle
The loader side (rut-driver) turns a path into a booted Vm in five
steps:
| step | what happens |
|---|---|
| 1. mount | A directory is one module: its rut.toml names the package (name), its entry (entry.lib / entry.type / entry.libs), and its [deps]/[peer-deps]/[dev-deps] (Project structure and rut.toml). A .rutbundle mounts identically from a zip (Module bundles). The graph walks [deps] recursively — cycle guard, first-mount-wins, name-mismatch is an error — then runs one peer gate over the closed set (Dependency kinds). |
| 2. resolve surfaces | Use paths resolve against mounted modules, exact and single-step: a package name resolves or the diagnostic names the consumer manifest. A .d.rut surface compiles through the checker and publishes signatures only. |
| 3. compile the graph | Each module compiles (sources, in dependency post-order); inline = true packages splice into their consumers instead of linking; host packages synthesize bodyless thunks from their declared surfaces (The compiler pipeline). |
| 4. link + flatten | Module-local type/function/const ids rebase into the global tables; the shared boot prefix passes through; name tables merge; duplicate (trait, type) impl pairs and duplicate module names are link errors. Cyclic use is a compile-graph error, never a runtime event. |
| 5. verify + boot | The load verifier re-checks every function (Module binary and verification); the Vm constructor joins the program’s host thunks against the embedder’s HostRegistry — a declared-but-unbound host fn is a boot error. |
Loading a .rutc binary (vm.load_binary-style paths, and the wasm
runner) skips steps 2–3 and lands directly in verify + boot.
What the host can call
Callable names are the program’s entry fns plus the conventional
main. Their signatures were checked against the host-crossing rule at
compile time — primitives, str, bytes, opaque, ?T over a crossing
type, and crossing tuples — so a bad surface can never surprise the
embedder at call time. An entry fn -> (?T, err) decodes positionally at
vm.call as a (value, err) pair (below).
Use paths never execute anything: importing a package mounts its surface; only the host’s calls run code.
The embedder surface
Everything the host needs is a method on Vm (plus the registries built
before it):
| call | purpose |
|---|---|
HostRegistry::register(name, f) | bind a host-fn body; signatures derived from the Rust shape |
register_async!(hosts, "pkg::name", ...) | bind an async body to the host-future rows |
install_std_log / _math / _nmap / _http / _async / _bench_cross | mount the toolchain’s host bodies (Embedding and native modules) |
mount_std_core(session) / mount_std(session) / mount_std_async(session) | mount the builtin packages (core; core + calc; the async pair) |
load_path_session(path) / load_bundle_bytes(bytes, origin) | mount a directory / an in-memory bundle |
compile_graph(&session, root) | compile + link the mounted graph |
Vm::new(prog, &limits, hooks, registry) | boot; fails if a declared host fn is unbound |
vm.call(export, args) -> Result<Value, Trap> | sync entry — typed arg/ret adapters over the boundary |
vm.resume() | continue a parked frame after add_fuel |
vm.run_ready(), vm.next_deadline(), vm.pending_tasks(), vm.drive(fut) | the async driving verbs |
vm.set_now(ms), vm.arm_timer(deadline, fut) | the virtual clock |
vm.add_fuel(n), vm.fuel_used, vm.heap_usage() | budgets and telemetry (Resource limits and fuel) |
vm.call_host_row(row, args) | invoke a registered host row directly (tests, tooling) |
Errors are values (Result), bugs are traps, and the host is always in
charge of time, I/O, and lifetime.
The frame loop
An embedder that owns an event loop (a GUI, a game frame, a web page) runs one engine frame per tick:
#![allow(unused)]
fn main() {
fn on_vsync(&mut self) {
self.process_host_events(); // input, network, ...
self.rut.run_ready()?; // drive ready tasks to completion
if let Some(d) = self.rut.next_deadline() { // earliest armed sleep
self.schedule_wake(d); // host parks until then,
} // then advances the clock
self.render_frame();
}
}
pending_tasks() is the idle test — zero means the program has nothing
runnable. There is no job executor inside the VM: parking, waking, and
timing are these three verbs plus set_now
(Async and await). A program that mounts no async packages
simply has no launcher; await stays cold-poll inline
(The async model).
The soft-fail law (the err channel)
An entry fn -> (?T, err) never produces Err. The pair decodes at
vm.call as Ok — with data in the pair: a non-empty err string
means the turn failed softly; an empty err means success (nil value =
“not found”). Err remains exclusively the trapped turn: a bug, wiring
drift, exhausted fuel — the loud channel.
The law for host event loops (the pump pattern): per queued event, call the turn entry, then —
Ok: decode the pair. A non-empty err is reported (surfaced to the user/log, retained) and the loop keeps draining: the container survives, the queue proceeds, the page stays alive. A returned err must never poison the container or kill the drain — it is data the host acts on.Err: the pump aborts loud — a trapped turn is a bug, not data. TheVmitself survives for the next good turn.
On a raw JSON boundary (the wasm host), the envelope carries "err"
beside "trap": a soft-fail run reads {"trap": null, "err": "…"}, a
success reads "err": null, and a panic sets trap with "err": null.
trap never fills err. Fuel and heap reporting are unchanged by this.
Parse-time robustness
The same embeddability pillar holds at the other end of the pipeline: the frontend is stack-bounded with explicit nesting budgets — malformed or hostile module source yields diagnostics, never a host stack overflow, and a module with diagnostics never reaches the VM.