Skip to main content

10 — Lifetime, GC, and exception boundaries

ObjectRegistry, struct-related registries, Opaque validity, single lua_State, and C#↔Lua exception translation. Opaque details → marshal/04-OPAQUE.md; Registry implementation → impl/marshal/REGISTRIES.md.


1. Design principles

PrincipleNotes
Lua semantics firstManaged object lifetime must align with Lua userdata / ref lifetime
Il2Cpp GC integrationByObj slot arrays register as GC roots; non-blittable structs must scan struct memory
Opaque is temporaryValid only within a synchronous C#→Lua call frame; persistence forbidden
Single primary stateDefault one lua_State; ref release is batched via the frame pump
Predictable exceptionsC# exceptions ↔ Lua errors convert uniformly at the boundary

Mono and Il2Cpp behave the same externally; internals may differ (GCHandle vs Il2CppObject*).


2. ObjectRegistry (ByObj userdata)

2.1 Responsibilities

Manages ByObj userdata for class / string / array / delegate / boxed enum, etc.:

MechanismNotes
Slot tableEach Push’d managed object gets a slotIndex into _registeredObjects[]
GC rootSlot array registered via GarbageCollector::RegisterRoot so Il2Cpp GC cannot collect while Lua still holds the userdata
Weak-value cache(Il2CppObject*, viewKlass) → registry ref; avoid duplicate Push for the same identity+façade
__gcLua GC collects userdata → UnregisterObject(slot) + remove cache entry

2.2 Push / Pop

ObjectRegistry::Push(L, obj, viewKlass, metatableRefIndex);
Il2CppObject* o = ObjectRegistry::Pop(L, idx);
  • viewKlass: declared-type façade (see marshal/06-CLASS.md); cache key includes (obj, viewKlass)
  • Pop: validates UserDataKind::ByObj; nilnullptr

2.3 Lifetime

C# returns object → Push → userdata (slot registered + root keeps alive)
→ While Lua holds it: slot non-empty; object not collected by Il2Cpp alone
→ userdata __gc → UnregisterObject → slot cleared
→ If no other C# refs: object may be Il2Cpp GC’d

Note: releasing userdata does not guarantee immediate C# finalizers; it only drops ZLua’s root keep-alive.

2.4 Shutdown

ObjectRegistry::Shutdown(L):

  1. Clear C++ (obj, view) map
  2. luaL_unref the weak-value cache table

Must run before lua_close, with no unfinished cross-boundary calls.


3. Struct and value-type registries

3.1 ByVal userdata

struct instance userdata payload is a value copy (or pinned box). __gc releases the native copy / GCHandle; does not use ObjectRegistry slots (unless boxed as ByObj).

3.2 NotBlittableStructRegistry (Il2Cpp)

ByVal userdata for non-blittable structs:

ItemNotes
Storagestruct copy inside userdata
GCRegisterPushRootCallback scans in-memory reference fields of the struct
__gcRelease(index) symmetric with the Registry

Blittable structs default to the StructHandle (opaque) path with no userdata __gc; see marshal/05-STRUCT.md.

3.3 Mono equivalent

Mono uses GCHandle / boxed equivalents with the same Lua-visible semantics (userdata release → unpin / free copy).


4. OpaqueValue lifetime

4.1 Validity domain

ItemRule
Produced byC#→Lua: GetFunction delegate invoke, delegate callbacks, by-val ref / struct annotated [LuaMarshalAs(OpaqueValue)]
Shapelightuserdata handle (generation + index)
ValidOnly while the producing C#→Lua call has not yet returned
Invalid afterC# returns; or OpaqueParameterScope generation advances

4.2 Forbidden

  • Storing into globals, table fields, upvalues for later pcall or async use
  • : / . member access on opaque
  • Assuming a handle still points at valid memory across frames

After invalidation, get_opaquevalue / set_opaquevalue / Pop as an argument → invalid opaque parameter handle.

4.3 vs Registry

OpaqueValueByObj / StructUserData
RegistrationNot in ObjectRegistryRegistry + __gc
LifetimeCall frameLua userdata lifetime
metatableNoneYes (ByVal/ByObj)

5. Delegate and Lua function refs

5.1 Lua → C# delegate

