RFC-0018: Re-entrant interface dispatch from lifecycle handlers
- Status: Resolved (the crash) — the open design question moves to RFC-0019
- Created: 2026-05-12
- Affects:
@@:self.method(...)inside$>(enter) and<$(exit) handlers - Superseded in part by: RFC-0019 — uniform enter/exit dispatch (the broader “is a lifecycle handler special?” question)
Reader’s note. This RFC was written before RFC-0019 removed the enter / exit cascade. Lifecycle-semantics passages below quote the pre-RFC-0019 contract (kernel walks the ancestor chain on
$>/<$). The construction context-push fix it documents survives RFC-0019 unchanged. For the current$>/<$dispatch contract — leaf-only, ancestors via explicit=> $^forward — see RFC-0019.
Summary
@@:self.method(args) — re-entrant interface dispatch, calling your own
interface from inside a handler — used to crash when it appeared in a $>
(enter) or <$ (exit) handler that ran during the initial enter cascade
(constructing a system with @@System()): the codegen emits a post-call
transition guard that reads the per-call context stack
(_context_stack[-1]._transitioned), and during construction the stack was
empty, so the read was out of bounds (IndexError / IndexOutOfBoundsException
/ equivalent on every backend with a context stack; Rust used the safe
.last().map_or(false, …) form and only no-op’d the guard). A $> handler on a
non-start state was unaffected — it runs during a real transition’s enter leg,
which has the triggering event’s FrameContext on the stack.
The cause turned out to be narrow: the documented runtime contract is that
construction runs the start state’s $> inside a discarded FrameContext
(frame_runtime.md § “Step 5”: “Like every other entry into the kernel, the
firing goes through a wrapper that pushes a FrameContext, runs the router, and
pops … Any handler that runs during the cascade — including the start state’s
$> — needs a context on the stack so that @@:return, @@:data, and other
context-stack reads have something to resolve against.”). RFC-0017
split the constructor into a bare allocator + __frame_init and, in doing so,
dropped that context push — __frame_init ran the enter cascade with no
context around it. The fix is to restore it: __frame_init pushes a
FrameContext for the synthetic $> enter event, runs the enter cascade + the
transition loop inside it, then pops — bringing the codegen back in line with
the spec. With that, _context_stack[-1] during the start state’s $> is valid,
the post-@@:self.method(...) guard reads a real context and fires correctly,
and @@:return / @@:data resolve as the spec already promised.
The deeper questions this RFC originally posed — should a lifecycle handler be
able to re-enter the kernel, and is the enter/exit cascade the right model at all
— are taken up in RFC-0019, which proposes making $>/<$
ordinary leaf-dispatched events; under that model a lifecycle handler is just a
handler and the question dissolves.
What was never affected
- Direct transitions from a lifecycle handler.
$> { -> $Next },$> { do_work; -> $Next }, and chained$>-transitions all work — a direct transition only queues__next_compartment; the transition loop drains it after the cascade, iteratively. It never goes through the post-@@:selfguard. The auto-advance / continuously-running-service pattern is and was supported. @@:self.method(...)from a non-start$>/<$. It runs with the triggering event’s context on the stack, so the guard always had something to read. (The33_ai_agentdemo uses this.)
Why not a validator reject
An earlier interim idea was to reject @@:self.method(...) in $>/<$ handlers
with a validator error. It doesn’t work cleanly: “this $> is running during the
initial enter cascade” is a runtime condition (empty context stack), not a
syntactic one — the same $> body runs both at construction and on a later
transition into that state. A validator can only do the blunt “no @@:self.… in
any $>/<$”, which would reject working code (the non-start $> case above).
Restoring the documented context contract fixes the crash without restricting
anything.
Residual: @@:self.method(...) chains that transition
@@:self.method(...) is a full atomic dispatch including its own transition
drain. So if method itself transitions, the transition is processed inside
the @@:self.method(...) call. From a $> handler that means: the call enters
the next state (running its whole $> cascade) and returns to a handler for a
state you’ve already left. The post-@@:self guard then bails the calling
handler — so it doesn’t crash and you don’t run more of the abandoned $> body
— but if the next state’s $> also does @@:self.method(...) and so on, each
hop nests inside the previous one’s call stack instead of unwinding to the
transition loop, so a long chain overflows the call stack. This is pre-existing
and not lifecycle-specific — it’s a property of @@:self.… chains generally
(it happens the same way from event handlers). RFC-0019 discusses
a “no-drain” dispatch shape that would make @@:self.method(...) from a
lifecycle handler queue-and-defer like a direct -> $X, turning the chain into
a constant-stack loop; it’s noted there, not adopted here.
Defense-in-depth
Independently of the __frame_init fix, the post-@@:self.method(...) guard on
every backend now checks the context stack is non-empty before indexing it (the
form Rust already used). With the __frame_init fix in place the stack is never
empty during the start cascade, so this is belt-and-suspenders — but it costs
nothing and protects any other path that might ever run a handler context-free.
References
frame_runtime.md§ “Step 5” — construction fires the start state’s$>inside a discardedFrameContext. § “Step 19” — the post-self-call transition guard contract. § “Step 21” — the enter/exit cascade.frame_language.md§ “Self Reference” —@@:self.method(...). § “System Context” — “Lifecycle events ($>,<$) use the existing context.”- RFC-0017 — init decoupling; the constructor split that dropped the context push.
- RFC-0019 — uniform enter/exit dispatch; where the “is a
lifecycle handler special?” question and the
@@:self.…-chain recursion are carried forward. frame_expansion.rs—FrameSegmentKind::ContextSelfCallarm (the@@:self.method(...)lowering) andgenerate_self_call_guard(the context-stack-safe post-call guard).system_codegen.rs—__frame_init/ the constructor’s enter-event code (where the context push is restored).