Rune embed Context, Module, Vm, and instruction budget
Install only what scripts may call, compile once, run under a hard instruction cap.
Let operators edit decision scripts without a redeploy. Install only the host functions you mean to expose, compile once into a Unit, run on a Vm, and wrap the call in an instruction budget so a hot loop stops.
The code here was tested with rustc 1.99.0, cargo 1.99.0 and the rune crate at version 0.14.2, using Rust edition 2024. The only dependency in Cargo.toml is rune = "0.14.2".
The problem
Suppose you run a request-routing service. Every incoming request is checked against allow and deny rules: which tenants may reach which backends, which paths need extra scrutiny, which clients are throttled this week. These rules change often, sometimes several times in an afternoon, and the people who change them are usually operators rather than the engineers who wrote the router.
If the rules live in Rust, every change means a code edit, a review, a build, a deploy and a restart. That is slow for a one-line tweak, and it puts the engineers in the way of every policy decision.
The obvious fix is to let operators drop in a small script that the service reads at runtime. Doing that carelessly creates two dangers. A script with unrestricted native access could read files, open sockets or call anything the host process can call. A script with an unlimited loop could spin forever and stall the request thread that evaluates it.
So the real requirement has three parts: scripts are editable without recompiling, scripts can call only the functions you choose, and scripts stop after a fixed amount of work. Rune is a scripting language built to be embedded in Rust programs, and its embedding API covers all three. The rest of this article builds a small working example of each.
The idea, in plain words
You build a Context, which is the list of everything a script is allowed to call, starting from Rune's standard modules and adding your own Rust functions. You then hand the script text and that Context to rune::prepare, which compiles the script into a Unit, a compiled bytecode program. A Vm (virtual machine) takes the Unit and runs it, returning a value you convert back into a Rust type. Finally you wrap the call in rune::runtime::budget::with, which gives the run an instruction budget, a fixed number of virtual-machine steps after which the run stops with an error. The Context decides what scripts can reach, the Unit is the compiled script, the Vm executes it, and the budget decides how long it may run.
Concept A: Context and Module are the allow-list
A Context is the object that records which functions and types a script may use. Context::with_default_modules() creates one that already contains Rune's standard modules, so ordinary language features and standard-library helpers work out of the box. A Module is a named group of host items that you build yourself with Module::new(). You register a native Rust function on it, then add the whole Module to the Context with context.install(&module).
This matters because the Context is the only door a script has to the outside world. A script cannot call a Rust function you never installed, so an operator who edits a rule cannot reach your database client or your filesystem unless you put them in a Module. Without that rule, the safety of the whole setup would depend on trusting every script author.
Here is the lab's context builder and the single host function it exposes:
fn score(a: i64, b: i64) -> i64 {
a + b
}
pub fn build_context() -> rune::support::Result<Context> {
let mut context = Context::with_default_modules()?;
let mut module = Module::new();
module.function("score", score).build()?;
context.install(&module)?;
Ok(context)
}
The score function is ordinary Rust that adds two integers; nothing about it is Rune-specific. In build_context, the first line creates the Context with default modules. The next two lines create an empty Module and register score under the script-visible name "score". The .build()? call is required, because Module::function returns a builder rather than finishing the registration itself. The last step, context.install(&module)?, copies the Module's contents into the Context. The return type rune::support::Result lets every ? propagate a Rune error to the caller.
A script that uses the function looks like this:
pub fn main() {
score(20, 22)
}
The script calls score as if it were a built-in function, and it never sees Rust types or the Module. It sees only a name it is allowed to use.
**Common mistake: forgetting context.install.**
- Symptom: The script that calls
scorefails when you build it withrune::prepare. The lab describes this as a missing-function or unresolved-name diagnostic, and the exact message text was not captured, so none is quoted here. - Cause: The host function is absent from the Context the Unit was built against. Creating the Module and registering
scoreon it changes only the Module. Untilinstallruns, the Context knows nothing about it. - Fix: Call
context.install(&module)?before you pass the Context toprepare, and keep it inside the same function that builds the Context so the two cannot drift apart.
Concept B: prepare, Unit, Vm
Sources is the collection of named script texts you give the compiler, and the rune::sources! macro builds one from inline text. rune::prepare(&mut sources) starts a compile, .with_context(&context) tells it which functions exist, and .build() returns the Unit. A Unit is the compiled bytecode for your script, which is what the Vm actually runs.
Compilation happens separately from running so that you can do it once and run many times, and so that a bad script is rejected at compile time before any request depends on it. Compiling against a Context also means the compiler can check every function name the script calls against your allow-list. If the check fails, you never get a Unit at all. The Rune Book's example also passes a diagnostics collector via with_diagnostics, a place where compile errors and warnings are gathered for printing, and the lab used Diagnostics on the hot-loop path while the short score path kept to ?.
The lab's full path from script text to a Rust integer looks like this:
let context = build_context()?;
let runtime = Arc::new(context.runtime()?);
let mut sources = rune::sources! {
entry => {
pub fn main() {
score(20, 22)
}
}
};
let unit = rune::prepare(&mut sources)
.with_context(&context)
.build()?;
let unit = Arc::new(unit);
let mut vm = Vm::new(runtime, unit);
let output = vm.call(["main"], ())?;
let value: i64 = i64::from_value(output)?; // 42
The sources! block names one source, entry, and its body is the script text with a public main function. After build()?, the Unit is wrapped in an Arc, the standard shared-ownership pointer, because Vm::new takes an Arc<Unit> and an Arc<RuntimeContext>. A RuntimeContext is the part of the Context the Vm needs while running, and context.runtime()? produces it. Because both arrive behind Arc, you can compile once and share the pieces among many Vms. The call vm.call(["main"], ()) names the function path main and passes no arguments. It returns a Rune value, and i64::from_value turns that into a Rust i64. The lab's cargo run printed:
score script returned: 42
The Vm also offers execute and complete for other styles of running, but the lab used only call, so those two were not run.
**Common mistake: handing the Vm plain values instead of Arc values.**
- Symptom: The Rust compiler rejects the
Vm::new(...)line with a type mismatch. The exact compiler text was not captured in the lab notes, so it is not quoted here. - Cause:
Vm::newneeds anArc<RuntimeContext>and anArc<Unit>. Passing a bareUnitor the result ofcontext.runtime()?withoutArc::newsupplies the wrong types. - Fix: Wrap the Unit with
Arc::new(unit)and the runtime withArc::new(context.runtime()?), usingstd::sync::Arc, exactly as the lab code does.
One further trap came up while writing the lab scripts. Rune variables are mutable by default, so a script that says let mut n = 0 fails at prepare. Write let n = 0 and reassign it later.
Concept C: the instruction budget
An instruction budget is a cap on how many virtual-machine instructions a run may execute. Rune's budget is off by default. You turn it on by wrapping the call in rune::runtime::budget::with(N, || ...) and then calling .call() on the result, which runs your closure under the limit. The Rune Book refers to this module as rune::budget, but in 0.14.2 the path that compiles is rune::runtime::budget.
You want a budget because a decision script written by a person can have an infinite loop by accident, and a single stuck request must not hold a worker forever. A fixed count is also predictable: the same script under the same budget stops at the same point, which a wall-clock timeout cannot promise. Without it, you would have to kill threads, which is far messier.
The lab's runaway-loop test:
use rune::runtime::budget;
// Rune vars are mutable by default; do NOT write `let mut n` (compile failure).
let mut sources = rune::sources! {
entry => {
pub fn main() {
let n = 0;
while n < 100000 {
n = n + 1;
}
n
}
}
};
let unit = rune::prepare(&mut sources).with_context(&context).build()?;
let mut vm = Vm::new(runtime, Arc::new(unit));
let output = budget::with(50, || vm.call(["main"], ())).call()?;
The script counts to 100,000, which needs far more than 50 instructions. The closure || vm.call(["main"], ()) is what runs under the limit, and .call()? returns the error when the limit is hit. The lab's cargo run printed this for the loop:
hot loop under budget 50 failed as expected: Halted for unexpected reason `limited`
The text says limited, not "budget". In 0.14.2 the error is the Vm halting with the reason Limited, so any code that matches on the message should look for that word. The success case uses the same wrapper with a larger number, 10000, around the short score script, and the lab printed:
score under budget 10000 returned: 42
All three lab tests passed. Verbatim unit-test output:
running 3 tests
test tests::host_score_via_vm ... ok
test tests::budget_ok_for_score_script ... ok
budget-exceeded error (verbatim Display): Halted for unexpected reason `limited`
test tests::budget_exceeded_on_hot_loop ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s
Two limits of the budget are documented but were not run in the lab. First, budgeting counts only virtual-machine instructions, so native functions must cooperate, using budget::acquire and BudgetGuard::take, if they do heavy work. Second, memory limits are a separate mechanism, rune::alloc::limit::with, and they constrain only collections from the rune::alloc module, not std::vec::Vec.
Common mistake: assuming the budget stops a hostile native function.
- Symptom: A script calls one of your registered functions, and the call never returns even though you set a small budget. This was not run in the lab, and the description comes from the Rune documentation.
- Cause: The budget is spent per virtual-machine instruction. A Rust loop inside a native function is invisible to the VM and costs no budget, so without explicit cooperation the cap cannot be enforced.
- Fix: Keep host functions short and bounded. If one must do variable amounts of work, make it call
budget::acquireand take from the returned guard as it goes.
Putting it together
Here is one allow/deny rule from authoring to rejection, using the lab pieces. At startup the service calls build_context, which creates the Context with default modules and installs the Module holding score. This is the allow-list step: from here on, a rule can call score and the standard modules, and nothing else.
When an operator saves a new rule, the service puts its text into a Sources collection and runs rune::prepare(...).with_context(&context).build(). If the rule calls a function that was never installed, this step fails and the old rule stays in place. The lab keeps the script inline in sources!; reading the text from a rule.rn file is the step a real service adds, and it was not run here.
For each request, the service wraps both Arc values into a Vm and calls main under budget::with(10000, ...). The score rule returned 42 within that cap, and your service decides which integers mean allow or deny. A rule shaped like the hot loop fails under its budget with Halted for unexpected reason \limited\``. The service can treat that error as a rejected run and apply a default decision, perhaps deny, while the operator is told the rule is too expensive. The router keeps serving throughout.
Check yourself
- **What does
context.install(&module)do, and what happens if you skip it?** It adds the Module's host functions to the Context. Without it, a script callingscorefails to build, because the function is absent from the Context the Unit is built against.
- What is a Unit, and why is it separate from the Vm? A Unit is the compiled bytecode from
rune::prepare. Keeping it separate lets you compile once, reject bad scripts early, and share the Unit through anArcamong many Vms.
- A script exceeds its budget. What error text should your code expect in 0.14.2?
Halted for unexpected reason \limited\`. It sayslimited`, not "budget".
- Why can a registered Rust function still hang the service despite a budget? The budget counts only VM instructions. A Rust loop inside a native function costs nothing unless the function cooperates through
budget::acquireandBudgetGuard::take.
- **Which collections does
rune::alloc::limit::withconstrain?** Only those fromrune::alloc, notstd::vec::Vec. This was not run in the lab.
Sources
- The Rune Book, Sandboxing: https://rune-rs.github.io/book/sandboxing.html
- The Rune Book, Functions (calling functions in Rust): https://rune-rs.github.io/book/functions.html
- docs.rs, rune 0.14.2,
Context: https://docs.rs/rune/0.14.2/rune/struct.Context.html - docs.rs, rune 0.14.2,
Module: https://docs.rs/rune/0.14.2/rune/struct.Module.html - docs.rs, rune 0.14.2,
Vm: https://docs.rs/rune/0.14.2/rune/struct.Vm.html - docs.rs, rune 0.14.2,
runtime::budget::with: https://docs.rs/rune/0.14.2/rune/runtime/budget/fn.with.html - docs.rs, rune 0.14.2,
prepare: https://docs.rs/rune/0.14.2/rune/fn.prepare.html