Resource Lifetime
GPU buffers, textures, and other engine objects are handles owned by scope — they die deterministically, and the runtime warns about leaks.
A GPUBuffer or GPUTexture wrapper holds a
u32 handle; the real resource lives host-side behind a reference
count. There are no finalizers anywhere — the collector frees a dead
wrapper’s few bytes of wasm heap and runs nothing of yours. Lifetime
instead follows scope:
- Persistent — created in
init(), alive until you callrelease()or the instance is disposed. - Per-frame — created during
advance()ordraw(). Release it (finish()for a render pass), or the frame collector releases it at the boundary.
The reap piggybacks on the frame collector (--runtime frame,
what Rive projects bake with). Every wrapper records the handle it mints
against its own pointer in an owner table; an explicit release()
removes the entry. At the frame boundary the host runs a collection, normally
a minor one over the nursery, a major when the old region outgrows its budget:
wrappers that are no longer reachable die. The stdlib then walks the owner
table and asks __resolveForward about each owner; survivors answer
with their address, old or moved, and a dead owner answers zero — its handle is
released right there. Deterministic, same frame, every time. The stub runtime tracks no ownership, so under it a forgotten per-frame
resource simply leaks.
export class Particles extends Layout {
tex: GPUTexture; // persistent: created in init
override init(context: Context): bool {
this.tex = new GPUTexture(desc); // yours until release()/dispose()
return true;
}
override advance(seconds: f64): bool {
let staging = new GPUBuffer(BufferUsage.vertex, size, data);
// ... upload, draw ...
staging.release(); // best: prompt release
// forgetting it is safe: the boundary collection frees it this frame
return true;
}
dispose(): void {
this.tex.release(); // instance teardown hook
}
}
Watch a frame play out
Step through the machinery: wasm linear memory on top, the owner table the wrappers write to, and the host’s handle table. The interesting frame is the one where a buffer’s release is forgotten.
wasm linear memory — old region | nursery
owner table
host handle table → gpu resources
Instance teardown
When the host drops a script instance — its artboard is deleted, or the
editor reloads the module — the dispose() hook runs once, with the
Context still valid. Release persistent resources there. Anything
you miss is swept when the file itself goes away, which for a long-lived file
is far too late for GPU memory.
The leak warnings
Two watchdogs run in development hosts. Each re-arms after it fires, every 8 MB of growth or 512 handles, so a leaking session keeps hearing about it:
- “script heap grew…” — linear memory is
growing every frame. Under
framethe collector is running, so something is accumulating references: a growing array, map, or cache. Understubper-frame allocations leak by design; switch towasmRuntime: frame. - “script leaked N host handles…” — handles outlive their use and nothing is reclaiming them; the message names the worst offender by kind. Usually a persistent resource created per frame, or a stub module, which tracks no ownership.
The full design — why scope-proof beats finalizers, and what the Luau lane does instead — lives with the host-binding notes in the repo.