The Host Boundary
Three concepts: the ownership boundary, re-entrancy, and Send/Sync.
This chapter produces ADR-011 and contains the hardest Rust problem in the curriculum. It is hard for a good reason: the borrow checker is describing a real hazard that C runtimes have and do not report.
Concept 1: The Ownership Boundary
1. Concept
The Engine owns the runtime. The host owns its own data. Neither may hold a raw reference into
the other's memory across a point where that memory can move or die.
2. Problem
Two symmetric hazards:
- Script → host. If a script could hold a pointer to a Rust
Article, the script would outlive the borrow, or theVecholding it would reallocate, and you would have a use-after-free with a scripting language on top of it. - Host → script. If the host holds a
GcRef<Table>in a local variable and then calls back into the engine, a collection can free the object. The generation check turns that into an error rather than corruption — but an error is still a bug.
3. Mental model
The boundary is a membrane. Values cross it by being copied or converted, never by being pointed at. What crosses is data, not addresses. The only exception is a rooted handle, and rooted means the collector knows about it.
HOST │ ENGINE
──── │ ──────
owns: Article, User, the deadline │ owns: heap, stack, globals, every Value
holds: Engine (by value) │
│
passes IN: marshaled copies │ never sees a Rust pointer
or ROOTED userdata │
gets OUT: marshaled copies │ never escapes the Engine
registers: Rc<dyn Fn> callbacks │ calls back OUT, re-entrantly
4. Implementation
#![allow(unused)] fn main() { pub struct Engine { vm: Vm, sources: SourceMap, capabilities: Capabilities, } impl Engine { pub fn execute(&mut self, src: &str) -> Result<()>; pub fn call<A: ToValues, R: FromValues>(&mut self, name: &str, args: A) -> Result<R>; pub fn set_global<V: ToValue>(&mut self, name: &str, v: V) -> Result<()>; pub fn get_global<V: FromValue>(&mut self, name: &str) -> Result<V>; pub fn register_function<F>(&mut self, name: &str, f: F) -> Result<()> where F: Fn(&mut Ctx<'_>, &[Value]) -> Result<Vec<Value>> + 'static; pub fn limits(&mut self) -> &mut Limits; pub fn stats(&self) -> Stats; pub fn collect(&mut self); } }
Seven methods. That is the entire public surface, and keeping it that small is a decision worth defending: every method is a compatibility promise, and a runtime whose API is one page is a runtime a host can audit.
Note what is not there: no way to obtain a GcRef, no way to get a &Table, no way to reach the
Heap. The types exist and are pub(crate). A host that cannot name a handle cannot hold a stale
one.
5. Alternatives
| Option | How the host talks to the runtime | Systems |
|---|---|---|
| A. Owned values in, owned values out (ours) | marshaling, plus rooted userdata for the exceptions | rhai, koto; the simple half of mlua |
| B. An explicit value stack | the host pushes and pops; every pushed value is rooted by construction | Lua's C API, and it is why that API looks the way it does |
| C. Lifetime-branded handles | Value<'lua> tied to a &'lua Lua borrow | mlua/rlua, and piccolo's GC branding. Compile-time safety; the lifetime infects host code |
| D. Hand out raw pointers and document the rules | — | Many C++ embeddings. Fast, and a permanent source of CVEs |
Option C deserves real respect: mlua uses Rust's lifetimes so that a Value<'lua> cannot outlive
its Lua, checked at compile time. That is strictly stronger than Ember's runtime check. The cost is
that the lifetime propagates into every host struct that stores a value, which is a real ergonomic
tax and the reason mlua also offers owned RegistryKey handles as an escape hatch.
6. Decision
A, with B's rooting discipline inside
Ctx.
Ember's public API deals in owned Rust values. Where a script needs to hold a host object, it gets userdata — an engine-owned object the collector traces, not a pointer to host memory.
7. Tradeoffs
| We gain | We lose |
|---|---|
| No lifetime parameter in the public API | A conversion (and sometimes a copy) at every crossing |
| A host cannot construct a stale handle, because it cannot name one | Large host objects must be userdata rather than marshaled |
| The API fits on one page | Compile-time enforcement is traded for runtime checks |
8. Production concerns
- The
Engineis the unit of isolation. Two engines share nothing: no heap, no globals, no intern table, no hash seed. That is what makes "one engine per tenant" a meaningful statement — and it is the only isolation Ember offers, which the threat model must say plainly. - Errors crossing the boundary must not leak host internals. An
EmberErrorrendered for a partially-trusted caller should not contain filesystem paths or host struct names. TheSourceMapsupplies the file name at render time precisely so the host can choose not to. - Panics must not cross. A registered Rust function that panics unwinds through the VM's frames
and out of
execute. The VM's invariants may be half-updated. Ember's answer:register_functionwraps the callback incatch_unwindand converts a panic intoErrorKind::Host, andEngineis marked poisoned so no further calls run on a suspect VM. A host bug should not become a runtime bug, and this is the one placecatch_unwindearns its keep.
9. References
- The Lua 5.4 Reference Manual, chapter 4 (the C API). Read it as API design: every function operates on an explicit stack, and that is the rooting discipline made mandatory.
mlua's documentation onLua,Value<'lua>, andRegistryKey— option C, in production, with the escape hatch that shows where the lifetime became painful.rhai'sEngineandDynamic— option A, in a Rust-native scripting language, and a good comparison point for API surface size.piccolo's "GC arena branding" — lifetimes used to make unrooted access impossible, taken further thanmlua.
Concept 2: Re-entrancy
1. Concept
Script calls Rust calls script. The VM is on the Rust call stack twice, and the inner invocation must not observe or corrupt the outer one's state.
2. Problem
#![allow(unused)] fn main() { // The obvious code, which does not compile — and the compiler is right. Op::Call(argc, want) => { let native = self.heap.native(handle)?; // immutable borrow of self.heap let results = (native.func)(self, args)?; // ERROR: `self` already borrowed } }
The borrow checker's objection is not pedantic. native.func could, legitimately, cause the heap to
reallocate its slot table, or trigger a collection that frees the very NativeFn being executed.
In C this compiles and is a use-after-free — Lua guards against it by convention (lua_CFunction
receives only a lua_State* and must use the stack API) rather than by checking.
3. Mental model
Take what you need, then let go, then call. The borrow must end before control leaves. That is the whole discipline, and it applies identically to metamethod dispatch, module loading, and
__tostring.
4. Implementation
#![allow(unused)] fn main() { /// Natives are Rc<dyn Fn>, so "take what you need" is a refcount bump. pub type NativeFn = dyn Fn(&mut Ctx<'_>, &[Value]) -> Result<Vec<Value>>; fn call_native(&mut self, h: GcRef<Native>, argc: u8, want: u8, ip: usize) -> Result<()> { // 1. TAKE: clone the Rc. Cheap, and it ends the borrow of the heap. let f: Rc<NativeFn> = self.heap.native(h)?.func.clone(); // 2. Copy the arguments out. `Value` is Copy — ADR-004 paying rent again. let args: Vec<Value> = self.stack[self.stack.len() - argc as usize..].to_vec(); self.stack.truncate(self.stack.len() - argc as usize - 1); // 3. Sync, then CALL. The VM is now consistent and unborrowed. self.frame_mut().ip = ip + 1; self.budget.tick_call()?; let results = { let mut ctx = Ctx::new(self); // Ctx owns &mut Vm for the duration f(&mut ctx, &args)? }; // 4. Adjust results exactly as a script return would. self.push_adjusted(results, want); Ok(()) } }
Ctx is the host's view of the VM while it is inside a callback:
#![allow(unused)] fn main() { pub struct Ctx<'vm> { vm: &'vm mut Vm } impl Ctx<'_> { /// Call back into the script. Re-entrant: this pushes another frame. pub fn call(&mut self, f: Value, args: &[Value]) -> Result<Vec<Value>>; /// Allocate a table, string, etc. May trigger a collection. pub fn new_table(&mut self) -> Result<Value>; /// Root a value for as long as the guard lives. REQUIRED across any /// allocation, because a Rust local is not a GC root. pub fn root(&mut self, v: Value) -> RootGuard<'_>; pub fn error(&self, msg: impl Into<String>) -> EmberError; } }
5–7. Alternatives, decision, tradeoffs
| Option | How the borrow conflict is resolved |
|---|---|
A. Rc<dyn Fn> + clone before calling (ours) | A refcount bump. Safe, obvious, and the borrow visibly ends |
B. Box<dyn Fn> taken out of the slot and put back | No refcount, but the slot is empty during the call — so re-entrancy on the same function breaks |
C. Store natives outside the heap in a Vec indexed by id | Avoids the heap borrow entirely; natives are then not collectable, which is usually fine |
D. RefCell<Vm> | Compiles, and panics on re-entrancy at run time. A host crash |
E. unsafe raw pointer to the VM | Works, and voids the no-unsafe property for the sake of one refcount |
Decision: A. B fails the recursive case, which is not exotic — a native
mapthat calls a script function that callsmapagain. D converts a compile error into a production panic, which is a strictly worse trade. C is a legitimate alternative worth noting: it makes natives non-collectable, which for host-registered functions (which live as long as the engine) is arguably correct.
8. Production concerns
- Depth counts across the boundary. Script → native → script → native recursion must consume the call-depth budget, or a host function that calls back becomes an unbounded recursion vector. Every route into a call must go through the same limit check — this was flagged in Lab 7 and this is where it lands.
- Host functions are outside the instruction budget.
tick_call()charges one unit for the call; a native that sleeps for a minute is invisible. This is a real limit of what a sandbox can promise and it goes in the threat model, not a footnote. Hosts that register expensive functions must budget them themselves. Ctxmust not outlive the call. It borrows the VM; Rust enforces it. Do not add a way to clone or store it.- Errors from host functions are
ErrorKind::Host. Distinguishable from script bugs and from limits, which means a host can route them differently — page onHost, metric onLimit, log onRuntime.
9. References
- Lua's
lua_CFunctioncontract andlua_call/lua_pcallin the manual — the same problem solved by convention. mlua'sLua::scopeand its callback lifetimes — how option C handles the same hazard with lifetimes instead of a refcount.- The Rust nomicon on
catch_unwindand unwind safety, for the panic-boundary decision above.
Concept 3: Send and Sync
1–3. Concept, problem, mental model
A service wants to run policies on a thread pool. The question "can I move an
Engineto another thread?" has an answer, and the answer must be honest rather than convenient.
Sync would mean two threads may use one Engine concurrently. That is false and cannot be made
true cheaply: the VM has a mutable stack, a mutable heap, and a collector that assumes exclusive
access. Making it Sync means a lock around every operation, which is a mutex-guarded interpreter —
strictly worse than one engine per thread.
Send — moving an engine to another thread — is a different question, and the answer depends on
implementation details the API should not leak by accident.
4. Implementation
#![allow(unused)] fn main() { // Ember's default: NEITHER. `Rc<Proto>`, `Rc<NativeFn>`, and the whole heap are // single-threaded by construction. // // This is a DECISION, not an oversight. A negative impl documents it: impl !Send for Engine {} impl !Sync for Engine {} }
5–7. Alternatives, decision, tradeoffs
| Option | Meaning for a host | Cost |
|---|---|---|
A. !Send + !Sync (ours, default) | Create the engine on the thread that uses it. A pool creates one per worker | Zero. Policies must be compiled per worker, or shared via source text |
B. Send, not Sync | Move an engine between threads; still one thread at a time | Swap every Rc for Arc and require F: Send on callbacks. Atomic refcounts on a hot path |
C. Send + Sync with an internal lock | Share one engine across threads | A lock on every operation; the collector becomes a stop-the-world-of-threads problem |
| D. Multiple VMs, one shared immutable code cache | Compile once, run anywhere | The real answer for a service, and it is an architecture, not a trait impl |
ADR-011: A by default; B behind a
sendfeature; C never.
Two reasons for A:
Arcis not free, and it would be paid on everyProtoand every native call, in every deployment, to serve the subset that needsSend. Measure it in Section 7 before making it the default.- D is what a real service wants anyway. Compiled
Chunks are immutable; a service should compile a policy once and hand anArc<Proto>to each worker's engine, rather than moving engines around. That is Lab 22's module cache and it is a better answer than either trait.
The honest cost of A, stated for docs/limitations.md: a tokio task that is not pinned to a
thread cannot hold an Engine across an .await. Hosts must run policy evaluation on a blocking
pool, or use a per-worker engine and a channel. That is a real constraint and it should be in the
README, not discovered.
8. Production concerns
- Do not accidentally become
Send. If every field happens to beSend, Rust auto-implements it and hosts will start relying on it. The explicit negative impl (or aPhantomData<*const ()>marker) makes it a decision. Add a test:static_assertions::assert_not_impl_any!(Engine: Send, Sync). - The hash seed is per-engine, so two engines produce identical observable behavior but different internal bucket layouts. That is the property that lets a pool be built at all — and it only holds because iteration order does not depend on the hash.
- Determinism is per-engine and across engines. Two engines, same script, same inputs → same output. Test it; a pool is useless otherwise.
9. References
- The Rustonomicon on
Send/Sync, and thestatic_assertionscrate. mlua's feature matrix — it offerssendas an opt-in with exactly this tradeoff, which is good evidence the decision space is real.- V8's
Isolatemodel: one isolate, one thread, with explicitLockerfor the exceptions. The same conclusion, reached by a much larger runtime.
Things to Notice
- What crosses the boundary is data, not addresses. Every hazard in this chapter is a violation of that one sentence.
- The borrow checker is describing a real C bug.
lua_CFunction's stack-only contract exists for the same reason Ember clones anRc. RefCellconverts a compile error into a production panic. That is not a workaround; it is a worse version of the same problem.- A seven-method API is a feature. Every public function is a forever-promise.
- The sandbox cannot see inside host functions. Say so in the threat model rather than implying otherwise.
!Sendis a decision with a cost, and the cost belongs in the README. "You cannot hold an Engine across an await" is the kind of thing hosts should read, not discover.- The right answer for a service is neither trait — it is one engine per worker plus a shared immutable code cache.
Validation / Self-check
- State the ownership boundary in one sentence. Name the two symmetric hazards it prevents.
- Why does the obvious
call_nativenot compile, and what real bug is the compiler describing? - Give the five ways to resolve that borrow conflict, and say why B and D are rejected.
- What is
Ctx, what may a host do with it, and why can it not be stored? - Name three things a host function is not charged for, and where that must be documented.
- Why is
EnginenotSync? Why is it notSendby default, and what is the upgrade path? - What is the honest cost of
!Sendfor an async host, and where does it belong? - Why does
register_functionwrap the callback incatch_unwind? - Why does the public API expose no
GcRef, and what class of bug does that eliminate?
Next: Value Marshaling.