Lab 20: Host Objects (Milestone 13)

Background

You will build src/userdata.rs: the UserData trait, per-type metatables, safe downcasting, GC tracing for host objects, and the index-handle pattern that lets a script read fields from a 10,000-element candidate list without allocating 10,000 tables.

At the end, article.semantic_score works and Article is still a Rust struct.

Why This Lab Matters

  • This is the capstone's data path. Get it wrong and every request allocates tens of thousands of objects before ranking starts.
  • A host object that holds an Ember Value and does not trace it is a collector bug in host code — the worst place to debug one.
  • Every userdata method is a capability, and this is where the capability surface stops being the standard library.

Prerequisites


Predict First

  1. create_userdata(article) — where does the Article live afterwards? Can the host still mutate it?
  2. Ten thousand articles as userdata: how many metatables?
  3. A userdata whose Rust type holds a Value (an Ember callback). What must the host implement?
  4. A script calls article:is_fresh() where article is actually a Vector3. What happens?
  5. candidates[50000] where there are 1,000. What should happen?
  6. A script stashes candidates in a global. What does the next evaluation see?

Step 1: The UserData Trait and the Builder

#![allow(unused)]
fn main() {
pub trait UserData: 'static {
    fn build(b: &mut UserDataBuilder<Self>) where Self: Sized;
    /// GC edges. Default: none. Override if this type holds Ember Values.
    fn trace(&self, _grey: &mut Vec<Handle>) {}
}

pub struct UserDataBuilder<T> { /* ... */ }

