Lifecycle Invariants
This note documents the runtime ownership and lifecycle rules that prevent stale-callback, use-after-drop, and rebuild-state bugs.
For widget subtree teardown specifically, see Widget Ownership And Teardown. For prevention strategies and next-step options, see Lifecycle Hardening Plan.
Why This Exists
Runtime bugs in space often come from one of four failures:
- an object has more than one update owner
- an object stays subscribed after its owner considers it dropped
- rebuild code reuses stale ambient state instead of injecting current state explicitly
- teardown is treated as a soft suggestion instead of a terminal boundary
These failures are especially expensive because they often look harmless until timing changes expose them.
Core Rules
1. Every runtime object has one owner
For each long-lived object, be able to answer all three questions unambiguously:
- who creates it
- who updates it
- who drops it
If different layers answer different questions, the design is wrong.
Example:
GraphViewbelongs toHomeWorldHomeWorldcreates it in runtime setupHomeWorld:updatedrivesgraph-view:updateHomeWorldteardown drops it
app may keep a reference to the active object for convenience, but that does not make app the owner.
2. drop is terminal
After drop, public methods and owned callback entry points should usually assert immediately.
Do not silently ignore post-drop calls in normal owned runtime flow. Silent behavior converts lifecycle bugs into corrupted state and delayed failures.
The main exception is truly external late completion:
- process callbacks
- RPC futures
- editor/file chooser completions
- similar async work that may already be in flight and is not fully suppressible by construction
Those paths should usually return quietly after confirming the target object is already dropped, unless the async source itself is supposed to be owned and cancelled strictly enough that a late callback would indicate a real bug.
Good:
(fn assert-not-dropped [context]
(assert (not dropped?)
(string.format "GraphView %s called after drop" context)))3. Global frame signals are the exception
app.engine.events.updated is a low-level primitive. Most objects should not subscribe to it directly.
Prefer:
- world-owned updates for world runtime objects
- widget/layout-owned updates for widget trees
- domain signals for model changes
Direct subscriptions to global frame signals require a specific reason and a clear owner responsible for disconnecting them.
4. Signal semantics must match lifecycle expectations
If disconnect happens during an emit, later callbacks from that same emit should not still fire. Otherwise a dropped object can still receive stale callbacks during teardown.
The current Signal contract is:
disconnectsuppresses later same-emit callbacksclearsuppresses remaining same-emit callbacksconnectduring emit only affects future emits- failing handlers must still unwind internal emit bookkeeping correctly
Low-level contracts like this remove entire classes of downstream defensive patches.
5. Rebuild paths must inject current state explicitly
Rebuild code should not assume that reused build context is already current.
If rebuild depends on theme, active world, camera, selection, or other mutable app state, inject that state deliberately as part of the rebuild.
Assertions: Where They Pay Off
Assertions are useful when they defend a real invariant, not when they paper over a design that still allows the bad path by construction.
Use assertions at:
- lifecycle boundaries: create, activate, deactivate, drop
- public mutation entry points: reject use after drop
- owned signal/callback entry points: reject callbacks into dropped objects
- required build/update context boundaries: fail if critical dependencies are missing
- restore/persistence boundaries: fail on structurally invalid state
Avoid relying on assertions alone when ownership is still ambiguous. Fix ownership first.
Case Study: Theme Switch Graph Labels
Symptom
After a theme switch, old graph node labels remained visible and new labels were added on top.
Root Cause
This was not a label teardown bug. It was a lifecycle and ownership bug.
- theme change dropped the old
GraphView GraphViewwas also directly subscribed toapp.engine.events.updated- the same engine update emission continued and invoked the old view's queued callback once more
- the dropped view still had enough internal state to recreate labels
There was a second bug in the same area:
- graph-view rebuild reused a build context whose theme had not been updated yet
- the rebuilt view could therefore use stale theme state
Fix
The final fix was architectural, not just defensive:
GraphViewno longer subscribes directly to raw engine frame updatesHomeWorld:updateownsgraph-view:updateSignalsemantics were tightened so disconnecting during emit suppresses later same-emit callbacksGraphViewnow asserts on use after drop- theme rebuild explicitly injects the active theme into the build context before recreating the view
Lessons
- a guard may be the correct temporary correctness fix, but it should trigger an ownership review
- split update ownership is a design smell
- stale ambient context is as dangerous as stale callbacks
- once the architecture is corrected, assertions become valid and useful
Review Checklist
When adding or reviewing a runtime object, check these points:
- Is there exactly one owner?
- Is frame/update ownership explicit?
- Are all signal subscriptions owned and disconnected by the same owner?
- Does
dropmake further use fail fast? - Does rebuild explicitly inject all mutable state it depends on?
- Are there tests for misuse, not just normal operation?
If any answer is unclear, the object is probably under-specified.
