Resource limits and fuel
Two knobs make rut safe for third-party code: a heap budget and fuel. Both are enforced as resumable traps, both are set at construction, and both bind on the VM’s own accounting (the VM heap) — not on the OS.
#![allow(unused)]
fn main() {
pub struct Limits {
pub fuel: Option<u64>, // None = unbounded ops
pub heap_limit_bytes: Option<u64>, // None = host-enforced only
pub interrupt_every: u32, // check period; default 1024
}
}
The heap budget
| What counts | everything the VM heap tracks: cell headers + payloads, buffer blocks, string blocks, frames and register blocks, opaque store entries |
| What does not | module binaries, the shared type table, host-side Rust state |
| Check points | every Heap::alloc route: cell mint, Vec growth (push reallocation), string concat, frame-pool growth, host crossing mints |
| On failure | Trap::OutOfMemory — the check runs before any write, so the heap is byte-identical to its pre-op state; nothing is half-initialized |
| Resumption | the frame is parked: raise the limit (Heap::set_limit), drop references and retry, or drop the VM |
| Observability | vm.heap_usage() (live), vm.heap_peak() (high-water) |
Vec growth charges the budget before the write; shrinking is never
refunded (v1 overcounts rather than undercounts).
Fuel
- One unit per executed op;
fuelcounts down. The counter is checked everyinterrupt_everyops (default1024) and at loop back-edges. Trap::OutOfFuelparks the frame exactly like any resumable stop: nothing is unwound. Resumption isvm.add_fuel(n)thenvm.resume()— the frame is the loop state.- Fuel is the deterministic budget: the same program with the same fuel dies at the same op, every run — reproducible reports and hang proofs in tests.
- Native fns run outside fuel. A hanging native is a host bug: keep native bodies non-blocking, or push blocking work to a thread through the host-futures lane (the host futures bridge).
The trap contract
| Trap | Raised by | Frame state | Resumption |
|---|---|---|---|
OutOfMemory | heap budget exceeded at an allocation check | parked before the write | raise limit / free refs → resume() |
OutOfFuel | fuel reached 0 at a check point | parked at the exact pc | add_fuel(n) → resume() |
#![allow(unused)]
fn main() {
let limits = Limits { fuel: Some(1_000_000),
heap_limit_bytes: Some(64 * 1024 * 1024),
interrupt_every: 1024 };
let mut vm = Vm::new(prog, limits, HostHooks::default(), hosts)?;
match vm.call::<_, ()>("main", ()) {
Err(t) if t.name() == "OutOfFuel" => {
vm.add_fuel(1_000_000);
vm.resume::<()>()?; // re-enters at the parked pc
}
r => { r?; }
}
}
The async layer’s resume granularity is the checkpoint, not the op: a drive that runs out of fuel parks the future’s frame, and a re-drive re-enters at the frame’s checkpoint state (async and await).
Hang detection
The host owns time; the recipes differ by embedder:
| Host | Recipe |
|---|---|
| UI / game | drain the ready queue inside the frame loop; grant fuel per frame so a runaway script starves at the next check |
| wasm page | grant finite fuel; a watchdog stops refueling — the parked frame dies at its next check |
| desktop service | a watchdog thread flips a flag the host honors between resume() calls; no joins, no signals |
| tests | finite fuel + the virtual clock (vm.set_now) → fully deterministic hang proofs |
Anything blocking that cannot be bounded by fuel (IO, locks) belongs on
a worker thread answering a Completer — the VM thread never blocks on
it (the host futures bridge).
Workers
A worker VM is constructed with its own Limits; argument
transfers are counted against the child budget before the worker
starts, so a worker cannot OOM its parent
(workers and channels).
Within one VM, the ready ring is round-robin: one greedy launched future cannot starve the rest of a queue drain indefinitely — each frame runs to its next park or completion, and fuel bounds each of those runs.