When creating delegate userdata (ByObj) implicitly or via zlua.to_delegate:

  • native holds a Lua registry ref (luaL_ref) to the script function
  • C# holding the delegate keeps the script function alive
  • Lua GC of delegate userdata queues deferred luaL_unref (avoid unref directly on the C# stack)

5.2 C# → Lua (LuaMethod)

After a C# delegate is pushed to Lua it may be called as d(...) (IMT.__call). If C# no longer holds the delegate, associated Lua refs release on finalization / Dispose paths.

5.3 Frame pump

LuaAppDomain.ProcessPendingRefReleases() (driven by LuaFramePump) drains the deferred unref queue. Must run on the Unity main thread, same thread as Lua calls.


6. Single lua_State and threading

6.1 Default model

The ZLua host defaults to one primary lua_State:

  • CSharp root, Registry caches, and module loader all bind to that state
  • No lock-free concurrent multi-thread access to the same L

6.2 Call threads

ScenarioRequirement
Unity main thread calling LuaSupported by default
Background thread calling LuaHost must synchronize explicitly; undefined if not serialized
C# GetFunction / delegate bridge / Lua→C#Same thread that initialized L, or a controlled queue

6.3 Coroutines

Lua coroutines may run within the same lua_State; Opaque handles must not escape the producing C# call stack into other coroutines asynchronously (still subject to §4).


7. Initialize and domain Reset order

The public host API is LuaAppDomain.Initialize / Reset (no public Shutdown). Internal teardown and ObjectRegistry::Shutdown still follow the order below.

7.1 Initialize (conceptual)

1. luaL_newstate / open standard libraries
2. ZLuaLib::RegisterGlobals
3. dostring zlualib.lua
4. ObjectRegistry::Initialize
5. TypeRegistry / MetaTableCache / Opaque scope init
6. Create CSharp root table
7. Install module loader
8. (Il2Cpp) RegisterPushRootCallback for struct roots

Calling Initialize again when a main lua_State already exists → throws (use Reset).

7.2 Reset (conceptual)

Reset(loader) is scheduled first; LuaFramePump applies it at this frame’s EndOfFrame (full-domain teardown + Initialize per §7.1):

At call site:
1. Record pending loader (later calls overwrite earlier ones)
2. Ensure the frame pump is registered; do not lua_close immediately

EndOfFrame:
1. Drain pending ref releases
2. ObjectRegistry::Shutdown / Struct / MetaTable / …
3. lua_close (all old GetFunction delegates become invalid)
4. Rebuild lua_State per §7.1 and install loader

Il2Cpp: process-level Bridge / XML binding tables / MetadataUtil / InternalCall are not unloaded by Reset; only state-level resources are rebuilt.

Use Reset when clearing the script world on Player / hot reload to avoid registry leaks and stale roots. Because real teardown is deferred to end of frame, mid-boundary Reset calls need not be forbidden.


8. Exception boundary

8.1 C# calling Lua

EventBehavior
Lua error(msg)Caught; throw managed exception (type per implementation)
Unbalanced Lua stacknative assert / exception; no leak
C# exception crossing nativeForbidden; boundary layer translates or aborts

GetFunction delegate invoke maintains OpaqueParameterScope around invoke so exception paths also invalidate opaque handles.

8.2 Lua calling C#

EventBehavior
C# throwsluaL_error equivalent; message includes type / method context (Mono / Il2Cpp identical or equivalent)
Scriptpcall catches string / error object

Editor Mono (all Lua families): must not call lua_error inside a managed reverse-P/Invoke frame. Use the native callback gate: managed pushes the error and returns a sentinel; native calls lua_error after managed returns. Spec: build/03-MONO-LUAJIT-CALLBACK-GATE.md. Il2Cpp does not use that gate; it also obeys the engineering rule “no C++ destructor dependence when lua_erroring”. Callback Debug.Log stack-capture issues are covered by implementation LuaPrintBuffer (deferred flush).

8.3 Error messages

Bind failure, marshal failure, no matching overload, invalid opaque, etc.: Mono and Il2Cpp must give equivalent wording for the same condition (prefix differences allowed; meaning the same).


9. GC interaction summary

Object kindLua collection triggersManaged collection
ByObj classuserdata __gcCollectable after root drop
ByVal blittableuserdata __gc or scope endStack / copy released
ByVal non-blittableuserdata __gcScan + free copy
Opaquescope end (not userdata GC)No root involved

DocContent
01-HOST-API.mdGetFunction, exceptions
marshal/04-OPAQUE.mdOpaque API
marshal/06-CLASS.mdByObj, view
marshal/05-STRUCT.mdstruct GC
marshal/09-FUNCTION.mddelegate ref
compare/GC.mdComparison with other approaches