impl<T: UserData> UserDataBuilder<T> {
    pub fn field<V: ToValue>(&mut self, name: &str, get: impl Fn(&T) -> V + 'static);
    pub fn method(&mut self, name: &str,
                  f: impl Fn(&T, &mut Ctx<'_>, &[Value]) -> Result<Vec<Value>> + 'static);
    pub fn meta_index_int(&mut self, f: impl Fn(&T, i64, &mut Ctx<'_>) -> Result<Value> + 'static);
    pub fn meta_len(&mut self, f: impl Fn(&T) -> i64 + 'static);
    pub fn meta_tostring(&mut self, f: impl Fn(&T) -> String + 'static);
}
}

The metatable is built once per type per engine, keyed by TypeId, and cached:

#![allow(unused)]
fn main() {
fn metatable_for<T: UserData>(&mut self) -> Result<GcRef<Table>> {
    if let Some(&m) = self.type_metas.get(&TypeId::of::<T>()) { return Ok(m); }
    let mut b = UserDataBuilder::<T>::new(self);
    T::build(&mut b);
    let m = b.finish()?;
    self.type_metas.insert(TypeId::of::<T>(), m);
    Ok(m)
}
}

Checkpoint question. type_metas is a HashMap<TypeId, GcRef<Table>> on the engine. Which root set does it join, and what is the count now?


Step 2: The Heap Object and Safe Downcasting

#![allow(unused)]
fn main() {
pub struct UserDataObj {
    type_id: TypeId,
    data: Box<dyn AnyUserData>,        // the host's value
    meta: GcRef<Table>,                // per TYPE, shared
}

impl Heap {
    pub fn userdata_ref<T: UserData>(&self, r: GcRef<UserDataObj>) -> Result<&T> {
        let obj = self.userdata(r)?;
        if obj.type_id != TypeId::of::<T>() {
            // A TYPED error, not a transmute, not a panic. This is why the
            // trait requires 'static — TypeId needs it.
            return Err(internal(format!("userdata is not a {}", type_name::<T>())));
        }
        Ok(obj.data.downcast_ref::<T>().expect("type_id checked above"))
    }
}
}

That expect is one of the very few in the codebase, and it is justified by the check on the line above. Write the justification in a comment; an expect without one is a future panic.


Step 3: Tracing

#![allow(unused)]
fn main() {
// A host object holding an Ember callback IS a GC edge, and the collector
// cannot discover it. This is the one thing a host can do to break the
// collector, so make it prominent in the docs.
struct Subscription { on_event: Value, name: String }

impl UserData for Subscription {
    fn build(b: &mut UserDataBuilder<Self>) { /* ... */ }
    fn trace(&self, grey: &mut Vec<Handle>) { push_value(grey, self.on_event); }
}
}

Wire UserDataObj into trace_children:

#![allow(unused)]
fn main() {
Some(HeapObject::UserData(u)) => {
    grey.push(u.meta.erase());     // the per-type metatable is an edge too
    u.data.trace(grey);            // ← the host's job
}
}

Then the test that proves it, which must be in the userdata test suite rather than the GC's, so that a host reading these docs finds it:

#![allow(unused)]
fn main() {
#[test]
fn host_objects_holding_values_must_trace_them() {
    with_gc_stress(|| {
        let mut e = Engine::new();
        e.execute("function cb() return 42 end").unwrap();
        let f = e.get_global_value("cb").unwrap();
        let ud = e.create_userdata(Subscription { on_event: f, name: "s".into() }).unwrap();
        e.set_global("sub", ud).unwrap();
        e.collect();
        assert_eq!(e.execute("return sub:fire()").unwrap(), 42);  // fails without trace()
    });
}
}

Step 4: Field Access

__index on a userdata's metatable is a native function that looks the key up in a per-type table of accessors:

   article.semantic_score
     │
     ├─▶ Value::UserData → type_metatable(Article)
     ├─▶ mt.__index (a native fn)
     ├─▶ accessors["semantic_score"] → |a: &Article| a.semantic_score
     └─▶ ToValue → Value::Float

Three probes and a native call. Compare with a table field, which is one probe. That gap is real, it is what the Lab 18 experiment measured for tables, and it is why Section 7's inline cache targets GET_FIELD on userdata as well.

Note: Fields are read-only by default. article.semantic_score = 5 errors unless the host registered a setter. That is the safe default: a script mutating host data through a field assignment is almost always a bug, and making it opt-in means the host thought about it.


Step 5: The Index-Handle Pattern

This is the step that decides the capstone.

#![allow(unused)]
fn main() {
struct Candidates { articles: Vec<Article> }
struct ArticleRef  { owner: GcRef<UserDataObj>, index: usize }

impl UserData for Candidates {
    fn build(b: &mut UserDataBuilder<Self>) {
        b.meta_len(|c| c.articles.len() as i64);
        b.meta_index_int(|c, i, ctx| {
            let idx = match usize::try_from(i - 1) { Ok(n) => n, Err(_) => return Ok(Value::Nil) };
            if idx >= c.articles.len() { return Ok(Value::Nil); }   // VALIDATE. Always.
            ctx.create_userdata(ArticleRef { owner: ctx.self_handle(), index: idx })
        });
    }
}

impl UserData for ArticleRef {
    fn build(b: &mut UserDataBuilder<Self>) {
        b.field_via("semantic_score", |r, ctx| Ok(r.resolve(ctx)?.semantic_score));
        b.field_via("age_hours",      |r, ctx| Ok(r.resolve(ctx)?.age_hours));
        b.field_via("topic",          |r, ctx| Ok(r.resolve(ctx)?.topic.as_str()));
    }
    fn trace(&self, grey: &mut Vec<Handle>) { grey.push(self.owner.erase()); }  // ← the owner edge
}
}

Three things this gets right:

  1. ArticleRef is an index, not a pointer. The same move as GcRef, one layer up: an index can be validated, an address cannot.
  2. The index is validated on every access. A script-supplied index is untrusted input; an unchecked one panics (Rust) rather than corrupting (C), which is better and still a host crash.
  3. ArticleRef traces its owner. Without that edge, a collection can free the Candidates while articles still reference it — and the generation check would report a stale handle from inside host code.

The Trace

#![allow(unused)]
fn main() {
// examples/host_objects.rs
let candidates = Candidates { articles: load_candidates(10_000) };
let handle = engine.create_userdata(candidates)?;
engine.set_global("candidates", handle)?;
engine.execute(r#"
    function best()
        local top, top_score = nil, -1
        for i = 1, #candidates do
            local a = candidates[i]
            local s = a.semantic_score
            if a.age_hours < 6 then s = s * 1.4 end
            if s > top_score then top, top_score = a, s end
        end
        return top_score
    end
"#)?;
let score: f64 = engine.call("best", ())?;
}
$ cargo run --example host_objects --release
best score = 1.372
--- stats ---
instructions:   180,043
allocations:    10,001        ← one Candidates + 10,000 ArticleRefs
live_objects:   14
gc_runs:        3

Now the comparison that justifies the design. Run the same policy with the candidates marshaled into tables instead:

$ cargo run --example host_objects --release -- --marshal
--- stats ---
instructions:   180,043       ← identical: the SCRIPT did the same work
allocations:    50,004        ← 10,000 tables + 40,000 strings/fields
gc_runs:        17

Same script, same instructions, 5× the allocations and 5× the collections — because marshaling is O(fields exposed) and userdata is O(fields read), and the policy reads 2 of 4 fields.

Then the third version, which is the one to actually ship:

$ cargo run --example host_objects --release -- --lazy-refs
--- stats ---
allocations:    1             ← ArticleRef created only for the ones examined... 

...except that this policy examines all of them, so the third version is not faster here. Write that down. The lazy design wins when the policy short-circuits (if a.topic ~= user.topic then goto continue end), and measuring the case where it does not help is how you learn the shape of the win rather than memorizing "userdata is faster".


Expected Output

$ cargo test --test userdata
test field_access_reads_the_rust_struct ... ok
test methods_downcast_safely ... ok
test wrong_type_is_a_typed_error_not_a_panic ... ok
test out_of_range_index_returns_nil ... ok
test host_objects_holding_values_must_trace_them ... ok
test fields_are_read_only_by_default ... ok

$ ember run --stats examples/policy.ember      # with candidates injected

Debugging Steps

article.semantic_score is nil

The accessor table is keyed by an interned string and you looked up with a fresh one — or the metatable was built per object and the one attached is empty.

A method on the wrong type panics

downcast_ref().unwrap() without the type_id check above it.

--gc-stress fails with a stale handle inside a host method

A missing trace — either the userdata's own Value fields, or ArticleRef's owner edge, or the per-type metatable.

10,000 metatables in the heap census

metatable_for is not caching by TypeId, or create_userdata builds one per call.

candidates[0] returns the first article

Lua is 1-indexed. i - 1 before the bounds check, and usize::try_from to reject 0 and negatives.

The host mutated an Article and the script did not see it

create_userdata moved the value into the engine. The host no longer owns it. If you need shared mutation, that is a different design (Arc<Mutex<_>> or take-and-return) and it needs to be a deliberate choice.

The next evaluation sees the previous request's candidates

A script stashed the global. Swap the globals table per evaluation — Lab 23.


Experiment

CLAIM. The right data-passing design depends on the ratio of fields read to fields exposed, and the crossover is measurable.

METHOD. Three implementations of the same policy over N candidates with F fields each, where the policy reads R of them: (a) marshal to tables; (b) one userdata per candidate; (c) an index-handle pattern with lazy refs. Vary R from 1 to F.

PREDICTION. At what R does marshaling become competitive? Where does (c) beat (b)?

RESULT. A small table in docs/learning/12-embedding.md. Then write the guidance sentence for docs/embedding.md — the one a host developer will actually read — in the form "use tables when …, userdata when …", backed by your numbers.


Test

#![allow(unused)]
fn main() {
#[test]
fn field_access_reads_the_rust_struct() {
    let mut e = Engine::new();
    let h = e.create_userdata(Article { id: 7, semantic_score: 0.9, age_hours: 2.0,
                                        topic: "sports".into() }).unwrap();
    e.set_global("a", h).unwrap();
    assert_eq!(e.eval::<f64>("return a.semantic_score").unwrap(), 0.9);
    assert_eq!(e.eval::<String>("return a.topic").unwrap(), "sports");
}

#[test]
fn one_metatable_per_type_not_per_object() {
    let mut e = Engine::new();
    for i in 0..1000 { let _ = e.create_userdata(Article::dummy(i)).unwrap(); }
    assert_eq!(e.heap_census().tables, 1 + BUILTIN_TABLES,
               "a metatable was built per object");
}

#[test]
fn wrong_type_is_a_typed_error_not_a_panic() {
    let mut e = Engine::new();
    let v = e.create_userdata(Vector3::zero()).unwrap();
    e.set_global("v", v).unwrap();
    let err = e.execute("return v:is_fresh()").unwrap_err();
    assert_eq!(err.kind, ErrorKind::Runtime);   // not a panic, not UB
}

#[test]
fn out_of_range_and_non_integer_indices_return_nil() {
    let mut e = with_candidates(10);
    for expr in ["candidates[0]", "candidates[11]", "candidates[-1]", "candidates[1.5]"] {
        assert_eq!(e.eval::<Option<Value>>(&format!("return {expr}")).unwrap(), None, "{expr}");
    }
}

#[test]
fn fields_are_read_only_by_default() {
    let mut e = with_article();
    assert_eq!(e.execute("a.semantic_score = 5").unwrap_err().kind, ErrorKind::Runtime);
}

#[test]
fn index_handles_keep_their_owner_alive() {
    with_gc_stress(|| {
        let mut e = with_candidates(100);
        e.execute("ref = candidates[5]").unwrap();
        e.execute("candidates = nil").unwrap();
        e.collect();
        // `ref` traces its owner, so the Candidates object survives.
        assert!(e.eval::<f64>("return ref.semantic_score").is_ok());
    });
}
}

Challenge Extensions

  1. Mutable fields. b.field_mut("boost", |a| &mut a.boost). Then work out what it means for the borrow checker when a script mutates a field while iterating the owning collection, and decide whether to allow it.
  2. Arc<T> userdata. Share a host object between the engine and the host, with interior mutability. Document the aliasing rules; this is where a careless design becomes a data race in a send-featured build.
  3. A userdata inline cache. After Section 7, cache the accessor lookup per call site. Measure on the capstone's inner loop.
  4. __eq and __lt for userdata. Compare articles by score so a script can table.sort them. Then check what happens with a comparator that errors mid-sort.
  5. A capability-surface entry per userdata type. Extend the golden test from the userdata chapter so adding a method to Article shows up in the diff.

Deliverables

  • UserData trait with build and trace; UserDataBuilder with fields, methods, and the relevant metamethods.
  • Metatables cached per TypeId; the per-object test passes.
  • TypeId-checked downcasting; a wrong type is a typed error, and the one expect is justified in a comment.
  • type_metas added to enumerate_roots; the count constant bumped.
  • trace wired into trace_children, with the host-callback test under --gc-stress.
  • Fields read-only by default; assignment errors unless a setter was registered.
  • The index-handle pattern, with validation on every access and an owner edge traced.
  • examples/host_objects.rs with the three data-passing modes and --stats output for each.
  • The fields-read-vs-exposed experiment, and the resulting guidance sentence in docs/embedding.md.
  • Every userdata method appears in the capability-surface golden file.

Validation / Self-check

  1. Where does the Article live after create_userdata, and what can the host still do with it?
  2. Why is the metatable per type? What does per-object cost, in your measured numbers?
  3. What does TypeId buy, and why does UserData: 'static follow from wanting it?
  4. When must a host implement trace, and what is the symptom of not doing so?
  5. Why is ArticleRef an index rather than a reference? Name the two other places in Ember that use the same move.
  6. Why must the index be validated on every access, given that Rust would panic anyway?
  7. Give the guidance sentence from your experiment: when tables, when userdata?
  8. Why are fields read-only by default?
  9. What happens if a script stashes a userdata in a global, and which lab fixes it?

Next: Lab 21 — The Standard Library.