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-@@:self guard. 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. (The 33_ai_agent demo 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 discarded FrameContext. § “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.rsFrameSegmentKind::ContextSelfCall arm (the @@:self.method(...) lowering) and generate_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).