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 the Vec holding 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

OptionHow the host talks to the runtimeSystems
A. Owned values in, owned values out (ours)marshaling, plus rooted userdata for the exceptionsrhai, koto; the simple half of mlua
B. An explicit value stackthe host pushes and pops; every pushed value is rooted by constructionLua's C API, and it is why that API looks the way it does
C. Lifetime-branded handlesValue<'lua> tied to a &'lua Lua borrowmlua/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 gainWe lose
No lifetime parameter in the public APIA conversion (and sometimes a copy) at every crossing
A host cannot construct a stale handle, because it cannot name oneLarge host objects must be userdata rather than marshaled
The API fits on one pageCompile-time enforcement is traded for runtime checks

8. Production concerns

  • The Engine is 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 EmberError rendered for a partially-trusted caller should not contain filesystem paths or host struct names. The SourceMap supplies 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_function wraps the callback in catch_unwind and converts a panic into ErrorKind::Host, and Engine is 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 place catch_unwind earns 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 on Lua, Value<'lua>, and RegistryKey — option C, in production, with the escape hatch that shows where the lifetime became painful.
  • rhai's Engine and Dynamic — 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 than mlua.

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

OptionHow 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 backNo 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 idAvoids 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 VMWorks, 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 map that calls a script function that calls map again. 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.
  • Ctx must 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 on Host, metric on Limit, log on Runtime.

9. References

  • Lua's lua_CFunction contract and lua_call/lua_pcall in the manual — the same problem solved by convention.
  • mlua's Lua::scope and its callback lifetimes — how option C handles the same hazard with lifetimes instead of a refcount.
  • The Rust nomicon on catch_unwind and 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 Engine to 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

OptionMeaning for a hostCost
A. !Send + !Sync (ours, default)Create the engine on the thread that uses it. A pool creates one per workerZero. Policies must be compiled per worker, or shared via source text
B. Send, not SyncMove an engine between threads; still one thread at a timeSwap every Rc for Arc and require F: Send on callbacks. Atomic refcounts on a hot path
C. Send + Sync with an internal lockShare one engine across threadsA lock on every operation; the collector becomes a stop-the-world-of-threads problem
D. Multiple VMs, one shared immutable code cacheCompile once, run anywhereThe real answer for a service, and it is an architecture, not a trait impl

ADR-011: A by default; B behind a send feature; C never.

Two reasons for A:

  1. Arc is not free, and it would be paid on every Proto and every native call, in every deployment, to serve the subset that needs Send. Measure it in Section 7 before making it the default.
  2. D is what a real service wants anyway. Compiled Chunks are immutable; a service should compile a policy once and hand an Arc<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 be Send, Rust auto-implements it and hosts will start relying on it. The explicit negative impl (or a PhantomData<*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 the static_assertions crate.
  • mlua's feature matrix — it offers send as an opt-in with exactly this tradeoff, which is good evidence the decision space is real.
  • V8's Isolate model: one isolate, one thread, with explicit Locker for 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 an Rc.
  • RefCell converts 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.
  • !Send is 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

  1. State the ownership boundary in one sentence. Name the two symmetric hazards it prevents.
  2. Why does the obvious call_native not compile, and what real bug is the compiler describing?
  3. Give the five ways to resolve that borrow conflict, and say why B and D are rejected.
  4. What is Ctx, what may a host do with it, and why can it not be stored?
  5. Name three things a host function is not charged for, and where that must be documented.
  6. Why is Engine not Sync? Why is it not Send by default, and what is the upgrade path?
  7. What is the honest cost of !Send for an async host, and where does it belong?
  8. Why does register_function wrap the callback in catch_unwind?
  9. Why does the public API expose no GcRef, and what class of bug does that eliminate?

Next: Value Marshaling.