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
Valueand 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
- Lab 19 complete.
- Host Objects and Userdata read.
- Metatables re-read — userdata field access goes
through
__index, which already exists.
Predict First
create_userdata(article)— where does theArticlelive afterwards? Can the host still mutate it?- Ten thousand articles as userdata: how many metatables?
- A userdata whose Rust type holds a
Value(an Ember callback). What must the host implement? - A script calls
article:is_fresh()wherearticleis actually aVector3. What happens? candidates[50000]where there are 1,000. What should happen?- A script stashes
candidatesin 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 = 5errors 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:
ArticleRefis an index, not a pointer. The same move asGcRef, one layer up: an index can be validated, an address cannot.- 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.
ArticleReftraces its owner. Without that edge, a collection can free theCandidateswhile 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
- 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. 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 asend-featured build.- A userdata inline cache. After Section 7, cache the accessor lookup per call site. Measure on the capstone's inner loop.
__eqand__ltfor userdata. Compare articles by score so a script cantable.sortthem. Then check what happens with a comparator that errors mid-sort.- A capability-surface entry per userdata type. Extend the golden test from
the userdata chapter so adding a
method to
Articleshows up in the diff.
Deliverables
-
UserDatatrait withbuildandtrace;UserDataBuilderwith 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 oneexpectis justified in a comment. -
type_metasadded toenumerate_roots; the count constant bumped. -
tracewired intotrace_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.rswith the three data-passing modes and--statsoutput 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
- Where does the
Articlelive aftercreate_userdata, and what can the host still do with it? - Why is the metatable per type? What does per-object cost, in your measured numbers?
- What does
TypeIdbuy, and why doesUserData: 'staticfollow from wanting it? - When must a host implement
trace, and what is the symptom of not doing so? - Why is
ArticleRefan index rather than a reference? Name the two other places in Ember that use the same move. - Why must the index be validated on every access, given that Rust would panic anyway?
- Give the guidance sentence from your experiment: when tables, when userdata?
- Why are fields read-only by default?
- What happens if a script stashes a userdata in a global, and which lab fixes it?